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:
@@ -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
@@ -15,6 +15,13 @@
|
||||
"when": 1788763704015,
|
||||
"tag": "0001_lumpy_bill_hollister",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1788765229376,
|
||||
"tag": "0002_aspiring_whiplash",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { notFound } from 'next/navigation';
|
||||
import { Check, ExternalLink, Gift, ImageOff, Pencil } from 'lucide-react';
|
||||
import { InstructionsSection } from '@/components/katalog/instructions-section';
|
||||
import { PhotosSection } from '@/components/katalog/photos-section';
|
||||
import { PriceSection } from '@/components/katalog/price-section';
|
||||
import { SetActions } from '@/components/katalog/set-actions';
|
||||
import { CollectionItemsSection } from '@/components/sammlung/collection-items-section';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
listCollectionItemsForSet,
|
||||
listInstructionsForSet,
|
||||
listPhotosForSet,
|
||||
listPricePoints,
|
||||
} from '@/lib/queries/catalog';
|
||||
import { listMissingParts } from '@/lib/queries/collection';
|
||||
import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors';
|
||||
@@ -169,6 +171,19 @@ export default async function SetDetailPage({ params, searchParams }: Props) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Preis und Wertentwicklung</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PriceSection
|
||||
setId={set.id}
|
||||
prices={listPricePoints(set.id)}
|
||||
retailPriceCents={set.retailPriceCents}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Anleitungen</CardTitle>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -56,7 +56,12 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
|
||||
const tiles = [
|
||||
{ icon: Boxes, label: 'Sets', value: formatNumber(stats.setCount) },
|
||||
{ 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) },
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
export * from './auth';
|
||||
export * from './catalog';
|
||||
export * from './collector';
|
||||
export * from './prices';
|
||||
export * from './collection';
|
||||
export * from './wishlist';
|
||||
export * from './relations';
|
||||
|
||||
@@ -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`),
|
||||
],
|
||||
);
|
||||
@@ -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 }) => ({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<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 {
|
||||
setCount: totals?.setCount ?? 0,
|
||||
itemCount: totals?.itemCount ?? 0,
|
||||
pieceCount: totals?.pieceCount ?? 0,
|
||||
retailValueCents: totals?.retailValueCents ?? 0,
|
||||
currentValueCents: current?.total ?? 0,
|
||||
paidCents: totals?.paidCents ?? 0,
|
||||
byManufacturer,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user