eletrotupi / tcc/ commit / 23893a7

frontend: link mood and triggers with care actions

introduce a new zustand ephemeral store to allocate these with an
appointment, then consume these on their post-actions. It has a TTL of
30min, otherwise manual linkage is needed, albeit I have no fucking clue
where to shove that
Pedro Lucas Porcellis porcellis@eletrotupi.com 1 month ago 23893a7b41f6b4b4ee7958a5d186afebd3f12827
Parents: bdaa191
9 file(s) changed
  • frontend/app/(tabs)/new/entry.tsx +12 -3
  • frontend/app/care-actions/appointment.tsx +3 -1
  • frontend/app/care-actions/post-appointment.tsx +5 -1
  • frontend/app/triggers/new.tsx +15 -55
  • frontend/constants/care-action-categories.ts +34 -0
  • frontend/hooks/index.ts +1 -0
  • frontend/hooks/useCareAction.queries.ts +15 -0
  • frontend/stores/careActionLink.ts +63 -0
  • frontend/stores/index.ts +2 -3
frontend/app/(tabs)/new/entry.tsx
1 1
import { useState, useEffect } from "react";
2 2
import { Link, router, useLocalSearchParams } from "expo-router";
3 3

4 4
import { View, Text, StyleSheet, ScrollView } from "react-native";
5 5

▸ 9 unchanged lines
15 15
import { Section, SectionHeader } from "@/components/ui/Sections";
16 16
import { Spacing, Typography, Colors } from "@/constants/theme";
17 17
import { ScaleSlider } from "@/components/ui/ScaleSlider";
18 18
import { MoodComponentCard } from "@/components/ui/MoodComponentCard";
19 19
import { Ionicons } from "@expo/vector-icons";
20 -
import { useThemeColor, useCreateMoodEntry } from "@/hooks";
21 -
import { useMoodEntryStore } from "@/stores";
20 +
import { useThemeColor, useCreateMoodEntry, usePatchCareAction } from "@/hooks";
21 +
import { useMoodEntryStore, useCareActionLinkStore } from "@/stores";
22 22

23 23
import { MOODS, getMood } from "@/constants/moods";
24 24

25 25
import { intensityToValue } from "@/constants/mood-components";
26 26

▸ 11 unchanged lines
38 38

39 39
  const { selectedMood, setSelectedMood, components, reset } =
40 40
    useMoodEntryStore();
41 41

42 42
  const createMoodEntry = useCreateMoodEntry();
43 +
  const patchCareAction = usePatchCareAction();
43 44

44 45
  useEffect(() => {
45 46
    setSelectedMood(getMood(initialMood));
46 47
  }, [initialMood]);
47 48

▸ 14 unchanged lines
62 63
        intensity: intensityToValue(c.intensity),
63 64
      })),
64 65
    };
65 66

66 67
    const result = await createMoodEntry.mutateAsync(data);
67 -
    const moodId = result.mood.id;
68 +
    const moodId = (result as any).mood?.id ?? (result as any).moodEntry?.id;
69 +

70 +
    const careActionId = useCareActionLinkStore.getState().linkMood();
71 +
    if (careActionId && moodId) {
72 +
      await patchCareAction.mutateAsync({
73 +
        id: String(careActionId),
74 +
        data: { moodId },
75 +
      });
76 +
    }
68 77

69 78
    reset();
70 79

71 80
    router.replace(`/new/post-mood?moodId=${moodId}`);
72 81
  };
▸ 127 unchanged lines
200 209
  },
201 210
  resumeCard: {
202 211
    padding: Spacing.cardGap,
203 212
  },
204 213
});
frontend/app/care-actions/appointment.tsx
1 1
import {
2 2
  View,
3 3
  Text,
4 4
  StyleSheet,
5 5
  KeyboardAvoidingView,
▸ 10 unchanged lines
16 16
import { CategoryChips, CategoryOption } from "@/components/ui/CategoryChips";
17 17
import { Spacing, Typography, Colors, BorderRadius, Shadows } from "@/constants/theme";
18 18
import { Ionicons } from "@expo/vector-icons";
19 19
import { useCreateCareAction } from "@/hooks";
20 20
import { AppointmentType } from "@/lib/api";
21 +
import { useCareActionLinkStore } from "@/stores";
21 22

22 23
const APPOINTMENT_TYPES: CategoryOption<AppointmentType>[] = [
23 24
  { label: 'Analista', value: 'ANALYST', icon: <Ionicons name="person-outline" size={16} color={Colors.light.tint} /> },
24 25
  { label: 'Psiquiatra', value: 'PSYCHIATRIST', icon: <Ionicons name="medical-outline" size={16} color={Colors.light.tint} /> },
25 26
  { label: 'Clínico', value: 'GP', icon: <Ionicons name="fitness-outline" size={16} color={Colors.light.tint} /> },
▸ 9 unchanged lines
35 36
  const [appointmentNote, setAppointmentNote] = useState('');
36 37

37 38
  const createCareAction = useCreateCareAction();
38 39

39 40
  const handleSave = async () => {
40 -
    await createCareAction.mutateAsync({
41 +
    const result = await createCareAction.mutateAsync({
41 42
      type: 'APPOINTMENT',
42 43
      moment: new Date(),
43 44
      appointment: {
44 45
        type: appointmentType,
45 46
        duration: appointmentDuration,
46 47
        note: appointmentNote || undefined,
47 48
      },
48 49
    });
50 +
    useCareActionLinkStore.getState().setPending(result.careAction.id);
49 51
    router.replace('/care-actions/post-appointment');
50 52
  };
51 53

52 54
  return (
53 55
    <KeyboardAvoidingView behavior="height" style={styles.keyboardView}>
▸ 113 unchanged lines
167 169
  },
168 170
  durationLabelActive: {
169 171
    color: Colors.light.text,
170 172
  },
171 173
});
frontend/app/care-actions/post-appointment.tsx
1 1
import { View, Text, StyleSheet, TouchableOpacity, ScrollView } from "react-native";
2 2
import { router } from "expo-router";
3 3
import { Ionicons } from "@expo/vector-icons";
4 4
import { SafeAreaView } from "react-native-safe-area-context";
5 5

6 6
import { Button } from "@/components/ui/Button";
7 7
import { Card } from "@/components/ui/Cards";
8 8
import { Colors, Spacing, Typography, BorderRadius, Shadows } from "@/constants/theme";
9 +
import { useCareActionLinkStore } from "@/stores";
9 10

10 11
export default function PostAppointment() {
11 12
  return (
12 13
    <SafeAreaView style={styles.safe}>
13 14
      <ScrollView
▸ 65 unchanged lines
79 80
        </Card>
80 81

81 82
        <Button
82 83
          title="Concluir"
83 84
          variant="ghost"
84 -
          onPress={() => router.replace("/(tabs)/actions")}
85 +
          onPress={() => {
86 +
            useCareActionLinkStore.getState().clear();
87 +
            router.replace("/(tabs)/actions");
88 +
          }}
85 89
          style={styles.concludeButton}
86 90
        />
87 91
      </ScrollView>
88 92
    </SafeAreaView>
89 93
  );
▸ 90 unchanged lines
180 184
  },
181 185
  concludeButton: {
182 186
    marginTop: 8,
183 187
  },
184 188
});
frontend/app/triggers/new.tsx
1 1
import {
2 2
  View,
3 3
  Text,
4 4
  StyleSheet,
5 5
  KeyboardAvoidingView,
▸ 6 unchanged lines
12 12
import { TextArea } from "@/components/ui/Input";
13 13
import { Button } from "@/components/ui/Button";
14 14
import { Section, SectionHeader } from "@/components/ui/Sections";
15 15
import { Spacing, Typography, Colors, BorderRadius } from "@/constants/theme";
16 16
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
17 -
import { useCreateTrigger, useMoodEntries, useLinkMoodToTrigger } from "@/hooks";
17 +
import { useCreateTrigger, useLinkMoodToTrigger, usePatchCareAction } from "@/hooks";
18 +
import { useCareActionLinkStore } from "@/stores";
18 19
import { CategoryChips, CategoryOption } from "@/components/ui/CategoryChips";
19 20
import { TriggerCategory } from "@/constants/triggers";
20 21
import { TRIGGER_CATEGORY_DEFINITIONS } from "@/constants/trigger-categories";
21 22
import { TimePicker } from "@/components/ui/TimePicker";
22 -
import { getMood } from "@/constants/moods";
23 -
import { LinkRow } from "@/components/ui/LinkRow";
24 23

25 24
const TRIGGER_CATEGORIES: CategoryOption<TriggerCategory>[] =
26 25
  TRIGGER_CATEGORY_DEFINITIONS.map((d) => ({
27 26
    label: d.label,
28 27
    value: d.id,
▸ 1 unchanged lines
30 29
  }));
31 30

32 31
export default function NewTrigger() {
33 32
  const [moment, setMoment] = useState(new Date());
34 33
  const [comment, setComment] = useState("");
35 -
  const { category: initialCategory, moodId: linkedMoodId } = useLocalSearchParams<{ category?: string; moodId?: string }>();
34 +
  const { category: initialCategory, moodId: linkedMoodId } = 
35 +
    useLocalSearchParams<{ category?: string; moodId?: string }>();
36 +

36 37
  const [category, setCategory] = useState(initialCategory ?? "WORK");
37 38
  const createTrigger = useCreateTrigger();
38 39
  const linkMoodToTrigger = useLinkMoodToTrigger();
39 -
  const [linkMood, setLinkMood] = useState(false);
40 -
  const { data: moodEntries, isLoading: isLoadingMood } = useMoodEntries({
41 -
    limit: 1,
42 -
  });
43 -

44 -
  const latestMood = moodEntries?.entries[0] ?? null;
45 -
  const moodDef = latestMood
46 -
    ? getMood(latestMood.selectedMood.toLowerCase())
47 -
    : null;
40 +
  const patchCareAction = usePatchCareAction();
48 41

49 42
  const save = async () => {
50 43
    const data = {
51 44
      moment,
52 45
      comment,
53 -
      category,
54 -
      ...(linkMood && latestMood ? { moodId: latestMood.id } : null),
46 +
      category
55 47
    };
56 48

57 49
    const result = await createTrigger.mutateAsync(data);
50 +

51 +
    const careActionId = useCareActionLinkStore.getState().linkTrigger();
52 +
    if (careActionId) {
53 +
      await patchCareAction.mutateAsync({
54 +
        id: String(careActionId),
55 +
        data: { triggerId: result.trigger.id },
56 +
      });
57 +
    }
58 58

59 59
    if (linkedMoodId) {
60 60
      await linkMoodToTrigger.mutateAsync({
61 61
        triggerId: String(result.trigger.id),
62 62
        moodId: linkedMoodId,
▸ 55 unchanged lines
118 118
                minRows={5}
119 119
                maxRows={10}
120 120
                placeholder="Descreva o que gerou esse sentimento"
121 121
              />
122 122
            </Card>
123 -
          </Section>
124 -

125 -
          <Section>
126 -
            <SectionHeader title="Vincular registro" />
127 -

128 -
            <LinkRow
129 -
              icon={
130 -
                <View
131 -
                  style={[styles.iconWrap, { backgroundColor: "transparent" }]}
132 -
                >
133 -
                  <Text style={styles.moodIconEmoji}>
134 -
                    {moodDef?.icon ?? "😐"}
135 -
                  </Text>
136 -
                </View>
137 -
              }
138 -
              label="Humor atual"
139 -
              sublabel={
140 -
                isLoadingMood ? "..." : (moodDef?.label ?? "Nenhum registrado")
141 -
              }
142 -
              disabled={!latestMood}
143 -
              value={linkMood && !!latestMood}
144 -
              onToggle={setLinkMood}
145 -
            />
146 -

147 -
            <LinkRow
148 -
              icon={
149 -
                <MaterialIcons
150 -
                  name="psychology"
151 -
                  size={40}
152 -
                  color={Colors.light.disabled}
153 -
                />
154 -
              }
155 -
              label="Prática"
156 -
              sublabel={"Nenhuma registrada"}
157 -
              disabled={true}
158 -
              value={false}
159 -
              onToggle={() => {
160 -
                console.log("When we get that");
161 -
              }}
162 -
            />
163 123
          </Section>
164 124

165 125
          <Button title="Salvar Registro" onPress={save} />
166 126
        </View>
167 127
      </ScrollView>
▸ 52 unchanged lines
220 180
    alignItems: "center",
221 181
    justifyContent: "center",
222 182
    marginTop: 1,
223 183
  },
224 184
});
frontend/constants/care-action-categories.ts
1 +
import type { CareAction } from "@/lib/api/types";
2 +

1 3
export type CareActionMeta = {
2 4
  icon: string;
3 5
  color: string;
4 6
  iconBackground: string;
5 7
  label: string;
▸ 10 unchanged lines
16 18
};
17 19

18 20
export function getActivityCategory(type?: string): CareActionMeta {
19 21
  return ACTIVITY_CATEGORIES[type ?? "OTHER"] ?? ACTIVITY_CATEGORIES.OTHER;
20 22
}
23 +

24 +
const APPOINTMENT_LABELS: Record<string, string> = {
25 +
  ANALYST:       'Analista',
26 +
  PSYCHIATRIST:  'Psiquiatra',
27 +
  GP:            'Clínico',
28 +
  NUTRITIONIST:  'Nutricionista',
29 +
  PHYSIOTHERAPIST: 'Fisioterapeuta',
30 +
  OTHER:         'Consulta',
31 +
};
32 +

33 +
export function getCareActionMeta(ca: CareAction): CareActionMeta {
34 +
  switch (ca.type) {
35 +
    case 'APPOINTMENT':
36 +
      return {
37 +
        icon: 'psychology',
38 +
        color: '#1D4ED8',
39 +
        iconBackground: '#DBEAFE',
40 +
        label: APPOINTMENT_LABELS[ca.appointment?.type ?? ''] ?? 'Consulta',
41 +
      };
42 +
    case 'MEDICINE':
43 +
      return {
44 +
        icon: 'medication',
45 +
        color: '#7C3AED',
46 +
        iconBackground: '#EDE9FE',
47 +
        label: ca.medicineLog?.regimen?.name ?? 'Medicação',
48 +
      };
49 +
    case 'ACTIVITY':
50 +
      return getActivityCategory(ca.activity?.type);
51 +
    default:
52 +
      return { icon: 'star', color: '#6B7280', iconBackground: '#F3F4F6', label: 'Cuidado' };
53 +
  }
54 +
}
frontend/hooks/index.ts
1 1
// Legacy shit from react native
2 2
export { useThemeColor } from "@/hooks/use-theme-color";
3 3

4 4
export { useColorScheme } from "@/hooks/use-color-scheme";
5 5

▸ 35 unchanged lines
41 41
export {
42 42
  careActionKeys,
43 43
  useCareActions,
44 44
  useCareAction,
45 45
  useCreateCareAction,
46 +
  usePatchCareAction,
46 47
  useDeleteCareAction,
47 48
} from "@/hooks/useCareAction.queries";
frontend/hooks/useCareAction.queries.ts
1 1
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
2 2

3 3
import {
4 4
  apiClient,
5 5
  CareAction,
6 6
  CareActionType,
7 7
  CreateCareActionPayload,
8 +
  PatchCareActionPayload,
8 9
} from "@/lib/api";
9 10

10 11
interface CareActionFilters {
11 12
  type?: CareActionType;
12 13
  from?: string;
▸ 29 unchanged lines
42 43
  return useMutation({
43 44
    mutationFn: (payload: CreateCareActionPayload) =>
44 45
      apiClient.createCareAction(payload),
45 46

46 47
    onSuccess: () => {
48 +
      queryClient.invalidateQueries({ queryKey: careActionKeys.lists() });
49 +
    },
50 +
  });
51 +
};
52 +

53 +
export const usePatchCareAction = () => {
54 +
  const queryClient = useQueryClient();
55 +

56 +
  return useMutation({
57 +
    mutationFn: ({ id, data }: { id: string; data: PatchCareActionPayload }) =>
58 +
      apiClient.patchCareAction(id, data),
59 +

60 +
    onSuccess: (_result, { id }) => {
61 +
      queryClient.invalidateQueries({ queryKey: careActionKeys.detail(id) });
47 62
      queryClient.invalidateQueries({ queryKey: careActionKeys.lists() });
48 63
    },
49 64
  });
50 65
};
51 66

▸ 25 unchanged lines
77 92
    onSuccess: () => {
78 93
      queryClient.invalidateQueries({ queryKey: careActionKeys.lists() });
79 94
    },
80 95
  });
81 96
};
frontend/stores/careActionLink.ts
@@ -0,0 +1,63 @@
1 + import { create } from 'zustand';
2 +
3 + const TTL_MS = 30 * 60 * 1000;
4 +
5 + interface State {
6 + pendingId: number | null;
7 + expiresAt: number | null;
8 + triggerLinked: boolean;
9 + moodLinked: boolean;
10 + setPending: (id: number) => void;
11 + linkTrigger: () => number | null;
12 + linkMood: () => number | null;
13 + clear: () => void;
14 + }
15 +
16 + const isExpired = (expiresAt: number | null) =>
17 + !expiresAt || Date.now() > expiresAt;
18 +
19 + export const useCareActionLinkStore = create<State>((set, get) => ({
20 + pendingId: null,
21 + expiresAt: null,
22 + triggerLinked: false,
23 + moodLinked: false,
24 +
25 + setPending: (id) =>
26 + set({ pendingId: id, expiresAt: Date.now() + TTL_MS, triggerLinked: false, moodLinked: false }),
27 +
28 + linkTrigger: () => {
29 + const { pendingId, expiresAt, triggerLinked, moodLinked } = get();
30 + if (!pendingId || isExpired(expiresAt)) {
31 + set({ pendingId: null, expiresAt: null, triggerLinked: false, moodLinked: false });
32 + return null;
33 + }
34 + if (triggerLinked) return null;
35 +
36 + const newMoodLinked = moodLinked;
37 + if (newMoodLinked) {
38 + set({ pendingId: null, expiresAt: null, triggerLinked: false, moodLinked: false });
39 + } else {
40 + set({ triggerLinked: true });
41 + }
42 + return pendingId;
43 + },
44 +
45 + linkMood: () => {
46 + const { pendingId, expiresAt, triggerLinked, moodLinked } = get();
47 + if (!pendingId || isExpired(expiresAt)) {
48 + set({ pendingId: null, expiresAt: null, triggerLinked: false, moodLinked: false });
49 + return null;
50 + }
51 + if (moodLinked) return null;
52 +
53 + const newTriggerLinked = triggerLinked;
54 + if (newTriggerLinked) {
55 + set({ pendingId: null, expiresAt: null, triggerLinked: false, moodLinked: false });
56 + } else {
57 + set({ moodLinked: true });
58 + }
59 + return pendingId;
60 + },
61 +
62 + clear: () => set({ pendingId: null, expiresAt: null, triggerLinked: false, moodLinked: false }),
63 + }));
frontend/stores/index.ts
1 -
export {
2 -
  useMoodEntryStore
3 -
} from '@/stores/moodEntry';
1 +
export { useMoodEntryStore } from '@/stores/moodEntry';
2 +
export { useCareActionLinkStore } from '@/stores/careActionLink';