From 13d808ccffddf70c2339e5371c7f4f35c865eeca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 12:37:14 +0200 Subject: [PATCH] fix(mood): bound the fit's read, and show the day as a date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small things the first pass left rough. The fit's window read had no row cap. A year holds 365 days and the fold keeps one entry per day, so the cap only bites on an account that logs many times a day — but an unbounded read in a nightly job is a table scan waiting for the account that has years of history. The rows now arrive newest first under a cap, which also puts the loss on the right end: an account over the cap loses its oldest days, never the recent ones a forecast is written for. And the card showed the raw day key. It goes through the account's own date-order preference now, pinned to UTC like every other day key in the app, so this card cannot show a different day from the list beside it. --- .../__tests__/mood-prognosis-card.test.tsx | 8 +++++- .../insights/mood/mood-prognosis-card.tsx | 13 +++++++-- src/lib/analytics/mood-prognosis/load.ts | 28 ++++++++++++++++--- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/components/insights/mood/__tests__/mood-prognosis-card.test.tsx b/src/components/insights/mood/__tests__/mood-prognosis-card.test.tsx index ac0d87f8c..7423ac228 100644 --- a/src/components/insights/mood/__tests__/mood-prognosis-card.test.tsx +++ b/src/components/insights/mood/__tests__/mood-prognosis-card.test.tsx @@ -102,7 +102,13 @@ describe("", () => { it("says which day it is about when that day is not today", () => { const stale = render(present({ current: false, ageDays: 4 })); expect(stale).toContain('data-slot="mood-prognosis-age"'); - expect(stale).toContain("2026-08-07"); + // Rendered through the account's own date-order preference rather than as + // the raw day key — under this render's `en` locale and the default AUTO + // preference that is month-first. The assertion is on the formatted form + // precisely because a raw `2026-08-07` on the screen would pass a looser + // one. + expect(stale).toContain("08/07/2026"); + expect(stale).not.toContain("2026-08-07"); }); it("names what the model weighted, in the reader's own words", () => { diff --git a/src/components/insights/mood/mood-prognosis-card.tsx b/src/components/insights/mood/mood-prognosis-card.tsx index a4343e2b3..4167d1941 100644 --- a/src/components/insights/mood/mood-prognosis-card.tsx +++ b/src/components/insights/mood/mood-prognosis-card.tsx @@ -35,7 +35,8 @@ import { TileHeader } from "@/components/insights/tile-header"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; import { apiGet } from "@/lib/api/api-fetch"; -import { useTranslations } from "@/lib/i18n/context"; +import { formatDate } from "@/lib/date-format"; +import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import type { PrognosisReading } from "@/lib/analytics/mood-prognosis/read"; @@ -78,7 +79,13 @@ export function MoodPrognosisCard() { */ export function MoodPrognosisView({ reading }: { reading: PrognosisReading }) { const { t, locale } = useTranslations(); + const dateFormat = useDateFormatPreference(); const data = reading; + // The day key is a plain calendar date and is rendered as one, through the + // account's own date-order preference and pinned to UTC — the same treatment + // every other day key in the app gets, so this card cannot show a different + // day from the list beside it. + const day = data.present ? formatDate(data.date, dateFormat, locale) : ""; // Below the first rung there is nothing to say and the surface says nothing // rather than showing an empty frame with an apology in it. @@ -145,7 +152,7 @@ export function MoodPrognosisView({ reading }: { reading: PrognosisReading }) { > {t("insights.mood.prognosis.statement", { value: formatValue(data.predicted, locale), - date: data.date, + date: day, n: String(data.n), })}

@@ -164,7 +171,7 @@ export function MoodPrognosisView({ reading }: { reading: PrognosisReading }) { className="text-muted-foreground text-xs" data-slot="mood-prognosis-age" > - {t("insights.mood.prognosis.notToday", { date: data.date })} + {t("insights.mood.prognosis.notToday", { date: day })}

) : null} diff --git a/src/lib/analytics/mood-prognosis/load.ts b/src/lib/analytics/mood-prognosis/load.ts index eb2ba1b99..f21bc319a 100644 --- a/src/lib/analytics/mood-prognosis/load.ts +++ b/src/lib/analytics/mood-prognosis/load.ts @@ -44,6 +44,17 @@ export const PROGNOSIS_FIT_WINDOW_DAYS = 365; */ export const PROGNOSIS_WRITE_WINDOW_DAYS = 7; +/** + * Hard cap on the rows one fit reads. + * + * A year holds 365 days and the fold keeps one entry per day, so the cap only + * ever bites on an account that logs many times a day — and there it bites on + * the oldest days, which is the right end to lose. It is here because an + * unbounded `findMany` in a nightly job over an account with years of history + * is a table scan waiting for the account that has one. + */ +export const MAX_PROGNOSIS_ROWS = 5000; + /** `YYYY-MM-DD`, this many days before the given instant, in UTC terms. */ export function dayKeyBefore(now: Date, days: number): string { return new Date(now.getTime() - days * 86_400_000).toISOString().slice(0, 10); @@ -67,10 +78,13 @@ export async function loadPrognosisDays( const since = dayKeyBefore(now, windowDays); const entries = await prisma.moodEntry.findMany({ where: { userId, deletedAt: null, date: { gte: since } }, - // Ascending, so a later entry on the same day overwrites the earlier one - // in the fold below and the "last entry of the day wins" rule is the - // read order rather than a comparison. - orderBy: [{ date: "asc" }, { moodLoggedAt: "asc" }], + // Descending, and the fold below keeps the FIRST row it sees per day, so + // "the last entry of a day wins" is the read order rather than a + // comparison. Descending also puts the cap on the right end: an account + // that logs many times a day loses its oldest days to the limit, never + // its newest ones, and the newest are the days a forecast is written for. + orderBy: [{ date: "desc" }, { moodLoggedAt: "desc" }], + take: MAX_PROGNOSIS_ROWS, select: { date: true, moodA1: true, @@ -107,6 +121,9 @@ export async function loadPrognosisDays( const byDay = new Map(); for (const entry of entries) { + // The rows arrive newest first, so the first one seen for a day is that + // day's last entry. + if (byDay.has(entry.date)) continue; byDay.set(entry.date, { day: entry.date, a1: entry.moodA1, @@ -185,6 +202,9 @@ export async function countQualifyingDays( }, distinct: ["date"], select: { date: true }, + // Bounded by the window either way — a year holds 366 distinct day keys — + // and the cap says so rather than trusting the window to be small. + take: 400, }); return days.length; }