From 9fbdc14869fb76233cff788e4ba3292a9b603e1e Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 24 Apr 2026 14:16:40 +0200 Subject: [PATCH] feat(wardrobe,picture): symmetric wardrobeGarmentId FK + garment try-on strips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solo-garment Try-Ons had no back-reference into Wardrobe — the generated image landed in Picture.images with wardrobeOutfitId=null and was unfindable from the garment detail page. The M4.1 comment called it "deliberate — a standalone preview, not an outfit"; in practice users open the garment detail expecting to see past tries. Drop the asymmetry. Picture stays the single source of truth for every AI-generated image; Wardrobe references by FK, not heuristic. - Dexie v42: index `images.wardrobeOutfitId` (was unindexed, the existing outfit query fell back to a scan) and add the symmetric `images.wardrobeGarmentId` column + index. Fresh start — not live, no migration of old rows, undefined on existing rows is the correct "no back-ref" semantics. - LocalImage / Image gain `wardrobeGarmentId?: string | null` in the picture module's types + converter. Invariant: at most one of `wardrobeOutfitId` / `wardrobeGarmentId` set per row (solo try-ons write the garment id, outfit try-ons write the outfit id). - runGarmentTryOn stamps `wardrobeGarmentId: garment.id` on the new Picture row. runOutfitTryOn unchanged — it already wrote wardrobeOutfitId for the symmetric outfit path. - New queries in wardrobe/queries.ts: - `useGarmentSoloTryOns(garmentId)` — live-queries Picture for rows tagged with this garment's id. - `useOutfitsContainingGarment(garmentId)` — live-queries wardrobeOutfits whose `garmentIds[]` includes the garment, for the cross-outfit context strip. - DetailGarmentView gets two new sections under the existing meta/ action stack: 1. "Anproben" — solo-try-on thumbnails (click → full image in a new tab; proper lightbox can reuse Picture's modal later). 2. "In Outfits" — outfits containing this garment, each rendering its own `lastTryOn` snapshot as the thumb, click → outfit detail. Reuses the outfit row's cached snapshot so no extra image lookup. Crypto registry unchanged: `images` only encrypts prompt + negativePrompt; the new FK stays plaintext like wardrobeOutfitId. mana-sync is field-level-generic — no schema work needed, the new column syncs as a plain field. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/mana/apps/web/src/lib/data/database.ts | 19 ++++ .../web/src/lib/modules/picture/queries.ts | 1 + .../apps/web/src/lib/modules/picture/types.ts | 9 ++ .../src/lib/modules/wardrobe/api/try-on.ts | 10 +- .../web/src/lib/modules/wardrobe/queries.ts | 45 +++++++++ .../wardrobe/views/DetailGarmentView.svelte | 97 ++++++++++++++++++- 6 files changed, 176 insertions(+), 5 deletions(-) diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index cd92e299a..d4e4f3562 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -992,6 +992,25 @@ db.version(41).stores({ wardrobeOutfits: 'id, createdAt, isFavorite, isArchived', }); +// v42 — Index `images.wardrobeOutfitId` + add `images.wardrobeGarmentId` +// index for fast Wardrobe back-reference queries. Until now Try-On +// results in `picture.images` carried a back-ref to `wardrobeOutfits` +// but no index — `useOutfitTryOns` fell back to a linear filter across +// every image in the active space. Solo-Garment Try-Ons (M4.1) didn't +// have a back-ref at all; the result ended up in the Picture gallery +// and was unfindable from the garment detail page. +// +// Both fields are plaintext FKs to Wardrobe rows in the same space. +// Index both together so outfit-detail and garment-detail queries are +// single-field equality lookups (O(log n)) instead of table scans, and +// add the symmetric `wardrobeGarmentId` so Solo-Try-Ons have the same +// first-class back-reference as Outfit-Try-Ons. Invariant (enforced in +// the write path): at most one of the two is set per row. +db.version(42).stores({ + images: + 'id, isFavorite, isPublic, isArchived, prompt, updatedAt, wardrobeOutfitId, wardrobeGarmentId', +}); + // ─── Sync Routing ────────────────────────────────────────── // SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE, // toSyncName() and fromSyncName() are now derived from per-module diff --git a/apps/mana/apps/web/src/lib/modules/picture/queries.ts b/apps/mana/apps/web/src/lib/modules/picture/queries.ts index c6d027e2f..d83c383dd 100644 --- a/apps/mana/apps/web/src/lib/modules/picture/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/picture/queries.ts @@ -50,6 +50,7 @@ export function toImage(local: LocalImage): Image { referenceImageIds: local.referenceImageIds ?? undefined, generationMode: local.generationMode ?? undefined, wardrobeOutfitId: local.wardrobeOutfitId ?? undefined, + wardrobeGarmentId: local.wardrobeGarmentId ?? undefined, createdAt: local.createdAt ?? new Date().toISOString(), updatedAt: local.updatedAt ?? new Date().toISOString(), }; diff --git a/apps/mana/apps/web/src/lib/modules/picture/types.ts b/apps/mana/apps/web/src/lib/modules/picture/types.ts index 324e06be4..1f484e3e6 100644 --- a/apps/mana/apps/web/src/lib/modules/picture/types.ts +++ b/apps/mana/apps/web/src/lib/modules/picture/types.ts @@ -52,6 +52,14 @@ export interface LocalImage extends BaseRecord { * an extra table. Plaintext — it's an FK. */ wardrobeOutfitId?: string | null; + /** + * Back-reference to `wardrobeGarments.id` when this image was produced + * by the solo-garment try-on flow (M4.1). Symmetric to + * wardrobeOutfitId — pictures are at most one of the two. Lets the + * garment detail view list its own try-ons without scanning by + * `referenceImageIds` containment. + */ + wardrobeGarmentId?: string | null; } export interface LocalBoard extends BaseRecord { @@ -122,6 +130,7 @@ export interface Image { referenceImageIds?: string[]; generationMode?: ImageGenerationMode; wardrobeOutfitId?: string; + wardrobeGarmentId?: string; createdAt: string; updatedAt: string; } diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/api/try-on.ts b/apps/mana/apps/web/src/lib/modules/wardrobe/api/try-on.ts index 25b6d294e..627392b96 100644 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/api/try-on.ts +++ b/apps/mana/apps/web/src/lib/modules/wardrobe/api/try-on.ts @@ -303,11 +303,13 @@ export async function runGarmentTryOn(params: RunGarmentTryOnParams): Promise(async () => { + if (!garmentId) return []; + const locals = await scopedForModule('picture', 'images') + .and((row) => row.wardrobeGarmentId === garmentId) + .toArray(); + const visible = locals + .filter((row) => !row.deletedAt && !row.isArchived) + .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); + const decrypted = await decryptRecords('images', visible); + return decrypted.map(toImage); + }, [] as Image[]); +} + +/** + * All outfits in the active space that include the given garment, + * newest first. Used on the garment detail page to render a "Teil + * dieser Outfits"-Strip so the user can jump back into any outfit + * they've built around the item — each outfit's own `lastTryOn` + * snapshot provides the thumbnail without another image lookup. + */ +export function useOutfitsContainingGarment(garmentId: string | null) { + return useLiveQueryWithDefault(async () => { + if (!garmentId) return []; + const locals = await scopedForModule( + 'wardrobe', + 'wardrobeOutfits' + ).toArray(); + const visible = locals + .filter( + (row) => !row.deletedAt && !row.isArchived && (row.garmentIds ?? []).includes(garmentId) + ) + .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); + const decrypted = await decryptRecords('wardrobeOutfits', visible); + return decrypted.map(toOutfit); + }, [] as Outfit[]); +} diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/views/DetailGarmentView.svelte b/apps/mana/apps/web/src/lib/modules/wardrobe/views/DetailGarmentView.svelte index b748bb170..96767dd38 100644 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/views/DetailGarmentView.svelte +++ b/apps/mana/apps/web/src/lib/modules/wardrobe/views/DetailGarmentView.svelte @@ -7,7 +7,7 @@