Files
website/src/lib/pc/shared.ts
2026-10-25 16:35:30 +00:00

487 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Client-safe helpers for the PC setup tracker (/trackers/pc): labels, colours, formatting
// and the cm/360 maths. No server imports here.
export type Specs = Record<string, string | number | boolean | null>;
export type Part = {
id: number;
deviceId: number;
category: string;
brand: string | null;
model: string;
quantity: number;
specs: Specs;
unitPriceCents: number | null;
totalPriceCents: number | null;
shop: string | null;
orderDate: string | null;
warrantyUntil: string | null;
status: string;
verified: boolean;
dpi: number | null;
measuredDpi: number | null;
url: string | null;
imageUrl: string | null;
/** Owner only (null for everyone else). */
notes: string | null;
orderNumber: string | null;
invoiceNumber: string | null;
source: string | null;
/** Time slider fields (see SLOT_META and presentAt). */
slug: string | null;
section: string | null;
slot: string | null;
installedAt: string | null;
removedAt: string | null;
replacesId: number | null;
usage: string | null;
imageUrls: string[];
hidden: boolean;
};
export type Device = {
id: number;
name: string;
label: string | null;
hostnames: string[];
kind: string;
status: string;
brand: string | null;
model: string | null;
specs: Specs;
priceCents: number | null;
monthlyCents: number | null;
budgetCents: number | null;
shop: string | null;
purchaseDate: string | null;
url: string | null;
imageUrl: string | null;
sortOrder: number;
notes: string | null;
installedAt: string | null;
removedAt: string | null;
imageUrls: string[];
hidden: boolean;
parts: Part[];
};
export type SetupPhoto = {
id: number;
url: string;
caption: string | null;
takenAt: string | null;
sortOrder: number;
};
export type Game = {
id: number;
game: string;
cmPer360: number | null;
yaw: number | null;
inGameSens: number | null;
mouseId: number | null;
fov: string | null;
notes: string | null;
sortOrder: number;
};
/** Build order, then peripherals. Gradient tiles: black/white page, colourful content. */
export const CATEGORY_META: Record<string, { label: string; from: string; to: string }> = {
case: { label: 'Case', from: '#e5e7eb', to: '#9ca3af' },
cpu: { label: 'CPU', from: '#60a5fa', to: '#2563eb' },
motherboard: { label: 'Motherboard', from: '#34d399', to: '#059669' },
ram: { label: 'Memory', from: '#a78bfa', to: '#7c3aed' },
cpu_cooler: { label: 'Cooler', from: '#67e8f9', to: '#0891b2' },
gpu: { label: 'Graphics', from: '#f87171', to: '#dc2626' },
psu: { label: 'Power', from: '#fde047', to: '#ca8a04' },
storage: { label: 'Storage', from: '#fb923c', to: '#ea580c' },
fan: { label: 'Fans', from: '#7dd3fc', to: '#0284c7' },
lighting: { label: 'Lighting', from: '#f0abfc', to: '#c026d3' },
monitor: { label: 'Monitor', from: '#818cf8', to: '#4338ca' },
mouse: { label: 'Mouse', from: '#f472b6', to: '#db2777' },
keyboard: { label: 'Keyboard', from: '#2dd4bf', to: '#0d9488' },
headset: { label: 'Headset', from: '#c4b5fd', to: '#6d28d9' },
audio: { label: 'Audio', from: '#fda4af', to: '#e11d48' },
controller: { label: 'Controller', from: '#bef264', to: '#65a30d' },
accessory: { label: 'Accessory', from: '#cbd5e1', to: '#64748b' },
microphone: { label: 'Microphone', from: '#fca5a5', to: '#ef4444' },
mousepad: { label: 'Mousepad', from: '#86efac', to: '#16a34a' },
webcam: { label: 'Webcam', from: '#93c5fd', to: '#3b82f6' },
chair: { label: 'Chair', from: '#fcd34d', to: '#d97706' },
other: { label: 'Other', from: '#d4d4d8', to: '#71717a' }
};
export const CATEGORIES = Object.keys(CATEGORY_META);
export const categoryLabel = (c: string) => CATEGORY_META[c]?.label ?? c;
export const DEVICE_KIND_META: Record<string, { label: string; from: string; to: string }> = {
desktop: { label: 'Desktop', from: '#22d3ee', to: '#0891b2' },
laptop: { label: 'Laptop', from: '#a78bfa', to: '#7c3aed' },
server: { label: 'Server', from: '#34d399', to: '#059669' },
vm: { label: 'VM', from: '#94a3b8', to: '#475569' },
phone: { label: 'Phone', from: '#f472b6', to: '#db2777' },
tablet: { label: 'Tablet', from: '#fb923c', to: '#ea580c' },
monitor: { label: 'Monitor', from: '#818cf8', to: '#4338ca' },
other: { label: 'Other', from: '#d4d4d8', to: '#71717a' }
};
export const DEVICE_KINDS = Object.keys(DEVICE_KIND_META);
export const DEVICE_STATUSES = ['active', 'spare', 'planned', 'retired', 'sold'] as const;
export const PART_STATUSES = ['in_use', 'planned', 'spare', 'retired', 'sold'] as const;
export const SECTIONS = ['pc', 'desk', 'audio', 'sim'] as const;
export const USAGES = [
'current',
'stored',
'previous',
'work-issued',
'placeholder',
'planned',
'legacy',
'legacy-previous'
] as const;
/**
* Slots on the setup page, in display order. `icon` is a PcIcon category; several DB slots can
* share one card (`card`), e.g. the three small cooler accessories.
*/
export const SLOT_META: Record<string, { label: string; icon: string; card?: string }> = {
case: { label: 'Case', icon: 'case' },
cpu: { label: 'CPU', icon: 'cpu' },
motherboard: { label: 'Motherboard', icon: 'motherboard' },
memory: { label: 'Memory', icon: 'ram' },
gpu: { label: 'Graphics', icon: 'gpu' },
cooler: { label: 'Cooler', icon: 'cpu_cooler' },
psu: { label: 'Power', icon: 'psu' },
storage: { label: 'Storage', icon: 'storage' },
'fans-case': { label: 'Case fans', icon: 'fan' },
'fans-radiator': { label: 'Radiator fans', icon: 'fan' },
lighting: { label: 'Lighting', icon: 'lighting' },
'contact-frame': { label: 'Accessories', icon: 'accessory', card: 'accessories' },
'cooler-mount': { label: 'Accessories', icon: 'accessory', card: 'accessories' },
'thermal-paste': { label: 'Accessories', icon: 'accessory', card: 'accessories' },
monitor: { label: 'Monitor', icon: 'monitor' },
'monitor-arm': { label: 'Arm', icon: 'accessory' },
keyboard: { label: 'Keyboard', icon: 'keyboard' },
keycaps: { label: 'Keycaps', icon: 'keyboard' },
keypad: { label: 'Keypad', icon: 'keyboard' },
'mouse-gaming': { label: 'Mouse', icon: 'mouse' },
'mouse-work': { label: 'Work mouse', icon: 'mouse' },
mousepad: { label: 'Mousepad', icon: 'mousepad' },
webcam: { label: 'Webcam', icon: 'webcam' },
chair: { label: 'Chair', icon: 'chair' },
mic: { label: 'Microphone', icon: 'microphone' },
'mic-arm': { label: 'Mic arm', icon: 'audio' },
headphones: { label: 'Headphones', icon: 'headset' },
'headset-work': { label: 'Work headset', icon: 'headset' },
iems: { label: 'IEMs', icon: 'headset' },
wheel: { label: 'Wheel', icon: 'controller' },
shifter: { label: 'Shifter', icon: 'controller' }
};
const SLOT_ORDER = Object.keys(SLOT_META);
export const slotLabel = (slot: string | null, category: string) =>
(slot && SLOT_META[slot]?.label) ?? categoryLabel(category);
export const slotIcon = (slot: string | null, category: string) =>
(slot && SLOT_META[slot]?.icon) ?? category;
/** Thumbnail next to each self-hosted image: /hardware/x/1.webp -> /hardware/x/1.t.webp. */
export const thumb = (url: string) =>
url.startsWith('/hardware/') ? url.replace(/\.webp$/, '.t.webp') : url;
/** Owned long ago and gone, date unknown: not placeable on the timeline (the "earlier" tray). */
export const isUndatedPast = (p: {
usage?: string | null;
installedAt: string | null;
removedAt: string | null;
}) => p.usage === 'legacy-previous' || (p.usage === 'previous' && !p.removedAt && !p.installedAt);
/** In the setup on day `at` (YYYY-MM-DD). Undated items count as always there. */
export const presentAt = (
p: { installedAt: string | null; removedAt: string | null },
at: string
) => (!p.installedAt || p.installedAt <= at) && (!p.removedAt || p.removedAt > at);
export type SlotCard = { key: string; slot: string; parts: Part[] };
/** Visible parts of one section on day `at`, one card per slot (several parts may share it). */
/** An add-on (e.g. a tilt pivot) extends another part in the same slot card; it is not counted as an extra unit. */
export const isAddOn = (p: { model: string }) => /\badd-on\b/i.test(p.model);
export function slotCards(parts: Part[], section: string, at: string): SlotCard[] {
const cards = new Map<string, SlotCard>();
for (const p of parts) {
if (p.section !== section || p.hidden || isUndatedPast(p) || !presentAt(p, at)) continue;
const slot = p.slot ?? p.category;
const key = SLOT_META[slot]?.card ?? slot;
const card = cards.get(key) ?? { key, slot, parts: [] };
card.parts.push(p);
cards.set(key, card);
}
const rank = (s: string) => {
const i = SLOT_ORDER.indexOf(s);
return i < 0 ? 999 : i;
};
// Add-ons (e.g. a monitor-arm tilt pivot) sit after the item they extend.
for (const c of cards.values()) c.parts.sort((a, b) => Number(isAddOn(a)) - Number(isAddOn(b)));
return [...cards.values()].sort((a, b) => rank(a.slot) - rank(b.slot));
}
export type TimelineEvent = { date: string; added: string[]; removed: string[] };
const shortName = (p: { brand: string | null; model: string; quantity?: number }) => {
const name = `${p.brand ? `${p.brand} ` : ''}${p.model.replace(/\s*\(.*?\)\s*/g, ' ').trim()}`;
return (p.quantity ?? 1) > 1 ? `${p.quantity}× ${name}` : name;
};
/** One event per distinct install/removal date (parts and devices), oldest first. */
export function buildEvents(
items: {
brand: string | null;
model: string | null;
label?: string | null;
quantity?: number;
installedAt: string | null;
removedAt: string | null;
}[]
): TimelineEvent[] {
const byDate = new Map<string, TimelineEvent>();
const at = (d: string) => {
const e = byDate.get(d) ?? { date: d, added: [], removed: [] };
byDate.set(d, e);
return e;
};
for (const it of items) {
const name = shortName({
brand: it.brand,
model: it.model ?? it.label ?? '?',
quantity: it.quantity
});
if (it.installedAt) at(it.installedAt).added.push(name);
if (it.removedAt) at(it.removedAt).removed.push(name);
}
return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date));
}
export const monthYear = (d: string) =>
new Date(`${d}T12:00:00Z`).toLocaleDateString('en-GB', { month: 'short', year: 'numeric' });
export const eventLabel = (e: TimelineEvent) =>
[...e.added.map((n) => `+ ${n}`), ...e.removed.map((n) => `− ${n}`)].join(', ');
export const statusLabel = (s: string) => s.replace('_', ' ');
const eur2 = new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 2
});
const eur0 = new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0
});
export const money = (cents: number | null | undefined) =>
cents === null || cents === undefined
? null
: (cents % 100 === 0 ? eur0 : eur2).format(cents / 100);
export const shortDate = (d: string | null) =>
d
? new Date(`${d}T12:00:00Z`).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric'
})
: null;
/** "key: value" lines (the edit form) from specs and back. */
export const specsToText = (specs: Specs) =>
Object.entries(specs)
.map(([k, v]) => `${k}: ${v ?? ''}`)
.join('\n');
export function textToSpecs(text: string): Specs {
const out: Specs = {};
for (const line of text.split('\n')) {
const at = line.indexOf(':');
if (at < 1) continue;
const key = line.slice(0, at).trim().slice(0, 60);
const raw = line
.slice(at + 1)
.trim()
.slice(0, 300);
if (!key) continue;
out[key] =
raw === ''
? null
: raw === 'true'
? true
: raw === 'false'
? false
: /^-?\d+(\.\d+)?$/.test(raw)
? Number(raw)
: raw;
}
return out;
}
/** "speed_mts" -> "speed mts", for spec chips. */
export const specKey = (k: string) => k.replace(/_/g, ' ');
/**
* In-game sensitivity for a physical cm/360 with a mouse of `dpi` real counts per inch, in a
* game that turns `yaw` degrees per count at sensitivity 1: sens = 360 × 2.54 / (yaw × dpi × cm).
*/
export function sensFor(cmPer360: number | null, yaw: number | null, dpi: number | null) {
if (!cmPer360 || !yaw || !dpi) return null;
return (360 * 2.54) / (yaw * dpi * cmPer360);
}
/** cm/360 that a given in-game sens gives with a mouse (inverse of sensFor). */
export function cmFor(sens: number | null, yaw: number | null, dpi: number | null) {
if (!sens || !yaw || !dpi) return null;
return (360 * 2.54) / (yaw * dpi * sens);
}
/** Physical units: a 3-pack of fans counts as 3. */
export const units = (p: { quantity: number; specs: Specs }) =>
p.quantity * (Number(p.specs.fans_in_pack) || 1);
/** "Lian Li UNI FAN SL-INF 120 Wireless 3-pack White (art 1161338)" -> without packs/brackets. */
export const displayName = (p: { brand: string | null; model: string }) =>
`${p.brand ? `${p.brand} ` : ''}${p.model
.replace(/\s*\(.*?\)\s*/g, ' ')
.replace(/\b\d-pack\s*/gi, '')
.replace(/\s+,/g, ',')
.trim()}`;
const KEY_SPECS: Record<string, [string, (v: string | number | boolean) => string][]> = {
cpu: [
['cores', (v) => `${v} cores`],
['max_turbo_ghz', (v) => `${v} GHz`]
],
gpu: [
['vram_gb', (v) => `${v} GB`],
['architecture', String]
],
ram: [
['capacity_gb', (v) => `${v} GB`],
['speed_mts', (v) => `${v} MT/s`]
],
storage: [
['capacity_gb', (v) => (Number(v) >= 1000 ? `${Number(v) / 1000} TB` : `${v} GB`)],
['interface', String]
],
psu: [
['wattage_w', (v) => `${v} W`],
['efficiency', String]
],
motherboard: [
['chipset', String],
['form_factor', String]
],
case: [
['form_factor', String],
['color', String]
],
cpu_cooler: [
['radiator_mm', (v) => `${v} mm`],
['type', String]
],
fan: [
['size_mm', (v) => `${v} mm`],
['color', String]
],
monitor: [
['size_in', (v) => `${v}"`],
['refresh_hz', (v) => `${v} Hz`],
['resolution', String]
]
};
/** One short spec line for a card, e.g. "16 cores · 5.2 GHz". */
export function keySpec(p: { category: string; specs: Specs }): string | null {
const keys = KEY_SPECS[p.category];
const pairs = keys
? keys.flatMap(([k, f]) => (p.specs[k] != null && p.specs[k] !== '' ? [f(p.specs[k]!)] : []))
: Object.entries(p.specs)
.filter(
([k, v]) =>
v != null &&
v !== '' &&
typeof v !== 'boolean' &&
!k.endsWith('_art') &&
k !== 'sku' &&
k !== 'mfr_part'
)
.slice(0, 1)
.map(([, v]) => String(v));
return pairs.length ? pairs.join(' · ') : null;
}
export type LightboxEntry = {
id: number | null;
kicker: string;
name: string;
qty: number;
specs: Specs;
url: string | null;
since: string | null;
until: string | null;
badges: string[];
note: string | null;
};
export type LightboxItem = { title: string; images: string[]; entries: LightboxEntry[] };
export function usageBadges(p: { usage?: string | null; status: string; model: string | null }) {
const out: string[] = [];
if (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')
out.push('previous');
if (p.usage === 'legacy') out.push('long ago');
if (p.model && /\bTBD\b/.test(p.model)) out.push('model TBD');
if (p.status === 'planned') out.push('planned');
return out;
}
export function partEntry(p: Part): LightboxEntry {
return {
id: p.id,
kicker: slotLabel(p.slot, p.category),
name: displayName(p),
qty: units(p),
specs: Object.fromEntries(
Object.entries(p.specs).filter(
([k, v]) => v != null && v !== '' && !k.endsWith('_art') && k !== 'sku'
)
),
url: p.url,
since: p.installedAt,
until: p.removedAt,
badges: usageBadges(p),
note: null
};
}
export function deviceItem(d: Device): LightboxItem {
return {
title: d.label ?? d.name,
images: d.imageUrls,
entries: [
{
id: null,
kicker: DEVICE_KIND_META[d.kind]?.label ?? d.kind,
name: [d.brand, d.model].filter(Boolean).join(' ') || d.name,
qty: 1,
specs: Object.fromEntries(Object.entries(d.specs).filter(([, v]) => v != null && v !== '')),
url: d.url,
since: d.installedAt ?? d.purchaseDate,
until: d.removedAt,
badges: d.status === 'active' ? [] : [d.status],
note: d.notes
},
...d.parts
.filter((p) => !p.hidden)
.map((p) => ({ ...partEntry(p), kicker: categoryLabel(p.category) }))
]
};
}