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:
Marc-André Bombeck committed 2026-10-21 22:47:07 +02:00
1 parent e482f76508
commit 89b30ebdee
14 files changed
+218 -49

No files matched your search

+24 -4
View File
@@ -50159,6 +50159,7 @@ components:
enum:
- none
- unrated
- in_progress
- low
- below
- usual
@@ -50166,12 +50167,14 @@ components:
- high
description: "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."
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."
activeMinutes:
type: string
enum:
- none
- unrated
- in_progress
- low
- below
- usual
@@ -50179,12 +50182,14 @@ components:
- high
description: "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."
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."
load:
type: string
enum:
- none
- unrated
- in_progress
- low
- below
- usual
@@ -50192,7 +50197,8 @@ components:
- high
description: "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."
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."
required:
- steps
- activeMinutes
@@ -50412,6 +50418,17 @@ components:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
completedDays:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: Days of the period that have ended (today excluded).
completedValue:
description: "`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."
type:
- number
- "null"
usual:
description: "The person's usual full period: the median of the twelve weeks (or, per day, of the six months) before it.
Null with fewer than three earlier periods of data."
@@ -50419,7 +50436,8 @@ components:
- number
- "null"
usualToDate:
description: The usual figure for the days elapsed so far; equals `usual` once the period is over.
description: 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.
type:
- number
- "null"
@@ -50430,6 +50448,8 @@ components:
required:
- value
- daysWithData
- completedDays
- completedValue
- usual
- usualToDate
- referencePeriods
+5 -2
View File
@@ -13640,7 +13640,8 @@
"below": "Unter deinem Üblichen",
"usual": "Etwa wie üblich",
"above": "Über deinem Üblichen",
"high": "Weit über deinem Üblichen"
"high": "Weit über deinem Üblichen",
"in_progress": "Heute, noch nicht vorbei"
},
"legend": "Verglichen mit deinen 12 Wochen davor:",
"noBaseline": "Dein üblicher Bereich erscheint nach 14 Tagen mit Daten.",
@@ -13650,7 +13651,9 @@
"workouts": "{value} Trainings",
"load": "Durchschnittliche Trainingslast {value}",
"usual": "Bis hierher üblich bei dir: {value}",
"noUsual": "Noch zu wenige frühere Wochen oder Monate zum Vergleichen."
"noUsual": "Noch zu wenige frühere Wochen oder Monate zum Vergleichen.",
"usualThroughYesterday": "Bis gestern {value}, bis dahin üblich bei dir {usual}",
"firstDay": "Der Vergleich mit deinem Üblichen kommt, sobald der erste Tag vorbei ist."
},
"sportCount": "{sport}: {count}",
"goal": {
+5 -2
View File
@@ -13640,7 +13640,8 @@
"below": "Below your usual",
"usual": "About your usual",
"above": "Above your usual",
"high": "Far above your usual"
"high": "Far above your usual",
"in_progress": "Today, still in progress"
},
"legend": "Compared with your 12 weeks before:",
"noBaseline": "Your usual range shows after 14 days with data.",
@@ -13650,7 +13651,9 @@
"workouts": "{value} workouts",
"load": "Average training load {value}",
"usual": "Your usual by this point: {value}",
"noUsual": "Not enough earlier weeks or months to compare with yet."
"noUsual": "Not enough earlier weeks or months to compare with yet.",
"usualThroughYesterday": "Through yesterday {value}, your usual by then {usual}",
"firstDay": "Compared with your usual once the first day is over."
},
"sportCount": "{sport}: {count}",
"goal": {
+5 -2
View File
@@ -13640,7 +13640,8 @@
"below": "Por debajo de lo habitual",
"usual": "Como de costumbre",
"above": "Por encima de lo habitual",
"high": "Muy por encima de lo habitual"
"high": "Muy por encima de lo habitual",
"in_progress": "Hoy, aún en curso"
},
"legend": "Comparado con tus 12 semanas anteriores:",
"noBaseline": "Tu rango habitual aparece tras 14 días con datos.",
@@ -13650,7 +13651,9 @@
"workouts": "{value} entrenamientos",
"load": "Carga de entrenamiento media {value}",
"usual": "Lo habitual hasta este punto: {value}",
"noUsual": "Aún no hay suficientes semanas o meses anteriores para comparar."
"noUsual": "Aún no hay suficientes semanas o meses anteriores para comparar.",
"usualThroughYesterday": "Hasta ayer {value}, lo habitual para ti hasta entonces {usual}",
"firstDay": "Se compara con lo habitual cuando termine el primer día."
},
"sportCount": "{sport}: {count}",
"goal": {
+5 -2
View File
@@ -13640,7 +13640,8 @@
"below": "En dessous de ton habitude",
"usual": "Comme d'habitude",
"above": "Au-dessus de ton habitude",
"high": "Bien au-dessus de ton habitude"
"high": "Bien au-dessus de ton habitude",
"in_progress": "Aujourd’hui, en cours"
},
"legend": "Comparé à tes 12 semaines précédentes :",
"noBaseline": "Ta plage habituelle apparaît après 14 jours avec des données.",
@@ -13650,7 +13651,9 @@
"workouts": "{value} entraînements",
"load": "Charge d'entraînement moyenne {value}",
"usual": "Ton habitude à ce stade : {value}",
"noUsual": "Pas encore assez de semaines ou de mois précédents pour comparer."
"noUsual": "Pas encore assez de semaines ou de mois précédents pour comparer.",
"usualThroughYesterday": "Jusqu’à hier {value}, ton habitude à ce stade {usual}",
"firstDay": "Comparé à ton habitude dès que le premier jour est terminé."
},
"sportCount": "{sport} : {count}",
"goal": {
+5 -2
View File
@@ -13640,7 +13640,8 @@
"below": "Sotto il tuo solito",
"usual": "Come al solito",
"above": "Sopra il tuo solito",
"high": "Molto sopra il tuo solito"
"high": "Molto sopra il tuo solito",
"in_progress": "Oggi, ancora in corso"
},
"legend": "Rispetto alle tue 12 settimane precedenti:",
"noBaseline": "Il tuo intervallo abituale compare dopo 14 giorni con dati.",
@@ -13650,7 +13651,9 @@
"workouts": "{value} allenamenti",
"load": "Carico di allenamento medio {value}",
"usual": "Il tuo solito fino a questo punto: {value}",
"noUsual": "Non ci sono ancora abbastanza settimane o mesi precedenti per un confronto."
"noUsual": "Non ci sono ancora abbastanza settimane o mesi precedenti per un confronto.",
"usualThroughYesterday": "Fino a ieri {value}, il tuo solito fino a quel punto {usual}",
"firstDay": "Il confronto con il tuo solito arriva quando il primo giorno è finito."
},
"sportCount": "{sport}: {count}",
"goal": {
+5 -2
View File
@@ -13640,7 +13640,8 @@
"below": "평소보다 적음",
"usual": "평소와 비슷함",
"above": "평소보다 많음",
"high": "평소보다 훨씬 많음"
"high": "평소보다 훨씬 많음",
"in_progress": "오늘, 아직 진행 중"
},
"legend": "이전 12주와 비교:",
"noBaseline": "데이터가 있는 날이 14일이 되면 평소 범위가 표시됩니다.",
@@ -13650,7 +13651,9 @@
"workouts": "운동 {value}회",
"load": "평균 운동 부하 {value}",
"usual": "이 시점까지의 평소 값: {value}",
"noUsual": "비교할 이전 주나 달이 아직 충분하지 않습니다."
"noUsual": "비교할 이전 주나 달이 아직 충분하지 않습니다.",
"usualThroughYesterday": "어제까지 {value}, 그때까지의 평소 {usual}",
"firstDay": "첫날이 끝나면 평소와 비교합니다."
},
"sportCount": "{sport}: {count}",
"goal": {
+5 -2
View File
@@ -13640,7 +13640,8 @@
"below": "Onder je gewone niveau",
"usual": "Ongeveer zoals gewoonlijk",
"above": "Boven je gewone niveau",
"high": "Ver boven je gewone niveau"
"high": "Ver boven je gewone niveau",
"in_progress": "Vandaag, nog bezig"
},
"legend": "Vergeleken met je 12 weken daarvoor:",
"noBaseline": "Je gebruikelijke bereik verschijnt na 14 dagen met gegevens.",
@@ -13650,7 +13651,9 @@
"workouts": "{value} trainingen",
"load": "Gemiddelde trainingsbelasting {value}",
"usual": "Gebruikelijk tot dit punt: {value}",
"noUsual": "Nog niet genoeg eerdere weken of maanden om mee te vergelijken."
"noUsual": "Nog niet genoeg eerdere weken of maanden om mee te vergelijken.",
"usualThroughYesterday": "Tot en met gisteren {value}, je gebruikelijke tot dan {usual}",
"firstDay": "Vergeleken met je gebruikelijke zodra de eerste dag voorbij is."
},
"sportCount": "{sport}: {count}",
"goal": {
+5 -2
View File
@@ -13640,7 +13640,8 @@
"below": "Poniżej twojej normy",
"usual": "Mniej więcej jak zwykle",
"above": "Powyżej twojej normy",
"high": "Znacznie powyżej twojej normy"
"high": "Znacznie powyżej twojej normy",
"in_progress": "Dziś, jeszcze trwa"
},
"legend": "W porównaniu z twoimi 12 wcześniejszymi tygodniami:",
"noBaseline": "Twój zwykły zakres pojawi się po 14 dniach z danymi.",
@@ -13650,7 +13651,9 @@
"workouts": "Treningi: {value}",
"load": "Średnie obciążenie treningowe {value}",
"usual": "Twoja norma do tego momentu: {value}",
"noUsual": "Za mało wcześniejszych tygodni lub miesięcy do porównania."
"noUsual": "Za mało wcześniejszych tygodni lub miesięcy do porównania.",
"usualThroughYesterday": "Do wczoraj {value}, twoja zwykła wartość do tego dnia {usual}",
"firstDay": "Porównanie ze zwykłą wartością pojawi się po zakończeniu pierwszego dnia."
},
"sportCount": "{sport}: {count}",
"goal": {
@@ -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 },
+46 -16
View File
@@ -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),
},
};
},
+22 -3
View File
@@ -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(),
})