hotsheet 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,2060 @@
1
+ #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // src/gitignore.ts
13
+ var gitignore_exports = {};
14
+ __export(gitignore_exports, {
15
+ addHotsheetToGitignore: () => addHotsheetToGitignore,
16
+ ensureGitignore: () => ensureGitignore,
17
+ getGitRoot: () => getGitRoot,
18
+ isGitRepo: () => isGitRepo,
19
+ isHotsheetGitignored: () => isHotsheetGitignored
20
+ });
21
+ import { execSync } from "child_process";
22
+ import { appendFileSync, existsSync, readFileSync } from "fs";
23
+ import { join as join2 } from "path";
24
+ function isHotsheetGitignored(repoRoot) {
25
+ try {
26
+ execSync("git check-ignore -q .hotsheet", { cwd: repoRoot, stdio: "ignore" });
27
+ return true;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+ function isGitRepo(dir) {
33
+ try {
34
+ execSync("git rev-parse --is-inside-work-tree", { cwd: dir, stdio: "ignore" });
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+ function getGitRoot(dir) {
41
+ try {
42
+ return execSync("git rev-parse --show-toplevel", { cwd: dir, encoding: "utf-8" }).trim();
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+ function addHotsheetToGitignore(repoRoot) {
48
+ const gitignorePath = join2(repoRoot, ".gitignore");
49
+ if (existsSync(gitignorePath)) {
50
+ const content = readFileSync(gitignorePath, "utf-8");
51
+ if (content.includes(".hotsheet")) return;
52
+ const prefix = content.endsWith("\n") ? "" : "\n";
53
+ appendFileSync(gitignorePath, `${prefix}.hotsheet/
54
+ `);
55
+ } else {
56
+ appendFileSync(gitignorePath, ".hotsheet/\n");
57
+ }
58
+ }
59
+ function ensureGitignore(cwd) {
60
+ if (!isGitRepo(cwd)) return;
61
+ const gitRoot = getGitRoot(cwd);
62
+ if (gitRoot === null) return;
63
+ if (!isHotsheetGitignored(gitRoot)) {
64
+ addHotsheetToGitignore(gitRoot);
65
+ console.log(" Added .hotsheet/ to .gitignore");
66
+ }
67
+ }
68
+ var init_gitignore = __esm({
69
+ "src/gitignore.ts"() {
70
+ "use strict";
71
+ }
72
+ });
73
+
74
+ // src/cli.ts
75
+ import { mkdirSync as mkdirSync3 } from "fs";
76
+ import { tmpdir } from "os";
77
+ import { join as join6, resolve } from "path";
78
+
79
+ // src/cleanup.ts
80
+ import { rmSync as rmSync2 } from "fs";
81
+
82
+ // src/db/connection.ts
83
+ import { PGlite } from "@electric-sql/pglite";
84
+ import { mkdirSync, rmSync } from "fs";
85
+ import { join } from "path";
86
+ var db = null;
87
+ var currentDbPath = null;
88
+ function setDataDir(dataDir2) {
89
+ const dbDir = join(dataDir2, "db");
90
+ mkdirSync(dbDir, { recursive: true });
91
+ mkdirSync(join(dataDir2, "attachments"), { recursive: true });
92
+ currentDbPath = dbDir;
93
+ }
94
+ async function getDb() {
95
+ if (db !== null) return db;
96
+ if (currentDbPath === null) throw new Error("Data directory not set. Call setDataDir() first.");
97
+ try {
98
+ db = new PGlite(currentDbPath);
99
+ await db.waitReady;
100
+ await initSchema(db);
101
+ return db;
102
+ } catch (err) {
103
+ db = null;
104
+ const message = err instanceof Error ? err.message : String(err);
105
+ if (message.includes("Aborted") || message.includes("RuntimeError")) {
106
+ console.error("Database appears to be corrupt. Recreating...");
107
+ console.error("(Previous ticket data will be lost.)");
108
+ try {
109
+ rmSync(currentDbPath, { recursive: true, force: true });
110
+ } catch {
111
+ }
112
+ db = new PGlite(currentDbPath);
113
+ await db.waitReady;
114
+ await initSchema(db);
115
+ return db;
116
+ }
117
+ throw err;
118
+ }
119
+ }
120
+ async function initSchema(db2) {
121
+ await db2.exec(`
122
+ CREATE SEQUENCE IF NOT EXISTS ticket_seq START 1;
123
+
124
+ CREATE TABLE IF NOT EXISTS tickets (
125
+ id SERIAL PRIMARY KEY,
126
+ ticket_number TEXT UNIQUE NOT NULL,
127
+ title TEXT NOT NULL DEFAULT '',
128
+ details TEXT NOT NULL DEFAULT '',
129
+ category TEXT NOT NULL DEFAULT 'issue',
130
+ priority TEXT NOT NULL DEFAULT 'default',
131
+ status TEXT NOT NULL DEFAULT 'not_started',
132
+ up_next BOOLEAN NOT NULL DEFAULT FALSE,
133
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
134
+ updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
135
+ completed_at TIMESTAMP,
136
+ deleted_at TIMESTAMP
137
+ );
138
+
139
+ CREATE TABLE IF NOT EXISTS attachments (
140
+ id SERIAL PRIMARY KEY,
141
+ ticket_id INTEGER NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
142
+ original_filename TEXT NOT NULL,
143
+ stored_path TEXT NOT NULL,
144
+ created_at TIMESTAMP NOT NULL DEFAULT NOW()
145
+ );
146
+
147
+ CREATE INDEX IF NOT EXISTS idx_attachments_ticket ON attachments(ticket_id);
148
+ CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
149
+ CREATE INDEX IF NOT EXISTS idx_tickets_up_next ON tickets(up_next);
150
+
151
+ CREATE TABLE IF NOT EXISTS settings (
152
+ key TEXT PRIMARY KEY,
153
+ value TEXT NOT NULL
154
+ );
155
+
156
+ INSERT INTO settings (key, value) VALUES ('detail_position', 'side') ON CONFLICT DO NOTHING;
157
+ INSERT INTO settings (key, value) VALUES ('detail_width', '360') ON CONFLICT DO NOTHING;
158
+ INSERT INTO settings (key, value) VALUES ('detail_height', '300') ON CONFLICT DO NOTHING;
159
+ INSERT INTO settings (key, value) VALUES ('trash_cleanup_days', '3') ON CONFLICT DO NOTHING;
160
+ INSERT INTO settings (key, value) VALUES ('completed_cleanup_days', '30') ON CONFLICT DO NOTHING;
161
+ INSERT INTO settings (key, value) VALUES ('verified_cleanup_days', '30') ON CONFLICT DO NOTHING;
162
+ `);
163
+ await db2.exec(`
164
+ ALTER TABLE tickets ADD COLUMN IF NOT EXISTS notes TEXT NOT NULL DEFAULT '';
165
+ ALTER TABLE tickets ADD COLUMN IF NOT EXISTS verified_at TIMESTAMP;
166
+ `).catch(() => {
167
+ });
168
+ }
169
+
170
+ // src/db/queries.ts
171
+ function parseNotes(raw2) {
172
+ if (!raw2 || raw2 === "") return [];
173
+ try {
174
+ const parsed = JSON.parse(raw2);
175
+ if (Array.isArray(parsed)) return parsed;
176
+ } catch {
177
+ }
178
+ return [{ text: raw2, created_at: (/* @__PURE__ */ new Date()).toISOString() }];
179
+ }
180
+ async function nextTicketNumber() {
181
+ const db2 = await getDb();
182
+ const result = await db2.query("SELECT nextval('ticket_seq')");
183
+ return `HS-${result.rows[0].nextval}`;
184
+ }
185
+ async function createTicket(title, defaults) {
186
+ const db2 = await getDb();
187
+ const ticketNumber = await nextTicketNumber();
188
+ const cols = ["ticket_number", "title"];
189
+ const vals = [ticketNumber, title];
190
+ if (defaults?.category !== void 0 && defaults.category !== "") {
191
+ cols.push("category");
192
+ vals.push(defaults.category);
193
+ }
194
+ if (defaults?.priority !== void 0 && defaults.priority !== "") {
195
+ cols.push("priority");
196
+ vals.push(defaults.priority);
197
+ }
198
+ if (defaults?.status !== void 0 && defaults.status !== "") {
199
+ cols.push("status");
200
+ vals.push(defaults.status);
201
+ }
202
+ if (defaults?.up_next !== void 0) {
203
+ cols.push("up_next");
204
+ vals.push(defaults.up_next);
205
+ }
206
+ const placeholders = vals.map((_, i) => `$${i + 1}`).join(", ");
207
+ const result = await db2.query(
208
+ `INSERT INTO tickets (${cols.join(", ")}) VALUES (${placeholders}) RETURNING *`,
209
+ vals
210
+ );
211
+ return result.rows[0];
212
+ }
213
+ async function getTicket(id) {
214
+ const db2 = await getDb();
215
+ const result = await db2.query(`SELECT * FROM tickets WHERE id = $1`, [id]);
216
+ return result.rows[0] ?? null;
217
+ }
218
+ async function updateTicket(id, updates) {
219
+ const db2 = await getDb();
220
+ const sets = ["updated_at = NOW()"];
221
+ const values = [];
222
+ let paramIdx = 1;
223
+ for (const [key, value] of Object.entries(updates)) {
224
+ if (value === void 0) continue;
225
+ if (key === "notes") continue;
226
+ sets.push(`${key} = $${paramIdx}`);
227
+ values.push(value);
228
+ paramIdx++;
229
+ }
230
+ if (updates.notes !== void 0 && updates.notes !== "") {
231
+ const current = await db2.query(`SELECT notes FROM tickets WHERE id = $1`, [id]);
232
+ const existing = parseNotes(current.rows[0]?.notes || "");
233
+ existing.push({ text: updates.notes, created_at: (/* @__PURE__ */ new Date()).toISOString() });
234
+ sets.push(`notes = $${paramIdx}`);
235
+ values.push(JSON.stringify(existing));
236
+ paramIdx++;
237
+ }
238
+ if (updates.status === "completed") {
239
+ sets.push("completed_at = NOW()");
240
+ sets.push("verified_at = NULL");
241
+ sets.push("up_next = FALSE");
242
+ } else if (updates.status === "verified") {
243
+ sets.push("verified_at = NOW()");
244
+ sets.push("completed_at = COALESCE(completed_at, NOW())");
245
+ sets.push("up_next = FALSE");
246
+ } else if (updates.status === "deleted") {
247
+ sets.push("deleted_at = NOW()");
248
+ } else if (updates.status === "not_started" || updates.status === "started") {
249
+ sets.push("completed_at = NULL");
250
+ sets.push("verified_at = NULL");
251
+ sets.push("deleted_at = NULL");
252
+ }
253
+ values.push(id);
254
+ const result = await db2.query(
255
+ `UPDATE tickets SET ${sets.join(", ")} WHERE id = $${paramIdx} RETURNING *`,
256
+ values
257
+ );
258
+ return result.rows[0] ?? null;
259
+ }
260
+ async function deleteTicket(id) {
261
+ await updateTicket(id, { status: "deleted" });
262
+ }
263
+ async function hardDeleteTicket(id) {
264
+ const db2 = await getDb();
265
+ await db2.query(`DELETE FROM tickets WHERE id = $1`, [id]);
266
+ }
267
+ async function getTickets(filters = {}) {
268
+ const db2 = await getDb();
269
+ const conditions = [];
270
+ const values = [];
271
+ let paramIdx = 1;
272
+ if (filters.status === "open") {
273
+ conditions.push(`status != 'deleted' AND status != 'completed' AND status != 'verified'`);
274
+ } else if (filters.status) {
275
+ conditions.push(`status = $${paramIdx}`);
276
+ values.push(filters.status);
277
+ paramIdx++;
278
+ } else {
279
+ conditions.push(`status != 'deleted'`);
280
+ }
281
+ if (filters.category) {
282
+ conditions.push(`category = $${paramIdx}`);
283
+ values.push(filters.category);
284
+ paramIdx++;
285
+ }
286
+ if (filters.priority) {
287
+ conditions.push(`priority = $${paramIdx}`);
288
+ values.push(filters.priority);
289
+ paramIdx++;
290
+ }
291
+ if (filters.up_next !== void 0) {
292
+ conditions.push(`up_next = $${paramIdx}`);
293
+ values.push(filters.up_next);
294
+ paramIdx++;
295
+ }
296
+ if (filters.search !== void 0 && filters.search !== "") {
297
+ conditions.push(`(title ILIKE $${paramIdx} OR details ILIKE $${paramIdx} OR ticket_number ILIKE $${paramIdx})`);
298
+ values.push(`%${filters.search}%`);
299
+ paramIdx++;
300
+ }
301
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
302
+ let orderBy;
303
+ switch (filters.sort_by) {
304
+ case "priority":
305
+ orderBy = `CASE priority
306
+ WHEN 'highest' THEN 1 WHEN 'high' THEN 2 WHEN 'default' THEN 3
307
+ WHEN 'low' THEN 4 WHEN 'lowest' THEN 5 END`;
308
+ break;
309
+ case "category":
310
+ orderBy = "category";
311
+ break;
312
+ case "status":
313
+ orderBy = `CASE status
314
+ WHEN 'started' THEN 1 WHEN 'not_started' THEN 2 WHEN 'completed' THEN 3 WHEN 'verified' THEN 4 END`;
315
+ break;
316
+ case "ticket_number":
317
+ orderBy = "id";
318
+ break;
319
+ case "created":
320
+ case void 0:
321
+ orderBy = "created_at";
322
+ break;
323
+ }
324
+ const dir = filters.sort_dir === "asc" ? "ASC" : "DESC";
325
+ const result = await db2.query(
326
+ `SELECT * FROM tickets ${where} ORDER BY ${orderBy} ${dir}, id DESC`,
327
+ values
328
+ );
329
+ return result.rows;
330
+ }
331
+ async function batchUpdateTickets(ids, updates) {
332
+ for (const id of ids) {
333
+ await updateTicket(id, updates);
334
+ }
335
+ }
336
+ async function batchDeleteTickets(ids) {
337
+ for (const id of ids) {
338
+ await deleteTicket(id);
339
+ }
340
+ }
341
+ async function toggleUpNext(id) {
342
+ const db2 = await getDb();
343
+ const result = await db2.query(
344
+ `UPDATE tickets SET up_next = NOT up_next, updated_at = NOW() WHERE id = $1 RETURNING *`,
345
+ [id]
346
+ );
347
+ return result.rows[0] ?? null;
348
+ }
349
+ async function addAttachment(ticketId, originalFilename, storedPath) {
350
+ const db2 = await getDb();
351
+ const result = await db2.query(
352
+ `INSERT INTO attachments (ticket_id, original_filename, stored_path) VALUES ($1, $2, $3) RETURNING *`,
353
+ [ticketId, originalFilename, storedPath]
354
+ );
355
+ return result.rows[0];
356
+ }
357
+ async function getAttachments(ticketId) {
358
+ const db2 = await getDb();
359
+ const result = await db2.query(
360
+ `SELECT * FROM attachments WHERE ticket_id = $1 ORDER BY created_at ASC`,
361
+ [ticketId]
362
+ );
363
+ return result.rows;
364
+ }
365
+ async function deleteAttachment(id) {
366
+ const db2 = await getDb();
367
+ const result = await db2.query(
368
+ `DELETE FROM attachments WHERE id = $1 RETURNING *`,
369
+ [id]
370
+ );
371
+ return result.rows[0] ?? null;
372
+ }
373
+ async function getTicketsForCleanup(verifiedDays = 30, trashDays = 3) {
374
+ const db2 = await getDb();
375
+ const result = await db2.query(`
376
+ SELECT * FROM tickets
377
+ WHERE (status = 'verified' AND verified_at < NOW() - INTERVAL '1 day' * $1)
378
+ OR (status = 'deleted' AND deleted_at < NOW() - INTERVAL '1 day' * $2)
379
+ `, [verifiedDays, trashDays]);
380
+ return result.rows;
381
+ }
382
+ async function getSettings() {
383
+ const db2 = await getDb();
384
+ const result = await db2.query("SELECT key, value FROM settings");
385
+ const settings = {};
386
+ for (const row of result.rows) {
387
+ settings[row.key] = row.value;
388
+ }
389
+ return settings;
390
+ }
391
+ async function updateSetting(key, value) {
392
+ const db2 = await getDb();
393
+ await db2.query(
394
+ `INSERT INTO settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2`,
395
+ [key, value]
396
+ );
397
+ }
398
+ async function restoreTicket(id) {
399
+ return updateTicket(id, { status: "not_started" });
400
+ }
401
+ async function batchRestoreTickets(ids) {
402
+ for (const id of ids) {
403
+ await restoreTicket(id);
404
+ }
405
+ }
406
+ async function emptyTrash() {
407
+ const db2 = await getDb();
408
+ const result = await db2.query(`SELECT id FROM tickets WHERE status = 'deleted'`);
409
+ const ids = result.rows.map((r) => r.id);
410
+ for (const id of ids) {
411
+ await hardDeleteTicket(id);
412
+ }
413
+ return ids;
414
+ }
415
+ async function getTicketStats() {
416
+ const db2 = await getDb();
417
+ const totalResult = await db2.query(
418
+ `SELECT COUNT(*) as count FROM tickets WHERE status != 'deleted'`
419
+ );
420
+ const openResult = await db2.query(
421
+ `SELECT COUNT(*) as count FROM tickets WHERE status != 'deleted' AND status != 'completed' AND status != 'verified'`
422
+ );
423
+ const upNextResult = await db2.query(
424
+ `SELECT COUNT(*) as count FROM tickets WHERE up_next = true AND status != 'deleted'`
425
+ );
426
+ const byCategoryResult = await db2.query(
427
+ `SELECT category, COUNT(*) as count FROM tickets WHERE status != 'deleted' GROUP BY category`
428
+ );
429
+ const byStatusResult = await db2.query(
430
+ `SELECT status, COUNT(*) as count FROM tickets WHERE status != 'deleted' GROUP BY status`
431
+ );
432
+ const by_category = {};
433
+ for (const row of byCategoryResult.rows) {
434
+ by_category[row.category] = parseInt(row.count, 10);
435
+ }
436
+ const by_status = {};
437
+ for (const row of byStatusResult.rows) {
438
+ by_status[row.status] = parseInt(row.count, 10);
439
+ }
440
+ return {
441
+ total: parseInt(totalResult.rows[0].count, 10),
442
+ open: parseInt(openResult.rows[0].count, 10),
443
+ up_next: parseInt(upNextResult.rows[0].count, 10),
444
+ by_category,
445
+ by_status
446
+ };
447
+ }
448
+
449
+ // src/cleanup.ts
450
+ async function cleanupAttachments() {
451
+ try {
452
+ const settings = await getSettings();
453
+ const verifiedDays = parseInt(settings.verified_cleanup_days, 10) || 30;
454
+ const trashDays = parseInt(settings.trash_cleanup_days, 10) || 3;
455
+ const tickets = await getTicketsForCleanup(verifiedDays, trashDays);
456
+ if (tickets.length === 0) return;
457
+ let cleaned = 0;
458
+ for (const ticket of tickets) {
459
+ const attachments = await getAttachments(ticket.id);
460
+ for (const att of attachments) {
461
+ try {
462
+ rmSync2(att.stored_path, { force: true });
463
+ } catch {
464
+ }
465
+ }
466
+ await hardDeleteTicket(ticket.id);
467
+ cleaned++;
468
+ }
469
+ if (cleaned > 0) {
470
+ console.log(` Cleaned up ${cleaned} old ticket(s) and their attachments.`);
471
+ }
472
+ } catch (err) {
473
+ console.error("Attachment cleanup failed:", err);
474
+ }
475
+ }
476
+
477
+ // src/demo.ts
478
+ var DEMO_SCENARIOS = [
479
+ { id: 1, label: "Main UI \u2014 all tickets with detail panel" },
480
+ { id: 2, label: "Quick entry \u2014 bullet-list ticket creation" },
481
+ { id: 3, label: "Sidebar filtering \u2014 category view" },
482
+ { id: 4, label: "AI worklist \u2014 Up Next tickets with notes" },
483
+ { id: 5, label: "Batch operations \u2014 multi-select toolbar" },
484
+ { id: 6, label: "Detail panel \u2014 bottom orientation with notes" }
485
+ ];
486
+ function daysAgo(days) {
487
+ const d = /* @__PURE__ */ new Date();
488
+ d.setTime(d.getTime() - days * 24 * 60 * 60 * 1e3);
489
+ return d.toISOString();
490
+ }
491
+ function notesJson(entries) {
492
+ if (entries.length === 0) return "";
493
+ return JSON.stringify(entries.map((e) => ({ text: e.text, created_at: daysAgo(e.days_ago) })));
494
+ }
495
+ var SCENARIO_1 = [
496
+ {
497
+ title: "Fix checkout failing when cart has mixed shipping methods",
498
+ details: 'When a cart contains items with different shipping methods (standard + express), the checkout process fails at the shipping calculation step.\n\nSteps to reproduce:\n1. Add an item with standard shipping\n2. Add an item with express-only shipping\n3. Proceed to checkout\n4. Error at shipping step: "Unable to calculate shipping"\n\nLikely issue is in ShippingCalculator.consolidate() which assumes a single method.',
499
+ category: "bug",
500
+ priority: "highest",
501
+ status: "started",
502
+ up_next: true,
503
+ notes: notesJson([{ text: "Confirmed the issue is in ShippingCalculator.consolidate(). It uses a single rate lookup instead of per-item calculation. Working on a fix that groups items by shipping method and merges the rates.", days_ago: 0.5 }]),
504
+ days_ago: 5,
505
+ updated_ago: 0.5
506
+ },
507
+ {
508
+ title: "Add product comparison view for category pages",
509
+ details: "Users should be able to select 2-4 products and see a side-by-side comparison table showing specs, price, ratings, and availability.",
510
+ category: "feature",
511
+ priority: "high",
512
+ status: "not_started",
513
+ up_next: true,
514
+ notes: "",
515
+ days_ago: 4,
516
+ updated_ago: 4
517
+ },
518
+ {
519
+ title: "Set up automated database backups to S3",
520
+ details: "Configure daily pg_dump backups with 30-day retention. Use the existing AWS credentials from the infra stack. Should run at 03:00 UTC.",
521
+ category: "task",
522
+ priority: "high",
523
+ status: "started",
524
+ up_next: true,
525
+ notes: notesJson([{ text: "Created the backup script and IAM role. Testing the S3 lifecycle policy for retention.", days_ago: 1 }]),
526
+ days_ago: 7,
527
+ updated_ago: 1
528
+ },
529
+ {
530
+ title: "Evaluate Stripe vs Square for payment processing",
531
+ details: "Compare fees, API quality, international support, and dispute handling. We need a recommendation by end of sprint.",
532
+ category: "investigation",
533
+ priority: "high",
534
+ status: "not_started",
535
+ up_next: true,
536
+ notes: "",
537
+ days_ago: 3,
538
+ updated_ago: 3
539
+ },
540
+ {
541
+ title: "Product images not loading on slow connections",
542
+ details: "Users on 3G connections report broken product images. Likely need progressive loading and proper srcset/sizes attributes.",
543
+ category: "bug",
544
+ priority: "default",
545
+ status: "not_started",
546
+ up_next: false,
547
+ notes: "",
548
+ days_ago: 6,
549
+ updated_ago: 6
550
+ },
551
+ {
552
+ title: "Allow customers to save multiple shipping addresses",
553
+ details: "Currently limited to one address. Users should be able to store and label multiple addresses (Home, Work, etc.) and pick during checkout.",
554
+ category: "feature",
555
+ priority: "default",
556
+ status: "not_started",
557
+ up_next: false,
558
+ notes: "",
559
+ days_ago: 10,
560
+ updated_ago: 10
561
+ },
562
+ {
563
+ title: "Update tax calculation to handle EU VAT rules",
564
+ details: "Need to support reverse charge for B2B and country-specific VAT rates. The current flat-rate approach is incorrect for EU customers.",
565
+ category: "requirement_change",
566
+ priority: "default",
567
+ status: "started",
568
+ up_next: false,
569
+ notes: "",
570
+ days_ago: 8,
571
+ updated_ago: 2
572
+ },
573
+ {
574
+ title: "Write API documentation for order endpoints",
575
+ details: "Document all /api/orders/* endpoints with request/response examples using OpenAPI 3.0 format.",
576
+ category: "task",
577
+ priority: "default",
578
+ status: "completed",
579
+ up_next: false,
580
+ notes: notesJson([{ text: "Documented all 12 order endpoints with examples. Published to /docs.", days_ago: 1 }]),
581
+ days_ago: 12,
582
+ updated_ago: 1,
583
+ completed_ago: 1
584
+ },
585
+ {
586
+ title: "Fix CORS headers blocking mobile app API requests",
587
+ details: "The mobile app gets CORS errors on preflight OPTIONS requests. Need to add proper Access-Control headers for the mobile origin.",
588
+ category: "bug",
589
+ priority: "highest",
590
+ status: "verified",
591
+ up_next: false,
592
+ notes: notesJson([
593
+ { text: "Added CORS middleware with correct origins. Tested against staging with the mobile app builds.", days_ago: 3 },
594
+ { text: "Verified fix is working in production. No more CORS errors in mobile app error logs.", days_ago: 2 }
595
+ ]),
596
+ days_ago: 14,
597
+ updated_ago: 2,
598
+ completed_ago: 3,
599
+ verified_ago: 2
600
+ },
601
+ {
602
+ title: "Add dark mode support",
603
+ details: "Implement system-preference detection and manual toggle. Use CSS custom properties for theming.",
604
+ category: "feature",
605
+ priority: "low",
606
+ status: "not_started",
607
+ up_next: false,
608
+ notes: "",
609
+ days_ago: 15,
610
+ updated_ago: 15
611
+ },
612
+ {
613
+ title: "Migrate to connection pooling for database",
614
+ details: "Switch from individual connections to pgBouncer or built-in pooling. Current approach is causing connection exhaustion under load.",
615
+ category: "task",
616
+ priority: "default",
617
+ status: "completed",
618
+ up_next: false,
619
+ notes: notesJson([{ text: "Migrated to pg pool with max 20 connections. Load tested successfully at 500 concurrent requests.", days_ago: 4 }]),
620
+ days_ago: 18,
621
+ updated_ago: 4,
622
+ completed_ago: 4
623
+ },
624
+ {
625
+ title: "Research SSR frameworks for product pages",
626
+ details: "Evaluate Next.js, Remix, and Astro for SEO-critical product pages. Need to consider hydration cost and build complexity.",
627
+ category: "investigation",
628
+ priority: "lowest",
629
+ status: "not_started",
630
+ up_next: false,
631
+ notes: "",
632
+ days_ago: 20,
633
+ updated_ago: 20
634
+ }
635
+ ];
636
+ var SCENARIO_2 = [
637
+ {
638
+ title: "Fix login redirect loop after session timeout",
639
+ details: "After session timeout, the redirect goes to /login?next=/login which creates an infinite loop.",
640
+ category: "bug",
641
+ priority: "high",
642
+ status: "not_started",
643
+ up_next: true,
644
+ notes: "",
645
+ days_ago: 2,
646
+ updated_ago: 2
647
+ },
648
+ {
649
+ title: "Add CSV export for order reports",
650
+ details: "Admin users need to export filtered order data as CSV for accounting.",
651
+ category: "feature",
652
+ priority: "default",
653
+ status: "not_started",
654
+ up_next: false,
655
+ notes: "",
656
+ days_ago: 3,
657
+ updated_ago: 3
658
+ },
659
+ {
660
+ title: "Update dependencies to latest versions",
661
+ details: "Several packages have security patches available. Run npm audit and update.",
662
+ category: "task",
663
+ priority: "default",
664
+ status: "started",
665
+ up_next: false,
666
+ notes: "",
667
+ days_ago: 1,
668
+ updated_ago: 0.5
669
+ }
670
+ ];
671
+ var SCENARIO_3 = [
672
+ {
673
+ title: "Fix checkout totals rounding incorrectly on multi-item carts",
674
+ details: "Subtotals accumulate floating-point errors. Use integer cents for all calculations.",
675
+ category: "bug",
676
+ priority: "highest",
677
+ status: "started",
678
+ up_next: true,
679
+ notes: "",
680
+ days_ago: 3,
681
+ updated_ago: 1
682
+ },
683
+ {
684
+ title: "Search returns stale results after product update",
685
+ details: "The search index isn't being refreshed when product details change. Need to trigger re-index on product save.",
686
+ category: "bug",
687
+ priority: "high",
688
+ status: "not_started",
689
+ up_next: true,
690
+ notes: "",
691
+ days_ago: 5,
692
+ updated_ago: 5
693
+ },
694
+ {
695
+ title: "Email notifications sent with wrong timezone offset",
696
+ details: "All notification timestamps show UTC instead of the user's configured timezone.",
697
+ category: "bug",
698
+ priority: "default",
699
+ status: "not_started",
700
+ up_next: false,
701
+ notes: "",
702
+ days_ago: 7,
703
+ updated_ago: 7
704
+ },
705
+ {
706
+ title: "Implement real-time inventory tracking",
707
+ details: 'Use WebSocket connections to push stock level changes to the product page. Show "Only X left" badges.',
708
+ category: "feature",
709
+ priority: "high",
710
+ status: "started",
711
+ up_next: true,
712
+ notes: notesJson([{ text: "WebSocket server is set up. Working on the client-side stock badge component.", days_ago: 0.5 }]),
713
+ days_ago: 6,
714
+ updated_ago: 0.5
715
+ },
716
+ {
717
+ title: "Add wishlist sharing via email",
718
+ details: "Users can generate a shareable link or send their wishlist directly to an email address.",
719
+ category: "feature",
720
+ priority: "default",
721
+ status: "not_started",
722
+ up_next: false,
723
+ notes: "",
724
+ days_ago: 9,
725
+ updated_ago: 9
726
+ },
727
+ {
728
+ title: "Product video support on detail pages",
729
+ details: "Allow merchants to upload product videos alongside photos. Support mp4 and embedded YouTube URLs.",
730
+ category: "feature",
731
+ priority: "low",
732
+ status: "not_started",
733
+ up_next: false,
734
+ notes: "",
735
+ days_ago: 12,
736
+ updated_ago: 12
737
+ },
738
+ {
739
+ title: "Migrate image storage to CDN",
740
+ details: "Move product images from local disk to CloudFront. Needs URL rewriting for existing images.",
741
+ category: "task",
742
+ priority: "high",
743
+ status: "started",
744
+ up_next: false,
745
+ notes: "",
746
+ days_ago: 4,
747
+ updated_ago: 2
748
+ },
749
+ {
750
+ title: "Set up error monitoring with Sentry",
751
+ details: "Configure Sentry for both server and client-side error tracking. Set up alert rules for critical errors.",
752
+ category: "task",
753
+ priority: "default",
754
+ status: "completed",
755
+ up_next: false,
756
+ notes: notesJson([{ text: "Sentry configured for Node.js backend and React frontend. Alert rules set for 5xx errors.", days_ago: 3 }]),
757
+ days_ago: 10,
758
+ updated_ago: 3,
759
+ completed_ago: 3
760
+ },
761
+ {
762
+ title: "Support guest checkout without account creation",
763
+ details: "High-priority requirement change from product. Many users abandon at the registration step. Allow checkout with just email.",
764
+ category: "requirement_change",
765
+ priority: "high",
766
+ status: "not_started",
767
+ up_next: true,
768
+ notes: "",
769
+ days_ago: 2,
770
+ updated_ago: 2
771
+ },
772
+ {
773
+ title: "Update return policy to 60-day window",
774
+ details: "Legal team requires extending the return window from 30 to 60 days. Update all customer-facing copy and the returns API logic.",
775
+ category: "requirement_change",
776
+ priority: "default",
777
+ status: "started",
778
+ up_next: false,
779
+ notes: "",
780
+ days_ago: 8,
781
+ updated_ago: 3
782
+ },
783
+ {
784
+ title: "Compare Redis vs Memcached for session storage",
785
+ details: "Current in-memory sessions don't survive restarts. Evaluate Redis and Memcached for persistence, speed, and ops complexity.",
786
+ category: "investigation",
787
+ priority: "high",
788
+ status: "not_started",
789
+ up_next: false,
790
+ notes: "",
791
+ days_ago: 6,
792
+ updated_ago: 6
793
+ },
794
+ {
795
+ title: "Analyze mobile conversion drop-off funnel",
796
+ details: "Mobile users convert at 1.2% vs 3.8% desktop. Investigate where in the funnel mobile users are dropping off.",
797
+ category: "investigation",
798
+ priority: "default",
799
+ status: "not_started",
800
+ up_next: false,
801
+ notes: "",
802
+ days_ago: 11,
803
+ updated_ago: 11
804
+ }
805
+ ];
806
+ var SCENARIO_4 = [
807
+ {
808
+ title: "Fix race condition in concurrent order placement",
809
+ details: "When two orders are placed simultaneously for the last item in stock, both succeed and inventory goes negative.\n\nNeed to add row-level locking in OrderService.place() or use a serializable transaction.",
810
+ category: "bug",
811
+ priority: "highest",
812
+ status: "started",
813
+ up_next: true,
814
+ notes: notesJson([
815
+ { text: "Reproduced the issue with a concurrent request test. The problem is in OrderService.place() \u2014 it reads inventory, then decrements in a separate query without locking.", days_ago: 1 },
816
+ { text: "Implemented SELECT ... FOR UPDATE on the inventory row. Running stress tests to confirm the fix holds under load.", days_ago: 0.3 }
817
+ ]),
818
+ days_ago: 4,
819
+ updated_ago: 0.3
820
+ },
821
+ {
822
+ title: "Add webhook notifications for order status changes",
823
+ details: "Merchants need to receive POST webhooks when order status changes (placed, shipped, delivered, cancelled). Include order details and a signature header for verification.",
824
+ category: "feature",
825
+ priority: "high",
826
+ status: "not_started",
827
+ up_next: true,
828
+ notes: "",
829
+ days_ago: 3,
830
+ updated_ago: 3
831
+ },
832
+ {
833
+ title: "Add input validation to all public API endpoints",
834
+ details: "Several endpoints accept unvalidated input. Add zod schemas for request bodies and query params on all /api/* routes.",
835
+ category: "task",
836
+ priority: "high",
837
+ status: "not_started",
838
+ up_next: true,
839
+ notes: "",
840
+ days_ago: 5,
841
+ updated_ago: 5
842
+ },
843
+ {
844
+ title: "Fix decimal precision loss in price calculations",
845
+ details: "Prices stored as NUMERIC(10,2) but JavaScript floating-point math causes rounding errors in totals. Convert all price math to integer cents.",
846
+ category: "bug",
847
+ priority: "default",
848
+ status: "not_started",
849
+ up_next: true,
850
+ notes: "",
851
+ days_ago: 6,
852
+ updated_ago: 6
853
+ },
854
+ {
855
+ title: "Evaluate caching strategies for product catalog",
856
+ details: "Product pages are slow under load. Investigate Redis caching, CDN edge caching, and stale-while-revalidate patterns. Need to maintain cache coherency on product updates.",
857
+ category: "investigation",
858
+ priority: "default",
859
+ status: "not_started",
860
+ up_next: true,
861
+ notes: "",
862
+ days_ago: 7,
863
+ updated_ago: 7
864
+ },
865
+ {
866
+ title: "Add bulk product import from CSV",
867
+ details: "Merchants need to upload a CSV of products to create/update inventory in batch. Support create, update, and skip-on-conflict modes.",
868
+ category: "feature",
869
+ priority: "low",
870
+ status: "completed",
871
+ up_next: false,
872
+ notes: notesJson([
873
+ { text: "Implemented CSV parser using papaparse. Supports create and update modes with duplicate detection by SKU.", days_ago: 3 },
874
+ { text: "Added validation for required fields (name, price, SKU) and friendly error messages with row numbers for malformed data.", days_ago: 2 }
875
+ ]),
876
+ days_ago: 10,
877
+ updated_ago: 2,
878
+ completed_ago: 2
879
+ },
880
+ {
881
+ title: "Normalize database schema for customer addresses",
882
+ details: "Addresses are currently embedded as JSON in the customers table. Extract to a separate addresses table with proper foreign keys.",
883
+ category: "task",
884
+ priority: "default",
885
+ status: "verified",
886
+ up_next: false,
887
+ notes: notesJson([
888
+ { text: "Created migration to extract addresses into a new table. Backfilled 12,400 existing address records.", days_ago: 5 },
889
+ { text: "Verified the migration ran correctly. All address lookups use the new table. Old JSON column can be dropped in next release.", days_ago: 3 }
890
+ ]),
891
+ days_ago: 14,
892
+ updated_ago: 3,
893
+ completed_ago: 5,
894
+ verified_ago: 3
895
+ }
896
+ ];
897
+ var SCENARIO_5 = [
898
+ {
899
+ title: "Fix email template rendering in Outlook",
900
+ details: "Order confirmation emails break in Outlook due to unsupported CSS flexbox. Use table-based layout.",
901
+ category: "bug",
902
+ priority: "default",
903
+ status: "not_started",
904
+ up_next: false,
905
+ notes: "",
906
+ days_ago: 3,
907
+ updated_ago: 3
908
+ },
909
+ {
910
+ title: "Handle timeout on third-party shipping rate API",
911
+ details: "When the shipping provider API times out, the checkout page shows a generic 500 error. Show a retry prompt instead.",
912
+ category: "bug",
913
+ priority: "default",
914
+ status: "not_started",
915
+ up_next: false,
916
+ notes: "",
917
+ days_ago: 4,
918
+ updated_ago: 4
919
+ },
920
+ {
921
+ title: "Fix pagination on search results page",
922
+ details: "Page 2+ of search results shows duplicate items. The OFFSET calculation is wrong when filters change.",
923
+ category: "bug",
924
+ priority: "default",
925
+ status: "not_started",
926
+ up_next: false,
927
+ notes: "",
928
+ days_ago: 5,
929
+ updated_ago: 5
930
+ },
931
+ {
932
+ title: "Cart badge count not updating after item removal",
933
+ details: "The header cart icon shows the old count until a full page refresh. The client state isn't being updated.",
934
+ category: "bug",
935
+ priority: "high",
936
+ status: "not_started",
937
+ up_next: false,
938
+ notes: "",
939
+ days_ago: 2,
940
+ updated_ago: 2
941
+ },
942
+ {
943
+ title: "Add order tracking page for customers",
944
+ details: "Customers need a page showing shipment status, tracking number, and estimated delivery. Pull data from the shipping provider API.",
945
+ category: "feature",
946
+ priority: "default",
947
+ status: "not_started",
948
+ up_next: false,
949
+ notes: "",
950
+ days_ago: 6,
951
+ updated_ago: 6
952
+ },
953
+ {
954
+ title: "Implement product review moderation queue",
955
+ details: "Admin interface to approve/reject/flag user reviews before they appear publicly.",
956
+ category: "feature",
957
+ priority: "default",
958
+ status: "not_started",
959
+ up_next: false,
960
+ notes: "",
961
+ days_ago: 7,
962
+ updated_ago: 7
963
+ },
964
+ {
965
+ title: "Add rate limiting to public API endpoints",
966
+ details: "Protect against abuse with per-IP rate limiting. Use a sliding window algorithm. Target: 100 req/min for anonymous, 500 for authenticated.",
967
+ category: "task",
968
+ priority: "default",
969
+ status: "not_started",
970
+ up_next: false,
971
+ notes: "",
972
+ days_ago: 8,
973
+ updated_ago: 8
974
+ },
975
+ {
976
+ title: "Set up staging environment on AWS",
977
+ details: "Mirror production setup with smaller instances. Auto-deploy from the develop branch.",
978
+ category: "task",
979
+ priority: "default",
980
+ status: "not_started",
981
+ up_next: false,
982
+ notes: "",
983
+ days_ago: 9,
984
+ updated_ago: 9
985
+ },
986
+ {
987
+ title: "Clean up unused CSS classes from redesign",
988
+ details: "The recent redesign left ~40 unused CSS classes. Run PurgeCSS and remove dead code.",
989
+ category: "task",
990
+ priority: "low",
991
+ status: "not_started",
992
+ up_next: false,
993
+ notes: "",
994
+ days_ago: 12,
995
+ updated_ago: 12
996
+ },
997
+ {
998
+ title: "Archive completed migration files older than 6 months",
999
+ details: "Move old migration files to an archive directory to keep the migrations folder manageable.",
1000
+ category: "task",
1001
+ priority: "low",
1002
+ status: "not_started",
1003
+ up_next: false,
1004
+ notes: "",
1005
+ days_ago: 14,
1006
+ updated_ago: 14
1007
+ }
1008
+ ];
1009
+ var SCENARIO_6 = [
1010
+ {
1011
+ title: "Implement real-time order tracking with WebSockets",
1012
+ details: "Build a live order tracking view that pushes status updates to the customer in real-time.\n\nRequirements:\n- WebSocket connection per active order\n- Status events: confirmed, preparing, shipped, out_for_delivery, delivered\n- Reconnect logic with exponential backoff\n- Fallback to polling for browsers without WebSocket support\n\nThe tracking page should show a visual timeline with the current step highlighted.",
1013
+ category: "feature",
1014
+ priority: "highest",
1015
+ status: "started",
1016
+ up_next: true,
1017
+ notes: notesJson([
1018
+ { text: "Set up the WebSocket server using ws library. Basic connection lifecycle working \u2014 connect, heartbeat, disconnect with cleanup.", days_ago: 3 },
1019
+ { text: "Implemented the event broadcast system. When an order status changes in the API, all connected clients for that order receive a push event. Added Redis pub/sub for multi-server support.", days_ago: 2 },
1020
+ { text: "Built the client-side tracking timeline component. Shows all 5 status steps with the current one highlighted. Working on the reconnect logic next.", days_ago: 0.5 }
1021
+ ]),
1022
+ days_ago: 6,
1023
+ updated_ago: 0.5
1024
+ },
1025
+ {
1026
+ title: "Fix memory leak in product search indexer",
1027
+ details: "The search indexer process grows from 200MB to 2GB+ over 24 hours. Likely a reference leak in the batch processing pipeline.",
1028
+ category: "bug",
1029
+ priority: "high",
1030
+ status: "started",
1031
+ up_next: true,
1032
+ notes: notesJson([{ text: "Heap snapshot shows the BatchProcessor holding references to completed batches. The onComplete callbacks are never cleaned up.", days_ago: 1 }]),
1033
+ days_ago: 5,
1034
+ updated_ago: 1
1035
+ },
1036
+ {
1037
+ title: "Add comprehensive test coverage for payment flow",
1038
+ details: "The payment processing flow has no integration tests. Add tests covering: successful payment, declined card, network timeout, partial refund, and currency conversion.",
1039
+ category: "task",
1040
+ priority: "high",
1041
+ status: "not_started",
1042
+ up_next: true,
1043
+ notes: "",
1044
+ days_ago: 4,
1045
+ updated_ago: 4
1046
+ },
1047
+ {
1048
+ title: "Add product recommendations based on purchase history",
1049
+ details: 'Show "Customers also bought" recommendations on product pages using collaborative filtering on order history.',
1050
+ category: "feature",
1051
+ priority: "default",
1052
+ status: "completed",
1053
+ up_next: false,
1054
+ notes: notesJson([
1055
+ { text: "Implemented a simple collaborative filtering algorithm. Computes item-item similarity from co-purchase frequency in the last 90 days.", days_ago: 5 },
1056
+ { text: "Added the recommendations API endpoint and the product page widget. Limited to 4 recommendations. Recalculation runs nightly via cron.", days_ago: 3 }
1057
+ ]),
1058
+ days_ago: 12,
1059
+ updated_ago: 3,
1060
+ completed_ago: 3
1061
+ },
1062
+ {
1063
+ title: "Migrate static assets to CDN",
1064
+ details: "Product images, CSS, and JS bundles should be served from CloudFront. Reduces server load and improves page load times globally.",
1065
+ category: "task",
1066
+ priority: "default",
1067
+ status: "verified",
1068
+ up_next: false,
1069
+ notes: notesJson([
1070
+ { text: "Configured CloudFront distribution with S3 origin. Migrated all product images (42GB) using the AWS CLI sync command.", days_ago: 7 },
1071
+ { text: "Updated asset URLs in the application to use the CDN domain. Cache hit rate is at 94% after 48 hours. TTFB improved from 240ms to 35ms for static assets.", days_ago: 5 }
1072
+ ]),
1073
+ days_ago: 16,
1074
+ updated_ago: 5,
1075
+ completed_ago: 7,
1076
+ verified_ago: 5
1077
+ },
1078
+ {
1079
+ title: "Fix broken breadcrumb links on category pages",
1080
+ details: "Nested category breadcrumbs link to the wrong parent when the category tree is more than 3 levels deep.",
1081
+ category: "bug",
1082
+ priority: "default",
1083
+ status: "not_started",
1084
+ up_next: false,
1085
+ notes: "",
1086
+ days_ago: 8,
1087
+ updated_ago: 8
1088
+ }
1089
+ ];
1090
+ var SCENARIO_DATA = {
1091
+ 1: SCENARIO_1,
1092
+ 2: SCENARIO_2,
1093
+ 3: SCENARIO_3,
1094
+ 4: SCENARIO_4,
1095
+ 5: SCENARIO_5,
1096
+ 6: SCENARIO_6
1097
+ };
1098
+ async function seedDemoData(scenario) {
1099
+ const db2 = await getDb();
1100
+ const tickets = SCENARIO_DATA[scenario];
1101
+ if (!tickets) return;
1102
+ for (let i = 0; i < tickets.length; i++) {
1103
+ const t = tickets[i];
1104
+ const ticketNumber = `HS-${i + 1}`;
1105
+ const createdAt = daysAgo(t.days_ago);
1106
+ const updatedAt = daysAgo(t.updated_ago);
1107
+ const completedAt = t.completed_ago !== void 0 ? daysAgo(t.completed_ago) : null;
1108
+ const verifiedAt = t.verified_ago !== void 0 ? daysAgo(t.verified_ago) : null;
1109
+ await db2.query(`
1110
+ INSERT INTO tickets (ticket_number, title, details, category, priority, status, up_next, notes, created_at, updated_at, completed_at, verified_at)
1111
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::timestamp, $10::timestamp, $11::timestamp, $12::timestamp)
1112
+ `, [ticketNumber, t.title, t.details, t.category, t.priority, t.status, t.up_next, t.notes, createdAt, updatedAt, completedAt, verifiedAt]);
1113
+ }
1114
+ await db2.query(`SELECT setval('ticket_seq', $1)`, [tickets.length]);
1115
+ if (scenario === 6) {
1116
+ await db2.query(`UPDATE settings SET value = 'bottom' WHERE key = 'detail_position'`);
1117
+ await db2.query(`UPDATE settings SET value = '280' WHERE key = 'detail_height'`);
1118
+ }
1119
+ }
1120
+
1121
+ // src/cli.ts
1122
+ init_gitignore();
1123
+
1124
+ // src/server.ts
1125
+ import { serve } from "@hono/node-server";
1126
+ import { exec } from "child_process";
1127
+ import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
1128
+ import { Hono as Hono3 } from "hono";
1129
+ import { dirname, join as join5 } from "path";
1130
+ import { fileURLToPath } from "url";
1131
+
1132
+ // src/routes/api.ts
1133
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, rmSync as rmSync3, writeFileSync as writeFileSync2 } from "fs";
1134
+ import { Hono } from "hono";
1135
+ import { basename, extname, join as join4, relative } from "path";
1136
+
1137
+ // src/sync/markdown.ts
1138
+ import { writeFileSync } from "fs";
1139
+ import { join as join3 } from "path";
1140
+
1141
+ // src/types.ts
1142
+ var CATEGORY_DESCRIPTIONS = {
1143
+ issue: "General issues that need attention",
1144
+ bug: "Bugs that should be fixed in the codebase",
1145
+ feature: "New features to be implemented",
1146
+ requirement_change: "Changes to existing requirements",
1147
+ task: "General tasks to complete",
1148
+ investigation: "Items requiring research or analysis"
1149
+ };
1150
+
1151
+ // src/sync/markdown.ts
1152
+ var dataDir;
1153
+ var worklistTimeout = null;
1154
+ var openTicketsTimeout = null;
1155
+ var WORKLIST_DEBOUNCE = 500;
1156
+ var OPEN_TICKETS_DEBOUNCE = 5e3;
1157
+ function initMarkdownSync(dir) {
1158
+ dataDir = dir;
1159
+ }
1160
+ function scheduleWorklistSync() {
1161
+ if (worklistTimeout) clearTimeout(worklistTimeout);
1162
+ worklistTimeout = setTimeout(() => {
1163
+ void syncWorklist();
1164
+ }, WORKLIST_DEBOUNCE);
1165
+ }
1166
+ function scheduleOpenTicketsSync() {
1167
+ if (openTicketsTimeout) clearTimeout(openTicketsTimeout);
1168
+ openTicketsTimeout = setTimeout(() => {
1169
+ void syncOpenTickets();
1170
+ }, OPEN_TICKETS_DEBOUNCE);
1171
+ }
1172
+ function scheduleAllSync() {
1173
+ scheduleWorklistSync();
1174
+ scheduleOpenTicketsSync();
1175
+ }
1176
+ function parseTicketNotes(raw2) {
1177
+ if (!raw2 || raw2 === "") return [];
1178
+ try {
1179
+ const parsed = JSON.parse(raw2);
1180
+ if (Array.isArray(parsed)) return parsed;
1181
+ } catch {
1182
+ }
1183
+ if (raw2.trim()) return [{ text: raw2, created_at: "" }];
1184
+ return [];
1185
+ }
1186
+ async function formatTicket(ticket) {
1187
+ const attachments = await getAttachments(ticket.id);
1188
+ const lines = [];
1189
+ lines.push(`TICKET ${ticket.ticket_number}:`);
1190
+ lines.push(`- ID: ${ticket.id}`);
1191
+ lines.push(`- Type: ${ticket.category}`);
1192
+ lines.push(`- Priority: ${ticket.priority}`);
1193
+ lines.push(`- Status: ${ticket.status.replace("_", " ")}`);
1194
+ lines.push(`- Title: ${ticket.title}`);
1195
+ if (ticket.details.trim()) {
1196
+ const detailLines = ticket.details.split("\n");
1197
+ lines.push(`- Details: ${detailLines[0]}`);
1198
+ for (let i = 1; i < detailLines.length; i++) {
1199
+ lines.push(` ${detailLines[i]}`);
1200
+ }
1201
+ }
1202
+ const notes = parseTicketNotes(ticket.notes);
1203
+ if (notes.length > 0) {
1204
+ lines.push(`- Notes:`);
1205
+ for (const note of notes) {
1206
+ const timestamp = note.created_at ? ` (${new Date(note.created_at).toLocaleString()})` : "";
1207
+ lines.push(` - ${note.text}${timestamp}`);
1208
+ }
1209
+ }
1210
+ if (attachments.length > 0) {
1211
+ lines.push(`- Attachments:`);
1212
+ for (const att of attachments) {
1213
+ lines.push(` - ${att.stored_path}`);
1214
+ }
1215
+ }
1216
+ return lines.join("\n");
1217
+ }
1218
+ function formatCategoryDescriptions(categories) {
1219
+ const lines = ["Ticket Types:"];
1220
+ for (const cat of categories) {
1221
+ lines.push(`- ${cat} - ${CATEGORY_DESCRIPTIONS[cat]}`);
1222
+ }
1223
+ return lines.join("\n");
1224
+ }
1225
+ async function syncWorklist() {
1226
+ try {
1227
+ const tickets = await getTickets({ up_next: true, sort_by: "priority", sort_dir: "asc" });
1228
+ const categories = /* @__PURE__ */ new Set();
1229
+ const sections = [];
1230
+ sections.push("# Hot Sheet - Up Next");
1231
+ sections.push("");
1232
+ sections.push("These are the current priority work items. Complete them in order of priority, where reasonable.");
1233
+ sections.push("");
1234
+ sections.push("## Workflow");
1235
+ sections.push("");
1236
+ sections.push("The Hot Sheet API is available at http://localhost:4174/api. Use it to update ticket status as you work:");
1237
+ sections.push("");
1238
+ sections.push('- **When you start working on a ticket**, set its status to "started":');
1239
+ sections.push(' `curl -X PATCH http://localhost:4174/api/tickets/{id} -H "Content-Type: application/json" -d \'{"status": "started"}\'`');
1240
+ sections.push("");
1241
+ sections.push('- **When you finish working on a ticket**, set its status to "completed" and add notes describing what was done:');
1242
+ sections.push(' `curl -X PATCH http://localhost:4174/api/tickets/{id} -H "Content-Type: application/json" -d \'{"status": "completed", "notes": "Description of work completed"}\'`');
1243
+ sections.push("");
1244
+ sections.push('Do NOT set tickets to "verified" \u2014 that status is reserved for human review.');
1245
+ sections.push("");
1246
+ if (tickets.length === 0) {
1247
+ sections.push("No items in the Up Next list.");
1248
+ } else {
1249
+ for (const ticket of tickets) {
1250
+ categories.add(ticket.category);
1251
+ sections.push("---");
1252
+ sections.push("");
1253
+ const formatted = await formatTicket(ticket);
1254
+ sections.push(formatted);
1255
+ sections.push("");
1256
+ }
1257
+ sections.push("---");
1258
+ sections.push("");
1259
+ sections.push(formatCategoryDescriptions(categories));
1260
+ }
1261
+ sections.push("");
1262
+ writeFileSync(join3(dataDir, "worklist.md"), sections.join("\n"), "utf-8");
1263
+ } catch (err) {
1264
+ console.error("Failed to sync worklist.md:", err);
1265
+ }
1266
+ }
1267
+ async function syncOpenTickets() {
1268
+ try {
1269
+ const tickets = await getTickets({ status: "open", sort_by: "priority", sort_dir: "asc" });
1270
+ const categories = /* @__PURE__ */ new Set();
1271
+ const sections = [];
1272
+ sections.push("# Hot Sheet - Open Tickets");
1273
+ sections.push("");
1274
+ sections.push(`Total: ${tickets.length} open ticket(s)`);
1275
+ sections.push("");
1276
+ const started = tickets.filter((t) => t.status === "started");
1277
+ const notStarted = tickets.filter((t) => t.status === "not_started");
1278
+ if (started.length > 0) {
1279
+ sections.push(`## Started (${started.length})`);
1280
+ sections.push("");
1281
+ for (const ticket of started) {
1282
+ categories.add(ticket.category);
1283
+ const formatted = await formatTicket(ticket);
1284
+ sections.push(formatted);
1285
+ sections.push("");
1286
+ }
1287
+ }
1288
+ if (notStarted.length > 0) {
1289
+ sections.push(`## Not Started (${notStarted.length})`);
1290
+ sections.push("");
1291
+ for (const ticket of notStarted) {
1292
+ categories.add(ticket.category);
1293
+ const formatted = await formatTicket(ticket);
1294
+ sections.push(formatted);
1295
+ sections.push("");
1296
+ }
1297
+ }
1298
+ if (tickets.length === 0) {
1299
+ sections.push("No open tickets.");
1300
+ } else {
1301
+ sections.push("---");
1302
+ sections.push("");
1303
+ sections.push(formatCategoryDescriptions(categories));
1304
+ }
1305
+ sections.push("");
1306
+ writeFileSync(join3(dataDir, "open-tickets.md"), sections.join("\n"), "utf-8");
1307
+ } catch (err) {
1308
+ console.error("Failed to sync open-tickets.md:", err);
1309
+ }
1310
+ }
1311
+
1312
+ // src/routes/api.ts
1313
+ var apiRoutes = new Hono();
1314
+ var changeVersion = 0;
1315
+ var pollWaiters = [];
1316
+ function notifyChange() {
1317
+ changeVersion++;
1318
+ const waiters = pollWaiters;
1319
+ pollWaiters = [];
1320
+ for (const resolve2 of waiters) {
1321
+ resolve2(changeVersion);
1322
+ }
1323
+ }
1324
+ apiRoutes.get("/poll", async (c) => {
1325
+ const clientVersion = parseInt(c.req.query("version") || "0", 10);
1326
+ if (changeVersion > clientVersion) {
1327
+ return c.json({ version: changeVersion });
1328
+ }
1329
+ const version = await Promise.race([
1330
+ new Promise((resolve2) => {
1331
+ pollWaiters.push(resolve2);
1332
+ }),
1333
+ new Promise((resolve2) => {
1334
+ setTimeout(() => resolve2(changeVersion), 3e4);
1335
+ })
1336
+ ]);
1337
+ return c.json({ version });
1338
+ });
1339
+ apiRoutes.get("/tickets", async (c) => {
1340
+ const filters = {};
1341
+ const category = c.req.query("category");
1342
+ if (category !== void 0 && category !== "") filters.category = category;
1343
+ const priority = c.req.query("priority");
1344
+ if (priority !== void 0 && priority !== "") filters.priority = priority;
1345
+ const status = c.req.query("status");
1346
+ if (status !== void 0 && status !== "") filters.status = status;
1347
+ const upNext = c.req.query("up_next");
1348
+ if (upNext !== void 0) filters.up_next = upNext === "true";
1349
+ const search = c.req.query("search");
1350
+ if (search !== void 0 && search !== "") filters.search = search;
1351
+ const sortBy = c.req.query("sort_by");
1352
+ if (sortBy !== void 0 && sortBy !== "") filters.sort_by = sortBy;
1353
+ const sortDir = c.req.query("sort_dir");
1354
+ if (sortDir !== void 0 && sortDir !== "") filters.sort_dir = sortDir;
1355
+ const tickets = await getTickets(filters);
1356
+ return c.json(tickets);
1357
+ });
1358
+ apiRoutes.post("/tickets", async (c) => {
1359
+ const body = await c.req.json();
1360
+ const ticket = await createTicket(body.title || "", body.defaults);
1361
+ scheduleAllSync();
1362
+ notifyChange();
1363
+ return c.json(ticket, 201);
1364
+ });
1365
+ apiRoutes.get("/tickets/:id", async (c) => {
1366
+ const id = parseInt(c.req.param("id"), 10);
1367
+ const ticket = await getTicket(id);
1368
+ if (!ticket) return c.json({ error: "Not found" }, 404);
1369
+ const attachments = await getAttachments(id);
1370
+ return c.json({ ...ticket, attachments });
1371
+ });
1372
+ apiRoutes.patch("/tickets/:id", async (c) => {
1373
+ const id = parseInt(c.req.param("id"), 10);
1374
+ const body = await c.req.json();
1375
+ const ticket = await updateTicket(id, body);
1376
+ if (!ticket) return c.json({ error: "Not found" }, 404);
1377
+ scheduleAllSync();
1378
+ notifyChange();
1379
+ return c.json(ticket);
1380
+ });
1381
+ apiRoutes.delete("/tickets/:id", async (c) => {
1382
+ const id = parseInt(c.req.param("id"), 10);
1383
+ await deleteTicket(id);
1384
+ scheduleAllSync();
1385
+ notifyChange();
1386
+ return c.json({ ok: true });
1387
+ });
1388
+ apiRoutes.delete("/tickets/:id/hard", async (c) => {
1389
+ const id = parseInt(c.req.param("id"), 10);
1390
+ const attachments = await getAttachments(id);
1391
+ for (const att of attachments) {
1392
+ try {
1393
+ rmSync3(att.stored_path, { force: true });
1394
+ } catch {
1395
+ }
1396
+ }
1397
+ await hardDeleteTicket(id);
1398
+ scheduleAllSync();
1399
+ notifyChange();
1400
+ return c.json({ ok: true });
1401
+ });
1402
+ apiRoutes.post("/tickets/batch", async (c) => {
1403
+ const body = await c.req.json();
1404
+ switch (body.action) {
1405
+ case "delete":
1406
+ await batchDeleteTickets(body.ids);
1407
+ break;
1408
+ case "restore":
1409
+ await batchRestoreTickets(body.ids);
1410
+ break;
1411
+ case "category":
1412
+ await batchUpdateTickets(body.ids, { category: body.value });
1413
+ break;
1414
+ case "priority":
1415
+ await batchUpdateTickets(body.ids, { priority: body.value });
1416
+ break;
1417
+ case "status":
1418
+ await batchUpdateTickets(body.ids, { status: body.value });
1419
+ break;
1420
+ case "up_next":
1421
+ await batchUpdateTickets(body.ids, { up_next: body.value });
1422
+ break;
1423
+ }
1424
+ scheduleAllSync();
1425
+ notifyChange();
1426
+ return c.json({ ok: true });
1427
+ });
1428
+ apiRoutes.post("/tickets/:id/restore", async (c) => {
1429
+ const id = parseInt(c.req.param("id"), 10);
1430
+ const ticket = await restoreTicket(id);
1431
+ if (!ticket) return c.json({ error: "Not found" }, 404);
1432
+ scheduleAllSync();
1433
+ notifyChange();
1434
+ return c.json(ticket);
1435
+ });
1436
+ apiRoutes.post("/trash/empty", async (c) => {
1437
+ const deleted = await getTickets({ status: "deleted" });
1438
+ for (const ticket of deleted) {
1439
+ const attachments = await getAttachments(ticket.id);
1440
+ for (const att of attachments) {
1441
+ try {
1442
+ rmSync3(att.stored_path, { force: true });
1443
+ } catch {
1444
+ }
1445
+ }
1446
+ }
1447
+ await emptyTrash();
1448
+ scheduleAllSync();
1449
+ notifyChange();
1450
+ return c.json({ ok: true });
1451
+ });
1452
+ apiRoutes.post("/tickets/:id/up-next", async (c) => {
1453
+ const id = parseInt(c.req.param("id"), 10);
1454
+ const ticket = await toggleUpNext(id);
1455
+ if (!ticket) return c.json({ error: "Not found" }, 404);
1456
+ scheduleAllSync();
1457
+ notifyChange();
1458
+ return c.json(ticket);
1459
+ });
1460
+ apiRoutes.post("/tickets/:id/attachments", async (c) => {
1461
+ const id = parseInt(c.req.param("id"), 10);
1462
+ const ticket = await getTicket(id);
1463
+ if (!ticket) return c.json({ error: "Ticket not found" }, 404);
1464
+ const dataDir2 = c.get("dataDir");
1465
+ const body = await c.req.parseBody();
1466
+ const file = body["file"];
1467
+ if (typeof file === "string") {
1468
+ return c.json({ error: "No file uploaded" }, 400);
1469
+ }
1470
+ const originalName = file.name;
1471
+ const ext = extname(originalName);
1472
+ const baseName = basename(originalName, ext);
1473
+ const storedName = `${ticket.ticket_number}_${baseName}${ext}`;
1474
+ const attachDir = join4(dataDir2, "attachments");
1475
+ mkdirSync2(attachDir, { recursive: true });
1476
+ const storedPath = join4(attachDir, storedName);
1477
+ const buffer = Buffer.from(await file.arrayBuffer());
1478
+ const { writeFileSync: writeFileSync3 } = await import("fs");
1479
+ writeFileSync3(storedPath, buffer);
1480
+ const attachment = await addAttachment(id, originalName, storedPath);
1481
+ scheduleAllSync();
1482
+ notifyChange();
1483
+ return c.json(attachment, 201);
1484
+ });
1485
+ apiRoutes.delete("/attachments/:id", async (c) => {
1486
+ const id = parseInt(c.req.param("id"), 10);
1487
+ const attachment = await deleteAttachment(id);
1488
+ if (!attachment) return c.json({ error: "Not found" }, 404);
1489
+ try {
1490
+ rmSync3(attachment.stored_path, { force: true });
1491
+ } catch {
1492
+ }
1493
+ scheduleAllSync();
1494
+ notifyChange();
1495
+ return c.json({ ok: true });
1496
+ });
1497
+ apiRoutes.get("/attachments/file/*", async (c) => {
1498
+ const filePath = c.req.path.replace("/api/attachments/file/", "");
1499
+ const dataDir2 = c.get("dataDir");
1500
+ const fullPath = join4(dataDir2, "attachments", filePath);
1501
+ if (!existsSync2(fullPath)) {
1502
+ return c.json({ error: "File not found" }, 404);
1503
+ }
1504
+ const { readFileSync: readFileSync3 } = await import("fs");
1505
+ const content = readFileSync3(fullPath);
1506
+ const ext = extname(fullPath).toLowerCase();
1507
+ const mimeTypes = {
1508
+ ".png": "image/png",
1509
+ ".jpg": "image/jpeg",
1510
+ ".jpeg": "image/jpeg",
1511
+ ".gif": "image/gif",
1512
+ ".svg": "image/svg+xml",
1513
+ ".pdf": "application/pdf",
1514
+ ".txt": "text/plain",
1515
+ ".md": "text/markdown",
1516
+ ".json": "application/json"
1517
+ };
1518
+ const contentType = mimeTypes[ext] || "application/octet-stream";
1519
+ return new Response(content, {
1520
+ headers: { "Content-Type": contentType }
1521
+ });
1522
+ });
1523
+ apiRoutes.get("/stats", async (c) => {
1524
+ const stats = await getTicketStats();
1525
+ return c.json(stats);
1526
+ });
1527
+ apiRoutes.get("/settings", async (c) => {
1528
+ const settings = await getSettings();
1529
+ return c.json(settings);
1530
+ });
1531
+ apiRoutes.patch("/settings", async (c) => {
1532
+ const body = await c.req.json();
1533
+ for (const [key, value] of Object.entries(body)) {
1534
+ await updateSetting(key, value);
1535
+ }
1536
+ return c.json({ ok: true });
1537
+ });
1538
+ apiRoutes.get("/worklist-info", (c) => {
1539
+ const dataDir2 = c.get("dataDir");
1540
+ const cwd = process.cwd();
1541
+ const worklistRel = relative(cwd, join4(dataDir2, "worklist.md"));
1542
+ const prompt = `Read ${worklistRel} for current work items.`;
1543
+ const claudeDir = join4(cwd, ".claude");
1544
+ let skillCreated = false;
1545
+ if (existsSync2(claudeDir)) {
1546
+ const skillDir = join4(claudeDir, "skills", "hotsheet");
1547
+ const skillFile = join4(skillDir, "SKILL.md");
1548
+ if (!existsSync2(skillFile)) {
1549
+ mkdirSync2(skillDir, { recursive: true });
1550
+ const skillContent = [
1551
+ "---",
1552
+ "name: hotsheet",
1553
+ "description: Read the Hot Sheet worklist and work through the current priority items",
1554
+ "allowed-tools: Read, Grep, Glob, Edit, Write, Bash",
1555
+ "---",
1556
+ "",
1557
+ `Read \`${worklistRel}\` and work through the tickets in priority order.`,
1558
+ "",
1559
+ "For each ticket:",
1560
+ "1. Read the ticket details carefully",
1561
+ "2. Implement the work described",
1562
+ "3. When complete, mark it done via the Hot Sheet UI",
1563
+ "",
1564
+ "Work through them in order of priority, where reasonable.",
1565
+ ""
1566
+ ].join("\n");
1567
+ writeFileSync2(skillFile, skillContent, "utf-8");
1568
+ skillCreated = true;
1569
+ }
1570
+ }
1571
+ return c.json({ prompt, skillCreated });
1572
+ });
1573
+ apiRoutes.get("/gitignore/status", async (c) => {
1574
+ const { isGitRepo: isGitRepo2, isHotsheetGitignored: isHotsheetGitignored2 } = await Promise.resolve().then(() => (init_gitignore(), gitignore_exports));
1575
+ const cwd = process.cwd();
1576
+ if (!isGitRepo2(cwd)) return c.json({ inGitRepo: false, ignored: false });
1577
+ return c.json({ inGitRepo: true, ignored: isHotsheetGitignored2(cwd) });
1578
+ });
1579
+ apiRoutes.post("/gitignore/add", async (c) => {
1580
+ const { ensureGitignore: ensureGitignore2 } = await Promise.resolve().then(() => (init_gitignore(), gitignore_exports));
1581
+ ensureGitignore2(process.cwd());
1582
+ return c.json({ ok: true });
1583
+ });
1584
+
1585
+ // src/routes/pages.tsx
1586
+ import { Hono as Hono2 } from "hono";
1587
+
1588
+ // src/utils/escapeHtml.ts
1589
+ function escapeHtml(str) {
1590
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1591
+ }
1592
+ function escapeAttr(str) {
1593
+ return str.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/'/g, "&#39;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1594
+ }
1595
+
1596
+ // src/jsx-runtime.ts
1597
+ var SafeHtml = class {
1598
+ __html;
1599
+ constructor(html) {
1600
+ this.__html = html;
1601
+ }
1602
+ toString() {
1603
+ return this.__html;
1604
+ }
1605
+ };
1606
+ function raw(html) {
1607
+ return new SafeHtml(html);
1608
+ }
1609
+ var VOID_TAGS = /* @__PURE__ */ new Set([
1610
+ "area",
1611
+ "base",
1612
+ "br",
1613
+ "col",
1614
+ "embed",
1615
+ "hr",
1616
+ "img",
1617
+ "input",
1618
+ "link",
1619
+ "meta",
1620
+ "source",
1621
+ "track",
1622
+ "wbr"
1623
+ ]);
1624
+ function renderChildren(children) {
1625
+ if (children == null || typeof children === "boolean") return "";
1626
+ if (children instanceof SafeHtml) return children.__html;
1627
+ if (typeof children === "string") return escapeHtml(children);
1628
+ if (typeof children === "number") return String(children);
1629
+ if (Array.isArray(children)) return children.map(renderChildren).join("");
1630
+ return "";
1631
+ }
1632
+ function renderAttr(key, value) {
1633
+ if (value == null || value === false) return "";
1634
+ if (value === true) return ` ${key}`;
1635
+ const name = key === "className" ? "class" : key === "htmlFor" ? "for" : key;
1636
+ let strValue;
1637
+ if (value instanceof SafeHtml) {
1638
+ strValue = value.__html;
1639
+ } else if (typeof value === "number") {
1640
+ strValue = String(value);
1641
+ } else if (typeof value === "string") {
1642
+ strValue = escapeAttr(value);
1643
+ } else {
1644
+ strValue = "";
1645
+ }
1646
+ return ` ${name}="${strValue}"`;
1647
+ }
1648
+ function jsx(tag, props) {
1649
+ if (typeof tag === "function") return tag(props);
1650
+ const { children, ...attrs } = props;
1651
+ const attrStr = Object.entries(attrs).map(([k, v]) => renderAttr(k, v)).join("");
1652
+ if (VOID_TAGS.has(tag)) return new SafeHtml(`<${tag}${attrStr}>`);
1653
+ const childStr = children != null ? renderChildren(children) : "";
1654
+ return new SafeHtml(`<${tag}${attrStr}>${childStr}</${tag}>`);
1655
+ }
1656
+
1657
+ // src/components/layout.tsx
1658
+ function Layout({ title, children }) {
1659
+ return /* @__PURE__ */ jsx("html", { lang: "en", children: [
1660
+ /* @__PURE__ */ jsx("head", { children: [
1661
+ /* @__PURE__ */ jsx("meta", { charset: "utf-8" }),
1662
+ /* @__PURE__ */ jsx("meta", { name: "viewport", content: "width=device-width, initial-scale=1" }),
1663
+ /* @__PURE__ */ jsx("title", { children: title }),
1664
+ /* @__PURE__ */ jsx("link", { rel: "stylesheet", href: "/static/styles.css" })
1665
+ ] }),
1666
+ /* @__PURE__ */ jsx("body", { children: [
1667
+ children,
1668
+ /* @__PURE__ */ jsx("script", { src: "/static/app.js" })
1669
+ ] })
1670
+ ] });
1671
+ }
1672
+
1673
+ // src/routes/pages.tsx
1674
+ var pageRoutes = new Hono2();
1675
+ pageRoutes.get("/", (c) => {
1676
+ const html = /* @__PURE__ */ jsx(Layout, { title: "Hot Sheet", children: [
1677
+ /* @__PURE__ */ jsx("div", { className: "app", children: [
1678
+ /* @__PURE__ */ jsx("header", { className: "app-header", children: [
1679
+ /* @__PURE__ */ jsx("div", { className: "app-title", children: /* @__PURE__ */ jsx("h1", { children: "Hot Sheet" }) }),
1680
+ /* @__PURE__ */ jsx("div", { className: "header-controls", children: [
1681
+ /* @__PURE__ */ jsx("div", { className: "search-box", children: /* @__PURE__ */ jsx("input", { type: "text", id: "search-input", placeholder: "Search tickets..." }) }),
1682
+ /* @__PURE__ */ jsx("div", { className: "sort-controls", children: /* @__PURE__ */ jsx("select", { id: "sort-select", children: [
1683
+ /* @__PURE__ */ jsx("option", { value: "created:desc", children: "Newest First" }),
1684
+ /* @__PURE__ */ jsx("option", { value: "created:asc", children: "Oldest First" }),
1685
+ /* @__PURE__ */ jsx("option", { value: "priority:asc", children: "Priority" }),
1686
+ /* @__PURE__ */ jsx("option", { value: "category:asc", children: "Category" }),
1687
+ /* @__PURE__ */ jsx("option", { value: "status:asc", children: "Status" })
1688
+ ] }) }),
1689
+ /* @__PURE__ */ jsx("button", { className: "settings-btn", id: "settings-btn", title: "Settings", children: raw("&#9881;") })
1690
+ ] })
1691
+ ] }),
1692
+ /* @__PURE__ */ jsx("div", { className: "app-body", children: [
1693
+ /* @__PURE__ */ jsx("nav", { className: "sidebar", children: [
1694
+ /* @__PURE__ */ jsx("div", { className: "sidebar-copy-prompt", id: "copy-prompt-section", style: "display:none", children: /* @__PURE__ */ jsx("button", { className: "copy-prompt-btn", id: "copy-prompt-btn", title: "Copy worklist prompt to clipboard", children: [
1695
+ /* @__PURE__ */ jsx("span", { className: "copy-prompt-icon", id: "copy-prompt-icon", children: raw('<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>') }),
1696
+ /* @__PURE__ */ jsx("span", { id: "copy-prompt-label", children: "Copy AI prompt" })
1697
+ ] }) }),
1698
+ /* @__PURE__ */ jsx("div", { className: "sidebar-section", children: [
1699
+ /* @__PURE__ */ jsx("div", { className: "sidebar-label", children: "Views" }),
1700
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item active", "data-view": "all", children: "All Tickets" }),
1701
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "up-next", children: "Up Next" }),
1702
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "open", children: "Open" }),
1703
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "completed", children: "Completed" }),
1704
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "verified", children: "Verified" }),
1705
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "trash", children: "Trash" })
1706
+ ] }),
1707
+ /* @__PURE__ */ jsx("div", { className: "sidebar-section", children: [
1708
+ /* @__PURE__ */ jsx("div", { className: "sidebar-label", children: "Category" }),
1709
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "category:issue", children: [
1710
+ /* @__PURE__ */ jsx("span", { className: "cat-dot", style: "background:#6b7280" }),
1711
+ " Issue"
1712
+ ] }),
1713
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "category:bug", children: [
1714
+ /* @__PURE__ */ jsx("span", { className: "cat-dot", style: "background:#ef4444" }),
1715
+ " Bug"
1716
+ ] }),
1717
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "category:feature", children: [
1718
+ /* @__PURE__ */ jsx("span", { className: "cat-dot", style: "background:#22c55e" }),
1719
+ " Feature"
1720
+ ] }),
1721
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "category:requirement_change", children: [
1722
+ /* @__PURE__ */ jsx("span", { className: "cat-dot", style: "background:#f97316" }),
1723
+ " Req Change"
1724
+ ] }),
1725
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "category:task", children: [
1726
+ /* @__PURE__ */ jsx("span", { className: "cat-dot", style: "background:#3b82f6" }),
1727
+ " Task"
1728
+ ] }),
1729
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "category:investigation", children: [
1730
+ /* @__PURE__ */ jsx("span", { className: "cat-dot", style: "background:#8b5cf6" }),
1731
+ " Investigation"
1732
+ ] })
1733
+ ] }),
1734
+ /* @__PURE__ */ jsx("div", { className: "sidebar-section", children: [
1735
+ /* @__PURE__ */ jsx("div", { className: "sidebar-label", children: "Priority" }),
1736
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "priority:highest", children: "Highest" }),
1737
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "priority:high", children: "High" }),
1738
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "priority:default", children: "Default" }),
1739
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "priority:low", children: "Low" }),
1740
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "priority:lowest", children: "Lowest" })
1741
+ ] }),
1742
+ /* @__PURE__ */ jsx("div", { className: "sidebar-stats", id: "stats-bar" })
1743
+ ] }),
1744
+ /* @__PURE__ */ jsx("div", { className: "content-area detail-side", id: "content-area", children: [
1745
+ /* @__PURE__ */ jsx("main", { className: "main-content", children: [
1746
+ /* @__PURE__ */ jsx("div", { className: "batch-toolbar", id: "batch-toolbar", children: [
1747
+ /* @__PURE__ */ jsx("input", { type: "checkbox", id: "batch-select-all", className: "batch-select-all", title: "Select all / none" }),
1748
+ /* @__PURE__ */ jsx("select", { id: "batch-category", title: "Set category", disabled: true, children: [
1749
+ /* @__PURE__ */ jsx("option", { value: "", children: "Category..." }),
1750
+ /* @__PURE__ */ jsx("option", { value: "issue", children: "Issue" }),
1751
+ /* @__PURE__ */ jsx("option", { value: "bug", children: "Bug" }),
1752
+ /* @__PURE__ */ jsx("option", { value: "feature", children: "Feature" }),
1753
+ /* @__PURE__ */ jsx("option", { value: "requirement_change", children: "Req Change" }),
1754
+ /* @__PURE__ */ jsx("option", { value: "task", children: "Task" }),
1755
+ /* @__PURE__ */ jsx("option", { value: "investigation", children: "Investigation" })
1756
+ ] }),
1757
+ /* @__PURE__ */ jsx("select", { id: "batch-priority", title: "Set priority", disabled: true, children: [
1758
+ /* @__PURE__ */ jsx("option", { value: "", children: "Priority..." }),
1759
+ /* @__PURE__ */ jsx("option", { value: "highest", children: "Highest" }),
1760
+ /* @__PURE__ */ jsx("option", { value: "high", children: "High" }),
1761
+ /* @__PURE__ */ jsx("option", { value: "default", children: "Default" }),
1762
+ /* @__PURE__ */ jsx("option", { value: "low", children: "Low" }),
1763
+ /* @__PURE__ */ jsx("option", { value: "lowest", children: "Lowest" })
1764
+ ] }),
1765
+ /* @__PURE__ */ jsx("select", { id: "batch-status", title: "Set status", disabled: true, children: [
1766
+ /* @__PURE__ */ jsx("option", { value: "", children: "Status..." }),
1767
+ /* @__PURE__ */ jsx("option", { value: "not_started", children: "Not Started" }),
1768
+ /* @__PURE__ */ jsx("option", { value: "started", children: "Started" }),
1769
+ /* @__PURE__ */ jsx("option", { value: "completed", children: "Completed" }),
1770
+ /* @__PURE__ */ jsx("option", { value: "verified", children: "Verified" })
1771
+ ] }),
1772
+ /* @__PURE__ */ jsx("button", { id: "batch-upnext", className: "batch-star-btn", title: "Toggle Up Next", disabled: true, children: raw('<span class="batch-star-icon">&#9734;</span>') }),
1773
+ /* @__PURE__ */ jsx("button", { id: "batch-delete", className: "btn btn-sm btn-danger batch-delete-btn", title: "Delete selected", disabled: true, children: raw('<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>') }),
1774
+ /* @__PURE__ */ jsx("span", { className: "batch-count", id: "batch-count" })
1775
+ ] }),
1776
+ /* @__PURE__ */ jsx("div", { className: "ticket-list", id: "ticket-list", children: raw('<div class="ticket-list-loading">Loading...</div>') })
1777
+ ] }),
1778
+ /* @__PURE__ */ jsx("div", { className: "detail-resize-handle", id: "detail-resize-handle", style: "display:none" }),
1779
+ /* @__PURE__ */ jsx("aside", { className: "detail-panel", id: "detail-panel", style: "display:none", children: [
1780
+ /* @__PURE__ */ jsx("div", { className: "detail-header", children: [
1781
+ /* @__PURE__ */ jsx("span", { className: "detail-ticket-number", id: "detail-ticket-number" }),
1782
+ /* @__PURE__ */ jsx("button", { className: "detail-close", id: "detail-close", title: "Close", children: raw("&times;") })
1783
+ ] }),
1784
+ /* @__PURE__ */ jsx("div", { className: "detail-body", children: [
1785
+ /* @__PURE__ */ jsx("div", { className: "detail-fields-row", children: [
1786
+ /* @__PURE__ */ jsx("div", { className: "detail-field", children: [
1787
+ /* @__PURE__ */ jsx("label", { children: "Category" }),
1788
+ /* @__PURE__ */ jsx("select", { id: "detail-category", children: [
1789
+ /* @__PURE__ */ jsx("option", { value: "issue", children: "Issue" }),
1790
+ /* @__PURE__ */ jsx("option", { value: "bug", children: "Bug" }),
1791
+ /* @__PURE__ */ jsx("option", { value: "feature", children: "Feature" }),
1792
+ /* @__PURE__ */ jsx("option", { value: "requirement_change", children: "Req Change" }),
1793
+ /* @__PURE__ */ jsx("option", { value: "task", children: "Task" }),
1794
+ /* @__PURE__ */ jsx("option", { value: "investigation", children: "Investigation" })
1795
+ ] })
1796
+ ] }),
1797
+ /* @__PURE__ */ jsx("div", { className: "detail-field", children: [
1798
+ /* @__PURE__ */ jsx("label", { children: "Priority" }),
1799
+ /* @__PURE__ */ jsx("select", { id: "detail-priority", children: [
1800
+ /* @__PURE__ */ jsx("option", { value: "highest", children: "Highest" }),
1801
+ /* @__PURE__ */ jsx("option", { value: "high", children: "High" }),
1802
+ /* @__PURE__ */ jsx("option", { value: "default", children: "Default" }),
1803
+ /* @__PURE__ */ jsx("option", { value: "low", children: "Low" }),
1804
+ /* @__PURE__ */ jsx("option", { value: "lowest", children: "Lowest" })
1805
+ ] })
1806
+ ] }),
1807
+ /* @__PURE__ */ jsx("div", { className: "detail-field", children: [
1808
+ /* @__PURE__ */ jsx("label", { children: "Status" }),
1809
+ /* @__PURE__ */ jsx("select", { id: "detail-status", children: [
1810
+ /* @__PURE__ */ jsx("option", { value: "not_started", children: "Not Started" }),
1811
+ /* @__PURE__ */ jsx("option", { value: "started", children: "Started" }),
1812
+ /* @__PURE__ */ jsx("option", { value: "completed", children: "Completed" }),
1813
+ /* @__PURE__ */ jsx("option", { value: "verified", children: "Verified" })
1814
+ ] })
1815
+ ] }),
1816
+ /* @__PURE__ */ jsx("div", { className: "detail-field", children: /* @__PURE__ */ jsx("label", { className: "detail-upnext-label", children: [
1817
+ /* @__PURE__ */ jsx("input", { type: "checkbox", id: "detail-upnext" }),
1818
+ "Up Next"
1819
+ ] }) })
1820
+ ] }),
1821
+ /* @__PURE__ */ jsx("div", { className: "detail-field detail-field-full", children: [
1822
+ /* @__PURE__ */ jsx("label", { children: "Title" }),
1823
+ /* @__PURE__ */ jsx("input", { type: "text", id: "detail-title" })
1824
+ ] }),
1825
+ /* @__PURE__ */ jsx("div", { className: "detail-field detail-field-full", children: [
1826
+ /* @__PURE__ */ jsx("label", { children: "Details" }),
1827
+ /* @__PURE__ */ jsx("textarea", { id: "detail-details", rows: 6, placeholder: "Add details..." })
1828
+ ] }),
1829
+ /* @__PURE__ */ jsx("div", { className: "detail-field detail-field-full", children: [
1830
+ /* @__PURE__ */ jsx("label", { children: "Attachments" }),
1831
+ /* @__PURE__ */ jsx("div", { id: "detail-attachments", className: "detail-attachments" }),
1832
+ /* @__PURE__ */ jsx("label", { className: "btn btn-sm upload-btn", children: [
1833
+ "Attach File",
1834
+ /* @__PURE__ */ jsx("input", { type: "file", id: "detail-file-input", style: "display:none" })
1835
+ ] })
1836
+ ] }),
1837
+ /* @__PURE__ */ jsx("div", { className: "detail-field detail-field-full", id: "detail-notes-section", style: "display:none", children: [
1838
+ /* @__PURE__ */ jsx("label", { children: "Notes" }),
1839
+ /* @__PURE__ */ jsx("div", { id: "detail-notes", className: "detail-notes" })
1840
+ ] }),
1841
+ /* @__PURE__ */ jsx("div", { className: "detail-meta detail-field-full", id: "detail-meta" })
1842
+ ] })
1843
+ ] })
1844
+ ] })
1845
+ ] }),
1846
+ /* @__PURE__ */ jsx("footer", { className: "app-footer", children: [
1847
+ /* @__PURE__ */ jsx("div", { className: "keyboard-hints", children: [
1848
+ /* @__PURE__ */ jsx("span", { children: [
1849
+ /* @__PURE__ */ jsx("kbd", { children: "Enter" }),
1850
+ " new ticket"
1851
+ ] }),
1852
+ /* @__PURE__ */ jsx("span", { children: [
1853
+ /* @__PURE__ */ jsx("kbd", { children: [
1854
+ raw("&#8984;"),
1855
+ "I/B/F/R/K/G"
1856
+ ] }),
1857
+ " category"
1858
+ ] }),
1859
+ /* @__PURE__ */ jsx("span", { children: [
1860
+ /* @__PURE__ */ jsx("kbd", { children: "Alt+1-5" }),
1861
+ " priority"
1862
+ ] }),
1863
+ /* @__PURE__ */ jsx("span", { children: [
1864
+ /* @__PURE__ */ jsx("kbd", { children: [
1865
+ raw("&#8984;"),
1866
+ "D"
1867
+ ] }),
1868
+ " up next"
1869
+ ] }),
1870
+ /* @__PURE__ */ jsx("span", { children: [
1871
+ /* @__PURE__ */ jsx("kbd", { children: "Esc" }),
1872
+ " close"
1873
+ ] })
1874
+ ] }),
1875
+ /* @__PURE__ */ jsx("div", { id: "status-bar", className: "status-bar" })
1876
+ ] })
1877
+ ] }),
1878
+ /* @__PURE__ */ jsx("div", { className: "settings-overlay", id: "settings-overlay", style: "display:none", children: /* @__PURE__ */ jsx("div", { className: "settings-dialog", children: [
1879
+ /* @__PURE__ */ jsx("div", { className: "settings-header", children: [
1880
+ /* @__PURE__ */ jsx("h2", { children: "Settings" }),
1881
+ /* @__PURE__ */ jsx("button", { className: "detail-close", id: "settings-close", children: raw("&times;") })
1882
+ ] }),
1883
+ /* @__PURE__ */ jsx("div", { className: "settings-body", children: [
1884
+ /* @__PURE__ */ jsx("div", { className: "settings-field", children: [
1885
+ /* @__PURE__ */ jsx("label", { children: "Detail Panel Position" }),
1886
+ /* @__PURE__ */ jsx("select", { id: "settings-detail-position", children: [
1887
+ /* @__PURE__ */ jsx("option", { value: "side", children: "Side" }),
1888
+ /* @__PURE__ */ jsx("option", { value: "bottom", children: "Bottom" })
1889
+ ] })
1890
+ ] }),
1891
+ /* @__PURE__ */ jsx("div", { className: "settings-field", children: [
1892
+ /* @__PURE__ */ jsx("label", { children: "Auto-clear trash after (days)" }),
1893
+ /* @__PURE__ */ jsx("input", { type: "number", id: "settings-trash-days", min: "1", value: "3" })
1894
+ ] }),
1895
+ /* @__PURE__ */ jsx("div", { className: "settings-field", children: [
1896
+ /* @__PURE__ */ jsx("label", { children: "Auto-clear verified after (days)" }),
1897
+ /* @__PURE__ */ jsx("input", { type: "number", id: "settings-verified-days", min: "1", value: "30" })
1898
+ ] })
1899
+ ] })
1900
+ ] }) })
1901
+ ] });
1902
+ return c.html(html.toString());
1903
+ });
1904
+
1905
+ // src/server.ts
1906
+ function tryServe(fetch, port) {
1907
+ return new Promise((resolve2, reject) => {
1908
+ const server = serve({ fetch, port });
1909
+ server.on("listening", () => {
1910
+ resolve2(port);
1911
+ });
1912
+ server.on("error", (err) => {
1913
+ reject(err);
1914
+ });
1915
+ });
1916
+ }
1917
+ async function startServer(port, dataDir2) {
1918
+ const app = new Hono3();
1919
+ app.use("*", async (c, next) => {
1920
+ c.set("dataDir", dataDir2);
1921
+ await next();
1922
+ });
1923
+ const selfDir = dirname(fileURLToPath(import.meta.url));
1924
+ const distDir = existsSync3(join5(selfDir, "client", "styles.css")) ? join5(selfDir, "client") : join5(selfDir, "..", "dist", "client");
1925
+ app.get("/static/styles.css", (c) => {
1926
+ const css = readFileSync2(join5(distDir, "styles.css"), "utf-8");
1927
+ return c.text(css, 200, { "Content-Type": "text/css", "Cache-Control": "no-cache" });
1928
+ });
1929
+ app.get("/static/app.js", (c) => {
1930
+ const js = readFileSync2(join5(distDir, "app.global.js"), "utf-8");
1931
+ return c.text(js, 200, { "Content-Type": "application/javascript", "Cache-Control": "no-cache" });
1932
+ });
1933
+ app.route("/api", apiRoutes);
1934
+ app.route("/", pageRoutes);
1935
+ let actualPort = port;
1936
+ for (let attempt = 0; attempt < 20; attempt++) {
1937
+ try {
1938
+ actualPort = await tryServe(app.fetch, port + attempt);
1939
+ break;
1940
+ } catch (err) {
1941
+ if (err instanceof Error && err.code === "EADDRINUSE" && attempt < 19) {
1942
+ continue;
1943
+ }
1944
+ throw err;
1945
+ }
1946
+ }
1947
+ if (actualPort !== port) {
1948
+ console.log(` Port ${port} in use, using ${actualPort} instead.`);
1949
+ }
1950
+ const url = `http://localhost:${actualPort}`;
1951
+ console.log(`
1952
+ Hot Sheet running at ${url}
1953
+ `);
1954
+ const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
1955
+ exec(`${openCmd} ${url}`);
1956
+ }
1957
+
1958
+ // src/cli.ts
1959
+ function printUsage() {
1960
+ console.log(`
1961
+ hotsheet - Lightweight local project management
1962
+
1963
+ Usage:
1964
+ hotsheet [options]
1965
+
1966
+ Options:
1967
+ --port <number> Port to run on (default: 4174)
1968
+ --data-dir <path> Store data in an alternative location (default: .hotsheet/)
1969
+ --help Show this help message
1970
+
1971
+ Examples:
1972
+ hotsheet
1973
+ hotsheet --port 8080
1974
+ hotsheet --data-dir ~/my-project/.hotsheet
1975
+ `);
1976
+ }
1977
+ function parseArgs(argv) {
1978
+ const args = argv.slice(2);
1979
+ let port = 4174;
1980
+ let dataDir2 = join6(process.cwd(), ".hotsheet");
1981
+ let demo = null;
1982
+ for (let i = 0; i < args.length; i++) {
1983
+ const arg = args[i];
1984
+ if (arg.startsWith("--demo:")) {
1985
+ demo = parseInt(arg.slice(7), 10);
1986
+ if (isNaN(demo) || demo < 1) {
1987
+ console.error(`Invalid demo scenario: ${arg}`);
1988
+ process.exit(1);
1989
+ }
1990
+ continue;
1991
+ }
1992
+ switch (arg) {
1993
+ case "--help":
1994
+ case "-h":
1995
+ printUsage();
1996
+ process.exit(0);
1997
+ break;
1998
+ case "--port":
1999
+ port = parseInt(args[++i], 10);
2000
+ if (isNaN(port)) {
2001
+ console.error("Invalid port number");
2002
+ process.exit(1);
2003
+ }
2004
+ break;
2005
+ case "--data-dir":
2006
+ dataDir2 = resolve(args[++i]);
2007
+ break;
2008
+ default:
2009
+ console.error(`Unknown option: ${arg}`);
2010
+ printUsage();
2011
+ process.exit(1);
2012
+ }
2013
+ }
2014
+ return { port, dataDir: dataDir2, demo };
2015
+ }
2016
+ async function main() {
2017
+ const parsed = parseArgs(process.argv);
2018
+ if (!parsed) {
2019
+ printUsage();
2020
+ process.exit(1);
2021
+ }
2022
+ const { port, demo } = parsed;
2023
+ let { dataDir: dataDir2 } = parsed;
2024
+ if (demo !== null) {
2025
+ const scenario = DEMO_SCENARIOS.find((s) => s.id === demo);
2026
+ if (!scenario) {
2027
+ console.error(`Unknown demo scenario: ${demo}`);
2028
+ console.error("Available scenarios:");
2029
+ for (const s of DEMO_SCENARIOS) {
2030
+ console.error(` --demo:${s.id} ${s.label}`);
2031
+ }
2032
+ process.exit(1);
2033
+ }
2034
+ dataDir2 = join6(tmpdir(), `hotsheet-demo-${demo}-${Date.now()}`);
2035
+ console.log(`
2036
+ DEMO MODE: ${scenario.label}
2037
+ `);
2038
+ }
2039
+ mkdirSync3(dataDir2, { recursive: true });
2040
+ if (demo === null) {
2041
+ ensureGitignore(process.cwd());
2042
+ }
2043
+ setDataDir(dataDir2);
2044
+ await getDb();
2045
+ if (demo !== null) {
2046
+ await seedDemoData(demo);
2047
+ }
2048
+ initMarkdownSync(dataDir2);
2049
+ scheduleAllSync();
2050
+ if (demo === null) {
2051
+ await cleanupAttachments();
2052
+ }
2053
+ console.log(` Data directory: ${dataDir2}`);
2054
+ await startServer(port, dataDir2);
2055
+ }
2056
+ main().catch((err) => {
2057
+ console.error(err);
2058
+ process.exit(1);
2059
+ });
2060
+ //# sourceMappingURL=cli.js.map