feat(sammler): mehrere sammler mit eigener sammlung und wunschliste

Der Katalog bleibt gemeinsam - ein Set existiert einmal, unabhaengig
davon, wer es hat. Besitz und Wunschlisten gehoeren dagegen je einem
Sammler. Damit beantwortet "hat er das schon?" jetzt die Frage "hat *er*
das schon?": ein Set, das nur die Schwester besitzt, faellt fuer den
Bruder wieder unter "fehlt noch" und darf auf seine Wunschliste.

Wichtig fuer den Alltag: Solange es nur einen Sammler gibt, taucht die
Unterscheidung nirgends in der Oberflaeche auf. Kein Umschalter, keine
Abfrage beim Hinzufuegen, keine Badges - die App bleibt exakt so
einfach wie vorher. Erst der zweite Sammler schaltet all das frei.

Die Besitzlogik liegt weiterhin an genau einer Stelle
(src/lib/ownership.ts) und hat nur eine Dimension dazubekommen. Das
automatische "erfuellt" auf Wunschlisten fragt jetzt ebenfalls je Liste:
ein Wunsch auf Max' Liste ist erfuellt, wenn Max das Set bekommt - nicht
wenn Mia es hat. Listen ohne Sammler verhalten sich wie bisher.

Die Migration legt einen Sammler "Sammlung" an und ordnet ihm alles
Vorhandene zu; umbenennen laesst er sich unter Verwaltung -> Sammler.
Der Seed uebernimmt ihn statt einen dritten anzulegen.

Refs Phase 3

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
unknown
2026-09-07 09:00:35 +02:00
co-authored by Claude Opus 5
parent 7265c03548
commit c23b754515
35 changed files with 3104 additions and 66 deletions
+40
View File
@@ -0,0 +1,40 @@
/**
* The small colour palette that tells collectors apart.
*
* Fixed classes rather than computed ones, because Tailwind only ships the
* classes it can see in the source.
*/
export const COLLECTOR_COLOR_LABELS: Record<string, string> = {
blue: 'Blau',
green: 'Grün',
amber: 'Gelb',
rose: 'Rot',
violet: 'Violett',
slate: 'Grau',
};
const BADGE_CLASSES: Record<string, string> = {
blue: 'bg-blue-100 text-blue-900 dark:bg-blue-950 dark:text-blue-200',
green: 'bg-emerald-100 text-emerald-900 dark:bg-emerald-950 dark:text-emerald-200',
amber: 'bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-200',
rose: 'bg-rose-100 text-rose-900 dark:bg-rose-950 dark:text-rose-200',
violet: 'bg-violet-100 text-violet-900 dark:bg-violet-950 dark:text-violet-200',
slate: 'bg-slate-200 text-slate-900 dark:bg-slate-800 dark:text-slate-100',
};
export function collectorBadgeClass(color: string | null | undefined): string {
return BADGE_CLASSES[color ?? 'blue'] ?? BADGE_CLASSES.blue!;
}
const DOT_CLASSES: Record<string, string> = {
blue: 'bg-blue-500',
green: 'bg-emerald-500',
amber: 'bg-amber-500',
rose: 'bg-rose-500',
violet: 'bg-violet-500',
slate: 'bg-slate-500',
};
export function collectorDotClass(color: string | null | undefined): string {
return DOT_CLASSES[color ?? 'blue'] ?? DOT_CLASSES.blue!;
}
+65 -8
View File
@@ -8,8 +8,9 @@ import type { CollectionStatus } from '@/db/types';
* status is anything but `sold`. A sold copy is history, not ownership - so a
* set that was sold shows up under "fehlt" again and can go back on a wishlist.
*
* Every query that asks about ownership goes through this module. If the rule
* ever changes, it changes here.
* With more than one collector the question gains a "for whom": the catalog is
* shared, ownership is not. Every query that asks about ownership goes through
* this module, optionally scoped to one collector.
*/
export const OWNED_STATUSES = ['owned', 'built', 'disassembled', 'lent_out'] as const;
@@ -18,6 +19,12 @@ export const SOLD_STATUS: CollectionStatus = 'sold';
export type OwnedStatus = (typeof OWNED_STATUSES)[number];
/** One owned copy, as far as ownership is concerned. */
export type OwnableCopy = {
status: CollectionStatus;
collectorId?: string | null;
};
/** Does a single collection item count towards ownership? */
export function isOwningStatus(status: CollectionStatus): status is OwnedStatus {
return status !== SOLD_STATUS;
@@ -37,22 +44,72 @@ export function countOwnedCopies(statuses: readonly CollectionStatus[]): number
}
/**
* SQL counterpart of `countOwnedCopies`, as a correlated subquery.
* Ownership from the point of view of one collector.
*
* Without a collector the question is "does anyone here have it", which is
* what a single-collector household asks anyway.
*/
export function countOwnedCopiesFor(
copies: readonly OwnableCopy[],
collectorId?: string | null,
): number {
return copies.filter(
(copy) =>
isOwningStatus(copy.status) &&
(collectorId === undefined || collectorId === null || copy.collectorId === collectorId),
).length;
}
export function isSetOwnedBy(
copies: readonly OwnableCopy[],
collectorId?: string | null,
): boolean {
return countOwnedCopiesFor(copies, collectorId) > 0;
}
/**
* SQL counterpart of `countOwnedCopiesFor`, as a correlated subquery.
*
* A subquery instead of a join, because a set owned twice must not appear
* twice in the catalog list.
*/
export function ownedCopiesSql(setIdColumn: SQL | unknown): SQL<number> {
export function ownedCopiesSql(
setIdColumn: SQL | unknown,
collectorId?: string | null,
): SQL<number> {
if (!collectorId) {
return sql<number>`(
select count(*) from collection_item ci
where ci.set_id = ${setIdColumn} and ci.status <> ${SOLD_STATUS}
)`;
}
return sql<number>`(
select count(*) from collection_item ci
where ci.set_id = ${setIdColumn} and ci.status <> ${SOLD_STATUS}
where ci.set_id = ${setIdColumn}
and ci.status <> ${SOLD_STATUS}
and ci.collector_id = ${collectorId}
)`;
}
/** How many wishlists this set is currently on. */
export function wishlistCountSql(setIdColumn: SQL | unknown): SQL<number> {
/**
* How many wishlists this set is on. Scoped to a collector where one is
* selected - a set on the other child's list is not "already wished for" here.
*/
export function wishlistCountSql(
setIdColumn: SQL | unknown,
collectorId?: string | null,
): SQL<number> {
if (!collectorId) {
return sql<number>`(
select count(*) from wishlist_item wi where wi.set_id = ${setIdColumn}
)`;
}
return sql<number>`(
select count(*) from wishlist_item wi where wi.set_id = ${setIdColumn}
select count(*) from wishlist_item wi
join wishlist w on w.id = wi.wishlist_id
where wi.set_id = ${setIdColumn} and w.collector_id = ${collectorId}
)`;
}
+9 -7
View File
@@ -14,6 +14,8 @@ export type CatalogFilters = {
ownership?: OwnershipFilter;
/** Hides sets that are already on this wishlist (for "aus fehlenden Sets hinzufügen"). */
excludeWishlistId?: string;
/** Scopes "owned" and "on a wishlist" to one collector. */
collectorId?: string;
page?: number;
};
@@ -52,7 +54,7 @@ function buildWhere(filters: CatalogFilters): SQL | undefined {
if (filters.themeId) conditions.push(eq(set.themeId, filters.themeId));
if (filters.year !== undefined) conditions.push(eq(set.year, filters.year));
const owned = ownedCopiesSql(set.id);
const owned = ownedCopiesSql(set.id, filters.collectorId);
switch (filters.ownership) {
case 'sammlung':
conditions.push(sql`${owned} > 0`);
@@ -61,7 +63,7 @@ function buildWhere(filters: CatalogFilters): SQL | undefined {
conditions.push(sql`${owned} = 0`);
break;
case 'wunschliste':
conditions.push(sql`${wishlistCountSql(set.id)} > 0`);
conditions.push(sql`${wishlistCountSql(set.id, filters.collectorId)} > 0`);
break;
default:
break;
@@ -95,8 +97,8 @@ export function listSets(filters: CatalogFilters): { rows: CatalogRow[]; total:
retailPriceCents: set.retailPriceCents,
manufacturerName: manufacturer.name,
themeName: theme.name,
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
wishlistCount: wishlistCountSql(set.id).mapWith(Number),
ownedCopies: ownedCopiesSql(set.id, filters.collectorId).mapWith(Number),
wishlistCount: wishlistCountSql(set.id, filters.collectorId).mapWith(Number),
})
.from(set)
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
@@ -126,7 +128,7 @@ export function listSetIds(filters: CatalogFilters): string[] {
.map((row) => row.id);
}
export function getSetById(id: string) {
export function getSetById(id: string, collectorId?: string) {
const row = db
.select({
id: set.id,
@@ -148,8 +150,8 @@ export function getSetById(id: string) {
notes: set.notes,
createdAt: set.createdAt,
updatedAt: set.updatedAt,
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
wishlistCount: wishlistCountSql(set.id).mapWith(Number),
ownedCopies: ownedCopiesSql(set.id, collectorId).mapWith(Number),
wishlistCount: wishlistCountSql(set.id, collectorId).mapWith(Number),
})
.from(set)
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
+28 -11
View File
@@ -1,8 +1,16 @@
import 'server-only';
import { and, asc, countDistinct, eq, inArray, sql, type SQL } from 'drizzle-orm';
import { db } from '@/db';
import { collectionItem, location, manufacturer, missingPart, set, theme } from '@/db/schema';
import { SOLD_STATUS } from '@/lib/ownership';
import {
collectionItem,
collector,
location,
manufacturer,
missingPart,
set,
theme,
} from '@/db/schema';
import { ownedCopiesSql, SOLD_STATUS } from '@/lib/ownership';
import type { CollectionStatus, Completeness, Condition } from '@/db/types';
export type CollectionFilters = {
@@ -11,6 +19,7 @@ export type CollectionFilters = {
completeness?: Completeness;
locationId?: string;
manufacturerId?: string;
collectorId?: string;
};
export type CollectionRow = {
@@ -21,6 +30,9 @@ export type CollectionRow = {
condition: Condition | null;
locationId: string | null;
locationName: string | null;
collectorId: string | null;
collectorName: string | null;
collectorColor: string | null;
acquiredAt: Date | null;
acquiredPriceCents: number | null;
acquiredFrom: string | null;
@@ -51,6 +63,7 @@ function buildWhere(filters: CollectionFilters): SQL | undefined {
if (filters.completeness) conditions.push(eq(collectionItem.completeness, filters.completeness));
if (filters.locationId) conditions.push(eq(collectionItem.locationId, filters.locationId));
if (filters.manufacturerId) conditions.push(eq(set.manufacturerId, filters.manufacturerId));
if (filters.collectorId) conditions.push(eq(collectionItem.collectorId, filters.collectorId));
return conditions.length > 0 ? and(...conditions) : undefined;
}
@@ -65,6 +78,9 @@ export function listCollectionItems(filters: CollectionFilters): CollectionRow[]
condition: collectionItem.condition,
locationId: collectionItem.locationId,
locationName: location.name,
collectorId: collectionItem.collectorId,
collectorName: collector.name,
collectorColor: collector.color,
acquiredAt: collectionItem.acquiredAt,
acquiredPriceCents: collectionItem.acquiredPriceCents,
acquiredFrom: collectionItem.acquiredFrom,
@@ -86,6 +102,7 @@ export function listCollectionItems(filters: CollectionFilters): CollectionRow[]
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
.leftJoin(theme, eq(theme.id, set.themeId))
.leftJoin(location, eq(location.id, collectionItem.locationId))
.leftJoin(collector, eq(collector.id, collectionItem.collectorId))
.where(buildWhere(filters))
.orderBy(asc(manufacturer.name), asc(set.setNumber))
.all();
@@ -104,8 +121,13 @@ export type CollectionStats = {
* The numbers on top of the collection page. Sold copies are history and do
* not count anywhere - same rule as everywhere else in the app.
*/
export function getCollectionStats(): CollectionStats {
const active = sql`${collectionItem.status} <> ${SOLD_STATUS}`;
export function getCollectionStats(collectorId?: string): CollectionStats {
const active = collectorId
? and(
sql`${collectionItem.status} <> ${SOLD_STATUS}`,
eq(collectionItem.collectorId, collectorId),
)
: sql`${collectionItem.status} <> ${SOLD_STATUS}`;
const [totals] = db
.select({
@@ -144,16 +166,11 @@ export function getCollectionStats(): CollectionStats {
}
/** Number of catalog sets that are not in the collection - the "fehlt" count. */
export function countMissingSets(): number {
export function countMissingSets(collectorId?: string): number {
const [row] = db
.select({ total: sql<number>`count(*)`.mapWith(Number) })
.from(set)
.where(
sql`not exists (
select 1 from collection_item ci
where ci.set_id = ${set.id} and ci.status <> ${SOLD_STATUS}
)`,
)
.where(sql`${ownedCopiesSql(set.id, collectorId)} = 0`)
.all();
return row?.total ?? 0;
+89
View File
@@ -0,0 +1,89 @@
import 'server-only';
import { asc, eq, sql } from 'drizzle-orm';
import { db } from '@/db';
import { collectionItem, collector } from '@/db/schema';
import { SOLD_STATUS } from '@/lib/ownership';
export type CollectorRow = {
id: string;
name: string;
color: string;
birthday: Date | null;
notes: string | null;
sortOrder: number;
itemCount: number;
};
export function listCollectors(): CollectorRow[] {
const itemCount = sql<number>`(
select count(*) from collection_item ci
where ci.collector_id = ${collector.id} and ci.status <> ${SOLD_STATUS}
)`.mapWith(Number);
return db
.select({
id: collector.id,
name: collector.name,
color: collector.color,
birthday: collector.birthday,
notes: collector.notes,
sortOrder: collector.sortOrder,
itemCount,
})
.from(collector)
.orderBy(asc(collector.sortOrder), asc(collector.name))
.all();
}
export function getCollector(id: string) {
return db.select().from(collector).where(eq(collector.id, id)).get() ?? null;
}
/**
* The collector a new collection item belongs to when the user did not pick
* one - which is every time there is only a single collector.
*/
export function defaultCollectorId(): string | null {
const row = db
.select({ id: collector.id })
.from(collector)
.orderBy(asc(collector.sortOrder), asc(collector.name))
.get();
return row?.id ?? null;
}
/**
* The whole multi-collector idea only becomes visible once a second collector
* exists. Until then the UI stays exactly as simple as it was.
*/
export function hasMultipleCollectors(): boolean {
const [row] = db
.select({ total: sql<number>`count(*)`.mapWith(Number) })
.from(collector)
.all();
return (row?.total ?? 0) > 1;
}
/** Reads the collector from a URL parameter, ignoring anything unknown. */
export function resolveCollectorId(value: string | undefined | null): string | undefined {
if (!value) return undefined;
const row = db.select({ id: collector.id }).from(collector).where(eq(collector.id, value)).get();
return row?.id;
}
/** Owned copies per collector - used by the collector management page. */
export function countItemsPerCollector(): Map<string, number> {
const rows = db
.select({
collectorId: collectionItem.collectorId,
total: sql<number>`count(*)`.mapWith(Number),
})
.from(collectionItem)
.where(sql`${collectionItem.status} <> ${SOLD_STATUS}`)
.groupBy(collectionItem.collectorId)
.all();
return new Map(
rows.flatMap((row) => (row.collectorId ? [[row.collectorId, row.total] as const] : [])),
);
}
+25 -7
View File
@@ -1,8 +1,8 @@
import 'server-only';
import { and, asc, count, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm';
import { db } from '@/db';
import { manufacturer, set, theme, wishlist, wishlistItem } from '@/db/schema';
import { ownedCopiesSql } from '@/lib/ownership';
import { collector, manufacturer, set, theme, wishlist, wishlistItem } from '@/db/schema';
import { ownedCopiesSql, SOLD_STATUS } from '@/lib/ownership';
/** Short list for the "auf welche Wunschliste?" dialogs. */
export function listWishlistTargets() {
@@ -34,12 +34,16 @@ export function listWishlists(ownerUserId: string) {
isPublic: wishlist.isPublic,
surpriseMode: wishlist.surpriseMode,
shareToken: wishlist.shareToken,
collectorId: wishlist.collectorId,
collectorName: collector.name,
collectorColor: collector.color,
createdAt: wishlist.createdAt,
itemCount: count(wishlistItem.id),
openCount: openItems,
})
.from(wishlist)
.leftJoin(wishlistItem, eq(wishlistItem.wishlistId, wishlist.id))
.leftJoin(collector, eq(collector.id, wishlist.collectorId))
.where(eq(wishlist.ownerUserId, ownerUserId))
.groupBy(wishlist.id)
.orderBy(asc(wishlist.title))
@@ -76,7 +80,10 @@ export type WishlistItemRow = {
ownedCopies: number;
};
export function listWishlistItems(wishlistId: string): WishlistItemRow[] {
export function listWishlistItems(
wishlistId: string,
collectorId?: string | null,
): WishlistItemRow[] {
return db
.select({
id: wishlistItem.id,
@@ -97,7 +104,7 @@ export function listWishlistItems(wishlistId: string): WishlistItemRow[] {
manufacturerName: manufacturer.name,
manufacturerWebsite: manufacturer.website,
themeName: theme.name,
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
ownedCopies: ownedCopiesSql(set.id, collectorId).mapWith(Number),
})
.from(wishlistItem)
.innerJoin(set, eq(set.id, wishlistItem.setId))
@@ -145,17 +152,28 @@ export function listWishlistsForSet(setId: string) {
* Marks wishlist entries as fulfilled once the set is in the collection, and
* clears the flag again if the copy was sold. Called after every change to the
* collection, so the lists stay honest without a background job.
*
* With several collectors the question is per list: a wish on Max's list is
* fulfilled when *Max* owns the set, not when his sister does. A list without
* a collector falls back to "anyone here owns it".
*/
export function reconcileFulfilledWishlistItems(): void {
const owned = ownedCopiesSql(wishlistItem.setId);
const ownedForThisList = sql`exists (
select 1
from collection_item ci
join wishlist w on w.id = ${wishlistItem.wishlistId}
where ci.set_id = ${wishlistItem.setId}
and ci.status <> ${SOLD_STATUS}
and (w.collector_id is null or ci.collector_id = w.collector_id)
)`;
db.update(wishlistItem)
.set({ fulfilledAt: new Date() })
.where(and(isNull(wishlistItem.fulfilledAt), sql`${owned} > 0`))
.where(and(isNull(wishlistItem.fulfilledAt), ownedForThisList))
.run();
db.update(wishlistItem)
.set({ fulfilledAt: null })
.where(and(isNotNull(wishlistItem.fulfilledAt), sql`${owned} = 0`))
.where(and(isNotNull(wishlistItem.fulfilledAt), sql`not ${ownedForThisList}`))
.run();
}
+19
View File
@@ -88,6 +88,22 @@ export const locationSchema = z.object({
parentLocationId: optionalId,
});
export const collectorColorEnum = z.enum([
'blue',
'green',
'amber',
'rose',
'violet',
'slate',
]);
export const collectorSchema = z.object({
name: z.string().trim().min(1, 'Bitte einen Namen angeben.').max(60),
color: collectorColorEnum.default('blue'),
birthday: optionalDate,
notes: optionalText(500),
});
// ---------------------------------------------------------------------------
// Catalog
// ---------------------------------------------------------------------------
@@ -127,6 +143,7 @@ export const conditionEnum = z.enum(['new_sealed', 'like_new', 'good', 'used', '
export const collectionItemSchema = z.object({
setId: z.string().trim().min(1),
collectorId: optionalId,
status: collectionStatusEnum.default('owned'),
completeness: completenessEnum.default('unknown'),
condition: z
@@ -171,6 +188,7 @@ export const occasionEnum = z.enum(['christmas', 'birthday', 'easter', 'other'])
export const wishlistSchema = z.object({
title: z.string().trim().min(1, 'Bitte einen Titel angeben.').max(80),
collectorId: optionalId,
occasion: occasionEnum.default('other'),
eventDate: optionalDate,
note: optionalText(1000),
@@ -205,3 +223,4 @@ export type LocationInput = z.infer<typeof locationSchema>;
export type SetInput = z.infer<typeof setSchema>;
export type CollectionItemInput = z.infer<typeof collectionItemSchema>;
export type WishlistInput = z.infer<typeof wishlistSchema>;
export type CollectorInput = z.infer<typeof collectorSchema>;