3c: partial consumption + undo for cooking sessions

- Append-only undo via correction transactions tied to cookingSessionId.
- POST /v1/cooking-sessions/:id/undo with 24h UTC window (409 + i18n after expiry).
- Complete undo scope: meals removed, meal boxes discarded, recipe_cooks
deleted, cookCount decremented; activation milestones left intact.
- Deterministic assumption-profile rollback via lastSessionAnswers.sessionId;
profile deleted when observationCount reaches 0.
- New status 'undone' as TEXT validated against COOKING_SESSION_STATUSES.
- Analytics event cooking_session_undone + domain events MEAL_BOX_DISCARDED,
MEAL_REMOVED, COOKING_SESSION_UNDONE.
- Partial consumption uses actualPortionsEaten/plannedPortions factor.
- i18n coverage for cooked.undoWindowExpired across all 12 locales + server.
- Invariant tests: computeBalance(transactions) === item.quantity after
complete, undo, and undo + new complete; transaction count increases on undo.
- Migration 0014_expand_event_types.sql adds new enum values.
- Updated FAS3-COOKING-SESSIONS-AUDIT.md with chosen semantics.
This commit is contained in:
Sven (AAMOS AI)
2026-08-07 05:47:54 +07:00
parent bbe7526ef3
commit cb493010d0
25 changed files with 969 additions and 30 deletions
@@ -55,6 +55,51 @@ export function updateCookingAssumptionProfile(
return { averageEatenPortions, averageLeftoverPortions, observationCount, lastSessionAnswers };
}
/**
* Återställ en antagandeprofil genom att ta bort ett specifikt sessions-id
* från historiken och räkna om medelvärdena deterministiskt från resterande
* observationer. Ren funktion alla beroenden är explicita argument.
*/
export function rollbackCookingAssumptionProfile(
sessionId: string,
existing: ExistingProfile,
): {
averageEatenPortions: number | null;
averageLeftoverPortions: number | null;
observationCount: number;
lastSessionAnswers: Array<{ sessionId: string; eaten: number; leftovers: number; date: string }>;
} {
const history = (existing.lastSessionAnswers ?? []).filter((a) => a.sessionId !== sessionId);
if (history.length === 0) {
return {
averageEatenPortions: null,
averageLeftoverPortions: null,
observationCount: 0,
lastSessionAnswers: [],
};
}
// Räkna om EMA från scratch i kronologisk ordning (äldst först).
const chronological = [...history].reverse();
let avgEaten = chronological[0]!.eaten;
let avgLeftovers = chronological[0]!.leftovers;
for (let i = 1; i < chronological.length; i++) {
const alpha = 1 / (i + 1);
const a = chronological[i]!;
avgEaten = round2(avgEaten + alpha * (a.eaten - avgEaten));
avgLeftovers = round2(avgLeftovers + alpha * (a.leftovers - avgLeftovers));
}
return {
averageEatenPortions: avgEaten,
averageLeftoverPortions: avgLeftovers,
observationCount: history.length,
lastSessionAnswers: history,
};
}
function clamp(n: number, min: number, max: number): number {
return Math.max(min, Math.min(max, n));
}