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,
)

View File

@@ -0,0 +1,283 @@
package dev.ulfrx.recipe.shared.domain.model
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Wire-format contract for the reshaped domain model.
*
* The sync engine (Phase 4) stores these shapes verbatim into Postgres JSONB
* columns; the planner UI (Phase 6/7) round-trips them through SQLDelight; a
* future client-side catalog editor would write through the same path. So the
* JSON keys, value-class flattening, enum casing, and default-omission behavior
* asserted below are load-bearing.
*/
class SerializationRoundTripTest {
private val json = Json { encodeDefaults = false }
private val pl = LocaleTag("pl")
private val en = LocaleTag("en")
// ── IDs ──────────────────────────────────────────────────────────────
@Test
fun `typed IDs serialize as bare strings instead of wrapper objects`() {
val encoded = json.encodeToString(RecipeId.serializer(), RecipeId("rcp_007"))
assertEquals("\"rcp_007\"", encoded)
val decoded = json.decodeFromString(RecipeId.serializer(), encoded)
assertEquals(RecipeId("rcp_007"), decoded)
}
// ── Localization ─────────────────────────────────────────────────────
@Test
fun `LocaleTag serializes as a bare string key`() {
// LocaleTag must serialize as a string so LocalizedString JSON keys
// read like {"pl":"Śniadanie"} — not {"raw":"pl"} wrappers.
val encoded = json.encodeToString(LocaleTag.serializer(), pl)
assertEquals("\"pl\"", encoded)
}
@Test
fun `LocalizedString forLocale falls back to Polish then to any value`() {
val name: LocalizedString = mapOf(pl to "Śniadanie", en to "Breakfast")
assertEquals("Śniadanie", name.forLocale(pl))
assertEquals("Breakfast", name.forLocale(en))
val onlyPolish: LocalizedString = mapOf(pl to "Obiad")
assertEquals("Obiad", onlyPolish.forLocale(en))
val empty: LocalizedString = emptyMap()
assertEquals("", empty.forLocale(pl))
}
// ── Units ────────────────────────────────────────────────────────────
@Test
fun `MeasurementUnit serializes as lowercase English wire names`() {
assertEquals("\"gram\"", json.encodeToString(MeasurementUnit.serializer(), MeasurementUnit.GRAM))
assertEquals("\"milliliter\"", json.encodeToString(MeasurementUnit.serializer(), MeasurementUnit.MILLILITER))
assertEquals("\"piece\"", json.encodeToString(MeasurementUnit.serializer(), MeasurementUnit.PIECE))
}
@Test
fun `Quantity with displayHint encodes hint as flat locale map`() {
val q = Quantity(amount = 30.0, unit = MeasurementUnit.MILLILITER, displayHint = mapOf(pl to "2 łyżki"))
val encoded = json.encodeToString(Quantity.serializer(), q)
assertEquals(
"{\"amount\":30.0,\"unit\":\"milliliter\",\"displayHint\":{\"pl\":\"2 łyżki\"}}",
encoded,
)
val decoded = json.decodeFromString(Quantity.serializer(), encoded)
assertEquals(q, decoded)
}
@Test
fun `Quantity without displayHint omits the null field on the wire`() {
// Phase 4 syncs these into JSONB; omitting nulls keeps the payload
// small and lets future fields default cleanly on the decoder side.
val q = Quantity(amount = 100.0, unit = MeasurementUnit.GRAM)
val encoded = json.encodeToString(Quantity.serializer(), q)
assertEquals("{\"amount\":100.0,\"unit\":\"gram\"}", encoded)
assertEquals(q, json.decodeFromString(Quantity.serializer(), encoded))
}
// ── PlanEntry — the full customization shape ─────────────────────────
@Test
fun `PlanEntry with every customization kind round trips`() {
val flour = IngredientId("ing_flour")
val butter = IngredientId("ing_butter")
val margarine = IngredientId("ing_margarine")
val salt = IngredientId("ing_salt")
val oil = IngredientId("ing_oil")
val sugar = IngredientId("ing_sugar")
val piatnica = ProductId("prd_piatnica_serek")
val entry =
PlanEntry(
id = PlanEntryId("pe_001"),
sync =
SyncMeta(
id = "pe_001",
householdId = HouseholdId("hh_aaa"),
createdAt = Instant.parse("2026-05-19T08:00:00Z"),
updatedAt = Instant.parse("2026-05-19T08:00:00Z"),
),
date = LocalDate(2026, 5, 19),
slotId = MealSlotId("slot_breakfast"),
recipeId = RecipeId("rcp_pancakes"),
servings = 2.0,
customization =
PlanCustomization(
overrides =
mapOf(
// Substitute butter → margarine
butter to IngredientCustomization(substituteWith = margarine),
// Exclude salt entirely
salt to IngredientCustomization(excluded = true),
// Override flour to 250g (use displayHint to prove it round-trips)
flour to
IngredientCustomization(
amountOverride =
Quantity(
amount = 250.0,
unit = MeasurementUnit.GRAM,
displayHint = mapOf(pl to "2 szklanki"),
),
),
// Pin a specific product
sugar to IngredientCustomization(productPick = piatnica),
),
added =
listOf(
AddedIngredient(
ingredientId = oil,
quantity =
Quantity(
amount = 15.0,
unit = MeasurementUnit.MILLILITER,
displayHint = mapOf(pl to "1 łyżka"),
),
),
),
),
)
val encoded = json.encodeToString(PlanEntry.serializer(), entry)
val decoded = json.decodeFromString(PlanEntry.serializer(), encoded)
assertEquals(entry, decoded)
// Spot-check wire shape: typed IDs are bare strings inside the payload
// (otherwise the JSONB column gets ugly and queries are unportable).
assertTrue(encoded.contains("\"recipeId\":\"rcp_pancakes\""), "recipeId should serialize as a bare string: $encoded")
assertTrue(encoded.contains("\"slotId\":\"slot_breakfast\""), "slotId should serialize as a bare string: $encoded")
}
@Test
fun `empty PlanCustomization omits with encodeDefaults off`() {
val empty = PlanCustomization()
val encoded = json.encodeToString(PlanCustomization.serializer(), empty)
// With encodeDefaults = false, both fields fall back to their empty
// defaults and the wire payload is `{}`. Phase 6 PlanEntry rows without
// customizations get the cheapest possible JSONB representation.
assertEquals("{}", encoded)
assertEquals(empty, json.decodeFromString(PlanCustomization.serializer(), encoded))
}
@Test
fun `IngredientCustomization with all defaults omits every field`() {
val empty = IngredientCustomization()
val encoded = json.encodeToString(IngredientCustomization.serializer(), empty)
// Repository contract: prune fully-default entries before write — but
// if one slips through, the wire representation is still empty.
assertEquals("{}", encoded)
}
// ── Catalog ──────────────────────────────────────────────────────────
@Test
fun `Ingredient with multi-locale name and required pantryUnit round trips`() {
val ingredient =
Ingredient(
id = IngredientId("ing_olej"),
sync =
SyncMeta(
id = "ing_olej",
householdId = HouseholdId("hh_aaa"),
createdAt = Instant.parse("2026-04-01T00:00:00Z"),
updatedAt = Instant.parse("2026-04-01T00:00:00Z"),
),
name = mapOf(pl to "Olej rzepakowy", en to "Rapeseed oil"),
categoryId = IngredientCategoryId("cat_suche"),
pantryUnit = MeasurementUnit.MILLILITER,
purchasePack = PurchasePack(amount = 1000.0, label = "1 l"),
nutritionPer100 = NutritionPer100(kcal = 884.0, protein = 0.0, fat = 100.0, carbs = 0.0),
)
val encoded = json.encodeToString(Ingredient.serializer(), ingredient)
val decoded = json.decodeFromString(Ingredient.serializer(), encoded)
assertEquals(ingredient, decoded)
assertTrue(
encoded.contains("\"name\":{\"pl\":\"Olej rzepakowy\",\"en\":\"Rapeseed oil\"}"),
"LocalizedString should serialize as a flat locale-keyed object: $encoded",
)
assertTrue(encoded.contains("\"pantryUnit\":\"milliliter\""))
assertTrue(!encoded.contains("\"weightPerPieceG\""), "null weightPerPieceG should be omitted: $encoded")
assertTrue(!encoded.contains("\"imagePath\""), "null imagePath should be omitted: $encoded")
}
@Test
fun `Recipe drops null imagePath but keeps populated localized title and steps`() {
val recipe =
Recipe(
id = RecipeId("rcp_naleśniki"),
sync =
SyncMeta(
id = "rcp_naleśniki",
householdId = HouseholdId("hh_aaa"),
createdAt = Instant.parse("2026-04-01T00:00:00Z"),
updatedAt = Instant.parse("2026-04-01T00:00:00Z"),
),
title = mapOf(pl to "Naleśniki", en to "Pancakes"),
minutes = 25,
tags = listOf("śniadanie", "szybkie"),
steps =
listOf(
mapOf(pl to "Wymieszaj mąkę z mlekiem.", en to "Mix flour with milk."),
mapOf(pl to "Smaż na patelni.", en to "Fry on a pan."),
),
ingredients =
listOf(
RecipeIngredient(
ingredientId = IngredientId("ing_flour"),
quantity = Quantity(amount = 200.0, unit = MeasurementUnit.GRAM),
),
),
nutritionPerServing = NutritionPer100(kcal = 320.0, protein = 8.0, fat = 9.0, carbs = 52.0),
)
val encoded = json.encodeToString(Recipe.serializer(), recipe)
val decoded = json.decodeFromString(Recipe.serializer(), encoded)
assertEquals(recipe, decoded)
assertTrue(!encoded.contains("\"imagePath\""), "null imagePath should be omitted: $encoded")
assertTrue(encoded.contains("\"tags\":[\"śniadanie\",\"szybkie\"]"))
}
// ── Sync ─────────────────────────────────────────────────────────────
@Test
fun `SyncMeta omits null deletedAt and surfaces all three timestamps`() {
val sync =
SyncMeta(
id = "pe_001",
householdId = HouseholdId("hh_aaa"),
createdAt = Instant.parse("2026-05-19T08:00:00Z"),
updatedAt = Instant.parse("2026-05-19T08:15:00Z"),
)
val encoded = json.encodeToString(SyncMeta.serializer(), sync)
assertTrue(encoded.contains("\"createdAt\":\"2026-05-19T08:00:00Z\""))
assertTrue(encoded.contains("\"updatedAt\":\"2026-05-19T08:15:00Z\""))
assertTrue(!encoded.contains("\"deletedAt\""), "null deletedAt should be omitted: $encoded")
val tombstoned = sync.copy(deletedAt = Instant.parse("2026-05-20T09:00:00Z"))
val tombstoneEncoded = json.encodeToString(SyncMeta.serializer(), tombstoned)
assertTrue(tombstoneEncoded.contains("\"deletedAt\":\"2026-05-20T09:00:00Z\""))
}
}