thesis: document data export and PDF report generation
Adds a section to the features chapter describing the CSV export and the PDF report, closing the gap where those features were only listed as requirements
Pedro Lucas Porcellis porcellis@eletrotupi.com · 9 days ago
3a9bb1a4b14edcfdaa020cfe24a371c731f83cc0 Browse filesParents
e3c2640Changed files
3
Additions
+348
Deletions
-3
/dev/null → api/scripts/seed-demo-report.mjs
1 +
import pg from 'pg'
2 +
3 +
const { Client } = pg
4 +
5 +
const dbUrl = process.env.DATABASE_URL ?? 'postgres://user:password@localhost:5430/orbit_dev'
6 +
const targetEmail = process.env.SEED_EMAIL ?? 'porcellis@eletrotupi.com'
7 +
const DAYS = 35
8 +
9 +
// Reproducible PRNG so the demo dataset is stable between runs.
10 +
function mulberry32(seed) {
11 +
return function () {
12 +
seed |= 0
13 +
seed = (seed + 0x6d2b79f5) | 0
14 +
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
15 +
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
16 +
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
17 +
}
18 +
}
19 +
const rand = mulberry32(20261019)
20 +
21 +
const pick = (arr) => arr[Math.floor(rand() * arr.length)]
22 +
const between = (min, max) => Math.floor(rand() * (max - min + 1)) + min
23 +
const round1 = (n) => Math.round(n * 10) / 10
24 +
25 +
function localDateStr(d) {
26 +
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
27 +
}
28 +
29 +
// ---- mood ----
30 +
function pickMood() {
31 +
const r = rand()
32 +
if (r < 0.15) return 'GREAT'
33 +
if (r < 0.5) return 'GOOD'
34 +
if (r < 0.75) return 'NEUTRAL'
35 +
if (r < 0.9) return 'SAD'
36 +
return 'ANGRY'
37 +
}
38 +
39 +
const MOOD_LEVELS = {
40 +
GREAT: { energy: [7, 9], stress: [1, 3], anxiety: [1, 3] },
41 +
GOOD: { energy: [6, 8], stress: [2, 4], anxiety: [2, 4] },
42 +
NEUTRAL: { energy: [4, 6], stress: [3, 5], anxiety: [3, 5] },
43 +
SAD: { energy: [2, 4], stress: [4, 6], anxiety: [5, 7] },
44 +
ANGRY: { energy: [4, 6], stress: [7, 9], anxiety: [6, 8] },
45 +
}
46 +
47 +
const MOOD_COMPONENTS = {
48 +
GREAT: ['JOY', 'GRATITUDE', 'MOTIVATED', 'CALM'],
49 +
GOOD: ['CALM', 'FOCUS', 'RELAXED', 'MOTIVATED'],
50 +
NEUTRAL: ['CALM', 'TIREDNESS', 'FOCUS', 'RELAXED'],
51 +
SAD: ['SADNESS', 'TIREDNESS', 'OVERWHELMED', 'GUILT', 'FEAR'],
52 +
ANGRY: ['ANGER', 'FRUSTRATION', 'RESTLESS'],
53 +
}
54 +
55 +
const MOOD_ANNOTATIONS = {
56 +
GREAT: ['Dia muito produtivo', 'Acordei bem disposto', 'Me senti leve e animado', null],
57 +
GOOD: ['Dia tranquilo', 'Boa noite de sono', 'Trabalho rendeu bem', null],
58 +
NEUTRAL: ['Nada de especial hoje', 'Rotina normal', 'Cansado, mas ok', null],
59 +
SAD: ['Noite mal dormida', 'Me senti para baixo', 'Sem energia hoje', null],
60 +
ANGRY: ['Estresse no trabalho', 'Briga em casa', 'Trânsito me irritou', null],
61 +
}
62 +
63 +
const INTENSITIES = ['LIGHT', 'MODERATE', 'HIGH']
64 +
65 +
function componentsFor(mood) {
66 +
const pool = MOOD_COMPONENTS[mood]
67 +
const count = between(1, Math.min(3, pool.length))
68 +
const chosen = new Set()
69 +
while (chosen.size < count) chosen.add(pick(pool))
70 +
71 +
return [...chosen].map((component) => {
72 +
let intensity = pick(INTENSITIES)
73 +
if (['ANGER', 'FEAR', 'OVERWHELMED', 'FRUSTRATION'].includes(component) && intensity === 'LIGHT') {
74 +
intensity = 'MODERATE'
75 +
}
76 +
if (['CALM', 'RELAXED'].includes(component) && intensity === 'HIGH') {
77 +
intensity = 'MODERATE'
78 +
}
79 +
return { component, intensity }
80 +
})
81 +
}
82 +
83 +
// ---- sleep ----
84 +
const SLEEP_NOTES = ['Boa noite de sono', 'Acordei de madrugada', 'Demorei para pegar no sono', 'Sono leve', null]
85 +
86 +
// ---- triggers ----
87 +
const TRIGGER_POOL = [
88 +
{ category: 'WORK', comment: 'Reunião estressante no trabalho' },
89 +
{ category: 'WORK', comment: 'Prazo apertado no projeto' },
90 +
{ category: 'FAMILY', comment: 'Discussão em família' },
91 +
{ category: 'PHYSICAL', comment: 'Noite mal dormida' },
92 +
{ category: 'PHYSICAL', comment: 'Dor de cabeça forte' },
93 +
{ category: 'INTERNAL', comment: 'Pensamentos ansiosos' },
94 +
{ category: 'INTERNAL', comment: 'Sentimento de culpa' },
95 +
{ category: 'SOCIAL', comment: 'Encontro com amigos' },
96 +
{ category: 'SOCIAL', comment: 'Evento social no fim de semana' },
97 +
{ category: 'HEALTH', comment: 'Consulta de rotina' },
98 +
{ category: 'THERAPY', comment: 'Sessão de terapia' },
99 +
{ category: 'FAMILY', comment: 'Visita de parentes' },
100 +
]
101 +
102 +
// ---- medicine ----
103 +
const REGIMENS = [
104 +
{ name: 'Lítio', dosage: '300 mg', periodicity: 'DAILY', scheduledAt: ['20:00'] },
105 +
{ name: 'Lamotrigina', dosage: '100 mg', periodicity: 'DAILY', scheduledAt: ['08:00'] },
106 +
]
107 +
108 +
// ---- activities ----
109 +
const ACTIVITY_POOL = [
110 +
{ type: 'WALK', duration: 30 },
111 +
{ type: 'WALK', duration: 45 },
112 +
{ type: 'MEDITATION', duration: 15 },
113 +
{ type: 'GYM', duration: 60 },
114 +
{ type: 'YOGA', duration: 45 },
115 +
{ type: 'CREATIVE', duration: 60 },
116 +
{ type: 'SOCIAL', duration: 90 },
117 +
]
118 +
119 +
async function main() {
120 +
const db = new Client({ connectionString: dbUrl })
121 +
await db.connect()
122 +
123 +
try {
124 +
const { rows } = await db.query(
125 +
'SELECT id, first_name, last_name FROM users WHERE email = $1',
126 +
[targetEmail],
127 +
)
128 +
if (rows.length === 0) {
129 +
console.error(`User ${targetEmail} not found`)
130 +
process.exitCode = 1
131 +
return
132 +
}
133 +
134 +
const user = rows[0]
135 +
const userId = user.id
136 +
137 +
// Wipe existing demo data, children first (FK-safe order).
138 +
await db.query('DELETE FROM care_actions WHERE user_id = $1', [userId])
139 +
await db.query('DELETE FROM medicine_regimens WHERE user_id = $1', [userId])
140 +
await db.query('DELETE FROM triggers WHERE user_id = $1', [userId])
141 +
await db.query('DELETE FROM sleep_records WHERE user_id = $1', [userId])
142 +
await db.query('DELETE FROM moods WHERE user_id = $1', [userId])
143 +
await db.query('DELETE FROM insights WHERE user_id = $1', [userId])
144 +
145 +
const now = new Date()
146 +
const dayAt = (offset) => {
147 +
const d = new Date(now)
148 +
d.setDate(now.getDate() - offset)
149 +
return d
150 +
}
151 +
152 +
const counts = { moods: 0, sleep: 0, triggers: 0, medicine: 0, activities: 0 }
153 +
154 +
// moods
155 +
for (let d = DAYS; d >= 0; d--) {
156 +
const r = rand()
157 +
const count = r < 0.05 ? 0 : r < 0.5 ? 1 : r < 0.85 ? 2 : 3
158 +
159 +
for (let i = 0; i < count; i++) {
160 +
const mood = pickMood()
161 +
const levels = MOOD_LEVELS[mood]
162 +
163 +
const moment = new Date(dayAt(d))
164 +
moment.setHours(pick([9, 12, 15, 18, 21]) + between(0, 2), between(0, 59), 0, 0)
165 +
166 +
const res = await db.query(
167 +
`INSERT INTO moods
168 +
(user_id, selected_mood, energy_level, stress_level, anxiety_level, annotation, moment)
169 +
VALUES ($1, $2, $3, $4, $5, $6, $7)
170 +
RETURNING id`,
171 +
[
172 +
userId,
173 +
mood,
174 +
between(...levels.energy),
175 +
between(...levels.stress),
176 +
between(...levels.anxiety),
177 +
pick(MOOD_ANNOTATIONS[mood]),
178 +
moment,
179 +
],
180 +
)
181 +
182 +
for (const c of componentsFor(mood)) {
183 +
await db.query(
184 +
'INSERT INTO mood_components (mood_id, component, intensity) VALUES ($1, $2, $3)',
185 +
[res.rows[0].id, c.component, c.intensity],
186 +
)
187 +
}
188 +
counts.moods++
189 +
}
190 +
}
191 +
192 +
// sleep records — one per night
193 +
for (let d = DAYS; d >= 0; d--) {
194 +
const day = dayAt(d)
195 +
const average = round1(5.5 + rand() * 3)
196 +
const note = pick(SLEEP_NOTES)
197 +
await db.query(
198 +
'INSERT INTO sleep_records (user_id, average, date, annotations, updated_at) VALUES ($1, $2, $3, $4, NOW())',
199 +
[userId, average, localDateStr(day), note],
200 +
)
201 +
counts.sleep++
202 +
}
203 +
204 +
// triggers
205 +
for (let i = 0; i < TRIGGER_POOL.length; i++) {
206 +
const t = TRIGGER_POOL[i]
207 +
const moment = new Date(dayAt(between(0, DAYS)))
208 +
moment.setHours(between(8, 21), between(0, 59), 0, 0)
209 +
await db.query(
210 +
'INSERT INTO triggers (user_id, category, moment, comment, updated_at) VALUES ($1, $2, $3, $4, NOW())',
211 +
[userId, t.category, moment, t.comment],
212 +
)
213 +
counts.triggers++
214 +
}
215 +
216 +
// medicine regimens + logs
217 +
for (const r of REGIMENS) {
218 +
const reg = await db.query(
219 +
`INSERT INTO medicine_regimens (user_id, name, dosage, periodicity, scheduled_at, active, updated_at)
220 +
VALUES ($1, $2, $3, $4, $5, true, NOW())
221 +
RETURNING id`,
222 +
[userId, r.name, r.dosage, r.periodicity, r.scheduledAt],
223 +
)
224 +
const regimenId = reg.rows[0].id
225 +
226 +
for (let d = DAYS; d >= 0; d--) {
227 +
if (rand() < 0.25) continue
228 +
229 +
const moment = new Date(dayAt(d))
230 +
const [h, m] = pick(r.scheduledAt).split(':').map(Number)
231 +
moment.setHours(h, m, 0, 0)
232 +
233 +
const ca = await db.query(
234 +
"INSERT INTO care_actions (user_id, type, moment, updated_at) VALUES ($1, 'MEDICINE', $2, NOW()) RETURNING id",
235 +
[userId, moment],
236 +
)
237 +
await db.query(
238 +
'INSERT INTO medicine_logs (care_action_id, regimen_id, taken_at) VALUES ($1, $2, $3)',
239 +
[ca.rows[0].id, regimenId, moment],
240 +
)
241 +
counts.medicine++
242 +
}
243 +
}
244 +
245 +
// activities
246 +
for (let i = 0; i < 10; i++) {
247 +
const a = pick(ACTIVITY_POOL)
248 +
const moment = new Date(dayAt(between(0, DAYS)))
249 +
moment.setHours(between(7, 20), between(0, 59), 0, 0)
250 +
251 +
const ca = await db.query(
252 +
"INSERT INTO care_actions (user_id, type, moment, updated_at) VALUES ($1, 'ACTIVITY', $2, NOW()) RETURNING id",
253 +
[userId, moment],
254 +
)
255 +
await db.query(
256 +
'INSERT INTO activities (care_action_id, type, duration) VALUES ($1, $2, $3)',
257 +
[ca.rows[0].id, a.type, a.duration],
258 +
)
259 +
counts.activities++
260 +
}
261 +
262 +
const name = [user.first_name, user.last_name].filter(Boolean).join(' ')
263 +
console.log(`Seeded demo data for ${targetEmail} (id ${user.id}, ${name})`)
264 +
console.log(counts)
265 +
} finally {
266 +
await db.end()
267 +
}
268 +
}
269 +
270 +
main().catch((err) => {
271 +
console.error(err)
272 +
process.exit(1)
273 +
})
monografia/chapters/architecture.tex
1 -
\chapter{Arquitetura e Elementos do Sistema}
1 +
\chapter{Arquitetura e Funcionamento do Sistema}
2 2
3 3
Como delineado as tecnologias no capítulo anterior, aqui se apresenta a
4 4
arquitetura, demonstrando como todas as tecnologias apresentadas acima se
5 5
conectam e conversam entre si.
6 6
13 13
\end{figure}
14 14
15 15
16 16
Este capítulo detalha o funcionamento de algumas funcionalidades
17 17
desenvolvidas durante o projeto. Apenas algumas funcionalidades mais
18 -
chaves são abordadas neste capítulo, como o registro de humor, vinculo
19 -
de um gatilho, indicadores, e o registro de atividades.
18 +
chaves são abordadas neste capítulo, como o registro de humor, o vínculo
19 +
de um gatilho, os indicadores, o registro de atividades e, por fim, a
20 +
exportação de dados e a geração de relatórios.
20 21
21 22
\section{Processo de Login}
22 23
23 24
Logo ao abrir o aplicativo, o usuário é redirecionado para a tela de
24 25
login. A tela em si é relativamente simples, e contém os campos de
335 336
reaproveita o mesmo mecanismo já descrito na seção de indicadores: para
336 337
um registro de sono, sinaliza se a duração foi insuficiente; para um
337 338
registro de humor, destaca a dimensão mais relevante daquele registro
338 339
(como ``Ansiedade alta'' na figura \ref{fig:screenshot-history}); para um
339 340
gatilho, indica sua categoria, e assim por diante.
341 +
342 +
\section{Exportação de Dados e Relatórios}\label{sec:export-report}
343 +
344 +
Além das telas de consulta e análise, o Nexo oferece duas formas de
345 +
levar os dados para fora do aplicativo: a exportação integral dos
346 +
registros, em formato aberto, e a geração de um relatório resumido em
347 +
PDF. Ambas materializam o princípio de soberania dos dados discutido nos
348 +
capítulos anteriores --- princípio reforçado pelo questionário, em que a
349 +
maioria dos respondentes atribuiu nota máxima à importância de ter
350 +
controle total sobre as próprias informações. A diferença entre as duas
351 +
reside no propósito: a exportação devolve a totalidade dos dados em um
352 +
formato legível e processável, enquanto o relatório condensa um período
353 +
específico em um documento pronto para ser guardado ou compartilhado com
354 +
um profissional de saúde.
355 +
356 +
A exportação é acionada na tela de configurações, na opção ``Exportar
357 +
Meus Dados''. Antes de disparar o processo, o aplicativo pede uma
358 +
confirmação e informa que o arquivo será enviado para o e-mail cadastrado
359 +
--- um lembrete explícito de que aquela operação retira dados do
360 +
dispositivo e os entrega por um canal externo.
361 +
362 +
% TODO: adicionar screenshot da tela de exportação (modal de confirmação)
363 +
364 +
Do ponto de vista técnico, a solicitação enfileira um \textit{job}
365 +
assíncrono na mesma infraestrutura de filas descrita na
366 +
seção~\ref{sec:insights}. O \textit{worker} percorre todas as entidades
367 +
associadas ao usuário --- perfil, humores e seus componentes, sono,
368 +
gatilhos e seus vínculos, ações de cuidado, medicamentos, consultas,
369 +
atividades e os indicadores já calculados --- e serializa cada uma em um
370 +
arquivo CSV próprio, com os rótulos traduzidos e os valores enumerados
371 +
convertidos para texto legível. Os arquivos são compactados em um único
372 +
\textit{zip}, acompanhados de um manifesto com a data de geração, e o
373 +
pacote é anexado a um e-mail enviado ao usuário; em caso de falha, um
374 +
e-mail de erro é disparado em seu lugar. O resultado é uma exportação
375 +
completa e portátil: nenhum campo é omitido ou agregado, de modo que o
376 +
usuário pode reimportar os dados em outra ferramenta ou auditá-los
377 +
diretamente --- alinhado à exigência de portabilidade levantada nos
378 +
requisitos não funcionais.
379 +
380 +
O relatório, por sua vez, é gerado sob demanda a partir da tela
381 +
``Relatório'', acessível também pelas configurações. O usuário escolhe um
382 +
período --- por meio de atalhos de 7, 30 ou 90 dias, ou um intervalo
383 +
personalizado limitado a 90 dias --- e aciona ``Gerar relatório''.
384 +
385 +
\begin{figure}[H]
386 +
\centering
387 +
\caption{Tela de geração de relatório}
388 +
\label{fig:screenshot-report}
389 +
\includegraphics[width=0.70\textwidth,height=0.60\textheight,keepaspectratio]{screenshot-report-initial.png}
390 +
\source{Elaborado pelo autor (2026)}
391 +
\end{figure}
392 +
393 +
O servidor reúne os registros de humor do período e os renderiza em um
394 +
PDF intitulado ``Relatório de Humor e Bem-Estar'', produzido com
395 +
\textit{@react-pdf/renderer} e devolvido ao aplicativo, que o oferece
396 +
pela folha de compartilhamento nativa do sistema --- permitindo salvá-lo
397 +
ou enviá-lo a um terapeuta. O documento é dividido em duas páginas: a
398 +
primeira traz uma visão geral (total de \textit{check-ins}, humor
399 +
predominante e dias rastreados), a distribuição percentual de cada humor
400 +
e um mapa de calor diário; a segunda lista os sentimentos e emoções mais
401 +
frequentes, com a intensidade média, e o registro detalhado dos
402 +
\textit{check-ins}, com os níveis de ansiedade, estresse e energia.
403 +
404 +
Cabe registrar, contudo, que o relatório desta versão cobre apenas os
405 +
dados de humor. A intenção original era incorporar também o sono, as
406 +
atividades e os padrões de gatilho --- inclusive a correlação entre sono
407 +
e energia já calculada nos indicadores ---, mas essa ampliação foi
408 +
adiada para uma versão futura. Como consequência, o relatório atual
409 +
complementa, mas não substitui, a exportação integral: para uma visão
410 +
completa de todos os tipos de dado, o usuário ainda recorre ao
411 +
\textit{zip} de CSV.
340 412
341 413
\FloatBarrier
/dev/null → monografia/images/screenshot-report-initial.png
Binary file