Merge branch 'main' into feat/encounter-phase-1

This commit is contained in:
Marc-André Bombeck committed 2026-08-08 12:22:04 +02:00
commit a83be074ca
65 files changed
+7864 -624

No files matched your search

@@ -3,7 +3,6 @@ import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
/**
* v1.8.6 QA — the in-target share must appear exactly once on the mood
@@ -11,10 +10,18 @@ import { queryKeys } from "@/lib/query-keys";
* (inTargetPct present) the same-number `in-target` takeaway is dropped
* from the narrative feed so the percentage is not duplicated one row
* below the headline tile.
*
* Driven against `<MoodInsightsBreakdowns>` rather than its parent since the
* breakdown cluster moved into its own `next/dynamic` chunk: under a static
* render the parent paints the loader skeleton and nothing this file is about
* is in the markup. The breakdown module takes the resolved payload as a prop,
* so the aggregate no longer has to be seeded into a query cache to reach the
* assertion. The provider and the auth stub stay because the "what stands
* out" card inside the cluster fetches the discovery surface itself.
*/
// `useAuth` runs its own /me query; stub it to a signed-in user so the
// section's `enabled: isAuthenticated` gate opens without a fetch.
// self-fetching card inside the cluster resolves without a request.
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1" },
@@ -25,7 +32,8 @@ vi.mock("@/hooks/use-auth", () => ({
}),
}));
import { MoodInsightsSections } from "../mood-insights-sections";
import { MoodInsightsBreakdowns } from "../mood-insights-breakdowns";
import type { MoodInsightsResponse } from "../mood-insights-shared";
type Response = {
summary: { totalEntries: number; inTargetPct: number | null };
@@ -62,12 +70,12 @@ function renderWithData(data: Response) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
// Seed the cache so the synchronous SSR render sees resolved data.
queryClient.setQueryData(queryKeys.moodInsights(), data);
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<MoodInsightsSections />
<MoodInsightsBreakdowns
data={data as unknown as MoodInsightsResponse}
/>
</I18nProvider>
</QueryClientProvider>,
);
@@ -105,7 +113,7 @@ function baseData(inTargetPct: number | null): Response {
};
}
describe("<MoodInsightsSections> — in-target dedup", () => {
describe("<MoodInsightsBreakdowns> — in-target dedup", () => {
it("renders the tile and drops the duplicate in-target narrative", () => {
const html = renderWithData(baseData(72));
// The tile carries the percentage exactly once.
@@ -4,6 +4,14 @@ import { join } from "node:path";
const COMPONENT_DIR = join(__dirname, "..");
const MOOD_LIST = join(__dirname, "../../../mood/mood-list.tsx");
/**
* The two modules the mood insights surface is rendered from. The breakdown
* cluster lives behind `next/dynamic` in its own file; both are the surface.
*/
const INSIGHTS_SURFACE_FILES = [
join(COMPONENT_DIR, "mood-insights-sections.tsx"),
join(COMPONENT_DIR, "mood-insights-breakdowns.tsx"),
];
/**
* v1.8.6 — the notes timeline display is removed from the mood insights
@@ -19,23 +27,31 @@ describe("mood notes timeline removal", () => {
});
it("no longer renders the notes timeline from the insights sections", () => {
const src = readFileSync(
join(COMPONENT_DIR, "mood-insights-sections.tsx"),
"utf8",
);
expect(src).not.toContain("MoodNotesTimeline");
expect(src).not.toContain("notesTimeline");
expect(src).not.toContain("notesTimelineTitle");
// Both halves of the surface: the eager module that paints the calendar
// and the assessment, and the deferred module that carries every
// breakdown. A guard reading only one of them would go quiet the moment
// the other grew a notes feed.
for (const file of INSIGHTS_SURFACE_FILES) {
const src = readFileSync(file, "utf8");
expect(src).not.toContain("MoodNotesTimeline");
expect(src).not.toContain("notesTimeline");
expect(src).not.toContain("notesTimelineTitle");
}
});
it("mounts the narrative feed in the insights sections", () => {
const src = readFileSync(
join(COMPONENT_DIR, "mood-insights-sections.tsx"),
"utf8",
);
// v1.12.7 — the narrative one-liners now ride the merged "What stands out"
// card (`MoodWhatStandsOut`), which renders `MoodNarrativeFeed` internally.
// The structural guard tracks the mount point on the sections surface.
//
// v1.38 — that mount point moved into `mood-insights-breakdowns.tsx` when
// the below-the-fold cluster went behind `next/dynamic`. Read as the union
// of the two files, so the guard asserts the card is mounted SOMEWHERE on
// the surface rather than in one particular file — which is what it was
// always about.
const src = INSIGHTS_SURFACE_FILES.map((f) => readFileSync(f, "utf8")).join(
"\n",
);
expect(src).toContain("MoodWhatStandsOut");
expect(src).toContain("MoodInTargetTile");
});
@@ -0,0 +1,74 @@
"use client";
/**
* How the day's mood sat on days carrying each context value.
*
* The copy rule this component exists to hold, stated once so nobody has to
* infer it from the strings: **no sentence here names a cause.** Not "family
* lowers your mood", not "overtime costs you two points". What the data
* supports is "on the days recorded so far with X, the average was lower", and
* that is what it says — with the two counts on the same line as the claim,
* not behind a tooltip, because a reader who cannot see that a row rests on
* six days cannot weigh it.
*
* The rows arriving here have already cleared the day floors and a
* family-wide Benjamini-Hochberg correction. Nothing in this file re-filters
* them; a second threshold in the presentation layer would be a second place
* to get the statistics wrong.
*/
import { useTranslations } from "@/lib/i18n/context";
import { contextValueLabelKey } from "@/lib/mood/context-vocabulary";
import type { ContextComparisonRow } from "@/lib/insights/mood-context-crosstab";
export function MoodContextComparison({
rows,
}: {
rows: ContextComparisonRow[];
}) {
const { t } = useTranslations();
if (rows.length === 0) return null;
return (
<div className="space-y-3" data-slot="mood-context-comparison">
<p className="text-muted-foreground text-sm">
{t("insights.mood.contextComparison.description")}
</p>
<ul className="space-y-3">
{rows.map((row) => {
const label = t(contextValueLabelKey(row.field, row.value));
const direction =
row.delta < 0
? t("insights.mood.contextComparison.lower")
: t("insights.mood.contextComparison.higher");
return (
<li
key={`${row.field}:${row.value}`}
className="border-border/60 space-y-1 border-t pt-3 first:border-t-0 first:pt-0"
data-slot="mood-context-comparison-row"
>
<p className="text-foreground text-sm">
{t("insights.mood.contextComparison.statement", {
label,
direction,
withAvg: row.withAvg.toFixed(1),
withoutAvg: row.withoutAvg.toFixed(1),
})}
</p>
{/* The counts ride with the statement, in the same block a
screen reader reaches in the same breath. */}
<p className="text-muted-foreground text-xs tabular-nums">
{t("insights.mood.contextComparison.counts", {
withDays: String(row.withDays),
withoutDays: String(row.withoutDays),
})}
</p>
</li>
);
})}
</ul>
<p className="text-muted-foreground text-xs">
{t("insights.mood.contextComparison.caveat")}
</p>
</div>
);
}
@@ -0,0 +1,276 @@
"use client";
/**
* Every mood breakdown below the fold, in one deferred module.
*
* The classification tiles, the "what stands out" card, the four Recharts
* mini-charts, both tag axes, the influence board, the two crosstabs, the
* day-context comparison and the correlation cards. Between them they pull in
* roughly a dozen card components and their icon sets, and none of it is
* visible until the reader has scrolled past the calendar, the line chart, the
* target card and the assessment.
*
* So it is a module rather than a section of its sibling, and its sibling
* loads it through `next/dynamic` — the same treatment the insights mother
* page gives its below-the-hero blocks. Splitting it out is what keeps the
* eager graph for this route down to the calendar and the assessment: while
* every one of these cards was a static import of `mood-insights-sections`,
* fetching the page paid for all of them before painting anything.
*
* It takes the resolved payload as a prop instead of running its own query.
* TanStack would have deduped a second read, but a prop means this module
* cannot render a different snapshot of the same aggregate from the one the
* regions above it drew.
*/
import {
BarChart3,
Briefcase,
CalendarRange,
Clock,
Gauge,
Grid3x3,
Link2,
Tag,
Tags,
TrendingUp,
} from "lucide-react";
import dynamic from "next/dynamic";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { SectionCard, type MoodInsightsResponse } from "./mood-insights-shared";
// v1.12.1 — the four Recharts mini-charts here are deferred via
// `next/dynamic`. The mood hero line chart above them is already dynamic on
// the page, so static-importing these pulled Recharts into the initial chunk
// for no first-paint benefit. Each loader paints a skeleton sized to the
// chart's own band so the deferred chunk arrives without a layout shift
// (charts stay Recharts, visually identical).
//
// v1.16.7 established the shared-barrel pattern for the group; all four
// loaders resolve the app-wide chart-runtime boundary, so they share the ONE
// recharts chunk with every other chart surface: the cards reveal together,
// and recharts ships exactly once for the whole app.
const MoodDimensionTrends = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodDimensionTrends,
})),
{
ssr: false,
loading: () => (
<Skeleton className="h-[clamp(160px,34vh,220px)] w-full rounded-md" />
),
},
);
const MoodDistributionChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodDistributionChart,
})),
{
ssr: false,
loading: () => (
<Skeleton className="h-[clamp(120px,26vh,150px)] w-full rounded-md" />
),
},
);
const MoodWeekdayChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodWeekdayChart,
})),
{
ssr: false,
loading: () => (
<Skeleton className="h-[clamp(120px,26vh,150px)] w-full rounded-md" />
),
},
);
const MoodTimeOfDayChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodTimeOfDayChart,
})),
{
ssr: false,
loading: () => (
<Skeleton className="h-[clamp(160px,38vh,220px)] w-full rounded-md" />
),
},
);
import { MoodContextComparison } from "./mood-context-comparison";
import { MoodTagBreakdown } from "./mood-tag-breakdown";
import { MoodCorrelationCards } from "./mood-correlation-cards";
import { MoodStructuredTagBreakdown } from "./mood-structured-tag-breakdown";
import { MoodWhatStandsOut } from "./mood-what-stands-out";
import { MoodInTargetTile } from "./mood-in-target-tile";
import { MoodStabilityTile } from "./mood-stability-tile";
import { MoodTagInfluence } from "./mood-tag-influence";
import { MoodTagMetricCrosstab } from "./mood-tag-metric-crosstab";
import { MoodFactorMetricCrosstab } from "./mood-factor-metric-crosstab";
export function MoodInsightsBreakdowns({
data,
}: {
data: MoodInsightsResponse;
}) {
const { t } = useTranslations();
// The in-target tile is the canonical surface for the in-target share.
// When it renders (inTargetPct present) drop the same-number `in-target`
// takeaway from the feed so the percentage appears exactly once on the
// page. The narrative still rides the API/LLM payload unchanged.
const inTargetShown = data.summary.inTargetPct != null;
const narratives = inTargetShown
? data.narratives.filter((item) => item.kind !== "in-target")
: data.narratives;
const hasTags = data.tags.length > 0;
const hasStructuredTags = data.structuredTags.length > 0;
const hasStabilityTile = data.stability != null;
const hasInTargetTile = data.summary.inTargetPct != null;
// F1 — fold the structured + flat influence axes into one list ranked by
// absolute delta, so the strongest "this tag moves my mood" rows lead
// regardless of which axis they came from. Defensive against a stale
// server-cache payload minted before the v1.11.5 shape landed (the
// aggregate is cached up to 60 s; a rollout can serve one old read).
const influenceRows = [
...(data.tagInfluence?.structured ?? []),
...(data.tagInfluence?.flat ?? []),
].sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
const hasInfluence = influenceRows.length > 0;
const crosstabRows = data.tagMetricCrosstab ?? [];
const hasCrosstab = crosstabRows.length > 0;
// Defensive against a stale server-cache payload minted before this shape
// landed, the same way the influence rows above are.
const contextRows = data.contextComparison ?? [];
const hasContextComparison = contextRows.length > 0;
const factorCrosstabRows = data.factorCrosstab ?? [];
const hasFactorCrosstab = factorCrosstabRows.length > 0;
const dimensionSummaries = data.dimensions ?? [];
const hasDimensions = dimensionSummaries.some((d) => d.present);
return (
// v1.12.7 — "rest" region. The Stimmungskalender (now above the chart) and
// the better-days Einschätzung (now right after the Ziel card) render in
// their own regions above; this region carries the Einordnung classification
// tiles (in-target share + stability band) FIRST, then the "what stands
// out" card, then the descriptive breakdown sections. The classification
// answers "where do I stand", and the rest is the supporting detail.
<div className="space-y-4">
{/* Einordnung — the classification tiles. */}
{(hasInTargetTile || hasStabilityTile) && (
// Two-up only when BOTH tiles render; a lone tile spans full width so
// it never leaves a half-width orphan with dead space beside it.
<div
className={cn(
"grid gap-4",
hasInTargetTile && hasStabilityTile && "sm:grid-cols-2",
)}
>
{hasInTargetTile && (
<MoodInTargetTile pct={data.summary.inTargetPct} />
)}
{hasStabilityTile && <MoodStabilityTile stability={data.stability} />}
</div>
)}
{/* v1.12.7 — the single "What stands out" card folds the narrative
one-liners AND the FDR-controlled discovered relations into one tile
(was two separate cards). Self-fetches the discovery surface and
renders nothing when both halves are empty. */}
<MoodWhatStandsOut narratives={narratives} />
{/* v1.37 — the five level-A dimensions. Renders only once at least one
of them carries a value; an account that has never opened the detail
section sees the page it saw before. */}
{hasDimensions && (
<SectionCard title={t("insights.mood.dimensions.title")} icon={Gauge}>
<MoodDimensionTrends dimensions={dimensionSummaries} />
</SectionCard>
)}
<div className="grid gap-4 lg:grid-cols-2">
<SectionCard
title={t("insights.mood.distributionTitle")}
icon={BarChart3}
>
<MoodDistributionChart distribution={data.distribution} />
</SectionCard>
<SectionCard
title={t("insights.mood.weekdayTitle")}
icon={CalendarRange}
>
<MoodWeekdayChart weekday={data.weekday} />
</SectionCard>
</div>
{data.timeOfDay.reliable && (
<SectionCard title={t("insights.mood.timeOfDay.title")} icon={Clock}>
<MoodTimeOfDayChart pattern={data.timeOfDay} />
</SectionCard>
)}
{hasStructuredTags && (
<SectionCard title={t("insights.mood.structuredTagsTitle")} icon={Tags}>
<MoodStructuredTagBreakdown tags={data.structuredTags} />
</SectionCard>
)}
{hasTags && (
<SectionCard title={t("insights.mood.tagsTitle")} icon={Tag}>
<MoodTagBreakdown tags={data.tags} />
</SectionCard>
)}
{hasInfluence && (
<SectionCard
title={t("insights.mood.influence.title")}
icon={TrendingUp}
>
<MoodTagInfluence rows={influenceRows} />
</SectionCard>
)}
{hasCrosstab && (
<SectionCard title={t("insights.mood.crosstab.title")} icon={Grid3x3}>
<MoodTagMetricCrosstab rows={crosstabRows} />
</SectionCard>
)}
{hasContextComparison && (
<SectionCard
title={t("insights.mood.contextComparison.title")}
icon={Briefcase}
>
<MoodContextComparison rows={contextRows} />
</SectionCard>
)}
{hasFactorCrosstab && (
<SectionCard
title={t("insights.mood.factorCrosstab.title")}
icon={Gauge}
>
<MoodFactorMetricCrosstab rows={factorCrosstabRows} />
</SectionCard>
)}
<SectionCard title={t("insights.mood.correlationsTitle")} icon={Link2}>
<p className="text-muted-foreground mb-2 text-sm">
{t("insights.mood.correlationsDescription")}
</p>
<MoodCorrelationCards
sleep={data.correlations.sleep}
steps={data.correlations.steps}
pulse={data.correlations.pulse}
weight={data.correlations.weight}
bloodPressureSystolic={data.correlations.bloodPressureSystolic}
/>
</SectionCard>
</div>
);
}
@@ -3,195 +3,78 @@
import dynamic from "next/dynamic";
import { useQuery } from "@tanstack/react-query";
import type { ComponentType } from "react";
import {
BarChart3,
CalendarDays,
CalendarRange,
Clock,
Gauge,
Grid3x3,
Link2,
Sparkles,
Tag,
Tags,
TrendingUp,
} from "lucide-react";
import { CalendarDays, Sparkles } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { MoodHeatmap } from "@/components/charts/mood-heatmap";
// v1.12.1 — the three Recharts mini-charts on this below-fold cluster are
// deferred via `next/dynamic`. The mood hero line chart above them is
// already dynamic on the page, so static-importing these pulled Recharts
// into the initial chunk for no first-paint benefit. Each loader paints a
// skeleton sized to the chart's own band so the deferred chunk arrives
// without a layout shift (charts stay Recharts, visually identical). Types
// stay value-free imports so they don't drag the chunk back in.
import type { MoodDistributionRow } from "./mood-distribution-chart";
import type { MoodDimensionSummaryData } from "./mood-dimension-trends";
import type { MoodWeekdayRow } from "./mood-weekday-chart";
import type { MoodTimeOfDayPattern } from "./mood-time-of-day-chart";
// v1.16.7 established the shared-barrel pattern for the trio; all three
// loaders now resolve the app-wide chart-runtime boundary, so they share
// the ONE recharts chunk with every other chart surface: the cards reveal
// together, and recharts ships exactly once for the whole app.
const MoodDimensionTrends = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodDimensionTrends,
})),
{
ssr: false,
loading: () => (
<Skeleton className="h-[clamp(160px,34vh,220px)] w-full rounded-md" />
),
},
);
const MoodDistributionChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodDistributionChart,
})),
{
ssr: false,
loading: () => (
<Skeleton className="h-[clamp(120px,26vh,150px)] w-full rounded-md" />
),
},
);
const MoodWeekdayChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodWeekdayChart,
})),
{
ssr: false,
loading: () => (
<Skeleton className="h-[clamp(120px,26vh,150px)] w-full rounded-md" />
),
},
);
const MoodTimeOfDayChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodTimeOfDayChart,
})),
{
ssr: false,
loading: () => (
<Skeleton className="h-[clamp(160px,38vh,220px)] w-full rounded-md" />
),
},
);
import { MoodTagBreakdown, type MoodTagRow } from "./mood-tag-breakdown";
import {
MoodCorrelationCards,
type MoodMetricCorrelationData,
} from "./mood-correlation-cards";
import {
MoodStructuredTagBreakdown,
type MoodStructuredTagRow,
} from "./mood-structured-tag-breakdown";
import { type MoodNarrativeItem } from "./mood-narrative-feed";
import { MoodWhatStandsOut } from "./mood-what-stands-out";
import { MoodInTargetTile } from "./mood-in-target-tile";
import {
MoodStabilityTile,
type MoodStabilityData,
} from "./mood-stability-tile";
import {
MoodTagInfluence,
type MoodTagInfluenceRow,
} from "./mood-tag-influence";
import { MoodBetterDays, type MoodBetterDayFactor } from "./mood-better-days";
import {
MoodTagMetricCrosstab,
type MoodTagMetricCrosstabRow,
} from "./mood-tag-metric-crosstab";
import {
MoodFactorMetricCrosstab,
type MoodFactorMetricCrosstabRow,
} from "./mood-factor-metric-crosstab";
import { SectionCard, type MoodInsightsResponse } from "./mood-insights-shared";
import { apiGet } from "@/lib/api/api-fetch";
/**
* Every breakdown below the fold, in its own chunk.
*
* The calendar and the assessment are what this route paints first; the
* dozen-odd breakdown cards under them — both tag axes, the influence board,
* the two crosstabs, the day-context comparison, the four Recharts
* mini-charts, the correlation cards — are not visible until the reader has
* scrolled past the line chart and the target card. Static-importing them
* made every cold mount of `/insights/mood` pay for all of them before
* painting anything, and it is what put this route on its bundle ceiling.
*
* Deferred behind `next/dynamic` like the below-the-hero blocks on the
* insights mother page. `ssr: false` because the whole subtree is a client
* render off a client query anyway, and the skeleton holds the block open at
* roughly the height of the classification tiles so the chunk lands without
* shoving the page.
*/
/**
* The better-days assessment, deferred for one specific reason: it renders the
* catalogue's tag icons, and that resolver
* (`@/components/mood/mood-tag-icons`) statically imports about a hundred and
* sixty lucide icons. Eager, that map rode the first-load graph of a route
* whose first paint is a calendar. The card itself sits a screen down, under
* the line chart and the target card.
*
* It shares the icon map with the breakdown module below, so the two resolve
* out of the same lazy group rather than duplicating it.
*/
const MoodBetterDays = dynamic(
() =>
import("./mood-better-days").then((mod) => ({
default: mod.MoodBetterDays,
})),
{
ssr: false,
loading: () => <Skeleton className="min-h-24 w-full rounded-md" />,
},
);
const MoodInsightsBreakdowns = dynamic(
() =>
import("./mood-insights-breakdowns").then((mod) => ({
default: mod.MoodInsightsBreakdowns,
})),
{
ssr: false,
loading: () => <Skeleton className="min-h-48 w-full rounded-xl" />,
},
);
/**
* v1.8.5 — additional Mood Insights sections.
*
* Reads the pre-computed aggregate bundle from `/api/mood/insights`
* (cheap cached server read, no LLM) and paints the heatmap,
* distribution, weekday pattern, tag breakdown, and mood × metric
* correlation cards. Renders nothing while loading / on error / on an
* empty data set so the page degrades gracefully to the line chart.
* Reads the pre-computed aggregate bundle from `/api/mood/insights` (cheap
* cached server read, no LLM) and paints the calendar and the assessment
* itself, handing the payload to the deferred breakdown module for everything
* under them. Renders nothing while loading / on error / on an empty data set
* so the page degrades gracefully to the line chart.
*/
interface MoodInsightsResponse {
summary: {
totalEntries: number;
inTargetPct: number | null;
};
heatmap: {
windowDays: number;
cells: Array<{ date: string; score: number; samples: number }>;
};
distribution: MoodDistributionRow[];
// Optional only to tolerate a stale pre-v1.37 cached payload during a
// rollout; the live endpoint always populates it.
dimensions?: MoodDimensionSummaryData[];
weekday: MoodWeekdayRow[];
timeOfDay: MoodTimeOfDayPattern;
stability: MoodStabilityData | null;
tags: MoodTagRow[];
structuredTags: MoodStructuredTagRow[];
// Optional only to tolerate a stale pre-v1.11.5 cached payload during a
// rollout; the live endpoint always populates both.
tagInfluence?: {
flat: MoodTagInfluenceRow[];
structured: MoodTagInfluenceRow[];
};
betterDays?: MoodBetterDayFactor[];
// Optional only to tolerate a stale pre-v1.12.0 cached payload during a
// rollout; the live endpoint always populates it.
tagMetricCrosstab?: MoodTagMetricCrosstabRow[];
// Optional only to tolerate a stale pre-v1.14.0 cached payload during a
// rollout; the live endpoint always populates it.
factorCrosstab?: MoodFactorMetricCrosstabRow[];
narratives: MoodNarrativeItem[];
correlations: {
sleep: MoodMetricCorrelationData;
steps: MoodMetricCorrelationData;
pulse: MoodMetricCorrelationData;
weight: MoodMetricCorrelationData;
bloodPressureSystolic: MoodMetricCorrelationData;
};
}
function SectionCard({
title,
icon,
children,
}: {
title: string;
// v1.12.6 — every mood section header now routes through `TileHeader`
// (icon + white heading) so the subpage reads as one card language.
icon: ComponentType<{ className?: string }>;
children: React.ReactNode;
}) {
return (
<Card>
<CardHeader>
<TileHeader icon={icon} title={title} />
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
/**
* v1.12.7 — the mood spine is split into three render regions so the page can
* thread the operator's exact top-to-bottom order without prop-drilling the
@@ -242,40 +125,8 @@ export function MoodInsightsSections({
const heatmapCells = Object.fromEntries(
data.heatmap.cells.map((cell) => [cell.date, cell]),
);
const hasTags = data.tags.length > 0;
const hasStructuredTags = data.structuredTags.length > 0;
// The in-target tile is the canonical surface for the in-target share.
// When it renders (inTargetPct present) drop the same-number `in-target`
// takeaway from the feed so the percentage appears exactly once on the
// page. The narrative still rides the API/LLM payload unchanged.
const inTargetShown = data.summary.inTargetPct != null;
const narratives = inTargetShown
? data.narratives.filter((item) => item.kind !== "in-target")
: data.narratives;
const hasStabilityTile = data.stability != null;
const hasInTargetTile = data.summary.inTargetPct != null;
// F1 — fold the structured + flat influence axes into one list ranked by
// absolute delta, so the strongest "this tag moves my mood" rows lead
// regardless of which axis they came from. Defensive against a stale
// server-cache payload minted before the v1.11.5 shape landed (the
// aggregate is cached up to 60 s; a rollout can serve one old read).
const influenceRows = [
...(data.tagInfluence?.structured ?? []),
...(data.tagInfluence?.flat ?? []),
].sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
const hasInfluence = influenceRows.length > 0;
const betterDays = data.betterDays ?? [];
const hasBetterDays = betterDays.length > 0;
const crosstabRows = data.tagMetricCrosstab ?? [];
const hasCrosstab = crosstabRows.length > 0;
const factorCrosstabRows = data.factorCrosstab ?? [];
const hasFactorCrosstab = factorCrosstabRows.length > 0;
const dimensionSummaries = data.dimensions ?? [];
const hasDimensions = dimensionSummaries.some((d) => d.present);
// v1.12.7 — the Stimmungskalender is lifted above the line chart on the page,
// so it renders as its own region here.
@@ -321,115 +172,8 @@ export function MoodInsightsSections({
);
}
return (
// v1.12.7 — "rest" region. The Stimmungskalender (now above the chart) and
// the better-days Einschätzung (now right after the Ziel card) render in
// their own regions above; this region carries the Einordnung classification
// tiles (in-target share + stability band) FIRST, then the "what stands
// out" card, then the descriptive breakdown sections. The classification
// answers "where do I stand", and the rest is the supporting detail.
<div className="space-y-4">
{/* Einordnung — the classification tiles. */}
{(hasInTargetTile || hasStabilityTile) && (
// Two-up only when BOTH tiles render; a lone tile spans full width so
// it never leaves a half-width orphan with dead space beside it.
<div
className={cn(
"grid gap-4",
hasInTargetTile && hasStabilityTile && "sm:grid-cols-2",
)}
>
{hasInTargetTile && (
<MoodInTargetTile pct={data.summary.inTargetPct} />
)}
{hasStabilityTile && <MoodStabilityTile stability={data.stability} />}
</div>
)}
{/* v1.12.7 — the single "What stands out" card folds the narrative
one-liners AND the FDR-controlled discovered relations into one tile
(was two separate cards). Self-fetches the discovery surface and
renders nothing when both halves are empty. */}
<MoodWhatStandsOut narratives={narratives} />
{/* v1.37 — the five level-A dimensions. Renders only once at least one
of them carries a value; an account that has never opened the detail
section sees the page it saw before. */}
{hasDimensions && (
<SectionCard title={t("insights.mood.dimensions.title")} icon={Gauge}>
<MoodDimensionTrends dimensions={dimensionSummaries} />
</SectionCard>
)}
<div className="grid gap-4 lg:grid-cols-2">
<SectionCard
title={t("insights.mood.distributionTitle")}
icon={BarChart3}
>
<MoodDistributionChart distribution={data.distribution} />
</SectionCard>
<SectionCard
title={t("insights.mood.weekdayTitle")}
icon={CalendarRange}
>
<MoodWeekdayChart weekday={data.weekday} />
</SectionCard>
</div>
{data.timeOfDay.reliable && (
<SectionCard title={t("insights.mood.timeOfDay.title")} icon={Clock}>
<MoodTimeOfDayChart pattern={data.timeOfDay} />
</SectionCard>
)}
{hasStructuredTags && (
<SectionCard title={t("insights.mood.structuredTagsTitle")} icon={Tags}>
<MoodStructuredTagBreakdown tags={data.structuredTags} />
</SectionCard>
)}
{hasTags && (
<SectionCard title={t("insights.mood.tagsTitle")} icon={Tag}>
<MoodTagBreakdown tags={data.tags} />
</SectionCard>
)}
{hasInfluence && (
<SectionCard
title={t("insights.mood.influence.title")}
icon={TrendingUp}
>
<MoodTagInfluence rows={influenceRows} />
</SectionCard>
)}
{hasCrosstab && (
<SectionCard title={t("insights.mood.crosstab.title")} icon={Grid3x3}>
<MoodTagMetricCrosstab rows={crosstabRows} />
</SectionCard>
)}
{hasFactorCrosstab && (
<SectionCard
title={t("insights.mood.factorCrosstab.title")}
icon={Gauge}
>
<MoodFactorMetricCrosstab rows={factorCrosstabRows} />
</SectionCard>
)}
<SectionCard title={t("insights.mood.correlationsTitle")} icon={Link2}>
<p className="text-muted-foreground mb-2 text-sm">
{t("insights.mood.correlationsDescription")}
</p>
<MoodCorrelationCards
sleep={data.correlations.sleep}
steps={data.correlations.steps}
pulse={data.correlations.pulse}
weight={data.correlations.weight}
bloodPressureSystolic={data.correlations.bloodPressureSystolic}
/>
</SectionCard>
</div>
);
// The rest region is one deferred subtree now: everything in it sits below
// the fold, and the payload rides in as a prop so the deferred module cannot
// draw a different snapshot of the aggregate from the regions above it.
return <MoodInsightsBreakdowns data={data} />;
}
@@ -0,0 +1,96 @@
"use client";
/**
* The payload shape and the card frame both mood-insights modules need.
*
* Extracted when the breakdown cluster moved behind `next/dynamic`: the
* eager module (heatmap + assessment) and the deferred one (every breakdown)
* both describe the same `/api/mood/insights` response and both draw the same
* card, and a second copy of either would be the drift this file exists to
* prevent. Nothing heavy lives here — the type is erased at build time and the
* frame is a `Card` with a `TileHeader` in it — so importing it from the eager
* module costs the first-load graph nothing.
*/
import type { ComponentType } from "react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import type { MoodDistributionRow } from "./mood-distribution-chart";
import type { MoodDimensionSummaryData } from "./mood-dimension-trends";
import type { MoodWeekdayRow } from "./mood-weekday-chart";
import type { MoodTimeOfDayPattern } from "./mood-time-of-day-chart";
import type { MoodTagRow } from "./mood-tag-breakdown";
import type { MoodMetricCorrelationData } from "./mood-correlation-cards";
import type { MoodStructuredTagRow } from "./mood-structured-tag-breakdown";
import type { MoodNarrativeItem } from "./mood-narrative-feed";
import type { MoodStabilityData } from "./mood-stability-tile";
import type { MoodTagInfluenceRow } from "./mood-tag-influence";
import type { MoodBetterDayFactor } from "./mood-better-days";
import type { MoodTagMetricCrosstabRow } from "./mood-tag-metric-crosstab";
import type { MoodFactorMetricCrosstabRow } from "./mood-factor-metric-crosstab";
import type { ContextComparisonRow } from "@/lib/insights/mood-context-crosstab";
export interface MoodInsightsResponse {
summary: {
totalEntries: number;
inTargetPct: number | null;
};
heatmap: {
windowDays: number;
cells: Array<{ date: string; score: number; samples: number }>;
};
distribution: MoodDistributionRow[];
// Optional only to tolerate a stale pre-v1.37 cached payload during a
// rollout; the live endpoint always populates it.
dimensions?: MoodDimensionSummaryData[];
weekday: MoodWeekdayRow[];
timeOfDay: MoodTimeOfDayPattern;
stability: MoodStabilityData | null;
tags: MoodTagRow[];
structuredTags: MoodStructuredTagRow[];
// Optional only to tolerate a stale pre-v1.11.5 cached payload during a
// rollout; the live endpoint always populates both.
tagInfluence?: {
flat: MoodTagInfluenceRow[];
structured: MoodTagInfluenceRow[];
};
betterDays?: MoodBetterDayFactor[];
// Optional only to tolerate a stale pre-v1.12.0 cached payload during a
// rollout; the live endpoint always populates it.
tagMetricCrosstab?: MoodTagMetricCrosstabRow[];
/** v1.38 — context value against the day's mood, counts included. */
contextComparison?: ContextComparisonRow[];
// Optional only to tolerate a stale pre-v1.14.0 cached payload during a
// rollout; the live endpoint always populates it.
factorCrosstab?: MoodFactorMetricCrosstabRow[];
narratives: MoodNarrativeItem[];
correlations: {
sleep: MoodMetricCorrelationData;
steps: MoodMetricCorrelationData;
pulse: MoodMetricCorrelationData;
weight: MoodMetricCorrelationData;
bloodPressureSystolic: MoodMetricCorrelationData;
};
}
export function SectionCard({
title,
icon,
children,
}: {
title: string;
// v1.12.6 — every mood section header now routes through `TileHeader`
// (icon + white heading) so the subpage reads as one card language.
icon: ComponentType<{ className?: string }>;
children: React.ReactNode;
}) {
return (
<Card>
<CardHeader>
<TileHeader icon={icon} title={title} />
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
applyOptimisticSlotMark,
slotMarkRequestBody,
complianceFromLedger,
formatSlotDelta,
groupLedgerByDay,
@@ -195,3 +196,23 @@ describe("applyOptimisticSlotMark", () => {
expect(next.rows[0].status).toBe("taken_late");
});
});
describe("slotMarkRequestBody", () => {
const row = { at: "2026-08-04T07:00:00.000Z" };
it("anchors takenAt on the slot instant, not on now", () => {
const body = slotMarkRequestBody(row, "taken");
expect(body).toEqual({
skipped: false,
scheduledFor: "2026-08-04T07:00:00.000Z",
takenAt: "2026-08-04T07:00:00.000Z",
});
});
it("keeps the skipped arm free of takenAt", () => {
expect(slotMarkRequestBody(row, "skipped")).toEqual({
skipped: true,
scheduledFor: "2026-08-04T07:00:00.000Z",
});
});
});
@@ -204,3 +204,18 @@ export function applyOptimisticSlotMark(
});
return { ...payload, rows };
}
/**
* Request body for marking a ledger slot from the Verlauf. The taken arm
* anchors `takenAt` on the slot instant: marking Tuesday 09:00 from a
* Thursday evening records a dose taken Tuesday 09:00, which is the claim
* the person is making. The optimistic paint above already promised this.
*/
export function slotMarkRequestBody(
row: Pick<LedgerRow, "at">,
action: "taken" | "skipped",
): Record<string, unknown> {
return action === "skipped"
? { skipped: true, scheduledFor: row.at }
: { skipped: false, scheduledFor: row.at, takenAt: row.at };
}
@@ -106,6 +106,7 @@ import {
formatSlotDelta,
groupLedgerByDay,
isSlotActionable,
slotMarkRequestBody,
type LedgerPayload,
type LedgerRow,
} from "@/components/medications/dose-history-ledger-compute";
@@ -273,18 +274,13 @@ export function DoseHistoryLedger({
try {
// Pin the displayed slot so the server marks THAT dose (the canonical
// slot upsert), not a now-snap to the nearest one. The POST returns
// the created event; its id drives the Undo affordance (the same
// soft-delete route the cards use).
// slot upsert), not a now-snap to the nearest one — and anchor
// `takenAt` on the same instant, matching the optimistic paint. The
// POST returns the created event; its id drives the Undo affordance
// (the same soft-delete route the cards use).
const created = await apiPost<{ id?: string } | undefined>(
`/api/medications/${medicationId}/intake`,
action === "skipped"
? { skipped: true, scheduledFor: row.at }
: {
skipped: false,
scheduledFor: row.at,
takenAt: new Date().toISOString(),
},
slotMarkRequestBody(row, action),
);
const eventId: string | undefined = created?.id;
toast.success(
@@ -0,0 +1,108 @@
/**
* Which zone a linked-day read is made in, and which surface decides it.
*
* The finding this file answers was not a wrong number — it was a doc comment
* that said "the detail view passes the entry's stored tz" beside code that
* always sent the browser's. Nothing was red, because nothing asserted the
* claim. Two ends, so two kinds of assertion:
*
* * the resolution itself, on the pure helper, at both branches;
* * the two call sites, structurally, because the resolution being right is
* worth nothing if the edit dialog never hands it the entry's zone.
*
* Both matchers assert a non-zero match count. An empty match set fails rather
* than passing quietly, which is how a guard stays a guard after somebody
* renames the thing it greps for.
*
* Mutation check: drop the `editing?.tz` argument at the edit call site and
* the call-site row goes red; make the helper prefer its browser argument and
* the resolution rows go red.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { linkedDayRequest } from "../use-linked-day-context";
const HERE = join(__dirname, "..");
const LIST = readFileSync(join(HERE, "mood-list.tsx"), "utf8");
const FORM = readFileSync(join(HERE, "mood-form.tsx"), "utf8");
/** The arguments of every `useLinkedDayContext(...)` call in a source file. */
function callArguments(source: string): string[] {
const calls: string[] = [];
const needle = "useLinkedDayContext(";
let at = source.indexOf(needle);
while (at !== -1) {
// Only a CALL, never the import line that names the same identifier.
const open = at + needle.length - 1;
let depth = 0;
let end = open;
for (let i = open; i < source.length; i++) {
if (source[i] === "(") depth++;
else if (source[i] === ")") {
depth--;
if (depth === 0) {
end = i;
break;
}
}
}
calls.push(source.slice(open + 1, end));
at = source.indexOf(needle, end);
}
return calls;
}
describe("linked-day timezone", () => {
it("prefers a stored zone over the browser's", () => {
const { tz, url } = linkedDayRequest(
"2026-06-11",
"America/New_York",
"Europe/Berlin",
);
expect(tz).toBe("America/New_York");
expect(url).toContain("tz=America%2FNew_York");
expect(url).toContain("date=2026-06-11");
});
it("falls back to the browser's zone when the row has none", () => {
// A legacy row (`tz IS NULL`) and an entry that does not exist yet are the
// same case here. Not Europe/Berlin: the server owns the legacy rule and a
// guess sent from the client would override the one place it is written.
expect(linkedDayRequest("2026-06-11", null, "Europe/Berlin").tz).toBe(
"Europe/Berlin",
);
expect(linkedDayRequest("2026-06-11", "", "Europe/Berlin").tz).toBe(
"Europe/Berlin",
);
expect(linkedDayRequest("2026-06-11", undefined, "Asia/Tokyo").tz).toBe(
"Asia/Tokyo",
);
});
it("has the edit dialog hand over the entry's own zone", () => {
const calls = callArguments(LIST);
expect(
calls.length,
"no useLinkedDayContext call found in mood-list.tsx — this matcher proves nothing",
).toBe(1);
expect(
calls[0],
"the edit dialog reads the linked day under the browser's zone; an entry logged abroad would be looked up on the wrong day",
).toContain("editing?.tz");
});
it("leaves the capture sheet on the browser's zone", () => {
const calls = callArguments(FORM);
expect(
calls.length,
"no useLinkedDayContext call found in mood-form.tsx — this matcher proves nothing",
).toBe(1);
// A new entry has no stored zone to pass, so the third argument is absent
// on purpose. Passing something here would be inventing a zone for a row
// that has not chosen one.
expect(calls[0]).not.toContain("tz");
});
});
+701
View File
@@ -0,0 +1,701 @@
"use client";
/**
* The four day-context sections, and the read-only block beside them.
*
* One component for both mood surfaces — the create sheet and the list's edit
* dialog — for the same reason the level-A sliders share one: what one writes
* the other has to be able to show, and a value somebody set on Monday and
* cannot correct on Tuesday is worse than one they were never offered.
*
* Every section is closed by default and renders no inputs while closed, so an
* entry is still one tap and Save. The count badge on a closed section says
* how much is in it, so nothing is hidden silently.
*
* Two copy rules hold throughout, and both are load-bearing rather than
* stylistic. Labels state facts and never judgements: "worked a regular day"
* is a fact and "worked too much" is a verdict on somebody's life. And the
* linked block is read-only — it shows what the sleep, activity, vitals and
* illness modules already hold, names where each figure comes from, and offers
* a way in to change it there. It is never an input here, because the moment
* it were, the same night would have two homes.
*/
import { useMemo } from "react";
import {
Activity,
Briefcase,
CalendarClock,
Link2,
Palmtree,
Users,
} from "lucide-react";
import { DateTimeField } from "@/components/ui/date-time-field";
import { FieldGroup } from "@/components/ui/field-group";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section";
import { SliderField } from "@/components/ui/slider";
import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "@/lib/i18n/context";
import {
CONTACT_CIRCLE_KEYS,
CONTACT_EXTENT_KEYS,
CONTACT_FORM_KEYS,
CONTEXT_MINUTES_MAX,
CONTEXT_MINUTES_MIN,
CONTEXT_RATING_MAX,
CONTEXT_RATING_MIN,
CONTEXT_VALENCE_MAX,
CONTEXT_VALENCE_MIN,
EVENT_TYPE_KEYS,
LEISURE_CATEGORY_KEYS,
WORK_STATUS_KEYS,
contextSectionLabelKey,
contextValueLabelKey,
} from "@/lib/mood/context-vocabulary";
import type { LinkedDayContext, LinkedFigure } from "@/lib/mood/linked-context";
const CONTEXT_NOTE_MAX_LENGTH = 500;
/** The context as the form holds it. `null` everywhere means unanswered. */
export interface MoodContextState {
workStatus: string | null;
workMinutes: number | null;
overtimeMinutes: number | null;
workLoad: number | null;
workSatisfaction: number | null;
contactCircles: string[];
contactForm: string | null;
contactExtent: string | null;
contactQuality: number | null;
contactSupport: number | null;
leisureCategories: string[];
leisureMinutes: number | null;
leisureJoy: number | null;
leisureRecovery: number | null;
eventType: string | null;
eventValence: number | null;
/** Local `datetime-local` value, or "" for unanswered. */
eventAt: string;
note: string;
}
export const EMPTY_MOOD_CONTEXT: MoodContextState = {
workStatus: null,
workMinutes: null,
overtimeMinutes: null,
workLoad: null,
workSatisfaction: null,
contactCircles: [],
contactForm: null,
contactExtent: null,
contactQuality: null,
contactSupport: null,
leisureCategories: [],
leisureMinutes: null,
leisureJoy: null,
leisureRecovery: null,
eventType: null,
eventValence: null,
eventAt: "",
note: "",
};
/** Which state fields each section owns, so a badge counts its own section. */
const SECTION_FIELDS = {
work: [
"workStatus",
"workMinutes",
"overtimeMinutes",
"workLoad",
"workSatisfaction",
],
contacts: [
"contactCircles",
"contactForm",
"contactExtent",
"contactQuality",
"contactSupport",
],
leisure: [
"leisureCategories",
"leisureMinutes",
"leisureJoy",
"leisureRecovery",
],
events: ["eventType", "eventValence", "eventAt", "note"],
} as const satisfies Record<string, ReadonlyArray<keyof MoodContextState>>;
function isAnswered(value: MoodContextState[keyof MoodContextState]): boolean {
if (value === null) return false;
if (Array.isArray(value)) return value.length > 0;
if (typeof value === "string") return value.trim() !== "";
return true;
}
/** How many answers a section holds — the collapsed section's badge. */
export function contextSectionCount(
state: MoodContextState,
section: keyof typeof SECTION_FIELDS,
): number {
return SECTION_FIELDS[section].filter((field) => isAnswered(state[field]))
.length;
}
/** Does the whole context say anything at all? */
export function contextIsEmpty(state: MoodContextState): boolean {
return (
Object.keys(SECTION_FIELDS) as Array<keyof typeof SECTION_FIELDS>
).every((section) => contextSectionCount(state, section) === 0);
}
/**
* The context as the API takes it.
*
* `null` when nothing was answered, which is how the write path is told to
* store no row (and to remove one that is there). Not `undefined`: on the edit
* path an omitted key means "leave it alone", and a person who cleared every
* section means the opposite of that.
*/
export function moodContextPayload(
state: MoodContextState,
): Record<string, unknown> | null {
if (contextIsEmpty(state)) return null;
return {
workStatus: state.workStatus,
workMinutes: state.workMinutes,
overtimeMinutes: state.overtimeMinutes,
workLoad: state.workLoad,
workSatisfaction: state.workSatisfaction,
contactCircles: state.contactCircles,
contactForm: state.contactForm,
contactExtent: state.contactExtent,
contactQuality: state.contactQuality,
contactSupport: state.contactSupport,
leisureCategories: state.leisureCategories,
leisureMinutes: state.leisureMinutes,
leisureJoy: state.leisureJoy,
leisureRecovery: state.leisureRecovery,
eventType: state.eventType,
eventValence: state.eventValence,
eventAt: state.eventAt ? new Date(state.eventAt).toISOString() : null,
note: state.note.trim() === "" ? null : state.note.trim(),
};
}
/** Seed the form from an entry being edited. */
export function moodContextFromEntry(
context:
| (Partial<Omit<MoodContextState, "eventAt" | "note">> & {
eventAt?: string | null;
note?: string | null;
})
| null
| undefined,
): MoodContextState {
if (!context) return EMPTY_MOOD_CONTEXT;
return {
...EMPTY_MOOD_CONTEXT,
...context,
contactCircles: context.contactCircles ?? [],
leisureCategories: context.leisureCategories ?? [],
// The wire carries an ISO instant; the control wants a local wall clock.
eventAt: context.eventAt ? toLocalInputValue(context.eventAt) : "",
note: context.note ?? "",
};
}
function toLocalInputValue(iso: string): string {
const at = new Date(iso);
const local = new Date(at.getTime() - at.getTimezoneOffset() * 60 * 1000);
return local.toISOString().slice(0, 16);
}
/** A selectable chip. Tapping the selected one again takes the answer back. */
function ChoiceChip({
label,
selected,
onClick,
}: {
label: string;
selected: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={selected}
data-slot="mood-context-choice"
className={`inline-flex min-h-11 items-center rounded-full border px-3 py-2 text-xs transition-colors ${
selected
? "border-primary bg-primary/15 text-primary"
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
}`}
>
{label}
</button>
);
}
function ChoiceRow({
label,
field,
keys,
value,
onChange,
}: {
label: string;
field: string;
keys: readonly string[];
value: string | null;
onChange: (next: string | null) => void;
}) {
const { t } = useTranslations();
return (
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-normal">
{label}
</Label>
<div className="flex flex-wrap gap-1.5">
{keys.map((key) => (
<ChoiceChip
key={key}
label={t(contextValueLabelKey(field, key))}
selected={value === key}
onClick={() => onChange(value === key ? null : key)}
/>
))}
</div>
</div>
);
}
function MultiChoiceRow({
label,
field,
keys,
value,
onChange,
}: {
label: string;
field: string;
keys: readonly string[];
value: string[];
onChange: (next: string[]) => void;
}) {
const { t } = useTranslations();
return (
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-normal">
{label}
</Label>
<div className="flex flex-wrap gap-1.5">
{keys.map((key) => (
<ChoiceChip
key={key}
label={t(contextValueLabelKey(field, key))}
selected={value.includes(key)}
onClick={() =>
onChange(
value.includes(key)
? value.filter((k) => k !== key)
: [...value, key],
)
}
/>
))}
</div>
</div>
);
}
function MinutesField({
id,
label,
value,
onChange,
}: {
id: string;
label: string;
value: number | null;
onChange: (next: number | null) => void;
}) {
const { t } = useTranslations();
return (
<FieldGroup htmlFor={id} label={label}>
<Input
id={id}
type="number"
inputMode="numeric"
min={CONTEXT_MINUTES_MIN}
max={CONTEXT_MINUTES_MAX}
step={5}
value={value === null ? "" : String(value)}
placeholder={t("mood.dayContext.minutesPlaceholder")}
onChange={(e) => {
const raw = e.target.value;
// An emptied field is an unanswered field, not a zero. Zero minutes
// is a real answer somebody can still type.
onChange(raw === "" ? null : Number(raw));
}}
/>
</FieldGroup>
);
}
/** One 0-10 rating with its two anchors, resolved from the vocabulary. */
function ContextRating({
field,
value,
onChange,
}: {
field: string;
value: number | null;
onChange: (next: number | null) => void;
}) {
const { t } = useTranslations();
const low = t(`mood.context.rating.${field}.low`);
const high = t(`mood.context.rating.${field}.high`);
return (
<SliderField
label={t(contextValueLabelKey("field", field))}
value={value}
min={CONTEXT_RATING_MIN}
max={CONTEXT_RATING_MAX}
step={1}
lowAnchor={low}
highAnchor={high}
emptyLabel={t("mood.dimensionNotAnswered")}
valueText={
value === null
? undefined
: t("mood.dimensionValueText", {
value,
max: CONTEXT_RATING_MAX,
low,
high,
})
}
clearLabel={t("mood.dimensionClear")}
onClear={() => onChange(null)}
onValueChange={onChange}
/>
);
}
interface MoodContextSectionsProps {
value: MoodContextState;
onChange: (next: MoodContextState) => void;
}
export function MoodContextSections({
value,
onChange,
}: MoodContextSectionsProps) {
const { t } = useTranslations();
const set = <K extends keyof MoodContextState>(
key: K,
next: MoodContextState[K],
) => onChange({ ...value, [key]: next });
return (
<>
<SheetSection
title={t(contextSectionLabelKey("work"))}
icon={<Briefcase />}
summary={
<SheetSectionCount count={contextSectionCount(value, "work")} />
}
>
<div className="space-y-4" data-slot="mood-context-work">
<ChoiceRow
label={t(contextValueLabelKey("field", "workStatus"))}
field="workStatus"
keys={WORK_STATUS_KEYS}
value={value.workStatus}
onChange={(next) => set("workStatus", next)}
/>
<MinutesField
id="mood-context-work-minutes"
label={t(contextValueLabelKey("field", "workMinutes"))}
value={value.workMinutes}
onChange={(next) => set("workMinutes", next)}
/>
<MinutesField
id="mood-context-overtime-minutes"
label={t(contextValueLabelKey("field", "overtimeMinutes"))}
value={value.overtimeMinutes}
onChange={(next) => set("overtimeMinutes", next)}
/>
<ContextRating
field="workLoad"
value={value.workLoad}
onChange={(next) => set("workLoad", next)}
/>
<ContextRating
field="workSatisfaction"
value={value.workSatisfaction}
onChange={(next) => set("workSatisfaction", next)}
/>
</div>
</SheetSection>
<SheetSection
title={t(contextSectionLabelKey("contacts"))}
icon={<Users />}
summary={
<SheetSectionCount count={contextSectionCount(value, "contacts")} />
}
>
<div className="space-y-4" data-slot="mood-context-contacts">
<p className="text-muted-foreground text-xs">
{t("mood.dayContext.contactsHelp")}
</p>
<MultiChoiceRow
label={t(contextValueLabelKey("field", "contactCircles"))}
field="contactCircles"
keys={CONTACT_CIRCLE_KEYS}
value={value.contactCircles}
onChange={(next) => set("contactCircles", next)}
/>
<ChoiceRow
label={t(contextValueLabelKey("field", "contactForm"))}
field="contactForm"
keys={CONTACT_FORM_KEYS}
value={value.contactForm}
onChange={(next) => set("contactForm", next)}
/>
<ChoiceRow
label={t(contextValueLabelKey("field", "contactExtent"))}
field="contactExtent"
keys={CONTACT_EXTENT_KEYS}
value={value.contactExtent}
onChange={(next) => set("contactExtent", next)}
/>
<ContextRating
field="contactQuality"
value={value.contactQuality}
onChange={(next) => set("contactQuality", next)}
/>
<ContextRating
field="contactSupport"
value={value.contactSupport}
onChange={(next) => set("contactSupport", next)}
/>
</div>
</SheetSection>
<SheetSection
title={t(contextSectionLabelKey("leisure"))}
icon={<Palmtree />}
summary={
<SheetSectionCount count={contextSectionCount(value, "leisure")} />
}
>
<div className="space-y-4" data-slot="mood-context-leisure">
<MultiChoiceRow
label={t(contextValueLabelKey("field", "leisureCategories"))}
field="leisureCategories"
keys={LEISURE_CATEGORY_KEYS}
value={value.leisureCategories}
onChange={(next) => set("leisureCategories", next)}
/>
<MinutesField
id="mood-context-leisure-minutes"
label={t(contextValueLabelKey("field", "leisureMinutes"))}
value={value.leisureMinutes}
onChange={(next) => set("leisureMinutes", next)}
/>
<ContextRating
field="leisureJoy"
value={value.leisureJoy}
onChange={(next) => set("leisureJoy", next)}
/>
<ContextRating
field="leisureRecovery"
value={value.leisureRecovery}
onChange={(next) => set("leisureRecovery", next)}
/>
</div>
</SheetSection>
<SheetSection
title={t(contextSectionLabelKey("events"))}
icon={<CalendarClock />}
summary={
<SheetSectionCount count={contextSectionCount(value, "events")} />
}
>
<div className="space-y-4" data-slot="mood-context-events">
<ChoiceRow
label={t(contextValueLabelKey("field", "eventType"))}
field="eventType"
keys={EVENT_TYPE_KEYS}
value={value.eventType}
onChange={(next) => set("eventType", next)}
/>
<SliderField
label={t(contextValueLabelKey("field", "eventValence"))}
value={value.eventValence}
min={CONTEXT_VALENCE_MIN}
max={CONTEXT_VALENCE_MAX}
step={1}
lowAnchor={t("mood.context.rating.eventValence.low")}
highAnchor={t("mood.context.rating.eventValence.high")}
emptyLabel={t("mood.dimensionNotAnswered")}
clearLabel={t("mood.dimensionClear")}
onClear={() => set("eventValence", null)}
onValueChange={(next) => set("eventValence", next)}
/>
<FieldGroup
htmlFor="mood-context-event-at"
label={t(contextValueLabelKey("field", "eventAt"))}
>
<DateTimeField
id="mood-context-event-at"
value={value.eventAt}
onChange={(next) => set("eventAt", next)}
/>
</FieldGroup>
<FieldGroup
htmlFor="mood-context-note"
label={t(contextValueLabelKey("field", "notes"))}
>
<Textarea
id="mood-context-note"
value={value.note}
onChange={(e) => set("note", e.target.value)}
placeholder={t("mood.dayContext.notePlaceholder")}
maxLength={CONTEXT_NOTE_MAX_LENGTH}
rows={3}
/>
</FieldGroup>
</div>
</SheetSection>
</>
);
}
function formatFigure(
figure: LinkedFigure | undefined,
notRecorded: string,
): string {
if (!figure || !figure.present) return notRecorded;
return `${figure.value} ${figure.unit}`;
}
/**
* The read-only block: what the other modules already hold for this day.
*
* Every row names where its number comes from and offers a way in to change it
* there. A block whose module is switched off is not rendered at all — not
* zeroed, not greyed out — because turning a module off is a statement about
* what somebody wants to see.
*/
export function MoodLinkedDaySection({
linked,
}: {
linked: LinkedDayContext | null;
}) {
const { t } = useTranslations();
const notRecorded = t("mood.dayContext.notRecorded");
const rows = useMemo(() => {
if (!linked) return [];
const out: Array<{ label: string; value: string; href: string }> = [];
if (linked.sleep.available) {
out.push({
label: t("mood.dayContext.linkedSleep"),
value: formatFigure(linked.sleep.asleep, notRecorded),
href: "/insights/sleep",
});
out.push({
label: t("mood.dayContext.linkedInBed"),
value: formatFigure(linked.sleep.inBed, notRecorded),
href: "/insights/sleep",
});
}
if (linked.activity.available) {
out.push({
label: t("mood.dayContext.linkedSteps"),
value: formatFigure(linked.activity.steps, notRecorded),
href: "/insights/steps",
});
out.push({
label: t("mood.dayContext.linkedActiveEnergy"),
value: formatFigure(linked.activity.activeEnergy, notRecorded),
href: "/insights/steps",
});
}
if (linked.vitals.available) {
out.push({
label: t("mood.dayContext.linkedRestingHeartRate"),
value: formatFigure(linked.vitals.restingHeartRate, notRecorded),
href: "/measurements",
});
out.push({
label: t("mood.dayContext.linkedHrv"),
value: formatFigure(linked.vitals.heartRateVariability, notRecorded),
href: "/measurements",
});
}
return out;
}, [linked, notRecorded, t]);
if (!linked) return null;
const bodyLine = linked.body.available
? linked.body.logged
? t("mood.dayContext.bodyLogged", {
count: String(linked.body.symptoms.length),
})
: t("mood.dayContext.bodyNotLogged")
: null;
return (
<SheetSection
title={t("mood.dayContext.linkedTitle")}
icon={<Link2 />}
summary={<SheetSectionCount count={rows.length} />}
>
<div className="space-y-3" data-slot="mood-linked-day">
<p className="text-muted-foreground text-xs">
{t("mood.dayContext.linkedHelp")}
</p>
<dl className="space-y-1.5">
{rows.map((row) => (
<div
key={row.label}
className="flex items-center justify-between gap-3"
>
<dt className="text-muted-foreground text-xs">{row.label}</dt>
<dd className="text-foreground text-sm tabular-nums">
{row.value}
</dd>
</div>
))}
</dl>
{bodyLine ? (
<div className="border-border/60 flex items-center justify-between gap-3 border-t pt-3">
<span className="text-muted-foreground text-xs">
<Activity className="mr-1 inline h-3 w-3" aria-hidden="true" />
{bodyLine}
</span>
<a
href={
linked.body.available && linked.body.episodeId
? `/illness/${linked.body.episodeId}`
: "/illness"
}
className="text-primary text-xs underline underline-offset-2"
>
{t("mood.dayContext.openIllness")}
</a>
</div>
) : null}
</div>
</SheetSection>
);
}
+42
View File
@@ -52,6 +52,15 @@ import {
seedA1FromMood,
type LevelAState,
} from "./mood-level-a-fields";
import {
EMPTY_MOOD_CONTEXT,
MoodContextSections,
MoodLinkedDaySection,
contextIsEmpty,
moodContextPayload,
type MoodContextState,
} from "./mood-context-fields";
import { dayOfLocalInput, useLinkedDayContext } from "./use-linked-day-context";
import { MoodQuickTags } from "./mood-quick-tags";
import { useRecentTags } from "./recent-tags";
import { moodFaceIcon } from "./mood-tag-icons";
@@ -128,6 +137,9 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
const [levelA, setLevelA] = useState<LevelAState>(EMPTY_LEVEL_A);
const [a1Touched, setA1Touched] = useState(false);
const [levelAOpen, setLevelAOpen] = useState(false);
// v1.38 — the day's context. Four closed sections; nothing here is required
// and an entry that never opens one stores no context row at all.
const [context, setContext] = useState<MoodContextState>(EMPTY_MOOD_CONTEXT);
const [tagsInput, setTagsInput] = useState("");
// v1.8.5 — structured-tag keys picked from the taxonomy catalog.
const [tagKeys, setTagKeys] = useState<string[]>([]);
@@ -154,6 +166,9 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
// A level-A value the user set counts as typed input: Reset must ask
// before it discards an answer that took five slider moves.
Object.values(levelA).some((v) => v !== null) ||
// A filled context section is typed input too: Reset asks before it
// discards a day somebody described.
!contextIsEmpty(context) ||
note.trim() !== "";
// v1.37 — picking a face suggests a pleasantness value; the suggestion
@@ -184,6 +199,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
setLevelA(EMPTY_LEVEL_A);
setA1Touched(false);
setLevelAOpen(false);
setContext(EMPTY_MOOD_CONTEXT);
setTagsInput("");
setTagKeys([]);
setNote("");
@@ -225,6 +241,11 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
// v1.37 — only the values somebody answered. An untouched slider
// sends nothing, and the server writes NULL rather than a middle.
...levelACreatePayload(levelA),
// v1.38 — omitted when nothing was answered, so the create stores no
// context row rather than a row of NULLs.
...(contextIsEmpty(context)
? {}
: { context: moodContextPayload(context) }),
moodLoggedAt: timestamp,
});
@@ -295,6 +316,16 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
// primary input and a fast log is a single tap + Save.
const moodPicked = mood !== "";
// v1.38 — what the other modules already hold for the day being logged.
// Keyed on the timestamp field, so moving the entry to yesterday shows
// yesterday's night. Only fetched once a face is picked: an unopened sheet
// must not cost four modules a query.
// No third argument, and deliberately: this entry does not exist yet, so it
// has no stored zone. It will be anchored to the browser's, which is the
// zone the hook falls back to — the figures shown here are the figures the
// saved row will sit beside.
const linked = useLinkedDayContext(dayOfLocalInput(moodLoggedAt), moodPicked);
// v1.17.0 — the "Note & details" badge counts the free-text tags entered
// plus the note (when present), so a collapsed section communicates what
// it holds.
@@ -420,6 +451,17 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
onOpenChange={setLevelAOpen}
/>
{/* v1.38 — the day around the entry: work, contacts, leisure and a
notable event. Four closed sections; opening none of them is a
complete entry. */}
<MoodContextSections value={context} onChange={setContext} />
{/* v1.38 — read-only, and read from the modules that own it. Sleep,
activity and vitals are never asked for a second time here; the
body line links into the illness journal instead of growing a
second symptom form. */}
<MoodLinkedDaySection linked={linked} />
{/* v1.17.0 — "Note & details": free-text tags, GLP-1 quick-tags,
and the note field. The badge counts whichever of these carry
input. */}
+61
View File
@@ -94,6 +94,16 @@ import {
levelAUpdatePayload,
type LevelAState,
} from "./mood-level-a-fields";
import type { MoodContextWire } from "@/lib/mood/context";
import {
EMPTY_MOOD_CONTEXT,
MoodContextSections,
MoodLinkedDaySection,
moodContextFromEntry,
moodContextPayload,
type MoodContextState,
} from "./mood-context-fields";
import { dayOfLocalInput, useLinkedDayContext } from "./use-linked-day-context";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
@@ -118,6 +128,14 @@ interface MoodEntry {
a4?: number | null;
a5?: number | null;
note: string | null;
// v1.38 — the day context, null on entries that carry none.
context: MoodContextWire | null;
/**
* The IANA zone this entry's `date` is anchored to, or null on a legacy row.
* Read by the linked-day block so an entry logged in another country is
* looked up under ITS day rather than under the corrector's.
*/
tz: string | null;
source: string;
moodLoggedAt: string;
}
@@ -201,6 +219,12 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
// v1.37 — the five level-A values on the edit path. Seeded from the entry,
// so what the capture sheet wrote is visible and changeable here.
const [editLevelA, setEditLevelA] = useState<LevelAState>(EMPTY_LEVEL_A);
// v1.38 — the day context on the edit path, seeded from the entry so what
// the capture sheet wrote is visible and correctable here. A value that can
// be written on one surface and not corrected on another is worse than one
// nobody was offered.
const [editContext, setEditContext] =
useState<MoodContextState>(EMPTY_MOOD_CONTEXT);
const [editNote, setEditNote] = useState("");
const [editMoodLoggedAt, setEditMoodLoggedAt] = useState("");
const [editSeed, setEditSeed] = useState<{
@@ -209,6 +233,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
tagKeys: string[];
ratedFactors: RatedFactor[];
levelA: LevelAState;
context: MoodContextState;
note: string;
moodLoggedAt: string;
}>({
@@ -217,6 +242,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
tagKeys: [],
ratedFactors: [],
levelA: EMPTY_LEVEL_A,
context: EMPTY_MOOD_CONTEXT,
note: "",
moodLoggedAt: "",
});
@@ -410,6 +436,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
tagKeys,
ratedFactors,
levelA,
context,
note,
moodLoggedAt,
}: {
@@ -419,6 +446,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
tagKeys: string[];
ratedFactors: RatedFactor[];
levelA: LevelAState;
context: MoodContextState;
note: string | null;
moodLoggedAt: string;
}) => {
@@ -431,6 +459,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
// user cleared has to travel as an explicit null or the old answer
// survives an edit that visibly removed it.
...levelAUpdatePayload(levelA),
// Always stated on this path, and `null` when every section is empty:
// the dialog shows the whole context, so an omission here would leave
// a context the person just cleared sitting on the row.
context: moodContextPayload(context),
note,
moodLoggedAt,
});
@@ -446,6 +478,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
a.key.localeCompare(b.key),
),
levelA: editLevelA,
context: editContext,
note: editNote,
moodLoggedAt: editMoodLoggedAt,
});
@@ -465,12 +498,26 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
a.key.localeCompare(b.key),
),
levelA: editLevelA,
context: editContext,
note: editNote,
moodLoggedAt: editMoodLoggedAt,
},
blocked: updateMutation.isPending || deleteMutation.isPending,
});
// v1.38 — the linked figures for the entry being edited, read for its own
// local day. Only while a dialog is open: a list of fifty rows must not cost
// fifty module reads.
const editLinked = useLinkedDayContext(
dayOfLocalInput(editMoodLoggedAt),
editing !== null,
// The entry's OWN zone, not the browser's. An entry logged abroad decided
// which day it belongs to when it was written; re-reading that day under
// whichever zone the correction is being made from would shift the night
// beside it by hours with nothing on screen saying so.
editing?.tz ?? null,
);
const totalPages = data ? Math.ceil(data.meta.total / PAGE_SIZE) : 0;
function startEdit(entry: MoodEntry) {
@@ -482,6 +529,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
a.key.localeCompare(b.key),
),
levelA: levelAFromEntry(entry),
context: moodContextFromEntry(entry.context),
note: entry.note ?? "",
moodLoggedAt: toDateTimeLocalValue(entry.moodLoggedAt),
};
@@ -491,6 +539,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
setEditTagKeys(seed.tagKeys);
setEditRatedFactors(seed.ratedFactors);
setEditLevelA(seed.levelA);
setEditContext(seed.context);
setEditNote(seed.note);
setEditMoodLoggedAt(seed.moodLoggedAt);
setEditSeed(seed);
@@ -544,6 +593,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
tagKeys: editTagKeys,
ratedFactors: editRatedFactors,
levelA: editLevelA,
context: editContext,
note: trimmedNote.length > 0 ? trimmedNote : null,
moodLoggedAt: measuredDate.toISOString(),
});
@@ -1044,6 +1094,17 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
set. */}
<MoodLevelASection value={editLevelA} onChange={setEditLevelA} />
{/* v1.38 — the day context, seeded from the entry. */}
<MoodContextSections
value={editContext}
onChange={setEditContext}
/>
{/* v1.38 — read-only, from the modules that own it. Resolved for
the entry's own day, so an entry moved to another date shows
that day's figures. */}
<MoodLinkedDaySection linked={editLinked} />
{/* v1.8.5 (C1) — free-text note. */}
<div className="space-y-2">
<Label htmlFor="edit-mood-note">
@@ -0,0 +1,100 @@
"use client";
/**
* Read what the other modules already hold for one local day.
*
* A hook rather than a prop drilled from a page, because both mood surfaces
* need it and both know only the wall-clock value in their timestamp field.
*
* **Which zone that day is read in depends on which surface is asking, and the
* two are genuinely different questions.** The capture sheet is about to write
* a new entry, and that entry will be anchored to the zone the browser is in,
* so the browser's zone is the right one and the figures it shows are the
* figures the saved row will sit beside. The edit dialog is looking at an
* entry that already decided which day it belongs to and stored the zone it
* decided in: reading that entry's day under the corrector's browser zone
* would show a night from an entry logged in another country shifted by
* several hours, and nothing on the screen would say so. So the caller passes
* the entry's stored `tz` and the hook does not guess.
*
* The query key carries both the day and the zone. One key for both would let
* whichever answer arrived first serve the other — the same-key /
* different-shape cache poisoning the key factory exists to prevent, in its
* quieter same-shape / different-window form. Two surfaces asking about the
* same calendar date under two zones is exactly that case, and it is reachable
* from one screen: the list can hold an entry logged abroad while the sheet
* writes one logged here.
*/
import { useQuery } from "@tanstack/react-query";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { LinkedDayContext } from "@/lib/mood/linked-context";
/**
* The browser's own zone — the right answer for an entry that does not exist
* yet, and the wrong one for an entry that already stored its own.
*/
export function browserTimezone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
/** `YYYY-MM-DD` for a `datetime-local` value, or today when it is empty. */
export function dayOfLocalInput(value: string): string {
if (value.length >= 10) return value.slice(0, 10);
const now = new Date();
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60 * 1000);
return local.toISOString().slice(0, 10);
}
/**
* @param day the local day to read, `YYYY-MM-DD`
* @param enabled whether to fetch at all
* @param timezone the zone `day` is anchored to. Omit ONLY where the day has
* no zone of its own yet — a new entry the capture sheet is composing. An
* entry being edited passes its stored `tz`.
*/
/**
* The zone a linked-day read should use, and the URL that follows from it.
*
* Pure and exported so the decision can be tested without a renderer: the
* whole finding this answers was a doc comment claiming the edit dialog passed
* the entry's zone while the code always sent the browser's, and a claim like
* that is only worth as much as the assertion under it.
*
* A stored zone wins. Absent — a legacy row with `tz IS NULL`, or an entry
* that does not exist yet — falls back to the browser's, which is honest about
* being an assumption and is what the capture sheet means anyway. It does NOT
* fall back to Europe/Berlin: the server owns that legacy rule, and sending a
* guess would override the one place it is written down.
*/
export function linkedDayRequest(
day: string,
storedTimezone: string | null | undefined,
browserTz: string,
): { tz: string; url: string } {
const tz =
storedTimezone && storedTimezone.length > 0 ? storedTimezone : browserTz;
return {
tz,
url: `/api/mood/linked-context?date=${encodeURIComponent(day)}&tz=${encodeURIComponent(tz)}`,
};
}
export function useLinkedDayContext(
day: string,
enabled = true,
timezone?: string | null,
) {
const { tz, url } = linkedDayRequest(day, timezone, browserTimezone());
const query = useQuery({
queryKey: queryKeys.moodLinkedContext(day, tz),
queryFn: () => apiGet<LinkedDayContext>(url),
enabled,
// The figures come from other modules and change on their own schedule; a
// minute of staleness is cheaper than refetching four modules every time
// the sheet re-renders.
staleTime: 60_000,
});
return query.data ?? null;
}