+
+
+
- Ursprünglich
+ - {formatEuro(trend.baseCents)}
+
+
+
- Aktuell
+ - {formatEuro(trend.currentCents)}
+
+
+
- Entwicklung
+ -
+ {trend.changePercent === null ? (
+ –
+ ) : (
+
+ {gained ? (
+
+ ) : (
+
+ )}
+ {trend.changePercent > 0 ? '+' : ''}
+ {trend.changePercent.toLocaleString('de-DE')} %
+
+ )}
+
+
+
+
+ {prices.length > 0 ? (
+
+ {prices.map((price) => (
+ -
+
+
+ {formatEuro(price.priceCents)}{' '}
+ {CONDITION_LABELS[price.condition]}
+
+
+ {[formatDate(price.observedAt), price.source, price.note]
+ .filter(Boolean)
+ .join(' · ')}
+
+
+
+
+ ))}
+
+ ) : (
+
+ Noch kein Preis notiert. Der Ausgangswert ist die UVP des Sets.
+
+ )}
+
+ {open ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts
index 50acd42..554833b 100644
--- a/src/db/schema/index.ts
+++ b/src/db/schema/index.ts
@@ -1,6 +1,7 @@
export * from './auth';
export * from './catalog';
export * from './collector';
+export * from './prices';
export * from './collection';
export * from './wishlist';
export * from './relations';
diff --git a/src/db/schema/prices.ts b/src/db/schema/prices.ts
new file mode 100644
index 0000000..ae3e224
--- /dev/null
+++ b/src/db/schema/prices.ts
@@ -0,0 +1,35 @@
+import { check, index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
+import { sql } from 'drizzle-orm';
+import { set } from './catalog';
+
+/**
+ * An observed price for a set at a point in time.
+ *
+ * Kept as a series rather than a single field, because the interesting
+ * question is not "what does it cost" but "what has it done since we bought
+ * it". Entries are maintained by hand - the UVP from the set itself is the
+ * starting point, everything after that is what you actually saw somewhere.
+ */
+export const pricePoint = sqliteTable(
+ 'price_point',
+ {
+ id: text('id').primaryKey(),
+ setId: text('set_id')
+ .notNull()
+ .references(() => set.id, { onDelete: 'cascade' }),
+ /** EUR in cents, like every other amount in this app. */
+ priceCents: integer('price_cents').notNull(),
+ condition: text('condition', { enum: ['new', 'used'] })
+ .notNull()
+ .default('new'),
+ /** Where the price was seen, e.g. "Amazon", "Flohmarkt", "BrickLink". */
+ source: text('source'),
+ observedAt: integer('observed_at', { mode: 'timestamp' }).notNull(),
+ note: text('note'),
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
+ },
+ (t) => [
+ index('price_point_set_idx').on(t.setId, t.observedAt),
+ check('price_point_positive', sql`${t.priceCents} >= 0`),
+ ],
+);
diff --git a/src/db/schema/relations.ts b/src/db/schema/relations.ts
index d8a930e..68e8d5b 100644
--- a/src/db/schema/relations.ts
+++ b/src/db/schema/relations.ts
@@ -3,6 +3,7 @@ import { passkey, session, user } from './auth';
import { instruction, manufacturer, set, setTag, tag, theme } from './catalog';
import { collectionItem, location, missingPart, photo } from './collection';
import { collector } from './collector';
+import { pricePoint } from './prices';
import { wishlist, wishlistItem } from './wishlist';
export const userRelations = relations(user, ({ many }) => ({
@@ -49,6 +50,11 @@ export const setRelations = relations(set, ({ one, many }) => ({
photos: many(photo),
setTags: many(setTag),
wishlistItems: many(wishlistItem),
+ pricePoints: many(pricePoint),
+}));
+
+export const pricePointRelations = relations(pricePoint, ({ one }) => ({
+ set: one(set, { fields: [pricePoint.setId], references: [set.id] }),
}));
export const tagRelations = relations(tag, ({ many }) => ({
diff --git a/src/lib/prices.ts b/src/lib/prices.ts
new file mode 100644
index 0000000..b75e953
--- /dev/null
+++ b/src/lib/prices.ts
@@ -0,0 +1,77 @@
+/**
+ * Value development of a set.
+ *
+ * Pure functions: the interesting logic is "which number counts as the current
+ * value" and "what has changed since", and that should be testable without a
+ * database.
+ */
+
+export type PriceObservation = {
+ priceCents: number;
+ condition: 'new' | 'used';
+ observedAt: Date;
+};
+
+/**
+ * The current value: the most recent observation. Where two land on the same
+ * day, the one for a new set wins - that is the number you would pay to
+ * replace it.
+ */
+export function currentPrice(
+ observations: readonly PriceObservation[],
+ retailPriceCents?: number | null,
+): number | null {
+ if (observations.length === 0) return retailPriceCents ?? null;
+
+ const sorted = [...observations].sort((a, b) => {
+ const byDate = b.observedAt.getTime() - a.observedAt.getTime();
+ if (byDate !== 0) return byDate;
+ return a.condition === 'new' ? -1 : 1;
+ });
+
+ return sorted[0]!.priceCents;
+}
+
+export type PriceTrend = {
+ /** What it cost originally - the UVP, or the oldest observation. */
+ baseCents: number | null;
+ currentCents: number | null;
+ changeCents: number | null;
+ /** Rounded to one decimal, positive means it gained value. */
+ changePercent: number | null;
+};
+
+export function priceTrend(
+ observations: readonly PriceObservation[],
+ retailPriceCents?: number | null,
+): PriceTrend {
+ const current = currentPrice(observations, retailPriceCents);
+
+ const oldest = [...observations].sort(
+ (a, b) => a.observedAt.getTime() - b.observedAt.getTime(),
+ )[0];
+ // The recommended retail price is the honest baseline; only without one do
+ // we fall back to the earliest thing we saw.
+ const base = retailPriceCents ?? oldest?.priceCents ?? null;
+
+ if (base === null || current === null || base === 0) {
+ return { baseCents: base, currentCents: current, changeCents: null, changePercent: null };
+ }
+
+ const changeCents = current - base;
+ return {
+ baseCents: base,
+ currentCents: current,
+ changeCents,
+ changePercent: Math.round((changeCents / base) * 1000) / 10,
+ };
+}
+
+/** Sum of the current value of many sets, ignoring the ones without a price. */
+export function totalValueCents(
+ entries: readonly { observations: readonly PriceObservation[]; retailPriceCents?: number | null }[],
+): number {
+ return entries.reduce((total, entry) => {
+ return total + (currentPrice(entry.observations, entry.retailPriceCents) ?? 0);
+ }, 0);
+}
diff --git a/src/lib/queries/catalog.ts b/src/lib/queries/catalog.ts
index 1348a97..23b618d 100644
--- a/src/lib/queries/catalog.ts
+++ b/src/lib/queries/catalog.ts
@@ -1,7 +1,15 @@
import 'server-only';
import { and, asc, desc, eq, like, not, or, sql, type SQL } from 'drizzle-orm';
import { db } from '@/db';
-import { collectionItem, instruction, manufacturer, photo, set, theme } from '@/db/schema';
+import {
+ collectionItem,
+ instruction,
+ manufacturer,
+ photo,
+ pricePoint,
+ set,
+ theme,
+} from '@/db/schema';
import { ownedCopiesSql, wishlistCountSql, type OwnershipFilter } from '@/lib/ownership';
export const CATALOG_PAGE_SIZE = 48;
@@ -227,3 +235,28 @@ export function listSetYears(): number[] {
.all()
.flatMap((row) => (row.year === null ? [] : [row.year]));
}
+
+export type PricePointRow = {
+ id: string;
+ priceCents: number;
+ condition: 'new' | 'used';
+ source: string | null;
+ observedAt: Date;
+ note: string | null;
+};
+
+export function listPricePoints(setId: string): PricePointRow[] {
+ return db
+ .select({
+ id: pricePoint.id,
+ priceCents: pricePoint.priceCents,
+ condition: pricePoint.condition,
+ source: pricePoint.source,
+ observedAt: pricePoint.observedAt,
+ note: pricePoint.note,
+ })
+ .from(pricePoint)
+ .where(eq(pricePoint.setId, setId))
+ .orderBy(desc(pricePoint.observedAt))
+ .all();
+}
diff --git a/src/lib/queries/collection.ts b/src/lib/queries/collection.ts
index e47fea6..f68c96e 100644
--- a/src/lib/queries/collection.ts
+++ b/src/lib/queries/collection.ts
@@ -113,6 +113,8 @@ export type CollectionStats = {
itemCount: number;
pieceCount: number;
retailValueCents: number;
+ /** Latest observed price where there is one, UVP otherwise. */
+ currentValueCents: number;
paidCents: number;
byManufacturer: Array<{ name: string; count: number }>;
};
@@ -155,11 +157,28 @@ export function getCollectionStats(collectorId?: string): CollectionStats {
.orderBy(asc(manufacturer.name))
.all();
+ // Current value per copy: the newest observed price, falling back to the
+ // recommended retail price of the set.
+ const [current] = db
+ .select({
+ total: sql