feat(preise): beobachtete preise und wertentwicklung je set

Auf der Set-Detailseite lassen sich Preise notieren: Betrag, Zustand,
Datum, wo gesehen und eine Notiz. Daraus entsteht eine Reihe statt
eines einzelnen Feldes, denn die interessante Frage ist nicht "was
kostet es", sondern "was hat es seit dem Kauf gemacht".

Als Ausgangswert dient die UVP, sonst die aelteste Beobachtung; der
aktuelle Wert ist die neueste. Fallen am selben Tag zwei Beobachtungen
an, gewinnt der Neupreis - das ist die Zahl, die man zum Ersetzen
zahlen wuerde. Die Statistik-Kachel in der Sammlung zeigt entsprechend
"Aktueller Wert", sobald irgendwo ein Preis notiert ist, sonst
unveraendert "Wert nach UVP".

Bewusst nicht dabei: die Anbindung des BrickLink-Preisfuehrers. Sie war
im Plan als optional vermerkt und braucht OAuth1 mit einer pro Nutzer
registrierten Anwendung - viel Apparat fuer eine Zahl, die sich in zehn
Sekunden eintippen laesst. Die Datenstruktur ist dafuer offen: das Feld
"Wo" nimmt jede Quelle auf.

13 Tests fuer die Rechenwege, inklusive Division durch null, fehlender
UVP und Rundung.

Refs Phase 3

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
unknown
2026-09-07 09:17:03 +02:00
co-authored by Claude Opus 5
parent 87131b8e70
commit 91fb66b13b
14 changed files with 2695 additions and 2 deletions
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE `price_point` (
`id` text PRIMARY KEY NOT NULL,
`set_id` text NOT NULL,
`price_cents` integer NOT NULL,
`condition` text DEFAULT 'new' NOT NULL,
`source` text,
`observed_at` integer NOT NULL,
`note` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`set_id`) REFERENCES `set`(`id`) ON UPDATE no action ON DELETE cascade,
CONSTRAINT "price_point_positive" CHECK("price_point"."price_cents" >= 0)
);
--> statement-breakpoint
CREATE INDEX `price_point_set_idx` ON `price_point` (`set_id`,`observed_at`);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -15,6 +15,13 @@
"when": 1788763704015, "when": 1788763704015,
"tag": "0001_lumpy_bill_hollister", "tag": "0001_lumpy_bill_hollister",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1788765229376,
"tag": "0002_aspiring_whiplash",
"breakpoints": true
} }
] ]
} }
+15
View File
@@ -4,6 +4,7 @@ import { notFound } from 'next/navigation';
import { Check, ExternalLink, Gift, ImageOff, Pencil } from 'lucide-react'; import { Check, ExternalLink, Gift, ImageOff, Pencil } from 'lucide-react';
import { InstructionsSection } from '@/components/katalog/instructions-section'; import { InstructionsSection } from '@/components/katalog/instructions-section';
import { PhotosSection } from '@/components/katalog/photos-section'; import { PhotosSection } from '@/components/katalog/photos-section';
import { PriceSection } from '@/components/katalog/price-section';
import { SetActions } from '@/components/katalog/set-actions'; import { SetActions } from '@/components/katalog/set-actions';
import { CollectionItemsSection } from '@/components/sammlung/collection-items-section'; import { CollectionItemsSection } from '@/components/sammlung/collection-items-section';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -17,6 +18,7 @@ import {
listCollectionItemsForSet, listCollectionItemsForSet,
listInstructionsForSet, listInstructionsForSet,
listPhotosForSet, listPhotosForSet,
listPricePoints,
} from '@/lib/queries/catalog'; } from '@/lib/queries/catalog';
import { listMissingParts } from '@/lib/queries/collection'; import { listMissingParts } from '@/lib/queries/collection';
import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors'; import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors';
@@ -169,6 +171,19 @@ export default async function SetDetailPage({ params, searchParams }: Props) {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle>Preis und Wertentwicklung</CardTitle>
</CardHeader>
<CardContent>
<PriceSection
setId={set.id}
prices={listPricePoints(set.id)}
retailPriceCents={set.retailPriceCents}
/>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Anleitungen</CardTitle> <CardTitle>Anleitungen</CardTitle>
+61
View File
@@ -0,0 +1,61 @@
'use server';
import { revalidatePath } from 'next/cache';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '@/db';
import { pricePoint } from '@/db/schema';
import { actionError, actionOk, parseForm, type ActionResult } from '@/lib/action-result';
import { requireUser } from '@/lib/session';
import { optionalDate, optionalText, optionalPriceCents } from '@/lib/validation';
const schema = z.object({
priceCents: optionalPriceCents,
condition: z.enum(['new', 'used']).default('new'),
source: optionalText(80),
observedAt: optionalDate,
note: optionalText(200),
});
export async function addPricePoint(setId: string, input: unknown): Promise<ActionResult> {
await requireUser();
const parsed = parseForm(schema, input);
if (!parsed.ok) return parsed;
if (parsed.data.priceCents === null) return actionError('Bitte gib einen Preis an.');
db.insert(pricePoint)
.values({
id: crypto.randomUUID(),
setId,
priceCents: parsed.data.priceCents,
condition: parsed.data.condition,
source: parsed.data.source,
// No date given means "seen today".
observedAt: parsed.data.observedAt ?? new Date(),
note: parsed.data.note,
createdAt: new Date(),
})
.run();
revalidatePath(`/katalog/${setId}`);
revalidatePath('/sammlung');
return actionOk();
}
export async function deletePricePoint(id: string): Promise<ActionResult> {
await requireUser();
const existing = db
.select({ setId: pricePoint.setId })
.from(pricePoint)
.where(eq(pricePoint.id, id))
.get();
if (!existing) return actionOk();
db.delete(pricePoint).where(eq(pricePoint.id, id)).run();
revalidatePath(`/katalog/${existing.setId}`);
revalidatePath('/sammlung');
return actionOk();
}
+6 -1
View File
@@ -56,7 +56,12 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
const tiles = [ const tiles = [
{ icon: Boxes, label: 'Sets', value: formatNumber(stats.setCount) }, { icon: Boxes, label: 'Sets', value: formatNumber(stats.setCount) },
{ icon: Layers, label: 'Teile gesamt', value: formatNumber(stats.pieceCount) }, { icon: Layers, label: 'Teile gesamt', value: formatNumber(stats.pieceCount) },
{ icon: Euro, label: 'Wert nach UVP', value: formatEuro(stats.retailValueCents) }, {
icon: Euro,
// Notierte Preise gehen vor, sonst die UVP.
label: stats.currentValueCents === stats.retailValueCents ? 'Wert nach UVP' : 'Aktueller Wert',
value: formatEuro(stats.currentValueCents),
},
{ icon: PackageSearch, label: 'Fehlt noch', value: formatNumber(missing) }, { icon: PackageSearch, label: 'Fehlt noch', value: formatNumber(missing) },
] as const; ] as const;
+206
View File
@@ -0,0 +1,206 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Loader2, Plus, TrendingDown, TrendingUp, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { addPricePoint, deletePricePoint } from '@/app/(app)/katalog/price-actions';
import { formatDate, toDateInputValue } from '@/lib/format';
import { formatEuro } from '@/lib/money';
import { priceTrend, type PriceObservation } from '@/lib/prices';
import type { PricePointRow } from '@/lib/queries/catalog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
const CONDITION_LABELS: Record<string, string> = {
new: 'Neu',
used: 'Gebraucht',
};
/**
* Observed prices for one set.
*
* The point is not the single current price but the development: what a set
* did since it was bought. Everything here is maintained by hand - no price
* service is queried, and nothing leaves the server.
*/
export function PriceSection({
setId,
prices,
retailPriceCents,
}: {
setId: string;
prices: PricePointRow[];
retailPriceCents: number | null;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [open, setOpen] = useState(false);
const observations: PriceObservation[] = prices.map((price) => ({
priceCents: price.priceCents,
condition: price.condition,
observedAt: price.observedAt,
}));
const trend = priceTrend(observations, retailPriceCents);
const gained = (trend.changeCents ?? 0) > 0;
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const input = Object.fromEntries(new FormData(form).entries());
startTransition(async () => {
const result = await addPricePoint(setId, input);
if (!result.ok) {
toast.error(result.error);
return;
}
form.reset();
setOpen(false);
toast.success('Preis notiert.');
router.refresh();
});
}
function remove(id: string) {
startTransition(async () => {
const result = await deletePricePoint(id);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Eintrag gelöscht.');
router.refresh();
});
}
return (
<div className="space-y-4">
<dl className="grid grid-cols-3 gap-3 text-sm">
<div>
<dt className="text-xs text-muted-foreground">Ursprünglich</dt>
<dd className="font-medium">{formatEuro(trend.baseCents)}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">Aktuell</dt>
<dd className="font-medium">{formatEuro(trend.currentCents)}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">Entwicklung</dt>
<dd>
{trend.changePercent === null ? (
<span className="text-muted-foreground">–</span>
) : (
<span
className={`flex items-center gap-1 font-medium ${
gained ? 'text-emerald-600' : 'text-muted-foreground'
}`}
>
{gained ? (
<TrendingUp className="size-4" aria-hidden />
) : (
<TrendingDown className="size-4" aria-hidden />
)}
{trend.changePercent > 0 ? '+' : ''}
{trend.changePercent.toLocaleString('de-DE')} %
</span>
)}
</dd>
</div>
</dl>
{prices.length > 0 ? (
<ul className="divide-y divide-border rounded-lg border border-border">
{prices.map((price) => (
<li key={price.id} className="flex items-center gap-3 p-3">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">
{formatEuro(price.priceCents)}{' '}
<Badge variant="secondary">{CONDITION_LABELS[price.condition]}</Badge>
</p>
<p className="truncate text-xs text-muted-foreground">
{[formatDate(price.observedAt), price.source, price.note]
.filter(Boolean)
.join(' · ')}
</p>
</div>
<Button
variant="ghost"
size="icon-sm"
aria-label="Preis löschen"
onClick={() => remove(price.id)}
disabled={pending}
>
<Trash2 className="text-destructive" />
</Button>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">
Noch kein Preis notiert. Der Ausgangswert ist die UVP des Sets.
</p>
)}
{open ? (
<form onSubmit={submit} className="space-y-3 rounded-lg border border-border p-3">
<p className="text-sm font-medium">Preis notieren</p>
<div className="grid gap-3 sm:grid-cols-3">
<Field label="Preis" htmlFor="priceCents" required hint="In Euro.">
<Input
id="priceCents"
name="priceCents"
inputMode="decimal"
required
placeholder="79,99"
/>
</Field>
<Field label="Zustand" htmlFor="condition">
<NativeSelect id="condition" name="condition" defaultValue="new">
<option value="new">Neu</option>
<option value="used">Gebraucht</option>
</NativeSelect>
</Field>
<Field label="Gesehen am" htmlFor="observedAt" hint="Leer = heute.">
<Input
id="observedAt"
name="observedAt"
type="date"
defaultValue={toDateInputValue(new Date())}
/>
</Field>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Wo" htmlFor="source">
<Input id="source" name="source" maxLength={80} placeholder="Amazon, Flohmarkt …" />
</Field>
<Field label="Notiz" htmlFor="price-note">
<Input id="price-note" name="note" maxLength={200} />
</Field>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Plus />}
Speichern
</Button>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Abbrechen
</Button>
</div>
</form>
) : (
<Button variant="outline" onClick={() => setOpen(true)}>
<Plus />
Preis notieren
</Button>
)}
</div>
);
}
+1
View File
@@ -1,6 +1,7 @@
export * from './auth'; export * from './auth';
export * from './catalog'; export * from './catalog';
export * from './collector'; export * from './collector';
export * from './prices';
export * from './collection'; export * from './collection';
export * from './wishlist'; export * from './wishlist';
export * from './relations'; export * from './relations';
+35
View File
@@ -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`),
],
);
+6
View File
@@ -3,6 +3,7 @@ import { passkey, session, user } from './auth';
import { instruction, manufacturer, set, setTag, tag, theme } from './catalog'; import { instruction, manufacturer, set, setTag, tag, theme } from './catalog';
import { collectionItem, location, missingPart, photo } from './collection'; import { collectionItem, location, missingPart, photo } from './collection';
import { collector } from './collector'; import { collector } from './collector';
import { pricePoint } from './prices';
import { wishlist, wishlistItem } from './wishlist'; import { wishlist, wishlistItem } from './wishlist';
export const userRelations = relations(user, ({ many }) => ({ export const userRelations = relations(user, ({ many }) => ({
@@ -49,6 +50,11 @@ export const setRelations = relations(set, ({ one, many }) => ({
photos: many(photo), photos: many(photo),
setTags: many(setTag), setTags: many(setTag),
wishlistItems: many(wishlistItem), 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 }) => ({ export const tagRelations = relations(tag, ({ many }) => ({
+77
View File
@@ -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);
}
+34 -1
View File
@@ -1,7 +1,15 @@
import 'server-only'; import 'server-only';
import { and, asc, desc, eq, like, not, or, sql, type SQL } from 'drizzle-orm'; import { and, asc, desc, eq, like, not, or, sql, type SQL } from 'drizzle-orm';
import { db } from '@/db'; 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'; import { ownedCopiesSql, wishlistCountSql, type OwnershipFilter } from '@/lib/ownership';
export const CATALOG_PAGE_SIZE = 48; export const CATALOG_PAGE_SIZE = 48;
@@ -227,3 +235,28 @@ export function listSetYears(): number[] {
.all() .all()
.flatMap((row) => (row.year === null ? [] : [row.year])); .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();
}
+19
View File
@@ -113,6 +113,8 @@ export type CollectionStats = {
itemCount: number; itemCount: number;
pieceCount: number; pieceCount: number;
retailValueCents: number; retailValueCents: number;
/** Latest observed price where there is one, UVP otherwise. */
currentValueCents: number;
paidCents: number; paidCents: number;
byManufacturer: Array<{ name: string; count: number }>; byManufacturer: Array<{ name: string; count: number }>;
}; };
@@ -155,11 +157,28 @@ export function getCollectionStats(collectorId?: string): CollectionStats {
.orderBy(asc(manufacturer.name)) .orderBy(asc(manufacturer.name))
.all(); .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<number>`coalesce(sum(coalesce((
select p.price_cents from price_point p
where p.set_id = ${set.id}
order by p.observed_at desc, p.created_at desc
limit 1
), ${set.retailPriceCents}, 0)), 0)`.mapWith(Number),
})
.from(collectionItem)
.innerJoin(set, eq(set.id, collectionItem.setId))
.where(active)
.all();
return { return {
setCount: totals?.setCount ?? 0, setCount: totals?.setCount ?? 0,
itemCount: totals?.itemCount ?? 0, itemCount: totals?.itemCount ?? 0,
pieceCount: totals?.pieceCount ?? 0, pieceCount: totals?.pieceCount ?? 0,
retailValueCents: totals?.retailValueCents ?? 0, retailValueCents: totals?.retailValueCents ?? 0,
currentValueCents: current?.total ?? 0,
paidCents: totals?.paidCents ?? 0, paidCents: totals?.paidCents ?? 0,
byManufacturer, byManufacturer,
}; };
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest';
import { currentPrice, priceTrend, totalValueCents } from '@/lib/prices';
const on = (iso: string) => new Date(`${iso}T12:00:00.000Z`);
describe('Aktueller Wert', () => {
it('faellt ohne Beobachtung auf die UVP zurueck', () => {
expect(currentPrice([], 4999)).toBe(4999);
});
it('ist null, wenn es weder Beobachtung noch UVP gibt', () => {
expect(currentPrice([], null)).toBeNull();
expect(currentPrice([])).toBeNull();
});
it('nimmt die neueste Beobachtung, nicht die hoechste', () => {
const value = currentPrice(
[
{ priceCents: 9900, condition: 'new', observedAt: on('2026-01-01') },
{ priceCents: 6900, condition: 'new', observedAt: on('2026-06-01') },
],
4999,
);
expect(value).toBe(6900);
});
it('bevorzugt am selben Tag den Neupreis', () => {
const value = currentPrice([
{ priceCents: 4000, condition: 'used', observedAt: on('2026-06-01') },
{ priceCents: 8000, condition: 'new', observedAt: on('2026-06-01') },
]);
expect(value).toBe(8000);
});
it('schlaegt die UVP, sobald es eine Beobachtung gibt', () => {
const value = currentPrice(
[{ priceCents: 12000, condition: 'new', observedAt: on('2026-06-01') }],
4999,
);
expect(value).toBe(12000);
});
});
describe('Wertentwicklung', () => {
it('misst gegen die UVP', () => {
const trend = priceTrend(
[{ priceCents: 9998, condition: 'new', observedAt: on('2026-06-01') }],
4999,
);
expect(trend.baseCents).toBe(4999);
expect(trend.currentCents).toBe(9998);
expect(trend.changeCents).toBe(4999);
expect(trend.changePercent).toBe(100);
});
it('zeigt auch Verluste', () => {
const trend = priceTrend(
[{ priceCents: 2500, condition: 'used', observedAt: on('2026-06-01') }],
5000,
);
expect(trend.changeCents).toBe(-2500);
expect(trend.changePercent).toBe(-50);
});
it('nimmt ohne UVP die aelteste Beobachtung als Basis', () => {
const trend = priceTrend([
{ priceCents: 1000, condition: 'new', observedAt: on('2026-01-01') },
{ priceCents: 1500, condition: 'new', observedAt: on('2026-06-01') },
]);
expect(trend.baseCents).toBe(1000);
expect(trend.changePercent).toBe(50);
});
it('rechnet nicht, wenn es nichts zu vergleichen gibt', () => {
const trend = priceTrend([], null);
expect(trend.changeCents).toBeNull();
expect(trend.changePercent).toBeNull();
});
it('teilt nicht durch null', () => {
const trend = priceTrend(
[{ priceCents: 1000, condition: 'new', observedAt: on('2026-06-01') }],
0,
);
expect(trend.changePercent).toBeNull();
});
it('rundet den Prozentwert auf eine Nachkommastelle', () => {
const trend = priceTrend(
[{ priceCents: 3333, condition: 'new', observedAt: on('2026-06-01') }],
10000,
);
expect(trend.changePercent).toBe(-66.7);
});
});
describe('Gesamtwert', () => {
it('summiert die aktuellen Werte', () => {
const total = totalValueCents([
{
observations: [{ priceCents: 6900, condition: 'new', observedAt: on('2026-06-01') }],
retailPriceCents: 4999,
},
{ observations: [], retailPriceCents: 2999 },
]);
expect(total).toBe(9899);
});
it('ignoriert Sets ohne jeden Preis', () => {
const total = totalValueCents([
{ observations: [], retailPriceCents: null },
{ observations: [], retailPriceCents: 1000 },
]);
expect(total).toBe(1000);
});
});