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:
@@ -57,6 +57,7 @@ export const recipeSaved = builder("recipe_saved");
|
||||
export const cookingSessionStarted = builder("cooking_session_started");
|
||||
export const cookingSessionCompleted = builder("cooking_session_completed");
|
||||
export const cookingSessionCancelled = builder("cooking_session_cancelled");
|
||||
export const cookingSessionUndone = builder("cooking_session_undone");
|
||||
export const leftoversCreated = builder("leftovers_created");
|
||||
export const substitutionConfirmed = builder("substitution_confirmed");
|
||||
|
||||
|
||||
@@ -42,6 +42,13 @@ export interface EventPayloadMap {
|
||||
WEEK_PLAN_UPDATED: { weekPlanId: string; reason?: string | null };
|
||||
MEAL_BOX_CREATED: { mealBoxId: string; portions: number };
|
||||
MEAL_BOX_CONSUMED: { mealBoxId: string; portions: number };
|
||||
MEAL_BOX_DISCARDED: { mealBoxId: string; portions: number; source: string };
|
||||
MEAL_REMOVED: { mealId: string; recipeId: string; source: string };
|
||||
COOKING_SESSION_UNDONE: {
|
||||
cookingSessionId: string;
|
||||
recipeId: string;
|
||||
reversedTransactions: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Kontroll i kompileringstid att kartan täcker alla EventType. */
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { updateCookingAssumptionProfile } from "../src/cooking-profiles.js";
|
||||
import {
|
||||
updateCookingAssumptionProfile,
|
||||
rollbackCookingAssumptionProfile,
|
||||
} from "../src/cooking-profiles.js";
|
||||
|
||||
describe("updateCookingAssumptionProfile", () => {
|
||||
it("initializes profile from first observation", () => {
|
||||
@@ -61,3 +64,104 @@ describe("updateCookingAssumptionProfile", () => {
|
||||
expect(existing.lastSessionAnswers![0]!.sessionId).toBe("s11");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollbackCookingAssumptionProfile", () => {
|
||||
it("returns null averages and zero count when history becomes empty", () => {
|
||||
const existing = updateCookingAssumptionProfile(
|
||||
{
|
||||
householdId: "h1",
|
||||
canonicalIngredientId: "i1",
|
||||
plannedPortions: 4,
|
||||
actualPortionsEaten: 3,
|
||||
leftoverEstimatePortions: 1,
|
||||
sessionId: "s1",
|
||||
date: "2026-08-07",
|
||||
},
|
||||
{ averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 },
|
||||
);
|
||||
const rolled = rollbackCookingAssumptionProfile("s1", existing);
|
||||
expect(rolled.averageEatenPortions).toBeNull();
|
||||
expect(rolled.averageLeftoverPortions).toBeNull();
|
||||
expect(rolled.observationCount).toBe(0);
|
||||
expect(rolled.lastSessionAnswers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("recalculates averages deterministically from remaining history", () => {
|
||||
let existing: {
|
||||
averageEatenPortions: number | null;
|
||||
averageLeftoverPortions: number | null;
|
||||
observationCount: number;
|
||||
lastSessionAnswers?: Array<{ sessionId: string; eaten: number; leftovers: number; date: string }> | null;
|
||||
} = { averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 };
|
||||
|
||||
// Three chronological observations.
|
||||
const observations = [
|
||||
{ sessionId: "s1", eaten: 4, leftovers: 0 },
|
||||
{ sessionId: "s2", eaten: 2, leftovers: 1 },
|
||||
{ sessionId: "s3", eaten: 3, leftovers: 1 },
|
||||
];
|
||||
for (const obs of observations) {
|
||||
existing = updateCookingAssumptionProfile(
|
||||
{
|
||||
householdId: "h1",
|
||||
canonicalIngredientId: "i1",
|
||||
plannedPortions: 4,
|
||||
actualPortionsEaten: obs.eaten,
|
||||
leftoverEstimatePortions: obs.leftovers,
|
||||
sessionId: obs.sessionId,
|
||||
date: "2026-08-07",
|
||||
},
|
||||
existing,
|
||||
);
|
||||
}
|
||||
|
||||
// Roll back the middle observation and recompute from scratch.
|
||||
const rolled = rollbackCookingAssumptionProfile("s2", existing);
|
||||
expect(rolled.lastSessionAnswers).toHaveLength(2);
|
||||
expect(rolled.lastSessionAnswers!.map((a) => a.sessionId)).toContain("s1");
|
||||
expect(rolled.lastSessionAnswers!.map((a) => a.sessionId)).toContain("s3");
|
||||
|
||||
// Recompute EMA manually from s1 then s3.
|
||||
// s1: avgEaten=4, avgLeftovers=0
|
||||
// s3: alpha=1/2 => eaten=4+0.5*(3-4)=3.5, leftovers=0+0.5*(1-0)=0.5
|
||||
expect(rolled.averageEatenPortions).toBe(3.5);
|
||||
expect(rolled.averageLeftoverPortions).toBe(0.5);
|
||||
expect(rolled.observationCount).toBe(2);
|
||||
});
|
||||
|
||||
it("is idempotent: rolling back the same session twice leaves history unchanged", () => {
|
||||
let existing: {
|
||||
averageEatenPortions: number | null;
|
||||
averageLeftoverPortions: number | null;
|
||||
observationCount: number;
|
||||
lastSessionAnswers?: Array<{ sessionId: string; eaten: number; leftovers: number; date: string }> | null;
|
||||
} = { averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 };
|
||||
existing = updateCookingAssumptionProfile(
|
||||
{
|
||||
householdId: "h1",
|
||||
canonicalIngredientId: "i1",
|
||||
plannedPortions: 4,
|
||||
actualPortionsEaten: 3,
|
||||
leftoverEstimatePortions: 1,
|
||||
sessionId: "s1",
|
||||
date: "2026-08-07",
|
||||
},
|
||||
existing,
|
||||
);
|
||||
existing = updateCookingAssumptionProfile(
|
||||
{
|
||||
householdId: "h1",
|
||||
canonicalIngredientId: "i1",
|
||||
plannedPortions: 4,
|
||||
actualPortionsEaten: 2,
|
||||
leftoverEstimatePortions: 0,
|
||||
sessionId: "s2",
|
||||
date: "2026-08-07",
|
||||
},
|
||||
existing,
|
||||
);
|
||||
const first = rollbackCookingAssumptionProfile("s1", existing);
|
||||
const second = rollbackCookingAssumptionProfile("s1", first);
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,6 +53,7 @@ export const ANALYTICS_COOKING_EVENT_NAMES = [
|
||||
"cooking_session_started",
|
||||
"cooking_session_completed",
|
||||
"cooking_session_cancelled",
|
||||
"cooking_session_undone",
|
||||
"leftovers_created",
|
||||
"substitution_confirmed",
|
||||
] as const;
|
||||
|
||||
@@ -413,6 +413,9 @@ export const EVENT_TYPES = [
|
||||
"WEEK_PLAN_UPDATED",
|
||||
"MEAL_BOX_CREATED",
|
||||
"MEAL_BOX_CONSUMED",
|
||||
"MEAL_BOX_DISCARDED",
|
||||
"MEAL_REMOVED",
|
||||
"COOKING_SESSION_UNDONE",
|
||||
] as const;
|
||||
export type EventType = (typeof EVENT_TYPES)[number];
|
||||
|
||||
@@ -433,6 +436,20 @@ export const MEAL_LOG_SOURCES = [
|
||||
] as const;
|
||||
export type MealLogSource = (typeof MEAL_LOG_SOURCES)[number];
|
||||
|
||||
/** Cooking session lifecycle (Fas 3 §6). TEXT i databasen, valideras mot denna const. */
|
||||
export const COOKING_SESSION_STATUSES = [
|
||||
"planned",
|
||||
"started",
|
||||
"completed",
|
||||
"cancelled",
|
||||
"undone",
|
||||
] as const;
|
||||
export type CookingSessionStatus = (typeof COOKING_SESSION_STATUSES)[number];
|
||||
|
||||
/** Matlådestatuser (spec §24). TEXT i databasen, valideras mot denna const. */
|
||||
export const MEAL_BOX_STATUSES = ["available", "consumed", "discarded"] as const;
|
||||
export type MealBoxStatus = (typeof MEAL_BOX_STATUSES)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Smakprofil & feedback (spec §30)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user