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
+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();
}