jira-dashboard 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,1347 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.tsx
4
+ import { render } from "ink";
5
+ import { createRequire } from "node:module";
6
+
7
+ // src/app.tsx
8
+ import { Box as Box7, Text as Text7, useApp, useInput } from "ink";
9
+ import { useCallback, useEffect as useEffect4, useMemo, useRef as useRef2, useState as useState2 } from "react";
10
+
11
+ // src/components/IssueList.tsx
12
+ import { Box as Box3, Text as Text3 } from "ink";
13
+
14
+ // src/components/PanelFrame.tsx
15
+ import { Box, Text } from "ink";
16
+ import { jsx, jsxs } from "react/jsx-runtime";
17
+ var PANEL_HEADER_ROWS = 2;
18
+ var PANEL_CHROME_ROWS = 3;
19
+ function PanelFrame({
20
+ title,
21
+ focused,
22
+ height,
23
+ width,
24
+ flexGrow,
25
+ children
26
+ }) {
27
+ return /* @__PURE__ */ jsxs(
28
+ Box,
29
+ {
30
+ flexDirection: "column",
31
+ height,
32
+ width,
33
+ flexGrow,
34
+ flexShrink: width === void 0 ? 1 : 0,
35
+ borderStyle: "round",
36
+ borderColor: focused ? "cyan" : "gray",
37
+ paddingX: 1,
38
+ overflow: "hidden",
39
+ children: [
40
+ /* @__PURE__ */ jsx(Text, { bold: true, color: focused ? "cyan" : "white", children: title }),
41
+ children
42
+ ]
43
+ }
44
+ );
45
+ }
46
+
47
+ // src/components/PanelStatus.tsx
48
+ import { Box as Box2, Text as Text2 } from "ink";
49
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
50
+ var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
51
+ function spinnerChar(frame) {
52
+ return FRAMES[frame % FRAMES.length] ?? FRAMES[0] ?? "\xB7";
53
+ }
54
+ function Spinner({ label, frame }) {
55
+ return /* @__PURE__ */ jsxs2(Text2, { color: "cyan", children: [
56
+ spinnerChar(frame),
57
+ " ",
58
+ /* @__PURE__ */ jsx2(Text2, { color: "gray", children: label })
59
+ ] });
60
+ }
61
+ function PanelError({ error }) {
62
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
63
+ /* @__PURE__ */ jsxs2(Text2, { color: "red", wrap: "truncate-end", children: [
64
+ "\u2716 ",
65
+ error.message
66
+ ] }),
67
+ error.hints.slice(0, 3).map((hint, index) => /* @__PURE__ */ jsx2(Text2, { color: "gray", wrap: "truncate-end", children: hint }, index)),
68
+ /* @__PURE__ */ jsx2(Text2, { color: "gray", children: "Press r to retry." })
69
+ ] });
70
+ }
71
+
72
+ // src/components/IssueList.tsx
73
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
74
+ function statusColor(category) {
75
+ switch (category.toLowerCase()) {
76
+ case "in progress":
77
+ return "yellow";
78
+ case "done":
79
+ return "green";
80
+ default:
81
+ return "blue";
82
+ }
83
+ }
84
+ var KEY_WIDTH = 10;
85
+ var STATUS_WIDTH = 14;
86
+ function IssueList({
87
+ state,
88
+ focused,
89
+ height,
90
+ width,
91
+ selectedIndex,
92
+ offset,
93
+ spinnerFrame
94
+ }) {
95
+ const issues = state.data ?? [];
96
+ const visibleRows = Math.max(1, height - PANEL_CHROME_ROWS);
97
+ const summaryWidth = Math.max(8, width - 4 - 2 - KEY_WIDTH - STATUS_WIDTH);
98
+ const base = issues.length > visibleRows ? `my open tickets (${selectedIndex + 1}/${issues.length})` : `my open tickets (${issues.length})`;
99
+ const title = state.loading && issues.length > 0 ? `${spinnerChar(spinnerFrame)} ${base}` : base;
100
+ return /* @__PURE__ */ jsx3(PanelFrame, { title, focused, height, flexGrow: 1, children: state.loading && issues.length === 0 ? /* @__PURE__ */ jsx3(Spinner, { label: "loading tickets\u2026", frame: spinnerFrame }) : state.error ? /* @__PURE__ */ jsx3(PanelError, { error: state.error }) : issues.length === 0 ? /* @__PURE__ */ jsx3(Text3, { color: "gray", children: "No open tickets matched the query." }) : /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", children: issues.slice(offset, offset + visibleRows).map((issue, index) => {
101
+ const selected = offset + index === selectedIndex;
102
+ return /* @__PURE__ */ jsxs3(Box3, { children: [
103
+ /* @__PURE__ */ jsx3(Box3, { width: 2, flexShrink: 0, children: /* @__PURE__ */ jsx3(Text3, { color: "cyan", children: selected ? "\u25B8 " : " " }) }),
104
+ /* @__PURE__ */ jsx3(Box3, { width: KEY_WIDTH, flexShrink: 0, children: /* @__PURE__ */ jsx3(Text3, { bold: selected, color: selected ? "cyan" : "white", wrap: "truncate-end", children: issue.key }) }),
105
+ /* @__PURE__ */ jsx3(Box3, { width: STATUS_WIDTH, flexShrink: 0, children: /* @__PURE__ */ jsx3(Text3, { color: statusColor(issue.statusCategory), wrap: "truncate-end", children: issue.status }) }),
106
+ /* @__PURE__ */ jsx3(Box3, { width: summaryWidth, flexShrink: 1, children: /* @__PURE__ */ jsx3(
107
+ Text3,
108
+ {
109
+ color: selected ? "white" : "gray",
110
+ bold: selected,
111
+ inverse: selected && focused,
112
+ wrap: "truncate-end",
113
+ children: issue.summary
114
+ }
115
+ ) })
116
+ ] }, issue.key);
117
+ }) }) });
118
+ }
119
+
120
+ // src/components/IssuePreview.tsx
121
+ import { Box as Box4, Text as Text4 } from "ink";
122
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
123
+ var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
124
+ var FIELD_LABEL_WIDTH = 11;
125
+ function formatUpdated(value) {
126
+ if (!value) return "unknown";
127
+ const date = new Date(value);
128
+ if (Number.isNaN(date.getTime())) return value;
129
+ return date.toLocaleString();
130
+ }
131
+ function formatDay(key) {
132
+ const [, month = "01", day = "01"] = key.split("-");
133
+ return `${day} ${MONTHS[Number(month) - 1] ?? month}`;
134
+ }
135
+ function formatHours(hours) {
136
+ if (hours === 0) return "0h";
137
+ return hours < 1 ? `${Math.round(hours * 60)}m` : `${hours.toFixed(1)}h`;
138
+ }
139
+ function breadcrumbs(issue) {
140
+ const parts = [issue.projectName, issue.parent && `${issue.parent.key} ${issue.parent.summary}`].filter((part) => Boolean(part)).map((part) => part.trim());
141
+ return [...parts, issue.key].join(" \u203A ");
142
+ }
143
+ function wrapLines(text, width) {
144
+ const out = [];
145
+ for (const line of text.split("\n")) {
146
+ if (line.length <= width) {
147
+ out.push(line);
148
+ continue;
149
+ }
150
+ let rest = line;
151
+ while (rest.length > width) {
152
+ const cut = rest.lastIndexOf(" ", width);
153
+ const at = cut > width / 2 ? cut : width;
154
+ out.push(rest.slice(0, at));
155
+ rest = rest.slice(at).trimStart();
156
+ }
157
+ out.push(rest);
158
+ }
159
+ return out;
160
+ }
161
+ function Field({ label, value, color }) {
162
+ return /* @__PURE__ */ jsxs4(Box4, { children: [
163
+ /* @__PURE__ */ jsx4(Box4, { width: FIELD_LABEL_WIDTH, flexShrink: 0, children: /* @__PURE__ */ jsx4(Text4, { color: "gray", children: label }) }),
164
+ /* @__PURE__ */ jsx4(Text4, { color, wrap: "truncate-end", children: value })
165
+ ] });
166
+ }
167
+ function IssuePreview({
168
+ issue,
169
+ worklog,
170
+ worklogWindowDays,
171
+ focused,
172
+ height,
173
+ width,
174
+ offset
175
+ }) {
176
+ if (!issue) {
177
+ return /* @__PURE__ */ jsx4(PanelFrame, { title: "preview", focused, height, flexGrow: 1, children: /* @__PURE__ */ jsx4(Text4, { color: "gray", children: "Select a ticket above to preview it." }) });
178
+ }
179
+ const loggedHours = worklog.reduce((sum, entry) => sum + entry.hours, 0);
180
+ const loggedDates = worklog.map((entry) => `${formatDay(entry.date)} (${formatHours(entry.hours)})`);
181
+ const myWork = worklog.length > 0 ? `${formatHours(loggedHours)} \xB7 ${loggedDates.join(", ")}` : `nothing in the last ${worklogWindowDays} days`;
182
+ const total = issue.totalTimeSpentSeconds !== null && issue.totalTimeSpentSeconds > 0 ? `${formatHours(issue.totalTimeSpentSeconds / 3600)} logged by everyone` : null;
183
+ const fieldRows = 11 + (total ? 1 : 0);
184
+ const lines = wrapLines(issue.description || "(no description)", Math.max(20, width - 4));
185
+ const available = Math.max(1, height - PANEL_CHROME_ROWS - fieldRows);
186
+ const start = Math.min(offset, Math.max(0, lines.length - available));
187
+ const bodyRows = lines.length - start > available ? Math.max(1, available - 1) : available;
188
+ const visible = lines.slice(start, start + bodyRows);
189
+ const more = lines.length - start - visible.length;
190
+ return /* @__PURE__ */ jsxs4(
191
+ PanelFrame,
192
+ {
193
+ title: `${issue.key} \xB7 ${issue.url}`,
194
+ focused,
195
+ height,
196
+ flexGrow: 1,
197
+ children: [
198
+ /* @__PURE__ */ jsx4(Text4, { color: "gray", wrap: "truncate-end", children: breadcrumbs(issue) }),
199
+ /* @__PURE__ */ jsx4(Text4, { bold: true, wrap: "truncate-end", children: issue.summary }),
200
+ /* @__PURE__ */ jsx4(Field, { label: "type", value: issue.issueType || "\u2014" }),
201
+ /* @__PURE__ */ jsx4(Field, { label: "status", value: issue.status }),
202
+ /* @__PURE__ */ jsx4(Field, { label: "sprint", value: issue.sprints.length > 0 ? issue.sprints.join(", ") : "None" }),
203
+ /* @__PURE__ */ jsx4(Field, { label: "assignee", value: issue.assignee ?? "Unassigned" }),
204
+ /* @__PURE__ */ jsx4(Field, { label: "reporter", value: issue.reporter ?? "\u2014" }),
205
+ /* @__PURE__ */ jsx4(Field, { label: "priority", value: issue.priority ?? "\u2014" }),
206
+ /* @__PURE__ */ jsx4(Field, { label: "labels", value: issue.labels.length > 0 ? issue.labels.join(", ") : "\u2014" }),
207
+ /* @__PURE__ */ jsx4(
208
+ Field,
209
+ {
210
+ label: "your work",
211
+ value: myWork,
212
+ color: worklog.length > 0 ? "green" : void 0
213
+ }
214
+ ),
215
+ total && /* @__PURE__ */ jsx4(Field, { label: "total work", value: total }),
216
+ /* @__PURE__ */ jsx4(Field, { label: "updated", value: formatUpdated(issue.updated) }),
217
+ /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
218
+ visible.map((line, index) => /* @__PURE__ */ jsx4(Text4, { color: "white", wrap: "truncate-end", children: line || " " }, start + index)),
219
+ more > 0 && /* @__PURE__ */ jsxs4(Text4, { color: "gray", children: [
220
+ "\u2026 ",
221
+ more,
222
+ " more lines (\u2193 to scroll)"
223
+ ] })
224
+ ] })
225
+ ]
226
+ }
227
+ );
228
+ }
229
+
230
+ // src/components/WorkChartPanel.tsx
231
+ import { Box as Box5, Text as Text5 } from "ink";
232
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
233
+ var HOURS_PER_BAR = 4;
234
+ var LABEL_ROWS = 1;
235
+ var MIN_BAR_ROWS = 2;
236
+ var FULL_BLOCK = "\u2588";
237
+ var HALF_BLOCK = "\u2584";
238
+ var TRACE_BLOCK = "\u2581";
239
+ var PANEL_SIDE_COLUMNS = 5;
240
+ var MONTHS2 = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
241
+ function chartWidthFor(dayCount, barWidth) {
242
+ const bars = dayCount * barWidth + Math.max(0, dayCount - 1);
243
+ return bars + PANEL_SIDE_COLUMNS;
244
+ }
245
+ function WorkChartPanel({
246
+ days,
247
+ issueKey,
248
+ state,
249
+ focused,
250
+ height,
251
+ width,
252
+ spinnerFrame
253
+ }) {
254
+ if (state.loading && days.length === 0) {
255
+ return /* @__PURE__ */ jsx5(PanelFrame, { title: "logged work", focused, height, width, children: /* @__PURE__ */ jsx5(Spinner, { label: "loading\u2026", frame: spinnerFrame }) });
256
+ }
257
+ if (state.error) {
258
+ return /* @__PURE__ */ jsx5(PanelFrame, { title: "logged work", focused, height, width, children: /* @__PURE__ */ jsx5(PanelError, { error: state.error }) });
259
+ }
260
+ if (!issueKey || days.length === 0) {
261
+ return /* @__PURE__ */ jsx5(PanelFrame, { title: "logged work", focused, height, width, children: /* @__PURE__ */ jsx5(Text5, { color: "gray", children: "Select a ticket to see your work on it." }) });
262
+ }
263
+ const barWidth = chartWidthFor(days.length, 2) <= width ? 2 : 1;
264
+ const tallest = days.reduce((max, day) => Math.max(max, day.hours), 0);
265
+ const roomForRows = Math.max(1, height - PANEL_CHROME_ROWS - LABEL_ROWS);
266
+ const barRows = Math.min(roomForRows, Math.max(MIN_BAR_ROWS, Math.ceil(tallest / HOURS_PER_BAR)));
267
+ const rows = Array.from({ length: barRows }, (_, index) => barRows - 1 - index);
268
+ const total = days.reduce((sum, day) => sum + day.hours, 0);
269
+ const clipped = tallest / HOURS_PER_BAR > barRows;
270
+ const title = `${issueKey} \xB7 ${total.toFixed(1)}h over ${days.length}d \xB7 ${HOURS_PER_BAR}h = 1 bar` + (clipped ? ` \xB7 peak ${tallest.toFixed(1)}h` : "");
271
+ return /* @__PURE__ */ jsx5(PanelFrame, { title, focused, height, width, children: /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", flexGrow: 1, justifyContent: "flex-end", children: [
272
+ total === 0 && /* @__PURE__ */ jsx5(Text5, { color: "gray", children: "no time logged in this window" }),
273
+ total > 0 && rows.map((rowFromBottom) => /* @__PURE__ */ jsx5(Text5, { children: days.map((day, index) => /* @__PURE__ */ jsxs5(Text5, { children: [
274
+ index > 0 ? " " : "",
275
+ /* @__PURE__ */ jsx5(Text5, { color: barColor(day), children: glyphFor(day.hours, rowFromBottom).repeat(barWidth) })
276
+ ] }, day.date)) }, rowFromBottom)),
277
+ barWidth >= 2 ? /* @__PURE__ */ jsx5(Text5, { children: days.map((day, index) => /* @__PURE__ */ jsxs5(Text5, { children: [
278
+ index > 0 ? " " : "",
279
+ /* @__PURE__ */ jsx5(Text5, { color: isToday(day.date) ? "cyan" : "gray", bold: isToday(day.date), children: dayOfMonth(day.date) })
280
+ ] }, day.date)) }) : (
281
+ // One column per bar leaves no room for a label per day; a date range
282
+ // reads better than a row of single digits.
283
+ /* @__PURE__ */ jsx5(Text5, { color: "gray", wrap: "truncate-end", children: rangeLabel(days) })
284
+ )
285
+ ] }) });
286
+ }
287
+ function glyphFor(hours, rowFromBottom) {
288
+ const rowsFilled = hours / HOURS_PER_BAR - rowFromBottom;
289
+ if (rowsFilled >= 1) return FULL_BLOCK;
290
+ if (rowsFilled >= 0.5) return HALF_BLOCK;
291
+ if (rowsFilled > 0) return TRACE_BLOCK;
292
+ return " ";
293
+ }
294
+ function barColor(day) {
295
+ if (day.hours === 0) return "gray";
296
+ return isWeekend(day.date) ? "blue" : "green";
297
+ }
298
+ function parseDate(key) {
299
+ const [year = "1970", month = "01", day = "01"] = key.split("-");
300
+ return new Date(Number(year), Number(month) - 1, Number(day));
301
+ }
302
+ function isWeekend(key) {
303
+ const weekday = parseDate(key).getDay();
304
+ return weekday === 0 || weekday === 6;
305
+ }
306
+ function isToday(key) {
307
+ return parseDate(key).toDateString() === (/* @__PURE__ */ new Date()).toDateString();
308
+ }
309
+ function dayOfMonth(key) {
310
+ const [, , day = "01"] = key.split("-");
311
+ return day.padStart(2, "0");
312
+ }
313
+ function rangeLabel(days) {
314
+ const format = (key) => {
315
+ const [, month = "01", day = "01"] = key.split("-");
316
+ return `${day} ${MONTHS2[Number(month) - 1] ?? month}`;
317
+ };
318
+ const first = days[0]?.date;
319
+ const last = days[days.length - 1]?.date;
320
+ return first && last ? `${format(first)} \u2192 ${format(last)}` : "";
321
+ }
322
+
323
+ // src/components/WorklogPanel.tsx
324
+ import { Box as Box6, Text as Text6 } from "ink";
325
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
326
+ var WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
327
+ var MONTHS3 = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
328
+ function formatDate(key) {
329
+ const [year = "1970", month = "01", day = "01"] = key.split("-");
330
+ const date = new Date(Number(year), Number(month) - 1, Number(day));
331
+ return `${WEEKDAYS[date.getDay()]} ${day} ${MONTHS3[date.getMonth()]}`;
332
+ }
333
+ function isWeekend2(key) {
334
+ const [year = "1970", month = "01", day = "01"] = key.split("-");
335
+ const weekday = new Date(Number(year), Number(month) - 1, Number(day)).getDay();
336
+ return weekday === 0 || weekday === 6;
337
+ }
338
+ function hoursColor(hours, weekend) {
339
+ if (hours === 0) return weekend ? "gray" : "red";
340
+ if (hours < 6) return "yellow";
341
+ return "green";
342
+ }
343
+ function WorklogPanel({
344
+ state,
345
+ focused,
346
+ height,
347
+ width,
348
+ offset,
349
+ spinnerFrame
350
+ }) {
351
+ const days = state.data?.days ?? [];
352
+ const visibleRows = Math.max(1, height - PANEL_CHROME_ROWS);
353
+ const total = days.reduce((sum, day) => sum + day.hours, 0);
354
+ const keysWidth = Math.max(6, width - 4 - 12 - 7);
355
+ const base = days.length > 0 ? `last ${days.length} days \xB7 ${total.toFixed(1)}h total` : `last ${days.length || 14} days`;
356
+ const title = state.loading && days.length > 0 ? `${spinnerChar(spinnerFrame)} ${base}` : base;
357
+ return /* @__PURE__ */ jsx6(PanelFrame, { title, focused, height, width, children: state.loading && days.length === 0 ? /* @__PURE__ */ jsx6(Spinner, { label: "loading worklogs\u2026", frame: spinnerFrame }) : state.error ? /* @__PURE__ */ jsx6(PanelError, { error: state.error }) : /* @__PURE__ */ jsx6(Box6, { flexDirection: "column", children: days.slice(offset, offset + visibleRows).map((day) => {
358
+ const weekend = isWeekend2(day.date);
359
+ const keys = day.issueKeys.length > 0 ? day.issueKeys.join(", ") : "\u2014";
360
+ return /* @__PURE__ */ jsxs6(Box6, { children: [
361
+ /* @__PURE__ */ jsx6(Box6, { width: 12, flexShrink: 0, children: /* @__PURE__ */ jsx6(Text6, { color: weekend ? "gray" : "white", children: formatDate(day.date) }) }),
362
+ /* @__PURE__ */ jsx6(Box6, { width: 7, flexShrink: 0, children: /* @__PURE__ */ jsx6(Text6, { color: hoursColor(day.hours, weekend), children: `${day.hours.toFixed(1)}h`.padStart(6) }) }),
363
+ /* @__PURE__ */ jsx6(Box6, { width: keysWidth, flexShrink: 1, children: /* @__PURE__ */ jsx6(Text6, { color: "gray", wrap: "truncate-end", children: keys }) })
364
+ ] }, day.date);
365
+ }) }) });
366
+ }
367
+
368
+ // src/data/adf.ts
369
+ function adfToText(doc) {
370
+ if (typeof doc === "string") return doc;
371
+ if (typeof doc !== "object" || doc === null) return "";
372
+ return renderNodes(doc.content ?? [], 0).join("\n").replace(/\n{3,}/g, "\n\n").trim();
373
+ }
374
+ function renderNodes(nodes, depth) {
375
+ return nodes.flatMap((node) => renderNode(node, depth));
376
+ }
377
+ function renderNode(node, depth) {
378
+ const indent = " ".repeat(depth);
379
+ switch (node.type) {
380
+ case "text":
381
+ return [applyMarks(node)];
382
+ case "hardBreak":
383
+ return [""];
384
+ case "paragraph":
385
+ return [indent + inline(node.content ?? []), ""];
386
+ case "heading": {
387
+ const level = Number(node.attrs?.["level"] ?? 1);
388
+ return [`${"#".repeat(Math.min(level, 6))} ${inline(node.content ?? [])}`, ""];
389
+ }
390
+ case "bulletList":
391
+ return [...listItems(node, depth, () => "\u2022"), ""];
392
+ case "orderedList":
393
+ return [...listItems(node, depth, (i) => `${i + 1}.`), ""];
394
+ case "codeBlock": {
395
+ const lang = typeof node.attrs?.["language"] === "string" ? node.attrs["language"] : "";
396
+ const body = inline(node.content ?? []).split("\n");
397
+ return [`${indent}\`\`\`${lang}`, ...body.map((l) => indent + l), `${indent}\`\`\``, ""];
398
+ }
399
+ case "blockquote":
400
+ return [...renderNodes(node.content ?? [], depth).map((l) => `${indent}> ${l}`), ""];
401
+ case "rule":
402
+ return ["\u2500\u2500\u2500", ""];
403
+ case "mediaSingle":
404
+ case "mediaGroup":
405
+ return [`${indent}[attachment]`];
406
+ case "table":
407
+ return [...renderNodes(node.content ?? [], depth), ""];
408
+ case "tableRow":
409
+ return [indent + (node.content ?? []).map((cell) => inline(cell.content ?? [])).join(" | ")];
410
+ default:
411
+ return node.content ? renderNodes(node.content, depth) : [];
412
+ }
413
+ }
414
+ function listItems(node, depth, bullet) {
415
+ const indent = " ".repeat(depth);
416
+ return (node.content ?? []).flatMap((item, index) => {
417
+ const lines = renderNodes(item.content ?? [], depth + 1).filter((l) => l.trim() !== "");
418
+ const [first = "", ...rest] = lines;
419
+ return [`${indent}${bullet(index)} ${first.trim()}`, ...rest];
420
+ });
421
+ }
422
+ function inline(nodes) {
423
+ return nodes.map((node) => {
424
+ switch (node.type) {
425
+ case "text":
426
+ return applyMarks(node);
427
+ case "hardBreak":
428
+ return "\n";
429
+ case "mention":
430
+ return attr(node, "text") || "@unknown";
431
+ case "emoji":
432
+ return attr(node, "text") || attr(node, "shortName");
433
+ case "inlineCard":
434
+ return attr(node, "url");
435
+ case "date":
436
+ return attr(node, "timestamp");
437
+ case "status":
438
+ return `[${attr(node, "text")}]`;
439
+ default:
440
+ return node.content ? inline(node.content) : "";
441
+ }
442
+ }).join("");
443
+ }
444
+ function attr(node, name) {
445
+ const value = node.attrs?.[name];
446
+ return typeof value === "string" || typeof value === "number" ? String(value) : "";
447
+ }
448
+ function applyMarks(node) {
449
+ const text = node.text ?? "";
450
+ const link = node.marks?.find((m) => m.type === "link");
451
+ const href = link?.attrs?.["href"];
452
+ return typeof href === "string" && href !== text ? `${text} (${href})` : text;
453
+ }
454
+
455
+ // src/data/sprintField.ts
456
+ var SPRINT_SCHEMA = "com.pyxis.greenhopper.jira:gh-sprint";
457
+ var cached;
458
+ async function resolveSprintFieldId(client) {
459
+ if (cached !== void 0) return cached;
460
+ try {
461
+ const fields = await client.get("/rest/api/3/field");
462
+ cached = fields.find((field) => field.schema?.custom === SPRINT_SCHEMA)?.id ?? null;
463
+ } catch {
464
+ cached = null;
465
+ }
466
+ return cached;
467
+ }
468
+ function sprintNames(value) {
469
+ if (!Array.isArray(value)) return [];
470
+ const sprints = value.map((entry) => {
471
+ if (typeof entry === "string") return parseLegacySprint(entry);
472
+ if (typeof entry === "object" && entry !== null) return entry;
473
+ return null;
474
+ }).filter((sprint) => Boolean(sprint?.name));
475
+ const rank = (state) => {
476
+ switch (state?.toLowerCase()) {
477
+ case "active":
478
+ return 0;
479
+ case "future":
480
+ return 1;
481
+ default:
482
+ return 2;
483
+ }
484
+ };
485
+ return sprints.sort((a, b) => rank(a.state) - rank(b.state)).map(
486
+ (sprint) => sprint.state && sprint.state.toLowerCase() !== "active" ? `${sprint.name ?? ""} (${sprint.state.toLowerCase()})` : sprint.name ?? ""
487
+ );
488
+ }
489
+ function parseLegacySprint(raw) {
490
+ const name = /\bname=([^,\]]+)/.exec(raw)?.[1];
491
+ const state = /\bstate=([^,\]]+)/.exec(raw)?.[1];
492
+ return name ? { name, state } : null;
493
+ }
494
+
495
+ // src/data/issues.ts
496
+ var MAX_ISSUES = 100;
497
+ var FIELDS = [
498
+ "summary",
499
+ "status",
500
+ "priority",
501
+ "assignee",
502
+ "reporter",
503
+ "labels",
504
+ "updated",
505
+ "description",
506
+ "issuetype",
507
+ "parent",
508
+ "project",
509
+ "aggregatetimespent",
510
+ "timespent"
511
+ ];
512
+ async function fetchOpenIssues(client, config) {
513
+ const sprintFieldId = await resolveSprintFieldId(client);
514
+ const response = await client.post("/rest/api/3/search/jql", {
515
+ jql: config.issuesJql,
516
+ maxResults: MAX_ISSUES,
517
+ fields: sprintFieldId ? [...FIELDS, sprintFieldId] : FIELDS
518
+ });
519
+ return (response.issues ?? []).map((raw) => toIssue(raw, client, sprintFieldId));
520
+ }
521
+ function toIssue(raw, client, sprintFieldId) {
522
+ const fields = raw.fields ?? {};
523
+ return {
524
+ key: raw.key,
525
+ summary: fields.summary ?? "(no summary)",
526
+ status: fields.status?.name ?? "Unknown",
527
+ statusCategory: fields.status?.statusCategory?.name ?? "",
528
+ issueType: fields.issuetype?.name ?? "",
529
+ priority: fields.priority?.name ?? null,
530
+ assignee: fields.assignee?.displayName ?? null,
531
+ reporter: fields.reporter?.displayName ?? null,
532
+ labels: fields.labels ?? [],
533
+ updated: fields.updated ?? null,
534
+ description: adfToText(fields.description),
535
+ url: client.issueUrl(raw.key),
536
+ projectName: fields.project?.name ?? fields.project?.key ?? null,
537
+ parent: fields.parent?.key ? {
538
+ key: fields.parent.key,
539
+ summary: fields.parent.fields?.summary ?? "",
540
+ issueType: fields.parent.fields?.issuetype?.name ?? ""
541
+ } : null,
542
+ totalTimeSpentSeconds: fields.aggregatetimespent ?? fields.timespent ?? null,
543
+ sprints: sprintFieldId ? sprintNames(fields[sprintFieldId]) : []
544
+ };
545
+ }
546
+
547
+ // src/data/worklogs.ts
548
+ var WORKLOG_PAGE_SIZE = 1e3;
549
+ var CONCURRENCY = 5;
550
+ function localDateKey(date) {
551
+ const month = String(date.getMonth() + 1).padStart(2, "0");
552
+ const day = String(date.getDate()).padStart(2, "0");
553
+ return `${date.getFullYear()}-${month}-${day}`;
554
+ }
555
+ function startOfDay(daysAgo) {
556
+ const date = /* @__PURE__ */ new Date();
557
+ date.setHours(0, 0, 0, 0);
558
+ date.setDate(date.getDate() - daysAgo);
559
+ return date;
560
+ }
561
+ async function fetchWorklogDays(client, config, me) {
562
+ const days = config.worklogDays;
563
+ const windowStart = startOfDay(days - 1);
564
+ const search = await client.post("/rest/api/3/search/jql", {
565
+ jql: `worklogAuthor = currentUser() AND worklogDate >= -${days}d ORDER BY updated DESC`,
566
+ maxResults: 200,
567
+ fields: ["summary"]
568
+ });
569
+ const keys = (search.issues ?? []).map((issue) => issue.key);
570
+ const buckets = /* @__PURE__ */ new Map();
571
+ for (let i = 0; i < days; i += 1) {
572
+ buckets.set(localDateKey(startOfDay(i)), { seconds: 0, keys: /* @__PURE__ */ new Set() });
573
+ }
574
+ const perIssue = /* @__PURE__ */ new Map();
575
+ const startedAfter = String(windowStart.getTime());
576
+ await forEachLimited(keys, CONCURRENCY, async (key) => {
577
+ const response = await client.get(`/rest/api/3/issue/${key}/worklog`, {
578
+ startedAfter,
579
+ maxResults: String(WORKLOG_PAGE_SIZE)
580
+ });
581
+ for (const entry of response.worklogs ?? []) {
582
+ if (entry.author?.accountId !== me.accountId || !entry.started) continue;
583
+ const started = new Date(entry.started);
584
+ if (Number.isNaN(started.getTime()) || started < windowStart) continue;
585
+ const date = localDateKey(started);
586
+ const bucket = buckets.get(date);
587
+ if (!bucket) continue;
588
+ const seconds = entry.timeSpentSeconds ?? 0;
589
+ bucket.seconds += seconds;
590
+ bucket.keys.add(key);
591
+ const issueDays = perIssue.get(key) ?? /* @__PURE__ */ new Map();
592
+ issueDays.set(date, (issueDays.get(date) ?? 0) + seconds);
593
+ perIssue.set(key, issueDays);
594
+ }
595
+ });
596
+ const byIssue = {};
597
+ for (const [key, issueDays] of perIssue) {
598
+ byIssue[key] = Array.from(issueDays.entries()).map(([date, seconds]) => ({ date, hours: seconds / 3600 })).sort((a, b) => a.date.localeCompare(b.date));
599
+ }
600
+ return {
601
+ days: Array.from(buckets.entries()).map(([date, { seconds, keys: issueKeys }]) => ({
602
+ date,
603
+ hours: seconds / 3600,
604
+ issueKeys: Array.from(issueKeys).sort(compareIssueKeys)
605
+ })).sort((a, b) => a.date.localeCompare(b.date)),
606
+ byIssue,
607
+ windowDays: days
608
+ };
609
+ }
610
+ function compareIssueKeys(a, b) {
611
+ const [aProject = a, aNumber = "0"] = a.split("-");
612
+ const [bProject = b, bNumber = "0"] = b.split("-");
613
+ return aProject === bProject ? Number(aNumber) - Number(bNumber) : aProject.localeCompare(bProject);
614
+ }
615
+ async function forEachLimited(items, limit, worker) {
616
+ let cursor = 0;
617
+ const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
618
+ while (cursor < items.length) {
619
+ const item = items[cursor];
620
+ cursor += 1;
621
+ if (item !== void 0) await worker(item);
622
+ }
623
+ });
624
+ await Promise.all(runners);
625
+ }
626
+
627
+ // src/hooks/useHomeEndKeys.ts
628
+ import { useEffect, useRef } from "react";
629
+ import { useStdin } from "ink";
630
+ var ESC = String.fromCharCode(27);
631
+ var HOME_SEQUENCES = [`${ESC}[H`, `${ESC}[1~`, `${ESC}[7~`, `${ESC}OH`];
632
+ var END_SEQUENCES = [`${ESC}[F`, `${ESC}[4~`, `${ESC}[8~`, `${ESC}OF`];
633
+ function useHomeEndKeys(onHome, onEnd) {
634
+ const { stdin, isRawModeSupported } = useStdin();
635
+ const handlers = useRef({ onHome, onEnd });
636
+ handlers.current = { onHome, onEnd };
637
+ useEffect(() => {
638
+ if (!isRawModeSupported) return;
639
+ const onData = (data) => {
640
+ const text = typeof data === "string" ? data : data.toString("utf8");
641
+ if (HOME_SEQUENCES.some((sequence) => text.includes(sequence))) {
642
+ handlers.current.onHome();
643
+ } else if (END_SEQUENCES.some((sequence) => text.includes(sequence))) {
644
+ handlers.current.onEnd();
645
+ }
646
+ };
647
+ stdin.on("data", onData);
648
+ return () => {
649
+ stdin.off("data", onData);
650
+ };
651
+ }, [stdin, isRawModeSupported]);
652
+ }
653
+
654
+ // src/hooks/useMouse.ts
655
+ import { useEffect as useEffect2 } from "react";
656
+ import { useStdin as useStdin2, useStdout } from "ink";
657
+ var ESC2 = String.fromCharCode(27);
658
+ var MOUSE_ON = `${ESC2}[?1000h${ESC2}[?1006h`;
659
+ var MOUSE_OFF = `${ESC2}[?1006l${ESC2}[?1000l`;
660
+ var SGR_MOUSE = new RegExp(`${ESC2}\\[<(\\d+);(\\d+);(\\d+)([Mm])`, "g");
661
+ var SGR_PREFIX = `${ESC2}[<`;
662
+ function isMouseSequence(input) {
663
+ return input.includes(SGR_PREFIX);
664
+ }
665
+ function useMouse(onEvent) {
666
+ const { stdin, isRawModeSupported } = useStdin2();
667
+ const { stdout } = useStdout();
668
+ useEffect2(() => {
669
+ if (!isRawModeSupported) return;
670
+ stdout.write(MOUSE_ON);
671
+ const disable = () => {
672
+ stdout.write(MOUSE_OFF);
673
+ };
674
+ process.on("exit", disable);
675
+ const onData = (data) => {
676
+ const text = typeof data === "string" ? data : data.toString("utf8");
677
+ SGR_MOUSE.lastIndex = 0;
678
+ for (let match = SGR_MOUSE.exec(text); match; match = SGR_MOUSE.exec(text)) {
679
+ const [, rawButton = "0", rawColumn = "1", rawRow = "1", kind] = match;
680
+ if (kind !== "M") continue;
681
+ const button = Number(rawButton);
682
+ const column = Number(rawColumn) - 1;
683
+ const row = Number(rawRow) - 1;
684
+ if (button & 64) {
685
+ onEvent({ type: "wheel", direction: (button & 1) === 0 ? "up" : "down", column, row });
686
+ } else if ((button & 3) === 0) {
687
+ onEvent({ type: "press", column, row });
688
+ }
689
+ }
690
+ };
691
+ stdin.on("data", onData);
692
+ return () => {
693
+ stdin.off("data", onData);
694
+ process.off("exit", disable);
695
+ disable();
696
+ };
697
+ }, [stdin, stdout, isRawModeSupported, onEvent]);
698
+ }
699
+
700
+ // src/hooks/useTerminalSize.ts
701
+ import { useEffect as useEffect3, useState } from "react";
702
+ import { useStdout as useStdout2 } from "ink";
703
+ function useTerminalSize() {
704
+ const { stdout } = useStdout2();
705
+ const [size, setSize] = useState(() => ({
706
+ columns: stdout.columns || 80,
707
+ rows: stdout.rows || 24
708
+ }));
709
+ useEffect3(() => {
710
+ const onResize = () => {
711
+ setSize({ columns: stdout.columns || 80, rows: stdout.rows || 24 });
712
+ };
713
+ stdout.on("resize", onResize);
714
+ onResize();
715
+ return () => {
716
+ stdout.off("resize", onResize);
717
+ };
718
+ }, [stdout]);
719
+ return size;
720
+ }
721
+
722
+ // src/openUrl.ts
723
+ import { spawn } from "node:child_process";
724
+ function opener() {
725
+ switch (process.platform) {
726
+ case "darwin":
727
+ return { command: "open", args: [] };
728
+ case "win32":
729
+ return { command: "cmd", args: ["/c", "start", ""] };
730
+ default:
731
+ return { command: "xdg-open", args: [] };
732
+ }
733
+ }
734
+ async function openUrl(url) {
735
+ const { command, args } = opener();
736
+ return new Promise((resolve, reject) => {
737
+ const child = spawn(command, [...args, url], {
738
+ detached: true,
739
+ stdio: "ignore"
740
+ });
741
+ child.once("error", (cause) => {
742
+ reject(
743
+ new Error(
744
+ cause.code === "ENOENT" ? `${command} is not installed, so the browser could not be opened.` : `${command} failed: ${cause.message}`
745
+ )
746
+ );
747
+ });
748
+ child.once("spawn", () => {
749
+ child.unref();
750
+ resolve();
751
+ });
752
+ });
753
+ }
754
+
755
+ // src/types.ts
756
+ var JiraError = class extends Error {
757
+ hints;
758
+ constructor(message, hints = []) {
759
+ super(message);
760
+ this.name = "JiraError";
761
+ this.hints = hints;
762
+ }
763
+ };
764
+
765
+ // src/app.tsx
766
+ import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
767
+ var PANEL_ORDER = ["worklog", "issues", "preview"];
768
+ var MIN_TOP_HEIGHT = 6;
769
+ var MIN_LEFT_WIDTH = 34;
770
+ var SPINNER_INTERVAL_MS = 90;
771
+ var NOTICE_TIMEOUT_MS = 3e3;
772
+ var pending = (previous) => ({
773
+ data: previous?.data ?? null,
774
+ loading: true,
775
+ error: null
776
+ });
777
+ function toJiraError(cause) {
778
+ if (cause instanceof JiraError) return cause;
779
+ return new JiraError(cause instanceof Error ? cause.message : String(cause));
780
+ }
781
+ function App({ config, client, me }) {
782
+ const { exit } = useApp();
783
+ const { columns, rows } = useTerminalSize();
784
+ const [worklogs, setWorklogs] = useState2(() => pending());
785
+ const [issues, setIssues] = useState2(() => pending());
786
+ const [focus, setFocus] = useState2("issues");
787
+ const [selectedIndex, setSelectedIndex] = useState2(0);
788
+ const [issueOffset, setIssueOffset] = useState2(0);
789
+ const [worklogOffset, setWorklogOffset] = useState2(0);
790
+ const [previewOffset, setPreviewOffset] = useState2(0);
791
+ const [spinnerFrame, setSpinnerFrame] = useState2(0);
792
+ const [reloadToken, setReloadToken] = useState2(0);
793
+ const [notice, setNotice] = useState2(null);
794
+ const topHeight = Math.max(MIN_TOP_HEIGHT, Math.floor((rows - 1) / 2));
795
+ const bottomHeight = Math.max(MIN_TOP_HEIGHT, rows - 1 - topHeight);
796
+ const leftWidth = Math.max(MIN_LEFT_WIDTH, Math.min(52, Math.floor(columns * 0.42)));
797
+ const issueRows = Math.max(1, topHeight - 3);
798
+ const worklogRows = issueRows;
799
+ const dayCount = worklogs.data?.days.length ?? config.worklogDays;
800
+ const chartWidth = Math.min(
801
+ Math.floor(columns / 2),
802
+ Math.max(chartWidthFor(dayCount, 1), Math.min(chartWidthFor(dayCount, 2), columns - 40))
803
+ );
804
+ useEffect4(() => {
805
+ let cancelled = false;
806
+ setIssues(pending);
807
+ fetchOpenIssues(client, config).then((data) => {
808
+ if (!cancelled) setIssues({ data, loading: false, error: null });
809
+ }).catch((cause) => {
810
+ if (!cancelled) setIssues({ data: null, loading: false, error: toJiraError(cause) });
811
+ });
812
+ setWorklogs(pending);
813
+ fetchWorklogDays(client, config, me).then((data) => {
814
+ if (!cancelled) setWorklogs({ data, loading: false, error: null });
815
+ }).catch((cause) => {
816
+ if (!cancelled) setWorklogs({ data: null, loading: false, error: toJiraError(cause) });
817
+ });
818
+ return () => {
819
+ cancelled = true;
820
+ };
821
+ }, [client, config, me, reloadToken]);
822
+ const loading = worklogs.loading || issues.loading;
823
+ useEffect4(() => {
824
+ if (!loading) return;
825
+ const timer = setInterval(() => {
826
+ setSpinnerFrame((frame) => frame + 1);
827
+ }, SPINNER_INTERVAL_MS);
828
+ return () => {
829
+ clearInterval(timer);
830
+ };
831
+ }, [loading]);
832
+ const issueList = issues.data ?? [];
833
+ const selected = issueList[selectedIndex] ?? null;
834
+ useEffect4(() => {
835
+ setSelectedIndex((index) => Math.min(index, Math.max(0, issueList.length - 1)));
836
+ }, [issueList.length]);
837
+ useEffect4(() => {
838
+ setWorklogOffset(Math.max(0, (worklogs.data?.days.length ?? 0) - worklogRows));
839
+ }, [worklogs.data, worklogRows]);
840
+ useEffect4(() => {
841
+ setPreviewOffset(0);
842
+ }, [selected?.key]);
843
+ const selectIndex = useCallback(
844
+ (next) => {
845
+ if (issueList.length === 0) return;
846
+ const clamped = Math.max(0, Math.min(next, issueList.length - 1));
847
+ setSelectedIndex(clamped);
848
+ setIssueOffset((offset) => {
849
+ if (clamped < offset) return clamped;
850
+ if (clamped >= offset + issueRows) return clamped - issueRows + 1;
851
+ return offset;
852
+ });
853
+ },
854
+ [issueList.length, issueRows]
855
+ );
856
+ const scrollPanel = useCallback(
857
+ (panel, delta) => {
858
+ switch (panel) {
859
+ case "issues":
860
+ selectIndex(selectedIndex + delta);
861
+ break;
862
+ case "worklog":
863
+ setWorklogOffset(
864
+ (offset) => Math.max(0, Math.min(offset + delta, (worklogs.data?.days.length ?? 0) - worklogRows))
865
+ );
866
+ break;
867
+ case "preview":
868
+ setPreviewOffset((offset) => Math.max(0, offset + delta));
869
+ break;
870
+ }
871
+ },
872
+ [selectIndex, selectedIndex, worklogs.data?.days.length, worklogRows]
873
+ );
874
+ const chartDays = useMemo(() => {
875
+ const summary = worklogs.data;
876
+ if (!summary || !selected) return [];
877
+ const hoursByDate = new Map(
878
+ (summary.byIssue[selected.key] ?? []).map((entry) => [entry.date, entry.hours])
879
+ );
880
+ return summary.days.map((day) => ({
881
+ date: day.date,
882
+ hours: hoursByDate.get(day.date) ?? 0,
883
+ issueKeys: []
884
+ }));
885
+ }, [worklogs.data, selected]);
886
+ useEffect4(() => {
887
+ if (!notice) return;
888
+ const timer = setTimeout(() => {
889
+ setNotice(null);
890
+ }, NOTICE_TIMEOUT_MS);
891
+ return () => {
892
+ clearTimeout(timer);
893
+ };
894
+ }, [notice]);
895
+ const openSelected = useCallback(() => {
896
+ if (!selected) return;
897
+ openUrl(selected.url).then(() => {
898
+ setNotice({ text: `opened ${selected.key} in your browser`, error: false });
899
+ }).catch((cause) => {
900
+ setNotice({
901
+ text: cause instanceof Error ? cause.message : String(cause),
902
+ error: true
903
+ });
904
+ });
905
+ }, [selected]);
906
+ const goToStart = useCallback(() => {
907
+ switch (focus) {
908
+ case "issues":
909
+ selectIndex(0);
910
+ break;
911
+ case "worklog":
912
+ setWorklogOffset(0);
913
+ break;
914
+ case "preview":
915
+ setPreviewOffset(0);
916
+ break;
917
+ }
918
+ }, [focus, selectIndex]);
919
+ const goToEnd = useCallback(() => {
920
+ switch (focus) {
921
+ case "issues":
922
+ selectIndex(issueList.length - 1);
923
+ break;
924
+ case "worklog":
925
+ setWorklogOffset(Math.max(0, (worklogs.data?.days.length ?? 0) - worklogRows));
926
+ break;
927
+ case "preview":
928
+ break;
929
+ }
930
+ }, [focus, issueList.length, selectIndex, worklogs.data?.days.length, worklogRows]);
931
+ useHomeEndKeys(goToStart, goToEnd);
932
+ const layout = useRef2({ leftWidth, topHeight, issueRows, issueOffset });
933
+ layout.current = { leftWidth, topHeight, issueRows, issueOffset };
934
+ const onMouse = useCallback(
935
+ (event) => {
936
+ const { leftWidth: left, topHeight: top, issueRows: visible, issueOffset: offset } = layout.current;
937
+ const panel = event.row >= top ? "preview" : event.column >= left ? "issues" : "worklog";
938
+ if (event.type === "wheel") {
939
+ setFocus(panel);
940
+ scrollPanel(panel, event.direction === "up" ? -1 : 1);
941
+ return;
942
+ }
943
+ setFocus(panel);
944
+ if (panel !== "issues") return;
945
+ const row = event.row - PANEL_HEADER_ROWS;
946
+ if (row >= 0 && row < visible) selectIndex(offset + row);
947
+ },
948
+ [scrollPanel, selectIndex]
949
+ );
950
+ useMouse(onMouse);
951
+ useInput((input, key) => {
952
+ if (isMouseSequence(input)) return;
953
+ if (key.ctrl && input === "c") {
954
+ exit();
955
+ return;
956
+ }
957
+ if (input === "q" || key.escape) {
958
+ exit();
959
+ return;
960
+ }
961
+ if (input === "r") {
962
+ setReloadToken((token) => token + 1);
963
+ return;
964
+ }
965
+ if (key.return && focus === "issues") {
966
+ openSelected();
967
+ return;
968
+ }
969
+ if (key.tab) {
970
+ const step = key.shift ? -1 : 1;
971
+ setFocus((current) => {
972
+ const next = (PANEL_ORDER.indexOf(current) + step + PANEL_ORDER.length) % PANEL_ORDER.length;
973
+ return PANEL_ORDER[next] ?? "issues";
974
+ });
975
+ return;
976
+ }
977
+ if (key.downArrow) scrollPanel(focus, 1);
978
+ else if (key.upArrow) scrollPanel(focus, -1);
979
+ else if (key.pageDown) scrollPanel(focus, issueRows);
980
+ else if (key.pageUp) scrollPanel(focus, -issueRows);
981
+ else if (/^j+$/.test(input)) scrollPanel(focus, input.length);
982
+ else if (/^k+$/.test(input)) scrollPanel(focus, -input.length);
983
+ else if (input === "g") scrollPanel(focus, -Number.MAX_SAFE_INTEGER);
984
+ else if (input === "G") selectIndex(issueList.length - 1);
985
+ });
986
+ const hint = useMemo(
987
+ () => focus === "issues" ? "\u2191\u2193/jk move \xB7 home/end first/last \xB7 enter open \xB7 tab panel \xB7 r reload \xB7 q quit" : "\u2191\u2193/jk scroll \xB7 home/end top/bottom \xB7 tab panel \xB7 r reload \xB7 q quit",
988
+ [focus]
989
+ );
990
+ return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", width: columns, height: rows, children: [
991
+ /* @__PURE__ */ jsxs7(Box7, { flexDirection: "row", height: topHeight, flexShrink: 0, children: [
992
+ /* @__PURE__ */ jsx7(
993
+ WorklogPanel,
994
+ {
995
+ state: worklogs,
996
+ focused: focus === "worklog",
997
+ height: topHeight,
998
+ width: leftWidth,
999
+ offset: worklogOffset,
1000
+ spinnerFrame
1001
+ }
1002
+ ),
1003
+ /* @__PURE__ */ jsx7(
1004
+ IssueList,
1005
+ {
1006
+ state: issues,
1007
+ focused: focus === "issues",
1008
+ height: topHeight,
1009
+ width: columns - leftWidth,
1010
+ selectedIndex,
1011
+ offset: issueOffset,
1012
+ spinnerFrame
1013
+ }
1014
+ )
1015
+ ] }),
1016
+ /* @__PURE__ */ jsxs7(Box7, { flexDirection: "row", height: bottomHeight, flexShrink: 0, children: [
1017
+ /* @__PURE__ */ jsx7(
1018
+ WorkChartPanel,
1019
+ {
1020
+ days: chartDays,
1021
+ issueKey: selected?.key ?? null,
1022
+ state: worklogs,
1023
+ focused: false,
1024
+ height: bottomHeight,
1025
+ width: chartWidth,
1026
+ spinnerFrame
1027
+ }
1028
+ ),
1029
+ /* @__PURE__ */ jsx7(
1030
+ IssuePreview,
1031
+ {
1032
+ issue: selected,
1033
+ worklog: selected && worklogs.data?.byIssue[selected.key] || [],
1034
+ worklogWindowDays: worklogs.data?.windowDays ?? config.worklogDays,
1035
+ focused: focus === "preview",
1036
+ height: bottomHeight,
1037
+ width: columns - chartWidth,
1038
+ offset: previewOffset
1039
+ }
1040
+ )
1041
+ ] }),
1042
+ /* @__PURE__ */ jsxs7(Box7, { height: 1, flexShrink: 0, justifyContent: "space-between", children: [
1043
+ /* @__PURE__ */ jsxs7(Text7, { color: "gray", wrap: "truncate-end", children: [
1044
+ " ",
1045
+ hint
1046
+ ] }),
1047
+ notice ? /* @__PURE__ */ jsxs7(Text7, { color: notice.error ? "red" : "green", wrap: "truncate-end", children: [
1048
+ notice.error ? "\u2716" : "\u2713",
1049
+ " ",
1050
+ notice.text,
1051
+ " "
1052
+ ] }) : loading ? /* @__PURE__ */ jsxs7(Text7, { color: "cyan", wrap: "truncate-end", children: [
1053
+ spinnerChar(spinnerFrame),
1054
+ " loading\u2026",
1055
+ " "
1056
+ ] }) : /* @__PURE__ */ jsxs7(Text7, { color: "gray", wrap: "truncate-end", children: [
1057
+ me.displayName,
1058
+ " "
1059
+ ] })
1060
+ ] })
1061
+ ] });
1062
+ }
1063
+
1064
+ // src/data/client.ts
1065
+ var REQUEST_TIMEOUT_MS = 2e4;
1066
+ var JiraClient = class {
1067
+ baseUrl;
1068
+ authHeader;
1069
+ constructor(config) {
1070
+ this.baseUrl = config.baseUrl;
1071
+ this.authHeader = `Basic ${Buffer.from(`${config.email}:${config.apiToken}`).toString("base64")}`;
1072
+ }
1073
+ /** Browser URL for an issue, for display in the preview panel. */
1074
+ issueUrl(key) {
1075
+ return `${this.baseUrl}/browse/${key}`;
1076
+ }
1077
+ async get(path, params) {
1078
+ const url = new URL(`${this.baseUrl}${path}`);
1079
+ for (const [key, value] of Object.entries(params ?? {})) {
1080
+ url.searchParams.set(key, value);
1081
+ }
1082
+ return this.request("GET", url.toString());
1083
+ }
1084
+ async post(path, body) {
1085
+ return this.request("POST", `${this.baseUrl}${path}`, body);
1086
+ }
1087
+ async request(method, url, body) {
1088
+ let response;
1089
+ try {
1090
+ response = await fetch(url, {
1091
+ method,
1092
+ headers: {
1093
+ Authorization: this.authHeader,
1094
+ Accept: "application/json",
1095
+ ...body === void 0 ? {} : { "Content-Type": "application/json" }
1096
+ },
1097
+ ...body === void 0 ? {} : { body: JSON.stringify(body) },
1098
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
1099
+ });
1100
+ } catch (cause) {
1101
+ throw asNetworkError(cause, this.baseUrl);
1102
+ }
1103
+ if (!response.ok) {
1104
+ throw await asHttpError(response, url);
1105
+ }
1106
+ return await response.json();
1107
+ }
1108
+ /** Identify the caller; doubles as the credential check at startup. */
1109
+ async myself() {
1110
+ const me = await this.get("/rest/api/3/myself");
1111
+ return { accountId: me.accountId, displayName: me.displayName };
1112
+ }
1113
+ };
1114
+ function asNetworkError(cause, baseUrl) {
1115
+ const name = cause instanceof Error ? cause.name : "";
1116
+ if (name === "TimeoutError" || name === "AbortError") {
1117
+ return new JiraError("Jira did not respond within 20s.", [
1118
+ "Check your network connection or VPN, then press r to retry."
1119
+ ]);
1120
+ }
1121
+ return new JiraError(`Could not reach ${baseUrl}.`, [
1122
+ cause instanceof Error ? cause.message : String(cause),
1123
+ "Verify JIRA_BASE_URL points at your Atlassian site."
1124
+ ]);
1125
+ }
1126
+ async function asHttpError(response, url) {
1127
+ const detail = await readErrorDetail(response);
1128
+ switch (response.status) {
1129
+ case 401:
1130
+ return new JiraError("Jira rejected your credentials (401).", [
1131
+ "JIRA_EMAIL must be your Atlassian account email and JIRA_API_TOKEN an API token",
1132
+ "(not your password). Tokens: https://id.atlassian.com/manage-profile/security/api-tokens"
1133
+ ]);
1134
+ case 403:
1135
+ return new JiraError("Jira denied access (403).", [
1136
+ detail || "Your account may lack permission for this project."
1137
+ ]);
1138
+ case 400:
1139
+ return new JiraError("Jira rejected the request (400).", [
1140
+ detail || "The JQL query may be invalid for this site.",
1141
+ 'Adjust "issuesJql" in your config file.'
1142
+ ]);
1143
+ case 429:
1144
+ return new JiraError("Jira rate-limited this client (429).", [
1145
+ "Wait a moment, then press r to retry."
1146
+ ]);
1147
+ default:
1148
+ return new JiraError(`Jira returned ${response.status} for ${new URL(url).pathname}.`, [
1149
+ detail || response.statusText
1150
+ ]);
1151
+ }
1152
+ }
1153
+ async function readErrorDetail(response) {
1154
+ try {
1155
+ const body = await response.json();
1156
+ if (typeof body !== "object" || body === null) return "";
1157
+ const { errorMessages, errors } = body;
1158
+ const parts = [];
1159
+ if (Array.isArray(errorMessages)) parts.push(...errorMessages.map(String));
1160
+ if (errors && typeof errors === "object") {
1161
+ parts.push(...Object.entries(errors).map(([k, v]) => `${k}: ${String(v)}`));
1162
+ }
1163
+ return parts.join("; ");
1164
+ } catch {
1165
+ return "";
1166
+ }
1167
+ }
1168
+
1169
+ // src/data/config.ts
1170
+ import { readFileSync } from "node:fs";
1171
+ import { homedir } from "node:os";
1172
+ import { join } from "node:path";
1173
+ var CONFIG_PATH = join(
1174
+ process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config"),
1175
+ "jira-dashboard",
1176
+ "config.json"
1177
+ );
1178
+ var TOKEN_URL = "https://id.atlassian.com/manage-profile/security/api-tokens";
1179
+ var DEFAULT_ISSUES_JQL = [
1180
+ "assignee = currentUser()",
1181
+ "AND statusCategory != Done",
1182
+ 'AND status NOT IN ("Cancelled", "Closed", "Backlog")',
1183
+ "ORDER BY updated DESC"
1184
+ ].join(" ");
1185
+ var DEFAULT_WORKLOG_DAYS = 14;
1186
+ function readConfigFile() {
1187
+ let raw;
1188
+ try {
1189
+ raw = readFileSync(CONFIG_PATH, "utf8");
1190
+ } catch {
1191
+ return {};
1192
+ }
1193
+ try {
1194
+ const parsed = JSON.parse(raw);
1195
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1196
+ throw new Error("expected a JSON object");
1197
+ }
1198
+ return parsed;
1199
+ } catch (cause) {
1200
+ throw new JiraError(`Config file at ${CONFIG_PATH} is not valid JSON.`, [
1201
+ cause instanceof Error ? cause.message : String(cause),
1202
+ "Fix the file, or delete it and use environment variables instead."
1203
+ ]);
1204
+ }
1205
+ }
1206
+ function normalizeBaseUrl(value) {
1207
+ const trimmed = value.trim().replace(/\/+$/, "");
1208
+ if (!/^https?:\/\//.test(trimmed)) {
1209
+ throw new JiraError(`JIRA_BASE_URL must start with https:// (got "${trimmed}")`, [
1210
+ "Example: https://your-team.atlassian.net"
1211
+ ]);
1212
+ }
1213
+ return trimmed;
1214
+ }
1215
+ function loadConfig() {
1216
+ const file = readConfigFile();
1217
+ const baseUrl = process.env["JIRA_BASE_URL"] ?? file.baseUrl;
1218
+ const email = process.env["JIRA_EMAIL"] ?? file.email;
1219
+ const apiToken = process.env["JIRA_API_TOKEN"] ?? file.apiToken;
1220
+ const missing = [];
1221
+ if (!baseUrl) missing.push("JIRA_BASE_URL");
1222
+ if (!email) missing.push("JIRA_EMAIL");
1223
+ if (!apiToken) missing.push("JIRA_API_TOKEN");
1224
+ if (missing.length > 0 || !baseUrl || !email || !apiToken) {
1225
+ throw new JiraError(`Missing Jira credentials: ${missing.join(", ")}`, [
1226
+ "Set them in your shell:",
1227
+ ' export JIRA_BASE_URL="https://your-team.atlassian.net"',
1228
+ ' export JIRA_EMAIL="you@example.com"',
1229
+ ' export JIRA_API_TOKEN="..."',
1230
+ "",
1231
+ `Or write them to ${CONFIG_PATH}:`,
1232
+ ' { "baseUrl": "...", "email": "...", "apiToken": "..." }',
1233
+ "",
1234
+ `Create an API token at ${TOKEN_URL}`
1235
+ ]);
1236
+ }
1237
+ return {
1238
+ baseUrl: normalizeBaseUrl(baseUrl),
1239
+ email: email.trim(),
1240
+ apiToken: apiToken.trim(),
1241
+ issuesJql: file.issuesJql?.trim() || DEFAULT_ISSUES_JQL,
1242
+ worklogDays: typeof file.worklogDays === "number" && file.worklogDays > 0 ? Math.floor(file.worklogDays) : DEFAULT_WORKLOG_DAYS
1243
+ };
1244
+ }
1245
+
1246
+ // src/cli.tsx
1247
+ import { jsx as jsx8 } from "react/jsx-runtime";
1248
+ var require2 = createRequire(import.meta.url);
1249
+ var { version } = require2("../package.json");
1250
+ var ESC3 = String.fromCharCode(27);
1251
+ var ALT_SCREEN_ON = `${ESC3}[?1049h`;
1252
+ var ALT_SCREEN_OFF = `${ESC3}[?1049l`;
1253
+ var CURSOR_SHOW = `${ESC3}[?25h`;
1254
+ var HELP = `
1255
+ jdb \u2014 Jira dashboard in your terminal
1256
+
1257
+ Usage
1258
+ $ jdb
1259
+
1260
+ Options
1261
+ -h, --help Show this help
1262
+ -v, --version Show the version
1263
+
1264
+ Configuration (environment variables take precedence)
1265
+ JIRA_BASE_URL https://your-team.atlassian.net
1266
+ JIRA_EMAIL your Atlassian account email
1267
+ JIRA_API_TOKEN https://id.atlassian.com/manage-profile/security/api-tokens
1268
+
1269
+ Or ${CONFIG_PATH}:
1270
+ {
1271
+ "baseUrl": "https://your-team.atlassian.net",
1272
+ "email": "you@example.com",
1273
+ "apiToken": "...",
1274
+ "issuesJql": ${JSON.stringify(DEFAULT_ISSUES_JQL)},
1275
+ "worklogDays": 14
1276
+ }
1277
+
1278
+ Keys
1279
+ \u2191 \u2193 / j k move within the focused panel
1280
+ home / end jump to the first / last item
1281
+ enter open the selected ticket in your browser
1282
+ tab cycle panels (worklogs \u2192 tickets \u2192 preview)
1283
+ click/wheel select and scroll
1284
+ r reload
1285
+ q / esc quit
1286
+ `;
1287
+ function reportError(error) {
1288
+ const jiraError = error instanceof JiraError ? error : null;
1289
+ process.stderr.write(`
1290
+ jdb: ${jiraError?.message ?? String(error)}
1291
+ `);
1292
+ for (const hint of jiraError?.hints ?? []) {
1293
+ process.stderr.write(` ${hint}
1294
+ `);
1295
+ }
1296
+ process.stderr.write("\n");
1297
+ }
1298
+ async function main() {
1299
+ const args = process.argv.slice(2);
1300
+ if (args.includes("-h") || args.includes("--help")) {
1301
+ process.stdout.write(`${HELP}
1302
+ `);
1303
+ return 0;
1304
+ }
1305
+ if (args.includes("-v") || args.includes("--version")) {
1306
+ process.stdout.write(`${version}
1307
+ `);
1308
+ return 0;
1309
+ }
1310
+ if (!process.stdout.isTTY) {
1311
+ process.stderr.write("jdb: needs an interactive terminal (stdout is not a TTY).\n");
1312
+ return 1;
1313
+ }
1314
+ const config = loadConfig();
1315
+ const client = new JiraClient(config);
1316
+ const me = await client.myself();
1317
+ let restored = false;
1318
+ const restoreScreen = () => {
1319
+ if (restored) return;
1320
+ restored = true;
1321
+ process.stdout.write(ALT_SCREEN_OFF + CURSOR_SHOW);
1322
+ };
1323
+ process.stdout.write(ALT_SCREEN_ON);
1324
+ process.on("exit", restoreScreen);
1325
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
1326
+ process.once(signal, () => {
1327
+ restoreScreen();
1328
+ process.exit(signal === "SIGINT" ? 130 : 143);
1329
+ });
1330
+ }
1331
+ try {
1332
+ const { waitUntilExit } = render(/* @__PURE__ */ jsx8(App, { config, client, me }), {
1333
+ exitOnCtrlC: true
1334
+ });
1335
+ await waitUntilExit();
1336
+ } finally {
1337
+ restoreScreen();
1338
+ }
1339
+ return 0;
1340
+ }
1341
+ main().then((code) => {
1342
+ process.exitCode = code;
1343
+ }).catch((error) => {
1344
+ reportError(error);
1345
+ process.exitCode = 1;
1346
+ });
1347
+ //# sourceMappingURL=cli.js.map