eletrotupi / tcc/ commit / 8873ae7

frontend/insights: the daily pair reads today's insight

The insight hook now returns metadata typed for the type requested, so
the energy and sleep cards read their comparison and window fields with
no cast. The cache key picks up the device's local date, so the pair
refetches after midnight rather than keeping yesterday's row
Pedro Lucas Porcellis porcellis@eletrotupi.com · 12 days ago 8873ae72bb5c5381f95356155b0c0cb1d4041156 Browse files
Parents b36ca93
Changed files
5
Additions
+66
Deletions
-61
frontend/components/ui/DailyEnergyWidget.tsx
1 1
import { ActivityIndicator, StyleSheet, View } from "react-native";
2 2
import { Text } from "@/components/ui/Text";
3 3
import { Card } from "@/components/ui/Cards";
4 4
import { useInsight } from "@/hooks/useInsights.queries";
5 5
import { Ionicons } from "@expo/vector-icons";
6 6
import { Typography, Spacing, Colors, BorderRadius } from "@/constants/theme";
7 7

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

11 -
  const metadata = insight?.metadata as {
12 -
    peak: number;
13 -
    trough: number;
14 -
    entryCount: number;
15 -
    avgEnergy: number;
16 -
  };
11 +
  const metadata = insight?.metadata;
17 12

18 -
  const avgEnergy = metadata?.avgEnergy;
19 -
  const peak = metadata?.peak;
13 +
  const avgEnergy = metadata?.avgEnergy ?? 0;
14 +
  const peak = metadata?.peak ?? 0;
20 15

21 -
  const energyWidth = (avgEnergy / peak) * 100;
16 +
  const energyWidth = peak > 0 ? (avgEnergy / peak) * 100 : 0;
22 17
  const energySummary = () => {
23 18
    if (avgEnergy < 3) return "Energia baixa";
24 19
    if (avgEnergy < 5) return "Media baixa";
25 20
    if (avgEnergy < 7.5) return "Média Alta";
26 21
    return "Energia alta";
96 91
    fontSize: 11,
97 92
    fontWeight: 600,
98 93
    lineHeight: 16.5,
99 94
  },
100 95
});
frontend/components/ui/DailySleepWidget.tsx
1 1
import React from "react";
2 2
import { View, StyleSheet, ActivityIndicator } from "react-native";
3 3
import { Text } from "@/components/ui/Text";
4 4
import { Card } from "./Cards";
5 5
import { Colors, Spacing, Typography } from "@/constants/theme";
7 7
import { useInsight } from "@/hooks/useInsights.queries";
8 8

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

12 -
  const metadata = insight?.metadata as
13 -
    | {
14 -
        diffStr: string;
15 -
        diffMinutes: number;
16 -
      }
17 -
    | undefined;
12 +
  const metadata = insight?.metadata;
18 13

19 14
  const diffColor = metadata?.diffMinutes
20 15
    ? metadata.diffMinutes < 0
21 16
      ? Colors.light.danger
22 17
      : Colors.light.tint
76 71
  empty: {
77 72
    ...Typography.labelSm,
78 73
    opacity: 0.5,
79 74
  },
80 75
});
frontend/hooks/useInsights.queries.ts
1 +
import { format } from "date-fns";
1 2
import { useQuery } from "@tanstack/react-query";
2 3
import { apiClient, Insight, InsightType, InsightPeriod } from "@/lib/api";
4 +

5 +
// The device's local calendar day, used only to roll the cache over at midnight
6 +
// so the pair refetches; the server still resolves the actual day boundary
7 +
function localDay(): string {
8 +
  return format(new Date(), "yyyy-MM-dd");
9 +
}
3 10

4 11
export const insightKeys = {
5 12
  all: () => ["insights"] as const,
6 13
  lists: () => [...insightKeys.all(), "list"] as const,
7 14
  list: (filters?: InsightFilters) =>
8 15
    [...insightKeys.lists(), filters] as const,
9 -
  byType: (type: InsightType) => [...insightKeys.all(), "type", type] as const,
16 +
  byType: (type: InsightType) =>
17 +
    [...insightKeys.all(), "type", type, localDay()] as const,
10 18
};
11 19

12 20
interface InsightFilters {
13 21
  type?: InsightType;
14 22
  period?: InsightPeriod;
27 35
  ENERGY_SLEEP_CORRELATION: "WEEKLY",
28 36
  TRIGGER_PATTERN: "WEEKLY",
29 37
  DAILY_SLEEP: "DAILY",
30 38
};
31 39

32 -
export const useInsight = (type: InsightType) => {
40 +
export const useInsight = <T extends InsightType>(type: T) => {
33 41
  const period = INSIGHT_PERIOD[type];
34 42
  const staleTime = INSIGHT_STALE_TIMES[period];
35 43

36 44
  return useQuery({
37 45
    queryKey: insightKeys.byType(type),
38 46
    queryFn: () =>
39 47
      apiClient
40 -
        .getInsights({ type, limit: 1 })
48 +
        .getInsights<T>({ type, limit: 1 })
41 49
        .then((res) => res.insights[0] ?? null),
42 50
    staleTime,
43 51
  });
44 52
};
45 53

48 56
    queryKey: insightKeys.list(filters),
49 57
    queryFn: () => apiClient.getInsights(filters).then((res) => res.insights),
50 58
    staleTime: 1000 * 60 * 15,
51 59
  });
52 60
};
frontend/lib/api/client.ts
1 1
import * as SecureStore from "expo-secure-store";
2 2
import Constants from "expo-constants";
3 3

4 4
export class ApiError extends Error {
5 5
  status: number;
362 362
  async deleteTrigger(id: string): Promise<void> {
363 363
    return this.request(`/triggers/${id}`, { method: "DELETE" });
364 364
  }
365 365

366 366
  // Insights
367 -
  async getInsights(filters?: {
368 -
    type?: InsightType;
367 +
  async getInsights<T extends InsightType = InsightType>(filters?: {
368 +
    type?: T;
369 369
    period?: InsightPeriod;
370 370
    limit?: number;
371 -
  }): Promise<{ insights: Insight[] }> {
371 +
  }): Promise<{ insights: Insight<T>[] }> {
372 372
    const query = new URLSearchParams();
373 373
    if (filters?.type) query.set("type", filters.type);
374 374
    if (filters?.period) query.set("period", filters.period);
375 375
    if (filters?.limit) query.set("limit", String(filters.limit));
376 376

377 377
    const qs = query.toString();
378 -
    return this.request(`/insights${qs ? `?${qs}` : ""}`);
378 +
    return this.request<{ insights: Insight<T>[] }>(
379 +
      `/insights${qs ? `?${qs}` : ""}`,
380 +
    );
379 381
  }
380 382

381 383
  // Medicine Regimens
382 384
  async getMedicineRegimens(): Promise<{ regimens: MedicineRegimen[] }> {
383 385
    return this.request('/medicine-regimens');
525 527
    return response.arrayBuffer()
526 528
  }
527 529
}
528 530

529 531
export const apiClient = new ApiClient();
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;
167 167
  | "DAILY_ENERGY"
168 168
  | "DAILY_SLEEP";
169 169

170 170
export type InsightPeriod = "DAILY" | "WEEKLY" | "MONTHLY";
171 171

172 -
export type InsightMetadata =
173 -
  | {
174 -
      type: "MOOD_TREND";
175 -
      delta: number;
176 -
      avgFirst: number;
177 -
      avgSecond: number;
178 -
      dominantMood: string;
179 -
      avgEnergy: number;
180 -
    }
181 -
  | {
182 -
      type: "ENERGY_SLEEP_CORRELATION";
183 -
      correlationScore: number;
184 -
      sampleSize: number;
185 -
    }
186 -
  | {
187 -
      type: "TRIGGER_PATTERN";
188 -
      topCategory: string;
189 -
      topCount: number;
190 -
      total: number;
191 -
      distribution: Record<string, number>;
192 -
    }
193 -
  | {
194 -
      type: "DAILY_ENERGY";
195 -
      avgEnergy: number;
196 -
      dayTrend: number | null;
197 -
      peak: number;
198 -
      trough: number;
199 -
      entryCount: number;
200 -
    }
201 -
  | {
202 -
      type: "DAILY_SLEEP";
203 -
      totalHours: number;
204 -
      diffMinutes: number;
205 -
      sessionCount: number;
206 -
      hadNaps: boolean;
207 -
    };
172 +
export interface InsightMetadataByType {
173 +
  MOOD_TREND: {
174 +
    delta: number;
175 +
    avgFirst: number;
176 +
    avgSecond: number;
177 +
    dominantMood: string;
178 +
    avgEnergy: number;
179 +
  };
180 +
  ENERGY_SLEEP_CORRELATION: {
181 +
    correlationScore: number;
182 +
    sampleSize: number;
183 +
  };
184 +
  TRIGGER_PATTERN: {
185 +
    topCategory: string;
186 +
    topCount: number;
187 +
    total: number;
188 +
    distribution: Record<string, number>;
189 +
  };
190 +
  DAILY_ENERGY: {
191 +
    avgEnergy: number;
192 +
    dayTrend: number | null;
193 +
    peak: number;
194 +
    trough: number;
195 +
    entryCount: number;
196 +
    yesterdayAvg: number | null;
197 +
    diffEnergy: number | null;
198 +
    windowLabel: string;
199 +
  };
200 +
  DAILY_SLEEP: {
201 +
    totalHours: number;
202 +
    diffMinutes: number | null;
203 +
    diffStr: string;
204 +
    sessionCount: number;
205 +
    napStr: string;
206 +
    hadNaps: boolean;
207 +
    windowLabel: string;
208 +
  };
209 +
}
210 +

211 +
export type InsightMetadata<T extends InsightType = InsightType> =
212 +
  InsightMetadataByType[T];
208 213

209 -
export type Insight = {
214 +
export type Insight<T extends InsightType = InsightType> = {
210 215
  id: number;
211 -
  type: InsightType;
216 +
  type: T;
212 217
  period: InsightPeriod;
213 218
  title: string;
214 219
  body: string;
215 -
  metadata: InsightMetadata;
220 +
  metadata: InsightMetadata<T>;
216 221
  generatedAt: string;
217 222
  periodStart: string;
218 223
  periodEnd: string;
219 224
};
220 225

389 394
  moodId?: number;
390 395
}
391 396
export interface MedicineRegimenResponse { regimen: MedicineRegimen; }
392 397

393 398
export interface LinkMoodPayload { moodId: number; perceivedImpact: number; }