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;
}