@profullstack/timer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,194 @@
1
+ // The domain: what an entry is and the operations on a list of them.
2
+ //
3
+ // Pure functions over a plain array, deliberately. The store owns the file and
4
+ // the lock; the CLI owns argv and printing; this owns the rules. That split is
5
+ // what lets `billing --from-timer` reuse the selection logic without importing
6
+ // a command.
7
+ import { newId } from "./store.mjs";
8
+ import { localDay, parseDuration } from "./time.mjs";
9
+
10
+ /**
11
+ * A time entry.
12
+ *
13
+ * `end: null` is the running clock — there is no separate "running" record, so
14
+ * a crash mid-session leaves a recoverable open entry rather than a lost one.
15
+ * `agent` and `meta` exist because the second audience for this tool is a
16
+ * coding agent: it should be able to say which model logged the hours and hang
17
+ * its own identifiers off the entry without a schema change.
18
+ */
19
+ export function makeEntry({
20
+ project,
21
+ task = "",
22
+ tags = [],
23
+ start,
24
+ end = null,
25
+ notes = "",
26
+ agent = null,
27
+ rate = null,
28
+ billable = true,
29
+ meta = {},
30
+ }) {
31
+ if (!project) throw new Error("an entry needs a project");
32
+ return {
33
+ id: newId(),
34
+ project: String(project),
35
+ task: String(task || ""),
36
+ tags: [...new Set(tags.map((t) => String(t).trim()).filter(Boolean))],
37
+ start,
38
+ end,
39
+ notes: String(notes || ""),
40
+ agent: agent ? String(agent) : null,
41
+ rate: rate == null ? null : Number(rate),
42
+ billable: Boolean(billable),
43
+ meta: meta && typeof meta === "object" ? meta : {},
44
+ };
45
+ }
46
+
47
+ export const isRunning = (e) => !e.end;
48
+
49
+ /** Seconds on the clock, counting a running entry up to `now`. */
50
+ export function seconds(entry, now = new Date()) {
51
+ const start = new Date(entry.start).getTime();
52
+ const end = entry.end ? new Date(entry.end).getTime() : now.getTime();
53
+ return Math.max(0, Math.round((end - start) / 1000));
54
+ }
55
+
56
+ export function running(entries) {
57
+ return entries.filter(isRunning);
58
+ }
59
+
60
+ /**
61
+ * Resolve an id the way git resolves a sha: an unambiguous prefix is enough.
62
+ * Throws on ambiguity rather than picking one, because the wrong pick here
63
+ * edits somebody's billable hours.
64
+ */
65
+ export function findById(entries, id) {
66
+ const want = String(id).toLowerCase();
67
+ const exact = entries.find((e) => e.id === want);
68
+ if (exact) return exact;
69
+ const hits = entries.filter((e) => e.id.startsWith(want));
70
+ if (hits.length === 1) return hits[0];
71
+ if (hits.length > 1) {
72
+ throw new Error(`id "${id}" is ambiguous (${hits.map((e) => e.id).join(", ")})`);
73
+ }
74
+ return null;
75
+ }
76
+
77
+ /**
78
+ * Every filter the commands share, in one place.
79
+ *
80
+ * A window bound compares against the entry's *start*, and `until` is
81
+ * exclusive. An entry that spans the boundary therefore belongs to the day it
82
+ * began on — which is the same rule a timesheet uses, and the only one that
83
+ * keeps a total from being counted twice.
84
+ */
85
+ export function select(entries, {
86
+ project,
87
+ projects,
88
+ task,
89
+ tag,
90
+ tags,
91
+ agent,
92
+ since,
93
+ until,
94
+ billable,
95
+ ids,
96
+ runningOnly,
97
+ finishedOnly,
98
+ } = {}) {
99
+ const wantProjects = [projects, project].flat().filter(Boolean).map((p) => String(p).toLowerCase());
100
+ const wantTags = [tags, tag].flat().filter(Boolean).map((t) => String(t).toLowerCase());
101
+ const idSet = ids ? new Set(ids.map(String)) : null;
102
+ return entries.filter((e) => {
103
+ if (idSet && !idSet.has(e.id)) return false;
104
+ if (wantProjects.length && !wantProjects.includes(e.project.toLowerCase())) return false;
105
+ if (task && !e.task.toLowerCase().includes(String(task).toLowerCase())) return false;
106
+ if (wantTags.length && !e.tags.some((t) => wantTags.includes(t.toLowerCase()))) return false;
107
+ if (agent && String(e.agent || "").toLowerCase() !== String(agent).toLowerCase()) return false;
108
+ if (billable === true && !e.billable) return false;
109
+ if (billable === false && e.billable) return false;
110
+ if (runningOnly && !isRunning(e)) return false;
111
+ if (finishedOnly && isRunning(e)) return false;
112
+ if (since && e.start < since) return false;
113
+ if (until && e.start >= until) return false;
114
+ return true;
115
+ });
116
+ }
117
+
118
+ const GROUPERS = {
119
+ project: (e) => e.project,
120
+ task: (e) => e.task || "(no task)",
121
+ tag: (e) => (e.tags.length ? e.tags[0] : "(untagged)"),
122
+ day: (e) => localDay(e.start),
123
+ agent: (e) => e.agent || "(human)",
124
+ none: () => "total",
125
+ };
126
+
127
+ export const GROUP_KEYS = Object.keys(GROUPERS);
128
+
129
+ /**
130
+ * Totals by group, biggest first.
131
+ *
132
+ * `billableSeconds` is tracked alongside the total because "how long did this
133
+ * take" and "what can I charge for it" are different questions and a report
134
+ * that answers only one of them sends you back to the raw log.
135
+ */
136
+ export function summarize(entries, { group = "project", now = new Date() } = {}) {
137
+ const keyOf = GROUPERS[group];
138
+ if (!keyOf) throw new Error(`unknown grouping "${group}" (${GROUP_KEYS.join(", ")})`);
139
+ const buckets = new Map();
140
+ for (const e of entries) {
141
+ const key = keyOf(e);
142
+ const secs = seconds(e, now);
143
+ const bucket = buckets.get(key) || { key, seconds: 0, billableSeconds: 0, entries: 0, running: 0 };
144
+ bucket.seconds += secs;
145
+ if (e.billable) bucket.billableSeconds += secs;
146
+ bucket.entries += 1;
147
+ if (isRunning(e)) bucket.running += 1;
148
+ buckets.set(key, bucket);
149
+ }
150
+ const rows = [...buckets.values()];
151
+ // Day groupings read as a chronology; everything else reads as a ranking.
152
+ if (group === "day") rows.sort((a, b) => a.key.localeCompare(b.key));
153
+ else rows.sort((a, b) => b.seconds - a.seconds || a.key.localeCompare(b.key));
154
+ return rows;
155
+ }
156
+
157
+ export function totals(entries, now = new Date()) {
158
+ let total = 0;
159
+ let billable = 0;
160
+ for (const e of entries) {
161
+ const s = seconds(e, now);
162
+ total += s;
163
+ if (e.billable) billable += s;
164
+ }
165
+ return { seconds: total, billableSeconds: billable, entries: entries.length };
166
+ }
167
+
168
+ /**
169
+ * Close an entry. `at` defaults to now; an explicit `at` before the start is
170
+ * refused rather than clamped, because a negative session is a typo and
171
+ * silently turning it into zero hides it.
172
+ */
173
+ export function closeEntry(entry, at = new Date().toISOString()) {
174
+ if (!isRunning(entry)) throw new Error(`entry ${entry.id} is already stopped`);
175
+ if (at < entry.start) {
176
+ throw new Error(`cannot stop entry ${entry.id} at ${at}: it started later, at ${entry.start}`);
177
+ }
178
+ entry.end = at;
179
+ return entry;
180
+ }
181
+
182
+ /** `--duration 90m` on a manual entry, resolved against whichever bound is known. */
183
+ export function boundsFromDuration({ start, end, duration, now = new Date() }) {
184
+ const secs = duration == null ? null : parseDuration(duration);
185
+ if (duration != null && secs == null) throw new Error(`cannot read "${duration}" as a duration`);
186
+ if (start && end) return { start, end };
187
+ if (start && secs != null) return { start, end: new Date(new Date(start).getTime() + secs * 1000).toISOString() };
188
+ if (end && secs != null) return { start: new Date(new Date(end).getTime() - secs * 1000).toISOString(), end };
189
+ if (secs != null) {
190
+ const endIso = now.toISOString();
191
+ return { start: new Date(now.getTime() - secs * 1000).toISOString(), end: endIso };
192
+ }
193
+ return { start, end };
194
+ }
package/src/output.mjs ADDED
@@ -0,0 +1,64 @@
1
+ // Printing. Two audiences, one code path.
2
+ //
3
+ // A human gets aligned columns; an agent gets `--json`. The contract that
4
+ // makes the agent story work is that *every* command answers --json with a
5
+ // single JSON document on stdout and nothing else — no progress lines, no
6
+ // warnings, no colour. Anything advisory goes to stderr, which is why `warn`
7
+ // exists separately.
8
+ const CSI = `${String.fromCharCode(27)}[`;
9
+ const NO_COLOR = Boolean(process.env.NO_COLOR) || !process.stdout.isTTY;
10
+
11
+ const CODES = { dim: "2", bold: "1", red: "31", green: "32", yellow: "33", cyan: "36" };
12
+ export function paint(style, text) {
13
+ if (NO_COLOR || !CODES[style]) return String(text);
14
+ return `${CSI}${CODES[style]}m${text}${CSI}0m`;
15
+ }
16
+
17
+ export function emit(text = "") {
18
+ process.stdout.write(`${text}\n`);
19
+ }
20
+
21
+ export function warn(text) {
22
+ process.stderr.write(`${text}\n`);
23
+ }
24
+
25
+ /** The single JSON document a --json run is allowed to print. */
26
+ export function emitJson(value) {
27
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
28
+ }
29
+
30
+ /**
31
+ * An aligned table.
32
+ *
33
+ * Column widths come from the visible text, so a value that already carries
34
+ * colour codes would break alignment — which is why callers pass plain values
35
+ * and name the columns they want dimmed instead.
36
+ */
37
+ export function table(rows, columns, { dimCols = [] } = {}) {
38
+ if (!rows.length) return "";
39
+ const widths = columns.map((col) =>
40
+ Math.max(col.header.length, ...rows.map((r) => String(col.get(r) ?? "").length)));
41
+ const pad = (text, i) => (columns[i].align === "right"
42
+ ? String(text).padStart(widths[i])
43
+ : String(text).padEnd(widths[i]));
44
+ const out = [paint("dim", columns.map((c, i) => pad(c.header, i)).join(" ").trimEnd())];
45
+ for (const row of rows) {
46
+ const cells = columns.map((c, i) => {
47
+ const cell = pad(String(c.get(row) ?? ""), i);
48
+ return dimCols.includes(c.header) ? paint("dim", cell) : cell;
49
+ });
50
+ out.push(cells.join(" ").trimEnd());
51
+ }
52
+ return out.join("\n");
53
+ }
54
+
55
+ /** RFC 4180-ish CSV, because a timesheet ends up in a spreadsheet eventually. */
56
+ export function csv(rows, columns) {
57
+ const escape = (v) => {
58
+ const s = v == null ? "" : String(v);
59
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
60
+ };
61
+ const lines = [columns.map((c) => escape(c.header)).join(",")];
62
+ for (const row of rows) lines.push(columns.map((c) => escape(c.get(row))).join(","));
63
+ return lines.join("\n");
64
+ }
package/src/paths.mjs ADDED
@@ -0,0 +1,31 @@
1
+ // Where the timesheet lives, on every platform.
2
+ //
3
+ // One path on all three OSes rather than the usual per-platform data dir
4
+ // (%APPDATA%, ~/Library/Application Support, $XDG_DATA_HOME). That is a
5
+ // deliberate trade: this file is a published contract. `billing --from-timer`
6
+ // reads it, agents read it, and a human is expected to be able to `cat` it
7
+ // while debugging. One documented location is worth more here than OS
8
+ // convention, and homedir() is well defined on Windows too.
9
+ import { homedir } from "node:os";
10
+ import path from "node:path";
11
+
12
+ /** The shared parent for every Profullstack CLI's state. */
13
+ export function profullstackHome() {
14
+ return process.env.PROFULLSTACK_HOME || path.join(homedir(), ".profullstack");
15
+ }
16
+
17
+ /** The directory this CLI owns. */
18
+ export function timerHome() {
19
+ return process.env.TIMER_HOME || path.join(profullstackHome(), "timer");
20
+ }
21
+
22
+ /**
23
+ * The timesheet file itself.
24
+ *
25
+ * TIMER_DATA points at a *file*, not a directory, so a test (or an agent
26
+ * wanting a scratch timesheet) can redirect the whole store with one variable
27
+ * and no mkdir dance.
28
+ */
29
+ export function dataFile() {
30
+ return process.env.TIMER_DATA || path.join(timerHome(), "timesheet.json");
31
+ }
package/src/store.mjs ADDED
@@ -0,0 +1,148 @@
1
+ // Reading and writing the timesheet.
2
+ //
3
+ // Two things matter here and nothing else does:
4
+ //
5
+ // 1. A write must never leave a truncated file. Agents run this in the
6
+ // middle of other work and a half-written timesheet loses real hours,
7
+ // so every write is tmp-file + rename (atomic on all three platforms —
8
+ // Node's fs.rename maps to MoveFileEx/MOVEFILE_REPLACE_EXISTING on
9
+ // Windows, so it overwrites there like it does on POSIX).
10
+ //
11
+ // 2. Two processes must not interleave a read-modify-write. That is not
12
+ // hypothetical: the whole point of the agent story is several sessions
13
+ // clocking in at once. mkdir is the atomic primitive available on every
14
+ // filesystem we care about, so the lock is a directory.
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { randomBytes } from "node:crypto";
18
+
19
+ import { dataFile } from "./paths.mjs";
20
+
21
+ export const SCHEMA_VERSION = 1;
22
+
23
+ /** A fresh, empty timesheet. */
24
+ export function emptyStore() {
25
+ return { version: SCHEMA_VERSION, entries: [] };
26
+ }
27
+
28
+ /**
29
+ * A short, URL-safe, human-typeable id.
30
+ *
31
+ * Base32 without the letters that get misread aloud or in a terminal font
32
+ * (i, l, o, u), because these ids end up in `timer stop --id ...` typed by
33
+ * hand and in invoice line items read by a client.
34
+ */
35
+ const ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
36
+ export function newId(len = 8) {
37
+ const bytes = randomBytes(len);
38
+ let out = "";
39
+ for (const b of bytes) out += ALPHABET[b % ALPHABET.length];
40
+ return out;
41
+ }
42
+
43
+ function ensureDir(file) {
44
+ fs.mkdirSync(path.dirname(file), { recursive: true });
45
+ }
46
+
47
+ /**
48
+ * Read the timesheet, tolerating every "not there yet" case.
49
+ *
50
+ * A missing file is an empty timesheet, not an error: `timer status` on a
51
+ * fresh machine should say "nothing running", not crash. A *corrupt* file is
52
+ * a different matter and does throw — silently starting over would discard
53
+ * someone's billable hours.
54
+ */
55
+ export function read(file = dataFile()) {
56
+ let raw;
57
+ try {
58
+ raw = fs.readFileSync(file, "utf8");
59
+ } catch (err) {
60
+ if (err.code === "ENOENT") return emptyStore();
61
+ throw err;
62
+ }
63
+ if (!raw.trim()) return emptyStore();
64
+ let parsed;
65
+ try {
66
+ parsed = JSON.parse(raw);
67
+ } catch {
68
+ throw new Error(
69
+ `timesheet at ${file} is not valid JSON. Move it aside to start fresh — it has not been touched.`,
70
+ );
71
+ }
72
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) {
73
+ throw new Error(`timesheet at ${file} is missing its entries array`);
74
+ }
75
+ if (parsed.version > SCHEMA_VERSION) {
76
+ throw new Error(
77
+ `timesheet at ${file} was written by a newer timer (schema ${parsed.version}); upgrade with: npm install -g @profullstack/timer`,
78
+ );
79
+ }
80
+ return { version: SCHEMA_VERSION, ...parsed, entries: parsed.entries };
81
+ }
82
+
83
+ export function write(store, file = dataFile()) {
84
+ ensureDir(file);
85
+ const tmp = `${file}.${process.pid}.${newId(4)}.tmp`;
86
+ fs.writeFileSync(tmp, `${JSON.stringify({ ...store, version: SCHEMA_VERSION }, null, 2)}\n`, {
87
+ mode: 0o600,
88
+ });
89
+ fs.renameSync(tmp, file);
90
+ return file;
91
+ }
92
+
93
+ /**
94
+ * Hold an exclusive lock for the duration of fn.
95
+ *
96
+ * Stale locks are reclaimed after `staleMs` because the alternative is a
97
+ * killed agent wedging the timesheet for everyone until someone finds the
98
+ * directory by hand. Ten seconds is far longer than any operation here takes
99
+ * and far shorter than a human's patience.
100
+ */
101
+ export function withLock(fn, { file = dataFile(), timeoutMs = 5000, staleMs = 10_000 } = {}) {
102
+ const lock = `${file}.lock`;
103
+ ensureDir(file);
104
+ const started = Date.now();
105
+ for (;;) {
106
+ try {
107
+ fs.mkdirSync(lock);
108
+ break;
109
+ } catch (err) {
110
+ if (err.code !== "EEXIST") throw err;
111
+ let age = 0;
112
+ try {
113
+ age = Date.now() - fs.statSync(lock).mtimeMs;
114
+ } catch {
115
+ continue; // vanished between the mkdir and the stat — try again
116
+ }
117
+ if (age > staleMs) {
118
+ try {
119
+ fs.rmSync(lock, { recursive: true, force: true });
120
+ } catch { /* someone else won the race; the next mkdir decides */ }
121
+ continue;
122
+ }
123
+ if (Date.now() - started > timeoutMs) {
124
+ throw new Error(
125
+ `another timer process is holding ${lock}. If nothing else is running, remove that directory.`,
126
+ );
127
+ }
128
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
129
+ }
130
+ }
131
+ try {
132
+ return fn();
133
+ } finally {
134
+ try {
135
+ fs.rmSync(lock, { recursive: true, force: true });
136
+ } catch { /* best effort — a stale lock is reclaimed above */ }
137
+ }
138
+ }
139
+
140
+ /** read → mutate → write, under the lock. Returns whatever fn returns. */
141
+ export function update(fn, { file = dataFile() } = {}) {
142
+ return withLock(() => {
143
+ const store = read(file);
144
+ const result = fn(store);
145
+ write(store, file);
146
+ return result;
147
+ }, { file });
148
+ }
package/src/time.mjs ADDED
@@ -0,0 +1,163 @@
1
+ // Durations and dates, in the spellings people actually type.
2
+ //
3
+ // Everything stored is an ISO-8601 UTC instant. Everything *typed* is local
4
+ // and loose — "1h30m", "90m", "1.5h", "yesterday", "2026-08-01", "09:15".
5
+ // This module is the only place that converts between the two, so there is
6
+ // one answer to "what does --since mean" rather than one per command.
7
+
8
+ const UNITS = { w: 604800, d: 86400, h: 3600, m: 60, s: 1 };
9
+
10
+ /**
11
+ * Parse a duration into seconds. Returns null when it is not a duration —
12
+ * callers decide whether that is an error, because "1h" and "an id" arrive
13
+ * through the same argument in a couple of places.
14
+ *
15
+ * Accepts "1h30m", "1h 30m", "90m", "1.5h", "45s", "2d4h", and a bare number
16
+ * (minutes — the unit people mean when they omit one for a work session).
17
+ */
18
+ export function parseDuration(input) {
19
+ if (input == null) return null;
20
+ const text = String(input).trim().toLowerCase().replace(/\s+/g, "");
21
+ if (!text) return null;
22
+ if (/^\d+(\.\d+)?$/.test(text)) return Math.round(Number(text) * 60);
23
+ const re = /(\d+(?:\.\d+)?)([wdhms])/g;
24
+ let total = 0;
25
+ let matched = 0;
26
+ let m;
27
+ while ((m = re.exec(text)) !== null) {
28
+ total += Number(m[1]) * UNITS[m[2]];
29
+ matched += m[0].length;
30
+ }
31
+ if (matched !== text.length || total <= 0) return null;
32
+ return Math.round(total);
33
+ }
34
+
35
+ /** "2h 15m" — the human spelling, for terminals. */
36
+ export function formatDuration(seconds, { compact = false } = {}) {
37
+ const s = Math.max(0, Math.round(seconds));
38
+ const h = Math.floor(s / 3600);
39
+ const m = Math.floor((s % 3600) / 60);
40
+ const sec = s % 60;
41
+ const parts = [];
42
+ if (h) parts.push(`${h}h`);
43
+ if (m) parts.push(`${m}m`);
44
+ // Seconds only matter when they are all there is — an 8-hour day reported
45
+ // to the second reads like a stopwatch, not a timesheet.
46
+ if (!h && (sec || !m)) parts.push(`${sec}s`);
47
+ return parts.join(compact ? "" : " ");
48
+ }
49
+
50
+ /** Decimal hours, rounded to 2dp — the number that goes on an invoice. */
51
+ export function hours(seconds) {
52
+ return Math.round((seconds / 3600) * 100) / 100;
53
+ }
54
+
55
+ function startOfDay(d) {
56
+ const out = new Date(d);
57
+ out.setHours(0, 0, 0, 0);
58
+ return out;
59
+ }
60
+
61
+ /**
62
+ * Parse a moment. Local time in, UTC ISO string out.
63
+ *
64
+ * Handles the words ("now", "today", "yesterday"), a bare clock time ("09:15"
65
+ * — today at that time), a date ("2026-08-01"), a full ISO instant, and a
66
+ * negative offset ("-90m", "-2h") meaning "that long ago", which is how you
67
+ * fix a clock you forgot to start.
68
+ */
69
+ export function parseMoment(input, { now = new Date() } = {}) {
70
+ if (input == null) return null;
71
+ const text = String(input).trim();
72
+ if (!text) return null;
73
+ const lower = text.toLowerCase();
74
+ if (lower === "now") return new Date(now).toISOString();
75
+ if (lower === "today") return startOfDay(now).toISOString();
76
+ if (lower === "yesterday") {
77
+ const d = startOfDay(now);
78
+ d.setDate(d.getDate() - 1);
79
+ return d.toISOString();
80
+ }
81
+ if (lower.startsWith("-")) {
82
+ const secs = parseDuration(lower.slice(1));
83
+ if (secs == null) return null;
84
+ return new Date(now.getTime() - secs * 1000).toISOString();
85
+ }
86
+ // A bare clock time means today. This is the common case for `--at 09:15`
87
+ // ("I actually started at quarter past") and would otherwise be a parse
88
+ // error or, worse, 1970.
89
+ const clock = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(text);
90
+ if (clock) {
91
+ const d = startOfDay(now);
92
+ d.setHours(Number(clock[1]), Number(clock[2]), Number(clock[3] || 0), 0);
93
+ return d.toISOString();
94
+ }
95
+ // A bare date is midnight local, not midnight UTC. `new Date("2026-08-01")`
96
+ // parses as UTC per the spec, which silently shifts a whole day's entries
97
+ // across the boundary for anyone west of Greenwich.
98
+ const ymd = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
99
+ if (ymd) {
100
+ const d = new Date(Number(ymd[1]), Number(ymd[2]) - 1, Number(ymd[3]), 0, 0, 0, 0);
101
+ return d.toISOString();
102
+ }
103
+ const parsed = new Date(text);
104
+ if (Number.isNaN(parsed.getTime())) return null;
105
+ return parsed.toISOString();
106
+ }
107
+
108
+ /**
109
+ * Turn --since/--until/--today/--week/--month/--period into a concrete window.
110
+ * Both bounds are ISO strings or null (unbounded). `until` is exclusive.
111
+ */
112
+ export function resolveWindow(flags = {}, { now = new Date() } = {}) {
113
+ const period = flags.period
114
+ || (flags.today && "today")
115
+ || (flags.week && "week")
116
+ || (flags.month && "month")
117
+ || (flags.year && "year")
118
+ || null;
119
+
120
+ let since = flags.since ? parseMoment(flags.since, { now }) : null;
121
+ let until = flags.until ? parseMoment(flags.until, { now }) : null;
122
+ if (flags.since && !since) throw new Error(`--since: cannot read "${flags.since}" as a date`);
123
+ if (flags.until && !until) throw new Error(`--until: cannot read "${flags.until}" as a date`);
124
+
125
+ if (period) {
126
+ const from = startOfDay(now);
127
+ switch (period) {
128
+ case "today": break;
129
+ case "yesterday": {
130
+ from.setDate(from.getDate() - 1);
131
+ const to = new Date(from);
132
+ to.setDate(to.getDate() + 1);
133
+ return { since: from.toISOString(), until: to.toISOString() };
134
+ }
135
+ case "week": {
136
+ // Monday-based: a work week starts on Monday everywhere this is used.
137
+ const dow = (from.getDay() + 6) % 7;
138
+ from.setDate(from.getDate() - dow);
139
+ break;
140
+ }
141
+ case "month": from.setDate(1); break;
142
+ case "year": from.setMonth(0, 1); break;
143
+ default: throw new Error(`--period: unknown period "${period}" (today, yesterday, week, month, year)`);
144
+ }
145
+ since = since || from.toISOString();
146
+ }
147
+ return { since, until };
148
+ }
149
+
150
+ /** Local YYYY-MM-DD for an ISO instant — the key `report --group day` uses. */
151
+ export function localDay(iso) {
152
+ const d = new Date(iso);
153
+ const pad = (n) => String(n).padStart(2, "0");
154
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
155
+ }
156
+
157
+ /** Short local timestamp for tables: "Aug 29 14:05". */
158
+ export function shortStamp(iso) {
159
+ const d = new Date(iso);
160
+ const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
161
+ const pad = (n) => String(n).padStart(2, "0");
162
+ return `${months[d.getMonth()]} ${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
163
+ }