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 · 4 months ago cd24e52c4f9ec392235c1c0407fef8f18c191a40 Browse files
Parents 6b006f2
Changed 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
};