From dbe82c1019030dad5c4ecd892286d0c047b7afd8 Mon Sep 17 00:00:00 2001 From: grabowski Date: Tue, 7 Apr 2026 10:31:38 +0700 Subject: [PATCH] Add device checklists and retro Mac favicon Checklists feature: - device_checklists and checklist_items tables - Create multiple named checklists per device - Add/toggle/delete items with progress bar - Checkbox UI with green check, strikethrough for completed items - Delete checklist button with item count display Also adds the classic Mac happy face as favicon. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/server/db/schema.ts | 30 +++++ src/routes/(app)/devices/[id]/+page.server.ts | 99 ++++++++++++++++- src/routes/(app)/devices/[id]/+page.svelte | 105 ++++++++++++++++++ static/favicon.png | Bin 0 -> 7277 bytes 4 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 static/favicon.png diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index c43be12..b22de1f 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -195,6 +195,36 @@ export const installationLog = pgTable( ] ); +// ─── Device Checklists ────────────────────────────────────────────── + +export const deviceChecklists = pgTable( + 'device_checklists', + { + id: uuid('id').defaultRandom().primaryKey(), + deviceId: uuid('device_id') + .notNull() + .references(() => devices.id, { onDelete: 'cascade' }), + title: text('title').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull() + }, + (table) => [index('device_checklists_device_idx').on(table.deviceId)] +); + +export const checklistItems = pgTable( + 'checklist_items', + { + id: uuid('id').defaultRandom().primaryKey(), + checklistId: uuid('checklist_id') + .notNull() + .references(() => deviceChecklists.id, { onDelete: 'cascade' }), + text: text('text').notNull(), + checked: boolean('checked').default(false).notNull(), + sortOrder: integer('sort_order').default(0).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull() + }, + (table) => [index('checklist_items_checklist_idx').on(table.checklistId)] +); + // ─── Device Log (append-only repair/operation history) ─────────────── export const deviceLog = pgTable( diff --git a/src/routes/(app)/devices/[id]/+page.server.ts b/src/routes/(app)/devices/[id]/+page.server.ts index cad987b..399a0c1 100644 --- a/src/routes/(app)/devices/[id]/+page.server.ts +++ b/src/routes/(app)/devices/[id]/+page.server.ts @@ -8,9 +8,11 @@ import { components, installationLog, deviceLog, + deviceChecklists, + checklistItems, locations } from '$lib/server/db/schema.js'; -import { eq, desc } from 'drizzle-orm'; +import { eq, desc, sql } from 'drizzle-orm'; import { error, fail, redirect } from '@sveltejs/kit'; import { saveImage, saveDocument, deleteFile } from '$lib/server/uploads.js'; @@ -102,6 +104,30 @@ export const load: PageServerLoad = async ({ params }) => { .where(eq(deviceLog.deviceId, params.id)) .orderBy(desc(deviceLog.performedAt)); + // Checklists + const checklists = await db + .select() + .from(deviceChecklists) + .where(eq(deviceChecklists.deviceId, params.id)) + .orderBy(deviceChecklists.createdAt); + + const checklistIds = checklists.map((c) => c.id); + let itemsByChecklist: Record = {}; + let allItems: Array<{ id: string; checklistId: string; text: string; checked: boolean; sortOrder: number; createdAt: Date }> = []; + + if (checklistIds.length > 0) { + allItems = await db + .select() + .from(checklistItems) + .where(sql`${checklistItems.checklistId} IN ${checklistIds}`) + .orderBy(checklistItems.sortOrder, checklistItems.createdAt); + + for (const item of allItems) { + if (!itemsByChecklist[item.checklistId]) itemsByChecklist[item.checklistId] = []; + itemsByChecklist[item.checklistId].push(item); + } + } + return { device, computerDetails: compDetails, @@ -109,7 +135,11 @@ export const load: PageServerLoad = async ({ params }) => { documents, installedComponents, history, - operationLog + operationLog, + checklists: checklists.map((c) => ({ + ...c, + items: itemsByChecklist[c.id] ?? [] + })) }; }; @@ -219,5 +249,70 @@ export const actions: Actions = { .where(eq(devices.id, params.id)); redirect(303, '/devices'); + }, + + createChecklist: async ({ request, params }) => { + const formData = await request.formData(); + const title = (formData.get('title') as string)?.trim(); + if (!title) return fail(400, { error: 'Checklist title is required' }); + + await db.insert(deviceChecklists).values({ + deviceId: params.id, + title + }); + + return { checklistCreated: true }; + }, + + deleteChecklist: async ({ request }) => { + const formData = await request.formData(); + const checklistId = formData.get('checklistId') as string; + await db.delete(deviceChecklists).where(eq(deviceChecklists.id, checklistId)); + return { checklistDeleted: true }; + }, + + addChecklistItem: async ({ request }) => { + const formData = await request.formData(); + const checklistId = formData.get('checklistId') as string; + const text = (formData.get('text') as string)?.trim(); + if (!text) return fail(400, { error: 'Item text is required' }); + + // Get next sort order + const existing = await db + .select({ sortOrder: checklistItems.sortOrder }) + .from(checklistItems) + .where(eq(checklistItems.checklistId, checklistId)) + .orderBy(desc(checklistItems.sortOrder)) + .limit(1); + + const nextOrder = (existing[0]?.sortOrder ?? -1) + 1; + + await db.insert(checklistItems).values({ + checklistId, + text, + sortOrder: nextOrder + }); + + return { itemAdded: true }; + }, + + toggleChecklistItem: async ({ request }) => { + const formData = await request.formData(); + const itemId = formData.get('itemId') as string; + const checked = formData.get('checked') === 'true'; + + await db + .update(checklistItems) + .set({ checked: !checked }) + .where(eq(checklistItems.id, itemId)); + + return { itemToggled: true }; + }, + + deleteChecklistItem: async ({ request }) => { + const formData = await request.formData(); + const itemId = formData.get('itemId') as string; + await db.delete(checklistItems).where(eq(checklistItems.id, itemId)); + return { itemDeleted: true }; } }; diff --git a/src/routes/(app)/devices/[id]/+page.svelte b/src/routes/(app)/devices/[id]/+page.svelte index f5a9753..a201996 100644 --- a/src/routes/(app)/devices/[id]/+page.svelte +++ b/src/routes/(app)/devices/[id]/+page.svelte @@ -9,6 +9,8 @@ let showDocForm = $state(false); let showDeleteConfirm = $state(false); let showLogForm = $state(false); + let showNewChecklist = $state(false); + let newItemText: Record = $state({}); @@ -327,6 +329,109 @@ {/if} + + +
+
+

Checklists

+ +
+ + {#if showNewChecklist} +
+ + +
+ {/if} + + {#if data.checklists.length === 0} +

No checklists yet.

+ {:else} +
+ {#each data.checklists as checklist} +
+
+

{checklist.title}

+
+ + {checklist.items.filter((i: any) => i.checked).length}/{checklist.items.length} + +
+ + +
+
+
+ + + {#if checklist.items.length > 0} + {@const pct = Math.round((checklist.items.filter((i: any) => i.checked).length / checklist.items.length) * 100)} +
+
+
+ {/if} + + +
+ {#each checklist.items as item} +
+
+ + + +
+ + {item.text} + +
+ + +
+
+ {/each} +
+ + +
{ newItemText[checklist.id] = ''; }} + class="mt-2 flex gap-2"> + + + +
+
+ {/each} +
+ {/if} +
diff --git a/static/favicon.png b/static/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..85700734b8cc493136aff952fde312049af6edbb GIT binary patch literal 7277 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 literal 0 HcmV?d00001