Collapse PlanEntry customization into a materialized ingredient snapshot

PlanCustomization / IngredientCustomization / AddedIngredient disappear;
PlanEntry now carries List<PlanIngredient> directly. Substitutions,
exclusions, amount overrides, product picks, and added ingredients are
all just whatever ends up in the list. Recipe edits no longer mutate
historic plan entries — load-bearing once consumption tracking lands.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 23:07:02 +02:00
parent 2d2556fd26
commit ae4186d9fa
2 changed files with 68 additions and 86 deletions

View File

@@ -4,50 +4,34 @@ import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
/**
* Per-ingredient overrides applied to a planned meal. All fields are optional;
* an entry is only stored in [PlanCustomization.overrides] when at least one
* field is non-default. The repository prunes default-only entries on write.
* One ingredient line on a planned meal. Self-contained — substitutions,
* exclusions, amount overrides, and product picks are not modeled as
* overlays on the recipe; they're just whatever ends up in this list.
*
* The four fields cover PLAN-07 through PLAN-11 (substitutions, exclusions,
* amount overrides, product picks) collapsed onto a single per-ingredient row
* so they can compose — substituting almonds for walnuts AND overriding the
* amount AND pinning a brand all land on the same record.
* A swap (butter → margarine) is just a `PlanIngredient` with the swapped
* `ingredientId`. A removal is absence from the list. An amount tweak is
* the [quantity] field. A pinned brand is [productPick].
*/
@Serializable
public data class IngredientCustomization(
public val substituteWith: IngredientId? = null,
public val excluded: Boolean = false,
public val amountOverride: Quantity? = null,
public val productPick: ProductId? = null,
)
/**
* An ingredient injected into a planned meal that isn't part of the original
* recipe (PLAN-10). [productPick] optionally pins a specific [Product].
*/
@Serializable
public data class AddedIngredient(
public data class PlanIngredient(
public val ingredientId: IngredientId,
public val quantity: Quantity,
public val productPick: ProductId? = null,
)
/**
* All customizations attached to a single [PlanEntry]. Defaults are empty —
* a Phase 6 entry without customizations stays cheap.
*/
@Serializable
public data class PlanCustomization(
public val overrides: Map<IngredientId, IngredientCustomization> = emptyMap(),
public val added: List<AddedIngredient> = emptyList(),
)
/**
* One planned meal: a recipe at a date/slot pair. Identity is the UUID
* One planned meal: a recipe at a date/slot pair, with a fully-materialized
* snapshot of the ingredients as the user planned them. Identity is the UUID
* [PlanEntryId] — never the composite `(date, slot)` — so concurrent edits
* across devices can survive a delete + recreate race without colliding on a
* natural key (PLAN-14).
*
* [recipeId] is provenance only: it powers the "open recipe" link, the "cook
* again" action, and analytics. The recipe's current ingredient list is not
* consulted at read time — [ingredients] is the source of truth for this
* meal. This way, editing a recipe never mutates historic plan entries
* (load-bearing once consumption tracking lands in a later phase).
*
* "Skipped slot" (PLAN-12) is modeled as absence — no [PlanEntry] row exists
* for that `(date, slotId)`. No sentinel field, no sealed sibling.
*/
@@ -59,5 +43,5 @@ public data class PlanEntry(
public val slotId: MealSlotId,
public val recipeId: RecipeId,
public val servings: Double,
public val customization: PlanCustomization = PlanCustomization(),
public val ingredients: List<PlanIngredient>,
)