eletrotupi / tcc/ commit / 055fe40

frontend/api: insights UI improvements and post-mood bug fixes

Add a small description to the insights page, display calculation for
each insights, fix trigger-mood linkage by making it idempotent: return
the existing link instead of throwing a plain Error (which fell through
to a 500), so duplicate link attempts are a no-op

I still feel this is rather brittle, need to test more
Pedro Lucas Porcellis porcellis@eletrotupi.com 29 days ago 055fe407989da4d2458542e495875bb5944aa940
Parents: 60f9bbf
8 file(s) changed
  • api/src/services/triggerMoodLink.service.ts +1 -1
  • frontend/app/(tabs)/insights.tsx +1 -1
  • frontend/app/(tabs)/new/entry.tsx +1 -1
  • frontend/app/care-actions/post-appointment.tsx +1 -1
  • frontend/components/insights/EnergySleepWidget.tsx +7 -5
  • frontend/components/insights/InsightCard.tsx +20 -0
  • frontend/components/insights/MoodTrendWidget.tsx +1 -0
  • frontend/components/insights/TriggerPatternWidget.tsx +1 -0
api/src/services/triggerMoodLink.service.ts
1 1
import { prisma } from '@app/lib/prisma';
2 2
import { TriggerMoodLink } from '@prisma/client';
3 3

4 4
export const linkTriggerToMood = async (
5 5
  triggerId: number,
▸ 5 unchanged lines
11 11
      triggerId_moodId: { triggerId, moodId },
12 12
    },
13 13
  });
14 14

15 15
  if (existing) {
16 -
    throw new Error(`Link between trigger ${triggerId} and mood ${moodId} already exists`);
16 +
    return existing;
17 17
  }
18 18

19 19
  return prisma.triggerMoodLink.create({
20 20
    data: { triggerId, moodId, perceivedImpact },
21 21
  });
▸ 7 unchanged lines
29 29
    where: {
30 30
      triggerId_moodId: { triggerId, moodId },
31 31
    },
32 32
  });
33 33
};
frontend/app/(tabs)/insights.tsx
1 1
import { View, Text, ScrollView, StyleSheet } from "react-native";
2 2

3 3
import { ThemedText } from "@/components/misc/themed-text";
4 4
import { ThemedView } from "@/components/misc/themed-view";
5 5
import { ScreenLayout } from "@/components/ui/ScreenLayout";
▸ 24 unchanged lines
30 30
          { label: "Humor Estável", value: "92%" },
31 31
        ]}
32 32
      />
33 33

34 34
      <Section>
35 -
        <SectionHeader title="Indicadores" />
35 +
        <SectionHeader title="Indicadores" subtitle="Baseado nos últimos 7 dias" />
36 36

37 37
        <EnergySleepWidget />
38 38

39 39
        <MoodTrendWidget />
40 40

▸ 90 unchanged lines
131 131
  insightMetric: {
132 132
    ...Typography.headlineLg,
133 133
    fontSize: 22,
134 134
  },
135 135
});
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

▸ 35 unchanged lines
41 41

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

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

49 49
  useEffect(() => {
50 50
    return () => reset();
51 51
  }, []);
▸ 157 unchanged lines
209 209
  },
210 210
  resumeCard: {
211 211
    padding: Spacing.cardGap,
212 212
  },
213 213
});
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

▸ 52 unchanged lines
58 58
          </TouchableOpacity>
59 59

60 60
          <TouchableOpacity
61 61
            style={styles.optionRow}
62 62
            activeOpacity={0.7}
63 -
            onPress={() => router.replace("/new/entry")}
63 +
            onPress={() => router.replace("/new/entry?initialMood=neutral")}
64 64
          >
65 65
            <View style={[styles.optionIcon, { backgroundColor: "#fef9c3" }]}>
66 66
              <Ionicons name="heart-outline" size={22} color="#ca8a04" />
67 67
            </View>
68 68
            <View style={styles.optionText}>
▸ 115 unchanged lines
184 184
  },
185 185
  concludeButton: {
186 186
    marginTop: 8,
187 187
  },
188 188
});
frontend/components/insights/EnergySleepWidget.tsx
1 1
import React from "react";
2 2
import { Text, StyleSheet, ActivityIndicator } from "react-native";
3 3
import { Card } from "@/components/ui/Cards";
4 4
import { ThemedText } from "@/components/misc/themed-text";
5 5
import { useInsight } from "@/hooks/useInsights.queries";
▸ 33 unchanged lines
39 39
    <InsightCard
40 40
      category="Energia"
41 41
      body={insight.body}
42 42
      metric={label}
43 43
      accent={accent}
44 +
      footnote="Correlação de Pearson entre horas de sono e energia no dia seguinte."
44 45
    />
45 46
  );
46 47
};
47 48

48 49
function resolveCorrelation(
49 50
  score: number,
50 51
  sampleSize: number,
51 52
): { label: string; accent: MetricAccent } {
52 -
  const pct = Math.round(Math.abs(score) * 100);
53 53
  const lowData = sampleSize < 5;
54 +
  const suffix = lowData ? "*" : "";
54 55

55 56
  if (score > 0.6)
56 -
    return { label: lowData ? "Alta*" : `+${pct}%`, accent: "green" };
57 +
    return { label: `Forte${suffix}`, accent: "green" };
57 58
  if (score > 0.3)
58 -
    return { label: lowData ? "Média*" : `+${pct}%`, accent: "green" };
59 +
    return { label: `Moderada${suffix}`, accent: "green" };
59 60
  if (score >= -0.3)
60 -
    return { label: "~0%", accent: "neutral" };
61 -
  return { label: `-${pct}%`, accent: "purple" };
61 +
    return { label: `Nenhuma${suffix}`, accent: "neutral" };
62 +

63 +
  return { label: `—`, accent: "purple" };
62 64
}
63 65

64 66
const styles = StyleSheet.create({
65 67
  container: {
66 68
    flex: 1,
▸ 6 unchanged lines
73 75
  empty: {
74 76
    color: Colors.light.lightSlateGray,
75 77
    marginTop: 4,
76 78
  },
77 79
});
frontend/components/insights/InsightCard.tsx
1 1
import React from "react";
2 2
import { View, Text, StyleSheet } from "react-native";
3 3

4 4
import { Card } from "@/components/ui/Cards";
5 5
import { Colors, Spacing, Typography } from "@/constants/theme";
▸ 9 unchanged lines
15 15
interface InsightCardProps {
16 16
  category: string;
17 17
  body: React.ReactNode;
18 18
  metric: string;
19 19
  accent?: MetricAccent;
20 +
  footnote?: string;
20 21
}
21 22

22 23
export const InsightCard: React.FC<InsightCardProps> = ({
23 24
  category,
24 25
  body,
25 26
  metric,
26 27
  accent = "neutral",
28 +
  footnote,
27 29
}) => (
28 30
  <Card style={styles.cardContainer}>
29 31
    <View style={styles.inner}>
30 32
      <View style={styles.left}>
31 33
        <Text style={styles.category}>{category}</Text>
▸ 5 unchanged lines
37 39
      </View>
38 40
      <Text style={[styles.metric, { color: metricColor[accent] }]}>
39 41
        {metric}
40 42
      </Text>
41 43
    </View>
44 +
    {footnote && (
45 +
      <>
46 +
        <View style={styles.divider} />
47 +
        <Text style={styles.footnote}>{footnote}</Text>
48 +
      </>
49 +
    )}
42 50
  </Card>
43 51
);
44 52

45 53
interface HighlightProps {
46 54
  children: React.ReactNode;
▸ 31 unchanged lines
78 86
    color: Colors.light.text,
79 87
  },
80 88
  metric: {
81 89
    ...Typography.headlineLg,
82 90
    fontSize: 22,
91 +
  },
92 +
  divider: {
93 +
    height: StyleSheet.hairlineWidth,
94 +
    backgroundColor: Colors.light.divider,
95 +
    marginTop: Spacing.cardGap,
96 +
    marginBottom: 8,
97 +
  },
98 +
  footnote: {
99 +
    fontSize: 11,
100 +
    lineHeight: 15,
101 +
    color: Colors.light.textSecondary,
102 +
    fontStyle: "italic",
83 103
  },
84 104
});
frontend/components/insights/MoodTrendWidget.tsx
1 1
import React from "react";
2 2
import { Text, StyleSheet, ActivityIndicator } from "react-native";
3 3
import { Card } from "@/components/ui/Cards";
4 4
import { useInsight } from "@/hooks/useInsights.queries";
5 5
import { Colors, Spacing, Typography } from "@/constants/theme";
▸ 44 unchanged lines
50 50
    <InsightCard
51 51
      category="Humor"
52 52
      body={insight.body}
53 53
      metric={metric}
54 54
      accent={accent}
55 +
      footnote="Compara a média de humor da primeira e segunda metade da semana (escala 1–5)."
55 56
    />
56 57
  );
57 58
};
58 59

59 60
const styles = StyleSheet.create({
▸ 23 unchanged lines
83 84
  empty: {
84 85
    color: Colors.light.lightSlateGray,
85 86
    marginTop: 4,
86 87
  },
87 88
});
frontend/components/insights/TriggerPatternWidget.tsx
1 1
import React from "react";
2 2
import { Text, View, StyleSheet, ActivityIndicator } from "react-native";
3 3
import { MaterialIcons } from "@expo/vector-icons";
4 4
import { Card } from "@/components/ui/Cards";
5 5
import { useInsight } from "@/hooks/useInsights.queries";
▸ 49 unchanged lines
55 55
          <Text style={styles.bodyText}>{insight.body}</Text>
56 56
        </View>
57 57
      }
58 58
      metric={`${pct}%`}
59 59
      accent="neutral"
60 +
      footnote="Percentual da categoria de gatilho mais frequente no período."
60 61
    />
61 62
  );
62 63
};
63 64

64 65
const styles = StyleSheet.create({
▸ 21 unchanged lines
86 87
  empty: {
87 88
    color: Colors.light.lightSlateGray,
88 89
    marginTop: 4,
89 90
  },
90 91
});