mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
refactor(insights): consolidate relative-time and confidence-band tables
Three live byte-identical formatRelativeTime copies (hero-strip, daily-briefing, coach history-rail) lifted to src/lib/i18n/relative- time.ts. Both correlation-card and trend-annotation duplicated the exact same low/moderate/high Dracula classnames; extracted to src/components/insights/confidence-badge.ts (per-component i18n labels stay local — the copy differs). Drop the dead "null" placeholder branch in useCoachConversation's queryKey: the ternary picked a different key when id was null, but enabled: id !== null kept the query disabled so the placeholder never fired. Key directly on id. Hoist STORYBOARD_COLOR_BY_CATEGORY in /insights/page.tsx to module scope so the map isn't recreated on every render. Fixes Simplify-S-02, Simplify-S-03, Simplify-S-04, Simplify-S-05, Code-LOW-02. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
cfe6d1c577
commit
340239423b
9 files changed
+66
-84
No files matched your search
@@ -518,6 +518,16 @@ function getMedicationComplianceSectionStatus(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.4.20 phase D reconcile — module-scope so the map isn't recreated
|
||||||
|
// on every render of `<InsightsPage>` (the page renders often on
|
||||||
|
// TanStack-Query refetches; this map carries no closures over locals).
|
||||||
|
const STORYBOARD_COLOR_BY_CATEGORY: Record<string, string> = {
|
||||||
|
medication: "var(--dracula-pink)",
|
||||||
|
event: "var(--dracula-cyan)",
|
||||||
|
milestone: "var(--dracula-green)",
|
||||||
|
warning: "var(--dracula-orange)",
|
||||||
|
};
|
||||||
|
|
||||||
export default function InsightsPage() {
|
export default function InsightsPage() {
|
||||||
const { isAuthenticated, user } = useAuth();
|
const { isAuthenticated, user } = useAuth();
|
||||||
const { t, locale } = useTranslations();
|
const { t, locale } = useTranslations();
|
||||||
@@ -915,14 +925,9 @@ export default function InsightsPage() {
|
|||||||
// v1.4.20 phase B4 — storyboard annotations for the 90-day BP chart.
|
// v1.4.20 phase B4 — storyboard annotations for the 90-day BP chart.
|
||||||
// The advisor payload may carry up to 20 entries; we transform them
|
// The advisor payload may carry up to 20 entries; we transform them
|
||||||
// into the {date, label, color} shape <HealthChart> consumes. The
|
// into the {date, label, color} shape <HealthChart> consumes. The
|
||||||
// colour map mirrors the four canonical categories. Cached payloads
|
// colour map mirrors the four canonical categories (hoisted to
|
||||||
// from before PROMPT_VERSION 4.20.2 simply produce an empty array.
|
// module scope above). Cached payloads from before PROMPT_VERSION
|
||||||
const STORYBOARD_COLOR_BY_CATEGORY: Record<string, string> = {
|
// 4.20.2 simply produce an empty array.
|
||||||
medication: "var(--dracula-pink)",
|
|
||||||
event: "var(--dracula-cyan)",
|
|
||||||
milestone: "var(--dracula-green)",
|
|
||||||
warning: "var(--dracula-orange)",
|
|
||||||
};
|
|
||||||
const rawStoryboard = (
|
const rawStoryboard = (
|
||||||
advisor.payload?.insights as
|
advisor.payload?.insights as
|
||||||
| { storyboardAnnotations?: Array<{
|
| { storyboardAnnotations?: Array<{
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useTranslations } from "@/lib/i18n/context";
|
import { useTranslations } from "@/lib/i18n/context";
|
||||||
|
import { formatRelativeTime } from "@/lib/i18n/relative-time";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useCoachConversations,
|
useCoachConversations,
|
||||||
@@ -37,22 +38,6 @@ export interface HistoryRailProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRelativeTime(
|
|
||||||
iso: string,
|
|
||||||
t: (key: string, params?: Record<string, string | number>) => string,
|
|
||||||
): string {
|
|
||||||
const target = new Date(iso).getTime();
|
|
||||||
if (Number.isNaN(target)) return "";
|
|
||||||
const diffMs = Date.now() - target;
|
|
||||||
if (diffMs < 60_000) return t("insights.relativeJustNow");
|
|
||||||
const minutes = Math.floor(diffMs / 60_000);
|
|
||||||
if (minutes < 60) return t("insights.relativeMinutesAgo", { count: minutes });
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
if (hours < 24) return t("insights.relativeHoursAgo", { count: hours });
|
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
return t("insights.relativeDaysAgo", { count: days });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HistoryRail({
|
export function HistoryRail({
|
||||||
activeId,
|
activeId,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
|||||||
@@ -82,7 +82,11 @@ export function useCoachConversations(enabled = true) {
|
|||||||
*/
|
*/
|
||||||
export function useCoachConversation(id: string | null) {
|
export function useCoachConversation(id: string | null) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: id ? QUERY_KEYS.one(id) : ["coachConversation", "null"],
|
// The query is gated on `enabled: id !== null`, so the queryFn
|
||||||
|
// never fires when `id` is null. Key directly on `id` (TanStack
|
||||||
|
// Query accepts `null` in the array) — the previous "null"
|
||||||
|
// placeholder branch was structurally equivalent dead code.
|
||||||
|
queryKey: ["coachConversation", id] as const,
|
||||||
queryFn: async (): Promise<CoachConversationDetailDTO> => {
|
queryFn: async (): Promise<CoachConversationDetailDTO> => {
|
||||||
if (!id) throw new Error("missing id");
|
if (!id) throw new Error("missing id");
|
||||||
const res = await fetch(`/api/insights/chat/${id}`, {
|
const res = await fetch(`/api/insights/chat/${id}`, {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* v1.4.20 phase D reconcile — shared confidence-band classnames.
|
||||||
|
*
|
||||||
|
* Both `<CorrelationCard>` and `<TrendAnnotation>` paint a discrete
|
||||||
|
* "low / moderate / high" badge with byte-identical Dracula utility
|
||||||
|
* classes. Per-component i18n key maps stay local because the copy
|
||||||
|
* differs (correlationRow.* vs trendAnnotation.*); only the classname
|
||||||
|
* table is the load-bearing duplication.
|
||||||
|
*/
|
||||||
|
export type ConfidenceBand = "low" | "moderate" | "high";
|
||||||
|
|
||||||
|
export const CONFIDENCE_BADGE_CLASS: Record<ConfidenceBand, string> = {
|
||||||
|
high: "border-dracula-green/40 bg-dracula-green/10 text-dracula-green",
|
||||||
|
moderate: "border-dracula-orange/40 bg-dracula-orange/10 text-dracula-orange",
|
||||||
|
low: "border-dracula-comment/40 bg-dracula-comment/10 text-muted-foreground",
|
||||||
|
};
|
||||||
@@ -9,6 +9,7 @@ import { EmptyState } from "@/components/ui/empty-state";
|
|||||||
import { useTranslations } from "@/lib/i18n/context";
|
import { useTranslations } from "@/lib/i18n/context";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CorrelationResult } from "@/lib/insights/correlations";
|
import type { CorrelationResult } from "@/lib/insights/correlations";
|
||||||
|
import { CONFIDENCE_BADGE_CLASS } from "./confidence-badge";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v1.4.20 phase B3 — single Correlation card.
|
* v1.4.20 phase B3 — single Correlation card.
|
||||||
@@ -58,14 +59,6 @@ const SUBTITLE_KEY: Record<CorrelationResult["kind"], string> = {
|
|||||||
"weight-weekday": "insights.correlationRow.card.weightWeekdaySubtitle",
|
"weight-weekday": "insights.correlationRow.card.weightWeekdaySubtitle",
|
||||||
};
|
};
|
||||||
|
|
||||||
const CONFIDENCE_BADGE_CLASSNAME: Record<"low" | "moderate" | "high", string> =
|
|
||||||
{
|
|
||||||
high: "border-dracula-green/40 bg-dracula-green/10 text-dracula-green",
|
|
||||||
moderate:
|
|
||||||
"border-dracula-orange/40 bg-dracula-orange/10 text-dracula-orange",
|
|
||||||
low: "border-dracula-comment/40 bg-dracula-comment/10 text-muted-foreground",
|
|
||||||
};
|
|
||||||
|
|
||||||
const CONFIDENCE_LABEL_KEY: Record<"low" | "moderate" | "high", string> = {
|
const CONFIDENCE_LABEL_KEY: Record<"low" | "moderate" | "high", string> = {
|
||||||
high: "insights.correlationRow.confidenceHigh",
|
high: "insights.correlationRow.confidenceHigh",
|
||||||
moderate: "insights.correlationRow.confidenceModerate",
|
moderate: "insights.correlationRow.confidenceModerate",
|
||||||
@@ -102,7 +95,7 @@ export function CorrelationCard({ result }: CorrelationCardProps) {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 text-[10px]",
|
"shrink-0 text-[10px]",
|
||||||
CONFIDENCE_BADGE_CLASSNAME[result.confidenceBand.label],
|
CONFIDENCE_BADGE_CLASS[result.confidenceBand.label],
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{t(CONFIDENCE_LABEL_KEY[result.confidenceBand.label])}
|
{t(CONFIDENCE_LABEL_KEY[result.confidenceBand.label])}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { EmptyState } from "@/components/ui/empty-state";
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
import { useTranslations } from "@/lib/i18n/context";
|
import { useTranslations } from "@/lib/i18n/context";
|
||||||
|
import { formatRelativeTime } from "@/lib/i18n/relative-time";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type {
|
import type {
|
||||||
DailyBriefing as DailyBriefingPayload,
|
DailyBriefing as DailyBriefingPayload,
|
||||||
@@ -251,23 +252,3 @@ export function DailyBriefing({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Bucketed relative-time using i18n translation keys so every surface
|
|
||||||
* shares vocabulary. Buckets: <1m, <1h, <24h, else days. Mirrors the
|
|
||||||
* helper in `<InsightsPageHero>` so the two cards format the same way.
|
|
||||||
*/
|
|
||||||
function formatRelativeTime(
|
|
||||||
iso: string,
|
|
||||||
t: (key: string, params?: Record<string, string | number>) => string,
|
|
||||||
): string {
|
|
||||||
const target = new Date(iso).getTime();
|
|
||||||
if (Number.isNaN(target)) return "";
|
|
||||||
const diffMs = Date.now() - target;
|
|
||||||
if (diffMs < 60_000) return t("insights.relativeJustNow");
|
|
||||||
const minutes = Math.floor(diffMs / 60_000);
|
|
||||||
if (minutes < 60) return t("insights.relativeMinutesAgo", { count: minutes });
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
if (hours < 24) return t("insights.relativeHoursAgo", { count: hours });
|
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
return t("insights.relativeDaysAgo", { count: days });
|
|
||||||
}
|
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useTranslations } from "@/lib/i18n/context";
|
import { useTranslations } from "@/lib/i18n/context";
|
||||||
|
import { formatRelativeTime } from "@/lib/i18n/relative-time";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { SuggestedPrompts } from "./suggested-prompts";
|
import { SuggestedPrompts } from "./suggested-prompts";
|
||||||
import {
|
import {
|
||||||
@@ -134,26 +135,6 @@ function resolveGreetingKey(now: Date): string {
|
|||||||
return "insights.heroGreetingNight";
|
return "insights.heroGreetingNight";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Bucketed relative-time using i18n translation keys so every surface
|
|
||||||
* shares vocabulary. Mirrors the helper in `<InsightsPageHero>`.
|
|
||||||
*/
|
|
||||||
function formatRelativeTime(
|
|
||||||
iso: string,
|
|
||||||
t: (key: string, params?: Record<string, string | number>) => string,
|
|
||||||
): string {
|
|
||||||
const target = new Date(iso).getTime();
|
|
||||||
if (Number.isNaN(target)) return "";
|
|
||||||
const diffMs = Date.now() - target;
|
|
||||||
if (diffMs < 60_000) return t("insights.relativeJustNow");
|
|
||||||
const minutes = Math.floor(diffMs / 60_000);
|
|
||||||
if (minutes < 60) return t("insights.relativeMinutesAgo", { count: minutes });
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
if (hours < 24) return t("insights.relativeHoursAgo", { count: hours });
|
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
return t("insights.relativeDaysAgo", { count: days });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HeroStrip({
|
export function HeroStrip({
|
||||||
briefing,
|
briefing,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Sparkles } from "lucide-react";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { useTranslations } from "@/lib/i18n/context";
|
import { useTranslations } from "@/lib/i18n/context";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { CONFIDENCE_BADGE_CLASS } from "./confidence-badge";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v1.4.20 phase B3 — single-sentence AI annotation rendered directly
|
* v1.4.20 phase B3 — single-sentence AI annotation rendered directly
|
||||||
@@ -30,15 +31,6 @@ interface TrendAnnotationProps {
|
|||||||
confidence?: TrendAnnotationConfidenceBand;
|
confidence?: TrendAnnotationConfidenceBand;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONFIDENCE_BADGE_CLASSNAME: Record<
|
|
||||||
TrendAnnotationConfidenceBand,
|
|
||||||
string
|
|
||||||
> = {
|
|
||||||
high: "border-dracula-green/40 bg-dracula-green/10 text-dracula-green",
|
|
||||||
moderate: "border-dracula-orange/40 bg-dracula-orange/10 text-dracula-orange",
|
|
||||||
low: "border-dracula-comment/40 bg-dracula-comment/10 text-muted-foreground",
|
|
||||||
};
|
|
||||||
|
|
||||||
const CONFIDENCE_LABEL_KEY: Record<TrendAnnotationConfidenceBand, string> = {
|
const CONFIDENCE_LABEL_KEY: Record<TrendAnnotationConfidenceBand, string> = {
|
||||||
high: "insights.trendAnnotation.confidenceHigh",
|
high: "insights.trendAnnotation.confidenceHigh",
|
||||||
moderate: "insights.trendAnnotation.confidenceModerate",
|
moderate: "insights.trendAnnotation.confidenceModerate",
|
||||||
@@ -86,7 +78,7 @@ export function TrendAnnotation({
|
|||||||
<Badge
|
<Badge
|
||||||
data-slot="trend-annotation-confidence"
|
data-slot="trend-annotation-confidence"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn("text-[10px]", CONFIDENCE_BADGE_CLASSNAME[confidence])}
|
className={cn("text-[10px]", CONFIDENCE_BADGE_CLASS[confidence])}
|
||||||
>
|
>
|
||||||
{t(CONFIDENCE_LABEL_KEY[confidence])}
|
{t(CONFIDENCE_LABEL_KEY[confidence])}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* v1.4.20 phase D reconcile — shared relative-time helper.
|
||||||
|
*
|
||||||
|
* Formats an ISO timestamp as a localised "just now" / "N minutes ago"
|
||||||
|
* string via i18n keys. Three Insights surfaces (hero strip, daily
|
||||||
|
* briefing, history rail) carried byte-identical copies of this helper;
|
||||||
|
* lift the body here so a future i18n key change lands once.
|
||||||
|
*
|
||||||
|
* Buckets: <1m → just-now, <1h → minutes, <24h → hours, else days.
|
||||||
|
*/
|
||||||
|
export function formatRelativeTime(
|
||||||
|
iso: string,
|
||||||
|
t: (key: string, params?: Record<string, string | number>) => string,
|
||||||
|
): string {
|
||||||
|
const target = new Date(iso).getTime();
|
||||||
|
if (Number.isNaN(target)) return "";
|
||||||
|
const diffMs = Date.now() - target;
|
||||||
|
if (diffMs < 60_000) return t("insights.relativeJustNow");
|
||||||
|
const minutes = Math.floor(diffMs / 60_000);
|
||||||
|
if (minutes < 60) return t("insights.relativeMinutesAgo", { count: minutes });
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return t("insights.relativeHoursAgo", { count: hours });
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return t("insights.relativeDaysAgo", { count: days });
|
||||||
|
}
|
||||||
Reference in new issue
Block a user