pc: PC setup tracker at /trackers/pc on the hardware schema
All checks were successful
Deploy production / deploy (push) Successful in 1m4s
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:
191
src/lib/server/db/schema/hardware.ts
Normal file
191
src/lib/server/db/schema/hardware.ts
Normal 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`)
|
||||
]
|
||||
);
|
||||
@@ -19,3 +19,4 @@ export * from './groceries';
|
||||
export * from './home';
|
||||
export * from './cars';
|
||||
export * from './music';
|
||||
export * from './hardware';
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user