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/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 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})`;
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: issues.slice(offset, offset + visibleRows).map((issue, index) => {
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(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(
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
- inverse: selected && focused,
112
- wrap: "truncate-end",
113
- children: issue.summary
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 MONTHS2 = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
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) => /* @__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)),
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(Text5, { color: isToday(day.date) ? "cyan" : "gray", bold: isToday(day.date), children: dayOfMonth(day.date) })
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
- 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()]}`;
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 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;
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
- function hoursColor(hours, weekend) {
339
- if (hours === 0) return weekend ? "gray" : "red";
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 - 12 - 7);
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 weekend = isWeekend2(day.date);
359
- const keys = day.issueKeys.length > 0 ? day.issueKeys.join(", ") : "\u2014";
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: 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 }) })
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
- return (response.issues ?? []).map((raw) => toIssue(raw, client, sprintFieldId));
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 issueList = issues.data ?? [];
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
- setSelectedIndex((index) => Math.min(index, Math.max(0, issueList.length - 1)));
836
- }, [issueList.length]);
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 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",
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 REQUEST_TIMEOUT_MS = 2e4;
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(REQUEST_TIMEOUT_MS)
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