All checks were successful
Deploy production / deploy (push) Successful in 1m7s
- hardware: slots, sections, usage, install/removal dates, images and a hidden flag on components and devices; setup_photos table; setup_overview view gains the new columns - data: hide the 2021 friends-PC build and the wrong 140mm / unknown fans (kept, not deleted); 7x SL-INF + 3 Kraken stock fans; mice, audio, desk extras; MacBook Pro 16 M2 Pro 1 TB; dates for every swap so the slider can rebuild the setup on any day - self-hosted product images in static/hardware (webp + thumbs) - prices and order details are owner-only (in a popover)
482 lines
16 KiB
TypeScript
482 lines
16 KiB
TypeScript
// 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). */
|
||
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;
|
||
};
|
||
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) }))
|
||
]
|
||
};
|
||
}
|