Reshape shared/commonMain domain model

Replace the 11 hand-rolled model files with 7 grouped by concern. Typed
ID value classes kill bare-string FKs. Canonical 3-value MeasurementUnit
enum kills the runtime unitMismatch class — Polish vocabulary lives in
Quantity.displayHint as render-only metadata. MealExtras (5 maps) collapses
into IngredientCustomization + PlanCustomization. IngredientCategory and
MealSlot become household-scoped entities with LocalizedString names so
they're customizable without an app release. Display names land as
LocalizedString from day one; no Polish strings in identifiers or wire
codes. Recipe drops allowedSlots — slot affinity is a UI-layer match on
Recipe.tags vs MealSlot.name. Skip is absence, not a sealed sibling.

Plan: ~/.claude/plans/i-have-generated-some-inherited-conway.md.

Covered by SerializationRoundTripTest: 12 assertions across typed-ID
inlining, MeasurementUnit wire format, LocalizedString JSON shape, full
PlanEntry round-trip with every customization kind, SyncMeta tombstone
omission, and Catalog defaults handling. All targets compile and pass:
JVM, Android (debug + release), iOS Simulator Arm64.
This commit is contained in:
2026-05-19 23:11:05 +02:00
parent 815c4f4efc
commit 2d2556fd26
9 changed files with 720 additions and 4 deletions

View File

@@ -0,0 +1,150 @@
package dev.ulfrx.recipe.shared.domain.model
import kotlinx.serialization.Serializable
// ── Value-typed metadata ──────────────────────────────────────────────────
/**
* Macros per 100 g (or 100 ml for liquid ingredients). Daily-total aggregation
* (PLAN-13) reads these from ingredients/products and scales by the canonical
* quantity at meal time.
*/
@Serializable
public data class NutritionPer100(
public val kcal: Double,
public val protein: Double,
public val fat: Double,
public val carbs: Double,
)
/**
* Typical retail pack for an ingredient or a specific product (e.g.
* `PurchasePack(125.0, "125 g")`). Shopping list rounding uses this to suggest
* realistic pack-sized purchases instead of raw recipe weights.
*/
@Serializable
public data class PurchasePack(
public val amount: Double,
public val label: String,
)
// ── Household-scoped taxonomy ────────────────────────────────────────────
//
// IngredientCategory and MealSlot are user-curated per household: each
// household ships with a Polish default list at creation (Phase 3 server
// concern) and can rename / reorder / extend freely. Both LWW-sync via SyncMeta
// the same way other household data does.
/**
* A pantry / shopping grouping the household uses (`Pieczywo`, `Nabiał`,
* `Mięso i ryby`, …). [ordinal] drives display order in the pantry and
* shopping list. [name] is localized per the household's locale palette.
*/
@Serializable
public data class IngredientCategory(
public val id: IngredientCategoryId,
public val sync: SyncMeta,
public val name: LocalizedString,
public val ordinal: Int,
)
/**
* A meal-time slot a household plans into (`Śniadanie`, `Drugie śniadanie`,
* `Obiad`, `Przekąska`, `Kolacja`, plus anything custom). [ordinal] drives the
* within-day order in the planner. Slots are referenced by [PlanEntry.slotId].
*
* Recipes don't pin themselves to specific slots — slot affinity is a UI-layer
* match between [Recipe.tags] and [name] localized values, so households can
* rename slots without invalidating the catalog.
*/
@Serializable
public data class MealSlot(
public val id: MealSlotId,
public val sync: SyncMeta,
public val name: LocalizedString,
public val ordinal: Int,
)
// ── Catalog entities (server-owned in v1, household-editable later) ──────
//
// Ingredient / Product / Recipe carry SyncMeta so that future client-side
// catalog writes land on the same LWW path as everything else; v1 only reads
// them on the client.
/**
* A catalog-level ingredient (mąka, oliwa, jajko, …). [pantryUnit] is the
* canonical unit this ingredient is tracked in for pantry math; recipe lines
* targeting this ingredient must produce a [Quantity] in the same unit (or one
* that converts cleanly — pieces ↔ grams via [weightPerPieceG]).
*
* [weightPerPieceG] is only meaningful when [pantryUnit] == [MeasurementUnit.PIECE];
* leaving it null on a piece-tracked ingredient blocks nutrition aggregation.
* [purchasePack] feeds shopping rounding; [nutritionPer100] feeds daily totals.
*/
@Serializable
public data class Ingredient(
public val id: IngredientId,
public val sync: SyncMeta,
public val name: LocalizedString,
public val categoryId: IngredientCategoryId,
public val pantryUnit: MeasurementUnit,
public val weightPerPieceG: Double? = null,
public val purchasePack: PurchasePack? = null,
public val nutritionPer100: NutritionPer100? = null,
public val imagePath: String? = null,
)
/**
* A branded / packaged variant of an [Ingredient]. Products carry their own
* [nutritionPer100] because brand A's twaróg has different macros than brand
* B's, and per-entry [PlanCustomization.overrides] can pick a specific product.
* [brand] is plain `String?` — brand names are proper nouns and don't translate.
*/
@Serializable
public data class Product(
public val id: ProductId,
public val sync: SyncMeta,
public val ingredientId: IngredientId,
public val name: LocalizedString,
public val brand: String? = null,
public val pack: PurchasePack,
public val nutritionPer100: NutritionPer100? = null,
public val imagePath: String? = null,
)
/**
* One ingredient line on a recipe. [alternatives] is metadata only — the
* planner UI surfaces these as suggested swaps, but the actual per-entry
* substitution is recorded in [IngredientCustomization.substituteWith].
*/
@Serializable
public data class RecipeIngredient(
public val ingredientId: IngredientId,
public val quantity: Quantity,
public val alternatives: List<IngredientId> = emptyList(),
)
/**
* Catalog-level recipe definition. Synced via the same LWW path as household
* data so future client-side recipe editing lands additively. Recipes are
* server-seeded in v1 and read-only on the client.
*
* No `allowedSlots` field — slot affinity is a UI-layer concern using [tags]
* matched against [MealSlot.name] localized values. Recipes ship with Polish
* slot tags so default households work out of the box.
*
* [nutritionPerServing] is the cached pre-customization total; the calculator
* skips the per-ingredient walk when no [PlanCustomization] is present.
*/
@Serializable
public data class Recipe(
public val id: RecipeId,
public val sync: SyncMeta,
public val title: LocalizedString,
public val minutes: Int,
public val tags: List<String> = emptyList(),
public val steps: List<LocalizedString> = emptyList(),
public val ingredients: List<RecipeIngredient>,
public val nutritionPerServing: NutritionPer100,
public val imagePath: String? = null,
)

View File

@@ -0,0 +1,54 @@
package dev.ulfrx.recipe.shared.domain.model
import kotlin.jvm.JvmInline
import kotlinx.serialization.Serializable
// Typed id wrappers. `@Serializable @JvmInline value class` serializes as the
// underlying string in JSON (no `{"raw":"..."}` envelope) and erases to a
// String at runtime on JVM/Native — zero wire-format change, zero overhead,
// full compile-time protection against passing a RecipeId where an
// IngredientId is expected.
@Serializable
@JvmInline
public value class HouseholdId(public val raw: String)
@Serializable
@JvmInline
public value class UserId(public val raw: String)
@Serializable
@JvmInline
public value class RecipeId(public val raw: String)
@Serializable
@JvmInline
public value class IngredientId(public val raw: String)
@Serializable
@JvmInline
public value class ProductId(public val raw: String)
@Serializable
@JvmInline
public value class PlanEntryId(public val raw: String)
@Serializable
@JvmInline
public value class PantryItemId(public val raw: String)
@Serializable
@JvmInline
public value class ShoppingListId(public val raw: String)
@Serializable
@JvmInline
public value class ShoppingItemId(public val raw: String)
@Serializable
@JvmInline
public value class IngredientCategoryId(public val raw: String)
@Serializable
@JvmInline
public value class MealSlotId(public val raw: String)

View File

@@ -0,0 +1,52 @@
package dev.ulfrx.recipe.shared.domain.model
import kotlinx.serialization.Serializable
/**
* One stock row for an ingredient — optionally pinned to a specific [Product].
*
* The mockup's hybrid `Number-or-{total, items[]}` storage collapses to
* multiple flat rows: one with `productId == null` for generic stock, plus a
* row per tracked product. Aggregating "how much of X do I have?" is a sum
* across the rows sharing an [ingredientId].
*/
@Serializable
public data class PantryItem(
public val id: PantryItemId,
public val sync: SyncMeta,
public val ingredientId: IngredientId,
public val productId: ProductId? = null,
public val quantity: Quantity,
)
/**
* A named shopping list (most households use the default "Kitchen" list;
* additional lists are user-created). [ordinal] orders multiple lists in the
* UI. [name] is plain `String` — user-supplied free text, not catalog-localized.
*/
@Serializable
public data class ShoppingList(
public val id: ShoppingListId,
public val sync: SyncMeta,
public val name: String,
public val ordinal: Int,
)
/**
* One line on a shopping list. [productId] pins a specific brand when present.
* [checked] tracks "bought" state; checking + later sync into pantry happens at
* the repository layer. [sourceNote] is a free-text provenance hint ("From
* plan", "Ze spiżarni"); the v1 UI surfaces it but doesn't filter on it.
*/
@Serializable
public data class ShoppingItem(
public val id: ShoppingItemId,
public val sync: SyncMeta,
public val listId: ShoppingListId,
public val ingredientId: IngredientId,
public val productId: ProductId? = null,
public val quantity: Quantity,
public val checked: Boolean = false,
public val sourceNote: String? = null,
public val ordinal: Int,
)

View File

@@ -0,0 +1,35 @@
package dev.ulfrx.recipe.shared.domain.model
import kotlin.jvm.JvmInline
import kotlinx.serialization.Serializable
/**
* BCP-47 locale tag, e.g. `"pl"`, `"en"`, `"pl-PL"`. Held as a typed value
* class so localized-name maps don't get confused with arbitrary string keys.
*/
@Serializable
@JvmInline
public value class LocaleTag(public val raw: String)
/**
* A user-facing string in one or more locales. Stored as a flat JSON object
* keyed by locale tag (e.g. `{"pl": "Śniadanie", "en": "Breakfast"}`). v1 seed
* data ships with at least the Polish key populated; additional locales are
* additive.
*/
public typealias LocalizedString = Map<LocaleTag, String>
/**
* Resolve a localized string for the requested [locale], falling back to
* [fallback] (default Polish), then to any populated value, then to an empty
* string. The empty-string fallback is intentional — UI code should never crash
* because a translation hasn't shipped yet.
*/
public fun LocalizedString.forLocale(
locale: LocaleTag,
fallback: LocaleTag = LocaleTag("pl"),
): String =
this[locale]
?: this[fallback]
?: values.firstOrNull()
?: ""

View File

@@ -0,0 +1,63 @@
package dev.ulfrx.recipe.shared.domain.model
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.
*
* 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.
*/
@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 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
* [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).
*
* "Skipped slot" (PLAN-12) is modeled as absence — no [PlanEntry] row exists
* for that `(date, slotId)`. No sentinel field, no sealed sibling.
*/
@Serializable
public data class PlanEntry(
public val id: PlanEntryId,
public val sync: SyncMeta,
public val date: LocalDate,
public val slotId: MealSlotId,
public val recipeId: RecipeId,
public val servings: Double,
public val customization: PlanCustomization = PlanCustomization(),
)

View File

@@ -0,0 +1,36 @@
package dev.ulfrx.recipe.shared.domain.model
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
/**
* Per-row sync footer for every household-scoped or catalog entity.
*
* All three timestamps are server-assigned, never client-supplied — the
* monotonic `(updatedAt, id)` pair is the pull cursor, and a client-set
* `updatedAt` would break LWW conflict resolution across devices with drifted
* clocks. [deletedAt] is the soft-delete tombstone; rows with `deletedAt != null`
* still ship through sync so deletes propagate to other devices.
*/
@Serializable
public data class SyncMeta(
public val id: String,
public val householdId: HouseholdId,
public val createdAt: Instant,
public val updatedAt: Instant,
public val deletedAt: Instant? = null,
)
/**
* A household — the tenancy boundary for every per-household entity (plan,
* pantry, shopping list, the household-scoped taxonomy entries in
* [IngredientCategory] / [MealSlot]). Members share a single household; v1 has
* one active household per user.
*/
@Serializable
public data class Household(
public val id: HouseholdId,
public val name: String,
public val createdAt: Instant,
public val updatedAt: Instant,
)

View File

@@ -0,0 +1,39 @@
package dev.ulfrx.recipe.shared.domain.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* The three canonical units the whole system computes on. Polish vocabulary
* like `"łyżka"`, `"puszka"`, `"szczypta"` is NOT a unit — it lives in
* [Quantity.displayHint] as render-only metadata while the math runs on this
* fixed enum.
*/
@Serializable
public enum class MeasurementUnit {
@SerialName("gram")
GRAM,
@SerialName("milliliter")
MILLILITER,
@SerialName("piece")
PIECE,
}
/**
* Canonical [amount] in a fixed [unit], with an optional human-readable
* [displayHint] for UI rendering.
*
* The hint lets a recipe say "2 łyżki oleju" (stored as
* `Quantity(30.0, MILLILITER, {"pl": "2 łyżki"})`) and still aggregate cleanly
* with another row's `100ml` because both are millilitres internally. Catalog
* ingest is responsible for choosing the canonical amount; the domain model
* just stores the result.
*/
@Serializable
public data class Quantity(
public val amount: Double,
public val unit: MeasurementUnit,
public val displayHint: LocalizedString? = null,
)