jira-dashboard 0.1.0 → 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 +37 -1
- package/dist/cli.js +360 -135
- package/dist/cli.js.map +4 -4
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
[](https://github.com/vshymanskyy/StandWithUkraine/blob/main/docs/README.md)
|
|
2
|
+
|
|
3
|
+
---
|
|
4
|
+
|
|
1
5
|
# jira-dashboard (`jdb`)
|
|
2
6
|
|
|
3
7
|
A full-screen terminal dashboard for Jira Cloud, built with [Ink](https://github.com/vadimdemedes/ink).
|
|
@@ -52,13 +56,45 @@ Or write `~/.config/jira-dashboard/config.json` (environment variables win):
|
|
|
52
56
|
"email": "you@example.com",
|
|
53
57
|
"apiToken": "…",
|
|
54
58
|
"issuesJql": "assignee = currentUser() AND statusCategory != Done AND status NOT IN (\"Cancelled\", \"Closed\", \"Backlog\") ORDER BY updated DESC",
|
|
55
|
-
"worklogDays": 14
|
|
59
|
+
"worklogDays": 14,
|
|
60
|
+
"holidayCountry": "UA",
|
|
61
|
+
"holidayRegion": null
|
|
56
62
|
}
|
|
57
63
|
```
|
|
58
64
|
|
|
59
65
|
`issuesJql` is worth customising: status names vary per site, and some boards model the backlog
|
|
60
66
|
by sprint rather than by status — in that case append `AND sprint IS NOT EMPTY`.
|
|
61
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
|
+
|
|
62
98
|
## Keys
|
|
63
99
|
|
|
64
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
|
|
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) =>
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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(
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
|
339
|
-
|
|
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 -
|
|
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
|
|
359
|
-
const
|
|
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:
|
|
362
|
-
|
|
363
|
-
|
|
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
|
|
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(
|
|
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
|