diff --git a/src/lib/pc/SetupCard.svelte b/src/lib/pc/SetupCard.svelte index 3c3ce84..3aad9ce 100644 --- a/src/lib/pc/SetupCard.svelte +++ b/src/lib/pc/SetupCard.svelte @@ -49,7 +49,7 @@ : names ); const spec = $derived(keySpec(first)); - const badges = $derived([...new Set(parts.flatMap(usageBadges))]); + const badges = $derived([...new Set(parts.flatMap((p) => usageBadges(p)))]); const flag = $derived(badges.find((b) => b !== 'long ago') ?? null); const popId = $derived(`pop-${first.id}`); @@ -69,7 +69,7 @@ > {/if} {#if compact && flag}{flag.replace('model ', '')}{flag.replace('model ', '').replace(/^ordered, arriving/, 'arrives')}{/if} diff --git a/src/lib/pc/TimeSlider.svelte b/src/lib/pc/TimeSlider.svelte index 76b9785..3c104d4 100644 --- a/src/lib/pc/TimeSlider.svelte +++ b/src/lib/pc/TimeSlider.svelte @@ -9,8 +9,16 @@ min, max, events, + today = max, value = $bindable() - }: { min: string; max: string; events: TimelineEvent[]; value: string } = $props(); + }: { + min: string; + /** Last day on the slider; later than `today` when parts are ordered but not here yet. */ + max: string; + today?: string; + events: TimelineEvent[]; + value: string; + } = $props(); const DAY = 86_400_000; const toDay = (d: string) => Math.round(Date.parse(`${d}T00:00:00Z`) / DAY); @@ -22,7 +30,12 @@ const ticks = $derived( events .filter((e) => e.date >= min && e.date <= max) - .map((e) => ({ ...e, day: toDay(e.date), pct: ((toDay(e.date) - lo) / span) * 100 })) + .map((e) => ({ + ...e, + day: toDay(e.date), + pct: ((toDay(e.date) - lo) / span) * 100, + upcoming: e.date > today + })) ); const years = $derived.by(() => { const out: { y: number; pct: number }[] = []; @@ -31,6 +44,8 @@ return out; }); const current = $derived(toDay(value)); + /** Where today sits on the track when the slider runs into the future (ordered parts). */ + const nowPct = $derived(today < max ? ((toDay(today) - lo) / span) * 100 : null); /** Latest change on or before the chosen day. */ const last = $derived([...ticks].reverse().find((t) => t.day <= current) ?? null); @@ -66,7 +81,9 @@
+ {#if nowPct !== null}
{/if}
+ {#if nowPct !== null}{/if} {#each years.filter((y, i) => width > 520 || i % 2 === 0) as y (y.y)} {y.y} {/each} @@ -76,6 +93,7 @@ class="tick" class:past={t.day <= current} class:on={t.day === current} + class:upcoming={t.upcoming} style:left="{t.pct}%" aria-label="{shortDate(t.date)}: {eventLabel(t)}" onmouseenter={() => (hover = t)} @@ -105,13 +123,14 @@
{shortDate(value)} -

- {#if last} + {#if value > today}upcoming · + {/if}{#if last} {monthYear(last.date)} · {eventLabel(last)} {:else} before the first part @@ -199,6 +218,36 @@ background: var(--site-text); opacity: 0.9; } + .tick.upcoming { + background: var(--site-bg); + box-shadow: inset 0 0 0 1.5px #db2777; + opacity: 1; + } + .future { + position: absolute; + top: 0.55rem; + right: 0; + height: 0.3rem; + border-radius: 999px; + background: repeating-linear-gradient(90deg, #db2777 0 3px, transparent 3px 7px); + opacity: 0.55; + } + .now { + position: absolute; + top: 0.2rem; + z-index: 1; + width: 2px; + height: 1rem; + margin-left: -1px; + border-radius: 1px; + background: var(--site-text); + opacity: 0.7; + pointer-events: none; + } + .soon { + color: #db2777; + font-weight: 600; + } .tick.on, .tick:hover, .tick:focus-visible { diff --git a/src/lib/pc/shared.test.ts b/src/lib/pc/shared.test.ts index ced2a91..1a2fe98 100644 --- a/src/lib/pc/shared.test.ts +++ b/src/lib/pc/shared.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { cmFor, sensFor, specsToText, textToSpecs } from './shared'; +import { cmFor, sensFor, specsToText, textToSpecs, usageBadges } from './shared'; describe('pc tracker helpers', () => { it('computes CS2 sensitivity from cm/360 and real DPI', () => { @@ -14,4 +14,16 @@ describe('pc tracker helpers', () => { expect(textToSpecs(specsToText(specs))).toEqual(specs); expect(textToSpecs('no colon here\n: empty key\nspeed_mts: 3600')).toEqual({ speed_mts: 3600 }); }); + it('dates the badges of ordered and outgoing parts', () => { + const base = { status: 'in_use', usage: 'current', model: 'X' }; + const ordered = { ...base, installedAt: '2026-10-28', orderDate: '2026-10-26' }; + expect(usageBadges(ordered, '2026-10-26')).toEqual(['ordered, arriving Oct 28']); + expect(usageBadges(ordered, '2026-10-28')).toEqual([]); + expect(usageBadges({ ...base, installedAt: '2026-10-28' }, '2026-10-26')).toEqual([ + 'from Oct 28' + ]); + const old = { status: 'retired', usage: 'previous', model: 'Y', removedAt: '2026-10-28' }; + expect(usageBadges(old, '2026-10-26')).toEqual(['until Oct 28']); + expect(usageBadges(old, '2026-10-29')).toEqual(['previous']); + }); }); diff --git a/src/lib/pc/shared.ts b/src/lib/pc/shared.ts index 96bc6c1..0595d0f 100644 --- a/src/lib/pc/shared.ts +++ b/src/lib/pc/shared.ts @@ -430,11 +430,43 @@ export type LightboxEntry = { }; export type LightboxItem = { title: string; images: string[]; entries: LightboxEntry[] }; -export function usageBadges(p: { usage?: string | null; status: string; model: string | null }) { +/** Today as YYYY-MM-DD (UTC, the same day the page and slider use). */ +export const todayIso = () => new Date().toISOString().slice(0, 10); + +/** "2026-10-28" -> "Oct 28". */ +export const monthDay = (d: string) => + new Date(`${d}T12:00:00Z`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + +/** + * Card badges. Date-aware: a part with a future install date is "ordered, arriving " (or + * "from " when it is not a purchase, e.g. a relabelled drive), and a part with a future + * removal date is still in use, so it shows "until " instead of "previous". + */ +export function usageBadges( + p: { + usage?: string | null; + status: string; + model: string | null; + installedAt?: string | null; + removedAt?: string | null; + orderDate?: string | null; + }, + today: string = todayIso() +) { const out: string[] = []; - if (p.usage === 'stored' || p.status === 'spare') out.push('stored'); + const arriving = p.installedAt && p.installedAt > today ? p.installedAt : null; + const leaving = p.removedAt && p.removedAt > today ? p.removedAt : null; + if (arriving) + out.push( + p.orderDate ? `ordered, arriving ${monthDay(arriving)}` : `from ${monthDay(arriving)}` + ); + if (leaving) out.push(`until ${monthDay(leaving)}`); + if (!leaving && (p.usage === 'stored' || p.status === 'spare')) out.push('stored'); if (p.usage === 'work-issued') out.push('work'); - if (p.usage === 'previous' || p.usage === 'legacy-previous' || p.status === 'retired') + if ( + !leaving && + (p.usage === 'previous' || p.usage === 'legacy-previous' || p.status === 'retired') + ) out.push('previous'); if (p.usage === 'legacy') out.push('long ago'); if (p.model && /\bTBD\b/.test(p.model)) out.push('model TBD'); diff --git a/src/routes/trackers/pc/+page.svelte b/src/routes/trackers/pc/+page.svelte index d48ec62..4eb23c5 100644 --- a/src/routes/trackers/pc/+page.svelte +++ b/src/routes/trackers/pc/+page.svelte @@ -2,6 +2,7 @@ import { enhance } from '$app/forms'; import { replaceState } from '$app/navigation'; import { page } from '$app/state'; + import { untrack } from 'svelte'; import { flip } from 'svelte/animate'; import { fade } from 'svelte/transition'; import Plus from 'lucide-svelte/icons/plus'; @@ -13,6 +14,7 @@ import Sparkles from 'lucide-svelte/icons/sparkles'; import Trash2 from 'lucide-svelte/icons/trash-2'; import EyeOff from 'lucide-svelte/icons/eye-off'; + import Truck from 'lucide-svelte/icons/truck'; import EditDialogs from '#lib/pc/EditDialogs.svelte'; import Lightbox from '#lib/pc/Lightbox.svelte'; import PcIcon from '#lib/pc/PcIcon.svelte'; @@ -69,9 +71,21 @@ ]) ); const minDay = $derived(events.find((e) => e.date <= today)?.date ?? today); + // Ordered parts have a future install date: the slider runs on to that day so the new build + // can be previewed; "today" stays the default. + const maxDay = $derived(events.reduce((m, e) => (e.date > m ? e.date : m), today)); + const incoming = $derived( + parts.filter((p) => p.orderDate && p.installedAt && p.installedAt > today && !isUndatedPast(p)) + ); + const incomingDay = $derived( + incoming.reduce( + (m, p) => (m === null || p.installedAt! < m ? p.installedAt : m), + null + ) + ); // Read ?at= during SSR too, so a shared link renders that day without a flash. const q = page.url.searchParams.get('at'); - let at = $state(isDay(q) && q <= today ? q : today); + let at = $state(isDay(q) && q <= untrack(() => maxDay) ? q : today); let urlTimer: ReturnType | undefined; let mounted = false; $effect(() => { @@ -274,7 +288,22 @@

- + + {#if incomingDay && at < incomingDay} +

+ + Ordered, arriving {shortDate(incomingDay)}: {incoming.map(displayName).join(', ')}. + +

+ {:else if at > today} +

+ + Preview of {shortDate(at)}: parts marked "ordered" have not arrived yet. + +

+ {/if}
@@ -950,4 +979,36 @@ .link:hover { text-decoration: underline; } + .incoming { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem 0.5rem; + margin-top: 0.4rem; + padding: 0.4rem 0.8rem; + border: 1px dashed color-mix(in srgb, #db2777 55%, var(--site-border)); + border-radius: 0.8rem; + background: color-mix(in srgb, var(--site-surface) 92%, transparent); + backdrop-filter: blur(10px); + font-size: 0.78rem; + color: var(--site-muted); + } + .incoming :global(svg) { + color: #db2777; + flex-shrink: 0; + } + .incoming span { + flex: 1 1 16rem; + min-width: 0; + } + .incoming button { + border: 1px solid var(--site-border); + border-radius: 0.6rem; + padding: 0.15rem 0.55rem; + color: var(--site-text); + transition: background 0.15s; + } + .incoming button:hover { + background: var(--site-hover); + } diff --git a/static/hardware/case-o11d-mini-v2/1.t.webp b/static/hardware/case-o11d-mini-v2/1.t.webp new file mode 100644 index 0000000..c758985 Binary files /dev/null and b/static/hardware/case-o11d-mini-v2/1.t.webp differ diff --git a/static/hardware/case-o11d-mini-v2/1.webp b/static/hardware/case-o11d-mini-v2/1.webp new file mode 100644 index 0000000..793153f Binary files /dev/null and b/static/hardware/case-o11d-mini-v2/1.webp differ diff --git a/static/hardware/case-o11d-mini-v2/2.t.webp b/static/hardware/case-o11d-mini-v2/2.t.webp new file mode 100644 index 0000000..309fa2f Binary files /dev/null and b/static/hardware/case-o11d-mini-v2/2.t.webp differ diff --git a/static/hardware/case-o11d-mini-v2/2.webp b/static/hardware/case-o11d-mini-v2/2.webp new file mode 100644 index 0000000..5629aaf Binary files /dev/null and b/static/hardware/case-o11d-mini-v2/2.webp differ diff --git a/static/hardware/case-o11d-mini-v2/3.t.webp b/static/hardware/case-o11d-mini-v2/3.t.webp new file mode 100644 index 0000000..88c992a Binary files /dev/null and b/static/hardware/case-o11d-mini-v2/3.t.webp differ diff --git a/static/hardware/case-o11d-mini-v2/3.webp b/static/hardware/case-o11d-mini-v2/3.webp new file mode 100644 index 0000000..c46b600 Binary files /dev/null and b/static/hardware/case-o11d-mini-v2/3.webp differ diff --git a/static/hardware/cooler-lf3-360/1.t.webp b/static/hardware/cooler-lf3-360/1.t.webp new file mode 100644 index 0000000..60f6419 Binary files /dev/null and b/static/hardware/cooler-lf3-360/1.t.webp differ diff --git a/static/hardware/cooler-lf3-360/1.webp b/static/hardware/cooler-lf3-360/1.webp new file mode 100644 index 0000000..f8d24ff Binary files /dev/null and b/static/hardware/cooler-lf3-360/1.webp differ diff --git a/static/hardware/cooler-lf3-360/2.t.webp b/static/hardware/cooler-lf3-360/2.t.webp new file mode 100644 index 0000000..474ae22 Binary files /dev/null and b/static/hardware/cooler-lf3-360/2.t.webp differ diff --git a/static/hardware/cooler-lf3-360/2.webp b/static/hardware/cooler-lf3-360/2.webp new file mode 100644 index 0000000..4404ff7 Binary files /dev/null and b/static/hardware/cooler-lf3-360/2.webp differ diff --git a/static/hardware/cooler-lf3-360/3.t.webp b/static/hardware/cooler-lf3-360/3.t.webp new file mode 100644 index 0000000..09483a3 Binary files /dev/null and b/static/hardware/cooler-lf3-360/3.t.webp differ diff --git a/static/hardware/cooler-lf3-360/3.webp b/static/hardware/cooler-lf3-360/3.webp new file mode 100644 index 0000000..84b2544 Binary files /dev/null and b/static/hardware/cooler-lf3-360/3.webp differ diff --git a/static/hardware/cpu-9950x3d/1.t.webp b/static/hardware/cpu-9950x3d/1.t.webp new file mode 100644 index 0000000..84fd4c6 Binary files /dev/null and b/static/hardware/cpu-9950x3d/1.t.webp differ diff --git a/static/hardware/cpu-9950x3d/1.webp b/static/hardware/cpu-9950x3d/1.webp new file mode 100644 index 0000000..8d8779e Binary files /dev/null and b/static/hardware/cpu-9950x3d/1.webp differ diff --git a/static/hardware/mb-x870e-ice/1.t.webp b/static/hardware/mb-x870e-ice/1.t.webp new file mode 100644 index 0000000..39968a7 Binary files /dev/null and b/static/hardware/mb-x870e-ice/1.t.webp differ diff --git a/static/hardware/mb-x870e-ice/1.webp b/static/hardware/mb-x870e-ice/1.webp new file mode 100644 index 0000000..8f4d40b Binary files /dev/null and b/static/hardware/mb-x870e-ice/1.webp differ diff --git a/static/hardware/mb-x870e-ice/2.t.webp b/static/hardware/mb-x870e-ice/2.t.webp new file mode 100644 index 0000000..20d9539 Binary files /dev/null and b/static/hardware/mb-x870e-ice/2.t.webp differ diff --git a/static/hardware/mb-x870e-ice/2.webp b/static/hardware/mb-x870e-ice/2.webp new file mode 100644 index 0000000..7c6fa28 Binary files /dev/null and b/static/hardware/mb-x870e-ice/2.webp differ diff --git a/static/hardware/mb-x870e-ice/3.t.webp b/static/hardware/mb-x870e-ice/3.t.webp new file mode 100644 index 0000000..1f693dc Binary files /dev/null and b/static/hardware/mb-x870e-ice/3.t.webp differ diff --git a/static/hardware/mb-x870e-ice/3.webp b/static/hardware/mb-x870e-ice/3.webp new file mode 100644 index 0000000..26bb365 Binary files /dev/null and b/static/hardware/mb-x870e-ice/3.webp differ diff --git a/static/hardware/ram-klevv-fitv/1.t.webp b/static/hardware/ram-klevv-fitv/1.t.webp new file mode 100644 index 0000000..8f49754 Binary files /dev/null and b/static/hardware/ram-klevv-fitv/1.t.webp differ diff --git a/static/hardware/ram-klevv-fitv/1.webp b/static/hardware/ram-klevv-fitv/1.webp new file mode 100644 index 0000000..299483d Binary files /dev/null and b/static/hardware/ram-klevv-fitv/1.webp differ diff --git a/static/hardware/ram-klevv-fitv/2.t.webp b/static/hardware/ram-klevv-fitv/2.t.webp new file mode 100644 index 0000000..487d2a0 Binary files /dev/null and b/static/hardware/ram-klevv-fitv/2.t.webp differ diff --git a/static/hardware/ram-klevv-fitv/2.webp b/static/hardware/ram-klevv-fitv/2.webp new file mode 100644 index 0000000..e8afeb5 Binary files /dev/null and b/static/hardware/ram-klevv-fitv/2.webp differ diff --git a/static/hardware/ssd-renegade-g5/1.t.webp b/static/hardware/ssd-renegade-g5/1.t.webp new file mode 100644 index 0000000..ec5807b Binary files /dev/null and b/static/hardware/ssd-renegade-g5/1.t.webp differ diff --git a/static/hardware/ssd-renegade-g5/1.webp b/static/hardware/ssd-renegade-g5/1.webp new file mode 100644 index 0000000..be7ceee Binary files /dev/null and b/static/hardware/ssd-renegade-g5/1.webp differ diff --git a/static/hardware/ssd-renegade-g5/2.t.webp b/static/hardware/ssd-renegade-g5/2.t.webp new file mode 100644 index 0000000..a0da2ea Binary files /dev/null and b/static/hardware/ssd-renegade-g5/2.t.webp differ diff --git a/static/hardware/ssd-renegade-g5/2.webp b/static/hardware/ssd-renegade-g5/2.webp new file mode 100644 index 0000000..690918e Binary files /dev/null and b/static/hardware/ssd-renegade-g5/2.webp differ