fix(mood): bound the fit's read, and show the day as a date

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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 12:37:14 +02:00
1 parent 09bf0a1947
commit 13d808ccff
3 files changed
+41 -8

No files matched your search

@@ -102,7 +102,13 @@ describe("<MoodPrognosisView>", () => {
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", () => {
@@ -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),
})}
</p>
@@ -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 })}
</p>
) : null}
+24 -4
View File
@@ -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<string, PrognosisDayInput>();
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;
}