mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(insights): add <DailyBriefing> card component
New `src/components/insights/daily-briefing.tsx` renders the
full-width briefing card from the v1.4.20 redesign — narrative
paragraph + 0-5 key-finding rows below it, each row carries a
tone-coloured left bar (green/orange/cyan), metric icon, headline,
detail line, and an optional delta badge in the matching tone.
States covered:
- loaded: paragraph + findings + optional 'Generated <time>' meta
- empty (briefing=null + !loading): EmptyState with 'Generate
briefing' CTA wired through onRegenerate
- loading: animate-pulse skeleton matching the rest of the app's
loading affordance
- empty findings: paragraph still renders; the findings block
hides cleanly so the card doesn't paint a void section header
Optional metaSlot in the card header so the comparison toggle can
migrate from the hero to the briefing card in commit 5.
EN + DE translation keys added (insights.dailyBriefing.*). Test
suite: +18 specs covering rendering + states + locale + tone bars.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
9713bf1606
commit
92f332efe6
2 files changed
+476
No files matched your search
@@ -0,0 +1,203 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { I18nProvider } from "@/lib/i18n/context";
|
||||||
|
import { DailyBriefing } from "../daily-briefing";
|
||||||
|
import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.4.20 phase B1 — full-width Daily Briefing card.
|
||||||
|
*
|
||||||
|
* The card sits below the hero strip on `/insights` and renders the
|
||||||
|
* narrative paragraph + 0-5 key-finding rows produced by the AI
|
||||||
|
* pipeline. Tests cover: paragraph rendering, finding rows + tones +
|
||||||
|
* deltas, locale-aware title, empty state with CTA, loading skeleton,
|
||||||
|
* meta slot.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||||
|
return renderToStaticMarkup(
|
||||||
|
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseBriefing: DailyBriefingPayload = {
|
||||||
|
paragraph:
|
||||||
|
"You're trending well this week. Blood pressure is in target band on 9 of 10 readings, weight is down 2.5 kg over the last 30 days, and your medication compliance streak is now 21 days.",
|
||||||
|
keyFindings: [
|
||||||
|
{
|
||||||
|
tone: "good",
|
||||||
|
headline: "Blood pressure entered target",
|
||||||
|
detail: "9 of last 10 readings under 130/85.",
|
||||||
|
delta: "↓ 4 mmHg",
|
||||||
|
sourceWindow: "30d",
|
||||||
|
sourceMetric: "bp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tone: "watch",
|
||||||
|
headline: "Monday-morning systolic spike",
|
||||||
|
detail: "+6 mmHg vs other weekdays.",
|
||||||
|
delta: "+6 mmHg",
|
||||||
|
sourceWindow: "30d",
|
||||||
|
sourceMetric: "bp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tone: "info",
|
||||||
|
headline: "Weight down 30 d",
|
||||||
|
detail: "Linear, sustainable rate. BMI now 26.0.",
|
||||||
|
delta: null,
|
||||||
|
sourceWindow: "30d",
|
||||||
|
sourceMetric: "weight",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("<DailyBriefing>", () => {
|
||||||
|
it("renders the title in English", () => {
|
||||||
|
const html = render(<DailyBriefing briefing={baseBriefing} />);
|
||||||
|
expect(html).toMatch(/data-slot="daily-briefing"/);
|
||||||
|
expect(html).toContain("Daily Briefing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the title in German", () => {
|
||||||
|
const html = render(<DailyBriefing briefing={baseBriefing} />, "de");
|
||||||
|
expect(html).toContain("Tagesbriefing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the narrative paragraph", () => {
|
||||||
|
const html = render(<DailyBriefing briefing={baseBriefing} />);
|
||||||
|
expect(html).toMatch(/data-slot="daily-briefing-paragraph"/);
|
||||||
|
expect(html).toContain(
|
||||||
|
"Blood pressure is in target band on 9 of 10 readings",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders one row per key finding", () => {
|
||||||
|
const html = render(<DailyBriefing briefing={baseBriefing} />);
|
||||||
|
const matches = html.match(/data-slot="daily-briefing-finding"/g) ?? [];
|
||||||
|
expect(matches.length).toBe(3);
|
||||||
|
expect(html).toContain("Blood pressure entered target");
|
||||||
|
expect(html).toContain("Monday-morning systolic spike");
|
||||||
|
expect(html).toContain("Weight down 30 d");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the delta string when supplied", () => {
|
||||||
|
const html = render(<DailyBriefing briefing={baseBriefing} />);
|
||||||
|
expect(html).toMatch(/data-slot="daily-briefing-delta"/);
|
||||||
|
expect(html).toContain("↓ 4 mmHg");
|
||||||
|
expect(html).toContain("+6 mmHg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT render a delta badge when delta is null", () => {
|
||||||
|
const html = render(
|
||||||
|
<DailyBriefing
|
||||||
|
briefing={{
|
||||||
|
...baseBriefing,
|
||||||
|
keyFindings: [
|
||||||
|
{
|
||||||
|
...baseBriefing.keyFindings[2],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// Only one finding rendered, and it has delta=null.
|
||||||
|
const deltas = html.match(/data-slot="daily-briefing-delta"/g) ?? [];
|
||||||
|
expect(deltas.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses tone-specific colour classes per finding", () => {
|
||||||
|
const html = render(<DailyBriefing briefing={baseBriefing} />);
|
||||||
|
expect(html).toContain("bg-dracula-green"); // tone=good bar
|
||||||
|
expect(html).toContain("bg-dracula-orange"); // tone=watch bar
|
||||||
|
expect(html).toContain("bg-dracula-cyan"); // tone=info bar
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the 'Generated <time>' caption when updatedAt is supplied", () => {
|
||||||
|
const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString();
|
||||||
|
const html = render(
|
||||||
|
<DailyBriefing briefing={baseBriefing} updatedAt={fiveMinutesAgo} />,
|
||||||
|
);
|
||||||
|
expect(html).toMatch(/data-slot="daily-briefing-updated"/);
|
||||||
|
expect(html).toContain("Generated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT render the updated caption when updatedAt is missing", () => {
|
||||||
|
const html = render(<DailyBriefing briefing={baseBriefing} />);
|
||||||
|
expect(html).not.toContain('data-slot="daily-briefing-updated"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the keyFindings title separator", () => {
|
||||||
|
const html = render(<DailyBriefing briefing={baseBriefing} />);
|
||||||
|
expect(html).toMatch(/data-slot="daily-briefing-findings-title"/);
|
||||||
|
expect(html).toContain("Key findings");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the keyFindings section when the array is empty", () => {
|
||||||
|
const html = render(
|
||||||
|
<DailyBriefing
|
||||||
|
briefing={{ ...baseBriefing, keyFindings: [] }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(html).not.toContain('data-slot="daily-briefing-findings-title"');
|
||||||
|
expect(html).not.toContain('data-slot="daily-briefing-findings"');
|
||||||
|
// Paragraph still renders.
|
||||||
|
expect(html).toContain("trending well this week");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the empty state when briefing is null and not loading", () => {
|
||||||
|
const html = render(<DailyBriefing briefing={null} />);
|
||||||
|
expect(html).toMatch(/data-slot="daily-briefing-empty"/);
|
||||||
|
expect(html).toContain("No briefing yet");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the empty-state CTA when onRegenerate is supplied", () => {
|
||||||
|
const html = render(
|
||||||
|
<DailyBriefing briefing={null} onRegenerate={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(html).toMatch(/data-slot="daily-briefing-empty-cta"/);
|
||||||
|
expect(html).toContain("Generate briefing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the empty-state CTA when onRegenerate is not supplied", () => {
|
||||||
|
const html = render(<DailyBriefing briefing={null} />);
|
||||||
|
expect(html).not.toContain('data-slot="daily-briefing-empty-cta"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables the empty-state CTA while regenerating", () => {
|
||||||
|
const html = render(
|
||||||
|
<DailyBriefing
|
||||||
|
briefing={null}
|
||||||
|
onRegenerate={() => {}}
|
||||||
|
regenerating
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(html).toMatch(
|
||||||
|
/data-slot="daily-briefing-empty-cta"[^>]*disabled/,
|
||||||
|
);
|
||||||
|
expect(html).toContain("Regenerating");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the shimmer skeleton when loading", () => {
|
||||||
|
const html = render(<DailyBriefing briefing={null} loading />);
|
||||||
|
expect(html).toMatch(/data-slot="daily-briefing-skeleton"/);
|
||||||
|
expect(html).toContain("animate-pulse");
|
||||||
|
// Empty state must NOT render while loading.
|
||||||
|
expect(html).not.toContain('data-slot="daily-briefing-empty"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the meta slot when supplied", () => {
|
||||||
|
const html = render(
|
||||||
|
<DailyBriefing
|
||||||
|
briefing={baseBriefing}
|
||||||
|
metaSlot={<span data-testid="meta-token">meta-content</span>}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(html).toMatch(/data-slot="daily-briefing-meta-slot"/);
|
||||||
|
expect(html).toContain("meta-content");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT render the meta-slot wrapper when metaSlot is missing", () => {
|
||||||
|
const html = render(<DailyBriefing briefing={baseBriefing} />);
|
||||||
|
expect(html).not.toContain('data-slot="daily-briefing-meta-slot"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Activity, FileText, Heart, Pill, Scale, Smile, Sparkles } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
|
import { useTranslations } from "@/lib/i18n/context";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type {
|
||||||
|
DailyBriefing as DailyBriefingPayload,
|
||||||
|
DailyBriefingKeyFinding,
|
||||||
|
} from "@/lib/ai/schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.4.20 phase B1 — full-width Daily Briefing card.
|
||||||
|
*
|
||||||
|
* Renders the narrative paragraph + 0-5 key-finding rows synthesised
|
||||||
|
* by the AI insight pipeline. Lives directly below the hero strip on
|
||||||
|
* `/insights`. Keeps the existing per-section advisor + status cards
|
||||||
|
* untouched — this card is additive, not a replacement.
|
||||||
|
*
|
||||||
|
* Loading state: shimmer skeleton via Tailwind's `animate-pulse` so
|
||||||
|
* it matches the rest of the app.
|
||||||
|
*
|
||||||
|
* Empty state: when `briefing === null && !loading`, renders an
|
||||||
|
* <EmptyState> with a "Generate briefing" CTA wired through
|
||||||
|
* `onRegenerate`. The CTA hides cleanly when no handler is supplied.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface DailyBriefingProps {
|
||||||
|
/** Briefing payload — null when the cache has none. */
|
||||||
|
briefing: DailyBriefingPayload | null;
|
||||||
|
/** ISO timestamp of the briefing's generation, surfaced as a meta line. */
|
||||||
|
updatedAt?: string | null;
|
||||||
|
/** Loading state — shimmer skeleton replaces content. */
|
||||||
|
loading?: boolean;
|
||||||
|
/** Optional CTA wiring for the empty-state generate button. */
|
||||||
|
onRegenerate?: () => void;
|
||||||
|
/** Disables the regenerate CTA while a generation is in flight. */
|
||||||
|
regenerating?: boolean;
|
||||||
|
/**
|
||||||
|
* Optional slot for a meta control mounted in the card header — the
|
||||||
|
* comparison toggle migrates here from the hero in commit 5.
|
||||||
|
*/
|
||||||
|
metaSlot?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const METRIC_ICON: Record<DailyBriefingKeyFinding["sourceMetric"], React.ComponentType<{ className?: string }>> = {
|
||||||
|
bp: Heart,
|
||||||
|
weight: Scale,
|
||||||
|
pulse: Activity,
|
||||||
|
mood: Smile,
|
||||||
|
compliance: Pill,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TONE_BAR_CLASSNAME: Record<DailyBriefingKeyFinding["tone"], string> = {
|
||||||
|
good: "bg-dracula-green",
|
||||||
|
watch: "bg-dracula-orange",
|
||||||
|
info: "bg-dracula-cyan",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TONE_TEXT_CLASSNAME: Record<DailyBriefingKeyFinding["tone"], string> = {
|
||||||
|
good: "text-dracula-green",
|
||||||
|
watch: "text-dracula-orange",
|
||||||
|
info: "text-dracula-cyan",
|
||||||
|
};
|
||||||
|
|
||||||
|
function DeltaBadge({
|
||||||
|
delta,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
delta: string | null;
|
||||||
|
tone: DailyBriefingKeyFinding["tone"];
|
||||||
|
}) {
|
||||||
|
if (!delta) return null;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="daily-briefing-delta"
|
||||||
|
className={cn("text-xs font-semibold tabular-nums", TONE_TEXT_CLASSNAME[tone])}
|
||||||
|
>
|
||||||
|
{delta}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyFindingRow({ finding }: { finding: DailyBriefingKeyFinding }) {
|
||||||
|
const Icon = METRIC_ICON[finding.sourceMetric];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="daily-briefing-finding"
|
||||||
|
className="border-border/60 bg-card/40 relative flex items-start gap-3 rounded-md border p-3"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"absolute top-3 bottom-3 left-0 w-[3px] rounded-r",
|
||||||
|
TONE_BAR_CLASSNAME[finding.tone],
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
className={cn("mt-0.5 h-4 w-4 shrink-0", TONE_TEXT_CLASSNAME[finding.tone])}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<p className="text-sm leading-snug font-medium">{finding.headline}</p>
|
||||||
|
<DeltaBadge delta={finding.delta} tone={finding.tone} />
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs leading-snug">{finding.detail}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BriefingSkeleton() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="daily-briefing-skeleton"
|
||||||
|
className="space-y-4 motion-reduce:animate-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="bg-muted/60 h-3 w-11/12 animate-pulse rounded" />
|
||||||
|
<div className="bg-muted/60 h-3 w-10/12 animate-pulse rounded" />
|
||||||
|
<div className="bg-muted/60 h-3 w-9/12 animate-pulse rounded" />
|
||||||
|
<div className="bg-muted/60 h-3 w-8/12 animate-pulse rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="border-border/40 bg-card/30 flex h-16 items-center rounded-md border p-3"
|
||||||
|
>
|
||||||
|
<div className="bg-muted/60 h-3 w-1/3 animate-pulse rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DailyBriefing({
|
||||||
|
briefing,
|
||||||
|
updatedAt,
|
||||||
|
loading = false,
|
||||||
|
onRegenerate,
|
||||||
|
regenerating = false,
|
||||||
|
metaSlot,
|
||||||
|
}: DailyBriefingProps) {
|
||||||
|
const { t } = useTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card data-slot="daily-briefing" className="overflow-hidden">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles
|
||||||
|
className="text-dracula-purple h-4 w-4 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<CardTitle className="text-base font-semibold">
|
||||||
|
{t("insights.dailyBriefing.title")}
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
{metaSlot && (
|
||||||
|
<div data-slot="daily-briefing-meta-slot" className="flex items-center gap-2">
|
||||||
|
{metaSlot}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<span className="sr-only" aria-live="polite">
|
||||||
|
{t("insights.dailyBriefing.loadingLabel")}
|
||||||
|
</span>
|
||||||
|
<BriefingSkeleton />
|
||||||
|
</>
|
||||||
|
) : briefing ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p
|
||||||
|
data-slot="daily-briefing-paragraph"
|
||||||
|
className="text-foreground text-sm leading-relaxed"
|
||||||
|
>
|
||||||
|
{briefing.paragraph}
|
||||||
|
</p>
|
||||||
|
{briefing.keyFindings.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p
|
||||||
|
data-slot="daily-briefing-findings-title"
|
||||||
|
className="text-muted-foreground text-[11px] font-semibold tracking-wide uppercase"
|
||||||
|
>
|
||||||
|
{t("insights.dailyBriefing.keyFindingsTitle")}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
data-slot="daily-briefing-findings"
|
||||||
|
className="space-y-2"
|
||||||
|
>
|
||||||
|
{briefing.keyFindings.map((finding, index) => (
|
||||||
|
<KeyFindingRow
|
||||||
|
key={`${finding.sourceMetric}-${index}`}
|
||||||
|
finding={finding}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{updatedAt && (
|
||||||
|
<p
|
||||||
|
data-slot="daily-briefing-updated"
|
||||||
|
className="text-muted-foreground border-border/60 border-t pt-3 text-xs"
|
||||||
|
>
|
||||||
|
{t("insights.heroGenerated", {
|
||||||
|
time: formatRelativeTime(updatedAt, t),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
data-slot="daily-briefing-empty"
|
||||||
|
variant="plain"
|
||||||
|
icon={<FileText className="size-5" />}
|
||||||
|
title={t("insights.dailyBriefing.emptyTitle")}
|
||||||
|
description={t("insights.dailyBriefing.emptyDescription")}
|
||||||
|
action={
|
||||||
|
onRegenerate ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onRegenerate}
|
||||||
|
disabled={regenerating}
|
||||||
|
data-slot="daily-briefing-empty-cta"
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
{regenerating
|
||||||
|
? t("insights.heroRegenerating")
|
||||||
|
: t("insights.dailyBriefing.emptyAction")}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 });
|
||||||
|
}
|
||||||
Reference in new issue
Block a user