pc: PC setup tracker at /trackers/pc on the hardware schema
All checks were successful
Deploy production / deploy (push) Successful in 1m4s

- model the existing hardware schema in Drizzle (devices, components, new sensitivities)
- idempotent reconciling migration: new columns, checks, setup_overview view, grants, meta.tables
- seed: main PC labels/hostnames, planned AMD X3D + RTX 5090 build, stan-ovh (OVH RISE-S),
  other devices, sensitivity placeholders (no invented cm/360)
- owner-only add/edit via modals; public view hides notes/order numbers
- trackers dropdown entry + icon, seo/title/sitemap, owner write prefix
This commit is contained in:
2026-10-24 17:55:48 +00:00
parent 8442d59b68
commit 085a30bec8
21 changed files with 23538 additions and 6 deletions

177
src/lib/pc/shared.ts Normal file
View File

@@ -0,0 +1,177 @@
// 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;
};
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;
parts: Part[];
};
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' },
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 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);
}