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 filesParents
8873ae7Changed 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 +
}