feat(wardrobe): module foundation — garments + outfits space-scoped data layer (M1)

M1 of docs/plans/wardrobe-module.md — pure data layer + backend plumbing,
zero UI (that's M2). A user can now hold a digital wardrobe per space:
brand merch, club Trikots, family Kleiderschrank, team Kostüme, practice
Dresscode, and personal closet all live as separate pools under the same
Dexie tables, space-scoped like tags/scenes/agents after Phase 2c.

Data model — two tables, no join:

- wardrobeGarments (Dexie v41): single clothing items / accessories.
  Indexed on `category` + `createdAt` + `isArchived`. Encrypted:
  name/brand/color/size/material/tags/notes. Plaintext: category,
  mediaIds, counters, timestamps — all indexed or structural.
  `mediaIds[0]` is the primary photo used for try-on; additional
  ids are alternate views (back, detail) for M7.

- wardrobeOutfits (Dexie v41): named compositions referencing
  garment ids. Encrypted: name/description/tags. Plaintext:
  garmentIds (FK array), occasion (closed enum — useful for
  undecrypted filtering), season, booleans, lastTryOn snapshot.

- picture.images gains `wardrobeOutfitId?: string | null` as a
  plaintext back-reference. Try-on results land in the Picture
  gallery like any other generation; the outfit detail view
  queries them via this id rather than maintaining a third table.

Space scope:

- `wardrobe` added to all five explicit allowlists in shared-types/
  spaces.ts (personal is wildcard, no edit needed). Each space type
  gets a one-line comment explaining the real-world use case.
- App registry: `wardrobe` entry in shared-branding/mana-apps.ts
  with a rose→fuchsia gradient icon (T-shirt on hanger silhouette),
  color #e11d48, tier 'beta', status 'beta'.
- Module registry: wardrobeModuleConfig imported + appended to
  MODULE_CONFIGS so SYNC_APP_MAP picks it up automatically.

Backend:

- MAX_REFERENCE_IMAGES bumped 4 → 8 in picture/generate-with-
  reference (plus the client-side default in ReferenceImagePicker).
  Justified with a comment: face + body + top + bottom + shoes +
  outerwear + 2 accessories = 8. Cost doesn't scale with ref count
  (OpenAI bills per output), so the bump is a pure capability
  expansion with no credit-side risk.
- New POST /api/v1/wardrobe/garments/upload wraps uploadImageToMedia
  with app='wardrobe'. Registered under /api/v1/wardrobe in index.ts.
  Pattern 1:1 with the profile/me-images/upload endpoint; tier-gating
  falls out of wardrobe NOT being in RESOURCE_MODULES (tier='guest'
  works — consistent with picture's plain CRUD).

Stores emit domain events (WardrobeGarmentAdded, WardrobeOutfitCreated,
WardrobeOutfitTryOn, etc.) so later mana-ai missions can observe
activity without polling.

No UI in this commit. M2 (Garments-Grundlayer) wires the route + grid
+ upload-zone; M3 the Outfit composer; M4 the Try-On integration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 18:27:37 +02:00
parent f7536bc0b9
commit 4fc9d6c59c
36 changed files with 2058 additions and 158 deletions

View file

@ -90,6 +90,7 @@ import type {
} from '../../modules/broadcast/types';
import type { LocalArticle, LocalHighlight } from '../../modules/articles/types';
import type { LocalMeImage } from '../../modules/profile/types';
import type { LocalWardrobeGarment, LocalWardrobeOutfit } from '../../modules/wardrobe/types';
export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// ─── Chat ────────────────────────────────────────────────
@ -552,6 +553,33 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// lives in MinIO behind owner-RLS, not in Dexie.
meImages: entry<LocalMeImage>(['label', 'tags']),
// ─── Wardrobe (garments + outfits) ───────────────────────
// docs/plans/wardrobe-module.md M1. Two space-scoped tables.
//
// Garments: user-typed clothing metadata is the sensitive surface —
// brand names leak purchasing patterns, notes leak preferences,
// tags leak categorization intent. `category` stays plaintext
// because it's the Category-Tabs filter index; `mediaIds`, dates,
// and counters are structural.
wardrobeGarments: entry<LocalWardrobeGarment>([
'name',
'brand',
'color',
'size',
'material',
'tags',
'notes',
]),
// Outfits: name + description + tags are user-authored. Occasion
// stays plaintext (closed enum, small cardinality — useful to
// filter on without decrypt). `garmentIds` is an array of FKs,
// plaintext by the standard "IDs are plaintext" rule. `lastTryOn`
// is a structural pointer + prompt; the prompt itself isn't
// secret (OpenAI already saw it) but lands inside the encrypted
// JSON-stringified blob via the `season` array-path anyway — keep
// it plaintext and revisit if prompts later carry personal data.
wardrobeOutfits: entry<LocalWardrobeOutfit>(['name', 'description', 'tags']),
// Per-agent kontext documents — same schema as kontextDoc but keyed
// per agent. Content is free-form markdown.
agentKontextDocs: { enabled: true, fields: ['content'] },

View file

@ -974,6 +974,24 @@ db.version(40).upgrade(async (tx) => {
});
});
// v41 — Wardrobe module (docs/plans/wardrobe-module.md M1).
// Two space-scoped tables — garments (individual clothing items) and
// outfits (named compositions of garment refs). Try-on results live in
// picture.images with a wardrobeOutfitId back-reference; no join table
// here.
//
// Indices:
// - wardrobeGarments.category for the Category-Tabs filter
// - wardrobeGarments.createdAt for "newest first" ordering
// - wardrobeOutfits.createdAt for the grid default sort
// - wardrobeOutfits.isFavorite for the favorites filter
// Both tables get the standard spaceId/authorId/visibility stamping
// via the Dexie hook (they're NOT in USER_LEVEL_TABLES).
db.version(41).stores({
wardrobeGarments: 'id, category, createdAt, isArchived',
wardrobeOutfits: 'id, createdAt, isFavorite, isArchived',
});
// ─── 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

View file

@ -104,6 +104,7 @@ import { invoicesModuleConfig } from '$lib/modules/invoices/module.config';
import { broadcastModuleConfig } from '$lib/modules/broadcast/module.config';
import { wetterModuleConfig } from '$lib/modules/wetter/module.config';
import { websiteModuleConfig } from '$lib/modules/website/module.config';
import { wardrobeModuleConfig } from '$lib/modules/wardrobe/module.config';
import { aiModuleConfig } from '$lib/data/ai/module.config';
export const MODULE_CONFIGS: readonly ModuleConfig[] = [
@ -164,6 +165,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
broadcastModuleConfig,
wetterModuleConfig,
websiteModuleConfig,
wardrobeModuleConfig,
aiModuleConfig,
];

View file

@ -17,7 +17,7 @@
maxSelection?: number;
}
let { selectedIds = $bindable([]), maxSelection = 4 }: Props = $props();
let { selectedIds = $bindable([]), maxSelection = 8 }: Props = $props();
const referenceImages$ = useReferenceImages();
const referenceImages = $derived(referenceImages$.value ?? []);

View file

@ -47,6 +47,7 @@ export function toImage(local: LocalImage): Image {
sourceImageId: local.sourceImageId ?? undefined,
referenceImageIds: local.referenceImageIds ?? undefined,
generationMode: local.generationMode ?? undefined,
wardrobeOutfitId: local.wardrobeOutfitId ?? undefined,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};

View file

@ -35,6 +35,13 @@ export interface LocalImage extends BaseRecord {
/** mana-media ids of the me-images that fed a reference-edit. */
referenceImageIds?: string[] | null;
generationMode?: ImageGenerationMode | null;
/**
* Back-reference to `wardrobeOutfits.id` when this image was produced
* by the Wardrobe try-on flow (plan docs/plans/wardrobe-module.md).
* Lets the outfit detail view query all historical try-ons without
* an extra table. Plaintext it's an FK.
*/
wardrobeOutfitId?: string | null;
}
export interface LocalBoard extends BaseRecord {
@ -96,6 +103,7 @@ export interface Image {
sourceImageId?: string;
referenceImageIds?: string[];
generationMode?: ImageGenerationMode;
wardrobeOutfitId?: string;
createdAt: string;
updatedAt: string;
}

View file

@ -0,0 +1,9 @@
/**
* Wardrobe module Dexie table accessors.
*/
import { db } from '$lib/data/database';
import type { LocalWardrobeGarment, LocalWardrobeOutfit } from './types';
export const wardrobeGarmentsTable = db.table<LocalWardrobeGarment>('wardrobeGarments');
export const wardrobeOutfitsTable = db.table<LocalWardrobeOutfit>('wardrobeOutfits');

View file

@ -0,0 +1,6 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const wardrobeModuleConfig: ModuleConfig = {
appId: 'wardrobe',
tables: [{ name: 'wardrobeGarments' }, { name: 'wardrobeOutfits' }],
};

View file

@ -0,0 +1,139 @@
/**
* Wardrobe module read-side queries.
*
* All queries go through `scopedForModule` so switching the active
* space swaps the visible pool automatically (Brand-merch vs personal
* wardrobe vs family-wardrobe). Try-on history lives in `picture.images`
* filtered by `wardrobeOutfitId` see useOutfitTryOns below.
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { scopedForModule } from '$lib/data/scope';
import { decryptRecords } from '$lib/data/crypto';
import type { LocalImage, Image } from '$lib/modules/picture/types';
import { toImage } from '$lib/modules/picture/queries';
import {
toGarment,
toOutfit,
type Garment,
type GarmentCategory,
type LocalWardrobeGarment,
type LocalWardrobeOutfit,
type Outfit,
type OutfitOccasion,
} from './types';
// ─── Garments ─────────────────────────────────────────────────────
/** All non-archived, non-deleted garments in the active space. */
export function useAllGarments() {
return useLiveQueryWithDefault<Garment[]>(async () => {
const locals = await scopedForModule<LocalWardrobeGarment, string>(
'wardrobe',
'wardrobeGarments'
).toArray();
const visible = locals
.filter((row) => !row.deletedAt && !row.isArchived)
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
const decrypted = await decryptRecords('wardrobeGarments', visible);
return decrypted.map(toGarment);
}, [] as Garment[]);
}
/** Garments filtered by category — used by the Category-Tabs view. */
export function useGarmentsByCategory(category: GarmentCategory) {
return useLiveQueryWithDefault<Garment[]>(async () => {
const locals = await scopedForModule<LocalWardrobeGarment, string>(
'wardrobe',
'wardrobeGarments'
)
.and((row) => row.category === category)
.toArray();
const visible = locals
.filter((row) => !row.deletedAt && !row.isArchived)
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
const decrypted = await decryptRecords('wardrobeGarments', visible);
return decrypted.map(toGarment);
}, [] as Garment[]);
}
/** A single garment by id, live-updating. Null while loading / missing. */
export function useGarment(id: string | null) {
return useLiveQueryWithDefault<Garment | null>(async () => {
if (!id) return null;
const locals = await scopedForModule<LocalWardrobeGarment, string>(
'wardrobe',
'wardrobeGarments'
)
.and((row) => row.id === id)
.toArray();
const [local] = locals;
if (!local || local.deletedAt) return null;
const [decrypted] = await decryptRecords('wardrobeGarments', [local]);
return toGarment(decrypted);
}, null);
}
// ─── Outfits ──────────────────────────────────────────────────────
/** All non-archived outfits in the active space. */
export function useAllOutfits() {
return useLiveQueryWithDefault<Outfit[]>(async () => {
const locals = await scopedForModule<LocalWardrobeOutfit, string>(
'wardrobe',
'wardrobeOutfits'
).toArray();
const visible = locals
.filter((row) => !row.deletedAt && !row.isArchived)
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
const decrypted = await decryptRecords('wardrobeOutfits', visible);
return decrypted.map(toOutfit);
}, [] as Outfit[]);
}
export function useOutfitsByOccasion(occasion: OutfitOccasion) {
return useLiveQueryWithDefault<Outfit[]>(async () => {
const locals = await scopedForModule<LocalWardrobeOutfit, string>('wardrobe', 'wardrobeOutfits')
.and((row) => row.occasion === occasion)
.toArray();
const visible = locals
.filter((row) => !row.deletedAt && !row.isArchived)
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
const decrypted = await decryptRecords('wardrobeOutfits', visible);
return decrypted.map(toOutfit);
}, [] as Outfit[]);
}
export function useOutfit(id: string | null) {
return useLiveQueryWithDefault<Outfit | null>(async () => {
if (!id) return null;
const locals = await scopedForModule<LocalWardrobeOutfit, string>('wardrobe', 'wardrobeOutfits')
.and((row) => row.id === id)
.toArray();
const [local] = locals;
if (!local || local.deletedAt) return null;
const [decrypted] = await decryptRecords('wardrobeOutfits', [local]);
return toOutfit(decrypted);
}, null);
}
/**
* Every try-on ever rendered for an outfit, newest first. Pulls from
* `picture.images` (filtered by `wardrobeOutfitId`) because that's where
* generations physically land see plan decision #1 (kein drittes Table
* für Try-Ons). The outfit detail view renders these as a horizontal
* strip under the current composition.
*/
export function useOutfitTryOns(outfitId: string | null) {
return useLiveQueryWithDefault<Image[]>(async () => {
if (!outfitId) return [];
const locals = await scopedForModule<LocalImage, string>('picture', 'images')
.and((row) => row.wardrobeOutfitId === outfitId)
.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[]);
}

View file

@ -0,0 +1,128 @@
/**
* Garments store mutation-only service.
*
* Reads happen via `queries.ts`; this module owns the write path so
* encryption + domain events stay in one place. The Dexie creating-hook
* stamps `spaceId`, `authorId`, `visibility` automatically wardrobe
* is NOT in USER_LEVEL_TABLES.
*/
import { encryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { wardrobeGarmentsTable } from '../collections';
import { toGarment } from '../types';
import type { Garment, GarmentCategory, LocalWardrobeGarment } from '../types';
export interface CreateGarmentInput {
name: string;
category: GarmentCategory;
mediaIds: string[];
brand?: string | null;
color?: string | null;
size?: string | null;
material?: string | null;
tags?: string[];
notes?: string | null;
purchasedAt?: string | null;
priceCents?: number | null;
currency?: string | null;
}
export const wardrobeGarmentsStore = {
async createGarment(input: CreateGarmentInput): Promise<Garment> {
if (input.mediaIds.length === 0) {
throw new Error('Garment needs at least one photo');
}
const newLocal: LocalWardrobeGarment = {
id: crypto.randomUUID(),
name: input.name,
category: input.category,
mediaIds: input.mediaIds,
brand: input.brand ?? null,
color: input.color ?? null,
size: input.size ?? null,
material: input.material ?? null,
tags: input.tags ?? [],
notes: input.notes ?? null,
purchasedAt: input.purchasedAt ?? null,
priceCents: input.priceCents ?? null,
currency: input.currency ?? null,
wearCount: 0,
lastWornAt: null,
};
const snapshot = toGarment({ ...newLocal });
await encryptRecord('wardrobeGarments', newLocal);
await wardrobeGarmentsTable.add(newLocal);
emitDomainEvent('WardrobeGarmentAdded', 'wardrobe', 'wardrobeGarments', newLocal.id, {
garmentId: newLocal.id,
category: input.category,
});
return snapshot;
},
async updateGarment(
id: string,
patch: Partial<
Pick<
LocalWardrobeGarment,
| 'name'
| 'category'
| 'mediaIds'
| 'brand'
| 'color'
| 'size'
| 'material'
| 'tags'
| 'notes'
| 'purchasedAt'
| 'priceCents'
| 'currency'
>
>
): Promise<void> {
const wrapped = { ...patch } as Record<string, unknown>;
await encryptRecord('wardrobeGarments', wrapped);
await wardrobeGarmentsTable.update(id, {
...wrapped,
updatedAt: new Date().toISOString(),
});
},
/**
* Mark a garment as worn today. Bumps the wear count + stamps
* `lastWornAt`. The UI surfaces this as a one-tap button in the
* detail view; M7 adds it to the card too.
*/
async markWornToday(id: string): Promise<void> {
const existing = await wardrobeGarmentsTable.get(id);
if (!existing) return;
const today = new Date().toISOString().slice(0, 10);
await wardrobeGarmentsTable.update(id, {
wearCount: (existing.wearCount ?? 0) + 1,
lastWornAt: today,
updatedAt: new Date().toISOString(),
});
emitDomainEvent('WardrobeGarmentWorn', 'wardrobe', 'wardrobeGarments', id, {
garmentId: id,
wearCount: (existing.wearCount ?? 0) + 1,
});
},
async archiveGarment(id: string, archived: boolean): Promise<void> {
await wardrobeGarmentsTable.update(id, {
isArchived: archived,
updatedAt: new Date().toISOString(),
});
},
async deleteGarment(id: string): Promise<void> {
const nowIso = new Date().toISOString();
await wardrobeGarmentsTable.update(id, {
deletedAt: nowIso,
updatedAt: nowIso,
});
emitDomainEvent('WardrobeGarmentDeleted', 'wardrobe', 'wardrobeGarments', id, {
garmentId: id,
});
},
};

View file

@ -0,0 +1,125 @@
/**
* Outfits store mutation-only service.
*
* Outfits reference garments by id (plaintext array on the row). Try-On
* results are stored in `picture.images` with `wardrobeOutfitId` back-
* reference the `lastTryOn` snapshot here is just a convenience pointer
* so the outfit card can render the latest preview without a join query.
*/
import { encryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { wardrobeOutfitsTable } from '../collections';
import { toOutfit } from '../types';
import type {
LocalWardrobeOutfit,
Outfit,
OutfitOccasion,
OutfitSeason,
OutfitTryOn,
} from '../types';
export interface CreateOutfitInput {
name: string;
garmentIds: string[];
description?: string | null;
occasion?: OutfitOccasion | null;
season?: OutfitSeason[];
tags?: string[];
isFavorite?: boolean;
}
export const wardrobeOutfitsStore = {
async createOutfit(input: CreateOutfitInput): Promise<Outfit> {
if (input.garmentIds.length === 0) {
throw new Error('Outfit needs at least one garment');
}
const newLocal: LocalWardrobeOutfit = {
id: crypto.randomUUID(),
name: input.name,
description: input.description ?? null,
garmentIds: input.garmentIds,
occasion: input.occasion ?? null,
season: input.season,
tags: input.tags ?? [],
isFavorite: input.isFavorite ?? false,
};
const snapshot = toOutfit({ ...newLocal });
await encryptRecord('wardrobeOutfits', newLocal);
await wardrobeOutfitsTable.add(newLocal);
emitDomainEvent('WardrobeOutfitCreated', 'wardrobe', 'wardrobeOutfits', newLocal.id, {
outfitId: newLocal.id,
garmentCount: input.garmentIds.length,
});
return snapshot;
},
async updateOutfit(
id: string,
patch: Partial<
Pick<
LocalWardrobeOutfit,
'name' | 'description' | 'garmentIds' | 'occasion' | 'season' | 'tags'
>
>
): Promise<void> {
const wrapped = { ...patch } as Record<string, unknown>;
await encryptRecord('wardrobeOutfits', wrapped);
await wardrobeOutfitsTable.update(id, {
...wrapped,
updatedAt: new Date().toISOString(),
});
},
async toggleFavorite(id: string): Promise<void> {
const existing = await wardrobeOutfitsTable.get(id);
if (!existing) return;
await wardrobeOutfitsTable.update(id, {
isFavorite: !existing.isFavorite,
updatedAt: new Date().toISOString(),
});
},
async markWornToday(id: string): Promise<void> {
const today = new Date().toISOString().slice(0, 10);
await wardrobeOutfitsTable.update(id, {
lastWornAt: today,
updatedAt: new Date().toISOString(),
});
},
/**
* Pinning the most recent try-on. The `imageId` points at a
* `picture.images` row written by the M4 runTryOn helper; this
* method is called right after that row lands so the outfit card
* can surface the latest preview.
*/
async setLastTryOn(id: string, tryOn: OutfitTryOn): Promise<void> {
await wardrobeOutfitsTable.update(id, {
lastTryOn: tryOn,
updatedAt: new Date().toISOString(),
});
emitDomainEvent('WardrobeOutfitTryOn', 'wardrobe', 'wardrobeOutfits', id, {
outfitId: id,
imageId: tryOn.imageId,
});
},
async archiveOutfit(id: string, archived: boolean): Promise<void> {
await wardrobeOutfitsTable.update(id, {
isArchived: archived,
updatedAt: new Date().toISOString(),
});
},
async deleteOutfit(id: string): Promise<void> {
const nowIso = new Date().toISOString();
await wardrobeOutfitsTable.update(id, {
deletedAt: nowIso,
updatedAt: nowIso,
});
emitDomainEvent('WardrobeOutfitDeleted', 'wardrobe', 'wardrobeOutfits', id, {
outfitId: id,
});
},
};

View file

@ -0,0 +1,203 @@
/**
* Wardrobe module types two tables:
*
* - `wardrobeGarments`: individual clothing items / accessories, space-
* scoped via the standard Spaces stamping. Brand spaces hold Merch,
* clubs hold Trikots, families hold kid + parent wardrobes, etc.
* - `wardrobeOutfits`: named compositions of garment refs. A try-on
* snapshot points at a picture.images row (the generated image is
* just another entry in the Picture module's gallery).
*
* Try-on results themselves live in `picture.images` with an additional
* `wardrobeOutfitId` back-reference see apps/mana/apps/web/src/lib/
* modules/picture/types.ts. No third table in this module.
*
* Plan: docs/plans/wardrobe-module.md.
*/
import type { BaseRecord } from '@mana/local-store';
// ─── Garment ──────────────────────────────────────────────────────
/**
* Closed enum of clothing/accessory categories. Drives the category
* filter tabs in the UI and the try-on preset (`accessory`, `glasses`,
* `jewelry`, `hat` go face-ref only the others use face + fullbody).
*/
export type GarmentCategory =
| 'top' // Hemd, T-Shirt, Bluse, Pullover
| 'bottom' // Hose, Rock, Shorts
| 'dress' // Kleid, Anzug-Einteiler
| 'outerwear' // Jacke, Mantel
| 'shoes'
| 'accessory' // Schal, Gürtel, Tuch
| 'glasses'
| 'jewelry'
| 'hat'
| 'bag'
| 'other';
/**
* Accessory categories that skip the fullbody reference in try-on.
* `accessoryOnly=true` in the M4 runTryOn helper flips to face-only
* and a square prompt preset.
*/
export const FACE_ONLY_CATEGORIES: ReadonlySet<GarmentCategory> = new Set([
'glasses',
'jewelry',
'hat',
'accessory',
]);
export interface LocalWardrobeGarment extends BaseRecord {
id: string;
name: string;
category: GarmentCategory;
/**
* mana-media ids, at least one. `mediaIds[0]` is the primary photo
* used by try-on and tile thumbnails; additional ids are alternate
* views (back, detail) rendered on the detail page in M7.
*/
mediaIds: string[];
brand?: string | null;
color?: string | null; // freeform — "navy", "hellgrau", "#2a4d6e"
size?: string | null; // freeform — "M", "42", "US 10"
material?: string | null;
tags: string[];
notes?: string | null;
purchasedAt?: string | null; // ISO date (YYYY-MM-DD)
priceCents?: number | null;
currency?: string | null; // ISO 4217
isArchived?: boolean;
/** Incremented by the "heute getragen"-Button; null if never tracked. */
wearCount?: number;
lastWornAt?: string | null;
}
export interface Garment {
id: string;
name: string;
category: GarmentCategory;
mediaIds: string[];
brand?: string;
color?: string;
size?: string;
material?: string;
tags: string[];
notes?: string;
purchasedAt?: string;
priceCents?: number;
currency?: string;
isArchived?: boolean;
wearCount?: number;
lastWornAt?: string;
createdAt: string;
updatedAt: string;
}
export function toGarment(local: LocalWardrobeGarment): Garment {
return {
id: local.id,
name: local.name,
category: local.category,
mediaIds: local.mediaIds ?? [],
brand: local.brand ?? undefined,
color: local.color ?? undefined,
size: local.size ?? undefined,
material: local.material ?? undefined,
tags: local.tags ?? [],
notes: local.notes ?? undefined,
purchasedAt: local.purchasedAt ?? undefined,
priceCents: local.priceCents ?? undefined,
currency: local.currency ?? undefined,
isArchived: local.isArchived ?? undefined,
wearCount: local.wearCount ?? undefined,
lastWornAt: local.lastWornAt ?? undefined,
createdAt: local.createdAt ?? '',
updatedAt: local.updatedAt ?? '',
};
}
/** Primary photo of a garment; `null` if the row somehow has no ids. */
export function garmentPrimaryMediaId(garment: Pick<Garment, 'mediaIds'>): string | null {
return garment.mediaIds[0] ?? null;
}
// ─── Outfit ───────────────────────────────────────────────────────
/**
* Snapshot of the most recent try-on for an outfit. The full history
* lives in `picture.images` filtered by `wardrobeOutfitId === outfit.id`
* this pointer exists so the outfit detail view can render the latest
* preview without re-querying.
*/
export interface OutfitTryOn {
imageId: string; // points at picture.images.id
createdAt: string; // ISO
prompt: string;
model: string;
}
/** Closed enum of occasions the outfit is appropriate for. Freeform
* remains possible via tags; the enum keeps the primary filter small. */
export type OutfitOccasion =
| 'casual'
| 'work'
| 'formal'
| 'workout'
| 'date'
| 'travel'
| 'event'
| 'sleep'
| 'other';
export type OutfitSeason = 'spring' | 'summer' | 'autumn' | 'winter';
export interface LocalWardrobeOutfit extends BaseRecord {
id: string;
name: string;
description?: string | null;
/** References into `wardrobeGarments`. Must be in the same space. */
garmentIds: string[];
occasion?: OutfitOccasion | null;
season?: OutfitSeason[];
tags: string[];
isFavorite?: boolean;
isArchived?: boolean;
lastTryOn?: OutfitTryOn | null;
lastWornAt?: string | null;
}
export interface Outfit {
id: string;
name: string;
description?: string;
garmentIds: string[];
occasion?: OutfitOccasion;
season?: OutfitSeason[];
tags: string[];
isFavorite?: boolean;
isArchived?: boolean;
lastTryOn?: OutfitTryOn;
lastWornAt?: string;
createdAt: string;
updatedAt: string;
}
export function toOutfit(local: LocalWardrobeOutfit): Outfit {
return {
id: local.id,
name: local.name,
description: local.description ?? undefined,
garmentIds: local.garmentIds ?? [],
occasion: local.occasion ?? undefined,
season: local.season,
tags: local.tags ?? [],
isFavorite: local.isFavorite,
isArchived: local.isArchived,
lastTryOn: local.lastTryOn ?? undefined,
lastWornAt: local.lastWornAt ?? undefined,
createdAt: local.createdAt ?? '',
updatedAt: local.updatedAt ?? '',
};
}

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { sitesStore } from '../stores/sites.svelte';
import { PublishError } from '../publish';
import RollbackDialog from './RollbackDialog.svelte';
import type { Website } from '../types';
interface Props {
@ -12,6 +13,7 @@
let publishing = $state(false);
let unpublishing = $state(false);
let lastError = $state<string | null>(null);
let showHistory = $state(false);
const hasDraftAhead = $derived.by(() => {
if (!site.publishedVersion) return site.draftUpdatedAt !== null;
@ -77,6 +79,13 @@
<div class="wb-publishbar__actions">
{#if site.publishedVersion}
<button
class="wb-btn wb-btn--ghost"
onclick={() => (showHistory = true)}
title="Versionen einsehen / wiederherstellen"
>
Versionen
</button>
<button
class="wb-btn wb-btn--ghost"
onclick={onUnpublish}
@ -103,6 +112,10 @@
{/if}
</div>
{#if showHistory}
<RollbackDialog siteId={site.id} onClose={() => (showHistory = false)} />
{/if}
<style>
.wb-publishbar {
display: flex;

View file

@ -0,0 +1,256 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { sitesStore } from '../stores/sites.svelte';
import { fetchSnapshotHistory, PublishError, type SnapshotHistoryEntry } from '../publish';
interface Props {
siteId: string;
onClose: () => void;
}
let { siteId, onClose }: Props = $props();
let entries = $state<SnapshotHistoryEntry[] | null>(null);
let loadError = $state<string | null>(null);
let loading = $state(false);
let rollingBackId = $state<string | null>(null);
let actionError = $state<string | null>(null);
async function load() {
loading = true;
loadError = null;
try {
const token = await authStore.getValidToken();
if (!token) throw new Error('Nicht angemeldet');
entries = await fetchSnapshotHistory(siteId, token);
} catch (err) {
loadError =
err instanceof PublishError
? err.message
: err instanceof Error
? err.message
: String(err);
} finally {
loading = false;
}
}
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
siteId;
void load();
});
async function onRollback(snapshotId: string) {
if (!confirm('Diese Version als aktuell veröffentlicht setzen?')) return;
rollingBackId = snapshotId;
actionError = null;
try {
await sitesStore.rollback(siteId, snapshotId);
await load();
} catch (err) {
actionError =
err instanceof PublishError
? err.message
: err instanceof Error
? err.message
: String(err);
} finally {
rollingBackId = null;
}
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString('de-DE');
}
</script>
<div
class="wb-modal__backdrop"
onclick={onClose}
onkeydown={(e) => e.key === 'Escape' && onClose()}
role="button"
tabindex="-1"
aria-label="Schließen"
></div>
<div class="wb-modal" role="dialog" aria-modal="true" aria-labelledby="wb-rollback-title">
<header class="wb-modal__head">
<div>
<h3 id="wb-rollback-title">Version-History</h3>
<p>Wähle eine ältere veröffentlichte Version, um sie wieder live zu stellen.</p>
</div>
<button class="wb-modal__close" onclick={onClose} aria-label="Schließen">×</button>
</header>
<div class="wb-modal__body">
{#if loadError}
<p class="wb-error">{loadError}</p>
{:else if entries === null}
<p class="wb-empty">Lade…</p>
{:else if entries.length === 0}
<p class="wb-empty">Noch keine veröffentlichten Versionen.</p>
{:else}
{#if actionError}
<p class="wb-error">{actionError}</p>
{/if}
<ul class="wb-list">
{#each entries as entry (entry.id)}
<li class="wb-row" class:wb-row--current={entry.isCurrent}>
<div class="wb-row__meta">
<span class="wb-row__time">{formatDate(entry.publishedAt)}</span>
<span class="wb-row__id">{entry.id.slice(0, 8)}</span>
</div>
<div class="wb-row__actions">
{#if entry.isCurrent}
<span class="wb-pill wb-pill--current">Aktuell live</span>
{:else}
<button
class="wb-btn wb-btn--primary"
onclick={() => onRollback(entry.id)}
disabled={rollingBackId === entry.id || loading}
>
{rollingBackId === entry.id ? 'Stelle wieder her…' : 'Wiederherstellen'}
</button>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<style>
.wb-modal__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 40;
border: none;
}
.wb-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(92vw, 32rem);
max-height: 80vh;
background: rgb(15, 18, 24);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.75rem;
z-index: 50;
display: flex;
flex-direction: column;
overflow: hidden;
}
.wb-modal__head {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.wb-modal__head h3 {
margin: 0;
font-size: 1rem;
}
.wb-modal__head p {
margin: 0.2rem 0 0;
font-size: 0.8125rem;
opacity: 0.6;
}
.wb-modal__close {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
color: inherit;
padding: 0.1rem 0.5rem;
font-size: 1.1rem;
border-radius: 0.375rem;
cursor: pointer;
}
.wb-modal__body {
padding: 1rem 1.25rem 1.25rem;
overflow-y: auto;
}
.wb-empty {
margin: 1rem 0;
text-align: center;
opacity: 0.5;
font-style: italic;
font-size: 0.875rem;
}
.wb-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.wb-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.65rem 0.85rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 0.5rem;
}
.wb-row--current {
background: rgba(16, 185, 129, 0.06);
border-color: rgba(16, 185, 129, 0.2);
}
.wb-row__meta {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.wb-row__time {
font-size: 0.875rem;
}
.wb-row__id {
font-size: 0.7rem;
opacity: 0.5;
font-family: ui-monospace, monospace;
}
.wb-row__actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.wb-btn {
padding: 0.375rem 0.75rem;
border: none;
border-radius: 0.375rem;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
}
.wb-btn--primary {
background: rgba(99, 102, 241, 0.9);
color: white;
}
.wb-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.wb-pill {
font-size: 0.7rem;
padding: 0.12rem 0.5rem;
border-radius: 9999px;
}
.wb-pill--current {
background: rgba(16, 185, 129, 0.2);
color: rgb(110, 231, 183);
}
.wb-error {
margin: 0 0 0.75rem;
padding: 0.5rem 0.75rem;
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.3);
border-radius: 0.375rem;
color: rgb(248, 113, 113);
font-size: 0.8125rem;
}
</style>

View file

@ -0,0 +1,159 @@
/**
* Snapshot builder determinism test.
*
* Exit-criterion from docs/plans/website-builder.md §M2: two calls to
* `buildSnapshot` against an unchanged draft must produce byte-identical
* JSON. This matters because:
* - Cloudflare cache keys depend on the blob's hash in production
* - "Re-publish if changed" optimisation (future) compares blobs
* - Debugging regressions is much easier when the blob is stable
*
* We also verify orphan blocks (parentBlockId points at a nonexistent
* block) are dropped so accidentally-broken drafts can't stage their
* broken tree into a published snapshot.
*/
import 'fake-indexeddb/auto';
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mirror the Dexie-hook side-effect stubs from sync.test.ts so loading
// database.ts doesn't pull in unrelated runtime modules.
vi.mock('$lib/stores/funnel-tracking', () => ({
trackFirstContent: vi.fn(),
}));
vi.mock('$lib/triggers/registry', () => ({
fire: vi.fn(),
}));
vi.mock('$lib/triggers/inline-suggest', () => ({
checkInlineSuggestion: vi.fn().mockResolvedValue(null),
}));
// Embed resolvers touch live data modules (picture/library). Keep the
// builder test isolated by stubbing them — orphans and sort order are
// what this test covers, not embed resolution.
vi.mock('./embeds', () => ({
resolveEmbed: vi.fn(async () => ({
items: [],
resolvedAt: '2026-04-23T00:00:00.000Z',
})),
}));
import { websitesTable, websitePagesTable, websiteBlocksTable } from './collections';
import { buildSnapshot, buildBlockTree } from './publish';
import type { LocalWebsite, LocalWebsitePage, LocalWebsiteBlock } from './types';
const SITE_ID = '11111111-1111-1111-1111-111111111111';
const PAGE_ID = '22222222-2222-2222-2222-222222222222';
function localSite(): LocalWebsite {
return {
id: SITE_ID,
slug: 'test-site',
name: 'Test Site',
theme: { preset: 'classic' },
navConfig: { items: [] },
footerConfig: { text: '', links: [] },
settings: {},
publishedVersion: null,
draftUpdatedAt: '2026-04-23T00:00:00.000Z',
createdAt: '2026-04-23T00:00:00.000Z',
updatedAt: '2026-04-23T00:00:00.000Z',
};
}
function localPage(overrides: Partial<LocalWebsitePage> = {}): LocalWebsitePage {
return {
id: PAGE_ID,
siteId: SITE_ID,
path: '/',
title: 'Start',
seo: {},
order: 1024,
createdAt: '2026-04-23T00:00:00.000Z',
updatedAt: '2026-04-23T00:00:00.000Z',
...overrides,
};
}
function localBlock(
id: string,
order: number,
overrides: Partial<LocalWebsiteBlock> = {}
): LocalWebsiteBlock {
return {
id,
pageId: PAGE_ID,
parentBlockId: null,
slotKey: null,
type: 'richText',
props: { content: `Block ${id}`, align: 'left', size: 'md' },
schemaVersion: 1,
order,
createdAt: '2026-04-23T00:00:00.000Z',
updatedAt: '2026-04-23T00:00:00.000Z',
...overrides,
};
}
describe('buildSnapshot — determinism', () => {
beforeEach(async () => {
await websitesTable.clear();
await websitePagesTable.clear();
await websiteBlocksTable.clear();
});
it('two calls against the same draft produce byte-identical JSON', async () => {
await websitesTable.add(localSite());
await websitePagesTable.add(localPage());
await websiteBlocksTable.bulkAdd([
localBlock('b-gamma', 3072),
localBlock('b-alpha', 1024),
localBlock('b-beta', 2048),
]);
const first = await buildSnapshot(SITE_ID);
const second = await buildSnapshot(SITE_ID);
expect(JSON.stringify(first)).toBe(JSON.stringify(second));
});
it('sorts blocks with equal order by id (stable tiebreaker)', async () => {
await websitesTable.add(localSite());
await websitePagesTable.add(localPage());
await websiteBlocksTable.bulkAdd([
localBlock('b-zzzzz', 1024),
localBlock('b-aaaaa', 1024),
localBlock('b-mmmmm', 1024),
]);
const snap = await buildSnapshot(SITE_ID);
const ids = snap.pages[0]!.blocks.map((b) => b.id);
expect(ids).toEqual(['b-aaaaa', 'b-mmmmm', 'b-zzzzz']);
});
});
describe('buildBlockTree — orphan handling', () => {
it('drops blocks whose parentBlockId does not resolve', () => {
const blocks: LocalWebsiteBlock[] = [
localBlock('b-root', 1024),
localBlock('b-orphan', 2048, { parentBlockId: 'b-does-not-exist' }),
localBlock('b-child', 3072, { parentBlockId: 'b-root' }),
];
const tree = buildBlockTree(blocks);
// Top-level contains only b-root.
expect(tree.map((t) => t.id)).toEqual(['b-root']);
// b-root has exactly one child (b-child) — the orphan is dropped.
expect(tree[0]!.children.map((c) => c.id)).toEqual(['b-child']);
});
it('preserves nested children ordered by (order, id)', () => {
const blocks: LocalWebsiteBlock[] = [
localBlock('parent', 1024),
localBlock('child-c', 2048, { parentBlockId: 'parent' }),
localBlock('child-a', 1024, { parentBlockId: 'parent' }),
localBlock('child-b', 1024, { parentBlockId: 'parent' }),
];
const tree = buildBlockTree(blocks);
expect(tree[0]!.children.map((c) => c.id)).toEqual(['child-a', 'child-b', 'child-c']);
});
});