jira-dashboard 0.1.2 → 0.1.4
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/README.md +34 -1
- package/dist/cli.js +512 -154
- package/dist/cli.js.map +4 -4
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -69,6 +69,54 @@ function PanelError({ error }) {
|
|
|
69
69
|
] });
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// src/tree.ts
|
|
73
|
+
function buildIssueTree(issues, full) {
|
|
74
|
+
const byKey = new Map(issues.map((issue) => [issue.key, issue]));
|
|
75
|
+
const visible = full ? issues : issues.filter((issue) => issue.mine);
|
|
76
|
+
const visibleKeys = new Set(visible.map((issue) => issue.key));
|
|
77
|
+
const parentOf = (issue) => {
|
|
78
|
+
let key = issue.parent?.key ?? null;
|
|
79
|
+
const seen = /* @__PURE__ */ new Set([issue.key]);
|
|
80
|
+
while (key && !seen.has(key)) {
|
|
81
|
+
seen.add(key);
|
|
82
|
+
if (visibleKeys.has(key)) return key;
|
|
83
|
+
key = byKey.get(key)?.parent?.key ?? null;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
};
|
|
87
|
+
const roots = [];
|
|
88
|
+
const children = /* @__PURE__ */ new Map();
|
|
89
|
+
for (const issue of visible) {
|
|
90
|
+
const parent = parentOf(issue);
|
|
91
|
+
if (parent === null) {
|
|
92
|
+
roots.push(issue);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const siblings = children.get(parent);
|
|
96
|
+
if (siblings) siblings.push(issue);
|
|
97
|
+
else children.set(parent, [issue]);
|
|
98
|
+
}
|
|
99
|
+
const rows = [];
|
|
100
|
+
const walk = (issue, guides, last) => {
|
|
101
|
+
const kids = children.get(issue.key) ?? [];
|
|
102
|
+
rows.push({ issue, guides, last, hasChildren: kids.length > 0 });
|
|
103
|
+
kids.forEach((kid, index) => {
|
|
104
|
+
walk(kid, [...guides, !last], index === kids.length - 1);
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
roots.forEach((root, index) => {
|
|
108
|
+
walk(root, [], index === roots.length - 1);
|
|
109
|
+
});
|
|
110
|
+
return rows;
|
|
111
|
+
}
|
|
112
|
+
function treePrefix(row) {
|
|
113
|
+
const stem = row.guides.map((continues) => continues ? "\u2502 " : " ").join("");
|
|
114
|
+
return `${stem}${row.last ? "\u2514\u2500 " : "\u251C\u2500 "}`;
|
|
115
|
+
}
|
|
116
|
+
function folderMark(row) {
|
|
117
|
+
return row.hasChildren ? "\u25BE" : " ";
|
|
118
|
+
}
|
|
119
|
+
|
|
72
120
|
// src/components/IssueList.tsx
|
|
73
121
|
import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
74
122
|
function statusColor(category) {
|
|
@@ -81,10 +129,16 @@ function statusColor(category) {
|
|
|
81
129
|
return "blue";
|
|
82
130
|
}
|
|
83
131
|
}
|
|
132
|
+
function fit(text, width) {
|
|
133
|
+
if (width <= 0) return "";
|
|
134
|
+
return text.length > width ? `${text.slice(0, Math.max(0, width - 1))}\u2026` : text.padEnd(width);
|
|
135
|
+
}
|
|
84
136
|
var KEY_WIDTH = 10;
|
|
85
137
|
var STATUS_WIDTH = 14;
|
|
86
138
|
function IssueList({
|
|
87
139
|
state,
|
|
140
|
+
rows,
|
|
141
|
+
fullTree,
|
|
88
142
|
focused,
|
|
89
143
|
height,
|
|
90
144
|
width,
|
|
@@ -94,25 +148,48 @@ function IssueList({
|
|
|
94
148
|
}) {
|
|
95
149
|
const issues = state.data ?? [];
|
|
96
150
|
const visibleRows = Math.max(1, height - PANEL_CHROME_ROWS);
|
|
97
|
-
const
|
|
98
|
-
|
|
151
|
+
const gutterWidth = Math.min(
|
|
152
|
+
Math.max(0, Math.floor(width / 4)),
|
|
153
|
+
rows.reduce((widest, row) => Math.max(widest, treePrefix(row).length + 2), 0)
|
|
154
|
+
);
|
|
155
|
+
const summaryWidth = Math.max(8, width - 4 - 2 - gutterWidth - KEY_WIDTH - STATUS_WIDTH);
|
|
156
|
+
const label = fullTree ? "my tickets \xB7 full tree" : "my open tickets";
|
|
157
|
+
const base = rows.length > visibleRows ? `${label} (${selectedIndex + 1}/${rows.length})` : `${label} (${rows.length})`;
|
|
99
158
|
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:
|
|
159
|
+
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: rows.slice(offset, offset + visibleRows).map((row, index) => {
|
|
160
|
+
const issue = row.issue;
|
|
101
161
|
const selected = offset + index === selectedIndex;
|
|
162
|
+
const background = selected ? focused ? "cyan" : "blue" : void 0;
|
|
163
|
+
const cellColor = (own) => selected ? "black" : own;
|
|
102
164
|
return /* @__PURE__ */ jsxs3(Box3, { children: [
|
|
103
|
-
/* @__PURE__ */ jsx3(
|
|
104
|
-
|
|
105
|
-
/* @__PURE__ */ jsx3(
|
|
106
|
-
/* @__PURE__ */ jsx3(Box3, { width: summaryWidth, flexShrink: 1, children: /* @__PURE__ */ jsx3(
|
|
165
|
+
/* @__PURE__ */ jsx3(Text3, { color: cellColor("cyan"), backgroundColor: background, children: selected ? "\u25B8 " : " " }),
|
|
166
|
+
gutterWidth > 0 ? /* @__PURE__ */ jsx3(Text3, { color: cellColor("gray"), backgroundColor: background, children: fit(`${treePrefix(row)}${folderMark(row)} `, gutterWidth) }) : null,
|
|
167
|
+
/* @__PURE__ */ jsx3(
|
|
107
168
|
Text3,
|
|
108
169
|
{
|
|
109
|
-
color: selected ? "white" : "gray",
|
|
110
170
|
bold: selected,
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
children: issue.
|
|
171
|
+
color: cellColor(issue.mine ? "white" : "gray"),
|
|
172
|
+
backgroundColor: background,
|
|
173
|
+
children: fit(issue.key, KEY_WIDTH)
|
|
114
174
|
}
|
|
115
|
-
)
|
|
175
|
+
),
|
|
176
|
+
/* @__PURE__ */ jsx3(
|
|
177
|
+
Text3,
|
|
178
|
+
{
|
|
179
|
+
color: cellColor(statusColor(issue.statusCategory)),
|
|
180
|
+
backgroundColor: background,
|
|
181
|
+
children: fit(issue.status, STATUS_WIDTH)
|
|
182
|
+
}
|
|
183
|
+
),
|
|
184
|
+
/* @__PURE__ */ jsx3(
|
|
185
|
+
Text3,
|
|
186
|
+
{
|
|
187
|
+
color: cellColor("gray"),
|
|
188
|
+
bold: selected,
|
|
189
|
+
backgroundColor: background,
|
|
190
|
+
children: fit(issue.summary, summaryWidth)
|
|
191
|
+
}
|
|
192
|
+
)
|
|
116
193
|
] }, issue.key);
|
|
117
194
|
}) }) });
|
|
118
195
|
}
|
|
@@ -229,6 +306,45 @@ function IssuePreview({
|
|
|
229
306
|
|
|
230
307
|
// src/components/WorkChartPanel.tsx
|
|
231
308
|
import { Box as Box5, Text as Text5 } from "ink";
|
|
309
|
+
|
|
310
|
+
// src/dates.ts
|
|
311
|
+
var WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
312
|
+
var MONTHS2 = [
|
|
313
|
+
"Jan",
|
|
314
|
+
"Feb",
|
|
315
|
+
"Mar",
|
|
316
|
+
"Apr",
|
|
317
|
+
"May",
|
|
318
|
+
"Jun",
|
|
319
|
+
"Jul",
|
|
320
|
+
"Aug",
|
|
321
|
+
"Sep",
|
|
322
|
+
"Oct",
|
|
323
|
+
"Nov",
|
|
324
|
+
"Dec"
|
|
325
|
+
];
|
|
326
|
+
function parseDate(key) {
|
|
327
|
+
const [year = "1970", month = "01", day = "01"] = key.split("-");
|
|
328
|
+
return new Date(Number(year), Number(month) - 1, Number(day));
|
|
329
|
+
}
|
|
330
|
+
function formatDate(key) {
|
|
331
|
+
const [, , day = "01"] = key.split("-");
|
|
332
|
+
const date = parseDate(key);
|
|
333
|
+
return `${WEEKDAYS[date.getDay()]} ${day} ${MONTHS2[date.getMonth()]}`;
|
|
334
|
+
}
|
|
335
|
+
function isWeekend(key) {
|
|
336
|
+
const weekday = parseDate(key).getDay();
|
|
337
|
+
return weekday === 0 || weekday === 6;
|
|
338
|
+
}
|
|
339
|
+
function isToday(key) {
|
|
340
|
+
return parseDate(key).toDateString() === (/* @__PURE__ */ new Date()).toDateString();
|
|
341
|
+
}
|
|
342
|
+
function dayOfMonth(key) {
|
|
343
|
+
const [, , day = "01"] = key.split("-");
|
|
344
|
+
return day.padStart(2, "0");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// src/components/WorkChartPanel.tsx
|
|
232
348
|
import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
233
349
|
var HOURS_PER_BAR = 4;
|
|
234
350
|
var LABEL_ROWS = 1;
|
|
@@ -237,7 +353,7 @@ var FULL_BLOCK = "\u2588";
|
|
|
237
353
|
var HALF_BLOCK = "\u2584";
|
|
238
354
|
var TRACE_BLOCK = "\u2581";
|
|
239
355
|
var PANEL_SIDE_COLUMNS = 5;
|
|
240
|
-
var
|
|
356
|
+
var REST_DOT = "\xB7";
|
|
241
357
|
function chartWidthFor(dayCount, barWidth) {
|
|
242
358
|
const bars = dayCount * barWidth + Math.max(0, dayCount - 1);
|
|
243
359
|
return bars + PANEL_SIDE_COLUMNS;
|
|
@@ -249,7 +365,8 @@ function WorkChartPanel({
|
|
|
249
365
|
focused,
|
|
250
366
|
height,
|
|
251
367
|
width,
|
|
252
|
-
spinnerFrame
|
|
368
|
+
spinnerFrame,
|
|
369
|
+
dayMarks
|
|
253
370
|
}) {
|
|
254
371
|
if (state.loading && days.length === 0) {
|
|
255
372
|
return /* @__PURE__ */ jsx5(PanelFrame, { title: "logged work", focused, height, width, children: /* @__PURE__ */ jsx5(Spinner, { label: "loading\u2026", frame: spinnerFrame }) });
|
|
@@ -270,13 +387,24 @@ function WorkChartPanel({
|
|
|
270
387
|
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
388
|
return /* @__PURE__ */ jsx5(PanelFrame, { title, focused, height, width, children: /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", flexGrow: 1, justifyContent: "flex-end", children: [
|
|
272
389
|
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) =>
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
390
|
+
total > 0 && rows.map((rowFromBottom) => /* @__PURE__ */ jsx5(Text5, { children: days.map((day, index) => {
|
|
391
|
+
const rest = dayMarks.get(day.date)?.observed === true;
|
|
392
|
+
const glyph = rest && day.hours === 0 && rowFromBottom === 0 ? REST_DOT : glyphFor(day.hours, rowFromBottom);
|
|
393
|
+
return /* @__PURE__ */ jsxs5(Text5, { children: [
|
|
394
|
+
index > 0 ? " " : "",
|
|
395
|
+
/* @__PURE__ */ jsx5(Text5, { color: barColor(day, rest), children: glyph.repeat(barWidth) })
|
|
396
|
+
] }, day.date);
|
|
397
|
+
}) }, rowFromBottom)),
|
|
277
398
|
barWidth >= 2 ? /* @__PURE__ */ jsx5(Text5, { children: days.map((day, index) => /* @__PURE__ */ jsxs5(Text5, { children: [
|
|
278
399
|
index > 0 ? " " : "",
|
|
279
|
-
/* @__PURE__ */ jsx5(
|
|
400
|
+
/* @__PURE__ */ jsx5(
|
|
401
|
+
Text5,
|
|
402
|
+
{
|
|
403
|
+
color: isToday(day.date) ? "cyan" : dayMarks.get(day.date)?.observed ? "magenta" : "gray",
|
|
404
|
+
bold: isToday(day.date),
|
|
405
|
+
children: dayOfMonth(day.date)
|
|
406
|
+
}
|
|
407
|
+
)
|
|
280
408
|
] }, day.date)) }) : (
|
|
281
409
|
// One column per bar leaves no room for a label per day; a date range
|
|
282
410
|
// reads better than a row of single digits.
|
|
@@ -291,25 +419,11 @@ function glyphFor(hours, rowFromBottom) {
|
|
|
291
419
|
if (rowsFilled > 0) return TRACE_BLOCK;
|
|
292
420
|
return " ";
|
|
293
421
|
}
|
|
294
|
-
function barColor(day) {
|
|
422
|
+
function barColor(day, rest) {
|
|
423
|
+
if (rest) return "magenta";
|
|
295
424
|
if (day.hours === 0) return "gray";
|
|
296
425
|
return isWeekend(day.date) ? "blue" : "green";
|
|
297
426
|
}
|
|
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
427
|
function rangeLabel(days) {
|
|
314
428
|
const format = (key) => {
|
|
315
429
|
const [, month = "01", day = "01"] = key.split("-");
|
|
@@ -322,45 +436,294 @@ function rangeLabel(days) {
|
|
|
322
436
|
|
|
323
437
|
// src/components/WorklogPanel.tsx
|
|
324
438
|
import { Box as Box6, Text as Text6 } from "ink";
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
439
|
+
|
|
440
|
+
// src/data/calendar.ts
|
|
441
|
+
import { mkdirSync, readFileSync as readFileSync2, renameSync, writeFileSync } from "node:fs";
|
|
442
|
+
import { join as join2 } from "node:path";
|
|
443
|
+
|
|
444
|
+
// src/data/config.ts
|
|
445
|
+
import { readFileSync } from "node:fs";
|
|
446
|
+
import { homedir } from "node:os";
|
|
447
|
+
import { join } from "node:path";
|
|
448
|
+
|
|
449
|
+
// src/types.ts
|
|
450
|
+
var JiraError = class extends Error {
|
|
451
|
+
hints;
|
|
452
|
+
constructor(message, hints = []) {
|
|
453
|
+
super(message);
|
|
454
|
+
this.name = "JiraError";
|
|
455
|
+
this.hints = hints;
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
// src/data/config.ts
|
|
460
|
+
var CONFIG_DIR = join(
|
|
461
|
+
process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config"),
|
|
462
|
+
"jira-dashboard"
|
|
463
|
+
);
|
|
464
|
+
var CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
465
|
+
var TOKEN_URL = "https://id.atlassian.com/manage-profile/security/api-tokens";
|
|
466
|
+
var DEFAULT_ISSUES_JQL = [
|
|
467
|
+
"assignee = currentUser()",
|
|
468
|
+
"AND statusCategory != Done",
|
|
469
|
+
'AND status NOT IN ("Cancelled", "Closed", "Backlog")',
|
|
470
|
+
"ORDER BY updated DESC"
|
|
471
|
+
].join(" ");
|
|
472
|
+
var DEFAULT_WORKLOG_DAYS = 14;
|
|
473
|
+
function normalizeCode(value) {
|
|
474
|
+
const trimmed = value?.trim().toUpperCase();
|
|
475
|
+
return trimmed ? trimmed : null;
|
|
332
476
|
}
|
|
333
|
-
function
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
477
|
+
function readConfigFile() {
|
|
478
|
+
let raw;
|
|
479
|
+
try {
|
|
480
|
+
raw = readFileSync(CONFIG_PATH, "utf8");
|
|
481
|
+
} catch {
|
|
482
|
+
return {};
|
|
483
|
+
}
|
|
484
|
+
try {
|
|
485
|
+
const parsed = JSON.parse(raw);
|
|
486
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
487
|
+
throw new Error("expected a JSON object");
|
|
488
|
+
}
|
|
489
|
+
return parsed;
|
|
490
|
+
} catch (cause) {
|
|
491
|
+
throw new JiraError(`Config file at ${CONFIG_PATH} is not valid JSON.`, [
|
|
492
|
+
cause instanceof Error ? cause.message : String(cause),
|
|
493
|
+
"Fix the file, or delete it and use environment variables instead."
|
|
494
|
+
]);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
function normalizeBaseUrl(value) {
|
|
498
|
+
const trimmed = value.trim().replace(/\/+$/, "");
|
|
499
|
+
if (!/^https?:\/\//.test(trimmed)) {
|
|
500
|
+
throw new JiraError(`JIRA_BASE_URL must start with https:// (got "${trimmed}")`, [
|
|
501
|
+
"Example: https://your-team.atlassian.net"
|
|
502
|
+
]);
|
|
503
|
+
}
|
|
504
|
+
return trimmed;
|
|
505
|
+
}
|
|
506
|
+
function loadConfig() {
|
|
507
|
+
const file = readConfigFile();
|
|
508
|
+
const baseUrl = process.env["JIRA_BASE_URL"] ?? file.baseUrl;
|
|
509
|
+
const email = process.env["JIRA_EMAIL"] ?? file.email;
|
|
510
|
+
const apiToken = process.env["JIRA_API_TOKEN"] ?? file.apiToken;
|
|
511
|
+
const missing = [];
|
|
512
|
+
if (!baseUrl) missing.push("JIRA_BASE_URL");
|
|
513
|
+
if (!email) missing.push("JIRA_EMAIL");
|
|
514
|
+
if (!apiToken) missing.push("JIRA_API_TOKEN");
|
|
515
|
+
if (missing.length > 0 || !baseUrl || !email || !apiToken) {
|
|
516
|
+
throw new JiraError(`Missing Jira credentials: ${missing.join(", ")}`, [
|
|
517
|
+
"Set them in your shell:",
|
|
518
|
+
' export JIRA_BASE_URL="https://your-team.atlassian.net"',
|
|
519
|
+
' export JIRA_EMAIL="you@example.com"',
|
|
520
|
+
' export JIRA_API_TOKEN="..."',
|
|
521
|
+
"",
|
|
522
|
+
`Or write them to ${CONFIG_PATH}:`,
|
|
523
|
+
' { "baseUrl": "...", "email": "...", "apiToken": "..." }',
|
|
524
|
+
"",
|
|
525
|
+
`Create an API token at ${TOKEN_URL}`
|
|
526
|
+
]);
|
|
527
|
+
}
|
|
528
|
+
return {
|
|
529
|
+
baseUrl: normalizeBaseUrl(baseUrl),
|
|
530
|
+
email: email.trim(),
|
|
531
|
+
apiToken: apiToken.trim(),
|
|
532
|
+
issuesJql: file.issuesJql?.trim() || DEFAULT_ISSUES_JQL,
|
|
533
|
+
worklogDays: typeof file.worklogDays === "number" && file.worklogDays > 0 ? Math.floor(file.worklogDays) : DEFAULT_WORKLOG_DAYS,
|
|
534
|
+
holidayCountry: normalizeCode(process.env["JIRA_HOLIDAY_COUNTRY"] ?? file.holidayCountry),
|
|
535
|
+
holidayRegion: normalizeCode(process.env["JIRA_HOLIDAY_REGION"] ?? file.holidayRegion)
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// src/data/calendar.ts
|
|
540
|
+
var HOLIDAY_CACHE_PATH = join2(CONFIG_DIR, "holidays.json");
|
|
541
|
+
var DAYS_OFF_PATH = join2(CONFIG_DIR, "days-off.json");
|
|
542
|
+
var NAGER_URL = "https://date.nager.at/api/v3/PublicHolidays";
|
|
543
|
+
var REQUEST_TIMEOUT_MS = 2e4;
|
|
544
|
+
var CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
545
|
+
function isNonWorkingDay(key, marks) {
|
|
546
|
+
return isWeekend(key) || marks.get(key)?.observed === true;
|
|
547
|
+
}
|
|
548
|
+
var CACHE_VERSION = 2;
|
|
549
|
+
var DATE_KEY = /^\d{4}-\d{2}-\d{2}$/;
|
|
550
|
+
async function loadDayMarks(config, years) {
|
|
551
|
+
const marks = /* @__PURE__ */ new Map();
|
|
552
|
+
const regional = /* @__PURE__ */ new Map();
|
|
553
|
+
for (const holiday of await loadHolidays(config, years)) {
|
|
554
|
+
const counties = holiday.counties ?? [];
|
|
555
|
+
const observed = counties.length === 0 || config.holidayRegion !== null && counties.includes(config.holidayRegion);
|
|
556
|
+
if (observed) {
|
|
557
|
+
marks.set(holiday.date, { kind: "holiday", label: holiday.name, observed: true });
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
const group = regional.get(holiday.date) ?? { names: [], counties: [] };
|
|
561
|
+
if (!group.names.includes(holiday.name)) group.names.push(holiday.name);
|
|
562
|
+
for (const county of counties) if (!group.counties.includes(county)) group.counties.push(county);
|
|
563
|
+
regional.set(holiday.date, group);
|
|
564
|
+
}
|
|
565
|
+
for (const [date, { names, counties }] of regional) {
|
|
566
|
+
if (marks.has(date)) continue;
|
|
567
|
+
marks.set(date, {
|
|
568
|
+
kind: "holiday",
|
|
569
|
+
label: `${names.join(" / ")} \xB7 ${counties.join(", ")}`,
|
|
570
|
+
observed: false
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
for (const [date, label] of readDaysOff()) {
|
|
574
|
+
marks.set(date, { kind: "dayoff", label, observed: true });
|
|
575
|
+
}
|
|
576
|
+
return marks;
|
|
577
|
+
}
|
|
578
|
+
async function loadHolidays(config, years) {
|
|
579
|
+
const country = config.holidayCountry;
|
|
580
|
+
if (!country) return [];
|
|
581
|
+
const cached2 = readCache();
|
|
582
|
+
const cache = cached2 && cached2.country === country ? cached2 : { version: CACHE_VERSION, country, years: {} };
|
|
583
|
+
const stale = years.filter((year) => !isFresh(cache.years[String(year)]));
|
|
584
|
+
const fetched = await Promise.all(
|
|
585
|
+
stale.map(async (year) => [year, await fetchYear(country, year)])
|
|
586
|
+
);
|
|
587
|
+
let changed = false;
|
|
588
|
+
for (const [year, days] of fetched) {
|
|
589
|
+
if (!days) continue;
|
|
590
|
+
cache.years[String(year)] = { fetchedAt: (/* @__PURE__ */ new Date()).toISOString(), days };
|
|
591
|
+
changed = true;
|
|
592
|
+
}
|
|
593
|
+
if (changed || cached2 !== cache) writeCache(cache);
|
|
594
|
+
return years.flatMap((year) => cache.years[String(year)]?.days ?? []);
|
|
595
|
+
}
|
|
596
|
+
function isFresh(entry) {
|
|
597
|
+
if (!entry) return false;
|
|
598
|
+
const age = Date.now() - Date.parse(entry.fetchedAt);
|
|
599
|
+
return Number.isFinite(age) && age >= 0 && age < CACHE_TTL_MS;
|
|
600
|
+
}
|
|
601
|
+
async function fetchYear(country, year) {
|
|
602
|
+
try {
|
|
603
|
+
const response = await fetch(`${NAGER_URL}/${year}/${country}`, {
|
|
604
|
+
headers: { Accept: "application/json" },
|
|
605
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
606
|
+
});
|
|
607
|
+
if (!response.ok) return null;
|
|
608
|
+
const body = await response.json();
|
|
609
|
+
if (!Array.isArray(body)) return null;
|
|
610
|
+
const days = [];
|
|
611
|
+
for (const entry of body) {
|
|
612
|
+
const date = entry.date;
|
|
613
|
+
if (typeof date !== "string" || !DATE_KEY.test(date)) continue;
|
|
614
|
+
const counties = entry.global === true || !Array.isArray(entry.counties) ? [] : entry.counties.filter((county) => typeof county === "string");
|
|
615
|
+
const name = typeof entry.localName === "string" ? entry.localName : entry.name;
|
|
616
|
+
days.push({ date, name: typeof name === "string" ? name : "holiday", counties });
|
|
617
|
+
}
|
|
618
|
+
return days;
|
|
619
|
+
} catch {
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
function readCache() {
|
|
624
|
+
let parsed;
|
|
625
|
+
try {
|
|
626
|
+
parsed = JSON.parse(readFileSync2(HOLIDAY_CACHE_PATH, "utf8"));
|
|
627
|
+
} catch {
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
631
|
+
const { version: version2, country, years } = parsed;
|
|
632
|
+
if (version2 !== CACHE_VERSION) return null;
|
|
633
|
+
if (typeof years !== "object" || years === null) return null;
|
|
634
|
+
return {
|
|
635
|
+
version: CACHE_VERSION,
|
|
636
|
+
country: typeof country === "string" ? country : null,
|
|
637
|
+
years
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
function writeCache(cache) {
|
|
641
|
+
const temp = `${HOLIDAY_CACHE_PATH}.tmp`;
|
|
642
|
+
try {
|
|
643
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
644
|
+
writeFileSync(temp, `${JSON.stringify(cache, null, 2)}
|
|
645
|
+
`, "utf8");
|
|
646
|
+
renameSync(temp, HOLIDAY_CACHE_PATH);
|
|
647
|
+
} catch {
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
function readDaysOff() {
|
|
651
|
+
let parsed;
|
|
652
|
+
try {
|
|
653
|
+
parsed = JSON.parse(readFileSync2(DAYS_OFF_PATH, "utf8"));
|
|
654
|
+
} catch {
|
|
655
|
+
return [];
|
|
656
|
+
}
|
|
657
|
+
const list = Array.isArray(parsed) ? parsed : typeof parsed === "object" && parsed !== null && Array.isArray(parsed.dates) ? parsed.dates : [];
|
|
658
|
+
const days = [];
|
|
659
|
+
for (const entry of list) {
|
|
660
|
+
if (typeof entry === "string") {
|
|
661
|
+
if (DATE_KEY.test(entry)) days.push([entry, "day off"]);
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
665
|
+
const { date, label } = entry;
|
|
666
|
+
if (typeof date !== "string" || !DATE_KEY.test(date)) continue;
|
|
667
|
+
days.push([date, typeof label === "string" && label.trim() ? label.trim() : "day off"]);
|
|
668
|
+
}
|
|
669
|
+
return days;
|
|
337
670
|
}
|
|
338
|
-
|
|
339
|
-
|
|
671
|
+
|
|
672
|
+
// src/components/WorklogPanel.tsx
|
|
673
|
+
import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
674
|
+
var DATE_WIDTH = 14;
|
|
675
|
+
function hoursColor(hours, nonWorking) {
|
|
676
|
+
if (hours === 0) return nonWorking ? "gray" : "red";
|
|
340
677
|
if (hours < 6) return "yellow";
|
|
341
678
|
return "green";
|
|
342
679
|
}
|
|
680
|
+
function markGlyph(mark) {
|
|
681
|
+
if (!mark) return "";
|
|
682
|
+
if (mark.kind === "dayoff") return "\xB7";
|
|
683
|
+
return mark.observed ? "*" : "~";
|
|
684
|
+
}
|
|
685
|
+
function markBadge(mark) {
|
|
686
|
+
return mark?.kind === "holiday" ? "\u{1F389}" : "";
|
|
687
|
+
}
|
|
343
688
|
function WorklogPanel({
|
|
344
689
|
state,
|
|
345
690
|
focused,
|
|
346
691
|
height,
|
|
347
692
|
width,
|
|
348
693
|
offset,
|
|
349
|
-
spinnerFrame
|
|
694
|
+
spinnerFrame,
|
|
695
|
+
dayMarks
|
|
350
696
|
}) {
|
|
351
697
|
const days = state.data?.days ?? [];
|
|
352
698
|
const visibleRows = Math.max(1, height - PANEL_CHROME_ROWS);
|
|
353
699
|
const total = days.reduce((sum, day) => sum + day.hours, 0);
|
|
354
|
-
const keysWidth = Math.max(6, width - 4 -
|
|
700
|
+
const keysWidth = Math.max(6, width - 4 - DATE_WIDTH - 7);
|
|
355
701
|
const base = days.length > 0 ? `last ${days.length} days \xB7 ${total.toFixed(1)}h total` : `last ${days.length || 14} days`;
|
|
356
702
|
const title = state.loading && days.length > 0 ? `${spinnerChar(spinnerFrame)} ${base}` : base;
|
|
357
703
|
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
|
|
359
|
-
const
|
|
704
|
+
const mark = dayMarks.get(day.date);
|
|
705
|
+
const weekend = isWeekend(day.date);
|
|
706
|
+
const nonWorking = isNonWorkingDay(day.date, dayMarks);
|
|
707
|
+
const keys = day.issueKeys.length > 0 ? day.issueKeys.join(", ") : mark ? `(${mark.label})` : "\u2014";
|
|
360
708
|
return /* @__PURE__ */ jsxs6(Box6, { children: [
|
|
361
|
-
/* @__PURE__ */ jsx6(Box6, { width:
|
|
362
|
-
|
|
363
|
-
|
|
709
|
+
/* @__PURE__ */ jsx6(Box6, { width: DATE_WIDTH, flexShrink: 0, children: /* @__PURE__ */ jsx6(
|
|
710
|
+
Text6,
|
|
711
|
+
{
|
|
712
|
+
color: mark ? "magenta" : weekend ? "gray" : "white",
|
|
713
|
+
dimColor: mark !== void 0 && !mark.observed,
|
|
714
|
+
children: formatDate(day.date) + markGlyph(mark) + markBadge(mark)
|
|
715
|
+
}
|
|
716
|
+
) }),
|
|
717
|
+
/* @__PURE__ */ jsx6(Box6, { width: 7, flexShrink: 0, children: /* @__PURE__ */ jsx6(Text6, { color: hoursColor(day.hours, nonWorking), children: `${day.hours.toFixed(1)}h`.padStart(6) }) }),
|
|
718
|
+
/* @__PURE__ */ jsx6(Box6, { width: keysWidth, flexShrink: 1, children: /* @__PURE__ */ jsx6(
|
|
719
|
+
Text6,
|
|
720
|
+
{
|
|
721
|
+
color: mark && day.issueKeys.length === 0 ? "magenta" : "gray",
|
|
722
|
+
dimColor: mark !== void 0 && !mark.observed,
|
|
723
|
+
wrap: "truncate-end",
|
|
724
|
+
children: keys
|
|
725
|
+
}
|
|
726
|
+
) })
|
|
364
727
|
] }, day.date);
|
|
365
728
|
}) }) });
|
|
366
729
|
}
|
|
@@ -509,6 +872,7 @@ var FIELDS = [
|
|
|
509
872
|
"aggregatetimespent",
|
|
510
873
|
"timespent"
|
|
511
874
|
];
|
|
875
|
+
var MAX_ANCESTOR_ROUNDS = 4;
|
|
512
876
|
async function fetchOpenIssues(client, config) {
|
|
513
877
|
const sprintFieldId = await resolveSprintFieldId(client);
|
|
514
878
|
const response = await client.post("/rest/api/3/search/jql", {
|
|
@@ -516,9 +880,38 @@ async function fetchOpenIssues(client, config) {
|
|
|
516
880
|
maxResults: MAX_ISSUES,
|
|
517
881
|
fields: sprintFieldId ? [...FIELDS, sprintFieldId] : FIELDS
|
|
518
882
|
});
|
|
519
|
-
|
|
883
|
+
const issues = (response.issues ?? []).map((raw) => toIssue(raw, client, sprintFieldId, true));
|
|
884
|
+
const ancestors = await fetchAncestors(client, issues, sprintFieldId);
|
|
885
|
+
return [...issues, ...ancestors];
|
|
886
|
+
}
|
|
887
|
+
async function fetchAncestors(client, issues, sprintFieldId) {
|
|
888
|
+
const known = new Set(issues.map((issue) => issue.key));
|
|
889
|
+
const collected = [];
|
|
890
|
+
let frontier = issues;
|
|
891
|
+
for (let round = 0; round < MAX_ANCESTOR_ROUNDS; round += 1) {
|
|
892
|
+
const missing = [
|
|
893
|
+
...new Set(
|
|
894
|
+
frontier.map((issue) => issue.parent?.key).filter((key) => Boolean(key) && !known.has(key))
|
|
895
|
+
)
|
|
896
|
+
];
|
|
897
|
+
if (missing.length === 0) break;
|
|
898
|
+
for (const key of missing) known.add(key);
|
|
899
|
+
const fetched = await fetchByKeys(client, missing, sprintFieldId);
|
|
900
|
+
if (fetched.length === 0) break;
|
|
901
|
+
collected.push(...fetched);
|
|
902
|
+
frontier = fetched;
|
|
903
|
+
}
|
|
904
|
+
return collected;
|
|
905
|
+
}
|
|
906
|
+
async function fetchByKeys(client, keys, sprintFieldId) {
|
|
907
|
+
const response = await client.post("/rest/api/3/search/jql", {
|
|
908
|
+
jql: `key in (${keys.join(",")})`,
|
|
909
|
+
maxResults: keys.length,
|
|
910
|
+
fields: sprintFieldId ? [...FIELDS, sprintFieldId] : FIELDS
|
|
911
|
+
});
|
|
912
|
+
return (response.issues ?? []).map((raw) => toIssue(raw, client, sprintFieldId, false));
|
|
520
913
|
}
|
|
521
|
-
function toIssue(raw, client, sprintFieldId) {
|
|
914
|
+
function toIssue(raw, client, sprintFieldId, mine) {
|
|
522
915
|
const fields = raw.fields ?? {};
|
|
523
916
|
return {
|
|
524
917
|
key: raw.key,
|
|
@@ -540,7 +933,8 @@ function toIssue(raw, client, sprintFieldId) {
|
|
|
540
933
|
issueType: fields.parent.fields?.issuetype?.name ?? ""
|
|
541
934
|
} : null,
|
|
542
935
|
totalTimeSpentSeconds: fields.aggregatetimespent ?? fields.timespent ?? null,
|
|
543
|
-
sprints: sprintFieldId ? sprintNames(fields[sprintFieldId]) : []
|
|
936
|
+
sprints: sprintFieldId ? sprintNames(fields[sprintFieldId]) : [],
|
|
937
|
+
mine
|
|
544
938
|
};
|
|
545
939
|
}
|
|
546
940
|
|
|
@@ -752,16 +1146,6 @@ async function openUrl(url) {
|
|
|
752
1146
|
});
|
|
753
1147
|
}
|
|
754
1148
|
|
|
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
1149
|
// src/app.tsx
|
|
766
1150
|
import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
|
|
767
1151
|
var PANEL_ORDER = ["worklog", "issues", "preview"];
|
|
@@ -791,6 +1175,8 @@ function App({ config, client, me }) {
|
|
|
791
1175
|
const [spinnerFrame, setSpinnerFrame] = useState2(0);
|
|
792
1176
|
const [reloadToken, setReloadToken] = useState2(0);
|
|
793
1177
|
const [notice, setNotice] = useState2(null);
|
|
1178
|
+
const [dayMarks, setDayMarks] = useState2(() => /* @__PURE__ */ new Map());
|
|
1179
|
+
const [fullTree, setFullTree] = useState2(false);
|
|
794
1180
|
const topHeight = Math.max(MIN_TOP_HEIGHT, Math.floor((rows - 1) / 2));
|
|
795
1181
|
const bottomHeight = Math.max(MIN_TOP_HEIGHT, rows - 1 - topHeight);
|
|
796
1182
|
const leftWidth = Math.max(MIN_LEFT_WIDTH, Math.min(52, Math.floor(columns * 0.42)));
|
|
@@ -819,6 +1205,19 @@ function App({ config, client, me }) {
|
|
|
819
1205
|
cancelled = true;
|
|
820
1206
|
};
|
|
821
1207
|
}, [client, config, me, reloadToken]);
|
|
1208
|
+
useEffect4(() => {
|
|
1209
|
+
let cancelled = false;
|
|
1210
|
+
const today = /* @__PURE__ */ new Date();
|
|
1211
|
+
const oldest = new Date(today);
|
|
1212
|
+
oldest.setDate(oldest.getDate() - config.worklogDays);
|
|
1213
|
+
const years = [.../* @__PURE__ */ new Set([oldest.getFullYear(), today.getFullYear()])];
|
|
1214
|
+
void loadDayMarks(config, years).then((marks) => {
|
|
1215
|
+
if (!cancelled) setDayMarks(marks);
|
|
1216
|
+
});
|
|
1217
|
+
return () => {
|
|
1218
|
+
cancelled = true;
|
|
1219
|
+
};
|
|
1220
|
+
}, [config, reloadToken]);
|
|
822
1221
|
const loading = worklogs.loading || issues.loading;
|
|
823
1222
|
useEffect4(() => {
|
|
824
1223
|
if (!loading) return;
|
|
@@ -829,11 +1228,21 @@ function App({ config, client, me }) {
|
|
|
829
1228
|
clearInterval(timer);
|
|
830
1229
|
};
|
|
831
1230
|
}, [loading]);
|
|
832
|
-
const
|
|
1231
|
+
const keepSelectedKey = useRef2(null);
|
|
1232
|
+
const issueRowsData = useMemo(
|
|
1233
|
+
() => buildIssueTree(issues.data ?? [], fullTree),
|
|
1234
|
+
[issues.data, fullTree]
|
|
1235
|
+
);
|
|
1236
|
+
const issueList = useMemo(() => issueRowsData.map((row) => row.issue), [issueRowsData]);
|
|
833
1237
|
const selected = issueList[selectedIndex] ?? null;
|
|
834
1238
|
useEffect4(() => {
|
|
835
|
-
|
|
836
|
-
|
|
1239
|
+
const wanted = keepSelectedKey.current;
|
|
1240
|
+
keepSelectedKey.current = null;
|
|
1241
|
+
const target = wanted === null ? -1 : issueList.findIndex((issue) => issue.key === wanted);
|
|
1242
|
+
setSelectedIndex(
|
|
1243
|
+
(index) => target >= 0 ? target : Math.min(index, Math.max(0, issueList.length - 1))
|
|
1244
|
+
);
|
|
1245
|
+
}, [issueList]);
|
|
837
1246
|
useEffect4(() => {
|
|
838
1247
|
setWorklogOffset(Math.max(0, (worklogs.data?.days.length ?? 0) - worklogRows));
|
|
839
1248
|
}, [worklogs.data, worklogRows]);
|
|
@@ -853,6 +1262,13 @@ function App({ config, client, me }) {
|
|
|
853
1262
|
},
|
|
854
1263
|
[issueList.length, issueRows]
|
|
855
1264
|
);
|
|
1265
|
+
useEffect4(() => {
|
|
1266
|
+
setIssueOffset((offset) => {
|
|
1267
|
+
if (selectedIndex < offset) return selectedIndex;
|
|
1268
|
+
if (selectedIndex >= offset + issueRows) return selectedIndex - issueRows + 1;
|
|
1269
|
+
return Math.min(offset, Math.max(0, issueList.length - issueRows));
|
|
1270
|
+
});
|
|
1271
|
+
}, [selectedIndex, issueRows, issueList.length]);
|
|
856
1272
|
const scrollPanel = useCallback(
|
|
857
1273
|
(panel, delta) => {
|
|
858
1274
|
switch (panel) {
|
|
@@ -958,6 +1374,11 @@ function App({ config, client, me }) {
|
|
|
958
1374
|
exit();
|
|
959
1375
|
return;
|
|
960
1376
|
}
|
|
1377
|
+
if (input === "t") {
|
|
1378
|
+
keepSelectedKey.current = selected?.key ?? null;
|
|
1379
|
+
setFullTree((current) => !current);
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
961
1382
|
if (input === "r") {
|
|
962
1383
|
setReloadToken((token) => token + 1);
|
|
963
1384
|
return;
|
|
@@ -984,7 +1405,7 @@ function App({ config, client, me }) {
|
|
|
984
1405
|
else if (input === "G") selectIndex(issueList.length - 1);
|
|
985
1406
|
});
|
|
986
1407
|
const hint = useMemo(
|
|
987
|
-
() => focus === "issues" ? "\u2191\u2193/jk move \xB7
|
|
1408
|
+
() => focus === "issues" ? "\u2191\u2193/jk move \xB7 enter open \xB7 t tree \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
1409
|
[focus]
|
|
989
1410
|
);
|
|
990
1411
|
return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", width: columns, height: rows, children: [
|
|
@@ -997,13 +1418,16 @@ function App({ config, client, me }) {
|
|
|
997
1418
|
height: topHeight,
|
|
998
1419
|
width: leftWidth,
|
|
999
1420
|
offset: worklogOffset,
|
|
1000
|
-
spinnerFrame
|
|
1421
|
+
spinnerFrame,
|
|
1422
|
+
dayMarks
|
|
1001
1423
|
}
|
|
1002
1424
|
),
|
|
1003
1425
|
/* @__PURE__ */ jsx7(
|
|
1004
1426
|
IssueList,
|
|
1005
1427
|
{
|
|
1006
1428
|
state: issues,
|
|
1429
|
+
rows: issueRowsData,
|
|
1430
|
+
fullTree,
|
|
1007
1431
|
focused: focus === "issues",
|
|
1008
1432
|
height: topHeight,
|
|
1009
1433
|
width: columns - leftWidth,
|
|
@@ -1023,7 +1447,8 @@ function App({ config, client, me }) {
|
|
|
1023
1447
|
focused: false,
|
|
1024
1448
|
height: bottomHeight,
|
|
1025
1449
|
width: chartWidth,
|
|
1026
|
-
spinnerFrame
|
|
1450
|
+
spinnerFrame,
|
|
1451
|
+
dayMarks
|
|
1027
1452
|
}
|
|
1028
1453
|
),
|
|
1029
1454
|
/* @__PURE__ */ jsx7(
|
|
@@ -1062,7 +1487,7 @@ function App({ config, client, me }) {
|
|
|
1062
1487
|
}
|
|
1063
1488
|
|
|
1064
1489
|
// src/data/client.ts
|
|
1065
|
-
var
|
|
1490
|
+
var REQUEST_TIMEOUT_MS2 = 2e4;
|
|
1066
1491
|
var JiraClient = class {
|
|
1067
1492
|
baseUrl;
|
|
1068
1493
|
authHeader;
|
|
@@ -1095,7 +1520,7 @@ var JiraClient = class {
|
|
|
1095
1520
|
...body === void 0 ? {} : { "Content-Type": "application/json" }
|
|
1096
1521
|
},
|
|
1097
1522
|
...body === void 0 ? {} : { body: JSON.stringify(body) },
|
|
1098
|
-
signal: AbortSignal.timeout(
|
|
1523
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS2)
|
|
1099
1524
|
});
|
|
1100
1525
|
} catch (cause) {
|
|
1101
1526
|
throw asNetworkError(cause, this.baseUrl);
|
|
@@ -1166,83 +1591,6 @@ async function readErrorDetail(response) {
|
|
|
1166
1591
|
}
|
|
1167
1592
|
}
|
|
1168
1593
|
|
|
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
1594
|
// src/cli.tsx
|
|
1247
1595
|
import { jsx as jsx8 } from "react/jsx-runtime";
|
|
1248
1596
|
var require2 = createRequire(import.meta.url);
|
|
@@ -1265,6 +1613,9 @@ var HELP = `
|
|
|
1265
1613
|
JIRA_BASE_URL https://your-team.atlassian.net
|
|
1266
1614
|
JIRA_EMAIL your Atlassian account email
|
|
1267
1615
|
JIRA_API_TOKEN https://id.atlassian.com/manage-profile/security/api-tokens
|
|
1616
|
+
JIRA_HOLIDAY_COUNTRY ISO country code for statutory holidays, e.g. UA
|
|
1617
|
+
JIRA_HOLIDAY_REGION your subdivision, e.g. CA-ON \u2014 regional holidays are
|
|
1618
|
+
shown either way, but only count as days off with it
|
|
1268
1619
|
|
|
1269
1620
|
Or ${CONFIG_PATH}:
|
|
1270
1621
|
{
|
|
@@ -1272,9 +1623,16 @@ var HELP = `
|
|
|
1272
1623
|
"email": "you@example.com",
|
|
1273
1624
|
"apiToken": "...",
|
|
1274
1625
|
"issuesJql": ${JSON.stringify(DEFAULT_ISSUES_JQL)},
|
|
1275
|
-
"worklogDays": 14
|
|
1626
|
+
"worklogDays": 14,
|
|
1627
|
+
"holidayCountry": "UA"
|
|
1276
1628
|
}
|
|
1277
1629
|
|
|
1630
|
+
Personal leave goes in ${DAYS_OFF_PATH}:
|
|
1631
|
+
["2026-08-10", { "date": "2026-08-11", "label": "moving day" }]
|
|
1632
|
+
|
|
1633
|
+
Holidays are fetched from date.nager.at and cached next to the config;
|
|
1634
|
+
changing the country invalidates the cache.
|
|
1635
|
+
|
|
1278
1636
|
Keys
|
|
1279
1637
|
\u2191 \u2193 / j k move within the focused panel
|
|
1280
1638
|
home / end jump to the first / last item
|