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

25
src/lib/pc/Modal.svelte Normal file
View File

@@ -0,0 +1,25 @@
<script lang="ts">
import { onMount, type Snippet } from 'svelte';
// Native <dialog> (focus trap, Esc, backdrop from the browser), opened on mount.
let {
title,
onclose,
children
}: { title: string; onclose: () => void; children: Snippet<[() => void]> } = $props();
let dialog = $state<HTMLDialogElement>();
onMount(() => dialog?.showModal());
const close = () => dialog?.close();
</script>
<dialog
bind:this={dialog}
{onclose}
class="m-auto max-h-[calc(100dvh-2rem)] w-[min(40rem,calc(100vw-2rem))] overflow-y-auto rounded-2xl border border-border bg-popover p-0 text-popover-foreground shadow-2xl backdrop:bg-black/60 backdrop:backdrop-blur-sm"
aria-label={title}
>
<div class="p-5">
<h2 class="mb-4 text-lg font-semibold">{title}</h2>
{@render children(close)}
</div>
</dialog>

160
src/lib/pc/PartCard.svelte Normal file
View File

@@ -0,0 +1,160 @@
<script lang="ts">
import Pencil from 'lucide-svelte/icons/pencil';
import ExternalLink from 'lucide-svelte/icons/external-link';
import PcIcon from './PcIcon.svelte';
import { categoryLabel, money, shortDate, specKey, statusLabel, type Part } from './shared';
let {
part,
owner = false,
today,
onedit
}: { part: Part; owner?: boolean; today: string; onedit?: (p: Part) => void } = $props();
// Internal shop article numbers are noise on a card.
const chips = $derived(
Object.entries(part.specs)
.filter(([k, v]) => v !== null && v !== '' && !k.endsWith('_art') && k !== 'sku')
.slice(0, 5)
);
const underWarranty = $derived(!!part.warrantyUntil && part.warrantyUntil >= today);
const price = $derived(money(part.totalPriceCents));
</script>
<article class="part group" class:planned={part.status === 'planned'}>
<div class="flex items-start gap-3">
<PcIcon category={part.category} />
<div class="min-w-0 flex-1">
<p class="text-[11px] font-medium tracking-wider text-[var(--site-muted)] uppercase">
{categoryLabel(part.category)}{#if part.quantity > 1}&nbsp;· ×{part.quantity}{/if}
</p>
<h3 class="mt-0.5 leading-snug font-semibold">
{#if part.brand}<span class="text-[var(--site-muted)]">{part.brand}</span>{/if}
{part.model}
</h3>
</div>
{#if owner && onedit}
<button
type="button"
class="edit"
onclick={() => onedit(part)}
aria-label="Edit {part.brand ?? ''} {part.model}"><Pencil size={15} /></button
>
{/if}
</div>
{#if chips.length}
<ul class="mt-3 flex flex-wrap gap-1.5">
{#each chips as [k, v] (k)}
<li class="chip">
<span class="text-[var(--site-muted)]">{specKey(k)}</span>
{v === true ? 'yes' : v === false ? 'no' : v}
</li>
{/each}
</ul>
{/if}
{#if part.category === 'mouse' && (part.dpi || part.measuredDpi)}
<p class="mt-2 text-sm">
{#if part.dpi}<span class="chip">set {part.dpi} DPI</span>{/if}
{#if part.measuredDpi}<span class="chip">measured {part.measuredDpi} DPI</span>{/if}
</p>
{/if}
<div class="mt-auto flex flex-wrap items-center gap-x-3 gap-y-1 pt-3 text-sm">
{#if price}<span class="font-semibold tabular-nums">{price}</span>{/if}
{#if part.shop || part.orderDate}
<span class="text-[var(--site-muted)]"
>{[part.shop, shortDate(part.orderDate)].filter(Boolean).join(' · ')}</span
>
{/if}
{#if part.status !== 'in_use'}<span class="badge">{statusLabel(part.status)}</span>{/if}
{#if underWarranty}<span class="badge ok">warranty to {shortDate(part.warrantyUntil)}</span
>{/if}
{#if !part.verified}<span class="badge warn" title="Bought, but not confirmed still in use"
>unverified</span
>{/if}
{#if part.url}<a href={part.url} class="badge link" target="_blank" rel="noreferrer"
>link <ExternalLink size={11} /></a
>{/if}
</div>
{#if owner && (part.notes || part.orderNumber)}
<p class="mt-2 border-t border-[var(--site-border)] pt-2 text-xs text-[var(--site-muted)]">
{#if part.orderNumber}order {part.orderNumber}{#if part.invoiceNumber}, invoice {part.invoiceNumber}{/if}.
{/if}{part.notes ?? ''}
</p>
{/if}
</article>
<style>
.part {
display: flex;
flex-direction: column;
height: 100%;
border: 1px solid var(--site-border);
border-radius: 1rem;
background: var(--site-surface);
padding: 1rem;
transition:
transform 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.part:hover {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--site-text) 35%, var(--site-border));
box-shadow: 0 10px 30px -15px rgb(0 0 0 / 0.5);
}
.part.planned {
border-style: dashed;
}
.chip {
display: inline-flex;
gap: 0.3rem;
border-radius: 999px;
background: var(--site-hover);
padding: 0.1rem 0.55rem;
font-size: 0.75rem;
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.2rem;
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0 0.5rem;
font-size: 0.72rem;
color: var(--site-muted);
}
.badge.ok {
border-color: color-mix(in srgb, #10b981 50%, transparent);
color: #10b981;
}
.badge.warn {
border-color: color-mix(in srgb, #f59e0b 50%, transparent);
color: #f59e0b;
}
.badge.link:hover {
color: var(--site-text);
}
.edit {
border-radius: 0.5rem;
padding: 0.35rem;
color: var(--site-muted);
opacity: 0;
transition: opacity 0.15s;
}
.part:hover .edit,
.edit:focus-visible {
opacity: 1;
}
.edit:hover {
background: var(--site-hover);
color: var(--site-text);
}
@media (hover: none) {
.edit {
opacity: 1;
}
}
</style>

76
src/lib/pc/PcIcon.svelte Normal file
View File

@@ -0,0 +1,76 @@
<script lang="ts">
import Box from 'lucide-svelte/icons/box';
import CircuitBoard from 'lucide-svelte/icons/circuit-board';
import Cloud from 'lucide-svelte/icons/cloud';
import Cpu from 'lucide-svelte/icons/cpu';
import Fan from 'lucide-svelte/icons/fan';
import Gamepad2 from 'lucide-svelte/icons/gamepad-2';
import Gpu from 'lucide-svelte/icons/gpu';
import HardDrive from 'lucide-svelte/icons/hard-drive';
import Headphones from 'lucide-svelte/icons/headphones';
import Keyboard from 'lucide-svelte/icons/keyboard';
import Laptop from 'lucide-svelte/icons/laptop';
import Lightbulb from 'lucide-svelte/icons/lightbulb';
import MemoryStick from 'lucide-svelte/icons/memory-stick';
import Monitor from 'lucide-svelte/icons/monitor';
import Mouse from 'lucide-svelte/icons/mouse';
import Package from 'lucide-svelte/icons/package';
import PcCase from 'lucide-svelte/icons/pc-case';
import Server from 'lucide-svelte/icons/server';
import Smartphone from 'lucide-svelte/icons/smartphone';
import Snowflake from 'lucide-svelte/icons/snowflake';
import Speaker from 'lucide-svelte/icons/speaker';
import Tablet from 'lucide-svelte/icons/tablet';
import Zap from 'lucide-svelte/icons/zap';
import { CATEGORY_META, DEVICE_KIND_META } from './shared';
// A gradient tile with a white icon, for a part category or a device kind.
let {
category = undefined,
kind = undefined,
class: className = 'size-10'
}: { category?: string; kind?: string; class?: string } = $props();
const icons = {
case: PcCase,
cpu: Cpu,
motherboard: CircuitBoard,
ram: MemoryStick,
cpu_cooler: Snowflake,
gpu: Gpu,
psu: Zap,
storage: HardDrive,
fan: Fan,
lighting: Lightbulb,
monitor: Monitor,
mouse: Mouse,
keyboard: Keyboard,
headset: Headphones,
audio: Speaker,
controller: Gamepad2,
accessory: Package,
other: Box,
desktop: PcCase,
laptop: Laptop,
server: Server,
vm: Cloud,
phone: Smartphone,
tablet: Tablet
} as const;
const key = $derived(category ?? kind ?? 'other');
const meta = $derived(
(category ? CATEGORY_META[category] : kind ? DEVICE_KIND_META[kind] : undefined) ??
CATEGORY_META.other
);
const Icon = $derived(icons[key as keyof typeof icons] ?? Box);
const light = $derived(key === 'case' || key === 'psu' || key === 'controller');
</script>
<span
class="flex shrink-0 items-center justify-center rounded-xl shadow-sm ring-1 ring-black/10 ring-inset {className}"
style:background-image="linear-gradient(135deg, {meta.from}, {meta.to})"
style:color={light ? '#111' : '#fff'}
aria-hidden="true"
>
<Icon class="size-[52%]" strokeWidth={2} />
</span>

17
src/lib/pc/shared.test.ts Normal file
View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { cmFor, sensFor, specsToText, textToSpecs } from './shared';
describe('pc tracker helpers', () => {
it('computes CS2 sensitivity from cm/360 and real DPI', () => {
// 800 DPI, sens 1.0 in CS2 (yaw 0.022) is about 51.95 cm/360.
expect(cmFor(1, 0.022, 800)).toBeCloseTo(51.95, 1);
expect(sensFor(51.95, 0.022, 800)).toBeCloseTo(1, 2);
expect(sensFor(null, 0.022, 800)).toBeNull();
expect(sensFor(40, null, 800)).toBeNull();
});
it('round-trips specs through the edit textarea', () => {
const specs = { socket: 'AM5', cores: 8, rgb: true, note: null };
expect(textToSpecs(specsToText(specs))).toEqual(specs);
expect(textToSpecs('no colon here\n: empty key\nspeed_mts: 3600')).toEqual({ speed_mts: 3600 });
});
});

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);
}

View File

@@ -72,6 +72,13 @@ const allTrackers = [
external: false,
private: false
},
{
name: 'pc',
subtitle: 'my PC setup: every part, my devices, the next build and mouse sensitivities',
href: '/trackers/pc',
external: false,
private: false
},
{
name: 'health',
subtitle: 'private: body, workouts, sleep, meds and vaccinations',

View File

@@ -49,6 +49,11 @@ const publicPages: Record<string, { title: string; description: string }> = {
description:
'Search every car brand and model: specs, fuel types, Dutch registrations and US fuel economy.'
},
'/trackers/pc': {
title: 'PC setup',
description:
'Stan Runge’s PC setup: every part with specs and prices, his devices and servers, the planned next build and mouse sensitivities.'
},
'/lists': { title: 'Lists', description: 'Lists collected and maintained by Stan Runge.' },
'/projects/game-tracker': {
title: 'Game Tracker',

View File

@@ -0,0 +1,191 @@
import { sql } from 'drizzle-orm';
import {
bigint,
boolean,
check,
date,
index,
integer,
jsonb,
numeric,
text,
unique
} from 'drizzle-orm/pg-core';
import { hardwareSchema } from './schemas';
import { timestamptz } from './helpers';
// PC setup tracker (/trackers/pc): every device Stan owns or plans (PCs, laptops, servers,
// phones, tablets) and the parts/peripherals on them, plus game sensitivities (cm/360) so
// every mouse feels the same everywhere. The tables predate Drizzle (hand-made in Oct 2026
// from Stan's own list + Megekko/Alternate order mails), so ids are bigint identities.
// Unknown stays null; nothing is estimated. Order/invoice numbers and notes are owner-only.
export const HARDWARE_DEVICE_KINDS = [
'desktop',
'laptop',
'server',
'vm',
'phone',
'tablet',
'monitor',
'other'
] as const;
export const HARDWARE_DEVICE_STATUSES = ['active', 'spare', 'planned', 'retired', 'sold'] as const;
export const HARDWARE_CATEGORIES = [
'case',
'cpu',
'motherboard',
'ram',
'cpu_cooler',
'gpu',
'psu',
'storage',
'fan',
'lighting',
'monitor',
'mouse',
'keyboard',
'headset',
'audio',
'controller',
'accessory',
'other'
] as const;
export const HARDWARE_STATUSES = ['in_use', 'planned', 'spare', 'retired', 'sold'] as const;
const list = (values: readonly string[]) => sql.raw(values.map((v) => `'${v}'`).join(', '));
/** Structured specs; free-form per category, e.g. {"socket":"AM5","cores":8}. */
export type HardwareSpecs = Record<string, string | number | boolean | null>;
export const hardwareDevices = hardwareSchema.table(
'devices',
{
id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(),
/** Unique hostname-style slug, e.g. stan-pc, stan-ovh. */
name: text('name').notNull().unique(),
/** Display name, e.g. "Main PC". */
label: text('label'),
/** Other hostnames/OSes this device runs as, e.g. {stan-arch, stan-win11}. */
hostnames: text('hostnames').array().notNull().default([]),
kind: text('kind').notNull(),
status: text('status').notNull().default('active'),
brand: text('brand'),
model: text('model'),
specs: jsonb('specs').$type<HardwareSpecs>().notNull().default({}),
/** One-off purchase price, euro cents incl. VAT. */
priceCents: integer('price_cents'),
/** Recurring cost per month, euro cents (servers/VMs); see notes for VAT. */
monthlyCents: integer('monthly_cents'),
/** Budget for planned builds, euro cents. */
budgetCents: integer('budget_cents'),
shop: text('shop'),
purchaseDate: date('purchase_date'),
url: text('url'),
imageUrl: text('image_url'),
sortOrder: integer('sort_order').notNull().default(100),
notes: text('notes'),
createdAt: timestamptz('created_at').notNull().defaultNow(),
updatedAt: timestamptz('updated_at')
.notNull()
.defaultNow()
.$onUpdate(() => new Date())
},
() => [
check('devices_kind_check', sql`kind in (${list(HARDWARE_DEVICE_KINDS)})`),
check('devices_status_check', sql`status in (${list(HARDWARE_DEVICE_STATUSES)})`),
check(
'devices_cents_check',
sql`(price_cents is null or price_cents >= 0) and (monthly_cents is null or monthly_cents >= 0) and (budget_cents is null or budget_cents >= 0)`
)
]
);
export const hardwareComponents = hardwareSchema.table(
'components',
{
id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(),
deviceId: bigint('device_id', { mode: 'number' })
.notNull()
.references(() => hardwareDevices.id, { onDelete: 'cascade' }),
category: text('category').notNull(),
brand: text('brand'),
model: text('model').notNull(),
quantity: integer('quantity').notNull().default(1),
specs: jsonb('specs').$type<HardwareSpecs>().notNull().default({}),
/** Per unit, euro cents incl. VAT. */
unitPriceCents: integer('unit_price_cents'),
/** unit price × quantity, euro cents incl. VAT, excl. shipping. */
totalPriceCents: integer('total_price_cents'),
shop: text('shop'),
orderNumber: text('order_number'),
orderDate: date('order_date'),
warrantyUntil: date('warranty_until'),
invoiceNumber: text('invoice_number'),
status: text('status').notNull().default('in_use'),
/** false: the purchase is real but whether it is (still) in this setup is unconfirmed. */
verified: boolean('verified').notNull().default(true),
/** Mice: DPI set in the mouse software, and the real DPI measured with a ruler. */
dpi: integer('dpi'),
measuredDpi: integer('measured_dpi'),
url: text('url'),
imageUrl: text('image_url'),
notes: text('notes'),
/** user_list | gmail | user_statement | manual | plan. */
source: text('source'),
createdAt: timestamptz('created_at').notNull().defaultNow(),
updatedAt: timestamptz('updated_at')
.notNull()
.defaultNow()
.$onUpdate(() => new Date())
},
(t) => [
check('components_category_check', sql`category in (${list(HARDWARE_CATEGORIES)})`),
check('components_status_check', sql`status in (${list(HARDWARE_STATUSES)})`),
check('components_quantity_check', sql`quantity > 0`),
check('components_unit_price_cents_check', sql`unit_price_cents >= 0`),
check('components_total_price_cents_check', sql`total_price_cents >= 0`),
check(
'components_dpi_check',
sql`(dpi is null or dpi > 0) and (measured_dpi is null or measured_dpi > 0)`
),
unique('components_dedupe_uq').on(t.deviceId, t.model, t.orderNumber).nullsNotDistinct(),
index('components_device_idx').on(t.deviceId),
index('components_category_idx').on(t.category),
index('components_specs_gin').using('gin', t.specs)
]
);
/**
* One row per game: the physical sensitivity Stan wants (cm per 360° turn) and, where the
* game's yaw is known (degrees per mouse count at sens 1, e.g. CS2 0.022), the in-game value
* is computed per mouse from its measured DPI. Stored values are only what Stan entered.
*/
export const hardwareSensitivities = hardwareSchema.table(
'sensitivities',
{
id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(),
game: text('game').notNull().unique(),
cmPer360: numeric('cm_per_360', { precision: 7, scale: 2, mode: 'number' }),
/** Degrees per count at sensitivity 1 (CS2/Source: 0.022). */
yaw: numeric('yaw', { precision: 10, scale: 6, mode: 'number' }),
/** What is set in-game right now (as entered by Stan). */
inGameSens: numeric('in_game_sens', { precision: 10, scale: 4, mode: 'number' }),
/** Mouse this value was set with (a hardware.components row, category mouse). */
mouseId: bigint('mouse_id', { mode: 'number' }).references(() => hardwareComponents.id, {
onDelete: 'set null'
}),
fov: text('fov'),
notes: text('notes'),
sortOrder: integer('sort_order').notNull().default(100),
createdAt: timestamptz('created_at').notNull().defaultNow(),
updatedAt: timestamptz('updated_at')
.notNull()
.defaultNow()
.$onUpdate(() => new Date())
},
() => [
check('sensitivities_cm_check', sql`cm_per_360 is null or cm_per_360 > 0`),
check('sensitivities_yaw_check', sql`yaw is null or yaw > 0`)
]
);

View File

@@ -19,3 +19,4 @@ export * from './groceries';
export * from './home';
export * from './cars';
export * from './music';
export * from './hardware';

View File

@@ -1,8 +1,8 @@
import { pgSchema } from 'drizzle-orm/pg-core';
// One Postgres schema per domain (see ~/dev/setup/db/PLAN.md). `lists`, `legacy`,
// `energy` and `hardware` are deliberately not modelled here: lists are created at
// runtime through meta.create_list(), and energy/hardware belong to other tools.
// One Postgres schema per domain (see ~/dev/setup/db/PLAN.md). `lists`, `legacy` and
// `energy` are deliberately not modelled here: lists are created at runtime through
// meta.create_list(), and energy belongs to ned-collector.
export const authSchema = pgSchema('auth');
export const moneySchema = pgSchema('money');
export const houseSchema = pgSchema('house');
@@ -19,3 +19,4 @@ export const groceriesSchema = pgSchema('groceries');
export const homeSchema = pgSchema('home');
export const carsSchema = pgSchema('cars');
export const musicSchema = pgSchema('music');
export const hardwareSchema = pgSchema('hardware');

360
src/lib/server/hardware.ts Normal file
View File

@@ -0,0 +1,360 @@
// PC setup tracker (/trackers/pc): reads and owner-only writes on the hardware schema.
// Public visitors get everything except component notes, order and invoice numbers.
import { db } from '#lib/server/db/index.js';
import {
HARDWARE_CATEGORIES,
HARDWARE_DEVICE_KINDS,
HARDWARE_DEVICE_STATUSES,
HARDWARE_STATUSES,
hardwareComponents,
hardwareDevices,
hardwareSensitivities
} from '#lib/server/db/schema/index.js';
import { textToSpecs, type Device, type Game, type Part } from '#lib/pc/shared.js';
import { asc, eq, sql } from 'drizzle-orm';
import * as z from 'zod';
const ORDER = [...HARDWARE_CATEGORIES];
export async function loadSetup(owner: boolean) {
const [devices, parts, games] = await Promise.all([
db
.select()
.from(hardwareDevices)
.orderBy(asc(hardwareDevices.sortOrder), asc(hardwareDevices.name)),
db
.select()
.from(hardwareComponents)
.orderBy(
sql`array_position(${sql.raw(`ARRAY[${ORDER.map((c) => `'${c}'`).join(',')}]`)}::text[], ${hardwareComponents.category})`,
sql`${hardwareComponents.orderDate} nulls last`,
asc(hardwareComponents.model)
),
db
.select()
.from(hardwareSensitivities)
.orderBy(asc(hardwareSensitivities.sortOrder), asc(hardwareSensitivities.game))
]);
const byDevice = new Map<number, Part[]>();
for (const p of parts) {
const part: Part = {
id: p.id,
deviceId: p.deviceId,
category: p.category,
brand: p.brand,
model: p.model,
quantity: p.quantity,
specs: p.specs ?? {},
unitPriceCents: p.unitPriceCents,
totalPriceCents: p.totalPriceCents,
shop: p.shop,
orderDate: p.orderDate,
warrantyUntil: p.warrantyUntil,
status: p.status,
verified: p.verified,
dpi: p.dpi,
measuredDpi: p.measuredDpi,
url: p.url,
imageUrl: p.imageUrl,
notes: owner ? p.notes : null,
orderNumber: owner ? p.orderNumber : null,
invoiceNumber: owner ? p.invoiceNumber : null,
source: owner ? p.source : null
};
const list = byDevice.get(p.deviceId) ?? [];
list.push(part);
byDevice.set(p.deviceId, list);
}
const out: Device[] = devices.map((d) => ({
id: d.id,
name: d.name,
label: d.label,
hostnames: d.hostnames,
kind: d.kind,
status: d.status,
brand: d.brand,
model: d.model,
specs: d.specs ?? {},
priceCents: d.priceCents,
monthlyCents: d.monthlyCents,
budgetCents: d.budgetCents,
shop: d.shop,
purchaseDate: d.purchaseDate,
url: d.url,
imageUrl: d.imageUrl,
sortOrder: d.sortOrder,
notes: d.notes,
parts: byDevice.get(d.id) ?? []
}));
const sens: Game[] = games.map((g) => ({
id: g.id,
game: g.game,
cmPer360: g.cmPer360,
yaw: g.yaw,
inGameSens: g.inGameSens,
mouseId: g.mouseId,
fov: g.fov,
notes: owner ? g.notes : null,
sortOrder: g.sortOrder
}));
return { devices: out, games: sens };
}
// ---- form parsing (owner writes) ----
const str = (max: number) =>
z
.string()
.trim()
.max(max)
.transform((v) => (v === '' ? null : v))
.nullable()
.default(null);
const req = (max: number) => z.string().trim().min(1).max(max);
const int = (min: number, max: number) =>
z
.string()
.trim()
.transform((v, ctx) => {
if (v === '') return null;
const n = Number(v);
if (!Number.isInteger(n) || n < min || n > max) {
ctx.addIssue({ code: 'custom', message: `must be a whole number ${min}-${max}` });
return z.NEVER;
}
return n;
})
.nullable()
.default(null);
const num = (max: number) =>
z
.string()
.trim()
.transform((v, ctx) => {
if (v === '') return null;
const n = Number(v.replace(',', '.'));
if (!Number.isFinite(n) || n <= 0 || n > max) {
ctx.addIssue({ code: 'custom', message: 'must be a positive number' });
return z.NEVER;
}
return n;
})
.nullable()
.default(null);
/** Euros as typed ("1299", "1.299,00", "61.74") to cents. */
const cents = z
.string()
.trim()
.transform((v, ctx) => {
if (v === '') return null;
let s = v.replace(/[€\s]/g, '');
if (/,\d{1,2}$/.test(s)) s = s.replace(/\./g, '').replace(',', '.');
else s = s.replace(/,/g, '');
const n = Number(s);
if (!Number.isFinite(n) || n < 0 || n > 10_000_000) {
ctx.addIssue({ code: 'custom', message: 'not a price' });
return z.NEVER;
}
return Math.round(n * 100);
})
.nullable()
.default(null);
const day = z
.string()
.trim()
.transform((v, ctx) => {
if (v === '') return null;
if (!/^\d{4}-\d{2}-\d{2}$/.test(v)) {
ctx.addIssue({ code: 'custom', message: 'use YYYY-MM-DD' });
return z.NEVER;
}
return v;
})
.nullable()
.default(null);
const link = z
.string()
.trim()
.max(1000)
.transform((v, ctx) => {
if (v === '') return null;
if (!/^https?:\/\//i.test(v)) {
ctx.addIssue({ code: 'custom', message: 'links start with http(s)://' });
return z.NEVER;
}
return v;
})
.nullable()
.default(null);
const specs = z
.string()
.max(5000)
.default('')
.transform((v) => textToSpecs(v));
const pick = <T extends readonly [string, ...string[]]>(values: T) => z.enum(values);
const partSchema = z.object({
id: int(1, 2 ** 31),
deviceId: z.coerce.number().int().positive(),
category: pick(HARDWARE_CATEGORIES),
brand: str(120),
model: req(300),
quantity: z.coerce.number().int().min(1).max(999).default(1),
specs,
unitPrice: cents,
shop: str(120),
orderDate: day,
warrantyUntil: day,
orderNumber: str(120),
invoiceNumber: str(120),
status: pick(HARDWARE_STATUSES),
verified: z
.string()
.optional()
.transform((v) => v === 'on' || v === 'true'),
dpi: int(1, 100_000),
measuredDpi: int(1, 100_000),
url: link,
imageUrl: link,
notes: str(4000)
});
const deviceSchema = z.object({
id: int(1, 2 ** 31),
name: z
.string()
.trim()
.toLowerCase()
.regex(/^[a-z0-9][a-z0-9-]{0,62}$/, 'lowercase letters, digits and dashes'),
label: str(120),
hostnames: z
.string()
.default('')
.transform((v) =>
v
.split(/[\s,]+/)
.map((h) => h.trim().toLowerCase())
.filter(Boolean)
.slice(0, 20)
),
kind: pick(HARDWARE_DEVICE_KINDS),
status: pick(HARDWARE_DEVICE_STATUSES),
brand: str(120),
model: str(200),
specs,
price: cents,
monthly: cents,
budget: cents,
shop: str(120),
purchaseDate: day,
url: link,
sortOrder: int(0, 10_000),
notes: str(4000)
});
const gameSchema = z.object({
id: int(1, 2 ** 31),
game: req(120),
cmPer360: num(1000),
yaw: num(100),
inGameSens: num(100_000),
mouseId: int(1, 2 ** 31),
fov: str(60),
notes: str(2000),
sortOrder: int(0, 10_000)
});
type Result = { ok: true } | { ok: false; message: string };
const problem = (e: z.ZodError): Result => ({
ok: false,
message: e.issues.map((i) => `${i.path.join('.') || 'form'}: ${i.message}`).join('; ')
});
const fields = (form: FormData) =>
Object.fromEntries([...form.entries()].map(([k, v]) => [k, typeof v === 'string' ? v : '']));
export async function savePart(form: FormData): Promise<Result> {
const parsed = partSchema.safeParse(fields(form));
if (!parsed.success) return problem(parsed.error);
const { id, unitPrice, ...p } = parsed.data;
const values = {
...p,
unitPriceCents: unitPrice,
totalPriceCents: unitPrice === null ? null : unitPrice * p.quantity,
source: undefined as string | undefined
};
try {
if (id) {
delete values.source;
await db.update(hardwareComponents).set(values).where(eq(hardwareComponents.id, id));
} else {
await db.insert(hardwareComponents).values({ ...values, source: 'manual' });
}
} catch (e) {
return { ok: false, message: dbMessage(e) };
}
return { ok: true };
}
export async function saveDevice(form: FormData): Promise<Result> {
const parsed = deviceSchema.safeParse(fields(form));
if (!parsed.success) return problem(parsed.error);
const { id, price, monthly, budget, sortOrder, ...d } = parsed.data;
const values = {
...d,
priceCents: price,
monthlyCents: monthly,
budgetCents: budget,
sortOrder: sortOrder ?? 100
};
try {
if (id) await db.update(hardwareDevices).set(values).where(eq(hardwareDevices.id, id));
else await db.insert(hardwareDevices).values(values);
} catch (e) {
return { ok: false, message: dbMessage(e) };
}
return { ok: true };
}
export async function saveGame(form: FormData): Promise<Result> {
const parsed = gameSchema.safeParse(fields(form));
if (!parsed.success) return problem(parsed.error);
const { id, sortOrder, ...g } = parsed.data;
const values = { ...g, sortOrder: sortOrder ?? 100 };
try {
if (id)
await db.update(hardwareSensitivities).set(values).where(eq(hardwareSensitivities.id, id));
else await db.insert(hardwareSensitivities).values(values);
} catch (e) {
return { ok: false, message: dbMessage(e) };
}
return { ok: true };
}
const rowId = (form: FormData) => {
const n = Number(form.get('id'));
return Number.isInteger(n) && n > 0 ? n : null;
};
export async function deleteRow(
table: 'part' | 'device' | 'game',
form: FormData
): Promise<Result> {
const id = rowId(form);
if (!id) return { ok: false, message: 'Missing id' };
if (table === 'part') await db.delete(hardwareComponents).where(eq(hardwareComponents.id, id));
else if (table === 'game')
await db.delete(hardwareSensitivities).where(eq(hardwareSensitivities.id, id));
else await db.delete(hardwareDevices).where(eq(hardwareDevices.id, id));
return { ok: true };
}
function dbMessage(e: unknown): string {
const code =
(e as { code?: string; cause?: { code?: string } })?.code ??
(e as { cause?: { code?: string } })?.cause?.code;
if (code === '23505') return 'That already exists (same name, or same device + model + order).';
if (code === '23503') return 'Unknown device or mouse.';
if (code === '23514') return 'A value is out of range.';
console.error('hardware write failed', e);
return 'Could not save';
}

View File

@@ -41,6 +41,8 @@ describe('owner gate', () => {
expect(isOwnerWritePath('/trackers/health/import')).toBe(true);
expect(isOwnerWritePath('/trackers/cars/ford/mustang')).toBe(true);
expect(isOwnerOnlyPath('/trackers/cars/ford/mustang')).toBe(false);
expect(isOwnerWritePath('/trackers/pc')).toBe(true);
expect(isOwnerOnlyPath('/trackers/pc')).toBe(false);
expect(isOwnerWritePath('/trackers/bookshelf')).toBe(false);
expect(isOwnerWritePath('/lists/books')).toBe(false);
});

View File

@@ -49,7 +49,8 @@ export const ownerWritePrefixes = [
'/trackers/houses',
'/trackers/books',
'/trackers/cars',
'/trackers/music'
'/trackers/music',
'/trackers/pc'
] as const;
const normalise = (pathname: string) => pathname.toLowerCase().replace(/\/+$/, '');

View File

@@ -20,6 +20,7 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
'/trackers': 'Trackers',
'/trackers/books': 'Books',
'/trackers/cars': 'Cars',
'/trackers/pc': 'PC setup',
'/blog': 'Blog',
'/lists': 'Lists',
'/login': 'Sign in',

View File

@@ -51,6 +51,12 @@ export const trackerIcons: Record<string, TrackerIcon> = {
ink: WHITE,
paths: ['M9 18V5l12-2v13', c(6, 18, 3), c(18, 16, 3)]
},
pc: {
from: '#22d3ee',
to: '#0891b2',
ink: WHITE,
paths: ['M4 4h16v11H4z', 'M9 19h6M12 15v4', 'M8 8h3v3H8z', 'M14 8.5h2M14 11h2']
},
health: {
from: '#f472b6',
to: '#db2777',