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

View File

@@ -4,7 +4,6 @@ import { defineConfig } from 'drizzle-kit';
// - lists: runtime tables created through meta.create_list() (owned by personal_ddl)
// - legacy: pre-reorg tables kept read-only for rollback
// - energy: owned by ned-collector
// - hardware: not modelled yet
// Migrations run as the schema owner (`personal`), never as the app role.
export default defineConfig({
dialect: 'postgresql',
@@ -26,7 +25,8 @@ export default defineConfig({
'groceries',
'home',
'cars',
'music'
'music',
'hardware'
],
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
verbose: true,

View File

@@ -0,0 +1,290 @@
-- Hand-reconciled: the hardware schema already existed (hand-made 2026-10-22 from Stan's list +
-- order mails, see ~/dev/hardware/schema.sql; prices later moved to cents). This brings an existing
-- schema to the Drizzle model, and creates it from scratch on an empty database.
CREATE SCHEMA IF NOT EXISTS "hardware";
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "hardware"."devices" (
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hardware"."devices_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"name" text NOT NULL UNIQUE,
"label" text,
"hostnames" text[] DEFAULT '{}'::text[] NOT NULL,
"kind" text NOT NULL,
"status" text DEFAULT 'active' NOT NULL,
"brand" text,
"model" text,
"specs" jsonb DEFAULT '{}' NOT NULL,
"price_cents" integer,
"monthly_cents" integer,
"budget_cents" integer,
"shop" text,
"purchase_date" date,
"url" text,
"image_url" text,
"sort_order" integer DEFAULT 100 NOT NULL,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "devices_kind_check" CHECK (kind in ('desktop', 'laptop', 'server', 'vm', 'phone', 'tablet', 'monitor', 'other')),
CONSTRAINT "devices_status_check" CHECK (status in ('active', 'spare', 'planned', 'retired', 'sold')),
CONSTRAINT "devices_cents_check" CHECK ((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))
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "hardware"."components" (
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hardware"."components_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"device_id" bigint NOT NULL,
"category" text NOT NULL,
"brand" text,
"model" text NOT NULL,
"quantity" integer DEFAULT 1 NOT NULL,
"specs" jsonb DEFAULT '{}' NOT NULL,
"unit_price_cents" integer,
"total_price_cents" integer,
"shop" text,
"order_number" text,
"order_date" date,
"warranty_until" date,
"invoice_number" text,
"status" text DEFAULT 'in_use' NOT NULL,
"verified" boolean DEFAULT true NOT NULL,
"dpi" integer,
"measured_dpi" integer,
"url" text,
"image_url" text,
"notes" text,
"source" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "components_dedupe_uq" UNIQUE NULLS NOT DISTINCT("device_id","model","order_number"),
CONSTRAINT "components_category_check" CHECK (category in ('case', 'cpu', 'motherboard', 'ram', 'cpu_cooler', 'gpu', 'psu', 'storage', 'fan', 'lighting', 'monitor', 'mouse', 'keyboard', 'headset', 'audio', 'controller', 'accessory', 'other')),
CONSTRAINT "components_status_check" CHECK (status in ('in_use', 'planned', 'spare', 'retired', 'sold')),
CONSTRAINT "components_quantity_check" CHECK (quantity > 0),
CONSTRAINT "components_unit_price_cents_check" CHECK (unit_price_cents >= 0),
CONSTRAINT "components_total_price_cents_check" CHECK (total_price_cents >= 0),
CONSTRAINT "components_dpi_check" CHECK ((dpi is null or dpi > 0) and (measured_dpi is null or measured_dpi > 0))
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "hardware"."sensitivities" (
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hardware"."sensitivities_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"game" text NOT NULL UNIQUE,
"cm_per_360" numeric(7,2),
"yaw" numeric(10,6),
"in_game_sens" numeric(10,4),
"mouse_id" bigint,
"fov" text,
"notes" text,
"sort_order" integer DEFAULT 100 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "sensitivities_cm_check" CHECK (cm_per_360 is null or cm_per_360 > 0),
CONSTRAINT "sensitivities_yaw_check" CHECK (yaw is null or yaw > 0)
);
--> statement-breakpoint
-- Existing tables: new columns.
ALTER TABLE "hardware"."devices"
ADD COLUMN IF NOT EXISTS "label" text,
ADD COLUMN IF NOT EXISTS "hostnames" text[] DEFAULT '{}'::text[] NOT NULL,
ADD COLUMN IF NOT EXISTS "status" text DEFAULT 'active' NOT NULL,
ADD COLUMN IF NOT EXISTS "brand" text,
ADD COLUMN IF NOT EXISTS "model" text,
ADD COLUMN IF NOT EXISTS "specs" jsonb DEFAULT '{}' NOT NULL,
ADD COLUMN IF NOT EXISTS "price_cents" integer,
ADD COLUMN IF NOT EXISTS "monthly_cents" integer,
ADD COLUMN IF NOT EXISTS "budget_cents" integer,
ADD COLUMN IF NOT EXISTS "shop" text,
ADD COLUMN IF NOT EXISTS "purchase_date" date,
ADD COLUMN IF NOT EXISTS "url" text,
ADD COLUMN IF NOT EXISTS "image_url" text,
ADD COLUMN IF NOT EXISTS "sort_order" integer DEFAULT 100 NOT NULL,
ADD COLUMN IF NOT EXISTS "updated_at" timestamp with time zone DEFAULT now() NOT NULL;
--> statement-breakpoint
ALTER TABLE "hardware"."components"
ADD COLUMN IF NOT EXISTS "dpi" integer,
ADD COLUMN IF NOT EXISTS "measured_dpi" integer,
ADD COLUMN IF NOT EXISTS "url" text,
ADD COLUMN IF NOT EXISTS "image_url" text;
--> statement-breakpoint
-- Checks: widen kinds/categories/statuses (drop + re-add by name).
ALTER TABLE "hardware"."devices" DROP CONSTRAINT IF EXISTS "devices_kind_check";
ALTER TABLE "hardware"."devices" ADD CONSTRAINT "devices_kind_check" CHECK (kind in ('desktop', 'laptop', 'server', 'vm', 'phone', 'tablet', 'monitor', 'other'));
ALTER TABLE "hardware"."devices" DROP CONSTRAINT IF EXISTS "devices_status_check";
ALTER TABLE "hardware"."devices" ADD CONSTRAINT "devices_status_check" CHECK (status in ('active', 'spare', 'planned', 'retired', 'sold'));
ALTER TABLE "hardware"."devices" DROP CONSTRAINT IF EXISTS "devices_cents_check";
ALTER TABLE "hardware"."devices" ADD CONSTRAINT "devices_cents_check" CHECK ((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));
ALTER TABLE "hardware"."components" DROP CONSTRAINT IF EXISTS "components_category_check";
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_category_check" CHECK (category in ('case', 'cpu', 'motherboard', 'ram', 'cpu_cooler', 'gpu', 'psu', 'storage', 'fan', 'lighting', 'monitor', 'mouse', 'keyboard', 'headset', 'audio', 'controller', 'accessory', 'other'));
ALTER TABLE "hardware"."components" DROP CONSTRAINT IF EXISTS "components_status_check";
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_status_check" CHECK (status in ('in_use', 'planned', 'spare', 'retired', 'sold'));
ALTER TABLE "hardware"."components" DROP CONSTRAINT IF EXISTS "components_dpi_check";
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_dpi_check" CHECK ((dpi is null or dpi > 0) and (measured_dpi is null or measured_dpi > 0));
--> statement-breakpoint
-- Dedupe key: unique index -> constraint (same name, same NULLS NOT DISTINCT semantics);
-- device FK -> Drizzle's name.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'components_dedupe_uq' AND connamespace = 'hardware'::regnamespace) THEN
DROP INDEX IF EXISTS "hardware"."components_dedupe_uq";
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_dedupe_uq" UNIQUE NULLS NOT DISTINCT ("device_id","model","order_number");
END IF;
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'components_device_id_fkey' AND connamespace = 'hardware'::regnamespace) THEN
ALTER TABLE "hardware"."components" RENAME CONSTRAINT "components_device_id_fkey" TO "components_device_id_devices_id_fkey";
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'components_device_id_devices_id_fkey' AND connamespace = 'hardware'::regnamespace) THEN
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_device_id_devices_id_fkey" FOREIGN KEY ("device_id") REFERENCES "hardware"."devices"("id") ON DELETE CASCADE;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'sensitivities_mouse_id_components_id_fkey' AND connamespace = 'hardware'::regnamespace) THEN
ALTER TABLE "hardware"."sensitivities" ADD CONSTRAINT "sensitivities_mouse_id_components_id_fkey" FOREIGN KEY ("mouse_id") REFERENCES "hardware"."components"("id") ON DELETE SET NULL;
END IF;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "components_device_idx" ON "hardware"."components" ("device_id");
CREATE INDEX IF NOT EXISTS "components_category_idx" ON "hardware"."components" ("category");
CREATE INDEX IF NOT EXISTS "components_specs_gin" ON "hardware"."components" USING gin ("specs");
--> statement-breakpoint
-- Hand-written: updated_at triggers, overview view, grants, GUI registration, seed.
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY['devices','components','sensitivities'] LOOP
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'touch_updated_at' AND tgrelid = format('hardware.%I', t)::regclass) THEN
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON hardware.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
END IF;
END LOOP;
END $$;
--> statement-breakpoint
DROP VIEW IF EXISTS "hardware"."setup_overview";
--> statement-breakpoint
CREATE VIEW "hardware"."setup_overview" AS
SELECT d.name AS device,
d.label AS device_label,
d.kind AS device_kind,
d.status AS device_status,
c.category,
c.brand,
c.model,
c.quantity,
c.total_price_cents,
round(c.total_price_cents::numeric / 100.0, 2) AS total_price_eur,
c.shop,
c.order_date,
c.warranty_until,
(c.warranty_until >= current_date) AS under_warranty,
c.status,
c.verified,
c.specs,
c.dpi,
c.measured_dpi,
c.notes,
c.order_number,
c.invoice_number,
c.source,
c.id AS component_id
FROM hardware.components c
JOIN hardware.devices d ON d.id = c.device_id
ORDER BY d.sort_order, d.name,
array_position(ARRAY['case','cpu','motherboard','ram','cpu_cooler','gpu','psu','storage','fan','lighting','monitor','mouse','keyboard','headset','audio','controller','accessory','other'], c.category),
c.order_date NULLS LAST, c.model;
--> statement-breakpoint
COMMENT ON VIEW "hardware"."setup_overview" IS 'Flat view of every component per device (build order), with a live under_warranty flag. status = ''in_use'' is the current setup, ''planned'' the upgrade plan.';
--> statement-breakpoint
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT USAGE ON SCHEMA hardware TO personal_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA hardware TO personal_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA hardware TO personal_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA hardware GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA hardware GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
GRANT USAGE ON SCHEMA hardware TO personal_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA hardware TO personal_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA hardware GRANT SELECT ON TABLES TO personal_ro;
END IF;
END $$;
--> statement-breakpoint
DO $$
BEGIN
IF to_regclass('meta.tables') IS NOT NULL THEN
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
VALUES ('hardware','devices','hardware-devices','Hardware devices','core',true,false,1000),
('hardware','components','hardware-components','Hardware components','core',true,false,1000),
('hardware','sensitivities','hardware-sensitivities','Game sensitivities (cm/360)','core',true,false,1000)
ON CONFLICT DO NOTHING;
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
PERFORM meta.sync();
END IF;
END IF;
END $$;
--> statement-breakpoint
-- ===== Seed (idempotent). Facts from Stan's own list, order mails and what he told Daisy;
-- unknowns stay null. No home address in public-facing notes.
INSERT INTO hardware.devices (name, kind) VALUES ('stan-pc','desktop'), ('old-pc-2021','desktop')
ON CONFLICT (name) DO NOTHING;
--> statement-breakpoint
UPDATE hardware.devices SET
label = 'Main PC', hostnames = '{stan-arch,stan-win11}', status = 'active', model = 'Custom build',
shop = 'Megekko', sort_order = 10,
notes = 'Stan''s main PC: Lian Li O11 Dynamic XL ROG White, i9-12900KF, RX 7900 XT, 10 Lian Li fans around the case + 3 NZXT Aer on the radiator. Dual-boots Arch (stan-arch) and Windows 11 + WSL2 (stan-win11). Desk peripherals and the Odyssey G9 are tracked here. Mostly bought at Megekko 2021-2022.'
WHERE name = 'stan-pc' AND label IS NULL;
--> statement-breakpoint
UPDATE hardware.devices SET
label = 'Previous PC (2021)', status = 'retired', model = 'Budget build', shop = 'Megekko',
purchase_date = '2021-01-31', sort_order = 90
WHERE name = 'old-pc-2021' AND label IS NULL;
--> statement-breakpoint
INSERT INTO hardware.devices (name, label, hostnames, kind, status, brand, model, specs, price_cents, monthly_cents, budget_cents, shop, purchase_date, url, sort_order, notes) VALUES
('stan-pc-next','Next PC (planned)','{}','desktop','planned',NULL,'High-end AMD + RTX 5090 build','{}',NULL,NULL,1000000,NULL,NULL,NULL,15,
'Planned upgrade, budget up to about EUR 10k: top AMD X3D CPU, RTX 5090, fast DDR5, PCIe Gen5 SSD, high-end motherboard and a cooler that fits. Parts not picked yet.'),
('stan-ovh','Server','{}','server','active','OVHcloud','RISE-S',
'{"cpu":"AMD Ryzen 7 9700X","ram":"64 GB DDR5 ECC","storage":"2x 512 GB NVMe (software RAID1)","os":"Ubuntu 26.04"}',
NULL,6174,NULL,'OVHcloud','2026-10-24','https://www.ovhcloud.com/en/bare-metal/rise/',20,
'Dedicated server, prod since 2026-10-24 (replaced stan-vm). EUR 61.74/month excl. VAT. Runs Postgres, Gitea, the website and the bots.'),
('stan-vm','Old VM','{}','vm','retired','Hetzner Cloud',NULL,'{"vcpu":4,"ram":"8 GB"}',NULL,2000,NULL,'Hetzner',NULL,NULL,80,
'Old always-on Hetzner VM (about EUR 20/month, bought before the price rises). Replaced by stan-ovh on 2026-10-24; stopped, kept as a fallback until Stan cancels it.'),
('stan-macbookpro','MacBook Pro','{}','laptop','active','Apple','MacBook Pro','{}',NULL,NULL,NULL,NULL,NULL,NULL,30,
'Exact model/chip not recorded yet. An M5 Max MacBook Pro is planned.'),
('stan-pixel','Phone','{}','phone','active','Google','Pixel 11 Pro XL','{"storage":"256 GB"}',NULL,NULL,NULL,'Belsimpel','2026-10-18',NULL,40,
'Main phone since 2026-10-19, stock Android. About EUR 1,299 SIM-free (exact amount not recorded).'),
('stan-iphone','Old phone','{}','phone','spare','Apple','iPhone 14 Pro Max','{}',NULL,NULL,NULL,NULL,NULL,NULL,45,
'Previous phone, kept (weak battery and speed).'),
('stan-ipad','iPad','{}','tablet','active','Apple','iPad Air 13-inch (M2)','{"screen_in":13,"chip":"Apple M2"}',NULL,NULL,NULL,NULL,NULL,NULL,50,
'With a keyboard case; used for typing on the go and Termius SSH.'),
('cargill-laptop','Work laptop','{}','laptop','active',NULL,NULL,'{"ram":"16 GB"}',NULL,NULL,NULL,NULL,NULL,NULL,60,
'Cargill work laptop (not personal; Windows + WSL2). Model not recorded.')
ON CONFLICT (name) DO NOTHING;
--> statement-breakpoint
-- Peripherals get real categories instead of "accessory".
UPDATE hardware.components SET category = 'mouse' WHERE category = 'accessory' AND model ILIKE 'MX Master%';
--> statement-breakpoint
UPDATE hardware.components SET category = 'keyboard' WHERE category = 'accessory' AND (model ILIKE 'Alloy Origins%' OR model ILIKE 'Pudding Keycaps%' OR model ILIKE 'UwU%');
--> statement-breakpoint
UPDATE hardware.components SET category = 'controller' WHERE category = 'accessory' AND brand = 'Thrustmaster';
--> statement-breakpoint
UPDATE hardware.components SET category = 'audio' WHERE category = 'accessory' AND model ILIKE '%mic boom arm%';
--> statement-breakpoint
UPDATE hardware.components SET notes = replace(notes, ', delivered 2026-03-03 to Houtenstraat 15 Gouda.', ', delivered 2026-03-03.') WHERE notes LIKE '%Houtenstraat%';
--> statement-breakpoint
INSERT INTO hardware.components (device_id, category, brand, model, quantity, specs, status, verified, notes, source)
SELECT d.id, v.category, v.brand, v.model, v.quantity, v.specs::jsonb, v.status, true, v.notes, v.source
FROM (VALUES
('stan-ovh','cpu','AMD','Ryzen 7 9700X',1,'{"cores":8,"threads":16,"socket":"AM5"}','in_use',NULL,'manual'),
('stan-ovh','ram',NULL,'64 GB DDR5 ECC',1,'{"type":"DDR5 ECC","capacity_gb":64}','in_use',NULL,'manual'),
('stan-ovh','storage',NULL,'512 GB NVMe SSD',2,'{"interface":"NVMe","capacity_gb":512,"raid":"software RAID1"}','in_use','Mirrored (RAID1), about 440 GB usable for data.','manual'),
('stan-pc-next','cpu','AMD','Top Ryzen X3D (model TBD)',1,'{"socket":"AM5"}','planned','Pick the fastest gaming X3D chip available when buying.','plan'),
('stan-pc-next','gpu','NVIDIA','GeForce RTX 5090',1,'{"vram_gb":32}','planned','Board partner TBD.','plan'),
('stan-pc-next','motherboard',NULL,'High-end AM5 motherboard (TBD)',1,'{"socket":"AM5"}','planned',NULL,'plan'),
('stan-pc-next','ram',NULL,'Fast DDR5 kit (TBD)',1,'{"type":"DDR5"}','planned',NULL,'plan'),
('stan-pc-next','storage',NULL,'PCIe 5.0 NVMe SSD (TBD)',1,'{"interface":"PCIe 5.0 x4 NVMe"}','planned',NULL,'plan'),
('stan-pc-next','cpu_cooler',NULL,'Cooler for the new CPU (TBD)',1,'{}','planned','Must fit the new socket and the O11 Dynamic XL (or the new case).','plan')
) AS v(device, category, brand, model, quantity, specs, status, notes, source)
JOIN hardware.devices d ON d.name = v.device
ON CONFLICT (device_id, model, order_number) DO NOTHING;
--> statement-breakpoint
-- Games Stan plays; cm/360 left empty for him to fill. CS2's yaw (0.022 deg/count) is the
-- Source engine constant, so its in-game value is computed from a mouse's measured DPI.
INSERT INTO hardware.sensitivities (game, yaw, sort_order) VALUES
('Counter-Strike 2', 0.022, 10),
('Call of Duty', NULL, 20),
('osu!', NULL, 30),
('Sea of Thieves', NULL, 40)
ON CONFLICT (game) DO NOTHING;

File diff suppressed because it is too large Load Diff

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',

View File

@@ -17,6 +17,7 @@ const staticPaths = [
'/trackers/cars',
'/trackers/cars/brands',
'/trackers/music',
'/trackers/pc',
'/projects/game-tracker',
'/projects/game-tracker/pokemon',
'/projects/game-tracker/osu',

View File

@@ -0,0 +1,53 @@
import { isOwner, requireOwner } from '#lib/server/owner.js';
import { deleteRow, loadSetup, saveDevice, saveGame, savePart } from '#lib/server/hardware.js';
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
// Public page; only the owner sees notes/order numbers and the edit controls.
export const load: PageServerLoad = async (event) => {
const owner = isOwner(event);
if (owner) event.setHeaders({ 'cache-control': 'private, no-store' });
const { devices, games } = await loadSetup(owner);
return {
owner,
devices,
games,
seo: {
title: 'PC setup',
description:
'Every part in my PC with specs and prices, my other devices and servers, the planned next build and my mouse sensitivities.'
}
};
};
const run = async (result: Promise<{ ok: true } | { ok: false; message: string }>) => {
const r = await result;
return r.ok ? { saved: true } : fail(400, { message: r.message });
};
export const actions: Actions = {
savePart: async (event) => {
requireOwner(event);
return run(savePart(await event.request.formData()));
},
deletePart: async (event) => {
requireOwner(event);
return run(deleteRow('part', await event.request.formData()));
},
saveDevice: async (event) => {
requireOwner(event);
return run(saveDevice(await event.request.formData()));
},
deleteDevice: async (event) => {
requireOwner(event);
return run(deleteRow('device', await event.request.formData()));
},
saveGame: async (event) => {
requireOwner(event);
return run(saveGame(await event.request.formData()));
},
deleteGame: async (event) => {
requireOwner(event);
return run(deleteRow('game', await event.request.formData()));
}
};

View File

@@ -0,0 +1,868 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { SubmitFunction } from '@sveltejs/kit';
import Plus from 'lucide-svelte/icons/plus';
import Pencil from 'lucide-svelte/icons/pencil';
import Crosshair from 'lucide-svelte/icons/crosshair';
import Modal from '#lib/pc/Modal.svelte';
import PartCard from '#lib/pc/PartCard.svelte';
import PcIcon from '#lib/pc/PcIcon.svelte';
import {
CATEGORIES,
DEVICE_KINDS,
DEVICE_KIND_META,
DEVICE_STATUSES,
PART_STATUSES,
categoryLabel,
money,
sensFor,
shortDate,
specKey,
specsToText,
statusLabel,
type Device,
type Game,
type Part
} from '#lib/pc/shared.js';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const today = new Date().toISOString().slice(0, 10);
const main = $derived(
data.devices.find((d) => d.status === 'active' && d.kind === 'desktop') ?? null
);
const planned = $derived(data.devices.filter((d) => d.status === 'planned'));
const others = $derived(
data.devices.filter((d) => d !== main && (d.status === 'active' || d.status === 'spare'))
);
const retired = $derived(
data.devices.filter((d) => d.status === 'retired' || d.status === 'sold')
);
const navDevices = $derived([main, ...planned, ...others].filter((d): d is Device => !!d));
const sum = (parts: Part[]) => parts.reduce((s, p) => s + (p.totalPriceCents ?? 0), 0);
const inUse = (d: Device) => d.parts.filter((p) => p.status === 'in_use');
const mainParts = $derived(main ? inUse(main) : []);
const build = $derived(
mainParts.filter((p) =>
[
'case',
'cpu',
'motherboard',
'ram',
'cpu_cooler',
'gpu',
'psu',
'storage',
'fan',
'lighting'
].includes(p.category)
)
);
const desk = $derived(mainParts.filter((p) => !build.includes(p)));
const mice = $derived(
data.devices
.filter((d) => d.status !== 'retired' && d.status !== 'sold')
.flatMap((d) => d.parts)
.filter((p) => p.category === 'mouse' && p.status !== 'sold' && p.status !== 'retired')
);
const monthly = $derived(
data.devices.filter((d) => d.status === 'active').reduce((s, d) => s + (d.monthlyCents ?? 0), 0)
);
const deviceSpecs = (d: Device) =>
Object.entries(d.specs).filter(([, v]) => v !== null && v !== '');
const fmtSens = (n: number | null) =>
n === null ? null : n.toLocaleString('en-GB', { maximumFractionDigits: 3 });
// ---- owner editing ----
let editPart = $state<Partial<Part> | null>(null);
let editDevice = $state<Partial<Device> | null>(null);
let editGame = $state<Partial<Game> | null>(null);
let problem = $state('');
const submit =
(close: () => void): SubmitFunction =>
() => {
problem = '';
return async ({ result, update }) => {
if (result.type === 'failure')
problem = String((result.data as { message?: string })?.message ?? 'Could not save');
else if (result.type === 'success') {
close();
await update({ reset: false });
} else await update();
};
};
const confirmDelete = (what: string) => (event: SubmitEvent) => {
if (!confirm(`Delete ${what}?`)) event.preventDefault();
};
const euros = (cents: number | null | undefined) =>
cents === null || cents === undefined ? '' : (cents / 100).toFixed(2);
</script>
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<header class="mb-8 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">pc setup</h1>
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
Every part in my PC with specs and what I paid, the rest of my devices and servers, the next
build, and the mouse sensitivities I keep the same everywhere.
</p>
</div>
{#if data.owner}
<div class="flex gap-2">
<button
type="button"
class="action"
onclick={() =>
(editPart = {
deviceId: main?.id,
category: 'other',
status: 'in_use',
verified: true,
quantity: 1,
specs: {}
})}><Plus size={15} /> part</button
>
<button
type="button"
class="action"
onclick={() =>
(editDevice = { kind: 'desktop', status: 'active', specs: {}, hostnames: [] })}
><Plus size={15} /> device</button
>
</div>
{/if}
</header>
<dl class="stats">
<div>
<dt>parts in my PC</dt>
<dd>{build.length}</dd>
</div>
<div>
<dt>PC cost (known prices)</dt>
<dd>{money(sum(build)) ?? '–'}</dd>
</div>
<div>
<dt>desk + peripherals</dt>
<dd>{money(sum(desk)) ?? '–'}</dd>
</div>
<div>
<dt>servers / month</dt>
<dd>{money(monthly) ?? '–'}</dd>
</div>
</dl>
<nav class="devices" aria-label="Devices">
{#each navDevices as d (d.id)}
<a href="#{d.name}" class="device-tile">
<PcIcon kind={d.kind} class="size-11" />
<span class="min-w-0">
<span class="block truncate font-medium">{d.label ?? d.name}</span>
<span class="block truncate text-xs text-[var(--site-muted)]"
>{[d.brand, d.model].filter(Boolean).join(' ') || DEVICE_KIND_META[d.kind]?.label}</span
>
</span>
{#if d.status !== 'active'}<span class="pill">{d.status}</span>{/if}
</a>
{/each}
<a href="#sensitivity" class="device-tile">
<span
class="flex size-11 shrink-0 items-center justify-center rounded-xl text-white shadow-sm"
style:background-image="linear-gradient(135deg, #f472b6, #7c3aed)"
aria-hidden="true"><Crosshair size={22} /></span
>
<span class="min-w-0">
<span class="block font-medium">Sensitivity</span>
<span class="block text-xs text-[var(--site-muted)]">cm/360 per game</span>
</span>
</a>
</nav>
{#snippet deviceHeader(d: Device, title: string)}
<div class="mb-4 flex flex-wrap items-center gap-3">
<PcIcon kind={d.kind} class="size-12" />
<div class="min-w-0 flex-1">
<h2 class="text-2xl font-semibold tracking-tight">{title}</h2>
<p class="text-sm text-[var(--site-muted)]">
<code>{d.name}</code>{#each d.hostnames as h (h)}&nbsp;· <code>{h}</code>{/each}
{#if d.brand || d.model}&nbsp;· {[d.brand, d.model].filter(Boolean).join(' ')}{/if}
{#if d.monthlyCents}&nbsp;· {money(d.monthlyCents)}/month{/if}
{#if d.priceCents}&nbsp;· {money(d.priceCents)}{/if}
{#if d.purchaseDate}&nbsp;· since {shortDate(d.purchaseDate)}{/if}
</p>
</div>
{#if data.owner}
<button
type="button"
class="action"
onclick={() =>
(editPart = {
deviceId: d.id,
category: 'other',
status: d.status === 'planned' ? 'planned' : 'in_use',
verified: true,
quantity: 1,
specs: {}
})}><Plus size={15} /> part</button
>
<button
type="button"
class="action"
onclick={() => (editDevice = d)}
aria-label="Edit {d.name}"><Pencil size={15} /></button
>
{/if}
</div>
{#if deviceSpecs(d).length}
<ul class="mb-4 flex flex-wrap gap-2">
{#each deviceSpecs(d) as [k, v] (k)}
<li class="spec"><span>{specKey(k)}</span>{v}</li>
{/each}
</ul>
{/if}
{#if d.notes}<p class="mb-4 max-w-3xl text-sm text-[var(--site-muted)]">{d.notes}</p>{/if}
{/snippet}
{#snippet grid(parts: Part[])}
<ul class="parts">
{#each parts as p (p.id)}
<li><PartCard part={p} owner={data.owner} {today} onedit={(x) => (editPart = x)} /></li>
{/each}
</ul>
{/snippet}
{#if main}
<section id={main.name} class="section">
{@render deviceHeader(main, main.label ?? main.name)}
{@render grid(build)}
{#if desk.length}
<h3 class="mt-8 mb-3 text-lg font-semibold">Desk and peripherals</h3>
{@render grid(desk)}
{/if}
</section>
{/if}
{#each planned as d (d.id)}
{@const spent = sum(d.parts)}
<section id={d.name} class="section">
{@render deviceHeader(d, d.label ?? d.name)}
{#if d.budgetCents}
<div class="budget" aria-label="Budget">
<div class="flex justify-between text-sm">
<span>picked so far <strong class="tabular-nums">{money(spent)}</strong></span>
<span class="text-[var(--site-muted)]">budget {money(d.budgetCents)}</span>
</div>
<div class="bar">
<span style:width="{Math.min(100, (spent / d.budgetCents) * 100)}%"></span>
</div>
</div>
{/if}
{@render grid(d.parts)}
</section>
{/each}
<section id="sensitivity" class="section">
<div class="mb-4 flex flex-wrap items-center gap-3">
<span
class="flex size-12 shrink-0 items-center justify-center rounded-xl text-white shadow-sm"
style:background-image="linear-gradient(135deg, #f472b6, #7c3aed)"
aria-hidden="true"><Crosshair size={24} /></span
>
<div class="flex-1">
<h2 class="text-2xl font-semibold tracking-tight">Mice and sensitivity</h2>
<p class="text-sm text-[var(--site-muted)]">
One physical sensitivity per game (cm per 360° turn), converted per mouse from its
measured DPI, so every mouse feels the same on every PC.
</p>
</div>
{#if data.owner}
<button type="button" class="action" onclick={() => (editGame = {})}
><Plus size={15} /> game</button
>
{/if}
</div>
<div class="mice">
{#each mice as m (m.id)}
<div class="mouse">
<PcIcon category="mouse" class="size-9" />
<div class="min-w-0 flex-1">
<p class="truncate font-medium">{m.brand} {m.model}</p>
<p class="text-sm text-[var(--site-muted)]">
set {m.dpi ?? '?'} DPI · measured
<strong class="text-[var(--site-text)]">{m.measuredDpi ?? '?'}</strong> DPI
</p>
</div>
{#if data.owner}
<button
type="button"
class="action"
onclick={() => (editPart = m)}
aria-label="Edit {m.model}"><Pencil size={14} /></button
>
{/if}
</div>
{:else}
<p class="text-sm text-[var(--site-muted)]">No mice yet.</p>
{/each}
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>game</th>
<th>cm/360</th>
<th>yaw</th>
<th>set now</th>
{#each mice as m (m.id)}<th>{m.model}</th>{/each}
{#if data.owner}<th><span class="sr-only">edit</span></th>{/if}
</tr>
</thead>
<tbody>
{#each data.games as g (g.id)}
{@const setWith = mice.find((m) => m.id === g.mouseId)}
<tr>
<td class="font-medium">{g.game}</td>
<td class="tabular-nums">{g.cmPer360 ?? '–'}</td>
<td class="tabular-nums text-[var(--site-muted)]">{g.yaw ?? '–'}</td>
<td class="tabular-nums"
>{g.inGameSens ?? '–'}{#if setWith}<span class="text-xs text-[var(--site-muted)]">
· {setWith.model}</span
>{/if}</td
>
{#each mice as m (m.id)}
<td class="tabular-nums font-semibold"
>{fmtSens(sensFor(g.cmPer360, g.yaw, m.measuredDpi ?? m.dpi)) ?? '–'}</td
>
{/each}
{#if data.owner}
<td
><button
type="button"
class="action"
onclick={() => (editGame = g)}
aria-label="Edit {g.game}"><Pencil size={14} /></button
></td
>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
<p class="mt-3 text-xs text-[var(--site-muted)]">
Per-mouse values use in-game sens = 360 × 2.54 / (yaw × DPI × cm/360), with the measured DPI
when known. Measure DPI by moving the mouse exactly 10 cm and counting the cursor's pixels:
DPI = pixels / 3.937.
</p>
</section>
{#each others as d (d.id)}
<section id={d.name} class="section">
{@render deviceHeader(d, d.label ?? d.name)}
{#if d.parts.length}{@render grid(d.parts)}{/if}
</section>
{/each}
{#if retired.length}
<details class="section retired">
<summary class="cursor-pointer text-lg font-semibold">Retired ({retired.length})</summary>
{#each retired as d (d.id)}
<section id={d.name} class="mt-6">
{@render deviceHeader(d, d.label ?? d.name)}
{#if d.parts.length}{@render grid(d.parts)}{/if}
</section>
{/each}
</details>
{/if}
</div>
{#if editPart}
{@const p = editPart}
<Modal
title={p.id ? `Edit ${p.model}` : 'Add a part'}
onclose={() => {
editPart = null;
problem = '';
}}
>
{#snippet children(close)}
<form method="POST" action="?/savePart" use:enhance={submit(close)} class="form">
<input type="hidden" name="id" value={p.id ?? ''} />
<label
>device<select name="deviceId" value={p.deviceId}>
{#each data.devices as d (d.id)}<option value={d.id}>{d.label ?? d.name}</option>{/each}
</select></label
>
<label
>category<select name="category" value={p.category}>
{#each CATEGORIES as c (c)}<option value={c}>{categoryLabel(c)}</option>{/each}
</select></label
>
<label>brand<input name="brand" value={p.brand ?? ''} maxlength="120" /></label>
<label class="wide"
>model<input name="model" value={p.model ?? ''} required maxlength="300" /></label
>
<label
>quantity<input name="quantity" type="number" min="1" value={p.quantity ?? 1} /></label
>
<label
>price per unit (€)<input
name="unitPrice"
inputmode="decimal"
value={euros(p.unitPriceCents)}
/></label
>
<label>shop<input name="shop" value={p.shop ?? ''} /></label>
<label>order date<input name="orderDate" type="date" value={p.orderDate ?? ''} /></label>
<label
>warranty until<input
name="warrantyUntil"
type="date"
value={p.warrantyUntil ?? ''}
/></label
>
<label
>status<select name="status" value={p.status}>
{#each PART_STATUSES as s (s)}<option value={s}>{statusLabel(s)}</option>{/each}
</select></label
>
<label>order number<input name="orderNumber" value={p.orderNumber ?? ''} /></label>
<label>invoice number<input name="invoiceNumber" value={p.invoiceNumber ?? ''} /></label>
<label>DPI (set)<input name="dpi" type="number" min="1" value={p.dpi ?? ''} /></label>
<label
>DPI (measured)<input
name="measuredDpi"
type="number"
min="1"
value={p.measuredDpi ?? ''}
/></label
>
<label class="wide">link<input name="url" type="url" value={p.url ?? ''} /></label>
<label class="wide"
>image link<input name="imageUrl" type="url" value={p.imageUrl ?? ''} /></label
>
<label class="wide"
>specs (one "key: value" per line)<textarea name="specs" rows="4"
>{specsToText(p.specs ?? {})}</textarea
></label
>
<label class="wide"
>notes (only you)<textarea name="notes" rows="2">{p.notes ?? ''}</textarea></label
>
<label class="check"
><input type="checkbox" name="verified" checked={p.verified ?? true} /> confirmed still in this
setup</label
>
{#if problem}<p class="wide text-sm text-red-500" role="alert">{problem}</p>{/if}
<div class="buttons wide">
<button type="button" class="action" onclick={close}>Cancel</button>
<button type="submit" class="action primary">Save</button>
</div>
</form>
{#if p.id}
<form
method="POST"
action="?/deletePart"
use:enhance={submit(close)}
onsubmit={confirmDelete(p.model ?? 'this part')}
class="mt-2"
>
<input type="hidden" name="id" value={p.id} />
<button type="submit" class="text-sm text-red-500 hover:underline">Delete part</button>
</form>
{/if}
{/snippet}
</Modal>
{/if}
{#if editDevice}
{@const d = editDevice}
<Modal
title={d.id ? `Edit ${d.name}` : 'Add a device'}
onclose={() => {
editDevice = null;
problem = '';
}}
>
{#snippet children(close)}
<form method="POST" action="?/saveDevice" use:enhance={submit(close)} class="form">
<input type="hidden" name="id" value={d.id ?? ''} />
<label
>name (hostname)<input
name="name"
value={d.name ?? ''}
required
pattern="[a-z0-9][a-z0-9\-]*"
/></label
>
<label>label<input name="label" value={d.label ?? ''} /></label>
<label
>kind<select name="kind" value={d.kind}>
{#each DEVICE_KINDS as k (k)}<option value={k}>{DEVICE_KIND_META[k].label}</option
>{/each}
</select></label
>
<label
>status<select name="status" value={d.status}>
{#each DEVICE_STATUSES as s (s)}<option value={s}>{s}</option>{/each}
</select></label
>
<label>brand<input name="brand" value={d.brand ?? ''} /></label>
<label>model<input name="model" value={d.model ?? ''} /></label>
<label class="wide"
>other hostnames (comma separated)<input
name="hostnames"
value={(d.hostnames ?? []).join(', ')}
/></label
>
<label
>price (€)<input name="price" inputmode="decimal" value={euros(d.priceCents)} /></label
>
<label
>per month (€)<input
name="monthly"
inputmode="decimal"
value={euros(d.monthlyCents)}
/></label
>
<label
>budget (€)<input name="budget" inputmode="decimal" value={euros(d.budgetCents)} /></label
>
<label>shop<input name="shop" value={d.shop ?? ''} /></label>
<label>since<input name="purchaseDate" type="date" value={d.purchaseDate ?? ''} /></label>
<label
>sort order<input
name="sortOrder"
type="number"
min="0"
value={d.sortOrder ?? 100}
/></label
>
<label class="wide">link<input name="url" type="url" value={d.url ?? ''} /></label>
<label class="wide"
>specs (one "key: value" per line)<textarea name="specs" rows="4"
>{specsToText(d.specs ?? {})}</textarea
></label
>
<label class="wide"
>notes (public)<textarea name="notes" rows="3">{d.notes ?? ''}</textarea></label
>
{#if problem}<p class="wide text-sm text-red-500" role="alert">{problem}</p>{/if}
<div class="buttons wide">
<button type="button" class="action" onclick={close}>Cancel</button>
<button type="submit" class="action primary">Save</button>
</div>
</form>
{#if d.id}
<form
method="POST"
action="?/deleteDevice"
use:enhance={submit(close)}
onsubmit={confirmDelete(`${d.name} and all its parts`)}
class="mt-2"
>
<input type="hidden" name="id" value={d.id} />
<button type="submit" class="text-sm text-red-500 hover:underline"
>Delete device and its parts</button
>
</form>
{/if}
{/snippet}
</Modal>
{/if}
{#if editGame}
{@const g = editGame}
<Modal
title={g.id ? `Edit ${g.game}` : 'Add a game'}
onclose={() => {
editGame = null;
problem = '';
}}
>
{#snippet children(close)}
<form method="POST" action="?/saveGame" use:enhance={submit(close)} class="form">
<input type="hidden" name="id" value={g.id ?? ''} />
<label class="wide">game<input name="game" value={g.game ?? ''} required /></label>
<label>cm/360<input name="cmPer360" inputmode="decimal" value={g.cmPer360 ?? ''} /></label>
<label
>yaw (°/count at sens 1)<input
name="yaw"
inputmode="decimal"
value={g.yaw ?? ''}
/></label
>
<label
>in-game sens now<input
name="inGameSens"
inputmode="decimal"
value={g.inGameSens ?? ''}
/></label
>
<label
>set with mouse<select name="mouseId" value={g.mouseId ?? ''}>
<option value="">–</option>
{#each mice as m (m.id)}<option value={m.id}>{m.brand} {m.model}</option>{/each}
</select></label
>
<label>FOV<input name="fov" value={g.fov ?? ''} /></label>
<label
>sort order<input
name="sortOrder"
type="number"
min="0"
value={g.sortOrder ?? 100}
/></label
>
<label class="wide"
>notes (only you)<textarea name="notes" rows="2">{g.notes ?? ''}</textarea></label
>
{#if problem}<p class="wide text-sm text-red-500" role="alert">{problem}</p>{/if}
<div class="buttons wide">
<button type="button" class="action" onclick={close}>Cancel</button>
<button type="submit" class="action primary">Save</button>
</div>
</form>
{#if g.id}
<form
method="POST"
action="?/deleteGame"
use:enhance={submit(close)}
onsubmit={confirmDelete(g.game ?? 'this game')}
class="mt-2"
>
<input type="hidden" name="id" value={g.id} />
<button type="submit" class="text-sm text-red-500 hover:underline">Delete game</button>
</form>
{/if}
{/snippet}
</Modal>
{/if}
<style>
.stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
margin-bottom: 1.5rem;
}
@media (min-width: 640px) {
.stats {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.stats > div {
border: 1px solid var(--site-border);
border-radius: 1rem;
padding: 0.9rem 1rem;
background: var(--site-surface);
}
.stats dt {
font-size: 0.75rem;
color: var(--site-muted);
}
.stats dd {
margin-top: 0.2rem;
font-size: 1.5rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
}
.devices {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
gap: 0.6rem;
margin-bottom: 3rem;
}
.device-tile {
display: flex;
align-items: center;
gap: 0.75rem;
border: 1px solid var(--site-border);
border-radius: 1rem;
padding: 0.6rem 0.75rem;
background: var(--site-surface);
transition:
transform 0.15s ease,
background 0.15s ease,
border-color 0.15s ease;
}
.device-tile:hover {
transform: translateY(-2px);
background: var(--site-hover);
border-color: color-mix(in srgb, var(--site-text) 35%, var(--site-border));
}
.pill {
margin-left: auto;
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0 0.45rem;
font-size: 0.7rem;
color: var(--site-muted);
}
.section {
margin-bottom: 3.5rem;
scroll-margin-top: 5rem;
}
.section :global(code) {
font-size: 0.8em;
}
.parts {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
gap: 0.75rem;
}
.spec {
display: inline-flex;
gap: 0.4rem;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
padding: 0.25rem 0.6rem;
font-size: 0.85rem;
}
.spec span {
color: var(--site-muted);
}
.action {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
padding: 0.35rem 0.7rem;
font-size: 0.85rem;
transition:
background 0.15s,
border-color 0.15s;
}
.action:hover {
background: var(--site-hover);
border-color: color-mix(in srgb, var(--site-text) 35%, var(--site-border));
}
.action.primary {
background: var(--site-text);
color: var(--site-bg);
border-color: var(--site-text);
}
.action.primary:hover {
opacity: 0.85;
}
.budget {
margin-bottom: 1rem;
max-width: 36rem;
}
.bar {
margin-top: 0.4rem;
height: 0.55rem;
border-radius: 999px;
background: var(--site-hover);
overflow: hidden;
}
.bar span {
display: block;
height: 100%;
min-width: 0.55rem;
border-radius: 999px;
background: linear-gradient(90deg, #22d3ee, #7c3aed, #db2777);
}
.mice {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
gap: 0.6rem;
margin-bottom: 1rem;
}
.mouse {
display: flex;
align-items: center;
gap: 0.75rem;
border: 1px solid var(--site-border);
border-radius: 1rem;
padding: 0.6rem 0.75rem;
background: var(--site-surface);
}
.table-wrap {
overflow-x: auto;
border: 1px solid var(--site-border);
border-radius: 1rem;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th {
text-align: left;
font-weight: 500;
font-size: 0.75rem;
color: var(--site-muted);
padding: 0.6rem 0.9rem;
border-bottom: 1px solid var(--site-border);
white-space: nowrap;
}
td {
padding: 0.55rem 0.9rem;
border-bottom: 1px solid var(--site-border);
}
tbody tr:last-child td {
border-bottom: 0;
}
tbody tr:hover {
background: var(--site-hover);
}
.retired summary {
list-style: none;
color: var(--site-muted);
}
.retired summary:hover {
color: var(--site-text);
}
.form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem;
}
.form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.78rem;
color: var(--site-muted);
}
.form .wide,
.form .check,
.form .buttons {
grid-column: 1 / -1;
}
.form .check {
flex-direction: row;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--site-text);
}
.form input:not([type='checkbox']),
.form select,
.form textarea {
border: 1px solid var(--site-border);
border-radius: 0.5rem;
background: var(--site-bg);
color: var(--site-text);
padding: 0.4rem 0.6rem;
font-size: 0.9rem;
}
.form textarea {
font-family: ui-monospace, monospace;
font-size: 0.8rem;
}
.form .buttons {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
</style>