mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user