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
Parents:
bdaa1919 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';