mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
fix(activity): never rate today's partial day in the activity calendar
Today is shown as in_progress instead of being placed against whole days, and a running period is compared over its completed days only: the total through yesterday against the usual rate times the days completed. The period total and goal progress still count today. The comparison gains completedDays and completedValue.
This commit is contained in:
1 parent
e482f76508
commit
89b30ebdee
14 files changed
+218
-49
No files matched your search
@@ -58,6 +58,16 @@ function calendar() {
|
||||
load: null,
|
||||
});
|
||||
}
|
||||
// This morning: a few hundred steps so far.
|
||||
input.push({
|
||||
date: today,
|
||||
steps: 400,
|
||||
exerciseMinutes: null,
|
||||
workoutMinutes: 0,
|
||||
workoutCount: 0,
|
||||
workoutsBySport: {},
|
||||
load: null,
|
||||
});
|
||||
input.push({
|
||||
date: "2026-10-05",
|
||||
steps: 20000,
|
||||
@@ -113,10 +123,19 @@ describe("ActivityCalendarCard", () => {
|
||||
state.data = calendar();
|
||||
const html = render();
|
||||
expect(html).toContain('data-metric="steps"');
|
||||
expect(html).toContain("Your usual by this point:");
|
||||
// A running month is compared through yesterday, not on today.
|
||||
expect(html).toContain("Through yesterday");
|
||||
expect(html).toContain("your usual by then");
|
||||
expect(html).toContain("Running: 1");
|
||||
});
|
||||
|
||||
it("marks today as in progress rather than rating it", () => {
|
||||
state.data = calendar();
|
||||
const html = render();
|
||||
expect(html).toMatch(/data-date="2026-10-21" data-level="in_progress"/);
|
||||
expect(html).toContain("Today, still in progress");
|
||||
});
|
||||
|
||||
it("shows goal progress and a week tick column", () => {
|
||||
state.data = calendar();
|
||||
const html = render();
|
||||
|
||||
@@ -41,6 +41,7 @@ type DayLevel = CalendarDay["level"]["steps"];
|
||||
const LEVEL_BAR: Record<DayLevel, string> = {
|
||||
none: "bg-transparent",
|
||||
unrated: "bg-muted-foreground/60",
|
||||
in_progress: "bg-muted-foreground/30",
|
||||
low: "bg-chart-2/20",
|
||||
below: "bg-chart-2/40",
|
||||
usual: "bg-chart-2/60",
|
||||
@@ -412,10 +413,13 @@ export function ActivityCalendarCard() {
|
||||
.filter((m) => data.available[m])
|
||||
.map((m) => {
|
||||
const s = data.summary[m];
|
||||
// Compared over completed days only: today has not ended.
|
||||
const delta =
|
||||
s.value !== null && s.usualToDate
|
||||
? (s.value - s.usualToDate) / s.usualToDate
|
||||
s.completedValue !== null && s.usualToDate
|
||||
? (s.completedValue - s.usualToDate) / s.usualToDate
|
||||
: null;
|
||||
const running =
|
||||
data.today >= data.start && data.today <= data.end;
|
||||
return (
|
||||
<li
|
||||
key={m}
|
||||
@@ -431,11 +435,24 @@ export function ActivityCalendarCard() {
|
||||
})}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{s.usualToDate === null
|
||||
? t("activityCalendar.summary.noUsual")
|
||||
: t("activityCalendar.summary.usual", {
|
||||
value: formatMetric(m, s.usualToDate),
|
||||
})}
|
||||
{s.usual !== null && s.completedDays === 0
|
||||
? t("activityCalendar.summary.firstDay")
|
||||
: s.usualToDate === null
|
||||
? t("activityCalendar.summary.noUsual")
|
||||
: running
|
||||
? t(
|
||||
"activityCalendar.summary.usualThroughYesterday",
|
||||
{
|
||||
value:
|
||||
s.completedValue === null
|
||||
? "–"
|
||||
: formatMetric(m, s.completedValue),
|
||||
usual: formatMetric(m, s.usualToDate),
|
||||
},
|
||||
)
|
||||
: t("activityCalendar.summary.usual", {
|
||||
value: formatMetric(m, s.usualToDate),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{delta !== null ? (
|
||||
|
||||
@@ -143,7 +143,7 @@ describe("period against the usual period", () => {
|
||||
expect(result.referencePeriods).toBe(12);
|
||||
});
|
||||
|
||||
it("pro-rates the usual for a period still running", () => {
|
||||
it("compares a running period over its completed days, never today's partial day", () => {
|
||||
const bounds = periodBounds("week", "2026-10-21");
|
||||
const values = new Map<string, number>();
|
||||
for (let i = 1; i <= 84; i++)
|
||||
@@ -155,9 +155,27 @@ describe("period against the usual period", () => {
|
||||
values.get(d) ?? (d <= "2026-10-21" && d >= bounds.start ? 2000 : null),
|
||||
mode: "sum",
|
||||
});
|
||||
// The total shown counts today; the comparison stops at yesterday.
|
||||
expect(result.value).toBe(6000);
|
||||
expect(result.usual).toBe(7000);
|
||||
expect(result.usualToDate).toBe(3000);
|
||||
expect(result.completedDays).toBe(2);
|
||||
expect(result.completedValue).toBe(4000);
|
||||
expect(result.usualToDate).toBe(2000);
|
||||
});
|
||||
|
||||
it("has nothing to compare on the first day of a period", () => {
|
||||
const bounds = periodBounds("week", "2026-10-19");
|
||||
const result = comparePeriod({
|
||||
bounds,
|
||||
today: "2026-10-19", // Monday
|
||||
valueOf: (d) => (d <= "2026-10-19" ? 1000 : null),
|
||||
mode: "sum",
|
||||
});
|
||||
expect(result.value).toBe(1000);
|
||||
expect(result.usual).toBe(7000);
|
||||
expect(result.completedDays).toBe(0);
|
||||
expect(result.completedValue).toBeNull();
|
||||
expect(result.usualToDate).toBeNull();
|
||||
});
|
||||
|
||||
it("compares months per day, so February meets March fairly", () => {
|
||||
@@ -258,6 +276,28 @@ describe("the assembled calendar", () => {
|
||||
).toBe("none");
|
||||
});
|
||||
|
||||
it("shows today as in progress, never rated against whole days", () => {
|
||||
const morning = buildActivityCalendar({
|
||||
bounds,
|
||||
today,
|
||||
input: [...input, day(today, { steps: 800 })],
|
||||
goals: NO_ACTIVITY_GOALS,
|
||||
});
|
||||
const todayCell = morning.days.find((d) => d.date === today);
|
||||
expect(todayCell?.steps).toBe(800);
|
||||
expect(todayCell?.level.steps).toBe("in_progress");
|
||||
expect(todayCell?.level.activeMinutes).toBe("in_progress");
|
||||
expect(todayCell?.level.load).toBe("none");
|
||||
// Yesterday, with the same value, is rated.
|
||||
const yesterday = buildActivityCalendar({
|
||||
bounds,
|
||||
today,
|
||||
input: [...input, day("2026-10-20", { steps: 800 })],
|
||||
goals: NO_ACTIVITY_GOALS,
|
||||
}).days.find((d) => d.date === "2026-10-20");
|
||||
expect(yesterday?.level.steps).toBe("low");
|
||||
});
|
||||
|
||||
it("counts workouts by sport in the period", () => {
|
||||
expect(calendar.sports).toEqual([
|
||||
{ sportType: "cycling", count: 1 },
|
||||
|
||||
@@ -11,14 +11,17 @@
|
||||
* above the 90th `high`, everything between `usual`. With fewer than
|
||||
* `MIN_BASELINE_DAYS` days that carry a value the day is `unrated`:
|
||||
* there is not enough history to say what usual is, and the calendar says
|
||||
* so instead of guessing.
|
||||
* so instead of guessing. TODAY is `in_progress`, never rated: a day
|
||||
* that has not ended would read as a low day every morning.
|
||||
* - A PERIOD total is placed against the median total of the same kind of
|
||||
* period before it: the twelve weeks before a week, the six months before
|
||||
* a month. A month is compared per day (months differ in length), so the
|
||||
* usual month is the median daily rate of the earlier months times this
|
||||
* month's length. A period still running is compared with the usual rate
|
||||
* times the days elapsed so far, not with a full period it cannot have
|
||||
* reached yet. At least `MIN_REFERENCE_PERIODS` earlier periods with data
|
||||
* month's length. A period still running is compared over its COMPLETED
|
||||
* days only: the total through yesterday against the usual rate times the
|
||||
* days completed, never a full period it cannot have reached yet, and
|
||||
* never today's partial day (a morning would always read as a shortfall).
|
||||
* The period total shown and the goals still count today. At least `MIN_REFERENCE_PERIODS` earlier periods with data
|
||||
* are needed; fewer gives no usual.
|
||||
*
|
||||
* What a day's value is:
|
||||
@@ -62,7 +65,14 @@ export const MIN_REFERENCE_PERIODS = 3;
|
||||
export type ActivityPeriod = "week" | "month";
|
||||
export type RatedMetric = "steps" | "activeMinutes" | "load";
|
||||
export type DayLevel =
|
||||
"none" | "unrated" | "low" | "below" | "usual" | "above" | "high";
|
||||
| "none"
|
||||
| "unrated"
|
||||
| "in_progress"
|
||||
| "low"
|
||||
| "below"
|
||||
| "usual"
|
||||
| "above"
|
||||
| "high";
|
||||
|
||||
/** One local day of raw signals, as the reader assembles it. */
|
||||
export interface ActivityDayInput {
|
||||
@@ -237,13 +247,17 @@ export function dayRange(from: string, to: string): string[] {
|
||||
}
|
||||
|
||||
export interface PeriodComparison {
|
||||
/** The period's total (steps, active minutes, workouts) or mean (load), over the days so far. */
|
||||
/** The period's total (steps, active minutes, workouts) or mean (load), over the days so far, today included. */
|
||||
value: number | null;
|
||||
/** Days of the period that carry a value. */
|
||||
daysWithData: number;
|
||||
/** Days of the period that have ended (today excluded). */
|
||||
completedDays: number;
|
||||
/** `value` over the completed days only: what `usualToDate` is compared with. */
|
||||
completedValue: number | null;
|
||||
/** The usual full period, or null when there is not enough history. */
|
||||
usual: number | null;
|
||||
/** The usual figure for the days elapsed so far (equals `usual` for a finished period). */
|
||||
/** The usual figure for the completed days (equals `usual` for a finished period); null before the first day has ended. */
|
||||
usualToDate: number | null;
|
||||
/** Earlier periods the usual was computed from. */
|
||||
referencePeriods: number;
|
||||
@@ -293,15 +307,21 @@ export function comparePeriod(input: {
|
||||
}): PeriodComparison {
|
||||
const { bounds, today, valueOf, mode } = input;
|
||||
const lastCounted = bounds.end < today ? bounds.end : today;
|
||||
const yesterday = shiftDateKey(today, -1);
|
||||
const lastCompleted = bounds.end < yesterday ? bounds.end : yesterday;
|
||||
const length = daysBetweenDateKeys(bounds.start, bounds.end) + 1;
|
||||
const elapsed =
|
||||
lastCounted < bounds.start
|
||||
? 0
|
||||
: daysBetweenDateKeys(bounds.start, lastCounted) + 1;
|
||||
const daysUpTo = (last: string) =>
|
||||
last < bounds.start ? 0 : daysBetweenDateKeys(bounds.start, last) + 1;
|
||||
const elapsed = daysUpTo(lastCounted);
|
||||
const completed = daysUpTo(lastCompleted);
|
||||
const current =
|
||||
elapsed === 0
|
||||
? { value: null, n: 0 }
|
||||
: aggregate(dayRange(bounds.start, lastCounted).map(valueOf), mode);
|
||||
const done =
|
||||
completed === 0
|
||||
? { value: null, n: 0 }
|
||||
: aggregate(dayRange(bounds.start, lastCompleted).map(valueOf), mode);
|
||||
|
||||
// Each earlier period becomes a per-day rate (sum) or a mean (mean), so a
|
||||
// 28-day February and a 31-day March compare on the same footing.
|
||||
@@ -319,13 +339,15 @@ export function comparePeriod(input: {
|
||||
usualRate === null
|
||||
? null
|
||||
: mode === "sum"
|
||||
? usualRate * elapsed
|
||||
? usualRate * completed
|
||||
: usualRate;
|
||||
return {
|
||||
value: current.value,
|
||||
daysWithData: current.n,
|
||||
completedDays: completed,
|
||||
completedValue: done.value,
|
||||
usual,
|
||||
usualToDate: elapsed === 0 ? null : usualToDate,
|
||||
usualToDate: completed === 0 ? null : usualToDate,
|
||||
referencePeriods: rates.length,
|
||||
};
|
||||
}
|
||||
@@ -477,6 +499,14 @@ export function buildActivityCalendar(args: {
|
||||
? null
|
||||
: activeMinutesOf(day, hasActivityOn(date));
|
||||
const load = future ? null : day.load;
|
||||
// Today has not ended: shown, never rated against whole days.
|
||||
const levelOf = (
|
||||
value: number | null,
|
||||
stats: BaselineStats | null,
|
||||
): DayLevel =>
|
||||
date === today && value !== null
|
||||
? "in_progress"
|
||||
: dayLevel(value, stats);
|
||||
return {
|
||||
date,
|
||||
inPeriod: date >= bounds.start && date <= bounds.end,
|
||||
@@ -487,9 +517,9 @@ export function buildActivityCalendar(args: {
|
||||
workoutsBySport: future ? {} : day.workoutsBySport,
|
||||
load,
|
||||
level: {
|
||||
steps: dayLevel(steps, baseline.steps),
|
||||
activeMinutes: dayLevel(activeMinutes, baseline.activeMinutes),
|
||||
load: dayLevel(load, baseline.load),
|
||||
steps: levelOf(steps, baseline.steps),
|
||||
activeMinutes: levelOf(activeMinutes, baseline.activeMinutes),
|
||||
load: levelOf(load, baseline.load),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -21,9 +21,18 @@ export type ActivityCalendarQuery = z.infer<typeof activityCalendarQuerySchema>;
|
||||
const dateKey = z.iso.date();
|
||||
|
||||
const dayLevelEnum = z
|
||||
.enum(["none", "unrated", "low", "below", "usual", "above", "high"])
|
||||
.enum([
|
||||
"none",
|
||||
"unrated",
|
||||
"in_progress",
|
||||
"low",
|
||||
"below",
|
||||
"usual",
|
||||
"above",
|
||||
"high",
|
||||
])
|
||||
.describe(
|
||||
"Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual` between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with.",
|
||||
"Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual` between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with; `in_progress` = today, which has not ended and is never rated.",
|
||||
);
|
||||
|
||||
const baselineStatsSchema = z
|
||||
@@ -50,6 +59,16 @@ const periodComparisonSchema = z
|
||||
"The period so far: a total (steps, active minutes, workouts) or a mean (load).",
|
||||
),
|
||||
daysWithData: z.number().int(),
|
||||
completedDays: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("Days of the period that have ended (today excluded)."),
|
||||
completedValue: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe(
|
||||
"`value` over the completed days only. This, not `value`, is what `usualToDate` is compared with, so a running period is never judged on today's partial day.",
|
||||
),
|
||||
usual: z
|
||||
.number()
|
||||
.nullable()
|
||||
@@ -60,7 +79,7 @@ const periodComparisonSchema = z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The usual figure for the days elapsed so far; equals `usual` once the period is over.",
|
||||
"The usual figure for the completed days (today excluded); equals `usual` once the period is over, null before the period's first day has ended.",
|
||||
),
|
||||
referencePeriods: z.number().int(),
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user