pc tracker: Megekko new build (9950X3D/X870E), dated ordered/leaving badges, future slider
All checks were successful
Deploy production / deploy (push) Successful in 1m15s
@@ -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}`);
|
||||
</script>
|
||||
@@ -69,7 +69,7 @@
|
||||
>
|
||||
{/if}
|
||||
{#if compact && flag}<span class="flag" class:warn={flag === 'model TBD'}
|
||||
>{flag.replace('model ', '')}</span
|
||||
>{flag.replace('model ', '').replace(/^ordered, arriving/, 'arrives')}</span
|
||||
>{/if}
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="label">
|
||||
|
||||
@@ -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 @@
|
||||
<span class="icon" aria-hidden="true"><History size={16} /></span>
|
||||
<div class="track order-last sm:order-none" bind:clientWidth={width}>
|
||||
<div class="rail"></div>
|
||||
{#if nowPct !== null}<div class="future" style:left="{nowPct}%"></div>{/if}
|
||||
<div class="fill" style:width="{((current - lo) / span) * 100}%"></div>
|
||||
{#if nowPct !== null}<span class="now" style:left="{nowPct}%" title="today"></span>{/if}
|
||||
{#each years.filter((y, i) => width > 520 || i % 2 === 0) as y (y.y)}
|
||||
<span class="year" style:left="{y.pct}%">{y.y}</span>
|
||||
{/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 @@
|
||||
</div>
|
||||
<div class="when ml-auto sm:ml-0">
|
||||
<span class="date tabular-nums">{shortDate(value)}</span>
|
||||
<button type="button" class="today" disabled={value === max} onclick={() => (value = max)}
|
||||
<button type="button" class="today" disabled={value === today} onclick={() => (value = today)}
|
||||
><RotateCcw size={13} /> today</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<p class="last" aria-live="polite">
|
||||
{#if last}
|
||||
{#if value > today}<span class="soon">upcoming</span> ·
|
||||
{/if}{#if last}
|
||||
<span class="text-[var(--site-text)]">{monthYear(last.date)}</span> · {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 {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <day>" (or
|
||||
* "from <day>" 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 <day>" 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');
|
||||
|
||||
@@ -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<string | null>(
|
||||
(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<typeof setTimeout> | undefined;
|
||||
let mounted = false;
|
||||
$effect(() => {
|
||||
@@ -274,7 +288,22 @@
|
||||
</header>
|
||||
|
||||
<div class="sticky top-0 z-40 -mx-1 mb-3 px-1 pt-1">
|
||||
<TimeSlider min={minDay} max={today} {events} bind:value={at} />
|
||||
<TimeSlider min={minDay} max={maxDay} {today} {events} bind:value={at} />
|
||||
{#if incomingDay && at < incomingDay}
|
||||
<p class="incoming">
|
||||
<Truck size={14} />
|
||||
<span
|
||||
>Ordered, arriving {shortDate(incomingDay)}: {incoming.map(displayName).join(', ')}.</span
|
||||
>
|
||||
<button type="button" onclick={() => (at = incomingDay)}>preview the new build</button>
|
||||
</p>
|
||||
{:else if at > today}
|
||||
<p class="incoming">
|
||||
<Truck size={14} />
|
||||
<span>Preview of {shortDate(at)}: parts marked "ordered" have not arrived yet.</span>
|
||||
<button type="button" onclick={() => (at = today)}>back to today</button>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="top">
|
||||
@@ -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);
|
||||
}
|
||||
</style>
|
||||
|
||||
BIN
static/hardware/case-o11d-mini-v2/1.t.webp
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
static/hardware/case-o11d-mini-v2/1.webp
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
static/hardware/case-o11d-mini-v2/2.t.webp
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
static/hardware/case-o11d-mini-v2/2.webp
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
static/hardware/case-o11d-mini-v2/3.t.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
static/hardware/case-o11d-mini-v2/3.webp
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
static/hardware/cooler-lf3-360/1.t.webp
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
static/hardware/cooler-lf3-360/1.webp
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
static/hardware/cooler-lf3-360/2.t.webp
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
static/hardware/cooler-lf3-360/2.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
static/hardware/cooler-lf3-360/3.t.webp
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
static/hardware/cooler-lf3-360/3.webp
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
static/hardware/cpu-9950x3d/1.t.webp
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
static/hardware/cpu-9950x3d/1.webp
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
static/hardware/mb-x870e-ice/1.t.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
static/hardware/mb-x870e-ice/1.webp
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
static/hardware/mb-x870e-ice/2.t.webp
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
static/hardware/mb-x870e-ice/2.webp
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
static/hardware/mb-x870e-ice/3.t.webp
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
static/hardware/mb-x870e-ice/3.webp
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
static/hardware/ram-klevv-fitv/1.t.webp
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
static/hardware/ram-klevv-fitv/1.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
static/hardware/ram-klevv-fitv/2.t.webp
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
static/hardware/ram-klevv-fitv/2.webp
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
static/hardware/ssd-renegade-g5/1.t.webp
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
static/hardware/ssd-renegade-g5/1.webp
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
static/hardware/ssd-renegade-g5/2.t.webp
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
static/hardware/ssd-renegade-g5/2.webp
Normal file
|
After Width: | Height: | Size: 31 KiB |