hotsheet 0.3.0 → 0.4.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 CHANGED
@@ -104,9 +104,16 @@ async function initSchema(db2) {
104
104
  INSERT INTO settings (key, value) VALUES ('completed_cleanup_days', '30') ON CONFLICT DO NOTHING;
105
105
  INSERT INTO settings (key, value) VALUES ('verified_cleanup_days', '30') ON CONFLICT DO NOTHING;
106
106
  `);
107
+ await db2.exec(`
108
+ CREATE TABLE IF NOT EXISTS stats_snapshots (
109
+ date TEXT PRIMARY KEY,
110
+ data TEXT NOT NULL DEFAULT '{}'
111
+ );
112
+ `);
107
113
  await db2.exec(`
108
114
  ALTER TABLE tickets ADD COLUMN IF NOT EXISTS notes TEXT NOT NULL DEFAULT '';
109
115
  ALTER TABLE tickets ADD COLUMN IF NOT EXISTS verified_at TIMESTAMP;
116
+ ALTER TABLE tickets ADD COLUMN IF NOT EXISTS tags TEXT NOT NULL DEFAULT '[]';
110
117
  `).catch(() => {
111
118
  });
112
119
  }
@@ -218,6 +225,201 @@ var init_gitignore = __esm({
218
225
  }
219
226
  });
220
227
 
228
+ // src/db/stats.ts
229
+ var stats_exports = {};
230
+ __export(stats_exports, {
231
+ backfillSnapshots: () => backfillSnapshots,
232
+ getDashboardStats: () => getDashboardStats,
233
+ getSnapshots: () => getSnapshots,
234
+ recordDailySnapshot: () => recordDailySnapshot
235
+ });
236
+ async function recordDailySnapshot() {
237
+ const db2 = await getDb();
238
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
239
+ const existing = await db2.query(`SELECT date FROM stats_snapshots WHERE date = $1`, [today]);
240
+ if (existing.rows.length > 0) return;
241
+ const result = await db2.query(
242
+ `SELECT status, COUNT(*) as count FROM tickets WHERE status != 'deleted' GROUP BY status`
243
+ );
244
+ const data = { not_started: 0, started: 0, completed: 0, verified: 0, backlog: 0, archive: 0 };
245
+ for (const row of result.rows) {
246
+ if (row.status in data) {
247
+ data[row.status] = parseInt(row.count, 10);
248
+ }
249
+ }
250
+ await db2.query(
251
+ `INSERT INTO stats_snapshots (date, data) VALUES ($1, $2) ON CONFLICT (date) DO UPDATE SET data = $2`,
252
+ [today, JSON.stringify(data)]
253
+ );
254
+ }
255
+ async function backfillSnapshots() {
256
+ const db2 = await getDb();
257
+ const earliest = await db2.query(`SELECT MIN(DATE(created_at)) as min_date FROM tickets`);
258
+ if (!earliest.rows[0]?.min_date) return;
259
+ const startDate = new Date(earliest.rows[0].min_date);
260
+ const today = /* @__PURE__ */ new Date();
261
+ today.setHours(0, 0, 0, 0);
262
+ const existingRows = await db2.query(`SELECT date FROM stats_snapshots`);
263
+ const existingDates = new Set(existingRows.rows.map((r) => r.date));
264
+ const current = new Date(startDate);
265
+ while (current <= today) {
266
+ const dateStr = current.toISOString().slice(0, 10);
267
+ if (!existingDates.has(dateStr)) {
268
+ const dateEnd = dateStr + "T23:59:59.999Z";
269
+ const result = await db2.query(`
270
+ SELECT
271
+ CASE
272
+ WHEN verified_at IS NOT NULL AND verified_at <= $1 THEN 'verified'
273
+ WHEN completed_at IS NOT NULL AND completed_at <= $1 THEN 'completed'
274
+ WHEN deleted_at IS NOT NULL AND deleted_at <= $1 THEN 'deleted'
275
+ WHEN status = 'backlog' THEN 'backlog'
276
+ WHEN status = 'archive' THEN 'archive'
277
+ WHEN status = 'started' THEN 'started'
278
+ ELSE 'not_started'
279
+ END as status,
280
+ COUNT(*) as count
281
+ FROM tickets
282
+ WHERE created_at <= $1
283
+ GROUP BY 1
284
+ `, [dateEnd]);
285
+ const data = { not_started: 0, started: 0, completed: 0, verified: 0, backlog: 0, archive: 0 };
286
+ for (const row of result.rows) {
287
+ if (row.status in data) {
288
+ data[row.status] = parseInt(row.count, 10);
289
+ }
290
+ }
291
+ await db2.query(
292
+ `INSERT INTO stats_snapshots (date, data) VALUES ($1, $2) ON CONFLICT (date) DO NOTHING`,
293
+ [dateStr, JSON.stringify(data)]
294
+ );
295
+ }
296
+ current.setDate(current.getDate() + 1);
297
+ }
298
+ }
299
+ async function getSnapshots(days) {
300
+ const db2 = await getDb();
301
+ const since = /* @__PURE__ */ new Date();
302
+ since.setDate(since.getDate() - days);
303
+ const sinceStr = since.toISOString().slice(0, 10);
304
+ const result = await db2.query(
305
+ `SELECT date, data FROM stats_snapshots WHERE date >= $1 ORDER BY date ASC`,
306
+ [sinceStr]
307
+ );
308
+ return result.rows.map((r) => ({
309
+ date: r.date,
310
+ data: JSON.parse(r.data)
311
+ }));
312
+ }
313
+ async function getDashboardStats(days) {
314
+ const db2 = await getDb();
315
+ const since = /* @__PURE__ */ new Date();
316
+ since.setDate(since.getDate() - days);
317
+ const sinceStr = since.toISOString();
318
+ const completedByDay = await db2.query(
319
+ `SELECT DATE(completed_at) as date, COUNT(*) as count FROM tickets
320
+ WHERE completed_at >= $1 AND completed_at IS NOT NULL
321
+ GROUP BY DATE(completed_at) ORDER BY date ASC`,
322
+ [sinceStr]
323
+ );
324
+ const createdByDay = await db2.query(
325
+ `SELECT DATE(created_at) as date, COUNT(*) as count FROM tickets
326
+ WHERE created_at >= $1
327
+ GROUP BY DATE(created_at) ORDER BY date ASC`,
328
+ [sinceStr]
329
+ );
330
+ const dateMap = /* @__PURE__ */ new Map();
331
+ const current = new Date(since);
332
+ const today = /* @__PURE__ */ new Date();
333
+ while (current <= today) {
334
+ const d = current.toISOString().slice(0, 10);
335
+ dateMap.set(d, { completed: 0, created: 0 });
336
+ current.setDate(current.getDate() + 1);
337
+ }
338
+ for (const r of completedByDay.rows) {
339
+ const d = typeof r.date === "string" ? r.date.slice(0, 10) : new Date(r.date).toISOString().slice(0, 10);
340
+ const entry = dateMap.get(d);
341
+ if (entry) entry.completed = parseInt(r.count, 10);
342
+ }
343
+ for (const r of createdByDay.rows) {
344
+ const d = typeof r.date === "string" ? r.date.slice(0, 10) : new Date(r.date).toISOString().slice(0, 10);
345
+ const entry = dateMap.get(d);
346
+ if (entry) entry.created = parseInt(r.count, 10);
347
+ }
348
+ const throughput = Array.from(dateMap.entries()).map(([date, counts]) => ({ date, ...counts }));
349
+ const cycleTimeResult = await db2.query(
350
+ `SELECT ticket_number, title, completed_at, created_at FROM tickets
351
+ WHERE completed_at >= $1 AND completed_at IS NOT NULL AND status IN ('completed', 'verified')
352
+ ORDER BY completed_at ASC`,
353
+ [sinceStr]
354
+ );
355
+ const cycleTime = cycleTimeResult.rows.map((r) => ({
356
+ ticket_number: r.ticket_number,
357
+ title: r.title,
358
+ completed_at: r.completed_at,
359
+ days: Math.max(0, Math.round((new Date(r.completed_at).getTime() - new Date(r.created_at).getTime()) / 864e5))
360
+ }));
361
+ const catResult = await db2.query(
362
+ `SELECT category, COUNT(*) as count FROM tickets
363
+ WHERE status IN ('not_started', 'started')
364
+ GROUP BY category ORDER BY count DESC`
365
+ );
366
+ const categoryBreakdown = catResult.rows.map((r) => ({ category: r.category, count: parseInt(r.count, 10) }));
367
+ const catPeriodResult = await db2.query(
368
+ `SELECT category, COUNT(*) as count FROM tickets
369
+ WHERE status != 'deleted' AND (
370
+ created_at >= $1 OR
371
+ (completed_at IS NOT NULL AND completed_at >= $1) OR
372
+ (verified_at IS NOT NULL AND verified_at >= $1) OR
373
+ updated_at >= $1
374
+ )
375
+ GROUP BY category ORDER BY count DESC`,
376
+ [sinceStr]
377
+ );
378
+ const categoryPeriod = catPeriodResult.rows.map((r) => ({ category: r.category, count: parseInt(r.count, 10) }));
379
+ const now = /* @__PURE__ */ new Date();
380
+ const weekStart = new Date(now);
381
+ weekStart.setDate(weekStart.getDate() - weekStart.getDay());
382
+ weekStart.setHours(0, 0, 0, 0);
383
+ const lastWeekStart = new Date(weekStart);
384
+ lastWeekStart.setDate(lastWeekStart.getDate() - 7);
385
+ const completedThisWeekR = await db2.query(
386
+ `SELECT COUNT(*) as count FROM tickets WHERE completed_at >= $1`,
387
+ [weekStart.toISOString()]
388
+ );
389
+ const completedLastWeekR = await db2.query(
390
+ `SELECT COUNT(*) as count FROM tickets WHERE completed_at >= $1 AND completed_at < $2`,
391
+ [lastWeekStart.toISOString(), weekStart.toISOString()]
392
+ );
393
+ const wipR = await db2.query(
394
+ `SELECT COUNT(*) as count FROM tickets WHERE status = 'started'`
395
+ );
396
+ const createdThisWeekR = await db2.query(
397
+ `SELECT COUNT(*) as count FROM tickets WHERE created_at >= $1`,
398
+ [weekStart.toISOString()]
399
+ );
400
+ const cycleDays = cycleTime.map((c) => c.days).sort((a, b) => a - b);
401
+ const medianCycleTimeDays = cycleDays.length > 0 ? cycleDays[Math.floor(cycleDays.length / 2)] : null;
402
+ return {
403
+ throughput,
404
+ cycleTime,
405
+ categoryBreakdown,
406
+ categoryPeriod,
407
+ kpi: {
408
+ completedThisWeek: parseInt(completedThisWeekR.rows[0].count, 10),
409
+ completedLastWeek: parseInt(completedLastWeekR.rows[0].count, 10),
410
+ wipCount: parseInt(wipR.rows[0].count, 10),
411
+ createdThisWeek: parseInt(createdThisWeekR.rows[0].count, 10),
412
+ medianCycleTimeDays
413
+ }
414
+ };
415
+ }
416
+ var init_stats = __esm({
417
+ "src/db/stats.ts"() {
418
+ "use strict";
419
+ init_connection();
420
+ }
421
+ });
422
+
221
423
  // src/cli.ts
222
424
  import { mkdirSync as mkdirSync6 } from "fs";
223
425
  import { tmpdir } from "os";
@@ -491,14 +693,46 @@ var CATEGORY_DESCRIPTIONS = Object.fromEntries(
491
693
 
492
694
  // src/db/queries.ts
493
695
  init_connection();
696
+ var noteCounter = 0;
697
+ function generateNoteId() {
698
+ return `n_${Date.now().toString(36)}_${(noteCounter++).toString(36)}`;
699
+ }
494
700
  function parseNotes(raw) {
495
701
  if (!raw || raw === "") return [];
496
702
  try {
497
703
  const parsed = JSON.parse(raw);
498
- if (Array.isArray(parsed)) return parsed;
704
+ if (Array.isArray(parsed)) {
705
+ return parsed.map((n) => ({
706
+ id: n.id || generateNoteId(),
707
+ text: n.text,
708
+ created_at: n.created_at
709
+ }));
710
+ }
499
711
  } catch {
500
712
  }
501
- return [{ text: raw, created_at: (/* @__PURE__ */ new Date()).toISOString() }];
713
+ return [{ id: generateNoteId(), text: raw, created_at: (/* @__PURE__ */ new Date()).toISOString() }];
714
+ }
715
+ async function editNote(ticketId, noteId, text) {
716
+ const db2 = await getDb();
717
+ const result = await db2.query(`SELECT notes FROM tickets WHERE id = $1`, [ticketId]);
718
+ if (result.rows.length === 0) return null;
719
+ const notes = parseNotes(result.rows[0].notes);
720
+ const note = notes.find((n) => n.id === noteId);
721
+ if (!note) return null;
722
+ note.text = text;
723
+ await db2.query(`UPDATE tickets SET notes = $1, updated_at = NOW() WHERE id = $2`, [JSON.stringify(notes), ticketId]);
724
+ return notes;
725
+ }
726
+ async function deleteNote(ticketId, noteId) {
727
+ const db2 = await getDb();
728
+ const result = await db2.query(`SELECT notes FROM tickets WHERE id = $1`, [ticketId]);
729
+ if (result.rows.length === 0) return null;
730
+ const notes = parseNotes(result.rows[0].notes);
731
+ const idx = notes.findIndex((n) => n.id === noteId);
732
+ if (idx === -1) return null;
733
+ notes.splice(idx, 1);
734
+ await db2.query(`UPDATE tickets SET notes = $1, updated_at = NOW() WHERE id = $2`, [JSON.stringify(notes), ticketId]);
735
+ return notes;
502
736
  }
503
737
  async function nextTicketNumber() {
504
738
  const db2 = await getDb();
@@ -557,7 +791,7 @@ async function updateTicket(id, updates) {
557
791
  if (updates.notes !== void 0 && updates.notes !== "") {
558
792
  const current = await db2.query(`SELECT notes FROM tickets WHERE id = $1`, [id]);
559
793
  const existing = parseNotes(current.rows[0]?.notes || "");
560
- existing.push({ text: updates.notes, created_at: (/* @__PURE__ */ new Date()).toISOString() });
794
+ existing.push({ id: generateNoteId(), text: updates.notes, created_at: (/* @__PURE__ */ new Date()).toISOString() });
561
795
  sets.push(`notes = $${paramIdx}`);
562
796
  values.push(JSON.stringify(existing));
563
797
  paramIdx++;
@@ -574,8 +808,6 @@ async function updateTicket(id, updates) {
574
808
  sets.push("deleted_at = NOW()");
575
809
  } else if (updates.status === "backlog" || updates.status === "archive") {
576
810
  sets.push("up_next = FALSE");
577
- sets.push("completed_at = NULL");
578
- sets.push("verified_at = NULL");
579
811
  sets.push("deleted_at = NULL");
580
812
  } else if (updates.status === "not_started" || updates.status === "started") {
581
813
  sets.push("completed_at = NULL");
@@ -647,8 +879,8 @@ async function getTickets(filters = {}) {
647
879
  break;
648
880
  case "status":
649
881
  orderBy = `CASE status
650
- WHEN 'started' THEN 1 WHEN 'not_started' THEN 2 WHEN 'completed' THEN 3
651
- WHEN 'verified' THEN 4 WHEN 'backlog' THEN 5 WHEN 'archive' THEN 6 END`;
882
+ WHEN 'backlog' THEN 1 WHEN 'not_started' THEN 2 WHEN 'started' THEN 3
883
+ WHEN 'completed' THEN 4 WHEN 'verified' THEN 5 WHEN 'archive' THEN 6 END`;
652
884
  break;
653
885
  case "ticket_number":
654
886
  orderBy = "id";
@@ -750,6 +982,113 @@ async function getTicketsForCleanup(verifiedDays = 30, trashDays = 3) {
750
982
  `, [verifiedDays, trashDays]);
751
983
  return result.rows;
752
984
  }
985
+ var QUERYABLE_FIELDS = /* @__PURE__ */ new Set(["category", "priority", "status", "title", "details", "up_next", "tags"]);
986
+ var PRIORITY_ORD = `CASE priority WHEN 'highest' THEN 1 WHEN 'high' THEN 2 WHEN 'default' THEN 3 WHEN 'low' THEN 4 WHEN 'lowest' THEN 5 ELSE 3 END`;
987
+ var STATUS_ORD = `CASE status WHEN 'backlog' THEN 1 WHEN 'not_started' THEN 2 WHEN 'started' THEN 3 WHEN 'completed' THEN 4 WHEN 'verified' THEN 5 WHEN 'archive' THEN 6 ELSE 2 END`;
988
+ var PRIORITY_RANK = { highest: 1, high: 2, default: 3, low: 4, lowest: 5 };
989
+ var STATUS_RANK = { backlog: 1, not_started: 2, started: 3, completed: 4, verified: 5, archive: 6 };
990
+ function ordinalExpr(field) {
991
+ if (field === "priority") return PRIORITY_ORD;
992
+ if (field === "status") return STATUS_ORD;
993
+ return null;
994
+ }
995
+ function ordinalValue(field, value) {
996
+ if (field === "priority") return PRIORITY_RANK[value] ?? null;
997
+ if (field === "status") return STATUS_RANK[value] ?? null;
998
+ return null;
999
+ }
1000
+ async function queryTickets(logic, conditions, sortBy, sortDir) {
1001
+ const db2 = await getDb();
1002
+ const where = [];
1003
+ const values = [];
1004
+ let paramIdx = 1;
1005
+ where.push(`status != 'deleted'`);
1006
+ for (const cond of conditions) {
1007
+ if (!QUERYABLE_FIELDS.has(cond.field)) continue;
1008
+ const field = cond.field;
1009
+ if (field === "up_next") {
1010
+ where.push(`up_next = $${paramIdx}`);
1011
+ values.push(cond.value === "true");
1012
+ paramIdx++;
1013
+ continue;
1014
+ }
1015
+ const ordExpr = ordinalExpr(field);
1016
+ const ordVal = ordExpr ? ordinalValue(field, cond.value) : null;
1017
+ if (ordExpr && ordVal !== null && ["lt", "lte", "gt", "gte"].includes(cond.operator)) {
1018
+ const op = cond.operator === "lt" ? "<" : cond.operator === "lte" ? "<=" : cond.operator === "gt" ? ">" : ">=";
1019
+ where.push(`(${ordExpr}) ${op} $${paramIdx}`);
1020
+ values.push(ordVal);
1021
+ paramIdx++;
1022
+ continue;
1023
+ }
1024
+ switch (cond.operator) {
1025
+ case "equals":
1026
+ where.push(`${field} = $${paramIdx}`);
1027
+ values.push(cond.value);
1028
+ paramIdx++;
1029
+ break;
1030
+ case "not_equals":
1031
+ where.push(`${field} != $${paramIdx}`);
1032
+ values.push(cond.value);
1033
+ paramIdx++;
1034
+ break;
1035
+ case "contains":
1036
+ where.push(`${field} ILIKE $${paramIdx}`);
1037
+ values.push(`%${cond.value}%`);
1038
+ paramIdx++;
1039
+ break;
1040
+ case "not_contains":
1041
+ where.push(`${field} NOT ILIKE $${paramIdx}`);
1042
+ values.push(`%${cond.value}%`);
1043
+ paramIdx++;
1044
+ break;
1045
+ }
1046
+ }
1047
+ const joiner = logic === "any" ? " OR " : " AND ";
1048
+ const userConditions = where.slice(1);
1049
+ let whereClause = where[0];
1050
+ if (userConditions.length > 0) {
1051
+ whereClause += ` AND (${userConditions.join(joiner)})`;
1052
+ }
1053
+ let orderBy;
1054
+ switch (sortBy) {
1055
+ case "priority":
1056
+ orderBy = `CASE priority WHEN 'highest' THEN 1 WHEN 'high' THEN 2 WHEN 'default' THEN 3 WHEN 'low' THEN 4 WHEN 'lowest' THEN 5 END`;
1057
+ break;
1058
+ case "category":
1059
+ orderBy = "category";
1060
+ break;
1061
+ case "status":
1062
+ orderBy = `CASE status WHEN 'backlog' THEN 1 WHEN 'not_started' THEN 2 WHEN 'started' THEN 3 WHEN 'completed' THEN 4 WHEN 'verified' THEN 5 WHEN 'archive' THEN 6 END`;
1063
+ break;
1064
+ default:
1065
+ orderBy = "created_at";
1066
+ break;
1067
+ }
1068
+ const dir = sortDir === "asc" ? "ASC" : "DESC";
1069
+ const result = await db2.query(
1070
+ `SELECT * FROM tickets WHERE ${whereClause} ORDER BY ${orderBy} ${dir}, id DESC`,
1071
+ values
1072
+ );
1073
+ return result.rows;
1074
+ }
1075
+ async function getAllTags() {
1076
+ const db2 = await getDb();
1077
+ const result = await db2.query(`SELECT DISTINCT tags FROM tickets WHERE tags != '[]' AND status != 'deleted'`);
1078
+ const tagSet = /* @__PURE__ */ new Set();
1079
+ for (const row of result.rows) {
1080
+ try {
1081
+ const parsed = JSON.parse(row.tags);
1082
+ if (Array.isArray(parsed)) {
1083
+ for (const tag of parsed) {
1084
+ if (typeof tag === "string" && tag.trim()) tagSet.add(tag.trim());
1085
+ }
1086
+ }
1087
+ } catch {
1088
+ }
1089
+ }
1090
+ return Array.from(tagSet).sort();
1091
+ }
753
1092
  async function getCategories() {
754
1093
  const settings = await getSettings();
755
1094
  if (settings.categories) {
@@ -1986,6 +2325,13 @@ async function formatTicket(ticket) {
1986
2325
  lines.push(`- Priority: ${ticket.priority}`);
1987
2326
  lines.push(`- Status: ${ticket.status.replace("_", " ")}`);
1988
2327
  lines.push(`- Title: ${ticket.title}`);
2328
+ try {
2329
+ const tags = JSON.parse(ticket.tags);
2330
+ if (Array.isArray(tags) && tags.length > 0) {
2331
+ lines.push(`- Tags: ${tags.join(", ")}`);
2332
+ }
2333
+ } catch {
2334
+ }
1989
2335
  if (ticket.details.trim()) {
1990
2336
  const detailLines = ticket.details.split("\n");
1991
2337
  lines.push(`- Details: ${detailLines[0]}`);
@@ -2183,7 +2529,8 @@ apiRoutes.get("/tickets/:id", async (c) => {
2183
2529
  const ticket = await getTicket(id);
2184
2530
  if (!ticket) return c.json({ error: "Not found" }, 404);
2185
2531
  const attachments = await getAttachments(id);
2186
- return c.json({ ...ticket, attachments });
2532
+ const notes = parseNotes(ticket.notes);
2533
+ return c.json({ ...ticket, notes: JSON.stringify(notes), attachments });
2187
2534
  });
2188
2535
  apiRoutes.patch("/tickets/:id", async (c) => {
2189
2536
  const id = parseInt(c.req.param("id"), 10);
@@ -2201,6 +2548,25 @@ apiRoutes.delete("/tickets/:id", async (c) => {
2201
2548
  notifyChange();
2202
2549
  return c.json({ ok: true });
2203
2550
  });
2551
+ apiRoutes.patch("/tickets/:id/notes/:noteId", async (c) => {
2552
+ const id = parseInt(c.req.param("id"), 10);
2553
+ const noteId = c.req.param("noteId");
2554
+ const body = await c.req.json();
2555
+ const notes = await editNote(id, noteId, body.text);
2556
+ if (!notes) return c.json({ error: "Not found" }, 404);
2557
+ scheduleAllSync();
2558
+ notifyChange();
2559
+ return c.json(notes);
2560
+ });
2561
+ apiRoutes.delete("/tickets/:id/notes/:noteId", async (c) => {
2562
+ const id = parseInt(c.req.param("id"), 10);
2563
+ const noteId = c.req.param("noteId");
2564
+ const notes = await deleteNote(id, noteId);
2565
+ if (!notes) return c.json({ error: "Not found" }, 404);
2566
+ scheduleAllSync();
2567
+ notifyChange();
2568
+ return c.json(notes);
2569
+ });
2204
2570
  apiRoutes.delete("/tickets/:id/hard", async (c) => {
2205
2571
  const id = parseInt(c.req.param("id"), 10);
2206
2572
  const attachments = await getAttachments(id);
@@ -2360,6 +2726,15 @@ apiRoutes.get("/attachments/file/*", async (c) => {
2360
2726
  headers: { "Content-Type": contentType }
2361
2727
  });
2362
2728
  });
2729
+ apiRoutes.post("/tickets/query", async (c) => {
2730
+ const body = await c.req.json();
2731
+ const tickets = await queryTickets(body.logic, body.conditions, body.sort_by, body.sort_dir);
2732
+ return c.json(tickets);
2733
+ });
2734
+ apiRoutes.get("/tags", async (c) => {
2735
+ const tags = await getAllTags();
2736
+ return c.json(tags);
2737
+ });
2363
2738
  apiRoutes.get("/categories", async (c) => {
2364
2739
  const categories = await getCategories();
2365
2740
  return c.json(categories);
@@ -2378,6 +2753,15 @@ apiRoutes.get("/stats", async (c) => {
2378
2753
  const stats = await getTicketStats();
2379
2754
  return c.json(stats);
2380
2755
  });
2756
+ apiRoutes.get("/dashboard", async (c) => {
2757
+ const { getDashboardStats: getDashboardStats2, getSnapshots: getSnapshots2 } = await Promise.resolve().then(() => (init_stats(), stats_exports));
2758
+ const days = parseInt(c.req.query("days") || "30", 10);
2759
+ const [stats, snapshots] = await Promise.all([
2760
+ getDashboardStats2(days),
2761
+ getSnapshots2(days)
2762
+ ]);
2763
+ return c.json({ ...stats, snapshots });
2764
+ });
2381
2765
  apiRoutes.get("/settings", async (c) => {
2382
2766
  const settings = await getSettings();
2383
2767
  return c.json(settings);
@@ -2444,6 +2828,24 @@ apiRoutes.post("/gitignore/add", async (c) => {
2444
2828
  ensureGitignore2(process.cwd());
2445
2829
  return c.json({ ok: true });
2446
2830
  });
2831
+ apiRoutes.post("/print", async (c) => {
2832
+ const { html } = await c.req.json();
2833
+ const { writeFileSync: writeFileSync7 } = await import("fs");
2834
+ const { tmpdir: tmpdir2 } = await import("os");
2835
+ const { join: pathJoin } = await import("path");
2836
+ const { execFile } = await import("child_process");
2837
+ const tmpPath = pathJoin(tmpdir2(), `hotsheet-print-${Date.now()}.html`);
2838
+ writeFileSync7(tmpPath, html, "utf-8");
2839
+ const platform = process.platform;
2840
+ if (platform === "darwin") {
2841
+ execFile("open", [tmpPath]);
2842
+ } else if (platform === "win32") {
2843
+ execFile("start", ["", tmpPath], { shell: true });
2844
+ } else {
2845
+ execFile("xdg-open", [tmpPath]);
2846
+ }
2847
+ return c.json({ ok: true, path: tmpPath });
2848
+ });
2447
2849
 
2448
2850
  // src/routes/backups.ts
2449
2851
  import { Hono as Hono2 } from "hono";
@@ -2732,6 +3134,11 @@ pageRoutes.get("/", (c) => {
2732
3134
  ] }) })
2733
3135
  ] }),
2734
3136
  /* @__PURE__ */ jsx("button", { className: "glassbox-btn", id: "glassbox-btn", title: "Open Glassbox", style: "display:none", children: /* @__PURE__ */ jsx("img", { id: "glassbox-icon", alt: "Glassbox" }) }),
3137
+ /* @__PURE__ */ jsx("button", { className: "settings-btn print-btn", id: "print-btn", title: "Print", children: /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
3138
+ /* @__PURE__ */ jsx("path", { d: "M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" }),
3139
+ /* @__PURE__ */ jsx("path", { d: "M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6" }),
3140
+ /* @__PURE__ */ jsx("rect", { x: "6", y: "14", width: "12", height: "8", rx: "1" })
3141
+ ] }) }),
2735
3142
  /* @__PURE__ */ jsx("button", { className: "settings-btn", id: "settings-btn", title: "Settings", children: /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
2736
3143
  /* @__PURE__ */ jsx("path", { d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" }),
2737
3144
  /* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "3" })
@@ -2766,13 +3173,21 @@ pageRoutes.get("/", (c) => {
2766
3173
  /* @__PURE__ */ jsx("span", { id: "copy-prompt-label", children: "Copy AI prompt" })
2767
3174
  ] }) }),
2768
3175
  /* @__PURE__ */ jsx("div", { className: "sidebar-section", children: [
2769
- /* @__PURE__ */ jsx("div", { className: "sidebar-label", children: "Views" }),
3176
+ /* @__PURE__ */ jsx("div", { className: "sidebar-label", children: [
3177
+ "Views ",
3178
+ /* @__PURE__ */ jsx("button", { className: "sidebar-add-view-btn", id: "add-custom-view-btn", title: "New custom view", children: /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", width: "13", height: "13", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
3179
+ /* @__PURE__ */ jsx("rect", { width: "18", height: "18", x: "3", y: "3", rx: "2" }),
3180
+ /* @__PURE__ */ jsx("path", { d: "M8 12h8" }),
3181
+ /* @__PURE__ */ jsx("path", { d: "M12 8v8" })
3182
+ ] }) })
3183
+ ] }),
2770
3184
  /* @__PURE__ */ jsx("button", { className: "sidebar-item active", "data-view": "all", children: "All Tickets" }),
2771
3185
  /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "non-verified", children: "Non-Verified" }),
2772
3186
  /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "up-next", children: "Up Next" }),
2773
3187
  /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "open", children: "Open" }),
2774
3188
  /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "completed", children: "Completed" }),
2775
3189
  /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "verified", children: "Verified" }),
3190
+ /* @__PURE__ */ jsx("div", { id: "custom-views-container" }),
2776
3191
  /* @__PURE__ */ jsx("div", { className: "sidebar-divider" }),
2777
3192
  /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "backlog", children: "Backlog" }),
2778
3193
  /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "archive", children: "Archive" }),
@@ -2860,10 +3275,10 @@ pageRoutes.get("/", (c) => {
2860
3275
  /* @__PURE__ */ jsx("label", { children: "Status" }),
2861
3276
  /* @__PURE__ */ jsx("button", { id: "detail-status", className: "detail-dropdown-btn", "data-value": "not_started", children: "Not Started" })
2862
3277
  ] }),
2863
- /* @__PURE__ */ jsx("div", { className: "detail-field", children: /* @__PURE__ */ jsx("label", { className: "detail-upnext-label", children: [
2864
- /* @__PURE__ */ jsx("button", { className: "ticket-star detail-upnext-star", id: "detail-upnext", type: "button", children: "\u2606" }),
2865
- "Up Next"
2866
- ] }) })
3278
+ /* @__PURE__ */ jsx("div", { className: "detail-field", children: [
3279
+ /* @__PURE__ */ jsx("label", { children: "Up Next" }),
3280
+ /* @__PURE__ */ jsx("button", { className: "ticket-star detail-upnext-star", id: "detail-upnext", type: "button", children: "\u2606" })
3281
+ ] })
2867
3282
  ] }),
2868
3283
  /* @__PURE__ */ jsx("div", { className: "detail-field detail-field-full", children: [
2869
3284
  /* @__PURE__ */ jsx("label", { children: "Title" }),
@@ -2873,6 +3288,11 @@ pageRoutes.get("/", (c) => {
2873
3288
  /* @__PURE__ */ jsx("label", { children: "Details" }),
2874
3289
  /* @__PURE__ */ jsx("textarea", { id: "detail-details", rows: 6, placeholder: "Add details..." })
2875
3290
  ] }),
3291
+ /* @__PURE__ */ jsx("div", { className: "detail-field detail-field-full", children: [
3292
+ /* @__PURE__ */ jsx("label", { children: "Tags" }),
3293
+ /* @__PURE__ */ jsx("div", { id: "detail-tags", className: "detail-tags" }),
3294
+ /* @__PURE__ */ jsx("input", { type: "text", id: "detail-tag-input", className: "detail-tag-input", placeholder: "Add tag..." })
3295
+ ] }),
2876
3296
  /* @__PURE__ */ jsx("div", { className: "detail-field detail-field-full", children: [
2877
3297
  /* @__PURE__ */ jsx("label", { children: "Attachments" }),
2878
3298
  /* @__PURE__ */ jsx("div", { id: "detail-attachments", className: "detail-attachments" }),
@@ -2881,8 +3301,15 @@ pageRoutes.get("/", (c) => {
2881
3301
  /* @__PURE__ */ jsx("input", { type: "file", id: "detail-file-input", style: "display:none" })
2882
3302
  ] })
2883
3303
  ] }),
2884
- /* @__PURE__ */ jsx("div", { className: "detail-field detail-field-full", id: "detail-notes-section", style: "display:none", children: [
2885
- /* @__PURE__ */ jsx("label", { children: "Notes" }),
3304
+ /* @__PURE__ */ jsx("div", { className: "detail-field detail-field-full", id: "detail-notes-section", children: [
3305
+ /* @__PURE__ */ jsx("div", { className: "detail-notes-label", children: [
3306
+ /* @__PURE__ */ jsx("span", { children: "Notes" }),
3307
+ " ",
3308
+ /* @__PURE__ */ jsx("button", { className: "sidebar-add-view-btn", id: "detail-add-note-btn", title: "Add note", children: /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", width: "12", height: "12", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round", children: [
3309
+ /* @__PURE__ */ jsx("path", { d: "M5 12h14" }),
3310
+ /* @__PURE__ */ jsx("path", { d: "M12 5v14" })
3311
+ ] }) })
3312
+ ] }),
2886
3313
  /* @__PURE__ */ jsx("div", { id: "detail-notes", className: "detail-notes" })
2887
3314
  ] }),
2888
3315
  /* @__PURE__ */ jsx("div", { className: "detail-meta detail-field-full", id: "detail-meta" })
@@ -3322,6 +3749,11 @@ async function main() {
3322
3749
  AI tool skills created/updated for: ${updatedPlatforms.join(", ")}`);
3323
3750
  console.log(" Restart your AI tool to pick up the new ticket creation skills.\n");
3324
3751
  }
3752
+ Promise.resolve().then(() => (init_stats(), stats_exports)).then(async ({ recordDailySnapshot: recordDailySnapshot2, backfillSnapshots: backfillSnapshots2 }) => {
3753
+ await backfillSnapshots2();
3754
+ await recordDailySnapshot2();
3755
+ }).catch(() => {
3756
+ });
3325
3757
  if (demo === null) {
3326
3758
  initBackupScheduler(dataDir2);
3327
3759
  }