@nbtca/prompt 1.3.2 → 1.4.1

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.
Files changed (65) hide show
  1. package/README.md +44 -0
  2. package/SECURITY.md +47 -0
  3. package/dist/app/app.js +202 -0
  4. package/dist/app/chrome.js +104 -0
  5. package/dist/app/fields/list-field.js +174 -0
  6. package/dist/app/fields/text-field.js +38 -0
  7. package/dist/app/frame.js +48 -0
  8. package/dist/app/keys.js +20 -0
  9. package/dist/app/tabs.js +11 -0
  10. package/dist/app/view.js +1 -0
  11. package/dist/app/views/docs-render.js +82 -0
  12. package/dist/app/views/docs.js +457 -0
  13. package/dist/app/views/events-render.js +111 -0
  14. package/dist/app/views/events.js +228 -0
  15. package/dist/app/views/home.js +236 -0
  16. package/dist/app/views/schedule-grid-cursor.js +52 -0
  17. package/dist/app/views/schedule-render.js +317 -0
  18. package/dist/app/views/schedule.js +472 -0
  19. package/dist/app/views/settings-render.js +53 -0
  20. package/dist/app/views/settings.js +153 -0
  21. package/dist/auth/cookie-transport.js +222 -0
  22. package/dist/auth/errors.js +18 -0
  23. package/dist/auth/nbt-auth.js +239 -0
  24. package/dist/auth/session-store.js +118 -0
  25. package/dist/config/paths.js +22 -2
  26. package/dist/core/canvas.js +23 -0
  27. package/dist/core/capabilities.js +42 -0
  28. package/dist/core/components/confirm.js +75 -0
  29. package/dist/core/components/input-session.js +24 -0
  30. package/dist/core/components/menu.js +122 -0
  31. package/dist/core/components/messages.js +16 -0
  32. package/dist/core/components/note.js +18 -0
  33. package/dist/core/components/painter.js +26 -0
  34. package/dist/core/components/screen.js +18 -0
  35. package/dist/core/components/spinner.js +47 -0
  36. package/dist/core/components/text-input.js +98 -0
  37. package/dist/core/logo.js +40 -15
  38. package/dist/core/menu.js +24 -6
  39. package/dist/core/motion.js +86 -0
  40. package/dist/core/text.js +127 -5
  41. package/dist/core/theme.js +61 -0
  42. package/dist/core/transitions.js +19 -0
  43. package/dist/core/ui.js +5 -29
  44. package/dist/features/calendar-heatmap.js +29 -27
  45. package/dist/features/calendar-query.js +50 -0
  46. package/dist/features/calendar.js +192 -98
  47. package/dist/features/docs.js +258 -55
  48. package/dist/features/links.js +7 -4
  49. package/dist/features/schedule-query.js +47 -0
  50. package/dist/features/schedule-render.js +574 -0
  51. package/dist/features/schedule-store.js +73 -0
  52. package/dist/features/schedule-view.js +260 -0
  53. package/dist/features/settings.js +41 -30
  54. package/dist/features/status.js +37 -13
  55. package/dist/features/student-timetable.js +346 -0
  56. package/dist/features/update.js +16 -8
  57. package/dist/i18n/locales/en.json +149 -6
  58. package/dist/i18n/locales/zh.json +149 -6
  59. package/dist/index.js +59 -5
  60. package/dist/logo/ca-dotmatrix-large.txt +26 -0
  61. package/dist/logo/ca-dotmatrix-small.txt +12 -0
  62. package/dist/logo/ca-dotmatrix.txt +18 -16
  63. package/dist/logo/ca-logo.png +0 -0
  64. package/dist/main.js +33 -13
  65. package/package.json +10 -7
@@ -0,0 +1,86 @@
1
+ import { getCapabilities } from './capabilities.js';
2
+ import { ansi } from './canvas.js';
3
+ export function sleep(ms) {
4
+ return new Promise((resolve) => setTimeout(resolve, ms));
5
+ }
6
+ export async function typeReveal(lines, opts = {}) {
7
+ const write = opts.write ?? ((s) => { process.stdout.write(s); });
8
+ const reduced = opts.reducedMotion ?? getCapabilities().reducedMotion;
9
+ if (reduced) {
10
+ write(lines.join('\n') + '\n');
11
+ return;
12
+ }
13
+ const stepMs = opts.stepMs ?? 45;
14
+ for (const line of lines) {
15
+ write(line + '\n');
16
+ await sleep(stepMs);
17
+ }
18
+ }
19
+ const BRAILLE_BASE = 0x2800;
20
+ function brailleMask(ch) {
21
+ const code = ch.codePointAt(0) ?? 0;
22
+ return code >= BRAILLE_BASE && code <= BRAILLE_BASE + 0xff ? code - BRAILLE_BASE : -1;
23
+ }
24
+ /**
25
+ * Reveals a block of braille dot-matrix art by assembling it dot-by-dot in
26
+ * scattered order, rather than popping it in line by line -- a reveal that
27
+ * matches what the art actually is (an addressable grid of dots) instead of
28
+ * reusing the plain-text typewriter effect built for prose. Non-braille
29
+ * characters (spaces, stray ASCII) pass through unchanged on every frame.
30
+ */
31
+ export async function materializeBraille(art, paint, opts = {}) {
32
+ const write = opts.write ?? ((s) => { process.stdout.write(s); });
33
+ const reduced = opts.reducedMotion ?? getCapabilities().reducedMotion;
34
+ const lines = art.split('\n');
35
+ const charGrid = lines.map((line) => [...line]);
36
+ const maskGrid = charGrid.map((row) => row.map(brailleMask));
37
+ if (reduced || !maskGrid.some((row) => row.some((m) => m > 0))) {
38
+ write(paint(art) + '\n');
39
+ return;
40
+ }
41
+ const dots = [];
42
+ maskGrid.forEach((row, r) => row.forEach((mask, c) => {
43
+ if (mask <= 0)
44
+ return;
45
+ for (let bit = 0; bit < 8; bit++)
46
+ if (mask & (1 << bit))
47
+ dots.push([r, c, bit]);
48
+ }));
49
+ const rand = opts.random ?? Math.random;
50
+ for (let i = dots.length - 1; i > 0; i--) {
51
+ const j = Math.floor(rand() * (i + 1));
52
+ const a = dots[i], b = dots[j];
53
+ if (a && b) {
54
+ dots[i] = b;
55
+ dots[j] = a;
56
+ }
57
+ }
58
+ const acc = maskGrid.map((row) => row.map(() => 0));
59
+ const renderFrame = () => charGrid.map((row, r) => row.map((original, c) => {
60
+ const mask = maskGrid[r]?.[c] ?? -1;
61
+ if (mask <= 0)
62
+ return original;
63
+ return String.fromCodePoint(BRAILLE_BASE + (acc[r]?.[c] ?? 0));
64
+ }).join('')).join('\n');
65
+ const frameCount = Math.max(1, opts.frames ?? 12);
66
+ const frameMs = opts.frameMs ?? 35;
67
+ let shown = 0;
68
+ for (let f = 1; f <= frameCount; f++) {
69
+ const target = Math.round((dots.length * f) / frameCount);
70
+ while (shown < target) {
71
+ const d = dots[shown];
72
+ if (d) {
73
+ const [r, c, bit] = d;
74
+ const row = acc[r];
75
+ if (row)
76
+ row[c] = (row[c] ?? 0) | (1 << bit);
77
+ }
78
+ shown++;
79
+ }
80
+ write(paint(renderFrame()) + '\n');
81
+ if (f < frameCount) {
82
+ await sleep(frameMs);
83
+ write(ansi.cursorUp(lines.length) + ansi.cursorToCol0 + ansi.eraseDown);
84
+ }
85
+ }
86
+ }
package/dist/core/text.js CHANGED
@@ -1,4 +1,14 @@
1
- /** Width of a single Unicode character: 2 for CJK/fullwidth, 1 otherwise. */
1
+ /** Codepoints that occupy zero terminal columns: combining modifiers that
2
+ * merge into the glyph immediately before them, rather than rendering as
3
+ * their own character (zero-width joiner, variation selectors). Emoji
4
+ * sequences like a ZWJ family emoji or "❤️" (heart + VS-16) render as one
5
+ * glyph — counting the modifier itself would overcount by a full column. */
6
+ function isZeroWidth(cp) {
7
+ return cp === 0x200D // zero-width joiner
8
+ || cp === 0xFE0E // variation selector-15 (text presentation)
9
+ || cp === 0xFE0F; // variation selector-16 (emoji presentation)
10
+ }
11
+ /** Width of a single Unicode character: 2 for CJK/fullwidth/emoji, 1 otherwise. */
2
12
  function charWidth(ch) {
3
13
  const cp = ch.codePointAt(0) ?? 0;
4
14
  return ((cp >= 0x1100 && cp <= 0x115F) ||
@@ -14,7 +24,15 @@ function charWidth(ch) {
14
24
  (cp >= 0x20000 && cp <= 0x2A6DF) ||
15
25
  (cp >= 0x2A700 && cp <= 0x2CEAF) ||
16
26
  (cp >= 0x2CEB0 && cp <= 0x2EBEF) ||
17
- (cp >= 0x30000 && cp <= 0x323AF)) ? 2 : 1;
27
+ (cp >= 0x30000 && cp <= 0x323AF) ||
28
+ // Emoji block (Misc Symbols & Pictographs, Emoticons, Transport, Chess
29
+ // Symbols, Supplemental Symbols & Pictographs, Extended-A). Real
30
+ // terminals render these as double-width glyphs; undercounting even one
31
+ // emoji is enough to push a line one column past the terminal width and
32
+ // trigger an unwanted auto-wrap — this is a real bug that was found: an
33
+ // emoji-titled event line loading in scrolled the app's header out of
34
+ // view because of exactly this miscount.
35
+ (cp >= 0x1F300 && cp <= 0x1FAFF)) ? 2 : 1;
18
36
  }
19
37
  /** Strip ANSI escape sequences from a string. */
20
38
  // eslint-disable-next-line no-control-regex
@@ -22,12 +40,17 @@ const ANSI_RE = /\x1b\[[0-9;]*m/g;
22
40
  export function stripAnsi(str) {
23
41
  return str.replace(ANSI_RE, '');
24
42
  }
25
- /** Total visual width of a string (CJK characters count as 2, ANSI codes ignored). */
43
+ /** Total visual width of a string (CJK/emoji count as 2, zero-width
44
+ * modifiers count as 0, ANSI codes ignored). */
26
45
  export function visualWidth(str) {
27
46
  const plain = stripAnsi(str);
28
47
  let w = 0;
29
- for (const ch of plain)
48
+ for (const ch of plain) {
49
+ const cp = ch.codePointAt(0) ?? 0;
50
+ if (isZeroWidth(cp))
51
+ continue;
30
52
  w += charWidth(ch);
53
+ }
31
54
  return w;
32
55
  }
33
56
  /** Pad string to target visual width with trailing spaces. */
@@ -42,7 +65,8 @@ export function truncate(str, maxWidth) {
42
65
  let w = 0;
43
66
  let i = 0;
44
67
  for (const ch of str) {
45
- const cw = charWidth(ch);
68
+ const cp = ch.codePointAt(0) ?? 0;
69
+ const cw = isZeroWidth(cp) ? 0 : charWidth(ch);
46
70
  if (w + cw > maxWidth - 3)
47
71
  break;
48
72
  w += cw;
@@ -50,3 +74,101 @@ export function truncate(str, maxWidth) {
50
74
  }
51
75
  return str.slice(0, i) + '...';
52
76
  }
77
+ function tokenizeForWrapping(str) {
78
+ const tokens = [];
79
+ let index = 0;
80
+ while (index < str.length) {
81
+ const ansi = /^\x1b\[[0-9;]*m/.exec(str.slice(index));
82
+ if (ansi) {
83
+ tokens.push({ raw: ansi[0], width: 0, whitespace: false, sgr: true });
84
+ index += ansi[0].length;
85
+ continue;
86
+ }
87
+ const cp = str.codePointAt(index) ?? 0;
88
+ const raw = String.fromCodePoint(cp);
89
+ tokens.push({
90
+ raw,
91
+ width: isZeroWidth(cp) ? 0 : charWidth(raw),
92
+ whitespace: /\s/u.test(raw),
93
+ sgr: false,
94
+ });
95
+ index += raw.length;
96
+ }
97
+ return tokens;
98
+ }
99
+ function advanceSgr(active, tokens, start, end) {
100
+ let next = active;
101
+ for (let index = start; index < end; index += 1) {
102
+ const token = tokens[index];
103
+ if (!token?.sgr)
104
+ continue;
105
+ const params = token.raw.slice(2, -1).split(';');
106
+ if (params.includes('0') || params[0] === '') {
107
+ next = params.length === 1 ? '' : token.raw;
108
+ }
109
+ else {
110
+ next += token.raw;
111
+ }
112
+ }
113
+ return next;
114
+ }
115
+ function renderWrappedSegment(tokens, start, end, prefix) {
116
+ const body = tokens.slice(start, end).map((token) => token.raw).join('');
117
+ const styled = prefix + body;
118
+ return prefix || tokens.slice(start, end).some((token) => token.sgr)
119
+ ? `${styled}\x1b[0m`
120
+ : styled;
121
+ }
122
+ export function wrapAnsiToVisualWidth(str, maxWidth) {
123
+ const widthLimit = Math.max(1, Math.floor(maxWidth));
124
+ if (!Number.isFinite(maxWidth) || visualWidth(str) <= widthLimit)
125
+ return [str];
126
+ const tokens = tokenizeForWrapping(str);
127
+ const lines = [];
128
+ let activeSgr = '';
129
+ let start = 0;
130
+ while (start < tokens.length) {
131
+ let width = 0;
132
+ let hasVisible = false;
133
+ let lastWhitespace = -1;
134
+ let index = start;
135
+ while (index < tokens.length) {
136
+ const token = tokens[index];
137
+ if (!token)
138
+ break;
139
+ if (token.sgr || token.width === 0) {
140
+ index += 1;
141
+ continue;
142
+ }
143
+ if (width + token.width > widthLimit && hasVisible)
144
+ break;
145
+ width += token.width;
146
+ hasVisible = true;
147
+ if (token.whitespace)
148
+ lastWhitespace = index;
149
+ index += 1;
150
+ }
151
+ if (index >= tokens.length) {
152
+ lines.push(renderWrappedSegment(tokens, start, tokens.length, activeSgr));
153
+ break;
154
+ }
155
+ const overflow = tokens[index];
156
+ let end = index;
157
+ let next = index;
158
+ if (overflow?.whitespace) {
159
+ next = index + 1;
160
+ }
161
+ else if (lastWhitespace >= start) {
162
+ end = lastWhitespace;
163
+ next = lastWhitespace + 1;
164
+ }
165
+ if (end === start) {
166
+ end = Math.max(index, start + 1);
167
+ next = end;
168
+ }
169
+ lines.push(renderWrappedSegment(tokens, start, end, activeSgr));
170
+ activeSgr = advanceSgr(activeSgr, tokens, start, next);
171
+ start = next;
172
+ }
173
+ return lines.length > 0 ? lines : [''];
174
+ }
@@ -1,4 +1,26 @@
1
1
  import chalk from 'chalk';
2
+ import gradient from 'gradient-string';
3
+ import { pickIcon } from './icons.js';
4
+ // The one gradient the brand uses anywhere it appears -- the startup logo,
5
+ // and (in text form) the persistent header wordmark. Defined once here so
6
+ // both stay the same three stops instead of drifting apart.
7
+ export const brandGradient = gradient([
8
+ { color: '#124689', pos: 0 },
9
+ { color: '#0ea5e9', pos: 0.55 },
10
+ { color: '#06b6d4', pos: 1 },
11
+ ]);
12
+ /** The brand wordmark treatment: bold text painted in `brandGradient`,
13
+ * falling back to plain text under NO_COLOR (gradient-string doesn't
14
+ * auto-respect it the way chalk's own colors do). Currently used by the
15
+ * header's persistent "nbtca" mark (`app/chrome.ts`) -- named here, not
16
+ * left as a one-off local helper, so any future chrome element that wants
17
+ * "the brand gradient, as a wordmark" has a single place to reuse instead
18
+ * of re-deriving the NO_COLOR/bold/gradient combination again. */
19
+ export function brandMark(s) {
20
+ if (process.env['NO_COLOR'])
21
+ return s;
22
+ return chalk.bold(brandGradient(s));
23
+ }
2
24
  export const c = {
3
25
  brand: (s) => chalk.hex('#0ea5e9')(s),
4
26
  accent: (s) => chalk.cyan(s),
@@ -20,3 +42,42 @@ export const c = {
20
42
  return chalk.red(s);
21
43
  },
22
44
  };
45
+ export const glyph = {
46
+ cursor: () => pickIcon('→', '>'),
47
+ rule: () => pickIcon('─', '-'),
48
+ bullet: () => pickIcon('·', '.'),
49
+ dot: () => pickIcon('●', '*'),
50
+ updown: () => pickIcon('↑↓', 'up/down'),
51
+ enter: () => pickIcon('⏎', 'enter'),
52
+ // Two-level "how full" bar cell (Home's day-progress, Schedule's
53
+ // term-progress) — a single source of truth so every such bar in the
54
+ // app reads as the same visual language. Not the same vocabulary as
55
+ // calendar-heatmap's 5-level intensity scale, which is a deliberately
56
+ // finer-grained density visualization, not a binary fill/empty bar.
57
+ barFilled: () => pickIcon('█', '#'),
58
+ barEmpty: () => pickIcon('░', '-'),
59
+ };
60
+ export const space = {
61
+ indent: ' ',
62
+ };
63
+ export const type = {
64
+ heading: (s) => chalk.bold.white(s),
65
+ label: (s) => chalk.white(s),
66
+ body: (s) => s,
67
+ hint: (s) => chalk.dim(s),
68
+ /** The one thing on this screen your eye should land on: the app's own
69
+ * name, the tab you're on, the row a menu's cursor sits on, the class
70
+ * that's happening right now. `heading` marks a section as structure;
71
+ * `active` marks a single point as attention — never both on the same
72
+ * element, and never more than one or two `active` uses per screen, or
73
+ * the signal stops meaning anything. Brand color (#0ea5e9) precisely
74
+ * because there is exactly one brand-worthy thing to say on each of
75
+ * these screens, and this is where it belongs. */
76
+ active: (s) => chalk.bold(c.brand(s)),
77
+ /** The grid cursor's own visual signal: a solid brand-colored background
78
+ * block, deliberately distinct from `active` (bold text on the default
79
+ * background) so "this is today" and "this is where your cursor is" never
80
+ * share one visual language, even when the cursor lands on today's own
81
+ * column. */
82
+ cursor: (s) => chalk.bgHex('#0ea5e9').black(s),
83
+ };
@@ -0,0 +1,19 @@
1
+ import { clearScreen } from './ui.js';
2
+ import { typeReveal } from './motion.js';
3
+ import { glyph, type, space } from './theme.js';
4
+ import { screenWidth } from './components/screen.js';
5
+ import { pickIcon } from './icons.js';
6
+ export function breadcrumb(label) {
7
+ return `nbtca ${pickIcon('›', '>')} ${label}`;
8
+ }
9
+ export function buildScreenHeaderLines(crumb) {
10
+ return [
11
+ space.indent + type.heading(crumb),
12
+ space.indent + type.hint(glyph.rule().repeat(screenWidth())),
13
+ '',
14
+ ];
15
+ }
16
+ export async function enterScreen(crumb) {
17
+ clearScreen();
18
+ await typeReveal(buildScreenHeaderLines(crumb));
19
+ }
package/dist/core/ui.js CHANGED
@@ -1,35 +1,13 @@
1
1
  /**
2
2
  * Minimalist UI component library
3
- * Delegates to @clack/prompts for modern terminal output
3
+ * Delegates to self-rendered widgets for terminal output
4
4
  */
5
- import { log, spinner as clackSpinner } from '@clack/prompts';
5
+ import { success, error, warning, info } from './components/messages.js';
6
+ import { startSpinner } from './components/spinner.js';
6
7
  import chalk from 'chalk';
7
8
  import { pickIcon } from './icons.js';
8
9
  import { t } from '../i18n/index.js';
9
- /**
10
- * Display success message
11
- */
12
- export function success(msg) {
13
- log.success(msg);
14
- }
15
- /**
16
- * Display error message
17
- */
18
- export function error(msg) {
19
- log.error(msg);
20
- }
21
- /**
22
- * Display info message
23
- */
24
- export function info(msg) {
25
- log.info(msg);
26
- }
27
- /**
28
- * Display warning message
29
- */
30
- export function warning(msg) {
31
- log.warn(msg);
32
- }
10
+ export { success, error, warning, info };
33
11
  /**
34
12
  * Display divider line
35
13
  */
@@ -59,9 +37,7 @@ export function printNewLine(count = 1) {
59
37
  * Caller is responsible for calling .stop(msg) or .stop(msg, 1) on error.
60
38
  */
61
39
  export function createSpinner(msg) {
62
- const s = clackSpinner();
63
- s.start(msg);
64
- return s;
40
+ return startSpinner(msg);
65
41
  }
66
42
  export function handleGracefulExit(err) {
67
43
  const message = err instanceof Error ? err.message : String(err ?? '');
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import chalk from 'chalk';
6
6
  import { pickIcon } from '../core/icons.js';
7
+ import { space, type } from '../core/theme.js';
7
8
  import { t, getCurrentLanguage } from '../i18n/index.js';
8
9
  /** Parse a 'YYYY-MM-DD' date string into a UTC proxy Date (host-timezone-independent). */
9
10
  function parseBucketDate(date) {
@@ -29,17 +30,22 @@ function countToGlyph(count) {
29
30
  return pickIcon('▓', '-');
30
31
  return pickIcon('█', '=');
31
32
  }
33
+ // A fixed 4-stop truecolor green ramp, not chalk's named green/greenBright --
34
+ // those resolve to whatever the user's terminal theme defines for "green",
35
+ // same problem the rest of the app's palette solved by specifying hex
36
+ // directly (theme.ts's brand blue, brandGradient) instead of leaning on
37
+ // ANSI color names. Kept muted/desaturated rather than a punchy grass
38
+ // green so it sits quietly next to the app's own cool blue palette instead
39
+ // of reading as a louder, disconnected accent.
40
+ const HEATMAP_RAMP = ['#1b4332', '#2d6a4f', '#40916c', '#52b788'];
41
+ const MAX_WEEK_COLUMNS = 53;
42
+ const GRID_PREFIX_WIDTH = 6;
32
43
  /** Apply a green color ramp based on count. Identity when count is 0. */
33
44
  function applyColor(glyph, count, useColor) {
34
45
  if (!useColor || count <= 0)
35
46
  return glyph;
36
- if (count === 1)
37
- return chalk.green(glyph);
38
- if (count === 2)
39
- return chalk.green(glyph);
40
- if (count === 3)
41
- return chalk.greenBright(glyph);
42
- return chalk.bold.greenBright(glyph);
47
+ const level = Math.min(count, HEATMAP_RAMP.length) - 1;
48
+ return chalk.hex(HEATMAP_RAMP[level])(glyph);
43
49
  }
44
50
  /**
45
51
  * Render a GitHub-contributions-style heatmap grid.
@@ -51,21 +57,21 @@ function applyColor(glyph, count, useColor) {
51
57
  export function renderHeatmap(buckets, today, options) {
52
58
  const useColor = options?.color === true;
53
59
  const trans = t();
60
+ const cellWidth = options?.cols !== undefined
61
+ && options.cols < GRID_PREFIX_WIDTH + MAX_WEEK_COLUMNS * 2 ? 1 : 2;
62
+ const availableColumns = options?.cols === undefined
63
+ ? MAX_WEEK_COLUMNS
64
+ : Math.floor((options.cols - GRID_PREFIX_WIDTH) / cellWidth);
65
+ const numCols = Math.max(1, Math.min(MAX_WEEK_COLUMNS, availableColumns));
54
66
  // Build a lookup map: 'YYYY-MM-DD' -> count
55
67
  const countByDate = new Map();
56
68
  for (const b of buckets) {
57
69
  countByDate.set(b.date, b.count);
58
70
  }
59
- // Determine the grid window.
60
- // The grid ends at "today" (aligned to Mon-start week column).
61
- // The grid starts 365 days before today.
62
71
  const todayProxy = new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()));
63
72
  // Find the Monday that starts the week containing today
64
73
  const todayMonIndex = utcDayToMonIndex(todayProxy.getUTCDay());
65
74
  const gridEndMs = todayProxy.getTime() + (6 - todayMonIndex) * 86400000; // Sunday of today's week
66
- // Grid start: 52 full weeks back from the start of today's week, plus today's partial week
67
- // Total columns = 53 weeks
68
- const numCols = 53;
69
75
  const gridStartMs = gridEndMs - (numCols * 7 - 1) * 86400000;
70
76
  const gridStart = new Date(gridStartMs);
71
77
  const columns = [];
@@ -89,15 +95,9 @@ export function renderHeatmap(buckets, today, options) {
89
95
  }
90
96
  columns.push(column);
91
97
  }
92
- // Month labels row. Labels are 3-letter English abbreviations (universal and
93
- // single-width, so alignment holds in any language). Each grid column occupies
94
- // 2 terminal cells (glyph + joining space); we write each month's label into a
95
- // character buffer starting at the column where that month begins, letting it
96
- // overflow rightward into the spacing of the following columns (months are
97
- // ~4-5 columns apart, so labels never collide).
98
- const weekdayLabel = ' '; // 3-char prefix, matches the grid rows' "Mo " prefix
98
+ const weekdayLabel = space.indent; // matches the grid rows' "Mo " prefix width
99
99
  const monthFmt = new Intl.DateTimeFormat('en-US', { month: 'short', timeZone: 'UTC' });
100
- const cellsWidth = numCols * 2;
100
+ const cellsWidth = numCols * cellWidth;
101
101
  const monthChars = new Array(cellsWidth).fill(' ');
102
102
  let prevMonth = -1;
103
103
  for (let col = 0; col < numCols; col++) {
@@ -115,13 +115,13 @@ export function renderHeatmap(buckets, today, options) {
115
115
  if (month !== prevMonth) {
116
116
  prevMonth = month;
117
117
  const label = monthFmt.format(labelDate); // e.g. "Jun"
118
- const start = col * 2;
118
+ const start = col * cellWidth;
119
119
  for (let i = 0; i < label.length && start + i < cellsWidth; i++) {
120
120
  monthChars[start + i] = label[i] ?? ' ';
121
121
  }
122
122
  }
123
123
  }
124
- const monthLabelLine = weekdayLabel + monthChars.join('');
124
+ const monthLabelLine = space.indent + weekdayLabel + monthChars.join('');
125
125
  // Weekday labels (Mon/Wed/Fri only, index 0/2/4 in Mon-indexed scheme)
126
126
  const lang = getCurrentLanguage();
127
127
  const weekdayNames = lang === 'zh'
@@ -129,8 +129,10 @@ export function renderHeatmap(buckets, today, options) {
129
129
  : ['Mo', ' ', 'We', ' ', 'Fr', ' ', ' '];
130
130
  // Build output lines
131
131
  const lines = [];
132
- // Title line
133
- lines.push(trans.calendar.heatmap.title);
132
+ // Title line — same space.indent + type.heading treatment every other
133
+ // section heading in the app uses (this one was bare, so it sat flush
134
+ // against the terminal edge instead of matching the app's 3-space margin).
135
+ lines.push(space.indent + type.heading(trans.calendar.heatmap.title));
134
136
  lines.push('');
135
137
  // Month labels line
136
138
  lines.push(monthLabelLine);
@@ -144,7 +146,7 @@ export function renderHeatmap(buckets, today, options) {
144
146
  const glyph = countToGlyph(cell.count);
145
147
  return applyColor(glyph, cell.count, useColor);
146
148
  });
147
- lines.push(`${wdLabel} ${cells.join(' ')}`);
149
+ lines.push(`${space.indent}${wdLabel} ${cells.join(cellWidth === 2 ? ' ' : '')}`);
148
150
  }
149
151
  // Legend line
150
152
  const legendGlyphs = [
@@ -164,6 +166,6 @@ export function renderHeatmap(buckets, today, options) {
164
166
  ]
165
167
  : legendGlyphs;
166
168
  lines.push('');
167
- lines.push(`${trans.calendar.heatmap.legendLess} ${legendColored.join('')} ${trans.calendar.heatmap.legendMore}`);
169
+ lines.push(`${space.indent}${type.hint(trans.calendar.heatmap.legendLess)} ${legendColored.join('')} ${type.hint(trans.calendar.heatmap.legendMore)}`);
168
170
  return lines.join('\n');
169
171
  }
@@ -0,0 +1,50 @@
1
+ export function weekRange(now) {
2
+ const start = new Date(now);
3
+ start.setHours(0, 0, 0, 0);
4
+ const mondayOffset = (start.getDay() + 6) % 7; // days since Monday
5
+ start.setDate(start.getDate() - mondayOffset);
6
+ const end = new Date(start);
7
+ end.setDate(end.getDate() + 7);
8
+ return { start, end };
9
+ }
10
+ export function monthRange(now) {
11
+ const start = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0);
12
+ const end = new Date(now.getFullYear(), now.getMonth() + 1, 1, 0, 0, 0, 0);
13
+ return { start, end };
14
+ }
15
+ export function filterEvents(events, query) {
16
+ const q = query.trim().toLowerCase();
17
+ if (!q)
18
+ return events;
19
+ return events.filter((e) => (e.title ?? '').toLowerCase().includes(q) ||
20
+ (e.location ?? '').toLowerCase().includes(q));
21
+ }
22
+ export function countdownParts(target, now) {
23
+ const ms = target.getTime() - now.getTime();
24
+ if (ms <= 0)
25
+ return { past: true, days: 0, hours: 0, minutes: 0 };
26
+ const totalMin = Math.floor(ms / 60000);
27
+ return {
28
+ past: false,
29
+ days: Math.floor(totalMin / 1440),
30
+ hours: Math.floor((totalMin % 1440) / 60),
31
+ minutes: totalMin % 60,
32
+ };
33
+ }
34
+ /** True once a countdown is close enough to call out visually (default: 15
35
+ * minutes or less). A `past` countdown is never urgent — there's nothing
36
+ * left to hurry for. */
37
+ export function isCountdownUrgent(p, thresholdMinutes = 15) {
38
+ if (p.past)
39
+ return false;
40
+ const totalMinutes = p.days * 1440 + p.hours * 60 + p.minutes;
41
+ return totalMinutes <= thresholdMinutes;
42
+ }
43
+ export function buildExportFilename(event) {
44
+ const cleaned = (event.title ?? '')
45
+ .replace(/[^\p{L}\p{N}\-_ ]/gu, '')
46
+ .trim()
47
+ .replace(/\s+/g, '-')
48
+ .slice(0, 60);
49
+ return `${cleaned || 'event'}.ics`;
50
+ }