| 1 | import React from 'react'
|
| 2 | import {
|
| 3 | Document,
|
| 4 | Page,
|
| 5 | View,
|
| 6 | Text,
|
| 7 | Svg,
|
| 8 | Rect,
|
| 9 | Image,
|
| 10 | StyleSheet,
|
| 11 | } from '@react-pdf/renderer'
|
| 12 | import { format } from 'date-fns'
|
| 13 | import { ptBR } from 'date-fns/locale'
|
| 14 | import { BaseMoodOption, IntensityLevel, MoodComponentOption } from '@prisma/client'
|
| 15 | import { ReportData } from './report.data'
|
| 16 | import { LOGO_DATA_URI } from './logo'
|
| 17 |
|
| 18 | const T = {
|
| 19 | title: 'Relatório de Humor e Bem-Estar',
|
| 20 | appName: 'Nexo',
|
| 21 | tagline: 'Nexo · Rastreador de humor',
|
| 22 | confidential: 'Confidencial — uso clínico',
|
| 23 | sections: {
|
| 24 | glance: 'Visão Geral do Período',
|
| 25 | distribution: 'Distribuição de Humor',
|
| 26 | heatmap: 'Mapa de Calor Diário',
|
| 27 | components: 'Sentimentos & Emoções',
|
| 28 | log: 'Registros e níveis (ansiedade, energia e estresse)',
|
| 29 | },
|
| 30 | stats: {
|
| 31 | checkins: 'Check-ins',
|
| 32 | topMood: 'Humor predominante',
|
| 33 | daysTracked: 'Dias rastreados',
|
| 34 | },
|
| 35 | moods: {
|
| 36 | GREAT: 'Ótimo', GOOD: 'Bom', NEUTRAL: 'Neutro',
|
| 37 | SAD: 'Triste', ANGRY: 'Irritado',
|
| 38 | } as Record<BaseMoodOption, string>,
|
| 39 | intensity: {
|
| 40 | LIGHT: 'Leve', MODERATE: 'Moderado', HIGH: 'Alto',
|
| 41 | } as Record<IntensityLevel, string>,
|
| 42 | components: {
|
| 43 | JOY: 'Alegria', ANGER: 'Raiva', SADNESS: 'Tristeza',
|
| 44 | FEAR: 'Medo', GUILT: 'Culpa', FRUSTRATION: 'Frustração',
|
| 45 | CALM: 'Calma', MOTIVATED: 'Motivação', TIREDNESS: 'Cansaço',
|
| 46 | GRATITUDE: 'Gratidão', FOCUS: 'Foco', RESTLESS: 'Agitação',
|
| 47 | RELAXED: 'Relaxado', OVERWHELMED: 'Sobrecarga',
|
| 48 | } as Record<MoodComponentOption, string>,
|
| 49 | tableHeaders: {
|
| 50 | component: 'Componente', count: 'Qtd', avgIntensity: 'Int. Média',
|
| 51 | date: 'Data', mood: 'Humor', anxiety: 'Ans', stress: 'Est',
|
| 52 | energy: 'Ene', note: 'Nota',
|
| 53 | },
|
| 54 | }
|
| 55 |
|
| 56 | const MOOD_COLORS: Record<BaseMoodOption, string> = {
|
| 57 | GREAT: '#378ADD',
|
| 58 | GOOD: '#639922',
|
| 59 | NEUTRAL: '#888780',
|
| 60 | SAD: '#1D9E75',
|
| 61 | ANGRY: '#D85A30',
|
| 62 | }
|
| 63 |
|
| 64 | const INTENSITY_COLORS: Record<IntensityLevel, string> = {
|
| 65 | LIGHT: '#639922',
|
| 66 | MODERATE: '#BA7517',
|
| 67 | HIGH: '#D85A30',
|
| 68 | }
|
| 69 |
|
| 70 | const EMPTY_CELL = '#E8E7E2'
|
| 71 |
|
| 72 | const s = StyleSheet.create({
|
| 73 | page: {
|
| 74 | fontFamily: 'Helvetica',
|
| 75 | fontSize: 9,
|
| 76 | paddingTop: 40,
|
| 77 | paddingBottom: 50,
|
| 78 | paddingHorizontal: 40,
|
| 79 | color: '#1a1a1a',
|
| 80 | },
|
| 81 | header: {
|
| 82 | flexDirection: 'row',
|
| 83 | justifyContent: 'space-between',
|
| 84 | alignItems: 'flex-start',
|
| 85 | marginBottom: 4,
|
| 86 | paddingBottom: 8,
|
| 87 | borderBottomWidth: 1,
|
| 88 | borderBottomColor: '#e0e0e0',
|
| 89 | },
|
| 90 | headerTitle: {
|
| 91 | fontSize: 13,
|
| 92 | fontFamily: 'Helvetica-Bold',
|
| 93 | },
|
| 94 | headerLogo: {
|
| 95 | width: 48,
|
| 96 | height: 48,
|
| 97 | objectFit: 'contain',
|
| 98 | },
|
| 99 | headerSub: {
|
| 100 | fontSize: 8,
|
| 101 | color: '#666666',
|
| 102 | marginTop: 2,
|
| 103 | },
|
| 104 | footer: {
|
| 105 | position: 'absolute',
|
| 106 | bottom: 20,
|
| 107 | left: 40,
|
| 108 | right: 40,
|
| 109 | flexDirection: 'column',
|
| 110 | borderTopWidth: 1,
|
| 111 | borderTopColor: '#e0e0e0',
|
| 112 | paddingTop: 5,
|
| 113 | },
|
| 114 | footerRow: {
|
| 115 | flexDirection: 'row',
|
| 116 | justifyContent: 'space-between',
|
| 117 | },
|
| 118 | footerText: {
|
| 119 | fontSize: 7,
|
| 120 | color: '#888888',
|
| 121 | },
|
| 122 | section: {
|
| 123 | marginTop: 16,
|
| 124 | marginBottom: 4,
|
| 125 | },
|
| 126 | sectionTitle: {
|
| 127 | fontFamily: 'Helvetica-Bold',
|
| 128 | fontSize: 10,
|
| 129 | marginBottom: 6,
|
| 130 | paddingBottom: 3,
|
| 131 | borderBottomWidth: 1,
|
| 132 | borderBottomColor: '#e0e0e0',
|
| 133 | },
|
| 134 | statRow: {
|
| 135 | flexDirection: 'row',
|
| 136 | gap: 8,
|
| 137 | marginBottom: 4,
|
| 138 | },
|
| 139 | statBox: {
|
| 140 | flex: 1,
|
| 141 | borderWidth: 1,
|
| 142 | borderColor: '#e0e0e0',
|
| 143 | borderRadius: 4,
|
| 144 | padding: 8,
|
| 145 | },
|
| 146 | statLabel: {
|
| 147 | fontSize: 7,
|
| 148 | color: '#888888',
|
| 149 | marginBottom: 3,
|
| 150 | },
|
| 151 | statValue: {
|
| 152 | fontSize: 12,
|
| 153 | fontFamily: 'Helvetica-Bold',
|
| 154 | },
|
| 155 | barRow: {
|
| 156 | flexDirection: 'row',
|
| 157 | alignItems: 'center',
|
| 158 | marginBottom: 4,
|
| 159 | },
|
| 160 | barLabel: {
|
| 161 | width: 60,
|
| 162 | fontSize: 8,
|
| 163 | },
|
| 164 | barPct: {
|
| 165 | width: 32,
|
| 166 | fontSize: 8,
|
| 167 | textAlign: 'right',
|
| 168 | color: '#666666',
|
| 169 | },
|
| 170 | heatmapRow: {
|
| 171 | flexDirection: 'row',
|
| 172 | alignItems: 'center',
|
| 173 | marginBottom: 2,
|
| 174 | },
|
| 175 | heatmapWeekLabel: {
|
| 176 | width: 24,
|
| 177 | fontSize: 7,
|
| 178 | color: '#888888',
|
| 179 | },
|
| 180 | tableHeaderRow: {
|
| 181 | flexDirection: 'row',
|
| 182 | backgroundColor: '#f0f0f0',
|
| 183 | paddingVertical: 4,
|
| 184 | paddingHorizontal: 4,
|
| 185 | },
|
| 186 | tableRow: {
|
| 187 | flexDirection: 'row',
|
| 188 | paddingVertical: 3,
|
| 189 | paddingHorizontal: 4,
|
| 190 | },
|
| 191 | tableCell: {
|
| 192 | fontSize: 7,
|
| 193 | },
|
| 194 | })
|
| 195 |
|
| 196 | function formatPeriod(start: Date, end: Date): string {
|
| 197 | const s = format(start, 'd MMM', { locale: ptBR })
|
| 198 | const e = format(end, 'd MMM yyyy', { locale: ptBR })
|
| 199 | return `${s} – ${e}`
|
| 200 | }
|
| 201 |
|
| 202 | function truncate(str: string | null, max: number): string {
|
| 203 | if (!str) return '—'
|
| 204 | return str.length > max ? str.slice(0, max) + '…' : str
|
| 205 | }
|
| 206 |
|
| 207 | function topMoodLabel(dist: ReportData['moodDistribution']): string {
|
| 208 | const top = [...dist].sort((a, b) => b.count - a.count)[0]
|
| 209 | return top && top.count > 0 ? T.moods[top.mood] : '—'
|
| 210 | }
|
| 211 |
|
| 212 | type SectionHeaderProps = { label: string }
|
| 213 | function SectionHeader({ label }: SectionHeaderProps) {
|
| 214 | return (
|
| 215 | <View style={s.section}>
|
| 216 | <Text style={s.sectionTitle}>{label}</Text>
|
| 217 | </View>
|
| 218 | )
|
| 219 | }
|
| 220 |
|
| 221 | type StatBoxProps = { label: string; value: string }
|
| 222 | function StatBox({ label, value }: StatBoxProps) {
|
| 223 | return (
|
| 224 | <View style={s.statBox}>
|
| 225 | <Text style={s.statLabel}>{label}</Text>
|
| 226 | <Text style={s.statValue}>{value}</Text>
|
| 227 | </View>
|
| 228 | )
|
| 229 | }
|
| 230 |
|
| 231 | const BAR_MAX_WIDTH = 350
|
| 232 |
|
| 233 | type MoodBarProps = {
|
| 234 | mood: BaseMoodOption
|
| 235 | count: number
|
| 236 | pct: number
|
| 237 | maxPct: number
|
| 238 | }
|
| 239 | function MoodBar({ mood, count, pct, maxPct }: MoodBarProps) {
|
| 240 | const barWidth = maxPct > 0 ? (pct / maxPct) * BAR_MAX_WIDTH : 0
|
| 241 | const color = MOOD_COLORS[mood]
|
| 242 | return (
|
| 243 | <View style={s.barRow}>
|
| 244 | <Text style={s.barLabel}>{T.moods[mood]}</Text>
|
| 245 | <Svg width={BAR_MAX_WIDTH} height={12}>
|
| 246 | <Rect x={0} y={2} width={BAR_MAX_WIDTH} height={8}
|
| 247 | rx={2} fill={EMPTY_CELL} />
|
| 248 | {barWidth > 0 && (
|
| 249 | <Rect x={0} y={2} width={barWidth} height={8}
|
| 250 | rx={2} fill={color} />
|
| 251 | )}
|
| 252 | </Svg>
|
| 253 | <Text style={s.barPct}>{pct}%</Text>
|
| 254 | <Text style={[s.barPct, { width: 24 }]}>{count}</Text>
|
| 255 | </View>
|
| 256 | )
|
| 257 | }
|
| 258 |
|
| 259 | const CELL = 52
|
| 260 | const GAP = 4
|
| 261 |
|
| 262 | type HeatmapRowProps = {
|
| 263 | week: number
|
| 264 | cells: Array<{ date: string; mood: BaseMoodOption | null }>
|
| 265 | startDow: number
|
| 266 | }
|
| 267 | function HeatmapRow({ week, cells, startDow }: HeatmapRowProps) {
|
| 268 | const totalCols = 7
|
| 269 | const svgWidth = totalCols * (CELL + GAP)
|
| 270 |
|
| 271 | return (
|
| 272 | <View style={s.heatmapRow}>
|
| 273 | <Text style={s.heatmapWeekLabel}>S{week}</Text>
|
| 274 | <Svg width={svgWidth} height={CELL}>
|
| 275 | {Array.from({ length: totalCols }).map((_, col) => {
|
| 276 | const dayIndex = col - startDow
|
| 277 | const cell = dayIndex >= 0 ? cells[dayIndex] : undefined
|
| 278 | const fill = cell
|
| 279 | ? cell.mood ? MOOD_COLORS[cell.mood] : EMPTY_CELL
|
| 280 | : '#f8f8f8'
|
| 281 | return (
|
| 282 | <Rect
|
| 283 | key={col}
|
| 284 | x={col * (CELL + GAP)}
|
| 285 | y={0}
|
| 286 | width={CELL}
|
| 287 | height={CELL}
|
| 288 | rx={3}
|
| 289 | fill={fill}
|
| 290 | />
|
| 291 | )
|
| 292 | })}
|
| 293 | </Svg>
|
| 294 | </View>
|
| 295 | )
|
| 296 | }
|
| 297 |
|
| 298 | type TableHeaderProps = { cols: string[]; widths: number[] }
|
| 299 | function TableHeader({ cols, widths }: TableHeaderProps) {
|
| 300 | return (
|
| 301 | <View style={s.tableHeaderRow}>
|
| 302 | {cols.map((col, i) => (
|
| 303 | <Text key={i}
|
| 304 | style={[s.tableCell, { width: widths[i], fontFamily: 'Helvetica-Bold' }]}>
|
| 305 | {col}
|
| 306 | </Text>
|
| 307 | ))}
|
| 308 | </View>
|
| 309 | )
|
| 310 | }
|
| 311 |
|
| 312 | type TableRowProps = {
|
| 313 | cols: string[]
|
| 314 | widths: number[]
|
| 315 | even: boolean
|
| 316 | colors?: (string | undefined)[]
|
| 317 | }
|
| 318 | function TableRow({ cols, widths, even, colors }: TableRowProps) {
|
| 319 | return (
|
| 320 | <View style={[s.tableRow, { backgroundColor: even ? '#F8F7F4' : '#ffffff' }]}>
|
| 321 | {cols.map((col, i) => (
|
| 322 | <Text key={i}
|
| 323 | style={[s.tableCell, { width: widths[i], color: colors?.[i] ?? '#1a1a1a' }]}>
|
| 324 | {col}
|
| 325 | </Text>
|
| 326 | ))}
|
| 327 | </View>
|
| 328 | )
|
| 329 | }
|
| 330 |
|
| 331 | type PageHeaderProps = { data: ReportData }
|
| 332 | function PageHeader({ data }: PageHeaderProps) {
|
| 333 | const name = [data.user.firstName, data.user.lastName].filter(Boolean).join(' ')
|
| 334 | const period = formatPeriod(data.periodStart, data.periodEnd)
|
| 335 | return (
|
| 336 | <View style={s.header}>
|
| 337 | <View>
|
| 338 | <Text style={s.headerTitle}>{T.title}</Text>
|
| 339 | <Text style={s.headerSub}>{name} · {period}</Text>
|
| 340 | </View>
|
| 341 |
|
| 342 | <Image src={LOGO_DATA_URI} style={s.headerLogo} />
|
| 343 | </View>
|
| 344 | )
|
| 345 | }
|
| 346 |
|
| 347 | function PageFooter() {
|
| 348 | return (
|
| 349 | <View style={s.footer} fixed>
|
| 350 | <View style={s.footerRow}>
|
| 351 | <Text style={s.footerText}>{T.tagline}</Text>
|
| 352 | <Text
|
| 353 | style={s.footerText}
|
| 354 | render={({ pageNumber, totalPages }: { pageNumber: number; totalPages: number }) =>
|
| 355 | `${pageNumber} / ${totalPages}`
|
| 356 | }
|
| 357 | />
|
| 358 | </View>
|
| 359 | <Text style={[s.footerText, { marginTop: 2 }]}>{T.confidential}</Text>
|
| 360 | </View>
|
| 361 | )
|
| 362 | }
|
| 363 |
|
| 364 | function buildHeatmapWeeks(
|
| 365 | dailyMoods: ReportData['dailyMoods'],
|
| 366 | ): Array<{
|
| 367 | week: number
|
| 368 | cells: Array<{ date: string; mood: BaseMoodOption | null }>
|
| 369 | startDow: number
|
| 370 | }> {
|
| 371 | const weeks: ReturnType<typeof buildHeatmapWeeks> = []
|
| 372 | let weekNum = 1
|
| 373 | let i = 0
|
| 374 |
|
| 375 | if (dailyMoods.length === 0) return weeks
|
| 376 |
|
| 377 | const firstDow = new Date(dailyMoods[0].date + 'T12:00:00').getDay()
|
| 378 |
|
| 379 | while (i < dailyMoods.length) {
|
| 380 | const slots = 7 - (weekNum === 1 ? firstDow : 0)
|
| 381 | const cells = dailyMoods.slice(i, i + slots)
|
| 382 |
|
| 383 | weeks.push({ week: weekNum, cells, startDow: weekNum === 1 ? firstDow : 0 })
|
| 384 | i += slots
|
| 385 | weekNum++
|
| 386 | }
|
| 387 |
|
| 388 | return weeks
|
| 389 | }
|
| 390 |
|
| 391 | type Props = { data: ReportData }
|
| 392 |
|
| 393 | export default function ReportDocument({ data }: Props) {
|
| 394 | const maxPct = Math.max(...data.moodDistribution.map(d => d.pct), 0)
|
| 395 | const daysTracked = data.dailyMoods.filter(d => d.mood !== null).length
|
| 396 | const heatmapWeeks = buildHeatmapWeeks(data.dailyMoods)
|
| 397 |
|
| 398 | return (
|
| 399 | <Document>
|
| 400 | {/* Page 1 */}
|
| 401 | <Page size="A4" style={s.page}>
|
| 402 | <PageHeader data={data} />
|
| 403 | <PageFooter />
|
| 404 |
|
| 405 | <SectionHeader label={T.sections.glance} />
|
| 406 | <View style={s.statRow}>
|
| 407 | <StatBox label={T.stats.checkins} value={String(data.totalCheckins)} />
|
| 408 | <StatBox label={T.stats.topMood} value={topMoodLabel(data.moodDistribution)} />
|
| 409 | <StatBox label={T.stats.daysTracked} value={String(daysTracked)} />
|
| 410 | <StatBox
|
| 411 | label="Período"
|
| 412 | value={`${data.dailyMoods.length}d`}
|
| 413 | />
|
| 414 | </View>
|
| 415 |
|
| 416 | <SectionHeader label={T.sections.distribution} />
|
| 417 | {data.moodDistribution.map(d => (
|
| 418 | <MoodBar
|
| 419 | key={d.mood}
|
| 420 | mood={d.mood}
|
| 421 | count={d.count}
|
| 422 | pct={d.pct}
|
| 423 | maxPct={maxPct}
|
| 424 | />
|
| 425 | ))}
|
| 426 |
|
| 427 | <SectionHeader label={T.sections.heatmap} />
|
| 428 | {heatmapWeeks.map(({ week, cells, startDow }) => (
|
| 429 | <HeatmapRow key={week} week={week} cells={cells} startDow={startDow} />
|
| 430 | ))}
|
| 431 | </Page>
|
| 432 |
|
| 433 | {/* Page 2 */}
|
| 434 | <Page size="A4" style={s.page}>
|
| 435 | <PageHeader data={data} />
|
| 436 | <PageFooter />
|
| 437 |
|
| 438 | <SectionHeader label={T.sections.components} />
|
| 439 | {data.topComponents.length > 0 ? (
|
| 440 | <View>
|
| 441 | <TableHeader
|
| 442 | cols={[
|
| 443 | T.tableHeaders.component,
|
| 444 | T.tableHeaders.count,
|
| 445 | T.tableHeaders.avgIntensity,
|
| 446 | ]}
|
| 447 | widths={[200, 60, 100]}
|
| 448 | />
|
| 449 | {data.topComponents.map((c, i) => (
|
| 450 | <TableRow
|
| 451 | key={c.component}
|
| 452 | even={i % 2 === 1}
|
| 453 | cols={[
|
| 454 | T.components[c.component] ?? c.component,
|
| 455 | String(c.count),
|
| 456 | T.intensity[c.avgIntensity],
|
| 457 | ]}
|
| 458 | widths={[200, 60, 100]}
|
| 459 | colors={[
|
| 460 | undefined,
|
| 461 | undefined,
|
| 462 | INTENSITY_COLORS[c.avgIntensity],
|
| 463 | ]}
|
| 464 | />
|
| 465 | ))}
|
| 466 | </View>
|
| 467 | ) : (
|
| 468 | <Text style={[s.tableCell, { color: '#888888', marginTop: 4 }]}>
|
| 469 | Sem componentes registrados no período.
|
| 470 | </Text>
|
| 471 | )}
|
| 472 |
|
| 473 | <SectionHeader label={T.sections.log} />
|
| 474 | {data.checkinLog.length > 0 ? (
|
| 475 | <View>
|
| 476 | <TableHeader
|
| 477 | cols={[
|
| 478 | T.tableHeaders.date,
|
| 479 | T.tableHeaders.mood,
|
| 480 | T.tableHeaders.anxiety,
|
| 481 | T.tableHeaders.stress,
|
| 482 | T.tableHeaders.energy,
|
| 483 | T.tableHeaders.note,
|
| 484 | ]}
|
| 485 | widths={[48, 62, 28, 28, 28, 150]}
|
| 486 | />
|
| 487 | {data.checkinLog.map((entry, i) => (
|
| 488 | <TableRow
|
| 489 | key={i}
|
| 490 | even={i % 2 === 1}
|
| 491 | cols={[
|
| 492 | format(entry.moment, 'd MMM', { locale: ptBR }),
|
| 493 | T.moods[entry.mood] ?? entry.mood,
|
| 494 | String(entry.anxietyLevel),
|
| 495 | String(entry.stressLevel),
|
| 496 | String(entry.energyLevel),
|
| 497 | truncate(entry.annotation, 28),
|
| 498 | ]}
|
| 499 | widths={[48, 62, 28, 28, 28, 150]}
|
| 500 | colors={[
|
| 501 | undefined,
|
| 502 | MOOD_COLORS[entry.mood],
|
| 503 | undefined,
|
| 504 | undefined,
|
| 505 | undefined,
|
| 506 | undefined,
|
| 507 | ]}
|
| 508 | />
|
| 509 | ))}
|
| 510 | </View>
|
| 511 | ) : (
|
| 512 | <Text style={[s.tableCell, { color: '#888888', marginTop: 4 }]}>
|
| 513 | Sem check-ins no período.
|
| 514 | </Text>
|
| 515 | )}
|
| 516 | </Page>
|
| 517 | </Document>
|
| 518 | )
|
| 519 | }
|