pc setup v2: one-screen layout with a time slider, photo grid and lightbox
All checks were successful
Deploy production / deploy (push) Successful in 1m7s

- hardware: slots, sections, usage, install/removal dates, images and a hidden flag on
  components and devices; setup_photos table; setup_overview view gains the new columns
- data: hide the 2021 friends-PC build and the wrong 140mm / unknown fans (kept, not deleted);
  7x SL-INF + 3 Kraken stock fans; mice, audio, desk extras; MacBook Pro 16 M2 Pro 1 TB;
  dates for every swap so the slider can rebuild the setup on any day
- self-hosted product images in static/hardware (webp + thumbs)
- prices and order details are owner-only (in a popover)
This commit is contained in:
2026-10-25 16:15:51 +00:00
parent d414e7190e
commit eff5a24985
204 changed files with 32374 additions and 776 deletions

View File

@@ -1,5 +1,6 @@
import { sql } from 'drizzle-orm';
import {
type AnyPgColumn,
bigint,
boolean,
check,
@@ -49,9 +50,30 @@ export const HARDWARE_CATEGORIES = [
'audio',
'controller',
'accessory',
'microphone',
'mousepad',
'webcam',
'chair',
'other'
] as const;
export const HARDWARE_STATUSES = ['in_use', 'planned', 'spare', 'retired', 'sold'] as const;
/** Page sections on /trackers/pc. */
export const HARDWARE_SECTIONS = ['pc', 'desk', 'audio', 'sim'] as const;
/**
* How a part shows on the time slider: current, stored (owned, not set up), previous (gone,
* dated by removed_at), work-issued, placeholder (model TBD), legacy (owned long before the
* mail history, still owned) and legacy-previous (long ago and gone, date unknown).
*/
export const HARDWARE_USAGES = [
'current',
'stored',
'previous',
'work-issued',
'placeholder',
'planned',
'legacy',
'legacy-previous'
] as const;
const list = (values: readonly string[]) => sql.raw(values.map((v) => `'${v}'`).join(', '));
@@ -85,6 +107,15 @@ export const hardwareDevices = hardwareSchema.table(
imageUrl: text('image_url'),
sortOrder: integer('sort_order').notNull().default(100),
notes: text('notes'),
/** Time slider: when it joined / left the setup (null = unknown / still here). */
installedAt: date('installed_at'),
removedAt: date('removed_at'),
/** Self-hosted product photos (/hardware/...webp), first is the thumbnail. */
imageUrls: text('image_urls').array().notNull().default([]),
/** Where each image was downloaded from (attribution), same order as image_urls. */
imageSources: text('image_sources').array().notNull().default([]),
/** Kept for history but not shown (e.g. a PC built for someone else). */
hidden: boolean('hidden').notNull().default(false),
createdAt: timestamptz('created_at').notNull().defaultNow(),
updatedAt: timestamptz('updated_at')
.notNull()
@@ -133,6 +164,23 @@ export const hardwareComponents = hardwareSchema.table(
notes: text('notes'),
/** user_list | gmail | user_statement | manual | plan. */
source: text('source'),
/** Stable slug for merges/seeds, e.g. cpu-12900kf. */
slug: text('slug').unique(),
/** Page section (pc/desk/audio/sim) and slot within it (cpu, fans-case, mouse-gaming…). */
section: text('section'),
slot: text('slot'),
/** Time slider: in the setup from installed_at until removed_at (null = unknown / still). */
installedAt: date('installed_at'),
removedAt: date('removed_at'),
replacesId: bigint('replaces_id', { mode: 'number' }).references(
(): AnyPgColumn => hardwareComponents.id,
{ onDelete: 'set null' }
),
usage: text('usage'),
imageUrls: text('image_urls').array().notNull().default([]),
imageSources: text('image_sources').array().notNull().default([]),
/** Kept for history but not shown (wrong placeholders, a friend's build). */
hidden: boolean('hidden').notNull().default(false),
createdAt: timestamptz('created_at').notNull().defaultNow(),
updatedAt: timestamptz('updated_at')
.notNull()
@@ -143,6 +191,15 @@ export const hardwareComponents = hardwareSchema.table(
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_section_check',
sql`section is null or section in (${list(HARDWARE_SECTIONS)})`
),
check('components_usage_check', sql`usage is null or usage in (${list(HARDWARE_USAGES)})`),
check(
'components_dates_check',
sql`removed_at is null or installed_at is null or removed_at >= installed_at`
),
check('components_unit_price_cents_check', sql`unit_price_cents >= 0`),
check('components_total_price_cents_check', sql`total_price_cents >= 0`),
check(
@@ -152,10 +209,21 @@ export const hardwareComponents = hardwareSchema.table(
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)
index('components_specs_gin').using('gin', t.specs),
index('components_timeline_idx').on(t.installedAt, t.removedAt)
]
);
/** Stan's own photos of the whole setup (hero carousel); taken_at lets the slider pick one. */
export const hardwareSetupPhotos = hardwareSchema.table('setup_photos', {
id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(),
url: text('url').notNull(),
caption: text('caption'),
takenAt: date('taken_at'),
sortOrder: integer('sort_order').notNull().default(100),
createdAt: timestamptz('created_at').notNull().defaultNow()
});
/**
* 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