jira-dashboard 0.1.2 → 0.1.3

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 CHANGED
@@ -56,13 +56,45 @@ Or write `~/.config/jira-dashboard/config.json` (environment variables win):
56
56
  "email": "you@example.com",
57
57
  "apiToken": "…",
58
58
  "issuesJql": "assignee = currentUser() AND statusCategory != Done AND status NOT IN (\"Cancelled\", \"Closed\", \"Backlog\") ORDER BY updated DESC",
59
- "worklogDays": 14
59
+ "worklogDays": 14,
60
+ "holidayCountry": "UA",
61
+ "holidayRegion": null
60
62
  }
61
63
  ```
62
64
 
63
65
  `issuesJql` is worth customising: status names vary per site, and some boards model the backlog
64
66
  by sprint rather than by status — in that case append `AND sprint IS NOT EMPTY`.
65
67
 
68
+ ### Non-working days
69
+
70
+ `holidayCountry` is an ISO-3166 alpha-2 code (`UA`, `PL`, `US`, …). Its statutory holidays are
71
+ fetched from [date.nager.at](https://date.nager.at) and cached in
72
+ `~/.config/jira-dashboard/holidays.json`; changing the country invalidates that cache, and each
73
+ year is refreshed after 30 days. Both keys can be overridden with `JIRA_HOLIDAY_COUNTRY` and
74
+ `JIRA_HOLIDAY_REGION`. If the fetch fails the dashboard falls back to the cache and starts
75
+ regardless.
76
+
77
+ Many holidays are provincial or state-level rather than nationwide — Canada's Civic Holiday on
78
+ the first Monday of August, for instance. Those are **shown rather than skipped**, but dimmed and
79
+ marked `~` instead of `*`, listing the regions that observe them; they do not excuse an empty
80
+ timesheet. Set `holidayRegion` (`"CA-ON"`, `"US-CA"`, …) and the ones covering your region become
81
+ full days off:
82
+
83
+ ```
84
+ Mon 03 Aug~🎉 0.0h (Civic Holiday / British Columbia Day … · CA-ON, …) # region unset
85
+ Mon 03 Aug*🎉 0.0h (Civic Holiday) # holidayRegion CA-ON
86
+ ```
87
+
88
+ Personal leave goes in `~/.config/jira-dashboard/days-off.json`, which you maintain by hand:
89
+
90
+ ```json
91
+ ["2026-08-10", { "date": "2026-08-11", "label": "moving day" }]
92
+ ```
93
+
94
+ Both kinds of day show up in magenta in the two left panels — holidays marked `*🎉`, days off `·`,
95
+ holidays observed only in other regions dimmed and marked `~🎉` — and an empty day off is never
96
+ coloured red, so what is left in red is what still needs hours.
97
+
66
98
  ## Keys
67
99
 
68
100
  | Key | Action |
package/dist/cli.js CHANGED
@@ -229,6 +229,45 @@ function IssuePreview({
229
229
 
230
230
  // src/components/WorkChartPanel.tsx
231
231
  import { Box as Box5, Text as Text5 } from "ink";
232
+
233
+ // src/dates.ts
234
+ var WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
235
+ var MONTHS2 = [
236
+ "Jan",
237
+ "Feb",
238
+ "Mar",
239
+ "Apr",
240
+ "May",
241
+ "Jun",
242
+ "Jul",
243
+ "Aug",
244
+ "Sep",
245
+ "Oct",
246
+ "Nov",
247
+ "Dec"
248
+ ];
249
+ function parseDate(key) {
250
+ const [year = "1970", month = "01", day = "01"] = key.split("-");
251
+ return new Date(Number(year), Number(month) - 1, Number(day));
252
+ }
253
+ function formatDate(key) {
254
+ const [, , day = "01"] = key.split("-");
255
+ const date = parseDate(key);
256
+ return `${WEEKDAYS[date.getDay()]} ${day} ${MONTHS2[date.getMonth()]}`;
257
+ }
258
+ function isWeekend(key) {
259
+ const weekday = parseDate(key).getDay();
260
+ return weekday === 0 || weekday === 6;
261
+ }
262
+ function isToday(key) {
263
+ return parseDate(key).toDateString() === (/* @__PURE__ */ new Date()).toDateString();
264
+ }
265
+ function dayOfMonth(key) {
266
+ const [, , day = "01"] = key.split("-");
267
+ return day.padStart(2, "0");
268
+ }
269
+
270
+ // src/components/WorkChartPanel.tsx
232
271
  import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
233
272
  var HOURS_PER_BAR = 4;
234
273
  var LABEL_ROWS = 1;
@@ -237,7 +276,7 @@ var FULL_BLOCK = "\u2588";
237
276
  var HALF_BLOCK = "\u2584";
238
277
  var TRACE_BLOCK = "\u2581";
239
278
  var PANEL_SIDE_COLUMNS = 5;
240
- var MONTHS2 = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
279
+ var REST_DOT = "\xB7";
241
280
  function chartWidthFor(dayCount, barWidth) {
242
281
  const bars = dayCount * barWidth + Math.max(0, dayCount - 1);
243
282
  return bars + PANEL_SIDE_COLUMNS;
@@ -249,7 +288,8 @@ function WorkChartPanel({
249
288
  focused,
250
289
  height,
251
290
  width,
252
- spinnerFrame
291
+ spinnerFrame,
292
+ dayMarks
253
293
  }) {
254
294
  if (state.loading && days.length === 0) {
255
295
  return /* @__PURE__ */ jsx5(PanelFrame, { title: "logged work", focused, height, width, children: /* @__PURE__ */ jsx5(Spinner, { label: "loading\u2026", frame: spinnerFrame }) });
@@ -270,13 +310,24 @@ function WorkChartPanel({
270
310
  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
311
  return /* @__PURE__ */ jsx5(PanelFrame, { title, focused, height, width, children: /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", flexGrow: 1, justifyContent: "flex-end", children: [
272
312
  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)),
313
+ total > 0 && rows.map((rowFromBottom) => /* @__PURE__ */ jsx5(Text5, { children: days.map((day, index) => {
314
+ const rest = dayMarks.get(day.date)?.observed === true;
315
+ const glyph = rest && day.hours === 0 && rowFromBottom === 0 ? REST_DOT : glyphFor(day.hours, rowFromBottom);
316
+ return /* @__PURE__ */ jsxs5(Text5, { children: [
317
+ index > 0 ? " " : "",
318
+ /* @__PURE__ */ jsx5(Text5, { color: barColor(day, rest), children: glyph.repeat(barWidth) })
319
+ ] }, day.date);
320
+ }) }, rowFromBottom)),
277
321
  barWidth >= 2 ? /* @__PURE__ */ jsx5(Text5, { children: days.map((day, index) => /* @__PURE__ */ jsxs5(Text5, { children: [
278
322
  index > 0 ? " " : "",
279
- /* @__PURE__ */ jsx5(Text5, { color: isToday(day.date) ? "cyan" : "gray", bold: isToday(day.date), children: dayOfMonth(day.date) })
323
+ /* @__PURE__ */ jsx5(
324
+ Text5,
325
+ {
326
+ color: isToday(day.date) ? "cyan" : dayMarks.get(day.date)?.observed ? "magenta" : "gray",
327
+ bold: isToday(day.date),
328
+ children: dayOfMonth(day.date)
329
+ }
330
+ )
280
331
  ] }, day.date)) }) : (
281
332
  // One column per bar leaves no room for a label per day; a date range
282
333
  // reads better than a row of single digits.
@@ -291,25 +342,11 @@ function glyphFor(hours, rowFromBottom) {
291
342
  if (rowsFilled > 0) return TRACE_BLOCK;
292
343
  return " ";
293
344
  }
294
- function barColor(day) {
345
+ function barColor(day, rest) {
346
+ if (rest) return "magenta";
295
347
  if (day.hours === 0) return "gray";
296
348
  return isWeekend(day.date) ? "blue" : "green";
297
349
  }
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
350
  function rangeLabel(days) {
314
351
  const format = (key) => {
315
352
  const [, month = "01", day = "01"] = key.split("-");
@@ -322,45 +359,294 @@ function rangeLabel(days) {
322
359
 
323
360
  // src/components/WorklogPanel.tsx
324
361
  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()]}`;
362
+
363
+ // src/data/calendar.ts
364
+ import { mkdirSync, readFileSync as readFileSync2, renameSync, writeFileSync } from "node:fs";
365
+ import { join as join2 } from "node:path";
366
+
367
+ // src/data/config.ts
368
+ import { readFileSync } from "node:fs";
369
+ import { homedir } from "node:os";
370
+ import { join } from "node:path";
371
+
372
+ // src/types.ts
373
+ var JiraError = class extends Error {
374
+ hints;
375
+ constructor(message, hints = []) {
376
+ super(message);
377
+ this.name = "JiraError";
378
+ this.hints = hints;
379
+ }
380
+ };
381
+
382
+ // src/data/config.ts
383
+ var CONFIG_DIR = join(
384
+ process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config"),
385
+ "jira-dashboard"
386
+ );
387
+ var CONFIG_PATH = join(CONFIG_DIR, "config.json");
388
+ var TOKEN_URL = "https://id.atlassian.com/manage-profile/security/api-tokens";
389
+ var DEFAULT_ISSUES_JQL = [
390
+ "assignee = currentUser()",
391
+ "AND statusCategory != Done",
392
+ 'AND status NOT IN ("Cancelled", "Closed", "Backlog")',
393
+ "ORDER BY updated DESC"
394
+ ].join(" ");
395
+ var DEFAULT_WORKLOG_DAYS = 14;
396
+ function normalizeCode(value) {
397
+ const trimmed = value?.trim().toUpperCase();
398
+ return trimmed ? trimmed : null;
332
399
  }
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;
400
+ function readConfigFile() {
401
+ let raw;
402
+ try {
403
+ raw = readFileSync(CONFIG_PATH, "utf8");
404
+ } catch {
405
+ return {};
406
+ }
407
+ try {
408
+ const parsed = JSON.parse(raw);
409
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
410
+ throw new Error("expected a JSON object");
411
+ }
412
+ return parsed;
413
+ } catch (cause) {
414
+ throw new JiraError(`Config file at ${CONFIG_PATH} is not valid JSON.`, [
415
+ cause instanceof Error ? cause.message : String(cause),
416
+ "Fix the file, or delete it and use environment variables instead."
417
+ ]);
418
+ }
419
+ }
420
+ function normalizeBaseUrl(value) {
421
+ const trimmed = value.trim().replace(/\/+$/, "");
422
+ if (!/^https?:\/\//.test(trimmed)) {
423
+ throw new JiraError(`JIRA_BASE_URL must start with https:// (got "${trimmed}")`, [
424
+ "Example: https://your-team.atlassian.net"
425
+ ]);
426
+ }
427
+ return trimmed;
428
+ }
429
+ function loadConfig() {
430
+ const file = readConfigFile();
431
+ const baseUrl = process.env["JIRA_BASE_URL"] ?? file.baseUrl;
432
+ const email = process.env["JIRA_EMAIL"] ?? file.email;
433
+ const apiToken = process.env["JIRA_API_TOKEN"] ?? file.apiToken;
434
+ const missing = [];
435
+ if (!baseUrl) missing.push("JIRA_BASE_URL");
436
+ if (!email) missing.push("JIRA_EMAIL");
437
+ if (!apiToken) missing.push("JIRA_API_TOKEN");
438
+ if (missing.length > 0 || !baseUrl || !email || !apiToken) {
439
+ throw new JiraError(`Missing Jira credentials: ${missing.join(", ")}`, [
440
+ "Set them in your shell:",
441
+ ' export JIRA_BASE_URL="https://your-team.atlassian.net"',
442
+ ' export JIRA_EMAIL="you@example.com"',
443
+ ' export JIRA_API_TOKEN="..."',
444
+ "",
445
+ `Or write them to ${CONFIG_PATH}:`,
446
+ ' { "baseUrl": "...", "email": "...", "apiToken": "..." }',
447
+ "",
448
+ `Create an API token at ${TOKEN_URL}`
449
+ ]);
450
+ }
451
+ return {
452
+ baseUrl: normalizeBaseUrl(baseUrl),
453
+ email: email.trim(),
454
+ apiToken: apiToken.trim(),
455
+ issuesJql: file.issuesJql?.trim() || DEFAULT_ISSUES_JQL,
456
+ worklogDays: typeof file.worklogDays === "number" && file.worklogDays > 0 ? Math.floor(file.worklogDays) : DEFAULT_WORKLOG_DAYS,
457
+ holidayCountry: normalizeCode(process.env["JIRA_HOLIDAY_COUNTRY"] ?? file.holidayCountry),
458
+ holidayRegion: normalizeCode(process.env["JIRA_HOLIDAY_REGION"] ?? file.holidayRegion)
459
+ };
460
+ }
461
+
462
+ // src/data/calendar.ts
463
+ var HOLIDAY_CACHE_PATH = join2(CONFIG_DIR, "holidays.json");
464
+ var DAYS_OFF_PATH = join2(CONFIG_DIR, "days-off.json");
465
+ var NAGER_URL = "https://date.nager.at/api/v3/PublicHolidays";
466
+ var REQUEST_TIMEOUT_MS = 2e4;
467
+ var CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
468
+ function isNonWorkingDay(key, marks) {
469
+ return isWeekend(key) || marks.get(key)?.observed === true;
470
+ }
471
+ var CACHE_VERSION = 2;
472
+ var DATE_KEY = /^\d{4}-\d{2}-\d{2}$/;
473
+ async function loadDayMarks(config, years) {
474
+ const marks = /* @__PURE__ */ new Map();
475
+ const regional = /* @__PURE__ */ new Map();
476
+ for (const holiday of await loadHolidays(config, years)) {
477
+ const counties = holiday.counties ?? [];
478
+ const observed = counties.length === 0 || config.holidayRegion !== null && counties.includes(config.holidayRegion);
479
+ if (observed) {
480
+ marks.set(holiday.date, { kind: "holiday", label: holiday.name, observed: true });
481
+ continue;
482
+ }
483
+ const group = regional.get(holiday.date) ?? { names: [], counties: [] };
484
+ if (!group.names.includes(holiday.name)) group.names.push(holiday.name);
485
+ for (const county of counties) if (!group.counties.includes(county)) group.counties.push(county);
486
+ regional.set(holiday.date, group);
487
+ }
488
+ for (const [date, { names, counties }] of regional) {
489
+ if (marks.has(date)) continue;
490
+ marks.set(date, {
491
+ kind: "holiday",
492
+ label: `${names.join(" / ")} \xB7 ${counties.join(", ")}`,
493
+ observed: false
494
+ });
495
+ }
496
+ for (const [date, label] of readDaysOff()) {
497
+ marks.set(date, { kind: "dayoff", label, observed: true });
498
+ }
499
+ return marks;
500
+ }
501
+ async function loadHolidays(config, years) {
502
+ const country = config.holidayCountry;
503
+ if (!country) return [];
504
+ const cached2 = readCache();
505
+ const cache = cached2 && cached2.country === country ? cached2 : { version: CACHE_VERSION, country, years: {} };
506
+ const stale = years.filter((year) => !isFresh(cache.years[String(year)]));
507
+ const fetched = await Promise.all(
508
+ stale.map(async (year) => [year, await fetchYear(country, year)])
509
+ );
510
+ let changed = false;
511
+ for (const [year, days] of fetched) {
512
+ if (!days) continue;
513
+ cache.years[String(year)] = { fetchedAt: (/* @__PURE__ */ new Date()).toISOString(), days };
514
+ changed = true;
515
+ }
516
+ if (changed || cached2 !== cache) writeCache(cache);
517
+ return years.flatMap((year) => cache.years[String(year)]?.days ?? []);
518
+ }
519
+ function isFresh(entry) {
520
+ if (!entry) return false;
521
+ const age = Date.now() - Date.parse(entry.fetchedAt);
522
+ return Number.isFinite(age) && age >= 0 && age < CACHE_TTL_MS;
523
+ }
524
+ async function fetchYear(country, year) {
525
+ try {
526
+ const response = await fetch(`${NAGER_URL}/${year}/${country}`, {
527
+ headers: { Accept: "application/json" },
528
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
529
+ });
530
+ if (!response.ok) return null;
531
+ const body = await response.json();
532
+ if (!Array.isArray(body)) return null;
533
+ const days = [];
534
+ for (const entry of body) {
535
+ const date = entry.date;
536
+ if (typeof date !== "string" || !DATE_KEY.test(date)) continue;
537
+ const counties = entry.global === true || !Array.isArray(entry.counties) ? [] : entry.counties.filter((county) => typeof county === "string");
538
+ const name = typeof entry.localName === "string" ? entry.localName : entry.name;
539
+ days.push({ date, name: typeof name === "string" ? name : "holiday", counties });
540
+ }
541
+ return days;
542
+ } catch {
543
+ return null;
544
+ }
337
545
  }
338
- function hoursColor(hours, weekend) {
339
- if (hours === 0) return weekend ? "gray" : "red";
546
+ function readCache() {
547
+ let parsed;
548
+ try {
549
+ parsed = JSON.parse(readFileSync2(HOLIDAY_CACHE_PATH, "utf8"));
550
+ } catch {
551
+ return null;
552
+ }
553
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
554
+ const { version: version2, country, years } = parsed;
555
+ if (version2 !== CACHE_VERSION) return null;
556
+ if (typeof years !== "object" || years === null) return null;
557
+ return {
558
+ version: CACHE_VERSION,
559
+ country: typeof country === "string" ? country : null,
560
+ years
561
+ };
562
+ }
563
+ function writeCache(cache) {
564
+ const temp = `${HOLIDAY_CACHE_PATH}.tmp`;
565
+ try {
566
+ mkdirSync(CONFIG_DIR, { recursive: true });
567
+ writeFileSync(temp, `${JSON.stringify(cache, null, 2)}
568
+ `, "utf8");
569
+ renameSync(temp, HOLIDAY_CACHE_PATH);
570
+ } catch {
571
+ }
572
+ }
573
+ function readDaysOff() {
574
+ let parsed;
575
+ try {
576
+ parsed = JSON.parse(readFileSync2(DAYS_OFF_PATH, "utf8"));
577
+ } catch {
578
+ return [];
579
+ }
580
+ const list = Array.isArray(parsed) ? parsed : typeof parsed === "object" && parsed !== null && Array.isArray(parsed.dates) ? parsed.dates : [];
581
+ const days = [];
582
+ for (const entry of list) {
583
+ if (typeof entry === "string") {
584
+ if (DATE_KEY.test(entry)) days.push([entry, "day off"]);
585
+ continue;
586
+ }
587
+ if (typeof entry !== "object" || entry === null) continue;
588
+ const { date, label } = entry;
589
+ if (typeof date !== "string" || !DATE_KEY.test(date)) continue;
590
+ days.push([date, typeof label === "string" && label.trim() ? label.trim() : "day off"]);
591
+ }
592
+ return days;
593
+ }
594
+
595
+ // src/components/WorklogPanel.tsx
596
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
597
+ var DATE_WIDTH = 14;
598
+ function hoursColor(hours, nonWorking) {
599
+ if (hours === 0) return nonWorking ? "gray" : "red";
340
600
  if (hours < 6) return "yellow";
341
601
  return "green";
342
602
  }
603
+ function markGlyph(mark) {
604
+ if (!mark) return "";
605
+ if (mark.kind === "dayoff") return "\xB7";
606
+ return mark.observed ? "*" : "~";
607
+ }
608
+ function markBadge(mark) {
609
+ return mark?.kind === "holiday" ? "\u{1F389}" : "";
610
+ }
343
611
  function WorklogPanel({
344
612
  state,
345
613
  focused,
346
614
  height,
347
615
  width,
348
616
  offset,
349
- spinnerFrame
617
+ spinnerFrame,
618
+ dayMarks
350
619
  }) {
351
620
  const days = state.data?.days ?? [];
352
621
  const visibleRows = Math.max(1, height - PANEL_CHROME_ROWS);
353
622
  const total = days.reduce((sum, day) => sum + day.hours, 0);
354
- const keysWidth = Math.max(6, width - 4 - 12 - 7);
623
+ const keysWidth = Math.max(6, width - 4 - DATE_WIDTH - 7);
355
624
  const base = days.length > 0 ? `last ${days.length} days \xB7 ${total.toFixed(1)}h total` : `last ${days.length || 14} days`;
356
625
  const title = state.loading && days.length > 0 ? `${spinnerChar(spinnerFrame)} ${base}` : base;
357
626
  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";
627
+ const mark = dayMarks.get(day.date);
628
+ const weekend = isWeekend(day.date);
629
+ const nonWorking = isNonWorkingDay(day.date, dayMarks);
630
+ const keys = day.issueKeys.length > 0 ? day.issueKeys.join(", ") : mark ? `(${mark.label})` : "\u2014";
360
631
  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 }) })
632
+ /* @__PURE__ */ jsx6(Box6, { width: DATE_WIDTH, flexShrink: 0, children: /* @__PURE__ */ jsx6(
633
+ Text6,
634
+ {
635
+ color: mark ? "magenta" : weekend ? "gray" : "white",
636
+ dimColor: mark !== void 0 && !mark.observed,
637
+ children: formatDate(day.date) + markGlyph(mark) + markBadge(mark)
638
+ }
639
+ ) }),
640
+ /* @__PURE__ */ jsx6(Box6, { width: 7, flexShrink: 0, children: /* @__PURE__ */ jsx6(Text6, { color: hoursColor(day.hours, nonWorking), children: `${day.hours.toFixed(1)}h`.padStart(6) }) }),
641
+ /* @__PURE__ */ jsx6(Box6, { width: keysWidth, flexShrink: 1, children: /* @__PURE__ */ jsx6(
642
+ Text6,
643
+ {
644
+ color: mark && day.issueKeys.length === 0 ? "magenta" : "gray",
645
+ dimColor: mark !== void 0 && !mark.observed,
646
+ wrap: "truncate-end",
647
+ children: keys
648
+ }
649
+ ) })
364
650
  ] }, day.date);
365
651
  }) }) });
366
652
  }
@@ -752,16 +1038,6 @@ async function openUrl(url) {
752
1038
  });
753
1039
  }
754
1040
 
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
1041
  // src/app.tsx
766
1042
  import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
767
1043
  var PANEL_ORDER = ["worklog", "issues", "preview"];
@@ -791,6 +1067,7 @@ function App({ config, client, me }) {
791
1067
  const [spinnerFrame, setSpinnerFrame] = useState2(0);
792
1068
  const [reloadToken, setReloadToken] = useState2(0);
793
1069
  const [notice, setNotice] = useState2(null);
1070
+ const [dayMarks, setDayMarks] = useState2(() => /* @__PURE__ */ new Map());
794
1071
  const topHeight = Math.max(MIN_TOP_HEIGHT, Math.floor((rows - 1) / 2));
795
1072
  const bottomHeight = Math.max(MIN_TOP_HEIGHT, rows - 1 - topHeight);
796
1073
  const leftWidth = Math.max(MIN_LEFT_WIDTH, Math.min(52, Math.floor(columns * 0.42)));
@@ -819,6 +1096,19 @@ function App({ config, client, me }) {
819
1096
  cancelled = true;
820
1097
  };
821
1098
  }, [client, config, me, reloadToken]);
1099
+ useEffect4(() => {
1100
+ let cancelled = false;
1101
+ const today = /* @__PURE__ */ new Date();
1102
+ const oldest = new Date(today);
1103
+ oldest.setDate(oldest.getDate() - config.worklogDays);
1104
+ const years = [.../* @__PURE__ */ new Set([oldest.getFullYear(), today.getFullYear()])];
1105
+ void loadDayMarks(config, years).then((marks) => {
1106
+ if (!cancelled) setDayMarks(marks);
1107
+ });
1108
+ return () => {
1109
+ cancelled = true;
1110
+ };
1111
+ }, [config, reloadToken]);
822
1112
  const loading = worklogs.loading || issues.loading;
823
1113
  useEffect4(() => {
824
1114
  if (!loading) return;
@@ -997,7 +1287,8 @@ function App({ config, client, me }) {
997
1287
  height: topHeight,
998
1288
  width: leftWidth,
999
1289
  offset: worklogOffset,
1000
- spinnerFrame
1290
+ spinnerFrame,
1291
+ dayMarks
1001
1292
  }
1002
1293
  ),
1003
1294
  /* @__PURE__ */ jsx7(
@@ -1023,7 +1314,8 @@ function App({ config, client, me }) {
1023
1314
  focused: false,
1024
1315
  height: bottomHeight,
1025
1316
  width: chartWidth,
1026
- spinnerFrame
1317
+ spinnerFrame,
1318
+ dayMarks
1027
1319
  }
1028
1320
  ),
1029
1321
  /* @__PURE__ */ jsx7(
@@ -1062,7 +1354,7 @@ function App({ config, client, me }) {
1062
1354
  }
1063
1355
 
1064
1356
  // src/data/client.ts
1065
- var REQUEST_TIMEOUT_MS = 2e4;
1357
+ var REQUEST_TIMEOUT_MS2 = 2e4;
1066
1358
  var JiraClient = class {
1067
1359
  baseUrl;
1068
1360
  authHeader;
@@ -1095,7 +1387,7 @@ var JiraClient = class {
1095
1387
  ...body === void 0 ? {} : { "Content-Type": "application/json" }
1096
1388
  },
1097
1389
  ...body === void 0 ? {} : { body: JSON.stringify(body) },
1098
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
1390
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS2)
1099
1391
  });
1100
1392
  } catch (cause) {
1101
1393
  throw asNetworkError(cause, this.baseUrl);
@@ -1166,83 +1458,6 @@ async function readErrorDetail(response) {
1166
1458
  }
1167
1459
  }
1168
1460
 
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
1461
  // src/cli.tsx
1247
1462
  import { jsx as jsx8 } from "react/jsx-runtime";
1248
1463
  var require2 = createRequire(import.meta.url);
@@ -1265,6 +1480,9 @@ var HELP = `
1265
1480
  JIRA_BASE_URL https://your-team.atlassian.net
1266
1481
  JIRA_EMAIL your Atlassian account email
1267
1482
  JIRA_API_TOKEN https://id.atlassian.com/manage-profile/security/api-tokens
1483
+ JIRA_HOLIDAY_COUNTRY ISO country code for statutory holidays, e.g. UA
1484
+ JIRA_HOLIDAY_REGION your subdivision, e.g. CA-ON \u2014 regional holidays are
1485
+ shown either way, but only count as days off with it
1268
1486
 
1269
1487
  Or ${CONFIG_PATH}:
1270
1488
  {
@@ -1272,9 +1490,16 @@ var HELP = `
1272
1490
  "email": "you@example.com",
1273
1491
  "apiToken": "...",
1274
1492
  "issuesJql": ${JSON.stringify(DEFAULT_ISSUES_JQL)},
1275
- "worklogDays": 14
1493
+ "worklogDays": 14,
1494
+ "holidayCountry": "UA"
1276
1495
  }
1277
1496
 
1497
+ Personal leave goes in ${DAYS_OFF_PATH}:
1498
+ ["2026-08-10", { "date": "2026-08-11", "label": "moving day" }]
1499
+
1500
+ Holidays are fetched from date.nager.at and cached next to the config;
1501
+ changing the country invalidates the cache.
1502
+
1278
1503
  Keys
1279
1504
  \u2191 \u2193 / j k move within the focused panel
1280
1505
  home / end jump to the first / last item