eletrotupi / tcc/ commit / 5a1373a

frontend/insights: the daily pair shares one card

The energy and sleep cards were near-duplicates with their own headers
and metric styling, so the pair never read as one unit, and sleep
out-shouted its own number with a stored sentence. Both now feed one
presentational card: a tinted icon beside the title, a hero number with
its window label, an optional bar and the comparison line. Sleep shows
its total as the hero with no bar; energy keeps a bar scaled to its
maximum
Pedro Lucas Porcellis porcellis@eletrotupi.com · 12 days ago 5a1373aed06bccb3f57d57b91da01d9500c1ce91 Browse files
Parents 8873ae7
Changed files
3
Additions
+221
Deletions
-146
/dev/null → frontend/components/insights/InsightMetricCard.tsx
1 +
import React from "react";
2 +
import { ActivityIndicator, StyleSheet, View } from "react-native";
3 +
import { Ionicons } from "@expo/vector-icons";
4 +
import { Text } from "@/components/ui/Text";
5 +
import { Card } from "@/components/ui/Cards";
6 +
import { BorderRadius, Colors, Spacing, Typography } from "@/constants/theme";
7 +

8 +
type IoniconName = React.ComponentProps<typeof Ionicons>["name"];
9 +

10 +
interface InsightMetricCardProps {
11 +
  icon: IoniconName;
12 +
  title: string;
13 +
  iconColor?: string;
14 +
  iconBackground?: string;
15 +
  isLoading?: boolean;
16 +
  emptyMessage?: string;
17 +
  value?: string;
18 +
  windowLabel?: string;
19 +
  detail?: string;
20 +
  barRatio?: number;
21 +
  comparison?: string | null;
22 +
  comparisonColor?: string;
23 +
}
24 +

25 +
// One card for the daily insight pair: a tinted icon beside the title, a hero
26 +
// number with its window, an optional bounded bar, and the comparison line
27 +
export const InsightMetricCard: React.FC<InsightMetricCardProps> = ({
28 +
  icon,
29 +
  title,
30 +
  iconColor = Colors.light.tint,
31 +
  iconBackground = Colors.light.tint + "1A",
32 +
  isLoading = false,
33 +
  emptyMessage,
34 +
  value,
35 +
  windowLabel,
36 +
  detail,
37 +
  barRatio,
38 +
  comparison,
39 +
  comparisonColor = Colors.light.textSecondary,
40 +
}) => (
41 +
  <Card style={styles.container}>
42 +
    <View style={styles.header}>
43 +
      <View style={[styles.iconWrap, { backgroundColor: iconBackground }]}>
44 +
        <Ionicons name={icon} size={18} color={iconColor} />
45 +
      </View>
46 +
      <Text style={styles.title}>{title}</Text>
47 +
    </View>
48 +

49 +
    <View style={styles.body}>
50 +
      {isLoading ? (
51 +
        <ActivityIndicator size="small" color={iconColor} />
52 +
      ) : value == null ? (
53 +
        <Text style={styles.empty}>{emptyMessage}</Text>
54 +
      ) : (
55 +
        <>
56 +
          <View style={styles.heroRow}>
57 +
            <Text style={styles.value}>{value}</Text>
58 +
            {windowLabel && <Text style={styles.window}>{windowLabel}</Text>}
59 +
          </View>
60 +

61 +
          {barRatio != null && (
62 +
            <View style={styles.track}>
63 +
              <View
64 +
                style={[
65 +
                  styles.fill,
66 +
                  {
67 +
                    width: `${Math.round(barRatio * 100)}%`,
68 +
                    backgroundColor: iconColor,
69 +
                  },
70 +
                ]}
71 +
              />
72 +
            </View>
73 +
          )}
74 +

75 +
          {detail && <Text style={styles.detail}>{detail}</Text>}
76 +

77 +
          {comparison && (
78 +
            <Text style={[styles.comparison, { color: comparisonColor }]}>
79 +
              {comparison}
80 +
            </Text>
81 +
          )}
82 +
        </>
83 +
      )}
84 +
    </View>
85 +
  </Card>
86 +
);
87 +

88 +
const styles = StyleSheet.create({
89 +
  container: {
90 +
    flex: 1,
91 +
  },
92 +
  header: {
93 +
    flexDirection: "row",
94 +
    alignItems: "center",
95 +
    gap: Spacing.inlineGapSm,
96 +
  },
97 +
  iconWrap: {
98 +
    width: 32,
99 +
    height: 32,
100 +
    borderRadius: BorderRadius.md,
101 +
    alignItems: "center",
102 +
    justifyContent: "center",
103 +
  },
104 +
  title: {
105 +
    ...Typography.bodyMd,
106 +
    fontWeight: "700",
107 +
    color: Colors.light.text,
108 +
  },
109 +
  body: {
110 +
    marginTop: 10,
111 +
    gap: 6,
112 +
  },
113 +
  heroRow: {
114 +
    flexDirection: "row",
115 +
    alignItems: "baseline",
116 +
    gap: 6,
117 +
  },
118 +
  value: {
119 +
    ...Typography.headlineXg,
120 +
    fontSize: 28,
121 +
    lineHeight: 32,
122 +
    color: Colors.light.text,
123 +
  },
124 +
  window: {
125 +
    ...Typography.labelSm,
126 +
    fontWeight: "500",
127 +
    color: Colors.light.textSecondary,
128 +
  },
129 +
  track: {
130 +
    height: 6,
131 +
    borderRadius: BorderRadius.full,
132 +
    backgroundColor: Colors.light.divider,
133 +
    overflow: "hidden",
134 +
  },
135 +
  fill: {
136 +
    height: "100%",
137 +
    borderRadius: BorderRadius.full,
138 +
  },
139 +
  detail: {
140 +
    ...Typography.labelXs,
141 +
    color: Colors.light.textSecondary,
142 +
  },
143 +
  comparison: {
144 +
    ...Typography.labelSm,
145 +
    fontWeight: "600",
146 +
  },
147 +
  empty: {
148 +
    ...Typography.labelSm,
149 +
    color: Colors.light.textSecondary,
150 +
    opacity: 0.6,
151 +
  },
152 +
});
frontend/components/ui/DailyEnergyWidget.tsx
1 -
import { ActivityIndicator, StyleSheet, View } from "react-native";
2 -
import { Text } from "@/components/ui/Text";
3 -
import { Card } from "@/components/ui/Cards";
1 +
import { InsightMetricCard } from "@/components/insights/InsightMetricCard";
4 2
import { useInsight } from "@/hooks/useInsights.queries";
5 -
import { Ionicons } from "@expo/vector-icons";
6 -
import { Typography, Spacing, Colors, BorderRadius } from "@/constants/theme";
3 +
import { Colors } from "@/constants/theme";
4 +

5 +
const ENERGY_SCALE_MAX = 10;
7 6

8 7
export const DailyEnergyWidget = () => {
9 8
  const { data: insight, isLoading } = useInsight("DAILY_ENERGY");
10 9

11 10
  const metadata = insight?.metadata;
11 +
  const avgEnergy = metadata?.avgEnergy;
12 +
  const diffEnergy = metadata?.diffEnergy ?? null;
12 13

13 -
  const avgEnergy = metadata?.avgEnergy ?? 0;
14 -
  const peak = metadata?.peak ?? 0;
14 +
  return (
15 +
    <InsightMetricCard
16 +
      icon="flash"
17 +
      title="Energia"
18 +
      isLoading={isLoading}
19 +
      emptyMessage="Não há dados disponíveis para o dia de hoje"
20 +
      value={avgEnergy != null ? formatEnergy(avgEnergy) : undefined}
21 +
      windowLabel={metadata?.windowLabel}
22 +
      barRatio={
23 +
        avgEnergy != null
24 +
          ? Math.max(0, Math.min(1, avgEnergy / ENERGY_SCALE_MAX))
25 +
          : undefined
26 +
      }
27 +
      comparison={formatEnergyDiff(diffEnergy)}
28 +
      comparisonColor={energyDiffColor(diffEnergy)}
29 +
    />
30 +
  );
31 +
};
15 32

16 -
  const energyWidth = peak > 0 ? (avgEnergy / peak) * 100 : 0;
17 -
  const energySummary = () => {
18 -
    if (avgEnergy < 3) return "Energia baixa";
19 -
    if (avgEnergy < 5) return "Media baixa";
20 -
    if (avgEnergy < 7.5) return "Média Alta";
21 -
    return "Energia alta";
22 -
  };
33 +
function formatEnergy(value: number): string {
34 +
  return value.toFixed(1).replace(".", ",");
35 +
}
23 36

24 -
  return (
25 -
    <Card style={styles.container}>
26 -
      <View
27 -
        style={{
28 -
          flex: 1,
29 -
          flexDirection: "row",
30 -
          flexGrow: 0,
31 -
          paddingVertical: 10,
32 -
        }}
33 -
      >
34 -
        <Ionicons name="flash" size={20} color="#64748B" />
37 +
// Positive means more energy than yesterday; nothing to compare reads null
38 +
function formatEnergyDiff(diff: number | null): string | null {
39 +
  if (diff == null) return null;
35 40

36 -
        <Text style={{ ...Typography.headlineMd, marginBottom: 10 }}>
37 -
          Energia
38 -
        </Text>
39 -
      </View>
41 +
  const sign = diff > 0 ? "+" : diff < 0 ? "-" : "";
40 42

41 -
      <View style={{ flex: 1 }}>
42 -
        {isLoading ? (
43 -
          <View style={styles.empty}>
44 -
            <ActivityIndicator size="small" style={{ paddingBottom: 50 }} />
45 -
          </View>
46 -
        ) : insight ? (
47 -
          <>
48 -
            <Text style={{ ...Typography.headlineMd, marginBottom: 10 }}>
49 -
              {energySummary()}
50 -
            </Text>
43 +
  return `${sign}${Math.abs(diff).toFixed(1).replace(".", ",")} que ontem`;
44 +
}
51 45

52 -
            <View style={styles.lineTrack}>
53 -
              <View style={[styles.lineFill, { width: `${energyWidth}%` }]} />
54 -
            </View>
55 -
          </>
56 -
        ) : (
57 -
          <>
58 -
            <Text style={styles.empty}>
59 -
              Não há dados disponíveis para o dia de hoje
60 -
            </Text>
61 -
          </>
62 -
        )}
63 -
      </View>
64 -
    </Card>
65 -
  );
66 -
};
46 +
function energyDiffColor(diff: number | null): string {
47 +
  if (diff == null || diff === 0) return Colors.light.textSecondary;
67 48

68 -
const styles = StyleSheet.create({
69 -
  container: {
70 -
    flex: 1,
71 -
    padding: Spacing.cardGap,
72 -
  },
73 -
  empty: {
74 -
    ...Typography.labelSm,
75 -
    opacity: 0.5,
76 -
  },
77 -
  lineTrack: {
78 -
    height: 6,
79 -
    backgroundColor: Colors.light.divider,
80 -
    borderRadius: BorderRadius.full,
81 -
    marginBottom: 4,
82 -
    overflow: "hidden",
83 -
  },
84 -
  lineFill: {
85 -
    height: "100%",
86 -
    backgroundColor: Colors.light.tint,
87 -
    borderRadius: BorderRadius.full,
88 -
  },
89 -
  indicatorGreen: {
90 -
    color: Colors.light.tint,
91 -
    fontSize: 11,
92 -
    fontWeight: 600,
93 -
    lineHeight: 16.5,
94 -
  },
95 -
});
49 +
  return diff > 0 ? Colors.light.tint : Colors.light.danger;
50 +
}
frontend/components/ui/DailySleepWidget.tsx
1 -
import React from "react";
2 -
import { View, StyleSheet, ActivityIndicator } from "react-native";
3 -
import { Text } from "@/components/ui/Text";
4 -
import { Card } from "./Cards";
5 -
import { Colors, Spacing, Typography } from "@/constants/theme";
6 -
import { Ionicons } from "@expo/vector-icons";
1 +
import { InsightMetricCard } from "@/components/insights/InsightMetricCard";
7 2
import { useInsight } from "@/hooks/useInsights.queries";
3 +
import { Colors } from "@/constants/theme";
8 4

9 5
export const DailySleepWidget = () => {
10 6
  const { data: insight, isLoading } = useInsight("DAILY_SLEEP");
11 7

12 8
  const metadata = insight?.metadata;
13 -

14 -
  const diffColor = metadata?.diffMinutes
15 -
    ? metadata.diffMinutes < 0
16 -
      ? Colors.light.danger
17 -
      : Colors.light.tint
18 -
    : Colors.light.tint;
9 +
  const diffMinutes = metadata?.diffMinutes ?? null;
19 10

20 11
  return (
21 -
    <Card style={{ padding: Spacing.cardGap }}>
22 -
      <View
23 -
        style={{
24 -
          flex: 1,
25 -
          flexDirection: "row",
26 -
          flexGrow: 0,
27 -
          paddingVertical: 10,
28 -
        }}
29 -
      >
30 -
        <Ionicons name="moon" size={20} color="#64748B" />
31 -

32 -
        <Text style={{ ...Typography.headlineMd, marginBottom: 10 }}>Sono</Text>
33 -
      </View>
34 -

35 -
      <View style={{ flex: 1 }}>
36 -
        {isLoading ? (
37 -
          <View style={styles.empty}>
38 -
            <ActivityIndicator size="small" style={{ paddingBottom: 50 }} />
39 -
          </View>
40 -
        ) : insight ? (
41 -
          <>
42 -
            <Text style={{ ...Typography.headlineMd, marginBottom: 10 }}>
43 -
              {insight.body}
44 -
            </Text>
45 -
            <Text style={{ ...styles.indicatorGreen, color: diffColor }}>
46 -
              {metadata?.diffStr}
47 -
            </Text>
48 -
          </>
49 -
        ) : (
50 -
          <>
51 -
            <Text style={styles.empty}>
52 -
              Não há dados disponíveis para o dia de hoje
53 -
            </Text>
54 -
          </>
55 -
        )}
56 -
      </View>
57 -
    </Card>
12 +
    <InsightMetricCard
13 +
      icon="moon"
14 +
      title="Sono"
15 +
      iconColor={Colors.light.accentDarkPurple}
16 +
      iconBackground={Colors.light.accentPurple}
17 +
      isLoading={isLoading}
18 +
      emptyMessage="Não há dados disponíveis para o dia de hoje"
19 +
      value={
20 +
        metadata?.totalHours != null
21 +
          ? formatHours(metadata.totalHours)
22 +
          : undefined
23 +
      }
24 +
      windowLabel={metadata?.windowLabel}
25 +
      detail={metadata?.napStr || undefined}
26 +
      comparison={metadata?.diffStr || null}
27 +
      comparisonColor={
28 +
        diffMinutes != null && diffMinutes < 0
29 +
          ? Colors.light.danger
30 +
          : Colors.light.tint
31 +
      }
32 +
    />
58 33
  );
59 34
};
60 35

61 -
const styles = StyleSheet.create({
62 -
  container: {
63 -
    flex: 1,
64 -
    padding: Spacing.cardGap,
65 -
  },
66 -
  indicatorGreen: {
67 -
    fontSize: 11,
68 -
    fontWeight: 600,
69 -
    lineHeight: 16.5,
70 -
  },
71 -
  empty: {
72 -
    ...Typography.labelSm,
73 -
    opacity: 0.5,
74 -
  },
75 -
});
36 +
function formatHours(hours: number): string {
37 +
  const h = Math.floor(hours);
38 +
  const m = Math.round((hours - h) * 60);
39 +

40 +
  if (m === 0) return `${h}h`;
41 +

42 +
  return `${h}h ${m}min`;
43 +
}