eletrotupi / tcc/ commit / cd24e52

frontend: add care actions to the history tab and write a new detail screen for those

Pedro Lucas Porcellis porcellis@eletrotupi.com 1 month ago cd24e52c4f9ec392235c1c0407fef8f18c191a40
Parents: 6b006f2
11 file(s) changed
  • frontend/app/(tabs)/history.tsx +15 -8
  • frontend/app/care-actions/[id].tsx +541 -0
  • frontend/components/history/ActivityHistoryCard.tsx +63 -0
  • frontend/components/history/AppointmentHistoryCard.tsx +73 -0
  • frontend/components/history/MedicineHistoryCard.tsx +62 -0
  • frontend/components/ui/FilterPills.tsx +1 -0
  • frontend/constants/care-action-categories.ts +20 -0
  • frontend/hooks/useHistoryFeed.ts +6 -6
  • frontend/lib/api/types.ts +1 -0
  • frontend/lib/history/mappers.ts +97 -2
  • frontend/lib/history/types.ts +7 -3
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();
▸ 23 unchanged lines
56 63
        ))}
57 64
      </ScrollView>
58 65
    </ScreenLayout>
59 66
  );
60 67
}
frontend/app/care-actions/[id].tsx
@@ -0,0 +1,541 @@
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 + });
frontend/components/history/ActivityHistoryCard.tsx
@@ -0,0 +1,63 @@
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 + });
frontend/components/history/AppointmentHistoryCard.tsx
@@ -0,0 +1,73 @@
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 + });
frontend/components/history/MedicineHistoryCard.tsx
@@ -0,0 +1,62 @@
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,
▸ 13 unchanged lines
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;
▸ 79 unchanged lines
108 109
    top: 0,
109 110
    bottom: 0,
110 111
    width: 48,
111 112
  },
112 113
});
frontend/constants/care-action-categories.ts
@@ -0,0 +1,20 @@
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;
▸ 248 unchanged lines
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;
▸ 88 unchanged lines
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())
▸ 55 unchanged lines
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
};