added chart

This commit is contained in:
2026-06-17 16:46:40 +02:00
parent 7a90ced98e
commit f4e357a8a1
2 changed files with 283 additions and 11 deletions
+281
View File
@@ -0,0 +1,281 @@
<script lang="ts">
import type { PricePoint } from './lists.svelte';
let {
history,
target = null,
currency = 'EUR'
}: { history: PricePoint[]; target?: number | null; currency?: string | null } = $props();
// Geometry — a wide, short celestial band. viewBox units; scales to container.
const W = 600;
const H = 180;
const PAD = { t: 16, r: 14, b: 24, l: 46 };
const innerW = W - PAD.l - PAD.r;
const innerH = H - PAD.t - PAD.b;
// Chronological, oldest → newest, so the line reads left → right.
const points = $derived(
[...history].sort((a, b) => +new Date(a.fetched_at) - +new Date(b.fetched_at))
);
const cur = $derived(currency ?? 'EUR');
const stats = $derived.by(() => {
if (points.length === 0) return null;
const prices = points.map((p) => p.price);
let min = Math.min(...prices);
let max = Math.max(...prices);
// Fold the target into the vertical range so its line is always visible.
if (target != null) {
min = Math.min(min, target);
max = Math.max(max, target);
}
if (min === max) {
// Flat history — give it breathing room so it doesn't sit on an edge.
const pad = Math.max(1, Math.abs(min) * 0.1);
min -= pad;
max += pad;
}
const ts = points.map((p) => +new Date(p.fetched_at));
const tMin = Math.min(...ts);
const tMax = Math.max(...ts);
return { min, max, tMin, tMax };
});
function x(t: number): number {
if (!stats) return PAD.l;
const span = stats.tMax - stats.tMin || 1;
return PAD.l + ((t - stats.tMin) / span) * innerW;
}
function y(price: number): number {
if (!stats) return PAD.t;
const span = stats.max - stats.min || 1;
return PAD.t + (1 - (price - stats.min) / span) * innerH;
}
// Plotted coordinates for each price check.
const coords = $derived(
points.map((p) => ({ ...p, cx: x(+new Date(p.fetched_at)), cy: y(p.price) }))
);
const linePath = $derived(
coords.length
? coords.map((c, i) => `${i === 0 ? 'M' : 'L'} ${c.cx.toFixed(1)} ${c.cy.toFixed(1)}`).join(' ')
: ''
);
// Area = the line, then down to the baseline and back — fills under the curve.
const areaPath = $derived(
coords.length
? `${linePath} L ${coords[coords.length - 1].cx.toFixed(1)} ${(H - PAD.b).toFixed(1)} ` +
`L ${coords[0].cx.toFixed(1)} ${(H - PAD.b).toFixed(1)} Z`
: ''
);
// Extremes — gild the lowest (the prophecy), mark the highest in rose.
const lowIdx = $derived(
coords.length ? coords.reduce((lo, c, i) => (c.price < coords[lo].price ? i : lo), 0) : -1
);
const highIdx = $derived(
coords.length ? coords.reduce((hi, c, i) => (c.price > coords[hi].price ? i : hi), 0) : -1
);
const targetY = $derived(target != null && stats ? y(target) : null);
const latest = $derived(coords.length ? coords[coords.length - 1] : null);
const onSale = $derived(latest != null && target != null && latest.price <= target);
// Three horizontal gridlines (min / mid / max) for a sense of scale.
const gridLines = $derived.by(() => {
if (!stats) return [];
return [0, 0.5, 1].map((f) => {
const v = stats.min + (stats.max - stats.min) * f;
return { v, gy: y(v) };
});
});
// Hover — snap to the nearest point in time.
let hover = $state<number | null>(null);
let svgEl: SVGSVGElement | null = $state(null);
function onMove(e: PointerEvent) {
if (!svgEl || coords.length === 0) return;
const rect = svgEl.getBoundingClientRect();
const px = ((e.clientX - rect.left) / rect.width) * W;
let best = 0;
let bestD = Infinity;
for (let i = 0; i < coords.length; i++) {
const d = Math.abs(coords[i].cx - px);
if (d < bestD) {
bestD = d;
best = i;
}
}
hover = best;
}
function fmtMoney(v: number) {
return `${cur} ${v.toFixed(2)}`;
}
function fmtDay(s: string) {
return new Date(s).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' });
}
function fmtFull(s: string) {
return new Date(s).toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
}
const active = $derived(hover != null ? coords[hover] : null);
// Keep the tooltip from spilling off either edge.
const tipX = $derived(active ? Math.min(Math.max(active.cx, PAD.l + 56), W - PAD.r - 56) : 0);
const uid = 'pc-' + Math.random().toString(36).slice(2, 8);
</script>
{#if coords.length === 0}
<p class="text-xs text-mute">no price checks yet — hit refresh to start tracking.</p>
{:else}
<figure class="m-0">
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<svg
bind:this={svgEl}
viewBox="0 0 {W} {H}"
class="block w-full select-none overflow-visible"
role="img"
aria-label="price history chart"
onpointermove={onMove}
onpointerleave={() => (hover = null)}
>
<defs>
<linearGradient id="{uid}-area" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="var(--color-iris)" stop-opacity="0.35" />
<stop offset="60%" stop-color="var(--color-iris)" stop-opacity="0.06" />
<stop offset="100%" stop-color="var(--color-iris)" stop-opacity="0" />
</linearGradient>
<linearGradient id="{uid}-line" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="var(--color-holo)" />
<stop offset="50%" stop-color="var(--color-iris)" />
<stop offset="100%" stop-color="var(--color-rose)" />
</linearGradient>
<filter id="{uid}-glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3.5" result="b" />
<feMerge>
<feMergeNode in="b" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<!-- Gridlines + price ticks -->
{#each gridLines as g}
<line
x1={PAD.l}
y1={g.gy}
x2={W - PAD.r}
y2={g.gy}
stroke="var(--color-smoke)"
stroke-width="1"
stroke-dasharray="2 5"
opacity="0.6"
/>
<text x={PAD.l - 8} y={g.gy + 3.5} text-anchor="end" class="fill-mute" font-size="10">
{g.v.toFixed(0)}
</text>
{/each}
<!-- Target line — the price you've been praying for -->
{#if targetY != null}
<line
x1={PAD.l}
y1={targetY}
x2={W - PAD.r}
y2={targetY}
stroke="var(--color-mint)"
stroke-width="1.25"
stroke-dasharray="6 4"
opacity="0.8"
/>
<text x={W - PAD.r} y={targetY - 5} text-anchor="end" class="fill-mint" font-size="10">
target {target?.toFixed(0)}
</text>
{/if}
<!-- Area under the curve -->
<path d={areaPath} fill="url(#{uid}-area)" />
<!-- The price line itself, haloed -->
<path
d={linePath}
fill="none"
stroke="url(#{uid}-line)"
stroke-width="2.25"
stroke-linecap="round"
stroke-linejoin="round"
filter="url(#{uid}-glow)"
/>
<!-- Per-check dots; sold-out checks ringed in rose -->
{#each coords as c, i}
<circle
cx={c.cx}
cy={c.cy}
r={i === lowIdx || i === highIdx ? 4 : 2.5}
fill={i === lowIdx
? 'var(--color-gold)'
: i === highIdx
? 'var(--color-rose)'
: 'var(--color-iris)'}
stroke={c.in_stock === false ? 'var(--color-rose)' : 'var(--color-void)'}
stroke-width="1.5"
/>
{/each}
<!-- Hover crosshair + emphasised point -->
{#if active}
<line
x1={active.cx}
y1={PAD.t}
x2={active.cx}
y2={H - PAD.b}
stroke="var(--color-ink)"
stroke-width="1"
opacity="0.25"
/>
<circle cx={active.cx} cy={active.cy} r="5.5" fill="none" stroke="var(--color-ink)" stroke-width="1.5" />
<g transform="translate({tipX}, {Math.max(active.cy - 16, PAD.t + 8)})">
<rect x="-54" y="-26" width="108" height="34" rx="6" fill="var(--color-ash)" stroke="var(--color-smoke)" />
<text x="0" y="-12" text-anchor="middle" class="fill-ink" font-size="11" font-weight="700">
{fmtMoney(active.price)}
</text>
<text x="0" y="2" text-anchor="middle" class="fill-mute" font-size="9">
{fmtFull(active.fetched_at)}
</text>
</g>
{/if}
<!-- X axis endpoints -->
<text x={PAD.l} y={H - 6} text-anchor="start" class="fill-mute" font-size="10">
{fmtDay(points[0].fetched_at)}
</text>
{#if points.length > 1}
<text x={W - PAD.r} y={H - 6} text-anchor="end" class="fill-mute" font-size="10">
{fmtDay(points[points.length - 1].fetched_at)}
</text>
{/if}
</svg>
<figcaption class="mt-2 flex flex-wrap items-center justify-between gap-2 text-xs">
<span class="flex items-center gap-3 text-mute">
<span class="flex items-center gap-1"><span class="inline-block size-2 rounded-full" style="background:var(--color-gold)"></span>low {fmtMoney(coords[lowIdx].price)}</span>
<span class="flex items-center gap-1"><span class="inline-block size-2 rounded-full" style="background:var(--color-rose)"></span>high {fmtMoney(coords[highIdx].price)}</span>
</span>
{#if latest}
<span class:text-mint={onSale} class:text-ink={!onSale}>
now {fmtMoney(latest.price)}{onSale ? ' ✦' : ''}
</span>
{/if}
</figcaption>
</figure>
{/if}
+2 -11
View File
@@ -3,6 +3,7 @@
import { page } from '$app/state';
import { ApiError } from '$lib/api';
import { auth } from '$lib/auth.svelte';
import PriceChart from '$lib/PriceChart.svelte';
import {
lists,
listsApi,
@@ -403,18 +404,8 @@
<div class="border-t border-smoke pt-3">
{#if historyLoading}
<p class="text-xs text-mute flicker">loading history…</p>
{:else if history.length === 0}
<p class="text-xs text-mute">no price checks yet — hit refresh to start tracking.</p>
{:else}
<ul class="space-y-1 text-xs">
{#each history as h}
<li class="flex items-center justify-between gap-3">
<span class="text-ink">{money(h.price, h.currency)}</span>
{#if h.in_stock === false}<span class="text-rose">sold out</span>{/if}
<span class="text-mute">{fmtDate(h.fetched_at)}</span>
</li>
{/each}
</ul>
<PriceChart {history} target={item.target_price} currency={item.currency} />
{/if}
</div>
{/if}