mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
A week or month of steps, active minutes, workouts by sport and training load, each day placed against the person's own spread over the 84 days before the period and each period against the median of the twelve weeks or six months before it. Optional weekly and monthly goals live on the account (activity_goals_json), travel with the backup and are cleared by a data wipe. The calendar sits on the workouts page; the steps page links to it. Computed server-side without AI.
290 lines
9.0 KiB
TypeScript
290 lines
9.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
|
||
import { shiftDateKey } from "@/lib/tz/format";
|
||
import { NO_ACTIVITY_GOALS } from "@/lib/validations/activity-goals";
|
||
|
||
import {
|
||
BASELINE_DAYS,
|
||
MIN_BASELINE_DAYS,
|
||
activeMinutesOf,
|
||
baselineStats,
|
||
buildActivityCalendar,
|
||
comparePeriod,
|
||
dayLevel,
|
||
daysInMonth,
|
||
goalProgress,
|
||
periodBounds,
|
||
quantileSorted,
|
||
shiftMonth,
|
||
weekStartOf,
|
||
type ActivityDayInput,
|
||
} from "../calendar-math";
|
||
|
||
function day(
|
||
date: string,
|
||
over: Partial<ActivityDayInput> = {},
|
||
): ActivityDayInput {
|
||
return {
|
||
date,
|
||
steps: null,
|
||
exerciseMinutes: null,
|
||
workoutMinutes: 0,
|
||
workoutCount: 0,
|
||
workoutsBySport: {},
|
||
load: null,
|
||
...over,
|
||
};
|
||
}
|
||
|
||
describe("quantiles", () => {
|
||
it("interpolates linearly between ranks (PERCENTILE.INC)", () => {
|
||
const s = [1, 2, 3, 4, 5];
|
||
expect(quantileSorted(s, 0)).toBe(1);
|
||
expect(quantileSorted(s, 0.5)).toBe(3);
|
||
expect(quantileSorted(s, 0.25)).toBe(2);
|
||
expect(quantileSorted([10, 20], 0.5)).toBe(15);
|
||
expect(quantileSorted([10, 20, 30, 40], 0.9)).toBeCloseTo(37);
|
||
});
|
||
|
||
it("refuses a baseline below the day floor", () => {
|
||
expect(baselineStats(Array(MIN_BASELINE_DAYS - 1).fill(5000))).toBeNull();
|
||
const stats = baselineStats(
|
||
Array.from({ length: 20 }, (_, i) => (i + 1) * 1000),
|
||
);
|
||
expect(stats?.days).toBe(20);
|
||
expect(stats?.median).toBe(10500);
|
||
});
|
||
});
|
||
|
||
describe("day level against the person's own spread", () => {
|
||
const stats = baselineStats(Array.from({ length: 100 }, (_, i) => i))!;
|
||
it.each([
|
||
[null, "none"],
|
||
[5, "low"],
|
||
[20, "below"],
|
||
[50, "usual"],
|
||
[80, "above"],
|
||
[95, "high"],
|
||
] as const)("%s → %s", (value, level) => {
|
||
expect(dayLevel(value, stats)).toBe(level);
|
||
});
|
||
|
||
it("is unrated without a baseline, not guessed", () => {
|
||
expect(dayLevel(9000, null)).toBe("unrated");
|
||
});
|
||
});
|
||
|
||
describe("active minutes never double-count a workout", () => {
|
||
it("takes the larger of exercise and workout minutes", () => {
|
||
expect(
|
||
activeMinutesOf(
|
||
day("2026-05-01", { exerciseMinutes: 40, workoutMinutes: 30 }),
|
||
true,
|
||
),
|
||
).toBe(40);
|
||
expect(
|
||
activeMinutesOf(
|
||
day("2026-05-01", { exerciseMinutes: 10, workoutMinutes: 45 }),
|
||
true,
|
||
),
|
||
).toBe(45);
|
||
});
|
||
it("is 0 on a quiet day once the account has activity data, otherwise unknown", () => {
|
||
expect(activeMinutesOf(day("2026-05-01"), true)).toBe(0);
|
||
expect(activeMinutesOf(day("2026-05-01"), false)).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("period bounds", () => {
|
||
it("weeks run Monday to Sunday", () => {
|
||
// 2026-10-21 is a Wednesday.
|
||
expect(weekStartOf("2026-10-21")).toBe("2026-10-19");
|
||
expect(weekStartOf("2026-10-19")).toBe("2026-10-19");
|
||
expect(weekStartOf("2026-10-25")).toBe("2026-10-19");
|
||
const b = periodBounds("week", "2026-10-21");
|
||
expect([b.start, b.end]).toEqual(["2026-10-19", "2026-10-25"]);
|
||
expect(b.readStart).toBe(shiftDateKey("2026-10-19", -BASELINE_DAYS));
|
||
});
|
||
|
||
it("a month grid covers whole weeks and reads six months back", () => {
|
||
const b = periodBounds("month", "2026-02-14");
|
||
expect([b.start, b.end]).toEqual(["2026-02-01", "2026-02-28"]);
|
||
expect(b.gridStart).toBe("2026-01-26");
|
||
expect(b.gridEnd).toBe("2026-03-01");
|
||
expect(b.readStart).toBe("2025-08-01");
|
||
});
|
||
|
||
it("month arithmetic handles length and year turns", () => {
|
||
expect(daysInMonth("2024-02-01")).toBe(29);
|
||
expect(daysInMonth("2026-02-01")).toBe(28);
|
||
expect(shiftMonth("2026-01-01", -1)).toBe("2025-12-01");
|
||
expect(shiftMonth("2026-12-01", 1)).toBe("2027-01-01");
|
||
});
|
||
});
|
||
|
||
describe("period against the usual period", () => {
|
||
it("compares a finished week with the median of the twelve before", () => {
|
||
const values = new Map<string, number>();
|
||
// Twelve earlier weeks at 7 000 a day, this week at 10 000 a day.
|
||
const bounds = periodBounds("week", "2026-10-05");
|
||
for (let i = 1; i <= 84; i++)
|
||
values.set(shiftDateKey(bounds.start, -i), 7000);
|
||
for (let i = 0; i < 7; i++)
|
||
values.set(shiftDateKey(bounds.start, i), 10000);
|
||
const result = comparePeriod({
|
||
bounds,
|
||
today: "2026-10-21",
|
||
valueOf: (d) => values.get(d) ?? null,
|
||
mode: "sum",
|
||
});
|
||
expect(result.value).toBe(70000);
|
||
expect(result.usual).toBe(49000);
|
||
expect(result.usualToDate).toBe(49000);
|
||
expect(result.referencePeriods).toBe(12);
|
||
});
|
||
|
||
it("pro-rates the usual for a period still running", () => {
|
||
const bounds = periodBounds("week", "2026-10-21");
|
||
const values = new Map<string, number>();
|
||
for (let i = 1; i <= 84; i++)
|
||
values.set(shiftDateKey(bounds.start, -i), 1000);
|
||
const result = comparePeriod({
|
||
bounds,
|
||
today: "2026-10-21", // Wednesday: three days elapsed
|
||
valueOf: (d) =>
|
||
values.get(d) ?? (d <= "2026-10-21" && d >= bounds.start ? 2000 : null),
|
||
mode: "sum",
|
||
});
|
||
expect(result.value).toBe(6000);
|
||
expect(result.usual).toBe(7000);
|
||
expect(result.usualToDate).toBe(3000);
|
||
});
|
||
|
||
it("compares months per day, so February meets March fairly", () => {
|
||
const bounds = periodBounds("month", "2026-02-10");
|
||
const result = comparePeriod({
|
||
bounds,
|
||
today: "2026-10-21",
|
||
valueOf: (d) => (d < "2026-03-01" ? 100 : null),
|
||
mode: "sum",
|
||
});
|
||
expect(result.value).toBe(2800);
|
||
// Six earlier months at 100 a day → 28 days × 100.
|
||
expect(result.usual).toBe(2800);
|
||
});
|
||
|
||
it("gives no usual with fewer than three earlier periods of data", () => {
|
||
const bounds = periodBounds("week", "2026-10-05");
|
||
const result = comparePeriod({
|
||
bounds,
|
||
today: "2026-10-21",
|
||
valueOf: (d) => (d >= shiftDateKey(bounds.start, -14) ? 500 : null),
|
||
mode: "sum",
|
||
});
|
||
expect(result.referencePeriods).toBe(2);
|
||
expect(result.usual).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("goals", () => {
|
||
it("reports only goals that are set, for the period they belong to", () => {
|
||
const goals = {
|
||
...NO_ACTIVITY_GOALS,
|
||
workoutsPerWeek: 3,
|
||
stepsPerMonth: 200000,
|
||
};
|
||
expect(goalProgress("week", goals, { workouts: 4 })).toEqual([
|
||
{ key: "workoutsPerWeek", target: 3, value: 4, met: true },
|
||
]);
|
||
expect(goalProgress("month", goals, { steps: 150000 })).toEqual([
|
||
{ key: "stepsPerMonth", target: 200000, value: 150000, met: false },
|
||
]);
|
||
expect(goalProgress("week", NO_ACTIVITY_GOALS, { workouts: 9 })).toEqual(
|
||
[],
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("the assembled calendar", () => {
|
||
const today = "2026-10-21";
|
||
const bounds = periodBounds("month", today);
|
||
// 84 baseline days of 5 000–9 000 steps, a run every Monday.
|
||
const input: ActivityDayInput[] = [];
|
||
for (let i = 1; i <= 200; i++) {
|
||
const date = shiftDateKey(bounds.start, -i);
|
||
input.push(
|
||
day(date, {
|
||
steps: 5000 + (i % 5) * 1000,
|
||
workoutCount: i % 7 === 0 ? 1 : 0,
|
||
workoutMinutes: i % 7 === 0 ? 30 : 0,
|
||
workoutsBySport: i % 7 === 0 ? { running: 1 } : {},
|
||
}),
|
||
);
|
||
}
|
||
input.push(
|
||
day("2026-10-02", {
|
||
steps: 15000,
|
||
workoutCount: 2,
|
||
workoutMinutes: 90,
|
||
workoutsBySport: { running: 1, cycling: 1 },
|
||
}),
|
||
);
|
||
input.push(day("2026-10-03", { steps: 2000 }));
|
||
const calendar = buildActivityCalendar({
|
||
bounds,
|
||
today,
|
||
input,
|
||
goals: { ...NO_ACTIVITY_GOALS, workoutsPerWeek: 2 },
|
||
});
|
||
|
||
it("draws whole weeks and marks the days after today as future", () => {
|
||
expect(calendar.days[0].date).toBe(calendar.gridStart);
|
||
expect(calendar.days.length % 7).toBe(0);
|
||
const future = calendar.days.find((d) => d.date === "2026-10-22");
|
||
expect(future?.future).toBe(true);
|
||
expect(future?.level.steps).toBe("none");
|
||
});
|
||
|
||
it("rates a big day high and a short one low against the person's own spread", () => {
|
||
expect(calendar.baseline.steps?.days).toBe(BASELINE_DAYS);
|
||
expect(
|
||
calendar.days.find((d) => d.date === "2026-10-02")?.level.steps,
|
||
).toBe("high");
|
||
expect(
|
||
calendar.days.find((d) => d.date === "2026-10-03")?.level.steps,
|
||
).toBe("low");
|
||
expect(
|
||
calendar.days.find((d) => d.date === "2026-10-04")?.level.steps,
|
||
).toBe("none");
|
||
});
|
||
|
||
it("counts workouts by sport in the period", () => {
|
||
expect(calendar.sports).toEqual([
|
||
{ sportType: "cycling", count: 1 },
|
||
{ sportType: "running", count: 1 },
|
||
]);
|
||
expect(calendar.summary.workouts.value).toBe(2);
|
||
});
|
||
|
||
it("marks weekly goals per calendar row, and none for a week not yet begun", () => {
|
||
const week = calendar.weeks.find((w) => w.start === "2026-09-28");
|
||
expect(week?.goals).toEqual([
|
||
{ key: "workoutsPerWeek", target: 2, value: 2, met: true },
|
||
]);
|
||
const last = calendar.weeks[calendar.weeks.length - 1];
|
||
expect(last.start > today).toBe(true);
|
||
expect(last.goals).toEqual([]);
|
||
expect(calendar.next).toBeNull();
|
||
expect(calendar.previous).toBe("2026-09-01");
|
||
});
|
||
|
||
it("offers only the signals the account has", () => {
|
||
expect(calendar.available).toEqual({
|
||
steps: true,
|
||
activeMinutes: true,
|
||
workouts: true,
|
||
load: false,
|
||
});
|
||
});
|
||
});
|