Implement ST-304 energy meter

This commit is contained in:
Janpeter Visser 2026-04-19 02:25:52 +02:00
parent 5c15620993
commit c3e936b0db
10 changed files with 360 additions and 12 deletions

View file

@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import {
calculatePlannedPointsTotal,
calculatePlanningMeterSnapshot,
deriveActivityEnergyPoints,
} from "./meter";
describe("deriveActivityEnergyPoints", () => {
it("geeft 1 punt voor een korte lage activiteit", () => {
expect(
deriveActivityEnergyPoints({ durationMinutes: 15, impactLevel: "laag", status: "planned" }),
).toBe(1);
});
it("geeft 2 punten voor een middellange middenactiviteit", () => {
expect(
deriveActivityEnergyPoints({
durationMinutes: 30,
impactLevel: "midden",
status: "planned",
}),
).toBe(2);
});
it("geeft 4 punten voor een langere hoge activiteit", () => {
expect(
deriveActivityEnergyPoints({ durationMinutes: 60, impactLevel: "hoog", status: "planned" }),
).toBe(4);
});
it("telt een geskipt item niet mee", () => {
expect(
deriveActivityEnergyPoints({ durationMinutes: 45, impactLevel: "hoog", status: "skipped" }),
).toBe(0);
});
});
describe("calculatePlanningMeterSnapshot", () => {
it("somt punten van activiteiten op", () => {
expect(
calculatePlannedPointsTotal([
{ durationMinutes: 30, impactLevel: "midden", status: "planned" },
{ durationMinutes: 90, impactLevel: "laag", status: "planned" },
]),
).toBe(4);
});
it("berekent resterend budget en percentage", () => {
const snapshot = calculatePlanningMeterSnapshot(
[
{ durationMinutes: 30, impactLevel: "midden", status: "planned" },
{ durationMinutes: 60, impactLevel: "hoog", status: "planned" },
],
8,
);
expect(snapshot.plannedPoints).toBe(6);
expect(snapshot.remainingBudget).toBe(2);
expect(snapshot.progressPercent).toBe(75);
expect(snapshot.isOverBudget).toBe(false);
});
it("werkt ook zonder dagbudget", () => {
const snapshot = calculatePlanningMeterSnapshot(
[{ durationMinutes: 45, impactLevel: "midden", status: "planned" }],
null,
);
expect(snapshot.plannedPoints).toBe(2);
expect(snapshot.dailyBudget).toBeNull();
expect(snapshot.remainingBudget).toBeNull();
expect(snapshot.progressPercent).toBeNull();
});
it("markeert overschrijding zonder alarmerende blokkade", () => {
const snapshot = calculatePlanningMeterSnapshot(
[
{ durationMinutes: 60, impactLevel: "hoog", status: "planned" },
{ durationMinutes: 120, impactLevel: "hoog", status: "planned" },
],
6,
);
expect(snapshot.plannedPoints).toBe(9);
expect(snapshot.remainingBudget).toBe(-3);
expect(snapshot.isOverBudget).toBe(true);
expect(snapshot.progressPercent).toBe(100);
});
});

101
lib/planning/meter.ts Normal file
View file

@ -0,0 +1,101 @@
import type {
ActivityImpactLevel,
ActivityRecord,
ActivityStatus,
} from "@/lib/planning/types";
export type PlanningMeterSnapshot = {
plannedPoints: number;
activityCount: number;
dailyBudget: number | null;
remainingBudget: number | null;
progressRatio: number | null;
progressPercent: number | null;
isOverBudget: boolean;
};
type ActivityMeterInput = {
durationMinutes: number;
impactLevel: ActivityImpactLevel;
status?: ActivityStatus;
};
function deriveDurationBandPoints(durationMinutes: number) {
if (durationMinutes <= 15) {
return 1;
}
if (durationMinutes <= 45) {
return 2;
}
if (durationMinutes <= 90) {
return 3;
}
return 4;
}
function deriveImpactAdjustment(impactLevel: ActivityImpactLevel) {
if (impactLevel === "laag") {
return -1;
}
if (impactLevel === "hoog") {
return 1;
}
return 0;
}
export function deriveActivityEnergyPoints(input: ActivityMeterInput): number {
if (input.status === "skipped") {
return 0;
}
return Math.max(
1,
deriveDurationBandPoints(input.durationMinutes) + deriveImpactAdjustment(input.impactLevel),
);
}
export function calculatePlannedPointsTotal(
activities: Pick<ActivityRecord, "durationMinutes" | "impactLevel" | "status">[],
): number {
return activities.reduce(
(total, activity) => total + deriveActivityEnergyPoints(activity),
0,
);
}
export function calculatePlanningMeterSnapshot(
activities: Pick<ActivityRecord, "durationMinutes" | "impactLevel" | "status">[],
dailyBudget: number | null,
): PlanningMeterSnapshot {
const plannedPoints = calculatePlannedPointsTotal(activities);
if (dailyBudget === null) {
return {
plannedPoints,
activityCount: activities.length,
dailyBudget: null,
remainingBudget: null,
progressRatio: null,
progressPercent: null,
isOverBudget: false,
};
}
const remainingBudget = dailyBudget - plannedPoints;
const progressRatio = dailyBudget > 0 ? plannedPoints / dailyBudget : 0;
return {
plannedPoints,
activityCount: activities.length,
dailyBudget,
remainingBudget,
progressRatio,
progressPercent: Math.min(100, Math.round(progressRatio * 100)),
isOverBudget: remainingBudget < 0,
};
}