mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(activity): add an activity calendar with personal baseline and goals
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.
This commit is contained in:
1 parent
e1b70537cc
commit
5eb7caebae
40 files changed
+4172
-6
No files matched your search
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* `/api/activity/calendar` and `/api/activity/goals` (D1036).
|
||||
*
|
||||
* The goals: a PATCH replaces only what it names, `null` clears, an unknown
|
||||
* key or a goal out of bounds is a 422 and writes nothing, and clearing the
|
||||
* last goal returns the column to NULL rather than storing an empty object.
|
||||
* The calendar: the module gate answers before any read, a future or malformed
|
||||
* day is refused, and the reader is asked for exactly the window the period
|
||||
* needs and never past today.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
readActivityDays: vi.fn(),
|
||||
requireModuleEnabled: vi.fn(),
|
||||
auditLog: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (handler: unknown) => handler,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "user-1" } })),
|
||||
requireRecordAuth: vi.fn(async () => ({
|
||||
user: { id: "user-1" },
|
||||
actor: { id: "user-1" },
|
||||
grantId: null,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: mocks.auditLog }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: mocks.findUnique, update: mocks.update } },
|
||||
toJson: (v: unknown) => v,
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: mocks.requireModuleEnabled,
|
||||
}));
|
||||
vi.mock("@/lib/activity/calendar-read", () => ({
|
||||
readActivityDays: mocks.readActivityDays,
|
||||
}));
|
||||
vi.mock("@/lib/tz/resolver", () => ({
|
||||
resolveUserTimezone: vi.fn(async () => "Europe/Berlin"),
|
||||
}));
|
||||
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { GET as calendarGet } from "../calendar/route";
|
||||
import { GET as goalsGet, PATCH as goalsPatch } from "../goals/route";
|
||||
|
||||
type Handler = (req: NextRequest) => Promise<Response>;
|
||||
|
||||
function patch(body: unknown) {
|
||||
return (goalsPatch as unknown as Handler)(
|
||||
new NextRequest("http://localhost/api/activity/goals", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function calendar(query: string) {
|
||||
return (calendarGet as unknown as Handler)(
|
||||
new NextRequest(`http://localhost/api/activity/calendar${query}`),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.findUnique.mockResolvedValue({
|
||||
activityGoalsJson: { workoutsPerWeek: 3, stepsPerWeek: 50000 },
|
||||
});
|
||||
mocks.update.mockResolvedValue({});
|
||||
mocks.requireModuleEnabled.mockResolvedValue({ enabled: true });
|
||||
mocks.readActivityDays.mockResolvedValue({ days: [], loadType: null });
|
||||
});
|
||||
|
||||
describe("activity goals", () => {
|
||||
it("reads every goal, unset ones as null", async () => {
|
||||
const res = await (goalsGet as unknown as () => Promise<Response>)();
|
||||
const body = await res.json();
|
||||
expect(body.data).toEqual({
|
||||
workoutsPerWeek: 3,
|
||||
activeMinutesPerWeek: null,
|
||||
stepsPerWeek: 50000,
|
||||
stepsPerMonth: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("changes what the body names and keeps the rest", async () => {
|
||||
const res = await patch({ stepsPerWeek: null, stepsPerMonth: 250000 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(mocks.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: {
|
||||
activityGoalsJson: {
|
||||
workoutsPerWeek: 3,
|
||||
activeMinutesPerWeek: null,
|
||||
stepsPerWeek: null,
|
||||
stepsPerMonth: 250000,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mocks.auditLog).toHaveBeenCalledWith(
|
||||
"activity.goals.update",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("stores NULL once the last goal is cleared", async () => {
|
||||
await patch({ workoutsPerWeek: null, stepsPerWeek: null });
|
||||
expect(mocks.update.mock.calls[0][0].data.activityGoalsJson).toBe(
|
||||
Prisma.DbNull,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ caloriesPerWeek: 3000 }],
|
||||
[{ workoutsPerWeek: 0 }],
|
||||
[{ stepsPerWeek: 2.5 }],
|
||||
[{ stepsPerMonth: "200000" }],
|
||||
])("refuses %j without writing", async (body) => {
|
||||
const res = await patch(body);
|
||||
expect(res.status).toBe(422);
|
||||
expect(mocks.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("activity calendar", () => {
|
||||
it("answers the module gate before reading anything", async () => {
|
||||
mocks.requireModuleEnabled.mockResolvedValue({
|
||||
enabled: false,
|
||||
response: new Response(null, { status: 403 }),
|
||||
});
|
||||
const res = await calendar("?period=month");
|
||||
expect(res.status).toBe(403);
|
||||
expect(mocks.readActivityDays).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["?period=year", "?date=2026-13-01", "?date=yesterday"])(
|
||||
"refuses %s",
|
||||
async (q) => {
|
||||
const res = await calendar(q);
|
||||
expect(res.status).toBe(422);
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses a day after today", async () => {
|
||||
const res = await calendar("?date=2999-01-01");
|
||||
expect(res.status).toBe(422);
|
||||
expect(mocks.readActivityDays).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads the baseline window before the period and nothing past the grid", async () => {
|
||||
const res = await calendar("?period=week&date=2025-03-12");
|
||||
expect(res.status).toBe(200);
|
||||
const args = mocks.readActivityDays.mock.calls[0][0];
|
||||
// Week of Monday 2025-03-10; 84 days before it.
|
||||
expect(args.fromKey).toBe("2024-12-16");
|
||||
expect(args.toKey).toBe("2025-03-16");
|
||||
expect(args.tz).toBe("Europe/Berlin");
|
||||
const body = await res.json();
|
||||
expect(body.data.start).toBe("2025-03-10");
|
||||
expect(body.data.goals.workoutsPerWeek).toBe(3);
|
||||
expect(body.data.days).toHaveLength(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* `GET /api/activity/calendar` — a week or a month of activity against the
|
||||
* person's own normal, with their weekly and monthly goals (D1036).
|
||||
*
|
||||
* Computed server-side, without AI, from the stored readings: steps (the
|
||||
* canonical-source day totals the steps chart draws), exercise minutes,
|
||||
* workouts after the cross-source collapse, and a training-load score when a
|
||||
* device or HealthLog provides one. What "usual" means is in
|
||||
* `src/lib/activity/calendar-math.ts`; it is always the person's own history,
|
||||
* never a population norm.
|
||||
*
|
||||
* Owned by the Workouts module, like the page that shows it: a switched-off
|
||||
* module answers 403 here as `/api/workouts` does.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import {
|
||||
buildActivityCalendar,
|
||||
periodBounds,
|
||||
} from "@/lib/activity/calendar-math";
|
||||
import { readActivityDays } from "@/lib/activity/calendar-read";
|
||||
import { daysBetweenDateKeys, userDayKey } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { activityCalendarQuerySchema } from "@/lib/validations/activity-calendar";
|
||||
import { parseActivityGoals } from "@/lib/validations/activity-goals";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** How far back the calendar pages: ten years, the series route's ceiling. */
|
||||
const MAX_DAYS_BACK = 3650 - 200;
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
annotate({ action: { name: "activity.calendar.read" } });
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "workouts");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const parsed = activityCalendarQuerySchema.safeParse({
|
||||
period: params.get("period") ?? undefined,
|
||||
date: params.get("date") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
const tz = await resolveUserTimezone(user.id);
|
||||
const now = new Date();
|
||||
const today = userDayKey(now, tz);
|
||||
const anchor = parsed.data.date ?? today;
|
||||
if (anchor > today) {
|
||||
return apiError("The calendar cannot show a day after today", 422, {
|
||||
errorCode: "activity.calendar.future_date",
|
||||
});
|
||||
}
|
||||
if (daysBetweenDateKeys(anchor, today) > MAX_DAYS_BACK) {
|
||||
return apiError("The calendar reaches back ten years", 422, {
|
||||
errorCode: "activity.calendar.too_old",
|
||||
});
|
||||
}
|
||||
|
||||
const bounds = periodBounds(parsed.data.period, anchor);
|
||||
const lastRead = bounds.gridEnd < today ? bounds.gridEnd : today;
|
||||
const [{ days, loadType }, goalRow] = await Promise.all([
|
||||
readActivityDays({
|
||||
userId: user.id,
|
||||
tz,
|
||||
fromKey: bounds.readStart,
|
||||
toKey: lastRead,
|
||||
now,
|
||||
includeWorkouts: true,
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { activityGoalsJson: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const calendar = buildActivityCalendar({
|
||||
bounds,
|
||||
today,
|
||||
input: days,
|
||||
goals: parseActivityGoals(goalRow?.activityGoalsJson ?? null),
|
||||
});
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
activity_calendar_period: bounds.period,
|
||||
activity_calendar_days_read: days.length,
|
||||
activity_calendar_load_type: loadType ?? "none",
|
||||
},
|
||||
});
|
||||
return apiSuccess({ ...calendar, timezone: tz, loadType });
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* GET / PATCH the caller's activity goals (D1036).
|
||||
*
|
||||
* Every goal is optional and set by the person; there is no default goal.
|
||||
* PATCH replaces the goals the body names (`null` clears one) and keeps the
|
||||
* rest; an unknown key or an out-of-bounds goal is refused with the
|
||||
* multi-issue 422. The goals live on their own `User` column
|
||||
* (`activity_goals_json`) and the activity calendar reads them.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
ACTIVITY_GOAL_KEYS,
|
||||
activityGoalsSchema,
|
||||
hasNoActivityGoals,
|
||||
mergeActivityGoals,
|
||||
parseActivityGoals,
|
||||
type ActivityGoals,
|
||||
} from "@/lib/validations/activity-goals";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function readGoals(userId: string): Promise<ActivityGoals> {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { activityGoalsJson: true },
|
||||
});
|
||||
return parseActivityGoals(row?.activityGoalsJson ?? null);
|
||||
}
|
||||
|
||||
function setKeys(goals: ActivityGoals): string[] {
|
||||
return ACTIVITY_GOAL_KEYS.filter((key) => goals[key] !== null);
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
// Owner-only, like the write: a delegate sees the goals where they matter,
|
||||
// inside the calendar, which carries them.
|
||||
const { user } = await requireAuth();
|
||||
const goals = await readGoals(user.id);
|
||||
annotate({
|
||||
action: { name: "activity.goals.read" },
|
||||
meta: { set: setKeys(goals) },
|
||||
});
|
||||
return apiSuccess(goals);
|
||||
});
|
||||
|
||||
export const PATCH = apiHandler(async (request: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = activityGoalsSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
annotate({ action: { name: "activity.goals.validation-failed" } });
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const next = mergeActivityGoals(await readGoals(user.id), parsed.data);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
activityGoalsJson: hasNoActivityGoals(next)
|
||||
? Prisma.DbNull
|
||||
: toJson(next),
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("activity.goals.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { set: setKeys(next) },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "activity.goals.update" },
|
||||
meta: { set: setKeys(next) },
|
||||
});
|
||||
return apiSuccess(next);
|
||||
});
|
||||
Reference in new issue
Block a user