mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +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.
173 lines
4.7 KiB
TypeScript
173 lines
4.7 KiB
TypeScript
/**
|
|
* The activity calendar's reader against a real Postgres (D1036).
|
|
*
|
|
* - steps resolve to the source the person's ladder picks per day, so a day
|
|
* two devices counted shows one device's total, never the sum;
|
|
* - the day is the person's local day: a reading at 23:30 Berlin time lands
|
|
* on that day, not on the UTC day after;
|
|
* - a workout two devices recorded counts once, its minutes once;
|
|
* - the window is exact: nothing before `fromKey` or after `toKey`.
|
|
*/
|
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
|
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
import { readActivityDays } from "@/lib/activity/calendar-read";
|
|
import { invalidateUserTimezone } from "@/lib/tz/resolver";
|
|
import { userDayKey } from "@/lib/tz/format";
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
const prisma = getPrismaClient();
|
|
const TZ = "Europe/Berlin";
|
|
const USER = "activity-reader";
|
|
const DAY = 86_400_000;
|
|
const NOW = new Date();
|
|
|
|
function daysAgo(n: number, hourUtc = 10): Date {
|
|
const d = new Date(NOW.getTime() - n * DAY);
|
|
d.setUTCHours(hourUtc, 0, 0, 0);
|
|
return d;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
await truncateAllTables(prisma);
|
|
await prisma.user.create({
|
|
data: {
|
|
id: USER,
|
|
username: USER,
|
|
timezone: TZ,
|
|
sourcePriorityJson: {
|
|
metricPriority: { steps: ["FITBIT", "APPLE_HEALTH"] },
|
|
},
|
|
},
|
|
});
|
|
invalidateUserTimezone(USER);
|
|
|
|
await prisma.measurement.createMany({
|
|
data: [
|
|
// Day 3: both devices; the ladder picks Fitbit's 8 000.
|
|
{
|
|
userId: USER,
|
|
type: "ACTIVITY_STEPS",
|
|
value: 9500,
|
|
unit: "steps",
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: daysAgo(3),
|
|
},
|
|
{
|
|
userId: USER,
|
|
type: "ACTIVITY_STEPS",
|
|
value: 8000,
|
|
unit: "steps",
|
|
source: "FITBIT",
|
|
measuredAt: daysAgo(3),
|
|
},
|
|
// Day 2: Apple only, two slices that add up.
|
|
{
|
|
userId: USER,
|
|
type: "ACTIVITY_STEPS",
|
|
value: 3000,
|
|
unit: "steps",
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: daysAgo(2, 8),
|
|
},
|
|
{
|
|
userId: USER,
|
|
type: "ACTIVITY_STEPS",
|
|
value: 4000,
|
|
unit: "steps",
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: daysAgo(2, 15),
|
|
},
|
|
// 21:30 UTC is 23:30 in Berlin (summer) or 22:30 (winter): still day 5 locally.
|
|
{
|
|
userId: USER,
|
|
type: "ACTIVITY_STEPS",
|
|
value: 1200,
|
|
unit: "steps",
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: daysAgo(5, 21),
|
|
},
|
|
{
|
|
userId: USER,
|
|
type: "EXERCISE_MINUTES",
|
|
value: 25,
|
|
unit: "min",
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: daysAgo(3, 9),
|
|
},
|
|
{
|
|
userId: USER,
|
|
type: "EXERCISE_MINUTES",
|
|
value: 15,
|
|
unit: "min",
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: daysAgo(3, 17),
|
|
},
|
|
// Outside the window asked for below.
|
|
{
|
|
userId: USER,
|
|
type: "ACTIVITY_STEPS",
|
|
value: 777,
|
|
unit: "steps",
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: daysAgo(40),
|
|
},
|
|
],
|
|
});
|
|
|
|
const start = daysAgo(3, 7);
|
|
await prisma.workout.createMany({
|
|
data: [
|
|
{
|
|
userId: USER,
|
|
sportType: "running",
|
|
startedAt: start,
|
|
endedAt: new Date(start.getTime() + 40 * 60_000),
|
|
durationSec: 2400,
|
|
source: "APPLE_HEALTH",
|
|
externalId: "hk-1",
|
|
},
|
|
{
|
|
userId: USER,
|
|
sportType: "running",
|
|
startedAt: new Date(start.getTime() + 30_000),
|
|
endedAt: new Date(start.getTime() + 40 * 60_000),
|
|
durationSec: 2370,
|
|
source: "FITBIT",
|
|
externalId: "fb-1",
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
describe("readActivityDays", () => {
|
|
it("follows the ladder, the local day and the workout collapse", async () => {
|
|
const fromKey = userDayKey(daysAgo(10), TZ);
|
|
const toKey = userDayKey(NOW, TZ);
|
|
const { days } = await readActivityDays({
|
|
userId: USER,
|
|
tz: TZ,
|
|
fromKey,
|
|
toKey,
|
|
now: NOW,
|
|
includeWorkouts: true,
|
|
});
|
|
const by = new Map(days.map((d) => [d.date, d]));
|
|
|
|
const d3 = by.get(userDayKey(daysAgo(3), TZ));
|
|
expect(d3?.steps).toBe(8000);
|
|
expect(d3?.exerciseMinutes).toBe(40);
|
|
expect(d3?.workoutCount).toBe(1);
|
|
expect(d3?.workoutsBySport).toEqual({ running: 1 });
|
|
expect(d3?.workoutMinutes).toBe(40);
|
|
|
|
expect(by.get(userDayKey(daysAgo(2), TZ))?.steps).toBe(7000);
|
|
expect(by.get(userDayKey(daysAgo(5, 21), TZ))?.steps).toBe(1200);
|
|
expect(days.every((d) => d.date >= fromKey && d.date <= toKey)).toBe(true);
|
|
expect(days.some((d) => d.steps === 777)).toBe(false);
|
|
});
|
|
});
|