import React from 'react' import { Document, Page, View, Text, Svg, Rect, Image, StyleSheet, } from '@react-pdf/renderer' import { format } from 'date-fns' import { ptBR } from 'date-fns/locale' import { BaseMoodOption, IntensityLevel, MoodComponentOption } from '@prisma/client' import { ReportData } from './report.data' import { LOGO_DATA_URI } from './logo' const T = { title: 'Relatório de Humor e Bem-Estar', appName: 'Nexo', tagline: 'Nexo · Rastreador de humor — usenexo.xyz', confidential: 'Confidencial — uso clínico', sections: { glance: 'Visão Geral do Período', distribution: 'Distribuição de Humor', heatmap: 'Mapa de Calor Diário', components: 'Sentimentos & Emoções', log: 'Registros e níveis (ansiedade, energia e estresse)', }, stats: { checkins: 'Check-ins', topMood: 'Humor predominante', daysTracked: 'Dias rastreados', }, moods: { GREAT: 'Ótimo', GOOD: 'Bom', NEUTRAL: 'Neutro', SAD: 'Triste', ANGRY: 'Irritado', } as Record, intensity: { LIGHT: 'Leve', MODERATE: 'Moderado', HIGH: 'Alto', } as Record, components: { JOY: 'Alegria', ANGER: 'Raiva', SADNESS: 'Tristeza', FEAR: 'Medo', GUILT: 'Culpa', FRUSTRATION: 'Frustração', CALM: 'Calma', MOTIVATED: 'Motivação', TIREDNESS: 'Cansaço', GRATITUDE: 'Gratidão', FOCUS: 'Foco', RESTLESS: 'Agitação', RELAXED: 'Relaxado', OVERWHELMED: 'Sobrecarga', } as Record, tableHeaders: { component: 'Componente', count: 'Qtd', avgIntensity: 'Int. Média', date: 'Data', mood: 'Humor', anxiety: 'Ans', stress: 'Est', energy: 'Ene', note: 'Nota', }, } const MOOD_COLORS: Record = { GREAT: '#378ADD', GOOD: '#639922', NEUTRAL: '#888780', SAD: '#1D9E75', ANGRY: '#D85A30', } const INTENSITY_COLORS: Record = { LIGHT: '#639922', MODERATE: '#BA7517', HIGH: '#D85A30', } const EMPTY_CELL = '#E8E7E2' const s = StyleSheet.create({ page: { fontFamily: 'Helvetica', fontSize: 9, paddingTop: 40, paddingBottom: 50, paddingHorizontal: 40, color: '#1a1a1a', }, header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 4, paddingBottom: 8, borderBottomWidth: 1, borderBottomColor: '#e0e0e0', }, headerTitle: { fontSize: 13, fontFamily: 'Helvetica-Bold', }, headerLogo: { width: 48, height: 48, objectFit: 'contain', }, headerSub: { fontSize: 8, color: '#666666', marginTop: 2, }, footer: { position: 'absolute', bottom: 20, left: 40, right: 40, flexDirection: 'column', borderTopWidth: 1, borderTopColor: '#e0e0e0', paddingTop: 5, }, footerRow: { flexDirection: 'row', justifyContent: 'space-between', }, footerText: { fontSize: 7, color: '#888888', }, section: { marginTop: 16, marginBottom: 4, }, sectionTitle: { fontFamily: 'Helvetica-Bold', fontSize: 10, marginBottom: 6, paddingBottom: 3, borderBottomWidth: 1, borderBottomColor: '#e0e0e0', }, statRow: { flexDirection: 'row', gap: 8, marginBottom: 4, }, statBox: { flex: 1, borderWidth: 1, borderColor: '#e0e0e0', borderRadius: 4, padding: 8, }, statLabel: { fontSize: 7, color: '#888888', marginBottom: 3, }, statValue: { fontSize: 12, fontFamily: 'Helvetica-Bold', }, barRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 4, }, barLabel: { width: 60, fontSize: 8, }, barPct: { width: 32, fontSize: 8, textAlign: 'right', color: '#666666', }, heatmapRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 2, }, heatmapWeekLabel: { width: 24, fontSize: 7, color: '#888888', }, tableHeaderRow: { flexDirection: 'row', backgroundColor: '#f0f0f0', paddingVertical: 4, paddingHorizontal: 4, }, tableRow: { flexDirection: 'row', paddingVertical: 3, paddingHorizontal: 4, }, tableCell: { fontSize: 7, }, footnote: { fontSize: 6, color: '#aaaaaa', marginTop: 6, fontFamily: 'Helvetica-Oblique', }, }) function formatPeriod(start: Date, end: Date): string { const s = format(start, 'd MMM', { locale: ptBR }) const e = format(end, 'd MMM yyyy', { locale: ptBR }) return `${s} – ${e}` } function truncate(str: string | null, max: number): string { if (!str) return '—' return str.length > max ? str.slice(0, max) + '…' : str } function topMoodLabel(dist: ReportData['moodDistribution']): string { const top = [...dist].sort((a, b) => b.count - a.count)[0] return top && top.count > 0 ? T.moods[top.mood] : '—' } type SectionHeaderProps = { label: string } function SectionHeader({ label }: SectionHeaderProps) { return ( {label} ) } type StatBoxProps = { label: string; value: string } function StatBox({ label, value }: StatBoxProps) { return ( {label} {value} ) } const BAR_MAX_WIDTH = 350 type MoodBarProps = { mood: BaseMoodOption count: number pct: number maxPct: number } function MoodBar({ mood, count, pct, maxPct }: MoodBarProps) { const barWidth = maxPct > 0 ? (pct / maxPct) * BAR_MAX_WIDTH : 0 const color = MOOD_COLORS[mood] return ( {T.moods[mood]} {barWidth > 0 && ( )} {pct}% {count} ) } const CELL = 52 const GAP = 4 type HeatmapRowProps = { week: number cells: Array<{ date: string; mood: BaseMoodOption | null }> startDow: number } function HeatmapRow({ week, cells, startDow }: HeatmapRowProps) { const totalCols = 7 const svgWidth = totalCols * (CELL + GAP) return ( S{week} {Array.from({ length: totalCols }).map((_, col) => { const dayIndex = col - startDow const cell = dayIndex >= 0 ? cells[dayIndex] : undefined const fill = cell ? cell.mood ? MOOD_COLORS[cell.mood] : EMPTY_CELL : '#f8f8f8' return ( ) })} ) } type TableHeaderProps = { cols: string[]; widths: number[] } function TableHeader({ cols, widths }: TableHeaderProps) { return ( {cols.map((col, i) => ( {col} ))} ) } type TableRowProps = { cols: string[] widths: number[] even: boolean colors?: (string | undefined)[] } function TableRow({ cols, widths, even, colors }: TableRowProps) { return ( {cols.map((col, i) => ( {col} ))} ) } type PageHeaderProps = { data: ReportData } function PageHeader({ data }: PageHeaderProps) { const name = [data.user.firstName, data.user.lastName].filter(Boolean).join(' ') const period = formatPeriod(data.periodStart, data.periodEnd) return ( {T.title} {name} · {period} ) } function PageFooter() { return ( {T.tagline} `${pageNumber} / ${totalPages}` } /> {T.confidential} ) } function buildHeatmapWeeks( dailyMoods: ReportData['dailyMoods'], ): Array<{ week: number cells: Array<{ date: string; mood: BaseMoodOption | null }> startDow: number }> { const weeks: ReturnType = [] let weekNum = 1 let i = 0 if (dailyMoods.length === 0) return weeks const firstDow = new Date(dailyMoods[0].date + 'T12:00:00').getDay() while (i < dailyMoods.length) { const slots = 7 - (weekNum === 1 ? firstDow : 0) const cells = dailyMoods.slice(i, i + slots) weeks.push({ week: weekNum, cells, startDow: weekNum === 1 ? firstDow : 0 }) i += slots weekNum++ } return weeks } type Props = { data: ReportData } export default function ReportDocument({ data }: Props) { const maxPct = Math.max(...data.moodDistribution.map(d => d.pct), 0) const daysTracked = data.dailyMoods.filter(d => d.mood !== null).length const heatmapWeeks = buildHeatmapWeeks(data.dailyMoods) return ( {/* Page 1 */} {data.moodDistribution.map(d => ( ))} {heatmapWeeks.map(({ week, cells, startDow }) => ( ))} {/* Page 2 */} {data.topComponents.length > 0 ? ( {data.topComponents.map((c, i) => ( ))} ) : ( Sem componentes registrados no período. )} Ans = Ansiedade · Est = Estresse · Ene = Energia. Níveis percebidos de 0 a 10. {data.checkinLog.length > 0 ? ( {data.checkinLog.map((entry, i) => ( ))} ) : ( Sem check-ins no período. )} ) }