@unbrained/pm-web 2026.8.10 → 2026.8.17

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 (182) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/app.d.ts +16 -0
  3. package/dist/app.js +16 -0
  4. package/dist/app.js.map +1 -1
  5. package/dist/auth.d.ts +5 -0
  6. package/dist/auth.js.map +1 -1
  7. package/dist/board.d.ts +53 -0
  8. package/dist/board.js +48 -0
  9. package/dist/board.js.map +1 -1
  10. package/dist/crypto.d.ts +23 -0
  11. package/dist/crypto.js +32 -0
  12. package/dist/crypto.js.map +1 -1
  13. package/dist/db.d.ts +19 -0
  14. package/dist/db.js +19 -0
  15. package/dist/db.js.map +1 -1
  16. package/dist/ical.d.ts +72 -0
  17. package/dist/ical.js +73 -0
  18. package/dist/ical.js.map +1 -1
  19. package/dist/index.d.ts +11 -0
  20. package/dist/index.js +52 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/middleware/auth.d.ts +8 -0
  23. package/dist/middleware/auth.js.map +1 -1
  24. package/dist/oidc.d.ts +87 -0
  25. package/dist/oidc.js +113 -0
  26. package/dist/oidc.js.map +1 -1
  27. package/dist/routes/admin.js +15 -0
  28. package/dist/routes/admin.js.map +1 -1
  29. package/dist/routes/extensions.js +10 -0
  30. package/dist/routes/extensions.js.map +1 -1
  31. package/dist/routes/github.js +13 -0
  32. package/dist/routes/github.js.map +1 -1
  33. package/dist/routes/oidc.d.ts +10 -0
  34. package/dist/routes/oidc.js +51 -0
  35. package/dist/routes/oidc.js.map +1 -1
  36. package/dist/routes/pm.js +132 -1
  37. package/dist/routes/pm.js.map +1 -1
  38. package/dist/routes/sharing.js +8 -0
  39. package/dist/routes/sharing.js.map +1 -1
  40. package/dist/services/mutation-event-watcher.js +13 -0
  41. package/dist/services/mutation-event-watcher.js.map +1 -1
  42. package/dist/services/package-catalog.d.ts +24 -0
  43. package/dist/services/package-catalog.js +22 -2
  44. package/dist/services/package-catalog.js.map +1 -1
  45. package/dist/services/pm-runner.d.ts +82 -0
  46. package/dist/services/pm-runner.js +121 -0
  47. package/dist/services/pm-runner.js.map +1 -1
  48. package/dist/services/project-watcher.d.ts +22 -0
  49. package/dist/services/project-watcher.js +51 -0
  50. package/dist/services/project-watcher.js.map +1 -1
  51. package/dist/services/realtime-bus.d.ts +46 -0
  52. package/dist/services/realtime-bus.js +59 -0
  53. package/dist/services/realtime-bus.js.map +1 -1
  54. package/dist/services/sse.d.ts +134 -0
  55. package/dist/services/sse.js +145 -0
  56. package/dist/services/sse.js.map +1 -1
  57. package/manifest.json +1 -1
  58. package/package.json +5 -3
  59. package/public/src/app.js +44 -1
  60. package/public/src/app.js.map +1 -1
  61. package/public/src/app.ts +44 -1
  62. package/public/src/components/modals.js +11 -0
  63. package/public/src/components/modals.js.map +1 -1
  64. package/public/src/components/modals.ts +11 -0
  65. package/public/src/components/toast.js +2 -0
  66. package/public/src/components/toast.js.map +1 -1
  67. package/public/src/components/toast.ts +2 -0
  68. package/public/src/constants.js +21 -4
  69. package/public/src/constants.js.map +1 -1
  70. package/public/src/constants.ts +21 -4
  71. package/public/src/filters.js +10 -7
  72. package/public/src/filters.js.map +1 -1
  73. package/public/src/filters.ts +10 -7
  74. package/public/src/i18n/de.json +3 -1
  75. package/public/src/i18n/en.json +3 -1
  76. package/public/src/i18n/es.json +3 -1
  77. package/public/src/i18n/zh.json +3 -1
  78. package/public/src/i18n.js +11 -1
  79. package/public/src/i18n.js.map +1 -1
  80. package/public/src/i18n.ts +13 -1
  81. package/public/src/state.js +3 -0
  82. package/public/src/state.js.map +1 -1
  83. package/public/src/state.ts +3 -0
  84. package/public/src/sw.ts +71 -7
  85. package/public/src/theme.js +13 -1
  86. package/public/src/theme.js.map +1 -1
  87. package/public/src/theme.ts +15 -1
  88. package/public/src/types.ts +2 -0
  89. package/public/src/utils.js +26 -1
  90. package/public/src/utils.js.map +1 -1
  91. package/public/src/utils.ts +26 -1
  92. package/public/src/views/activity.js +1 -0
  93. package/public/src/views/activity.js.map +1 -1
  94. package/public/src/views/activity.ts +1 -0
  95. package/public/src/views/admin.js +134 -0
  96. package/public/src/views/admin.js.map +1 -1
  97. package/public/src/views/admin.ts +134 -0
  98. package/public/src/views/auth.js +31 -0
  99. package/public/src/views/auth.js.map +1 -1
  100. package/public/src/views/auth.ts +31 -0
  101. package/public/src/views/calendar.js +3 -0
  102. package/public/src/views/calendar.js.map +1 -1
  103. package/public/src/views/calendar.ts +3 -0
  104. package/public/src/views/comments-audit.js +1 -0
  105. package/public/src/views/comments-audit.js.map +1 -1
  106. package/public/src/views/comments-audit.ts +1 -0
  107. package/public/src/views/config.js +67 -0
  108. package/public/src/views/config.js.map +1 -1
  109. package/public/src/views/config.ts +67 -0
  110. package/public/src/views/context.js +2 -0
  111. package/public/src/views/context.js.map +1 -1
  112. package/public/src/views/context.ts +2 -0
  113. package/public/src/views/create.js +3 -0
  114. package/public/src/views/create.js.map +1 -1
  115. package/public/src/views/create.ts +3 -0
  116. package/public/src/views/dedupe.js +1 -0
  117. package/public/src/views/dedupe.js.map +1 -1
  118. package/public/src/views/dedupe.ts +1 -0
  119. package/public/src/views/export.js +6 -0
  120. package/public/src/views/export.js.map +1 -1
  121. package/public/src/views/export.ts +6 -0
  122. package/public/src/views/github.js +58 -0
  123. package/public/src/views/github.js.map +1 -1
  124. package/public/src/views/github.ts +58 -0
  125. package/public/src/views/graph-canvas.js +18 -0
  126. package/public/src/views/graph-canvas.js.map +1 -1
  127. package/public/src/views/graph-canvas.ts +20 -0
  128. package/public/src/views/graph.js +41 -0
  129. package/public/src/views/graph.js.map +1 -1
  130. package/public/src/views/graph.ts +42 -1
  131. package/public/src/views/groups.js +45 -0
  132. package/public/src/views/groups.js.map +1 -1
  133. package/public/src/views/groups.ts +45 -0
  134. package/public/src/views/guide.js +3 -0
  135. package/public/src/views/guide.js.map +1 -1
  136. package/public/src/views/guide.ts +3 -0
  137. package/public/src/views/health.js +3 -0
  138. package/public/src/views/health.js.map +1 -1
  139. package/public/src/views/health.ts +3 -0
  140. package/public/src/views/items.js +196 -6
  141. package/public/src/views/items.js.map +1 -1
  142. package/public/src/views/items.ts +196 -6
  143. package/public/src/views/normalize.js +2 -0
  144. package/public/src/views/normalize.js.map +1 -1
  145. package/public/src/views/normalize.ts +2 -0
  146. package/public/src/views/packages.js +67 -5
  147. package/public/src/views/packages.js.map +1 -1
  148. package/public/src/views/packages.ts +74 -5
  149. package/public/src/views/plan-execution.js +6 -0
  150. package/public/src/views/plan-execution.js.map +1 -1
  151. package/public/src/views/plan-execution.ts +8 -0
  152. package/public/src/views/plan.js +145 -0
  153. package/public/src/views/plan.js.map +1 -1
  154. package/public/src/views/plan.ts +146 -1
  155. package/public/src/views/projects.js +60 -0
  156. package/public/src/views/projects.js.map +1 -1
  157. package/public/src/views/projects.ts +60 -0
  158. package/public/src/views/router.js +43 -0
  159. package/public/src/views/router.js.map +1 -1
  160. package/public/src/views/router.ts +43 -0
  161. package/public/src/views/search.js +5 -0
  162. package/public/src/views/search.js.map +1 -1
  163. package/public/src/views/search.ts +5 -0
  164. package/public/src/views/settings.js +36 -0
  165. package/public/src/views/settings.js.map +1 -1
  166. package/public/src/views/settings.ts +36 -0
  167. package/public/src/views/shared.js +1 -0
  168. package/public/src/views/shared.js.map +1 -1
  169. package/public/src/views/shared.ts +1 -0
  170. package/public/src/views/sharing.js +32 -0
  171. package/public/src/views/sharing.js.map +1 -1
  172. package/public/src/views/sharing.ts +32 -0
  173. package/public/src/views/stats.js +1 -0
  174. package/public/src/views/stats.js.map +1 -1
  175. package/public/src/views/stats.ts +1 -0
  176. package/public/src/views/templates.js +4 -0
  177. package/public/src/views/templates.js.map +1 -1
  178. package/public/src/views/templates.ts +4 -0
  179. package/public/src/views/validate.js +1 -0
  180. package/public/src/views/validate.js.map +1 -1
  181. package/public/src/views/validate.ts +1 -0
  182. package/public/sw.js +60 -5
@@ -24,6 +24,8 @@ export const LOCALE_STORAGE_KEY = 'pmLocale';
24
24
 
25
25
  /** Locales shipped by this package. The first entry is the default/fallback. */
26
26
  export const SUPPORTED_LOCALES = ['en', 'de', 'es', 'zh'] as const;
27
+ /** String literal union of every locale code this package ships a catalog
28
+ * for, derived from `SUPPORTED_LOCALES`. */
27
29
  export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
28
30
 
29
31
  const DEFAULT_LOCALE: SupportedLocale = 'en';
@@ -166,7 +168,8 @@ export async function initI18n(): Promise<void> {
166
168
  applyTranslations();
167
169
  }
168
170
 
169
- /** Current active locale. */
171
+ /** Return the locale currently selected for the session, set by `initI18n` or
172
+ * `setLocale`. Defaults to the English fallback before initialization. */
170
173
  export function getLocale(): SupportedLocale {
171
174
  return currentLocale;
172
175
  }
@@ -232,6 +235,10 @@ export function applyTranslations(root?: ParentNode): void {
232
235
  bindAttr(scope, '[data-i18n-aria]', 'data-i18n-aria', (el, v) => { el.setAttribute('aria-label', v); });
233
236
  }
234
237
 
238
+ /** For every element in `scope` matching `selector`, read its `attr` data
239
+ * key, resolve the translation (with optional `data-i18n-params`
240
+ * substitutions), and pass the result to `setter`. Used to bind each
241
+ * `data-i18n*` flavor. */
235
242
  function bindAttr(
236
243
  scope: ParentNode,
237
244
  selector: string,
@@ -247,6 +254,8 @@ function bindAttr(
247
254
  });
248
255
  }
249
256
 
257
+ /** Parse a `data-i18n-params` JSON string into a parameter object, returning
258
+ * `undefined` when the JSON is missing, malformed, or not a plain object. */
250
259
  function safeParseParams(json: string): Record<string, string | number> | undefined {
251
260
  try {
252
261
  const parsed = JSON.parse(json) as unknown;
@@ -274,6 +283,9 @@ export function translateError(message: string): string {
274
283
  // Lazy reverse map: English error text → catalog key. Built from enCatalog
275
284
  // so it tracks the catalog without a hand-maintained second table.
276
285
  let cachedErrorReverse: Map<string, string> | null = null;
286
+ /** Build and cache a reverse lookup from English error text to its catalog
287
+ * key (covering only `error.*` entries), so server error strings can be
288
+ * mapped back to translatable keys at the display layer. */
277
289
  function errorReverseMap(): Map<string, string> {
278
290
  if (cachedErrorReverse) return cachedErrorReverse;
279
291
  const map = new Map<string, string>();
@@ -1,3 +1,6 @@
1
+ /** Mutable singleton holding the SPA's global application state: the signed-in
2
+ * user, loaded projects, the active project and view, item list and filters,
3
+ * and search state. Views read and mutate this directly. */
1
4
  export const state = {
2
5
  user: null,
3
6
  projects: [],
@@ -1 +1 @@
1
- {"version":3,"file":"state.js","sourceRoot":"","sources":["state.ts"],"names":[],"mappings":"AAKA,MAAM,CAAC,MAAM,KAAK,GAAa;IAC7B,IAAI,EAAE,IAAI;IACV,QAAQ,EAAE,EAAE;IACZ,cAAc,EAAE,IAAI;IACpB,WAAW,EAAE,UAAU;IACvB,OAAO,EAAE,OAAO;IAChB,KAAK,EAAE,EAAE;IACT,WAAW,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,GAAG,EAAE,EAAE,EAAE;IACnG,WAAW,EAAE,EAAE;IACf,aAAa,EAAE,EAAE;IACjB,UAAU,EAAE,QAAQ;IACpB,SAAS,EAAE,CAAC;IACZ,MAAM,EAAE,IAAI;CACb,CAAC"}
1
+ {"version":3,"file":"state.js","sourceRoot":"","sources":["state.ts"],"names":[],"mappings":"AAKA;;4DAE4D;AAC5D,MAAM,CAAC,MAAM,KAAK,GAAa;IAC7B,IAAI,EAAE,IAAI;IACV,QAAQ,EAAE,EAAE;IACZ,cAAc,EAAE,IAAI;IACpB,WAAW,EAAE,UAAU;IACvB,OAAO,EAAE,OAAO;IAChB,KAAK,EAAE,EAAE;IACT,WAAW,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,GAAG,EAAE,EAAE,EAAE;IACnG,WAAW,EAAE,EAAE;IACf,aAAa,EAAE,EAAE;IACjB,UAAU,EAAE,QAAQ;IACpB,SAAS,EAAE,CAAC;IACZ,MAAM,EAAE,IAAI;CACb,CAAC"}
@@ -3,6 +3,9 @@
3
3
  // ═══════════════════════════════════════════════════════════════
4
4
  import type { AppState } from './types.js';
5
5
 
6
+ /** Mutable singleton holding the SPA's global application state: the signed-in
7
+ * user, loaded projects, the active project and view, item list and filters,
8
+ * and search state. Views read and mutate this directly. */
6
9
  export const state: AppState = {
7
10
  user: null,
8
11
  projects: [],
package/public/src/sw.ts CHANGED
@@ -127,6 +127,9 @@ interface SyncEvent extends ExtendableEvent {
127
127
  readonly tag: string;
128
128
  }
129
129
 
130
+ /** Open (creating on first run) the IndexedDB database backing the offline
131
+ * mutation queue, ensuring the `mutations` object store and its timestamp
132
+ * index exist. Resolves with the ready database handle. */
130
133
  function openMutationDB(): Promise<IDBDatabase> {
131
134
  return new Promise((resolve, reject) => {
132
135
  const request = indexedDB.open(MUTATION_DB, 1);
@@ -185,18 +188,32 @@ async function queueMutation(method: string, path: string, body: unknown): Promi
185
188
  }
186
189
  }
187
190
 
188
- async function getQueuedMutations(): Promise<QueuedMutation[]> {
191
+ /** The outcome of reading the mutation queue: either the mutations were read
192
+ * successfully, or the read failed and the queue state is unknown. The
193
+ * discriminator lets callers distinguish an empty queue from an unreadable
194
+ * one instead of collapsing both into `[]`. */
195
+ type QueueReadResult =
196
+ | { ok: true; mutations: QueuedMutation[] }
197
+ | { ok: false; error: unknown };
198
+
199
+ /** Read every queued mutation out of IndexedDB in insertion order. Returns
200
+ * `{ ok: true, mutations }` on success (including an empty array when the
201
+ * queue is genuinely empty) or `{ ok: false, error }` on storage failure so
202
+ * callers can distinguish an empty queue from an unreadable one instead of
203
+ * treating both as drained. */
204
+ async function getQueuedMutations(): Promise<QueueReadResult> {
189
205
  try {
190
206
  const db = await openMutationDB();
191
207
  const tx = db.transaction(MUTATION_STORE, 'readonly');
192
208
  const store = tx.objectStore(MUTATION_STORE);
193
- return await new Promise<QueuedMutation[]>((resolve, reject) => {
209
+ const mutations = await new Promise<QueuedMutation[]>((resolve, reject) => {
194
210
  const request = store.getAll();
195
211
  request.onsuccess = () => resolve(request.result as QueuedMutation[]);
196
212
  request.onerror = () => reject(request.error);
197
213
  });
198
- } catch {
199
- return [];
214
+ return { ok: true, mutations };
215
+ } catch (error) {
216
+ return { ok: false, error };
200
217
  }
201
218
  }
202
219
 
@@ -218,10 +235,22 @@ async function clearMutation(id: number): Promise<boolean> {
218
235
  }
219
236
  }
220
237
 
238
+ /** Replay queued mutations to the API in order, removing each on success and
239
+ * stopping at the first failure so it can retry later, then post-message the
240
+ * connected clients with how many were replayed or remain. A storage read
241
+ * failure is never treated as a drained queue: the initial read failure
242
+ * aborts the flush, and a final read failure reports a partial result rather
243
+ * than claiming all mutations were replayed. */
221
244
  async function flushMutationQueue(): Promise<void> {
222
- const mutations = await getQueuedMutations();
245
+ const read = await getQueuedMutations();
246
+ if (!read.ok) {
247
+ console.warn('Failed to read mutation queue:', read.error);
248
+ return;
249
+ }
250
+ const mutations = read.mutations;
223
251
  if (mutations.length === 0) return;
224
252
 
253
+ let replayed = 0;
225
254
  for (const mut of mutations) {
226
255
  try {
227
256
  const opts: RequestInit = {
@@ -238,6 +267,7 @@ async function flushMutationQueue(): Promise<void> {
238
267
  // avoid duplicate replays on the next flush; it will retry later.
239
268
  break;
240
269
  }
270
+ replayed++;
241
271
  } else {
242
272
  console.warn('Offline mutation failed:', mut.method, mut.path, res.status);
243
273
  // Stop processing on first failure — try again later
@@ -250,8 +280,25 @@ async function flushMutationQueue(): Promise<void> {
250
280
  }
251
281
 
252
282
  // Notify clients about replayed mutations
253
- const remaining = await getQueuedMutations();
283
+ const remainingRead = await getQueuedMutations();
254
284
  const clients = await sw.clients.matchAll();
285
+ if (!remainingRead.ok) {
286
+ // Could not re-read the queue — do NOT claim all mutations were replayed.
287
+ // Report a partial result with the known replayed count so unreplayed
288
+ // mutations are not treated as drained.
289
+ console.warn('Failed to re-read mutation queue after replay:', remainingRead.error);
290
+ if (mutations.length > 0) {
291
+ clients.forEach((client) => {
292
+ client.postMessage({
293
+ type: 'MUTATIONS_PARTIAL',
294
+ replayed,
295
+ remaining: mutations.length - replayed,
296
+ });
297
+ });
298
+ }
299
+ return;
300
+ }
301
+ const remaining = remainingRead.mutations;
255
302
  if (remaining.length === 0 && mutations.length > 0) {
256
303
  clients.forEach((client) => {
257
304
  client.postMessage({ type: 'MUTATIONS_REPLAYED', count: mutations.length });
@@ -445,4 +492,21 @@ sw.addEventListener('sync', (event: Event) => {
445
492
  // ── Online event: flush queue when connectivity returns ──
446
493
  sw.addEventListener('online', () => {
447
494
  void flushMutationQueue();
448
- });
495
+ });
496
+
497
+ // ── Test-only: expose queue internals ──
498
+ // app.ts registers this file as a CLASSIC worker (`register('/sw.js', ...)`
499
+ // with no `type: 'module'`), so it cannot use `export` — a module-mode script
500
+ // fails to load under a classic registration, which would take offline support
501
+ // down. That leaves a global as the only seam a test can reach.
502
+ //
503
+ // It is inert in the browser because a classic service worker is the only
504
+ // script that ever runs in its own global scope: no page script, extension or
505
+ // import shares it, so nothing exists that could set the flag before this line
506
+ // executes. That invariant is the registration mode, not the flag name — if
507
+ // app.ts ever registers with `type: 'module'`, replace this with real exports
508
+ // rather than keeping both.
509
+ const __testGlobals = globalThis as unknown as Record<string, unknown>;
510
+ if (__testGlobals.__swTestHarness) {
511
+ __testGlobals.__swInternals = { getQueuedMutations, flushMutationQueue, queueMutation, clearMutation };
512
+ }
@@ -20,6 +20,9 @@ const LABEL = {
20
20
  light: 'Theme: light',
21
21
  dark: 'Theme: dark',
22
22
  };
23
+ /** Read the persisted theme choice from localStorage, defaulting to `auto`
24
+ * when no preference is stored or when localStorage is unavailable (e.g.
25
+ * private browsing). */
23
26
  export function getStoredTheme() {
24
27
  try {
25
28
  const v = localStorage.getItem(STORAGE_KEY);
@@ -29,6 +32,9 @@ export function getStoredTheme() {
29
32
  catch { /* localStorage may be unavailable (private mode) */ }
30
33
  return 'auto';
31
34
  }
35
+ /** Apply a theme to the page: set `data-theme` on `<html>`, update the toggle
36
+ * button's glyph/title, and sync the `<meta name="theme-color">` tag so the
37
+ * mobile browser chrome matches the chosen palette. */
32
38
  export function applyTheme(theme) {
33
39
  document.documentElement.setAttribute('data-theme', theme);
34
40
  const btn = document.getElementById('theme-toggle');
@@ -50,6 +56,8 @@ export function applyTheme(theme) {
50
56
  meta.setAttribute('content', color);
51
57
  }
52
58
  }
59
+ /** Persist a theme choice to localStorage and immediately apply it to the
60
+ * document. */
53
61
  export function setTheme(theme) {
54
62
  try {
55
63
  localStorage.setItem(STORAGE_KEY, theme);
@@ -57,12 +65,16 @@ export function setTheme(theme) {
57
65
  catch { /* ignore */ }
58
66
  applyTheme(theme);
59
67
  }
68
+ /** Advance to the next theme in the auto → light → dark order (wrapping
69
+ * around) and persist plus apply the new selection. */
60
70
  export function cycleTheme() {
61
71
  const current = getStoredTheme();
62
72
  const next = ORDER[(ORDER.indexOf(current) + 1) % ORDER.length];
63
73
  setTheme(next);
64
74
  }
65
- // Apply the persisted (or default) theme as early as possible.
75
+ /** Apply the persisted (or default) theme early to avoid a flash of the wrong
76
+ * palette, then register a listener so `auto` mode tracks live OS theme
77
+ * changes. */
66
78
  export function initTheme() {
67
79
  applyTheme(getStoredTheme());
68
80
  // When in auto mode, react to live OS theme changes.
@@ -1 +1 @@
1
- {"version":3,"file":"theme.js","sourceRoot":"","sources":["theme.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"theme.js","sourceRoot":"","sources":["theme.ts"],"names":[],"mappings":"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"}
@@ -8,6 +8,8 @@
8
8
  // preference) is "auto", matching the data-theme="auto" attribute in the HTML
9
9
  // shell so there is no flash of the wrong theme on load.
10
10
 
11
+ /** The user-selectable theme modes: an explicit `dark` or `light`, or `auto`
12
+ * which follows the OS `prefers-color-scheme` setting. */
11
13
  export type Theme = 'dark' | 'light' | 'auto';
12
14
 
13
15
  const STORAGE_KEY = 'pm-web-theme';
@@ -26,6 +28,9 @@ const LABEL: Record<Theme, string> = {
26
28
  dark: 'Theme: dark',
27
29
  };
28
30
 
31
+ /** Read the persisted theme choice from localStorage, defaulting to `auto`
32
+ * when no preference is stored or when localStorage is unavailable (e.g.
33
+ * private browsing). */
29
34
  export function getStoredTheme(): Theme {
30
35
  try {
31
36
  const v = localStorage.getItem(STORAGE_KEY);
@@ -34,6 +39,9 @@ export function getStoredTheme(): Theme {
34
39
  return 'auto';
35
40
  }
36
41
 
42
+ /** Apply a theme to the page: set `data-theme` on `<html>`, update the toggle
43
+ * button's glyph/title, and sync the `<meta name="theme-color">` tag so the
44
+ * mobile browser chrome matches the chosen palette. */
37
45
  export function applyTheme(theme: Theme): void {
38
46
  document.documentElement.setAttribute('data-theme', theme);
39
47
  const btn = document.getElementById('theme-toggle');
@@ -54,18 +62,24 @@ export function applyTheme(theme: Theme): void {
54
62
  }
55
63
  }
56
64
 
65
+ /** Persist a theme choice to localStorage and immediately apply it to the
66
+ * document. */
57
67
  export function setTheme(theme: Theme): void {
58
68
  try { localStorage.setItem(STORAGE_KEY, theme); } catch { /* ignore */ }
59
69
  applyTheme(theme);
60
70
  }
61
71
 
72
+ /** Advance to the next theme in the auto → light → dark order (wrapping
73
+ * around) and persist plus apply the new selection. */
62
74
  export function cycleTheme(): void {
63
75
  const current = getStoredTheme();
64
76
  const next = ORDER[(ORDER.indexOf(current) + 1) % ORDER.length];
65
77
  setTheme(next);
66
78
  }
67
79
 
68
- // Apply the persisted (or default) theme as early as possible.
80
+ /** Apply the persisted (or default) theme early to avoid a flash of the wrong
81
+ * palette, then register a listener so `auto` mode tracks live OS theme
82
+ * changes. */
69
83
  export function initTheme(): void {
70
84
  applyTheme(getStoredTheme());
71
85
  // When in auto mode, react to live OS theme changes.
@@ -2,6 +2,7 @@
2
2
  // TYPES
3
3
  // ═══════════════════════════════════════════════════════════════
4
4
 
5
+ /** The signed-in application user, as returned by the auth/session endpoints. */
5
6
  export interface User {
6
7
  id: string;
7
8
  email: string;
@@ -255,6 +256,7 @@ export interface AdminGroup {
255
256
 
256
257
  export type ToastType = 'info' | 'success' | 'error';
257
258
 
259
+ /** Union of every navigation view key the SPA router can display. */
258
260
  export type ViewName =
259
261
  | 'projects' | 'items' | 'create' | 'activity' | 'search'
260
262
  | 'stats' | 'calendar' | 'context' | 'graph' | 'sharing' | 'groups'
@@ -2,23 +2,41 @@
2
2
  // UTILITIES
3
3
  // ═══════════════════════════════════════════════════════════════
4
4
  import { PRIORITY_LABELS, TYPE_ICONS } from './constants.js';
5
+ /** Escape `&`, `<`, `>` and `"` so a value can be inserted into HTML text or a
6
+ * double-quoted attribute. Falsy input yields an empty string.
7
+ *
8
+ * NOT sufficient for a JavaScript context. It does not escape `'`, and even if
9
+ * it did, the browser HTML-decodes an attribute value before the JS parser sees
10
+ * it, so an entity cannot keep a quote from terminating a string literal. A
11
+ * value interpolated into an inline handler — `onclick="fn('${escHtml(x)}')"` —
12
+ * is therefore still injectable. Pass such values through a `data-*` attribute
13
+ * and bind with `addEventListener` instead. */
5
14
  export function escHtml(s) {
6
15
  if (!s)
7
16
  return '';
8
17
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
9
18
  }
19
+ /** Build a colored status-badge span for an item status, mapping the
20
+ * `in_progress` code to "In Progress" and CSS-classifying the rest by slug. */
10
21
  export function statusBadge(s) {
11
22
  const labels = { in_progress: 'In Progress' };
12
23
  const status = String(s || 'unknown');
13
24
  const classSuffix = status.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'unknown';
14
25
  return `<span class="status-badge status-${classSuffix}">${escHtml(labels[status] || status)}</span>`;
15
26
  }
27
+ /** Render a priority indicator span (P0–P4) whose color class and tooltip
28
+ * label come from the PRIORITY_LABELS lookup table. */
16
29
  export function priorityDot(p) {
17
30
  return `<span class="priority-dot priority-${p}" title="P${p}: ${PRIORITY_LABELS[p] || ''}"></span>`;
18
31
  }
32
+ /** Render an item-type glyph span using the TYPE_ICONS map, falling back to
33
+ * the middle-dot character when the type has no assigned icon. */
19
34
  export function typeIcon(t) {
20
- return `<span class="item-type-icon" title="${t}">${TYPE_ICONS[t] || '·'}</span>`;
35
+ return `<span class="item-type-icon" title="${escHtml(t)}">${TYPE_ICONS[t] || '·'}</span>`;
21
36
  }
37
+ /** Format a timestamp as a short relative phrase ("just now", "5m ago",
38
+ * "3h ago", "2d ago") or, past a week, a localized calendar date. Falsy or
39
+ * unparseable input returns an empty string. */
22
40
  export function relTime(ts) {
23
41
  if (!ts)
24
42
  return '';
@@ -35,11 +53,16 @@ export function relTime(ts) {
35
53
  return `${Math.floor(s / 86400)}d ago`;
36
54
  return d.toLocaleDateString();
37
55
  }
56
+ /** Format a timestamp as a US-style short date (e.g. "Jan 5, 2026"). Falsy
57
+ * input returns an empty string. */
38
58
  export function fmtDate(ts) {
39
59
  if (!ts)
40
60
  return '';
41
61
  return new Date(ts).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
42
62
  }
63
+ /** Toggle a button's disabled state and, when `text` is supplied, update the
64
+ * button label — replacing the first inner span when present, otherwise the
65
+ * whole text content. */
43
66
  export function setLoading(btn, yes, text) {
44
67
  btn.disabled = yes;
45
68
  if (text) {
@@ -50,9 +73,11 @@ export function setLoading(btn, yes, text) {
50
73
  btn.textContent = text;
51
74
  }
52
75
  }
76
+ /** Build an HTML string of `n` placeholder skeleton rows for loading states. */
53
77
  export function skeletonRows(n = 5) {
54
78
  return Array.from({ length: n }, () => '<div class="skeleton skeleton-row"></div>').join('');
55
79
  }
80
+ /** Build an HTML string of `n` placeholder skeleton cards for loading states. */
56
81
  export function skeletonCards(n = 3) {
57
82
  return Array.from({ length: n }, () => '<div class="skeleton skeleton-card"></div>').join('');
58
83
  }
@@ -1 +1 @@
1
- {"version":3,"file":"utils.js","sourceRoot":"","sources":["utils.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"utils.js","sourceRoot":"","sources":["utils.ts"],"names":[],"mappings":"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"}
@@ -3,11 +3,22 @@
3
3
  // ═══════════════════════════════════════════════════════════════
4
4
  import { PRIORITY_LABELS, TYPE_ICONS } from './constants.js';
5
5
 
6
+ /** Escape `&`, `<`, `>` and `"` so a value can be inserted into HTML text or a
7
+ * double-quoted attribute. Falsy input yields an empty string.
8
+ *
9
+ * NOT sufficient for a JavaScript context. It does not escape `'`, and even if
10
+ * it did, the browser HTML-decodes an attribute value before the JS parser sees
11
+ * it, so an entity cannot keep a quote from terminating a string literal. A
12
+ * value interpolated into an inline handler — `onclick="fn('${escHtml(x)}')"` —
13
+ * is therefore still injectable. Pass such values through a `data-*` attribute
14
+ * and bind with `addEventListener` instead. */
6
15
  export function escHtml(s: string | undefined | null): string {
7
16
  if (!s) return '';
8
17
  return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
9
18
  }
10
19
 
20
+ /** Build a colored status-badge span for an item status, mapping the
21
+ * `in_progress` code to "In Progress" and CSS-classifying the rest by slug. */
11
22
  export function statusBadge(s: string): string {
12
23
  const labels: Record<string, string> = { in_progress: 'In Progress' };
13
24
  const status = String(s || 'unknown');
@@ -15,14 +26,21 @@ export function statusBadge(s: string): string {
15
26
  return `<span class="status-badge status-${classSuffix}">${escHtml(labels[status] || status)}</span>`;
16
27
  }
17
28
 
29
+ /** Render a priority indicator span (P0–P4) whose color class and tooltip
30
+ * label come from the PRIORITY_LABELS lookup table. */
18
31
  export function priorityDot(p: number): string {
19
32
  return `<span class="priority-dot priority-${p}" title="P${p}: ${PRIORITY_LABELS[p]||''}"></span>`;
20
33
  }
21
34
 
35
+ /** Render an item-type glyph span using the TYPE_ICONS map, falling back to
36
+ * the middle-dot character when the type has no assigned icon. */
22
37
  export function typeIcon(t: string): string {
23
- return `<span class="item-type-icon" title="${t}">${TYPE_ICONS[t]||'·'}</span>`;
38
+ return `<span class="item-type-icon" title="${escHtml(t)}">${TYPE_ICONS[t]||'·'}</span>`;
24
39
  }
25
40
 
41
+ /** Format a timestamp as a short relative phrase ("just now", "5m ago",
42
+ * "3h ago", "2d ago") or, past a week, a localized calendar date. Falsy or
43
+ * unparseable input returns an empty string. */
26
44
  export function relTime(ts: string | undefined | null): string {
27
45
  if (!ts) return '';
28
46
  const d = new Date(ts);
@@ -35,11 +53,16 @@ export function relTime(ts: string | undefined | null): string {
35
53
  return d.toLocaleDateString();
36
54
  }
37
55
 
56
+ /** Format a timestamp as a US-style short date (e.g. "Jan 5, 2026"). Falsy
57
+ * input returns an empty string. */
38
58
  export function fmtDate(ts: string | undefined | null): string {
39
59
  if (!ts) return '';
40
60
  return new Date(ts).toLocaleDateString('en-US', { year:'numeric', month:'short', day:'numeric' });
41
61
  }
42
62
 
63
+ /** Toggle a button's disabled state and, when `text` is supplied, update the
64
+ * button label — replacing the first inner span when present, otherwise the
65
+ * whole text content. */
43
66
  export function setLoading(btn: HTMLButtonElement, yes: boolean, text?: string): void {
44
67
  btn.disabled = yes;
45
68
  if (text) {
@@ -49,10 +72,12 @@ export function setLoading(btn: HTMLButtonElement, yes: boolean, text?: string):
49
72
  }
50
73
  }
51
74
 
75
+ /** Build an HTML string of `n` placeholder skeleton rows for loading states. */
52
76
  export function skeletonRows(n = 5): string {
53
77
  return Array.from({length: n}, () => '<div class="skeleton skeleton-row"></div>').join('');
54
78
  }
55
79
 
80
+ /** Build an HTML string of `n` placeholder skeleton cards for loading states. */
56
81
  export function skeletonCards(n = 3): string {
57
82
  return Array.from({length: n}, () => '<div class="skeleton skeleton-card"></div>').join('');
58
83
  }
@@ -5,6 +5,7 @@ import { state } from '../state.js';
5
5
  import { api } from '../api.js';
6
6
  import { escHtml, relTime } from '../utils.js';
7
7
  import { TYPE_ICONS } from '../constants.js';
8
+ /** Renders the activity feed for the current project, listing recent changes with their type icon, a description or fallback, and a relative timestamp. */
8
9
  export async function renderActivityView() {
9
10
  const el = document.getElementById('content-activity');
10
11
  if (!el)
@@ -1 +1 @@
1
- {"version":3,"file":"activity.js","sourceRoot":"","sources":["activity.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,gBAAgB;AAChB,kEAAkE;AAClE,OAAO,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AACpC,OAAO,EAAE,GAAG,EAAE,MAAM,WAAW,CAAC;AAChC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAG7C,MAAM,CAAC,KAAK,UAAU,kBAAkB;IACtC,MAAM,EAAE,GAAG,QAAQ,CAAC,cAAc,CAAC,kBAAkB,CAAC,CAAC;IACvD,IAAI,CAAC,EAAE;QAAE,OAAO;IAChB,IAAI,CAAC,KAAK,CAAC,cAAc,EAAE,CAAC;QAAC,EAAE,CAAC,SAAS,GAAG,wFAAwF,CAAC;QAAC,OAAO;IAAC,CAAC;IAC/I,EAAE,CAAC,SAAS,GAAG;;gGAE+E,OAAO,CAAC,KAAK,CAAC,cAAc,CAAC,IAAI,CAAC;;;iJAGe,CAAC;IAEhJ,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,GAAG,CAAmB,KAAK,EAAC,aAAa,KAAK,CAAC,cAAc,CAAC,EAAE,uBAAuB,CAAC,CAAC;QAC5G,MAAM,KAAK,GAAoB,IAAI,CAAC,QAAQ,IAAI,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;QACjE,MAAM,MAAM,GAAG,QAAQ,CAAC,cAAc,CAAC,eAAe,CAAC,CAAC;QACxD,IAAI,CAAC,MAAM;YAAE,OAAO;QACpB,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACvB,MAAM,CAAC,SAAS,GAAG,yHAAyH,CAAC;YAC7I,OAAO;QACT,CAAC;QACD,MAAM,CAAC,SAAS,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAC,EAAE,CAAA;;qCAED,UAAU,CAAC,CAAC,CAAC,IAAI,IAAE,EAAE,CAAC,IAAE,GAAG;;uCAEzB,OAAO,CAAC,CAAC,CAAC,OAAO,IAAE,CAAC,CAAC,KAAK,IAAE,CAAC,CAAC,MAAM,IAAE,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC;uCACxD,OAAO,CAAC,CAAC,CAAC,SAAS,IAAE,CAAC,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,EAAE,CAAA,CAAC,CAAA,+CAA+C,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,SAAS,CAAA,CAAC,CAAA,EAAE;;aAE7I,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACtB,CAAC;IAAC,OAAM,GAAY,EAAE,CAAC;QACrB,MAAM,MAAM,GAAG,QAAQ,CAAC,cAAc,CAAC,eAAe,CAAC,CAAC;QACxD,IAAI,MAAM;YAAE,MAAM,CAAC,SAAS,GAAG,iEAAiE,OAAO,CAAC,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,cAAc,CAAC;IAC1K,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"activity.js","sourceRoot":"","sources":["activity.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,gBAAgB;AAChB,kEAAkE;AAClE,OAAO,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AACpC,OAAO,EAAE,GAAG,EAAE,MAAM,WAAW,CAAC;AAChC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAG7C,2JAA2J;AAC3J,MAAM,CAAC,KAAK,UAAU,kBAAkB;IACtC,MAAM,EAAE,GAAG,QAAQ,CAAC,cAAc,CAAC,kBAAkB,CAAC,CAAC;IACvD,IAAI,CAAC,EAAE;QAAE,OAAO;IAChB,IAAI,CAAC,KAAK,CAAC,cAAc,EAAE,CAAC;QAAC,EAAE,CAAC,SAAS,GAAG,wFAAwF,CAAC;QAAC,OAAO;IAAC,CAAC;IAC/I,EAAE,CAAC,SAAS,GAAG;;gGAE+E,OAAO,CAAC,KAAK,CAAC,cAAc,CAAC,IAAI,CAAC;;;iJAGe,CAAC;IAEhJ,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,GAAG,CAAmB,KAAK,EAAC,aAAa,KAAK,CAAC,cAAc,CAAC,EAAE,uBAAuB,CAAC,CAAC;QAC5G,MAAM,KAAK,GAAoB,IAAI,CAAC,QAAQ,IAAI,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;QACjE,MAAM,MAAM,GAAG,QAAQ,CAAC,cAAc,CAAC,eAAe,CAAC,CAAC;QACxD,IAAI,CAAC,MAAM;YAAE,OAAO;QACpB,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACvB,MAAM,CAAC,SAAS,GAAG,yHAAyH,CAAC;YAC7I,OAAO;QACT,CAAC;QACD,MAAM,CAAC,SAAS,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAC,EAAE,CAAA;;qCAED,UAAU,CAAC,CAAC,CAAC,IAAI,IAAE,EAAE,CAAC,IAAE,GAAG;;uCAEzB,OAAO,CAAC,CAAC,CAAC,OAAO,IAAE,CAAC,CAAC,KAAK,IAAE,CAAC,CAAC,MAAM,IAAE,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC;uCACxD,OAAO,CAAC,CAAC,CAAC,SAAS,IAAE,CAAC,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,EAAE,CAAA,CAAC,CAAA,+CAA+C,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,SAAS,CAAA,CAAC,CAAA,EAAE;;aAE7I,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACtB,CAAC;IAAC,OAAM,GAAY,EAAE,CAAC;QACrB,MAAM,MAAM,GAAG,QAAQ,CAAC,cAAc,CAAC,eAAe,CAAC,CAAC;QACxD,IAAI,MAAM;YAAE,MAAM,CAAC,SAAS,GAAG,iEAAiE,OAAO,CAAC,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,cAAc,CAAC;IAC1K,CAAC;AACH,CAAC"}
@@ -7,6 +7,7 @@ import { escHtml, relTime } from '../utils.js';
7
7
  import { TYPE_ICONS } from '../constants.js';
8
8
  import type { ActivityEntry, ActivityResponse } from '../api-types.js';
9
9
 
10
+ /** Renders the activity feed for the current project, listing recent changes with their type icon, a description or fallback, and a relative timestamp. */
10
11
  export async function renderActivityView(): Promise<void> {
11
12
  const el = document.getElementById('content-activity');
12
13
  if (!el) return;