eletrotupi / tcc/ commit / d139cd1

frontend: display the link of mood and trigger

Pedro Lucas Porcellis porcellis@eletrotupi.com 1 month ago d139cd140eae38d7f5b7000fa39a09c78d163f07
Parents: 729fa78
8 file(s) changed
  • frontend/app/(tabs)/new/post-mood.tsx +12 -1
  • frontend/app/care-actions/post-appointment.tsx +0 -1
  • frontend/app/entry/[id].tsx +94 -0
  • frontend/app/triggers/[id].tsx +103 -1
  • frontend/components/misc/NoLinkMessage.tsx +50 -0
  • frontend/hooks/useTrigger.queries.ts +3 -1
  • frontend/lib/api/index.ts +3 -0
  • frontend/lib/api/types.ts +10 -0
frontend/app/(tabs)/new/post-mood.tsx
1 1
import { useState } from "react";
2 2
import {
3 3
  View,
4 4
  Text,
5 5
  StyleSheet,
6 6
  TouchableOpacity,
7 7
  Pressable,
8 8
  ActivityIndicator,
9 9
} from "react-native";
10 -
import { router, useLocalSearchParams } from "expo-router";
10 +
import { router, useLocalSearchParams, Link } from "expo-router";
11 11
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
12 12

13 13
import { ScreenLayout } from "@/components/ui/ScreenLayout";
14 14
import { Card } from "@/components/ui/Cards";
15 +
import { Section } from '@/components/ui/Sections';
15 16
import { Button } from "@/components/ui/Button";
16 17
import { useAuth } from "@/context/AuthContext";
17 18
import { Colors, Spacing, Typography, BorderRadius, Shadows } from "@/constants/theme";
18 19
import { getTriggerCategory } from "@/constants/trigger-categories";
19 20
import { useTriggers, useLinkMoodToTrigger } from "@/hooks";
▸ 175 unchanged lines
195 196
              )}
196 197
            </>
197 198
          )}
198 199
        </Card>
199 200
      )}
201 +

202 +
      <Button
203 +
        title="Pular essa etapa"
204 +
        variant="ghost"
205 +
        onPress={() => router.replace("/(tabs)/new")}
206 +
        style={styles.concludeButton}
207 +
      />
200 208
    </ScreenLayout>
201 209
  );
202 210
}
203 211

204 212
const styles = StyleSheet.create({
▸ 153 unchanged lines
358 366
    fontWeight: "600",
359 367
    color: Colors.light.tint,
360 368
  },
361 369
  moodList: {
362 370
    gap: 8,
371 +
  },
372 +
  concludeButton: {
373 +
    marginTop: 8,
363 374
  },
364 375
});
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

▸ 70 unchanged lines
76 76
              color={Colors.light.disabled}
77 77
            />
78 78
          </TouchableOpacity>
79 79
        </Card>
80 80

81 -
        {/* Concluir ghost button */}
82 81
        <Button
83 82
          title="Concluir"
84 83
          variant="ghost"
85 84
          onPress={() => router.replace("/(tabs)/actions")}
86 85
          style={styles.concludeButton}
▸ 94 unchanged lines
181 180
  },
182 181
  concludeButton: {
183 182
    marginTop: 8,
184 183
  },
185 184
});
frontend/app/entry/[id].tsx
1 1
import { useState, useEffect, use } from "react";
2 2
import {
3 3
  StyleSheet,
4 4
  Text,
5 5
  View,
▸ 4 unchanged lines
10 10
import { useLocalSearchParams, router, Stack } from "expo-router";
11 11
import { format } from "date-fns";
12 12
import { ptBR } from "date-fns/locale";
13 13

14 14
import { Card, SubtleInfoCard } from "@/components/ui/Cards";
15 +
import { NoLinkMessage } from "@/components/misc/NoLinkMessage";
15 16
import { Badge } from "@/components/ui/Badge";
16 17
import { Button } from "@/components/ui/Button";
17 18
import { Col, Between, Row } from "@/components/ui/LayoutHelpers";
18 19
import { Section, SectionHeader } from "@/components/ui/Sections";
19 20
import { Theme, Colors, Spacing } from "@/constants/theme";
▸ 4 unchanged lines
24 25
  intensityToValue,
25 26
} from "@/constants/mood-components";
26 27
import { useMoodEntry, useDeleteMoodEntry } from "@/hooks";
27 28
import { getTimeBadge, formatMoment } from "@/lib/utils/time";
28 29
import { MoodComponentCard } from "@/components/ui/MoodComponentCard";
30 +
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
31 +
import { getTriggerCategory } from "@/constants/trigger-categories";
32 +

33 +
const impactColor = (n: number) => {
34 +
  if (n <= 1) return '#86efac';
35 +
  if (n <= 2) return '#22c55e';
36 +
  if (n <= 3) return '#f59e0b';
37 +
  if (n <= 4) return '#f97316';
38 +
  return '#ef4444';
39 +
};
29 40

30 41
export default function MoodDetailScreen() {
31 42
  const { id } = useLocalSearchParams<{ id: string }>();
32 43
  const { data: entry, isLoading, isError } = useMoodEntry(id);
33 44
  const deleteMoodEntry = useDeleteMoodEntry();
▸ 142 unchanged lines
176 187
              );
177 188
            })}
178 189
          </Col>
179 190
        </Section>
180 191

192 +
        {/* Linked triggers */}
193 +
        <Section>
194 +
          <SectionHeader title="Vínculo" variant="subtle" />
195 +
          {entry.triggerLinks && entry.triggerLinks.length > 0 ? (
196 +
            <Col gap={8}>
197 +
              {entry.triggerLinks.map((link) => {
198 +
                const cat = getTriggerCategory(link.trigger.category);
199 +
                return (
200 +
                  <Pressable
201 +
                    key={link.id}
202 +
                    onPress={() => router.push(`/triggers/${link.trigger.id}`)}
203 +
                    style={({ pressed }) => [pressed && { opacity: 0.7 }]}
204 +
                  >
205 +
                    <Card style={styles.linkCard}>
206 +
                      <Row style={{ alignItems: "center", gap: 12 }}>
207 +
                        <View style={[styles.linkIconCircle, { backgroundColor: cat.iconBackground }]}>
208 +
                          <MaterialIcons name={cat.icon} size={22} color={cat.color} />
209 +
                        </View>
210 +
                        <Col gap={3} style={{ flex: 1 }}>
211 +
                          <Row style={{ alignItems: "center", gap: 6 }}>
212 +
                            <Text style={styles.linkLabel}>{cat.label}</Text>
213 +
                            <Badge label="Gatilho" variant="orange" style={undefined} />
214 +
                          </Row>
215 +
                          <Text style={styles.linkTime}>
216 +
                            {formatMoment(new Date(link.trigger.moment))}
217 +
                          </Text>
218 +
                        </Col>
219 +
                        <Ionicons name="chevron-forward" size={16} color={Colors.light.disabled} />
220 +
                      </Row>
221 +
                      <View style={styles.impactBar}>
222 +
                        <View
223 +
                          style={[
224 +
                            styles.impactFill,
225 +
                            {
226 +
                              width: `${(link.perceivedImpact / 5) * 100}%` as any,
227 +
                              backgroundColor: impactColor(link.perceivedImpact),
228 +
                            },
229 +
                          ]}
230 +
                        />
231 +
                      </View>
232 +
                    </Card>
233 +
                  </Pressable>
234 +
                );
235 +
              })}
236 +
            </Col>
237 +
          ) : (
238 +
            <NoLinkMessage />
239 +
          )}
240 +
        </Section>
241 +

181 242
        {/* Annotation / notes */}
182 243
        {entry.annotation && (
183 244
          <Section>
184 245
            <SectionHeader title="Anotações" variant="subtle" />
185 246
            <Card style={{ padding: Spacing.cardGap }}>
▸ 127 unchanged lines
313 374
    lineHeight: 22,
314 375
    color: Colors.light.textSecondary,
315 376
    fontStyle: "italic",
316 377
    padding: 4,
317 378
  },
379 +
  // Linked trigger cards
380 +
  linkCard: {
381 +
    padding: Spacing.cardGap,
382 +
    overflow: "hidden",
383 +
    gap: 10,
384 +
  },
385 +
  linkIconCircle: {
386 +
    width: 44,
387 +
    height: 44,
388 +
    borderRadius: 22,
389 +
    alignItems: "center",
390 +
    justifyContent: "center",
391 +
  },
392 +
  linkTime: {
393 +
    fontSize: 12,
394 +
    color: Colors.light.textSecondary,
395 +
  },
396 +
  linkLabel: {
397 +
    fontSize: 15,
398 +
    fontWeight: "700",
399 +
    color: Colors.light.text,
400 +
  },
401 +
  impactBar: {
402 +
    height: 3,
403 +
    backgroundColor: Colors.light.divider,
404 +
    borderRadius: 99,
405 +
    overflow: "hidden",
406 +
  },
407 +
  impactFill: {
408 +
    height: "100%",
409 +
    borderRadius: 99,
410 +
  },
411 +

318 412
});
frontend/app/triggers/[id].tsx
1 1
import { useState, useEffect, use } from "react";
2 2
import {
3 3
  StyleSheet,
4 4
  Text,
5 5
  View,
▸ 4 unchanged lines
10 10
import { useLocalSearchParams, router, Stack } from "expo-router";
11 11
import { format } from "date-fns";
12 12
import { ptBR } from "date-fns/locale";
13 13

14 14
import { Card, SubtleInfoCard } from "@/components/ui/Cards";
15 +
import { NoLinkMessage } from "@/components/misc/NoLinkMessage";
15 16
import { Badge } from "@/components/ui/Badge";
16 17
import { Button } from "@/components/ui/Button";
17 18
import { Col, Between, Row } from "@/components/ui/LayoutHelpers";
18 19
import { Section, SectionHeader } from "@/components/ui/Sections";
19 20
import { Theme, Typography, Colors, Spacing } from "@/constants/theme";
20 -
import { MaterialIcons } from "@expo/vector-icons";
21 +
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
21 22
import { getTriggerCategory } from "@/constants/trigger-categories";
22 23
import {
23 24
  useTrigger,
24 25
  useDeleteTrigger,
25 26
} from "@/hooks/useTrigger.queries";
26 27
import { formatMoment } from "@/lib/utils/time";
28 +
import { getMood } from "@/constants/moods";
29 +

30 +
const impactColor = (n: number) => {
31 +
  if (n <= 1) return '#86efac';
32 +
  if (n <= 2) return '#22c55e';
33 +
  if (n <= 3) return '#f59e0b';
34 +
  if (n <= 4) return '#f97316';
35 +
  return '#ef4444';
36 +
};
27 37

28 38
export default function TriggerDetailsScreen() {
29 39
  const { id } = useLocalSearchParams<{ id: string }>();
30 40
  const { data: trigger, isLoading, isError } = useTrigger(id);
31 41
  const deleteTrigger = useDeleteTrigger();
▸ 87 unchanged lines
119 129
              </Text>
120 130
            </Col>
121 131
          </Row>
122 132
        </Card>
123 133

134 +
        {/* Linked moods */}
135 +
        <Section>
136 +
          <SectionHeader title="Vínculo" variant="subtle" />
137 +
          {trigger.moodLinks && trigger.moodLinks.length > 0 ? (
138 +
            <Col gap={8}>
139 +
              {trigger.moodLinks.map((link) => {
140 +
                const moodDef = getMood(link.mood.selectedMood.toLowerCase());
141 +
                return (
142 +
                  <Pressable
143 +
                    key={link.id}
144 +
                    onPress={() => router.push(`/entry/${link.mood.id}`)}
145 +
                    style={({ pressed }) => [pressed && { opacity: 0.7 }]}
146 +
                  >
147 +
                    <Card style={styles.linkCard}>
148 +
                      <Row style={{ alignItems: "center", gap: 12 }}>
149 +
                        <View style={styles.linkIconCircle}>
150 +
                          <Text style={styles.linkMoodEmoji}>{moodDef?.icon ?? "😐"}</Text>
151 +
                        </View>
152 +
                        <Col gap={3} style={{ flex: 1 }}>
153 +
                          <Row style={{ alignItems: "center", gap: 6 }}>
154 +
                            <Text style={styles.linkLabel}>
155 +
                              {moodDef?.label ?? link.mood.selectedMood}
156 +
                            </Text>
157 +

158 +
                            <Badge
159 +
                              label="Humor"
160 +
                              variant="yellow"
161 +
                              style={undefined}
162 +
                            />
163 +
                          </Row>
164 +

165 +
                          <Text style={styles.linkTime}>
166 +
                            {formatMoment(new Date(link.mood.moment))}
167 +
                          </Text>
168 +
                        </Col>
169 +
                        <Ionicons name="chevron-forward" size={16} color={Colors.light.disabled} />
170 +
                      </Row>
171 +
                      <View style={styles.impactBar}>
172 +
                        <View
173 +
                          style={[
174 +
                            styles.impactFill,
175 +
                            {
176 +
                              width: `${(link.perceivedImpact / 5) * 100}%`,
177 +
                              backgroundColor: impactColor(link.perceivedImpact),
178 +
                            },
179 +
                          ]}
180 +
                        />
181 +
                      </View>
182 +
                    </Card>
183 +
                  </Pressable>
184 +
                );
185 +
              })}
186 +
            </Col>
187 +
          ) : (
188 +
            <NoLinkMessage />
189 +
          )}
190 +
        </Section>
191 +

124 192
        {/* Annotation / notes */}
125 193
        {trigger.comment && (
126 194
          <Section>
127 195
            <SectionHeader title="Anotações" variant="subtle" />
128 196
            <Card style={{ padding: Spacing.cardGap }}>
▸ 109 unchanged lines
238 306
    fontSize: 14,
239 307
    lineHeight: 22,
240 308
    color: Colors.light.textSecondary,
241 309
    fontStyle: "italic",
242 310
    padding: 4,
311 +
  },
312 +
  // Linked mood cards
313 +
  linkCard: {
314 +
    padding: Spacing.cardGap,
315 +
    overflow: "hidden",
316 +
    gap: 10,
317 +
  },
318 +
  linkIconCircle: {
319 +
    width: 44,
320 +
    height: 44,
321 +
    alignItems: "center",
322 +
    justifyContent: "center",
323 +
  },
324 +
  linkMoodEmoji: {
325 +
    fontSize: 28,
326 +
  },
327 +
  linkTime: {
328 +
    fontSize: 12,
329 +
    color: Colors.light.textSecondary,
330 +
  },
331 +
  linkLabel: {
332 +
    fontSize: 15,
333 +
    fontWeight: "700",
334 +
    color: Colors.light.text,
335 +
  },
336 +
  impactBar: {
337 +
    height: 3,
338 +
    backgroundColor: Colors.light.divider,
339 +
    borderRadius: 99,
340 +
    overflow: "hidden",
341 +
  },
342 +
  impactFill: {
343 +
    height: "100%",
344 +
    borderRadius: 99,
243 345
  },
244 346
});
frontend/components/misc/NoLinkMessage.tsx
@@ -0,0 +1,50 @@
1 + import React from "react";
2 + import { View, Text, StyleSheet } from "react-native";
3 + import { Ionicons } from "@expo/vector-icons";
4 + import { Colors, Typography, Spacing } from "@/constants/theme";
5 +
6 + const styles = StyleSheet.create({
7 + container: {
8 + flexDirection: "row",
9 + alignItems: "flex-start",
10 + gap: 8,
11 + paddingHorizontal: 4,
12 + paddingVertical: Spacing.cardGap,
13 + },
14 + text: {
15 + ...Typography.bodyMd,
16 + color: Colors.light.textSecondary,
17 + flex: 1,
18 + lineHeight: 20,
19 + fontStyle: 'italic',
20 + fontSize: 12
21 + },
22 + bold: {
23 + fontWeight: "700"
24 + },
25 + });
26 +
27 + const MESSAGES = {
28 + general: (
29 + <>
30 + Registros também podem ser
31 + <Text style={styles.bold}> Sem vínculo </Text>
32 + ou{" "}
33 + <Text style={styles.bold}>Vinculados a uma ação de cuidado</Text> (ex:
34 + Meditação, Exercício).
35 + </>
36 + ),
37 + };
38 +
39 + interface NoLinkMessageProps {
40 + variant?: keyof typeof MESSAGES;
41 + }
42 +
43 + export function NoLinkMessage({ variant = "general" }: NoLinkMessageProps) {
44 + return (
45 + <View style={styles.container}>
46 + <Ionicons name="information-circle-outline" size={20} color={Colors.light.textSecondary} />
47 + <Text style={styles.text}>{MESSAGES[variant] ?? MESSAGES.general}</Text>
48 + </View>
49 + );
50 + }
frontend/hooks/useTrigger.queries.ts
1 1
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
2 2

3 3
import { apiClient, Trigger, CreateTriggerPayload, UpdateTriggerPayload } from "@/lib/api";
4 4
import { insightKeys } from "@/hooks/useInsights.queries";
5 +
import { moodKeys } from "@/hooks/useMoodEntries.queries";
5 6

6 7
export const triggerKeys = {
7 8
  all: () => ["triggers"] as const,
8 9
  lists: () => [...triggerKeys.all(), "list"] as const,
9 10
  list: (filters?: TriggerFilters) =>
▸ 132 unchanged lines
142 143
      moodId: string;
143 144
      perceivedImpact: number
144 145
    }) =>
145 146
      apiClient.linkMoodToTrigger(triggerId, { moodId: Number(moodId), perceivedImpact }),
146 147

147 -
    onSuccess: (_data, { triggerId }) => {
148 +
    onSuccess: (_data, { triggerId, moodId }) => {
148 149
      queryClient.invalidateQueries({ queryKey: triggerKeys.detail(triggerId) });
150 +
      queryClient.invalidateQueries({ queryKey: moodKeys.detail(moodId) });
149 151
    },
150 152
  });
151 153
};
152 154

153 155
// Delete a mood entry with optimistic removal
▸ 26 unchanged lines
180 182
        queryClient.invalidateQueries({ queryKey: insightKeys.byType("TRIGGER_PATTERN") });
181 183
      }, 2000);
182 184
    },
183 185
  });
184 186
};
frontend/lib/api/index.ts
1 1
export { apiClient } from "@/lib/api/client";
2 2

3 3
export type {
4 4
  User,
5 5
  AuthResponse,
▸ 25 unchanged lines
31 31
  // Medicine Regimens
32 32
  MedicineRegimen,
33 33
  MedicineRegimenResponse,
34 34
  CreateMedicineRegimenPayload,
35 35
  UpdateMedicineRegimenPayload,
36 +
  UpdateTriggerPayload,
36 37
  // Trigger-Mood linking
37 38
  TriggerMoodLink,
39 +
  TriggerMoodLinkWithMood,
40 +
  TriggerMoodLinkWithTrigger,
38 41
  LinkMoodPayload,
39 42
} from "@/lib/api/types";
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;
▸ 77 unchanged lines
83 83
  selectedMood: string;
84 84
  anxietyLevel: number;
85 85
  energyLevel: number;
86 86
  stressLevel: number;
87 87
  moodComponents: MoodComponent[];
88 +
  triggerLinks?: TriggerMoodLinkWithTrigger[];
88 89
  createdAt: Date;
89 90
  updatedAt: Date;
90 91
}
91 92

92 93
// Sleep Records
▸ 22 unchanged lines
115 116
  comment: string;
116 117
  category: string;
117 118
  moment: Date;
118 119
  createdAt: Date;
119 120
  updatedAt: Date;
121 +
  moodLinks?: TriggerMoodLinkWithMood[];
120 122
}
121 123

122 124
export interface CreateTriggerPayload {
123 125
  comment: string;
124 126
  category: string;
▸ 164 unchanged lines
289 291
  id: number;
290 292
  triggerId: number;
291 293
  moodId: number;
292 294
  perceivedImpact: number;
293 295
  linkedAt: Date;
296 +
}
297 +

298 +
export interface TriggerMoodLinkWithMood extends TriggerMoodLink {
299 +
  mood: MoodEntry;
300 +
}
301 +

302 +
export interface TriggerMoodLinkWithTrigger extends TriggerMoodLink {
303 +
  trigger: Trigger;
294 304
}
295 305

296 306
// Payloads
297 307
export interface CreateMedicineRegimenPayload {
298 308
  name: string;
▸ 43 unchanged lines
342 352

343 353
export interface CareActionResponse { careAction: CareAction; }
344 354
export interface MedicineRegimenResponse { regimen: MedicineRegimen; }
345 355

346 356
export interface LinkMoodPayload { moodId: number; perceivedImpact: number; }