frontend: add care actions to the history tab and write a new detail screen for those
Pedro Lucas Porcellis porcellis@eletrotupi.com · 4 months ago
cd24e52c4f9ec392235c1c0407fef8f18c191a40 Browse filesParents
6b006f2Changed files
11
Additions
+886
Deletions
-19
frontend/app/(tabs)/history.tsx
1 1
import { useState } from "react";
2 2
import { ScrollView, View, Text } from "react-native";
3 3
import { ScreenLayout } from "@/components/ui/ScreenLayout";
4 4
import { Section, SectionHeader } from "@/components/ui/Sections";
5 5
import { FilterPills } from "@/components/ui/FilterPills";
6 6
import { useAuth } from "@/context/AuthContext";
7 7
import { useHistoryFeed } from "@/hooks/useHistoryFeed";
8 -
import type { HistoryCategory } from "@/lib/history/types";
8 +
import type { HistoryCard, HistoryCategory } from "@/lib/history/types";
9 9
import { MoodHistoryCard } from "@/components/history/MoodHistoryCard";
10 10
import { SleepRecordCard } from "@/components/history/SleepRecordCard";
11 11
import { TriggerHistoryCard } from "@/components/history/TriggerCard";
12 -
13 -
const FILTERS: { label: string; value: HistoryCategory | "all" }[] = [
14 -
{ label: "Tudo", value: "all" },
15 -
{ label: "Humor", value: "mood" },
16 -
{ label: "Sono", value: "sleep" },
17 -
{ label: "Gatilho", value: "trigger" },
18 -
];
12 +
import { MedicineHistoryCard } from "@/components/history/MedicineHistoryCard";
13 +
import { AppointmentHistoryCard } from "@/components/history/AppointmentHistoryCard";
14 +
import { ActivityHistoryCard } from "@/components/history/ActivityHistoryCard";
19 15
20 16
function HistoryCardRenderer({ card }: { card: HistoryCard }) {
21 17
switch (card.category) {
22 18
case "mood":
23 19
return <MoodHistoryCard card={card as any} />;
24 20
case "sleep":
25 21
return <SleepRecordCard card={card as any} />;
26 22
case "trigger":
27 23
return <TriggerHistoryCard card={card as any} />;
24 +
case "care_action":
25 +
switch (card.subtype) {
26 +
case "medicine":
27 +
return <MedicineHistoryCard card={card as any} />;
28 +
case "appointment":
29 +
return <AppointmentHistoryCard card={card as any} />;
30 +
case "activity":
31 +
return <ActivityHistoryCard card={card as any} />;
32 +
default:
33 +
return null;
34 +
}
28 35
}
29 36
}
30 37
31 38
export default function History() {
32 39
const { user } = useAuth();
56 63
))}
57 64
</ScrollView>
58 65
</ScreenLayout>
59 66
);
60 67
}
/dev/null → frontend/app/care-actions/[id].tsx
1 +
import { useState, useEffect } from "react";
2 +
import {
3 +
StyleSheet,
4 +
Text,
5 +
View,
6 +
ScrollView,
7 +
Pressable,
8 +
ActivityIndicator,
9 +
} from "react-native";
10 +
import { useLocalSearchParams, router, Stack } from "expo-router";
11 +
import { MaterialIcons, Ionicons } from "@expo/vector-icons";
12 +
import { Card, SubtleInfoCard } from "@/components/ui/Cards";
13 +
import { Button } from "@/components/ui/Button";
14 +
import { Col, Row } from "@/components/ui/LayoutHelpers";
15 +
import { Section, SectionHeader } from "@/components/ui/Sections";
16 +
import { Badge } from "@/components/ui/Badge";
17 +
import { Typography, Theme, Colors, Spacing } from "@/constants/theme";
18 +
import {
19 +
useCareAction,
20 +
useDeleteCareAction,
21 +
} from "@/hooks/useCareAction.queries";
22 +
import { useTrigger } from "@/hooks/useTrigger.queries";
23 +
import { useMoodEntry } from "@/hooks/useMoodEntries.queries";
24 +
import { getActivityCategory } from "@/constants/care-action-categories";
25 +
import { getTriggerCategory } from "@/constants/trigger-categories";
26 +
import { getMood } from "@/constants/moods";
27 +
import { formatMoment } from "@/lib/utils/time";
28 +
import type { CareAction } from "@/lib/api";
29 +
30 +
// TODO: Generalize these into a single source of truth, similar to other
31 +
// constants
32 +
33 +
// label helpers
34 +
const APPOINTMENT_LABELS: Record<string, string> = {
35 +
ANALYST: "Analista",
36 +
PSYCHIATRIST: "Psiquiatra",
37 +
GP: "Clínico geral",
38 +
NUTRITIONIST: "Nutricionista",
39 +
PHYSIOTHERAPIST: "Fisioterapeuta",
40 +
OTHER: "Consulta",
41 +
};
42 +
43 +
const ACTIVITY_LABELS: Record<string, string> = {
44 +
WALK: "Caminhada",
45 +
YOGA: "Yoga",
46 +
GYM: "Academia",
47 +
MEDITATION: "Meditação",
48 +
SOCIAL: "Momento social",
49 +
CREATIVE: "Atividade criativa",
50 +
OTHER: "Atividade",
51 +
};
52 +
53 +
const PERIODICITY_LABELS: Record<string, string> = {
54 +
ONCE: "Dose única",
55 +
DAILY: "1× ao dia",
56 +
TWICE_DAILY: "2× ao dia",
57 +
THREE_TIMES_DAILY: "3× ao dia",
58 +
WEEKLY: "1× por semana",
59 +
BIWEEKLY: "2× por semana",
60 +
MONTHLY: "1× por mês",
61 +
};
62 +
63 +
// header meta
64 +
type HeaderMeta = {
65 +
icon: string;
66 +
color: string;
67 +
iconBg: string;
68 +
title: string;
69 +
};
70 +
71 +
function resolveHeader(ca: CareAction): HeaderMeta {
72 +
switch (ca.type) {
73 +
case "MEDICINE":
74 +
return {
75 +
icon: "medication",
76 +
color: "#7C3AED",
77 +
iconBg: "#EDE9FE",
78 +
title: ca.medicineLog?.regimen?.name ?? "Medicamento",
79 +
};
80 +
case "APPOINTMENT":
81 +
return {
82 +
icon: "psychology",
83 +
color: "#1D4ED8",
84 +
iconBg: "#DBEAFE",
85 +
title: `Consulta · ${APPOINTMENT_LABELS[ca.appointment?.type ?? ""] ?? "Consulta"}`,
86 +
};
87 +
case "ACTIVITY": {
88 +
const meta = getActivityCategory(ca.activity?.type);
89 +
return {
90 +
icon: meta.icon,
91 +
color: meta.color,
92 +
iconBg: meta.iconBackground,
93 +
title: ACTIVITY_LABELS[ca.activity?.type ?? ""] ?? "Atividade",
94 +
};
95 +
}
96 +
default:
97 +
return {
98 +
icon: "star",
99 +
color: "#6B7280",
100 +
iconBg: "#F3F4F6",
101 +
title: "Ação de cuidado",
102 +
};
103 +
}
104 +
}
105 +
106 +
// DetailRow
107 +
// TODO: Might as well generalize this into a component
108 +
type DetailRowProps = {
109 +
icon: string;
110 +
label: string;
111 +
value: string;
112 +
};
113 +
114 +
function DetailRow({ icon, label, value }: DetailRowProps) {
115 +
return (
116 +
<Row style={styles.detailRow}>
117 +
<View style={styles.detailIconWrap}>
118 +
<MaterialIcons name={icon as any} size={18} color="#6366F1" />
119 +
</View>
120 +
<Col gap={1}>
121 +
<Text style={styles.detailLabel}>{label}</Text>
122 +
<Text style={styles.detailValue}>{value}</Text>
123 +
</Col>
124 +
</Row>
125 +
);
126 +
}
127 +
128 +
export default function CareActionDetailScreen() {
129 +
const { id } = useLocalSearchParams<{ id: string }>();
130 +
const { data: careAction, isLoading, isError } = useCareAction(id);
131 +
const deleteCareAction = useDeleteCareAction();
132 +
133 +
const triggerId = String(careAction?.triggerId ?? "");
134 +
const moodId = String(careAction?.moodId ?? "");
135 +
const { data: trigger } = useTrigger(triggerId);
136 +
const { data: mood } = useMoodEntry(moodId);
137 +
138 +
const [minutesLeft, setMinutesLeft] = useState(0);
139 +
const [canDelete, setCanDelete] = useState(false);
140 +
141 +
useEffect(() => {
142 +
const updateTimer = () => {
143 +
const createdAt = careAction?.createdAt;
144 +
if (!createdAt) return;
145 +
146 +
const msSinceCreation = Date.now() - new Date(createdAt).getTime();
147 +
const windowMs = 5 * 60 * 1000;
148 +
149 +
setCanDelete(msSinceCreation < windowMs);
150 +
setMinutesLeft(
151 +
Math.max(0, Math.ceil((windowMs - msSinceCreation) / 60000)),
152 +
);
153 +
};
154 +
155 +
if (isLoading) return;
156 +
updateTimer();
157 +
const interval = setInterval(updateTimer, 1000);
158 +
return () => clearInterval(interval);
159 +
}, [careAction, isLoading]);
160 +
161 +
if (isLoading) {
162 +
return (
163 +
<View style={styles.centered}>
164 +
<ActivityIndicator size="large" color={Colors.light.tint} />
165 +
</View>
166 +
);
167 +
}
168 +
169 +
if (isError || !careAction) {
170 +
return (
171 +
<View style={styles.centered}>
172 +
<Text style={styles.errorText}>Registro não encontrado.</Text>
173 +
<Pressable onPress={() => router.back()} style={styles.backButton}>
174 +
<Text style={styles.backButtonText}>Voltar</Text>
175 +
</Pressable>
176 +
</View>
177 +
);
178 +
}
179 +
180 +
const header = resolveHeader(careAction);
181 +
182 +
// TODO: Componentize this as well? It's on each of the details screens
183 +
const deleteButtonMsg = () => {
184 +
const suffix = canDelete
185 +
? `(Disponível por ${minutesLeft < 1 ? "<1" : minutesLeft}:00)`
186 +
: "(Expirado)";
187 +
return `Excluir Registro ${suffix}`;
188 +
};
189 +
190 +
const handleDelete = async () => {
191 +
await deleteCareAction.mutateAsync(String(careAction.id));
192 +
router.back();
193 +
};
194 +
195 +
const hasLink = !!(careAction.triggerId || careAction.moodId);
196 +
197 +
return (
198 +
<>
199 +
<Stack.Screen options={{ title: "Ver detalhes" }} />
200 +
201 +
<ScrollView
202 +
style={styles.container}
203 +
contentContainerStyle={styles.content}
204 +
showsVerticalScrollIndicator={false}
205 +
>
206 +
<Card style={{ padding: Spacing.cardGap }}>
207 +
<Row style={{ alignItems: "center" }}>
208 +
<View style={[styles.iconWrap, { backgroundColor: header.iconBg }]}>
209 +
<MaterialIcons
210 +
name={header.icon as any}
211 +
size={22}
212 +
color={header.color}
213 +
/>
214 +
</View>
215 +
<Col gap={2}>
216 +
<Text style={styles.title}>{header.title}</Text>
217 +
<Text style={styles.subtitle}>
218 +
{formatMoment(new Date(careAction.moment))}
219 +
</Text>
220 +
</Col>
221 +
</Row>
222 +
</Card>
223 +
224 +
<Card style={styles.topCard}>
225 +
{careAction.type === "MEDICINE" && (
226 +
<>
227 +
{careAction.medicineLog?.regimen?.dosage ? (
228 +
<DetailRow
229 +
icon="medication"
230 +
label="Dosagem"
231 +
value={careAction.medicineLog.regimen.dosage}
232 +
/>
233 +
) : null}
234 +
{careAction.medicineLog?.regimen?.periodicity ? (
235 +
<DetailRow
236 +
icon="schedule"
237 +
label="Frequência"
238 +
value={
239 +
PERIODICITY_LABELS[
240 +
careAction.medicineLog.regimen.periodicity
241 +
] ?? careAction.medicineLog.regimen.periodicity
242 +
}
243 +
/>
244 +
) : null}
245 +
</>
246 +
)}
247 +
248 +
{careAction.type === "APPOINTMENT" && (
249 +
<>
250 +
<DetailRow
251 +
icon="psychology"
252 +
label="Tipo"
253 +
value={
254 +
APPOINTMENT_LABELS[careAction.appointment?.type ?? ""] ??
255 +
"Consulta"
256 +
}
257 +
/>
258 +
{careAction.appointment?.duration ? (
259 +
<DetailRow
260 +
icon="timer"
261 +
label="Duração"
262 +
value={`${careAction.appointment.duration} min`}
263 +
/>
264 +
) : null}
265 +
</>
266 +
)}
267 +
268 +
{careAction.type === "ACTIVITY" && (
269 +
<>
270 +
<DetailRow
271 +
icon={header.icon}
272 +
label="Tipo"
273 +
value="Atividade"
274 +
/>
275 +
{careAction.activity?.duration ? (
276 +
<DetailRow
277 +
icon="timer"
278 +
label="Duração"
279 +
value={`${careAction.activity.duration} min`}
280 +
/>
281 +
) : null}
282 +
</>
283 +
)}
284 +
</Card>
285 +
286 +
{careAction.appointment?.note ? (
287 +
<Section>
288 +
<SectionHeader title="Anotações" variant="subtle" />
289 +
<Card style={{ padding: Spacing.cardGap }}>
290 +
<Text style={styles.annotation}>
291 +
"{careAction.appointment.note.trim()}"
292 +
</Text>
293 +
</Card>
294 +
</Section>
295 +
) : null}
296 +
297 +
{hasLink && (
298 +
<Section>
299 +
<SectionHeader title="Vínculo" variant="subtle" />
300 +
<Col gap={8}>
301 +
{trigger && (
302 +
<Pressable
303 +
onPress={() =>
304 +
router.push(`/triggers/${trigger.id}`)
305 +
}
306 +
style={({ pressed }) => [pressed && { opacity: 0.7 }]}
307 +
>
308 +
<Card style={styles.linkCard}>
309 +
<Row style={{ alignItems: "center", gap: 12 }}>
310 +
{(() => {
311 +
const meta = getTriggerCategory(trigger.category);
312 +
return (
313 +
<View
314 +
style={[
315 +
styles.linkIconCircle,
316 +
{ backgroundColor: meta.iconBackground },
317 +
]}
318 +
>
319 +
<MaterialIcons
320 +
name={meta.icon as any}
321 +
size={20}
322 +
color={meta.color}
323 +
/>
324 +
</View>
325 +
);
326 +
})()}
327 +
<Col gap={2} style={{ flex: 1 }}>
328 +
<Row style={{ alignItems: "center", gap: 6 }}>
329 +
<Text style={styles.linkLabel}>
330 +
{getTriggerCategory(trigger.category).label}
331 +
</Text>
332 +
<Badge label="Gatilho" variant="yellow" style={undefined} />
333 +
</Row>
334 +
<Text style={styles.linkTime}>
335 +
{formatMoment(new Date(trigger.moment))}
336 +
</Text>
337 +
</Col>
338 +
<Ionicons
339 +
name="chevron-forward"
340 +
size={16}
341 +
color={Colors.light.disabled}
342 +
/>
343 +
</Row>
344 +
</Card>
345 +
</Pressable>
346 +
)}
347 +
348 +
{mood && (
349 +
<Pressable
350 +
onPress={() =>
351 +
router.push(`/entry/${mood.id}`)
352 +
}
353 +
style={({ pressed }) => [pressed && { opacity: 0.7 }]}
354 +
>
355 +
<Card style={styles.linkCard}>
356 +
<Row style={{ alignItems: "center", gap: 12 }}>
357 +
<View style={styles.linkIconCircle}>
358 +
<Text style={styles.linkEmoji}>
359 +
{getMood(mood.selectedMood.toLowerCase())?.icon ?? "😐"}
360 +
</Text>
361 +
</View>
362 +
<Col gap={2} style={{ flex: 1 }}>
363 +
<Row style={{ alignItems: "center", gap: 6 }}>
364 +
<Text style={styles.linkLabel}>
365 +
{getMood(mood.selectedMood.toLowerCase())?.label ?? mood.selectedMood}
366 +
</Text>
367 +
<Badge label="Humor" variant="yellow" style={undefined} />
368 +
</Row>
369 +
<Text style={styles.linkTime}>
370 +
{formatMoment(new Date(mood.moment))}
371 +
</Text>
372 +
</Col>
373 +
<Ionicons
374 +
name="chevron-forward"
375 +
size={16}
376 +
color={Colors.light.disabled}
377 +
/>
378 +
</Row>
379 +
</Card>
380 +
</Pressable>
381 +
)}
382 +
</Col>
383 +
</Section>
384 +
)}
385 +
386 +
<View style={styles.section}>
387 +
<Text style={styles.sectionLabel}>GESTÃO DO REGISTRO</Text>
388 +
<Button
389 +
variant="danger"
390 +
loading={deleteCareAction.isPending}
391 +
disabled={!canDelete || deleteCareAction.isPending}
392 +
onPress={handleDelete}
393 +
title={deleteButtonMsg()}
394 +
/>
395 +
396 +
<SubtleInfoCard
397 +
style={styles.deleteInfo}
398 +
text={`
399 +
A exclusão de registros é permitida apenas nos primeiros 5 minutos
400 +
após o envio para garantir a integridade do histórico emocional.
401 +
`}
402 +
/>
403 +
</View>
404 +
</ScrollView>
405 +
</>
406 +
);
407 +
}
408 +
409 +
const styles = StyleSheet.create({
410 +
container: {
411 +
flex: 1,
412 +
backgroundColor: Colors.light.background,
413 +
},
414 +
content: {
415 +
padding: Spacing.containerPadding,
416 +
gap: Spacing.sectionGap,
417 +
paddingBottom: 48,
418 +
},
419 +
centered: {
420 +
flex: 1,
421 +
justifyContent: "center",
422 +
alignItems: "center",
423 +
gap: 12,
424 +
},
425 +
errorText: {
426 +
fontSize: 15,
427 +
color: Colors.light.textSecondary,
428 +
},
429 +
backButton: {
430 +
paddingHorizontal: 20,
431 +
paddingVertical: 10,
432 +
backgroundColor: Colors.light.tint,
433 +
borderRadius: Theme.borderRadius.md,
434 +
},
435 +
backButtonText: {
436 +
fontWeight: "700",
437 +
color: Colors.light.text,
438 +
},
439 +
440 +
// Top card
441 +
topCard: {
442 +
padding: 16,
443 +
gap: 0,
444 +
},
445 +
iconWrap: {
446 +
width: 40,
447 +
height: 40,
448 +
borderRadius: 20,
449 +
alignItems: "center",
450 +
justifyContent: "center",
451 +
marginTop: 1,
452 +
},
453 +
title: {
454 +
...Typography.headlineLg,
455 +
color: Colors.light.text,
456 +
},
457 +
subtitle: {
458 +
fontSize: 13,
459 +
color: Colors.light.textSecondary,
460 +
},
461 +
divider: {
462 +
height: 1,
463 +
backgroundColor: Colors.light.divider,
464 +
marginVertical: 12,
465 +
},
466 +
467 +
// Detail rows
468 +
detailRow: {
469 +
alignItems: "center",
470 +
gap: 10,
471 +
paddingVertical: 6,
472 +
},
473 +
detailIconWrap: {
474 +
width: 32,
475 +
height: 32,
476 +
borderRadius: 8,
477 +
backgroundColor: "#EEF2FF",
478 +
alignItems: "center",
479 +
justifyContent: "center",
480 +
},
481 +
detailLabel: {
482 +
fontSize: 11,
483 +
color: Colors.light.textSecondary,
484 +
textTransform: "uppercase",
485 +
letterSpacing: 0.5,
486 +
},
487 +
detailValue: {
488 +
fontSize: 14,
489 +
fontWeight: "700",
490 +
color: Colors.light.text,
491 +
},
492 +
493 +
// Link cards
494 +
linkCard: {
495 +
padding: Spacing.cardGap,
496 +
},
497 +
linkIconCircle: {
498 +
width: 40,
499 +
height: 40,
500 +
borderRadius: 20,
501 +
alignItems: "center",
502 +
justifyContent: "center",
503 +
backgroundColor: "#F3F4F6",
504 +
},
505 +
linkLabel: {
506 +
fontSize: 15,
507 +
fontWeight: "700",
508 +
color: Colors.light.text,
509 +
},
510 +
linkTime: {
511 +
fontSize: 12,
512 +
color: Colors.light.textSecondary,
513 +
},
514 +
linkEmoji: {
515 +
fontSize: 22,
516 +
},
517 +
518 +
// Management
519 +
section: {
520 +
gap: 8,
521 +
},
522 +
sectionLabel: {
523 +
fontSize: 11,
524 +
fontWeight: "600",
525 +
letterSpacing: 0.8,
526 +
color: Colors.light.textSecondary,
527 +
textTransform: "uppercase",
528 +
},
529 +
deleteInfo: {
530 +
marginTop: 4,
531 +
},
532 +
533 +
// Annotation
534 +
annotation: {
535 +
fontSize: 14,
536 +
lineHeight: 22,
537 +
color: Colors.light.textSecondary,
538 +
fontStyle: "italic",
539 +
padding: 4,
540 +
},
541 +
});
/dev/null → frontend/components/history/ActivityHistoryCard.tsx
1 +
import { Text, View, StyleSheet } from "react-native";
2 +
import { MaterialIcons } from "@expo/vector-icons";
3 +
import { Card } from "@/components/ui/Cards";
4 +
import type { HistoryCard } from "@/lib/history/types";
5 +
import type { CareAction } from "@/lib/api";
6 +
import { Spacing } from "@/constants/theme";
7 +
import { getActivityCategory } from "@/constants/care-action-categories";
8 +
import { router } from "expo-router";
9 +
10 +
type Props = { card: HistoryCard & { raw: CareAction } };
11 +
12 +
export function ActivityHistoryCard({ card }: Props) {
13 +
const meta = getActivityCategory(card.raw.activity?.type);
14 +
const time = new Date(card.timestamp).toLocaleTimeString("pt-BR", {
15 +
hour: "2-digit",
16 +
minute: "2-digit",
17 +
});
18 +
19 +
return (
20 +
<Card
21 +
style={styles.card}
22 +
onPress={() => (router.push as any)(`/care-actions/${card.raw.id}`)}
23 +
>
24 +
<View style={styles.row}>
25 +
<View style={[styles.iconWrap, { backgroundColor: meta.iconBackground }]}>
26 +
<MaterialIcons name={meta.icon as any} size={22} color={meta.color} />
27 +
</View>
28 +
<View style={styles.body}>
29 +
<View style={styles.titleRow}>
30 +
<Text style={styles.title}>{card.title}</Text>
31 +
<Text style={styles.time}>{time}</Text>
32 +
</View>
33 +
<Text style={styles.summary} numberOfLines={2}>
34 +
{card.summary}
35 +
</Text>
36 +
</View>
37 +
</View>
38 +
</Card>
39 +
);
40 +
}
41 +
42 +
const styles = StyleSheet.create({
43 +
card: { padding: Spacing.cardGap },
44 +
row: { flexDirection: "row", alignItems: "flex-start", gap: 12 },
45 +
iconWrap: {
46 +
width: 40,
47 +
height: 40,
48 +
borderRadius: 20,
49 +
alignItems: "center",
50 +
justifyContent: "center",
51 +
marginTop: 1,
52 +
},
53 +
body: { flex: 1 },
54 +
titleRow: {
55 +
flexDirection: "row",
56 +
justifyContent: "space-between",
57 +
alignItems: "center",
58 +
marginBottom: 2,
59 +
},
60 +
title: { fontSize: 15, fontWeight: "600" },
61 +
time: { fontSize: 13, opacity: 0.45 },
62 +
summary: { fontSize: 13, opacity: 0.6, lineHeight: 18 },
63 +
});
/dev/null → frontend/components/history/AppointmentHistoryCard.tsx
1 +
import { Text, View, StyleSheet } from "react-native";
2 +
import { MaterialIcons } from "@expo/vector-icons";
3 +
import { Card } from "@/components/ui/Cards";
4 +
import { Badge } from "@/components/ui/Badge";
5 +
import type { HistoryCard } from "@/lib/history/types";
6 +
import type { CareAction } from "@/lib/api";
7 +
import { Spacing } from "@/constants/theme";
8 +
import { router } from "expo-router";
9 +
10 +
type Props = { card: HistoryCard & { raw: CareAction } };
11 +
12 +
export function AppointmentHistoryCard({ card }: Props) {
13 +
const time = new Date(card.timestamp).toLocaleTimeString("pt-BR", {
14 +
hour: "2-digit",
15 +
minute: "2-digit",
16 +
});
17 +
18 +
return (
19 +
<Card
20 +
style={styles.card}
21 +
onPress={() => (router.push as any)(`/care-actions/${card.raw.id}`)}
22 +
>
23 +
<View style={styles.row}>
24 +
<View style={[styles.iconWrap, styles.iconBg]}>
25 +
<MaterialIcons name="psychology" size={22} color="#1D4ED8" />
26 +
</View>
27 +
<View style={styles.body}>
28 +
<View style={styles.titleRow}>
29 +
<Text style={styles.title}>{card.title}</Text>
30 +
<Text style={styles.time}>{time}</Text>
31 +
</View>
32 +
{card.summary ? (
33 +
<Text style={styles.summary} numberOfLines={2}>
34 +
{card.summary}
35 +
</Text>
36 +
) : null}
37 +
{card.badge ? (
38 +
<Badge
39 +
label={card.badge.label}
40 +
variant={card.badge.variant as any}
41 +
style={styles.badge}
42 +
/>
43 +
) : null}
44 +
</View>
45 +
</View>
46 +
</Card>
47 +
);
48 +
}
49 +
50 +
const styles = StyleSheet.create({
51 +
card: { padding: Spacing.cardGap },
52 +
row: { flexDirection: "row", alignItems: "flex-start", gap: 12 },
53 +
iconWrap: {
54 +
width: 40,
55 +
height: 40,
56 +
borderRadius: 20,
57 +
alignItems: "center",
58 +
justifyContent: "center",
59 +
marginTop: 1,
60 +
},
61 +
iconBg: { backgroundColor: "#DBEAFE" },
62 +
body: { flex: 1 },
63 +
titleRow: {
64 +
flexDirection: "row",
65 +
justifyContent: "space-between",
66 +
alignItems: "center",
67 +
marginBottom: 2,
68 +
},
69 +
title: { fontSize: 15, fontWeight: "600" },
70 +
time: { fontSize: 13, opacity: 0.45 },
71 +
summary: { fontSize: 13, opacity: 0.6, lineHeight: 18, marginBottom: 6 },
72 +
badge: { alignSelf: "flex-start", marginTop: 4 },
73 +
});
/dev/null → frontend/components/history/MedicineHistoryCard.tsx
1 +
import { Text, View, StyleSheet } from "react-native";
2 +
import { MaterialIcons } from "@expo/vector-icons";
3 +
import { Card } from "@/components/ui/Cards";
4 +
import type { HistoryCard } from "@/lib/history/types";
5 +
import type { CareAction } from "@/lib/api";
6 +
import { Spacing } from "@/constants/theme";
7 +
import { router } from "expo-router";
8 +
9 +
type Props = { card: HistoryCard & { raw: CareAction } };
10 +
11 +
export function MedicineHistoryCard({ card }: Props) {
12 +
const time = new Date(card.timestamp).toLocaleTimeString("pt-BR", {
13 +
hour: "2-digit",
14 +
minute: "2-digit",
15 +
});
16 +
17 +
return (
18 +
<Card
19 +
style={styles.card}
20 +
onPress={() => (router.push as any)(`/care-actions/${card.raw.id}`)}
21 +
>
22 +
<View style={styles.row}>
23 +
<View style={[styles.iconWrap, styles.iconBg]}>
24 +
<MaterialIcons name="medication" size={22} color="#7C3AED" />
25 +
</View>
26 +
<View style={styles.body}>
27 +
<View style={styles.titleRow}>
28 +
<Text style={styles.title}>{card.title}</Text>
29 +
<Text style={styles.time}>{time}</Text>
30 +
</View>
31 +
<Text style={styles.summary} numberOfLines={2}>
32 +
{card.summary}
33 +
</Text>
34 +
</View>
35 +
</View>
36 +
</Card>
37 +
);
38 +
}
39 +
40 +
const styles = StyleSheet.create({
41 +
card: { padding: Spacing.cardGap },
42 +
row: { flexDirection: "row", alignItems: "flex-start", gap: 12 },
43 +
iconWrap: {
44 +
width: 40,
45 +
height: 40,
46 +
borderRadius: 20,
47 +
alignItems: "center",
48 +
justifyContent: "center",
49 +
marginTop: 1,
50 +
},
51 +
iconBg: { backgroundColor: "#EDE9FE" },
52 +
body: { flex: 1 },
53 +
titleRow: {
54 +
flexDirection: "row",
55 +
justifyContent: "space-between",
56 +
alignItems: "center",
57 +
marginBottom: 2,
58 +
},
59 +
title: { fontSize: 15, fontWeight: "600" },
60 +
time: { fontSize: 13, opacity: 0.45 },
61 +
summary: { fontSize: 13, opacity: 0.6, lineHeight: 18 },
62 +
});
frontend/components/ui/FilterPills.tsx
1 1
import { useRef } from "react";
2 2
import {
3 3
ScrollView,
4 4
TouchableOpacity,
5 5
Text,
19 19
export const HISTORY_FILTERS: FilterOption[] = [
20 20
{ label: "Tudo", value: "all" },
21 21
{ label: "Humor", value: "mood" },
22 22
{ label: "Sono", value: "sleep" },
23 23
{ label: "Gatilho", value: "trigger" },
24 +
{ label: "Cuidado", value: "care_action" },
24 25
];
25 26
26 27
type Props = {
27 28
active: HistoryCategory | "all";
28 29
onChange: (value: HistoryCategory | "all") => void;
108 109
top: 0,
109 110
bottom: 0,
110 111
width: 48,
111 112
},
112 113
});
/dev/null → frontend/constants/care-action-categories.ts
1 +
export type CareActionMeta = {
2 +
icon: string;
3 +
color: string;
4 +
iconBackground: string;
5 +
label: string;
6 +
};
7 +
8 +
export const ACTIVITY_CATEGORIES: Record<string, CareActionMeta> = {
9 +
WALK: { icon: "directions-walk", color: "#059669", iconBackground: "#D1FAE5", label: "Caminhada" },
10 +
YOGA: { icon: "self-improvement", color: "#7C3AED", iconBackground: "#EDE9FE", label: "Yoga" },
11 +
GYM: { icon: "fitness-center", color: "#DC2626", iconBackground: "#FEE2E2", label: "Academia" },
12 +
MEDITATION: { icon: "self-improvement", color: "#0891B2", iconBackground: "#CFFAFE", label: "Meditação" },
13 +
SOCIAL: { icon: "people", color: "#D97706", iconBackground: "#FEF3C7", label: "Social" },
14 +
CREATIVE: { icon: "brush", color: "#DB2777", iconBackground: "#FCE7F3", label: "Criativo" },
15 +
OTHER: { icon: "star", color: "#6B7280", iconBackground: "#F3F4F6", label: "Atividade" },
16 +
};
17 +
18 +
export function getActivityCategory(type?: string): CareActionMeta {
19 +
return ACTIVITY_CATEGORIES[type ?? "OTHER"] ?? ACTIVITY_CATEGORIES.OTHER;
20 +
}
frontend/hooks/useHistoryFeed.ts
1 -
// hooks/useHistoryFeed.ts
2 1
import { useMemo } from "react";
3 2
import {
4 3
mergeAndSort,
5 4
filterByCategory,
6 5
groupByDate,
7 6
} from "@/lib/utils/history";
8 7
import {
9 8
mapMoodToHistoryCard,
10 9
mapSleepToHistoryCard,
11 10
mapTriggerToHistoryCard,
11 +
mapCareActionToHistoryCard,
12 12
} from "@/lib/history/mappers";
13 13
import type { HistoryCategory } from "@/lib/history/types";
14 14
import { useMoodEntries, useSleepRecords, useTriggers } from "@/hooks";
15 +
import { useCareActions } from "@/hooks/useCareAction.queries";
15 16
16 17
export function useHistoryFeed(activeFilter: HistoryCategory | "all") {
17 18
const { data: moodsPage } = useMoodEntries({});
18 19
const { data: sleepPage } = useSleepRecords({});
19 20
const { data: triggersPage } = useTriggers({});
21 +
const { data: careActionsPage } = useCareActions({});
20 22
21 23
const moods = moodsPage?.entries ?? [];
22 24
const sleepRecords = sleepPage?.entries ?? [];
23 25
const triggers = triggersPage?.entries ?? [];
26 +
const careActions = careActionsPage?.entries ?? [];
24 27
25 28
const grouped = useMemo(() => {
26 29
const merged = mergeAndSort([
27 30
moods.map(mapMoodToHistoryCard),
28 31
sleepRecords.map(mapSleepToHistoryCard),
29 32
triggers.map(mapTriggerToHistoryCard),
33 +
careActions.map(mapCareActionToHistoryCard),
30 34
]);
31 35
32 -
console.log("Sleep records", sleepRecords)
33 -
console.log("Merged data", merged)
34 -
35 36
const filtered = filterByCategory(merged, activeFilter);
36 37
return groupByDate(filtered);
37 -
}, [moods, sleepRecords, triggers, activeFilter]);
38 +
}, [moods, sleepRecords, triggers, careActions, activeFilter]);
38 39
39 -
// TODO: expose the isLoading here
40 40
return grouped;
41 41
}
frontend/lib/api/types.ts
1 1
export interface User {
2 2
id: number;
3 3
email: string;
4 4
firstName: string;
5 5
lastName?: string;
254 254
export interface MedicineLog {
255 255
id: number;
256 256
regimenId?: number;
257 257
careActionId: number;
258 258
takenAt: Date;
259 +
regimen?: MedicineRegimen;
259 260
}
260 261
261 262
export interface AppointmentDetail {
262 263
id: number;
263 264
careActionId: number;
352 353
353 354
export interface CareActionResponse { careAction: CareAction; }
354 355
export interface MedicineRegimenResponse { regimen: MedicineRegimen; }
355 356
356 357
export interface LinkMoodPayload { moodId: number; perceivedImpact: number; }
frontend/lib/history/mappers.ts
1 1
import type { HistoryCard } from "@/lib/history/types";
2 -
import type { MoodEntry, SleepRecord, Trigger } from "@/lib/api";
2 +
import type { MoodEntry, SleepRecord, Trigger, CareAction } from "@/lib/api";
3 3
import { getMood } from "@/constants/moods";
4 -
import { parse, setHours } from "date-fns";
4 +
5 5
6 6
export function mapMoodToHistoryCard(mood: MoodEntry): HistoryCard {
7 7
const moodLabel = getMood(mood.selectedMood);
8 8
const components = mood.moodComponents
9 9
.map((c) => c.component.charAt(0) + c.component.slice(1).toLowerCase())
65 65
summary: [record.category, record.comment].filter(Boolean).join(" · "),
66 66
badge: undefined,
67 67
raw: record,
68 68
};
69 69
}
70 +
71 +
function activityLabel(type?: string): string {
72 +
const labels: Record<string, string> = {
73 +
WALK: "Caminhada",
74 +
YOGA: "Yoga",
75 +
GYM: "Academia",
76 +
MEDITATION: "Meditação",
77 +
SOCIAL: "Momento social",
78 +
CREATIVE: "Atividade criativa",
79 +
OTHER: "Atividade",
80 +
};
81 +
return type ? (labels[type] ?? "Atividade") : "Atividade";
82 +
}
83 +
84 +
function appointmentLabel(type?: string): string {
85 +
const labels: Record<string, string> = {
86 +
ANALYST: "Analista",
87 +
PSYCHIATRIST: "Psiquiatra",
88 +
GP: "Clínico geral",
89 +
NUTRITIONIST: "Nutricionista",
90 +
PHYSIOTHERAPIST: "Fisioterapeuta",
91 +
OTHER: "Consulta",
92 +
};
93 +
return type ? (labels[type] ?? "Consulta") : "Consulta";
94 +
}
95 +
96 +
export function mapCareActionToHistoryCard(record: CareAction): HistoryCard {
97 +
switch (record.type) {
98 +
case "MEDICINE": {
99 +
const log = record.medicineLog;
100 +
const name = log?.regimen?.name ?? "Medicamento";
101 +
const dosage = log?.regimen?.dosage;
102 +
return {
103 +
id: `care_action-${record.id}`,
104 +
category: "care_action",
105 +
subtype: "medicine",
106 +
timestamp: record.moment.toString(),
107 +
title: name,
108 +
summary: dosage ?? "Sem dosagem registrada",
109 +
raw: record,
110 +
};
111 +
}
112 +
113 +
case "APPOINTMENT": {
114 +
const appt = record.appointment;
115 +
const parts = [
116 +
appt?.duration ? `${appt.duration} min` : null,
117 +
appt?.note ?? null,
118 +
].filter(Boolean);
119 +
const linkedCount =
120 +
(record.triggerId ? 1 : 0) + (record.moodId ? 1 : 0);
121 +
return {
122 +
id: `care_action-${record.id}`,
123 +
category: "care_action",
124 +
subtype: "appointment",
125 +
timestamp: record.moment.toString(),
126 +
title: `Consulta · ${appointmentLabel(appt?.type)}`,
127 +
summary: parts.join(" · ") || "Sem anotações",
128 +
badge:
129 +
linkedCount > 0
130 +
? {
131 +
label: `${linkedCount} vínculo${linkedCount > 1 ? "s" : ""}`,
132 +
variant: "info",
133 +
}
134 +
: undefined,
135 +
raw: record,
136 +
};
137 +
}
138 +
139 +
case "ACTIVITY": {
140 +
const act = record.activity;
141 +
return {
142 +
id: `care_action-${record.id}`,
143 +
category: "care_action",
144 +
subtype: "activity",
145 +
timestamp: record.moment.toString(),
146 +
title: activityLabel(act?.type),
147 +
summary: act?.duration
148 +
? `${act.duration} min`
149 +
: "Sem duração registrada",
150 +
raw: record,
151 +
};
152 +
}
153 +
154 +
default:
155 +
return {
156 +
id: `care_action-${record.id}`,
157 +
category: "care_action",
158 +
timestamp: record.moment.toString(),
159 +
title: "Ação de cuidado",
160 +
summary: "",
161 +
raw: record,
162 +
};
163 +
}
164 +
}
frontend/lib/history/types.ts
1 -
import type { MoodEntry, SleepRecord, Trigger } from "@/lib/api";
2 -
export type HistoryCategory = "mood" | "sleep" | "trigger" | "intervention";
1 +
import type { MoodEntry, SleepRecord, Trigger, CareAction } from "@/lib/api";
2 +
3 +
export type HistoryCategory = "mood" | "sleep" | "trigger" | "care_action";
4 +
5 +
export type CareActionSubtype = "medicine" | "appointment" | "activity";
3 6
4 7
export type HistoryBadge = {
5 8
label: string;
6 9
variant: "neutral" | "warning" | "success" | "danger" | "info";
7 10
};
8 11
9 12
export type HistoryCard = {
10 13
id: string; // "<category>-<id>" to avoid collisions
11 14
category: HistoryCategory;
15 +
subtype?: CareActionSubtype; // only set when category === "care_action"
12 16
timestamp: string; // ISO string — used for sorting and display
13 17
title: string;
14 18
summary: string;
15 19
badge?: HistoryBadge;
16 -
raw: MoodEntry | SleepRecord | Trigger;
20 +
raw: MoodEntry | SleepRecord | Trigger | CareAction;
17 21
};