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 +447 -15
- package/dist/client/app.global.js +99 -3
- package/dist/client/styles.css +1 -1
- package/package.json +1 -1
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))
|
|
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 '
|
|
651
|
-
WHEN '
|
|
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
|
-
|
|
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:
|
|
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:
|
|
2864
|
-
/* @__PURE__ */ jsx("
|
|
2865
|
-
"
|
|
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",
|
|
2885
|
-
/* @__PURE__ */ jsx("
|
|
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
|
}
|