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>
78 lines
2.4 KiB
TypeScript
78 lines
2.4 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|