| 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 — usenexo.xyz',
|
| 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 | footnote: {
|
| 195 | fontSize: 6,
|
| 196 | color: '#aaaaaa',
|
| 197 | marginTop: 6,
|
| 198 | fontFamily: 'Helvetica-Oblique',
|
| 199 | },
|
| 200 | })
|
| 201 |
|
| 202 | function formatPeriod(start: Date, end: Date): string {
|
| 203 | const s = format(start, 'd MMM', { locale: ptBR })
|
| 204 | const e = format(end, 'd MMM yyyy', { locale: ptBR })
|
| 205 | return `${s} – ${e}`
|
| 206 | }
|
| 207 |
|
| 208 | function truncate(str: string | null, max: number): string {
|
| 209 | if (!str) return '—'
|
| 210 | return str.length > max ? str.slice(0, max) + '…' : str
|
| 211 | }
|
| 212 |
|
| 213 | function topMoodLabel(dist: ReportData['moodDistribution']): string {
|
| 214 | const top = [...dist].sort((a, b) => b.count - a.count)[0]
|
| 215 | return top && top.count > 0 ? T.moods[top.mood] : '—'
|
| 216 | }
|
| 217 |
|
| 218 | type SectionHeaderProps = { label: string }
|
| 219 | function SectionHeader({ label }: SectionHeaderProps) {
|
| 220 | return (
|
| 221 | <View style={s.section}>
|
| 222 | <Text style={s.sectionTitle}>{label}</Text>
|
| 223 | </View>
|
| 224 | )
|
| 225 | }
|
| 226 |
|
| 227 | type StatBoxProps = { label: string; value: string }
|
| 228 | function StatBox({ label, value }: StatBoxProps) {
|
| 229 | return (
|
| 230 | <View style={s.statBox}>
|
| 231 | <Text style={s.statLabel}>{label}</Text>
|
| 232 | <Text style={s.statValue}>{value}</Text>
|
| 233 | </View>
|
| 234 | )
|
| 235 | }
|
| 236 |
|
| 237 | const BAR_MAX_WIDTH = 350
|
| 238 |
|
| 239 | type MoodBarProps = {
|
| 240 | mood: BaseMoodOption
|
| 241 | count: number
|
| 242 | pct: number
|
| 243 | maxPct: number
|
| 244 | }
|
| 245 | function MoodBar({ mood, count, pct, maxPct }: MoodBarProps) {
|
| 246 | const barWidth = maxPct > 0 ? (pct / maxPct) * BAR_MAX_WIDTH : 0
|
| 247 | const color = MOOD_COLORS[mood]
|
| 248 | return (
|
| 249 | <View style={s.barRow}>
|
| 250 | <Text style={s.barLabel}>{T.moods[mood]}</Text>
|
| 251 | <Svg width={BAR_MAX_WIDTH} height={12}>
|
| 252 | <Rect x={0} y={2} width={BAR_MAX_WIDTH} height={8}
|
| 253 | rx={2} fill={EMPTY_CELL} />
|
| 254 | {barWidth > 0 && (
|
| 255 | <Rect x={0} y={2} width={barWidth} height={8}
|
| 256 | rx={2} fill={color} />
|
| 257 | )}
|
| 258 | </Svg>
|
| 259 | <Text style={s.barPct}>{pct}%</Text>
|
| 260 | <Text style={[s.barPct, { width: 24 }]}>{count}</Text>
|
| 261 | </View>
|
| 262 | )
|
| 263 | }
|
| 264 |
|
| 265 | const CELL = 52
|
| 266 | const GAP = 4
|
| 267 |
|
| 268 | type HeatmapRowProps = {
|
| 269 | week: number
|
| 270 | cells: Array<{ date: string; mood: BaseMoodOption | null }>
|
| 271 | startDow: number
|
| 272 | }
|
| 273 | function HeatmapRow({ week, cells, startDow }: HeatmapRowProps) {
|
| 274 | const totalCols = 7
|
| 275 | const svgWidth = totalCols * (CELL + GAP)
|
| 276 |
|
| 277 | return (
|
| 278 | <View style={s.heatmapRow}>
|
| 279 | <Text style={s.heatmapWeekLabel}>S{week}</Text>
|
| 280 | <Svg width={svgWidth} height={CELL}>
|
| 281 | {Array.from({ length: totalCols }).map((_, col) => {
|
| 282 | const dayIndex = col - startDow
|
| 283 | const cell = dayIndex >= 0 ? cells[dayIndex] : undefined
|
| 284 | const fill = cell
|
| 285 | ? cell.mood ? MOOD_COLORS[cell.mood] : EMPTY_CELL
|
| 286 | : '#f8f8f8'
|
| 287 | return (
|
| 288 | <Rect
|
| 289 | key={col}
|
| 290 | x={col * (CELL + GAP)}
|
| 291 | y={0}
|
| 292 | width={CELL}
|
| 293 | height={CELL}
|
| 294 | rx={3}
|
| 295 | fill={fill}
|
| 296 | />
|
| 297 | )
|
| 298 | })}
|
| 299 | </Svg>
|
| 300 | </View>
|
| 301 | )
|
| 302 | }
|
| 303 |
|
| 304 | type TableHeaderProps = { cols: string[]; widths: number[] }
|
| 305 | function TableHeader({ cols, widths }: TableHeaderProps) {
|
| 306 | return (
|
| 307 | <View style={s.tableHeaderRow}>
|
| 308 | {cols.map((col, i) => (
|
| 309 | <Text key={i}
|
| 310 | style={[s.tableCell, { width: widths[i], fontFamily: 'Helvetica-Bold' }]}>
|
| 311 | {col}
|
| 312 | </Text>
|
| 313 | ))}
|
| 314 | </View>
|
| 315 | )
|
| 316 | }
|
| 317 |
|
| 318 | type TableRowProps = {
|
| 319 | cols: string[]
|
| 320 | widths: number[]
|
| 321 | even: boolean
|
| 322 | colors?: (string | undefined)[]
|
| 323 | }
|
| 324 | function TableRow({ cols, widths, even, colors }: TableRowProps) {
|
| 325 | return (
|
| 326 | <View style={[s.tableRow, { backgroundColor: even ? '#F8F7F4' : '#ffffff' }]}>
|
| 327 | {cols.map((col, i) => (
|
| 328 | <Text key={i}
|
| 329 | style={[s.tableCell, { width: widths[i], color: colors?.[i] ?? '#1a1a1a' }]}>
|
| 330 | {col}
|
| 331 | </Text>
|
| 332 | ))}
|
| 333 | </View>
|
| 334 | )
|
| 335 | }
|
| 336 |
|
| 337 | type PageHeaderProps = { data: ReportData }
|
| 338 | function PageHeader({ data }: PageHeaderProps) {
|
| 339 | const name = [data.user.firstName, data.user.lastName].filter(Boolean).join(' ')
|
| 340 | const period = formatPeriod(data.periodStart, data.periodEnd)
|
| 341 | return (
|
| 342 | <View style={s.header}>
|
| 343 | <View>
|
| 344 | <Text style={s.headerTitle}>{T.title}</Text>
|
| 345 | <Text style={s.headerSub}>{name} · {period}</Text>
|
| 346 | </View>
|
| 347 |
|
| 348 | <Image src={LOGO_DATA_URI} style={s.headerLogo} />
|
| 349 | </View>
|
| 350 | )
|
| 351 | }
|
| 352 |
|
| 353 | function PageFooter() {
|
| 354 | return (
|
| 355 | <View style={s.footer} fixed>
|
| 356 | <View style={s.footerRow}>
|
| 357 | <Text style={s.footerText}>{T.tagline}</Text>
|
| 358 | <Text
|
| 359 | style={s.footerText}
|
| 360 | render={({ pageNumber, totalPages }: { pageNumber: number; totalPages: number }) =>
|
| 361 | `${pageNumber} / ${totalPages}`
|
| 362 | }
|
| 363 | />
|
| 364 | </View>
|
| 365 | <Text style={[s.footerText, { marginTop: 2 }]}>{T.confidential}</Text>
|
| 366 | </View>
|
| 367 | )
|
| 368 | }
|
| 369 |
|
| 370 | function buildHeatmapWeeks(
|
| 371 | dailyMoods: ReportData['dailyMoods'],
|
| 372 | ): Array<{
|
| 373 | week: number
|
| 374 | cells: Array<{ date: string; mood: BaseMoodOption | null }>
|
| 375 | startDow: number
|
| 376 | }> {
|
| 377 | const weeks: ReturnType<typeof buildHeatmapWeeks> = []
|
| 378 | let weekNum = 1
|
| 379 | let i = 0
|
| 380 |
|
| 381 | if (dailyMoods.length === 0) return weeks
|
| 382 |
|
| 383 | const firstDow = new Date(dailyMoods[0].date + 'T12:00:00').getDay()
|
| 384 |
|
| 385 | while (i < dailyMoods.length) {
|
| 386 | const slots = 7 - (weekNum === 1 ? firstDow : 0)
|
| 387 | const cells = dailyMoods.slice(i, i + slots)
|
| 388 |
|
| 389 | weeks.push({ week: weekNum, cells, startDow: weekNum === 1 ? firstDow : 0 })
|
| 390 | i += slots
|
| 391 | weekNum++
|
| 392 | }
|
| 393 |
|
| 394 | return weeks
|
| 395 | }
|
| 396 |
|
| 397 | type Props = { data: ReportData }
|
| 398 |
|
| 399 | export default function ReportDocument({ data }: Props) {
|
| 400 | const maxPct = Math.max(...data.moodDistribution.map(d => d.pct), 0)
|
| 401 | const daysTracked = data.dailyMoods.filter(d => d.mood !== null).length
|
| 402 | const heatmapWeeks = buildHeatmapWeeks(data.dailyMoods)
|
| 403 |
|
| 404 | return (
|
| 405 | <Document>
|
| 406 | {/* Page 1 */}
|
| 407 | <Page size="A4" style={s.page}>
|
| 408 | <PageHeader data={data} />
|
| 409 | <PageFooter />
|
| 410 |
|
| 411 | <SectionHeader label={T.sections.glance} />
|
| 412 | <View style={s.statRow}>
|
| 413 | <StatBox label={T.stats.checkins} value={String(data.totalCheckins)} />
|
| 414 | <StatBox label={T.stats.topMood} value={topMoodLabel(data.moodDistribution)} />
|
| 415 | <StatBox label={T.stats.daysTracked} value={String(daysTracked)} />
|
| 416 | <StatBox
|
| 417 | label="Período"
|
| 418 | value={`${data.dailyMoods.length}d`}
|
| 419 | />
|
| 420 | </View>
|
| 421 |
|
| 422 | <SectionHeader label={T.sections.distribution} />
|
| 423 | {data.moodDistribution.map(d => (
|
| 424 | <MoodBar
|
| 425 | key={d.mood}
|
| 426 | mood={d.mood}
|
| 427 | count={d.count}
|
| 428 | pct={d.pct}
|
| 429 | maxPct={maxPct}
|
| 430 | />
|
| 431 | ))}
|
| 432 |
|
| 433 | <SectionHeader label={T.sections.heatmap} />
|
| 434 | {heatmapWeeks.map(({ week, cells, startDow }) => (
|
| 435 | <HeatmapRow key={week} week={week} cells={cells} startDow={startDow} />
|
| 436 | ))}
|
| 437 | </Page>
|
| 438 |
|
| 439 | {/* Page 2 */}
|
| 440 | <Page size="A4" style={s.page}>
|
| 441 | <PageHeader data={data} />
|
| 442 | <PageFooter />
|
| 443 |
|
| 444 | <SectionHeader label={T.sections.components} />
|
| 445 | {data.topComponents.length > 0 ? (
|
| 446 | <View>
|
| 447 | <TableHeader
|
| 448 | cols={[
|
| 449 | T.tableHeaders.component,
|
| 450 | T.tableHeaders.count,
|
| 451 | T.tableHeaders.avgIntensity,
|
| 452 | ]}
|
| 453 | widths={[200, 60, 100]}
|
| 454 | />
|
| 455 | {data.topComponents.map((c, i) => (
|
| 456 | <TableRow
|
| 457 | key={c.component}
|
| 458 | even={i % 2 === 1}
|
| 459 | cols={[
|
| 460 | T.components[c.component] ?? c.component,
|
| 461 | String(c.count),
|
| 462 | T.intensity[c.avgIntensity],
|
| 463 | ]}
|
| 464 | widths={[200, 60, 100]}
|
| 465 | colors={[
|
| 466 | undefined,
|
| 467 | undefined,
|
| 468 | INTENSITY_COLORS[c.avgIntensity],
|
| 469 | ]}
|
| 470 | />
|
| 471 | ))}
|
| 472 | </View>
|
| 473 | ) : (
|
| 474 | <Text style={[s.tableCell, { color: '#888888', marginTop: 4 }]}>
|
| 475 | Sem componentes registrados no período.
|
| 476 | </Text>
|
| 477 | )}
|
| 478 |
|
| 479 | <SectionHeader label={T.sections.log} />
|
| 480 |
|
| 481 | <Text style={[s.footnote, { marginBottom: 10 }]}>
|
| 482 | Ans = Ansiedade · Est = Estresse · Ene = Energia.
|
| 483 | Níveis percebidos de 0 a 10.
|
| 484 | </Text>
|
| 485 |
|
| 486 | {data.checkinLog.length > 0 ? (
|
| 487 | <View>
|
| 488 | <TableHeader
|
| 489 | cols={[
|
| 490 | T.tableHeaders.date,
|
| 491 | T.tableHeaders.mood,
|
| 492 | T.tableHeaders.anxiety,
|
| 493 | T.tableHeaders.stress,
|
| 494 | T.tableHeaders.energy,
|
| 495 | T.tableHeaders.note,
|
| 496 | ]}
|
| 497 | widths={[56, 78, 30, 30, 30, 75]}
|
| 498 | />
|
| 499 | {data.checkinLog.map((entry, i) => (
|
| 500 | <TableRow
|
| 501 | key={i}
|
| 502 | even={i % 2 === 1}
|
| 503 | cols={[
|
| 504 | format(entry.moment, 'd MMM', { locale: ptBR }),
|
| 505 | T.moods[entry.mood] ?? entry.mood,
|
| 506 | String(entry.anxietyLevel),
|
| 507 | String(entry.stressLevel),
|
| 508 | String(entry.energyLevel),
|
| 509 | truncate(entry.annotation?.trim() ?? "", 73),
|
| 510 | ]}
|
| 511 | widths={[56, 78, 30, 30, 30, 75]}
|
| 512 | colors={[
|
| 513 | undefined,
|
| 514 | MOOD_COLORS[entry.mood],
|
| 515 | undefined,
|
| 516 | undefined,
|
| 517 | undefined,
|
| 518 | undefined,
|
| 519 | ]}
|
| 520 | />
|
| 521 | ))}
|
| 522 | </View>
|
| 523 | ) : (
|
| 524 | <Text style={[s.tableCell, { color: '#888888', marginTop: 4 }]}>
|
| 525 | Sem check-ins no período.
|
| 526 | </Text>
|
| 527 | )}
|
| 528 | </Page>
|
| 529 | </Document>
|
| 530 | )
|
| 531 | }
|