eletrotupi / tcc/ commit / fb784c6

frontend/insights: steady the daily pair on device

On a real build the first pass only held for a full pair: the window
label overflowed beside an hours-long sleep number, the energy number
read as a bare "6,5" with no sense of how it was, and a day could sum
past the 15h a single record allows

The window label now sits under the number, energy carries a level band,
the sleep hero caps at "24h+", and the card splits its height between
the title and the content so the data stays together instead of
spreading
Pedro Lucas Porcellis porcellis@eletrotupi.com · 12 days ago fb784c6226618a02a32f1f7d619d8844b6b623ef Browse files
Parents 5a1373a
Changed files
3
Additions
+35
Deletions
-16
frontend/components/insights/InsightMetricCard.tsx
1 1
import React from "react";
2 2
import { ActivityIndicator, StyleSheet, View } from "react-native";
3 3
import { Ionicons } from "@expo/vector-icons";
4 4
import { Text } from "@/components/ui/Text";
5 5
import { Card } from "@/components/ui/Cards";
13 13
  iconColor?: string;
14 14
  iconBackground?: string;
15 15
  isLoading?: boolean;
16 16
  emptyMessage?: string;
17 17
  value?: string;
18 +
  // Plain-language read of the number, e.g. "Baixa"
19 +
  band?: string;
18 20
  windowLabel?: string;
19 21
  detail?: string;
20 22
  barRatio?: number;
21 23
  comparison?: string | null;
22 24
  comparisonColor?: string;
23 25
}
24 26

25 27
// 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
28 +
// number with its band and window, an optional bounded bar, and the comparison
27 29
export const InsightMetricCard: React.FC<InsightMetricCardProps> = ({
28 30
  icon,
29 31
  title,
30 32
  iconColor = Colors.light.tint,
31 33
  iconBackground = Colors.light.tint + "1A",
32 34
  isLoading = false,
33 35
  emptyMessage,
34 36
  value,
37 +
  band,
35 38
  windowLabel,
36 39
  detail,
37 40
  barRatio,
38 41
  comparison,
39 42
  comparisonColor = Colors.light.textSecondary,
51 54
        <ActivityIndicator size="small" color={iconColor} />
52 55
      ) : value == null ? (
53 56
        <Text style={styles.empty}>{emptyMessage}</Text>
54 57
      ) : (
55 58
        <>
56 -
          <View style={styles.heroRow}>
57 -
            <Text style={styles.value}>{value}</Text>
58 -
            {windowLabel && <Text style={styles.window}>{windowLabel}</Text>}
59 -
          </View>
59 +
          <Text style={styles.value}>{value}</Text>
60 +

61 +
          {band && <Text style={styles.band}>{band}</Text>}
60 62

61 63
          {barRatio != null && (
62 64
            <View style={styles.track}>
63 65
              <View
64 66
                style={[
70 72
                ]}
71 73
              />
72 74
            </View>
73 75
          )}
74 76

77 +
          {windowLabel && <Text style={styles.window}>{windowLabel}</Text>}
78 +

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

77 81
          {comparison && (
78 82
            <Text style={[styles.comparison, { color: comparisonColor }]}>
79 83
              {comparison}
86 90
);
87 91

88 92
const styles = StyleSheet.create({
89 93
  container: {
90 94
    flex: 1,
95 +
    justifyContent: "space-between",
91 96
  },
92 97
  header: {
93 98
    flexDirection: "row",
94 99
    alignItems: "center",
95 100
    gap: Spacing.inlineGapSm,
105 110
    ...Typography.bodyMd,
106 111
    fontWeight: "700",
107 112
    color: Colors.light.text,
108 113
  },
109 114
  body: {
110 -
    marginTop: 10,
111 -
    gap: 6,
112 -
  },
113 -
  heroRow: {
114 -
    flexDirection: "row",
115 -
    alignItems: "baseline",
116 -
    gap: 6,
115 +
    marginTop: Spacing.cardGap,
116 +
    gap: Spacing.inlineGapSm,
117 117
  },
118 118
  value: {
119 119
    ...Typography.headlineXg,
120 120
    fontSize: 28,
121 121
    lineHeight: 32,
122 +
    color: Colors.light.text,
123 +
  },
124 +
  band: {
125 +
    ...Typography.bodyMd,
126 +
    fontWeight: "700",
122 127
    color: Colors.light.text,
123 128
  },
124 129
  window: {
125 130
    ...Typography.labelSm,
126 131
    fontWeight: "500",
148 153
    ...Typography.labelSm,
149 154
    color: Colors.light.textSecondary,
150 155
    opacity: 0.6,
151 156
  },
152 157
});
frontend/components/ui/DailyEnergyWidget.tsx
1 1
import { InsightMetricCard } from "@/components/insights/InsightMetricCard";
2 2
import { useInsight } from "@/hooks/useInsights.queries";
3 3
import { Colors } from "@/constants/theme";
4 4

5 5
const ENERGY_SCALE_MAX = 10;
16 16
      icon="flash"
17 17
      title="Energia"
18 18
      isLoading={isLoading}
19 19
      emptyMessage="Não há dados disponíveis para o dia de hoje"
20 20
      value={avgEnergy != null ? formatEnergy(avgEnergy) : undefined}
21 +
      band={avgEnergy != null ? energyBand(avgEnergy) : undefined}
21 22
      windowLabel={metadata?.windowLabel}
22 23
      barRatio={
23 24
        avgEnergy != null
24 25
          ? Math.max(0, Math.min(1, avgEnergy / ENERGY_SCALE_MAX))
25 26
          : undefined
30 31
  );
31 32
};
32 33

33 34
function formatEnergy(value: number): string {
34 35
  return value.toFixed(1).replace(".", ",");
36 +
}
37 +

38 +
// Same low/high read as the mood history badge: alta from 7 up, baixa below 4
39 +
function energyBand(value: number): string {
40 +
  if (value < 4) return "Baixa";
41 +
  if (value < 7) return "Média";
42 +

43 +
  return "Alta";
35 44
}
36 45

37 46
// Positive means more energy than yesterday; nothing to compare reads null
38 47
function formatEnergyDiff(diff: number | null): string | null {
39 48
  if (diff == null) return null;
46 55
function energyDiffColor(diff: number | null): string {
47 56
  if (diff == null || diff === 0) return Colors.light.textSecondary;
48 57

49 58
  return diff > 0 ? Colors.light.tint : Colors.light.danger;
50 59
}
frontend/components/ui/DailySleepWidget.tsx
1 1
import { InsightMetricCard } from "@/components/insights/InsightMetricCard";
2 2
import { useInsight } from "@/hooks/useInsights.queries";
3 3
import { Colors } from "@/constants/theme";
4 4

5 5
export const DailySleepWidget = () => {
31 31
      }
32 32
    />
33 33
  );
34 34
};
35 35

36 +
// A single record is capped at 15h, but a day sums them, so guard the display
37 +
const MAX_SLEEP_HOURS = 24;
38 +

36 39
function formatHours(hours: number): string {
37 -
  const h = Math.floor(hours);
38 -
  const m = Math.round((hours - h) * 60);
40 +
  if (hours >= MAX_SLEEP_HOURS) return `${MAX_SLEEP_HOURS}h+`;
41 +

42 +
  const minutes = Math.round(hours * 60);
43 +
  const h = Math.floor(minutes / 60);
39 44

40 -
  if (m === 0) return `${h}h`;
45 +
  if (minutes % 60 === 0) return `${h}h`;
41 46

42 -
  return `${h}h ${m}min`;
47 +
  return `${h}h ${minutes % 60}min`;
43 48
}