source_monitor 0.13.0 → 0.14.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.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/sm-configuration-setting/reference/settings-catalog.md +1 -0
  3. data/.claude/skills/sm-configure/SKILL.md +8 -1
  4. data/.claude/skills/sm-configure/reference/configuration-reference.md +11 -0
  5. data/.claude/skills/sm-event-handler/SKILL.md +1 -1
  6. data/.claude/skills/sm-event-handler/reference/events-api.md +1 -1
  7. data/.claude/skills/sm-host-setup/SKILL.md +13 -3
  8. data/.claude/skills/sm-host-setup/reference/initializer-template.md +11 -0
  9. data/.claude/skills/sm-host-setup/reference/setup-checklist.md +9 -1
  10. data/.claude/skills/sm-upgrade/reference/version-history.md +12 -0
  11. data/CHANGELOG.md +19 -0
  12. data/Gemfile.lock +1 -1
  13. data/README.md +3 -3
  14. data/VERSION +1 -1
  15. data/app/assets/builds/source_monitor/application.css +4 -0
  16. data/app/controllers/source_monitor/application_controller.rb +73 -14
  17. data/app/controllers/source_monitor/bulk_scrape_enablements_controller.rb +1 -1
  18. data/app/controllers/source_monitor/import_sessions/bulk_configuration.rb +3 -1
  19. data/app/controllers/source_monitor/import_sessions_controller.rb +118 -72
  20. data/app/controllers/source_monitor/sources_controller.rb +4 -18
  21. data/app/models/source_monitor/source.rb +1 -1
  22. data/app/views/layouts/source_monitor/application.html.erb +6 -0
  23. data/docs/configuration.md +18 -1
  24. data/docs/deployment.md +1 -1
  25. data/docs/goals/engine-hardening/.goalbuddy-board/app.js +543 -0
  26. data/docs/goals/engine-hardening/.goalbuddy-board/goalbuddy-mark.png +0 -0
  27. data/docs/goals/engine-hardening/.goalbuddy-board/index.html +111 -0
  28. data/docs/goals/engine-hardening/.goalbuddy-board/styles.css +991 -0
  29. data/docs/goals/engine-hardening/goal.md +97 -0
  30. data/docs/goals/engine-hardening/notes/T001-spec-validation.md +37 -0
  31. data/docs/goals/engine-hardening/state.yaml +324 -0
  32. data/docs/setup.md +3 -3
  33. data/docs/upgrade.md +41 -0
  34. data/lib/generators/source_monitor/install/templates/source_monitor.rb.tt +10 -0
  35. data/lib/source_monitor/analytics/scrape_recommendations.rb +21 -2
  36. data/lib/source_monitor/configuration/authentication_settings.rb +5 -1
  37. data/lib/source_monitor/fetching/feed_fetcher/failure_outcome.rb +85 -0
  38. data/lib/source_monitor/fetching/feed_fetcher/success_outcome.rb +85 -0
  39. data/lib/source_monitor/fetching/feed_fetcher.rb +27 -88
  40. data/lib/source_monitor/fetching/fetch_runner.rb +12 -5
  41. data/lib/source_monitor/import_sessions/wizard.rb +612 -0
  42. data/lib/source_monitor/items/batch_item_creator.rb +7 -6
  43. data/lib/source_monitor/items/item_creator.rb +7 -14
  44. data/lib/source_monitor/items/normalized_entry.rb +61 -0
  45. data/lib/source_monitor/security/authentication.rb +10 -0
  46. data/lib/source_monitor/version.rb +1 -1
  47. data/lib/source_monitor.rb +2 -0
  48. data/source_monitor.gemspec +7 -2
  49. metadata +12 -68
  50. data/.claude/agent-memory/vbw-vbw-debugger/MEMORY.md +0 -15
  51. data/.claude/agent-memory/vbw-vbw-dev/MEMORY.md +0 -34
  52. data/.claude/agent-memory/vbw-vbw-lead/MEMORY.md +0 -49
  53. data/.claude/agents/rails-concern.md +0 -464
  54. data/.claude/agents/rails-controller.md +0 -424
  55. data/.claude/agents/rails-hotwire.md +0 -446
  56. data/.claude/agents/rails-implement.md +0 -374
  57. data/.claude/agents/rails-job.md +0 -334
  58. data/.claude/agents/rails-lint.md +0 -294
  59. data/.claude/agents/rails-mailer.md +0 -371
  60. data/.claude/agents/rails-migration.md +0 -449
  61. data/.claude/agents/rails-model.md +0 -420
  62. data/.claude/agents/rails-policy.md +0 -443
  63. data/.claude/agents/rails-presenter.md +0 -427
  64. data/.claude/agents/rails-query.md +0 -412
  65. data/.claude/agents/rails-review.md +0 -490
  66. data/.claude/agents/rails-service.md +0 -458
  67. data/.claude/agents/rails-state-records.md +0 -465
  68. data/.claude/agents/rails-tdd.md +0 -314
  69. data/.claude/agents/rails-test.md +0 -441
  70. data/.claude/agents/rails-view-component.md +0 -418
  71. data/.claude/commands/rails-audit.md +0 -77
  72. data/.claude/commands/release.md +0 -366
  73. data/.claude/hooks/block-secrets.sh +0 -52
  74. data/.claude/settings.json +0 -85
  75. data/.claude/skills/action-cable-patterns/SKILL.md +0 -296
  76. data/.claude/skills/action-mailer-patterns/SKILL.md +0 -295
  77. data/.claude/skills/active-storage-setup/SKILL.md +0 -311
  78. data/.claude/skills/api-versioning/SKILL.md +0 -294
  79. data/.claude/skills/authentication-flow/SKILL.md +0 -335
  80. data/.claude/skills/authentication-flow/reference/current.md +0 -248
  81. data/.claude/skills/authentication-flow/reference/passwordless.md +0 -253
  82. data/.claude/skills/authentication-flow/reference/sessions.md +0 -201
  83. data/.claude/skills/authorization-pundit/SKILL.md +0 -462
  84. data/.claude/skills/caching-strategies/SKILL.md +0 -350
  85. data/.claude/skills/database-migrations/SKILL.md +0 -354
  86. data/.claude/skills/form-object-patterns/SKILL.md +0 -399
  87. data/.claude/skills/hotwire-patterns/SKILL.md +0 -247
  88. data/.claude/skills/hotwire-patterns/reference/stimulus.md +0 -307
  89. data/.claude/skills/hotwire-patterns/reference/tailwind-integration.md +0 -112
  90. data/.claude/skills/hotwire-patterns/reference/turbo-frames.md +0 -158
  91. data/.claude/skills/hotwire-patterns/reference/turbo-streams.md +0 -218
  92. data/.claude/skills/i18n-patterns/SKILL.md +0 -320
  93. data/.claude/skills/install/SKILL.md +0 -367
  94. data/.claude/skills/performance-optimization/SKILL.md +0 -311
  95. data/.claude/skills/rails-architecture/SKILL.md +0 -259
  96. data/.claude/skills/rails-architecture/reference/error-handling.md +0 -333
  97. data/.claude/skills/rails-architecture/reference/event-tracking.md +0 -142
  98. data/.claude/skills/rails-architecture/reference/layer-interactions.md +0 -417
  99. data/.claude/skills/rails-architecture/reference/multi-tenancy.md +0 -152
  100. data/.claude/skills/rails-architecture/reference/query-patterns.md +0 -342
  101. data/.claude/skills/rails-architecture/reference/service-patterns.md +0 -286
  102. data/.claude/skills/rails-architecture/reference/state-records.md +0 -250
  103. data/.claude/skills/rails-architecture/reference/testing-strategy.md +0 -326
  104. data/.claude/skills/rails-concern/SKILL.md +0 -399
  105. data/.claude/skills/rails-controller/SKILL.md +0 -336
  106. data/.claude/skills/rails-model-generator/SKILL.md +0 -321
  107. data/.claude/skills/rails-model-generator/reference/validations.md +0 -298
  108. data/.claude/skills/rails-presenter/SKILL.md +0 -274
  109. data/.claude/skills/rails-query-object/SKILL.md +0 -289
  110. data/.claude/skills/rails-service-object/SKILL.md +0 -349
  111. data/.claude/skills/solid-queue-setup/SKILL.md +0 -307
  112. data/.claude/skills/tdd-cycle/SKILL.md +0 -359
  113. data/.claude/skills/viewcomponent-patterns/SKILL.md +0 -333
  114. data/app/controllers/source_monitor/import_sessions/entry_annotation.rb +0 -187
  115. data/app/controllers/source_monitor/import_sessions/health_check_management.rb +0 -112
  116. data/app/controllers/source_monitor/import_sessions/opml_parser.rb +0 -130
@@ -0,0 +1,543 @@
1
+ let currentBoard = null;
2
+ let eventSource = null;
3
+ let currentSettings = null;
4
+
5
+ const boardEl = document.getElementById("board");
6
+ const liveStateEl = document.getElementById("live-state");
7
+ const liveDotEl = document.getElementById("live-dot");
8
+ const boardSwitcherEl = document.getElementById("board-switcher");
9
+ const settingsButtonEl = document.getElementById("settings-button");
10
+ const settingsPopoverEl = document.getElementById("settings-popover");
11
+ const githubStarsEl = document.getElementById("github-stars");
12
+ const modalEl = document.getElementById("task-modal");
13
+ const modalTitleEl = document.getElementById("modal-title");
14
+ const modalKickerEl = document.getElementById("modal-kicker");
15
+ const modalBodyEl = document.getElementById("modal-body");
16
+ const settingsStorageKey = "goalbuddy.localBoardSettings.v1";
17
+ const settingsDefaults = {
18
+ theme: "system",
19
+ density: "comfortable",
20
+ completedVisibility: "show",
21
+ boardOpenBehavior: "last",
22
+ motion: "system",
23
+ lastBoardPath: "",
24
+ };
25
+ const settingsOptions = {
26
+ theme: new Set(["system", "light", "dark"]),
27
+ density: new Set(["comfortable", "compact"]),
28
+ completedVisibility: new Set(["show", "collapse"]),
29
+ boardOpenBehavior: new Set(["last", "newest"]),
30
+ motion: new Set(["system", "reduce", "allow"]),
31
+ };
32
+
33
+ document.addEventListener("click", (event) => {
34
+ const card = event.target.closest("[data-task-id]");
35
+ if (card) openTask(card.dataset.taskId);
36
+ if (event.target.matches("[data-close-modal]")) closeModal();
37
+ if (settingsPopoverEl.hidden) return;
38
+ if (!event.target.closest(".settings-wrap")) closeSettings();
39
+ });
40
+
41
+ document.addEventListener("keydown", (event) => {
42
+ if (event.key === "Escape") {
43
+ closeModal();
44
+ closeSettings();
45
+ }
46
+ });
47
+
48
+ boardSwitcherEl.addEventListener("change", () => {
49
+ if (boardSwitcherEl.value && boardSwitcherEl.value !== window.location.href) {
50
+ window.location.href = boardSwitcherEl.value;
51
+ }
52
+ });
53
+
54
+ settingsButtonEl.addEventListener("click", () => {
55
+ if (settingsPopoverEl.hidden) {
56
+ openSettings();
57
+ } else {
58
+ closeSettings();
59
+ }
60
+ });
61
+
62
+ settingsPopoverEl.addEventListener("change", (event) => {
63
+ const control = event.target.closest("[data-setting]");
64
+ if (!control) return;
65
+ saveSettings({ ...currentSettings, [control.dataset.setting]: control.value });
66
+ });
67
+
68
+ async function loadBoard() {
69
+ const response = await fetch("./api/board", { cache: "no-store" });
70
+ if (!response.ok) throw new Error("Board request failed");
71
+ renderBoard(await response.json());
72
+ }
73
+
74
+ async function loadBoardSwitcher() {
75
+ const response = await fetch("../api/boards", { cache: "no-store" });
76
+ if (!response.ok) return;
77
+ const payload = await response.json();
78
+ renderBoardSwitcher(payload.boards || []);
79
+ }
80
+
81
+ async function loadSettings() {
82
+ try {
83
+ const response = await fetch("../api/settings", { cache: "no-store" });
84
+ if (!response.ok) throw new Error("Settings request failed");
85
+ const payload = await response.json();
86
+ currentSettings = normalizeSettings(payload.settings);
87
+ window.localStorage?.setItem(settingsStorageKey, JSON.stringify(currentSettings));
88
+ } catch {
89
+ currentSettings = readStoredSettings();
90
+ }
91
+ applySettings(currentSettings);
92
+ }
93
+
94
+ async function saveSettings(nextSettings) {
95
+ currentSettings = normalizeSettings(nextSettings);
96
+ window.localStorage?.setItem(settingsStorageKey, JSON.stringify(currentSettings));
97
+ applySettings(currentSettings);
98
+ try {
99
+ const response = await fetch("../api/settings", {
100
+ method: "PUT",
101
+ headers: { "Content-Type": "application/json" },
102
+ body: JSON.stringify({ settings: currentSettings }),
103
+ });
104
+ if (!response.ok) throw new Error("Settings save failed");
105
+ const payload = await response.json();
106
+ currentSettings = normalizeSettings(payload.settings);
107
+ window.localStorage?.setItem(settingsStorageKey, JSON.stringify(currentSettings));
108
+ applySettings(currentSettings);
109
+ } catch {
110
+ // Keep the localStorage fallback active when the local settings API is unavailable.
111
+ }
112
+ return currentSettings;
113
+ }
114
+
115
+ function readStoredSettings() {
116
+ try {
117
+ return normalizeSettings(JSON.parse(window.localStorage?.getItem(settingsStorageKey) || "{}"));
118
+ } catch {
119
+ return { ...settingsDefaults };
120
+ }
121
+ }
122
+
123
+ function normalizeSettings(settings) {
124
+ const normalized = { ...settingsDefaults };
125
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) return normalized;
126
+ for (const [key, allowed] of Object.entries(settingsOptions)) {
127
+ if (allowed.has(settings[key])) normalized[key] = settings[key];
128
+ }
129
+ if (typeof settings.lastBoardPath === "string" && /^\/[a-z0-9][a-z0-9-]*\/$/.test(settings.lastBoardPath)) {
130
+ normalized.lastBoardPath = settings.lastBoardPath;
131
+ }
132
+ return normalized;
133
+ }
134
+
135
+ function applySettings(settings) {
136
+ const normalized = normalizeSettings(settings);
137
+ document.documentElement.dataset.theme = normalized.theme;
138
+ document.documentElement.dataset.density = normalized.density;
139
+ document.documentElement.dataset.completedVisibility = normalized.completedVisibility;
140
+ document.documentElement.dataset.boardOpenBehavior = normalized.boardOpenBehavior;
141
+ document.documentElement.dataset.motion = normalized.motion;
142
+ for (const control of settingsPopoverEl.querySelectorAll("[data-setting]")) {
143
+ control.value = normalized[control.dataset.setting] || settingsDefaults[control.dataset.setting];
144
+ }
145
+ }
146
+
147
+ function rememberCurrentBoard() {
148
+ const boardPath = normalizePath(window.location.pathname);
149
+ if (!/^\/[a-z0-9][a-z0-9-]*\/$/.test(boardPath)) return;
150
+ const nextSettings = normalizeSettings({ ...currentSettings, lastBoardPath: boardPath });
151
+ currentSettings = nextSettings;
152
+ window.localStorage?.setItem(settingsStorageKey, JSON.stringify(nextSettings));
153
+ fetch("../api/settings", {
154
+ method: "PUT",
155
+ headers: { "Content-Type": "application/json" },
156
+ body: JSON.stringify({ settings: nextSettings }),
157
+ }).catch(() => {});
158
+ }
159
+
160
+ function openSettings() {
161
+ settingsPopoverEl.hidden = false;
162
+ settingsButtonEl.setAttribute("aria-expanded", "true");
163
+ settingsPopoverEl.querySelector("[data-setting]")?.focus();
164
+ }
165
+
166
+ function closeSettings() {
167
+ settingsPopoverEl.hidden = true;
168
+ settingsButtonEl.setAttribute("aria-expanded", "false");
169
+ }
170
+
171
+ function formatStars(count) {
172
+ if (count >= 1000) return `${(count / 1000).toFixed(count >= 10000 ? 0 : 1)}k`;
173
+ return String(count);
174
+ }
175
+
176
+ async function loadGithubStars() {
177
+ if (!githubStarsEl) return;
178
+ try {
179
+ const response = await fetch("https://api.github.com/repos/tolibear/goalbuddy", {
180
+ headers: { Accept: "application/vnd.github+json" },
181
+ });
182
+ if (!response.ok) throw new Error("GitHub API unavailable");
183
+ const repo = await response.json();
184
+ githubStarsEl.textContent = `${formatStars(repo.stargazers_count)} stars`;
185
+ } catch {
186
+ githubStarsEl.textContent = "GitHub";
187
+ }
188
+ }
189
+
190
+ function connectEvents() {
191
+ eventSource = new EventSource("./events");
192
+ eventSource.addEventListener("board", (event) => {
193
+ setLiveState("Live", true);
194
+ renderBoard(JSON.parse(event.data));
195
+ });
196
+ eventSource.addEventListener("error", () => {
197
+ setLiveState("Reconnecting", false);
198
+ });
199
+ }
200
+
201
+ function renderBoard(board) {
202
+ const previousPositions = measureCards();
203
+ const previousColumns = new Map();
204
+ for (const column of currentBoard?.columns || []) {
205
+ for (const task of column.tasks) previousColumns.set(task.id, column.id);
206
+ }
207
+ const movingTaskIds = tasksChangingColumns(board, previousColumns);
208
+ if (movingTaskIds.size) highlightMovingCards(movingTaskIds);
209
+ currentBoard = board;
210
+ document.getElementById("goal-title").textContent = board.goal.title;
211
+ document.title = board.goal.title ? board.goal.title + " - GoalBuddy Board" : "GoalBuddy Board";
212
+ document.getElementById("goal-tranche").textContent = board.goal.tranche || "";
213
+ document.getElementById("goal-status").textContent = board.goal.status;
214
+ document.getElementById("goal-active").textContent = board.goal.activeTask || "None";
215
+ document.getElementById("goal-updated").textContent = new Date(board.generatedAt).toLocaleTimeString();
216
+
217
+ if (board.error) {
218
+ boardEl.replaceChildren(renderBoardError(board.error));
219
+ return;
220
+ }
221
+
222
+ const delay = movingTaskIds.size ? 260 : 0;
223
+ window.setTimeout(() => {
224
+ boardEl.replaceChildren(...board.columns.map(renderColumn));
225
+ animateCardMoves(previousPositions, movingTaskIds);
226
+ }, delay);
227
+ }
228
+
229
+ function renderBoardError(message) {
230
+ const node = el("section", "board-error");
231
+ node.append(
232
+ el("h2", "", "GoalBuddy could not parse this board"),
233
+ el("p", "", message),
234
+ );
235
+ return node;
236
+ }
237
+
238
+ function renderBoardSwitcher(boards) {
239
+ boardSwitcherEl.closest(".board-switcher").classList.toggle("is-empty", boards.length <= 1);
240
+ const currentPath = normalizePath(window.location.pathname);
241
+ const options = boards.map((board) => {
242
+ const option = document.createElement("option");
243
+ option.value = board.url;
244
+ option.textContent = boardOptionLabel(board);
245
+ const boardPath = normalizePath(new URL(board.url, window.location.href).pathname);
246
+ if (boardPath === currentPath) option.selected = true;
247
+ return option;
248
+ });
249
+ boardSwitcherEl.replaceChildren(...options);
250
+ }
251
+
252
+ function renderColumn(column) {
253
+ const section = el("section", "column");
254
+ section.dataset.columnId = column.id;
255
+ const header = el("header", "column-header");
256
+ const titleWrap = el("div");
257
+ titleWrap.append(el("h2", "", column.title), el("p", "", column.description));
258
+ header.append(titleWrap, el("span", "column-count", String(column.tasks.length)));
259
+
260
+ const list = el("div", "card-list");
261
+ if (column.tasks.length === 0) {
262
+ list.append(el("p", "empty", "No cards"));
263
+ } else {
264
+ for (const task of column.tasks) list.append(renderCard(task));
265
+ }
266
+
267
+ section.append(header, list);
268
+ return section;
269
+ }
270
+
271
+ function renderCard(task) {
272
+ const button = el("button", `task-card ${task.active ? "is-active" : ""}`);
273
+ button.type = "button";
274
+ button.dataset.taskId = task.id;
275
+ button.dataset.status = task.status;
276
+
277
+ const topline = el("div", "card-topline");
278
+ topline.append(el("span", "task-id", task.id), statusBadge(task.status));
279
+
280
+ const footer = el("div", "card-footer");
281
+ footer.append(el("span", "badge role", task.assignee || task.type || "PM"));
282
+ if (task.subgoal) footer.append(subgoalBadge(task.subgoal));
283
+ if (task.receipt?.present) footer.append(el("span", "badge status-done", "Receipt"));
284
+
285
+ button.append(topline, el("h3", "task-title", task.title), footer);
286
+ return button;
287
+ }
288
+
289
+ function measureCards() {
290
+ const positions = new Map();
291
+ for (const card of boardEl.querySelectorAll("[data-task-id]")) {
292
+ const rect = card.getBoundingClientRect();
293
+ positions.set(card.dataset.taskId, {
294
+ left: rect.left,
295
+ top: rect.top,
296
+ width: rect.width,
297
+ height: rect.height,
298
+ columnId: card.closest("[data-column-id]")?.dataset.columnId || "",
299
+ });
300
+ }
301
+ return positions;
302
+ }
303
+
304
+ function tasksChangingColumns(board, previousColumns) {
305
+ const moving = new Set();
306
+ for (const column of board.columns) {
307
+ for (const task of column.tasks) {
308
+ const previousColumn = previousColumns.get(task.id);
309
+ if (previousColumn && previousColumn !== column.id) moving.add(task.id);
310
+ }
311
+ }
312
+ return moving;
313
+ }
314
+
315
+ function highlightMovingCards(taskIds) {
316
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
317
+ for (const card of boardEl.querySelectorAll("[data-task-id]")) {
318
+ if (!taskIds.has(card.dataset.taskId)) continue;
319
+ card.classList.add("is-moving");
320
+ card.animate([
321
+ { transform: "scale(1)", borderColor: "#eaeaea" },
322
+ { transform: "scale(1.025)", borderColor: "#9d8cff" },
323
+ { transform: "scale(1)", borderColor: "#c2b8ff" },
324
+ ], {
325
+ duration: 240,
326
+ easing: "cubic-bezier(0.16, 1, 0.3, 1)",
327
+ });
328
+ }
329
+ }
330
+
331
+ function animateCardMoves(previousPositions, movingTaskIds = new Set()) {
332
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
333
+
334
+ for (const card of boardEl.querySelectorAll("[data-task-id]")) {
335
+ const previous = previousPositions.get(card.dataset.taskId);
336
+ const current = card.getBoundingClientRect();
337
+ const columnId = card.closest("[data-column-id]")?.dataset.columnId || "";
338
+
339
+ if (!previous) {
340
+ card.animate([
341
+ { opacity: 0, transform: "translateY(10px) scale(0.98)" },
342
+ { opacity: 1, transform: "translateY(0) scale(1)" },
343
+ ], {
344
+ duration: 260,
345
+ easing: "cubic-bezier(0.16, 1, 0.3, 1)",
346
+ });
347
+ continue;
348
+ }
349
+
350
+ const dx = previous.left - current.left;
351
+ const dy = previous.top - current.top;
352
+ if (Math.abs(dx) < 1 && Math.abs(dy) < 1) continue;
353
+
354
+ const changedColumn = previous.columnId !== columnId;
355
+ const wasSelected = movingTaskIds.has(card.dataset.taskId);
356
+ card.animate([
357
+ {
358
+ transform: `translate(${dx}px, ${dy}px) scale(${changedColumn ? "1.015" : "1"})`,
359
+ opacity: changedColumn ? 0.9 : 1,
360
+ borderColor: wasSelected ? "#9d8cff" : "#eaeaea",
361
+ },
362
+ {
363
+ transform: "translate(0, 0) scale(1)",
364
+ opacity: 1,
365
+ borderColor: "#eaeaea",
366
+ },
367
+ ], {
368
+ duration: changedColumn ? 980 : 520,
369
+ easing: "cubic-bezier(0.19, 1, 0.22, 1)",
370
+ });
371
+ }
372
+ }
373
+
374
+ function openTask(taskId) {
375
+ const task = currentBoard?.tasks.find((candidate) => candidate.id === taskId);
376
+ if (!task) return;
377
+
378
+ modalKickerEl.textContent = `${task.id} · ${task.status}`;
379
+ modalTitleEl.textContent = task.title;
380
+ modalBodyEl.replaceChildren(renderTaskDetail(task));
381
+ modalEl.hidden = false;
382
+ }
383
+
384
+ function closeModal() {
385
+ modalEl.hidden = true;
386
+ }
387
+
388
+ function renderTaskDetail(task) {
389
+ const root = el("div");
390
+ const grid = el("dl", "detail-grid");
391
+ for (const [label, value] of [
392
+ ["Status", task.status],
393
+ ["Assignee", task.assignee || "Unassigned"],
394
+ ["Type", task.type],
395
+ ["Receipt", task.receipt?.summary || "None"],
396
+ ]) {
397
+ const item = el("div", "detail-item");
398
+ item.append(el("dt", "", label), el("dd", "", value));
399
+ grid.append(item);
400
+ }
401
+ root.append(grid);
402
+ if (task.subgoal) root.append(renderSubgoal(task.subgoal));
403
+ root.append(detailText("Objective", task.objective));
404
+ root.append(detailList("Inputs", task.inputs));
405
+ root.append(detailList("Constraints", task.constraints));
406
+ root.append(detailList("Expected Output", task.expectedOutput));
407
+ root.append(detailList("Allowed Files", task.allowedFiles));
408
+ root.append(detailList("Verify", task.verify));
409
+ root.append(detailList("Stop If", task.stopIf));
410
+ if (task.receipt?.decision) root.append(detailText("Decision", task.receipt.decision));
411
+ if (task.receipt?.changedFiles?.length) root.append(detailList("Changed Files", task.receipt.changedFiles));
412
+ if (task.receipt?.commands?.length) {
413
+ root.append(detailList("Commands", task.receipt.commands.map((command) => command.status ? `${command.status}: ${command.cmd}` : command.cmd)));
414
+ }
415
+ if (task.note?.content) {
416
+ const section = el("section", "detail-section");
417
+ section.append(el("h3", "", task.note.title || task.note.path), el("pre", "note", task.note.content));
418
+ root.append(section);
419
+ }
420
+ return root;
421
+ }
422
+
423
+ function renderSubgoal(subgoal) {
424
+ const section = el("section", "detail-section subgoal-section");
425
+ const header = el("div", "subgoal-header");
426
+ const titleWrap = el("div");
427
+ const board = subgoal.board;
428
+ titleWrap.append(
429
+ el("h3", "subgoal-title", board?.goal?.title || "Sub-goal"),
430
+ el("p", "subgoal-meta", [
431
+ subgoal.path,
432
+ subgoal.owner ? `owner: ${subgoal.owner}` : "",
433
+ subgoal.depth ? `depth: ${subgoal.depth}` : "",
434
+ ].filter(Boolean).join(" · ")),
435
+ );
436
+ header.append(titleWrap, subgoalBadge(subgoal));
437
+ section.append(header);
438
+
439
+ if (!board?.columns?.length) {
440
+ section.append(el("p", "", "No child board payload."));
441
+ return section;
442
+ }
443
+
444
+ const boardEl = el("div", "subgoal-board");
445
+ for (const column of board.columns) {
446
+ const columnEl = el("section", "subgoal-column");
447
+ const columnHeader = el("header", "subgoal-column-header");
448
+ columnHeader.append(el("h4", "", column.title), el("span", "column-count", String(column.tasks.length)));
449
+ const list = el("div", "subgoal-card-list");
450
+ if (column.tasks.length === 0) {
451
+ list.append(el("p", "empty", "No cards"));
452
+ } else {
453
+ for (const task of column.tasks) list.append(renderSubgoalTask(task));
454
+ }
455
+ columnEl.append(columnHeader, list);
456
+ boardEl.append(columnEl);
457
+ }
458
+ section.append(boardEl);
459
+
460
+ if (subgoal.rollupReceipt) {
461
+ section.append(detailText("Roll-up Receipt", subgoal.rollupReceipt));
462
+ }
463
+
464
+ return section;
465
+ }
466
+
467
+ function renderSubgoalTask(task) {
468
+ const card = el("article", `subgoal-task-card ${task.active ? "is-active" : ""}`);
469
+ const topline = el("div", "card-topline");
470
+ topline.append(el("span", "task-id", task.id), statusBadge(task.status));
471
+ const footer = el("div", "card-footer");
472
+ footer.append(el("span", "badge role", task.assignee || task.type || "PM"));
473
+ if (task.receipt?.present) footer.append(el("span", "badge status-done", "Receipt"));
474
+ card.append(topline, el("h4", "subgoal-task-title", task.title), footer);
475
+ return card;
476
+ }
477
+
478
+ function detailText(title, value) {
479
+ const section = el("section", "detail-section");
480
+ section.append(el("h3", "", title), el("p", "", value || "None"));
481
+ return section;
482
+ }
483
+
484
+ function detailList(title, values) {
485
+ const section = el("section", "detail-section");
486
+ section.append(el("h3", "", title));
487
+ if (!values?.length) {
488
+ section.append(el("p", "", "None"));
489
+ return section;
490
+ }
491
+ const list = el("ul");
492
+ for (const value of values) list.append(el("li", "", value));
493
+ section.append(list);
494
+ return section;
495
+ }
496
+
497
+ function statusBadge(status) {
498
+ const label = status === "done" ? "Completed" : status === "active" ? "Active" : status === "blocked" ? "Blocked" : "Queued";
499
+ return el("span", `badge status-${status}`, label);
500
+ }
501
+
502
+ function subgoalBadge(subgoal) {
503
+ return el("span", `badge subgoal status-${subgoal.status}`, `Sub-goal ${subgoal.status || "linked"}`);
504
+ }
505
+
506
+ function setLiveState(text, live) {
507
+ liveStateEl.textContent = text;
508
+ liveDotEl.classList.toggle("offline", !live);
509
+ settingsButtonEl.setAttribute("aria-label", `Settings. Board status: ${text}`);
510
+ settingsButtonEl.title = `Settings · ${text}`;
511
+ }
512
+
513
+ function normalizePath(pathname) {
514
+ return pathname.endsWith("/") ? pathname : pathname + "/";
515
+ }
516
+
517
+ function boardOptionLabel(board) {
518
+ const title = board.title || board.slug || board.goalDir || "GoalBuddy board";
519
+ return /[/\\]subgoals[/\\]/.test(board.goalDir || "") ? `Child: ${title}` : title;
520
+ }
521
+
522
+ function el(tag, className = "", text = "") {
523
+ const node = document.createElement(tag);
524
+ if (className) node.className = className;
525
+ if (text !== "") node.textContent = text;
526
+ return node;
527
+ }
528
+
529
+ loadSettings()
530
+ .then(loadBoard)
531
+ .then(() => {
532
+ setLiveState("Live", true);
533
+ rememberCurrentBoard();
534
+ loadGithubStars();
535
+ loadBoardSwitcher();
536
+ window.setInterval(loadBoardSwitcher, 5000);
537
+ connectEvents();
538
+ })
539
+ .catch((error) => {
540
+ setLiveState("Offline", false);
541
+ boardEl.textContent = error.message;
542
+ });
543
+
@@ -0,0 +1,111 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>GoalBuddy Board</title>
7
+ <link rel="stylesheet" href="./styles.css">
8
+ </head>
9
+ <body>
10
+ <header class="topbar">
11
+ <div class="topbar-primary">
12
+ <div class="brand" aria-label="Goal Buddy">
13
+ <img class="brand-mark" src="./goalbuddy-mark.png" alt="GoalBuddy">
14
+ <span class="brand-name">Goal Buddy</span>
15
+ <span class="live-dot" id="live-dot" aria-hidden="true"></span>
16
+ </div>
17
+ <nav class="board-switcher is-empty" aria-label="Local GoalBuddy boards">
18
+ <label for="board-switcher">Board</label>
19
+ <select id="board-switcher" aria-label="Switch local board"></select>
20
+ </nav>
21
+ </div>
22
+ <div class="header-tools">
23
+ <a class="github-stars" href="https://github.com/tolibear/goalbuddy" target="_blank" rel="noreferrer" aria-label="Open GoalBuddy on GitHub">
24
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m12 2.8 2.84 5.76 6.36.92-4.6 4.48 1.08 6.34L12 17.32 6.32 20.3l1.08-6.34-4.6-4.48 6.36-.92L12 2.8Z"></path></svg>
25
+ <span id="github-stars">Stars</span>
26
+ </a>
27
+ <div class="settings-wrap">
28
+ <button class="settings-button" id="settings-button" type="button" aria-expanded="false" aria-controls="settings-popover">
29
+ <svg viewBox="0 0 24 24" aria-hidden="true">
30
+ <path d="M12.2 2.75h-.4a1.6 1.6 0 0 0-1.58 1.36l-.18 1.18c-.46.16-.9.34-1.31.56l-1.02-.64a1.6 1.6 0 0 0-2.08.31l-.28.28a1.6 1.6 0 0 0-.31 2.08l.64 1.02c-.22.42-.4.86-.56 1.31l-1.18.18A1.6 1.6 0 0 0 2.58 12v.4A1.6 1.6 0 0 0 3.94 14l1.18.18c.16.46.34.9.56 1.31l-.64 1.02a1.6 1.6 0 0 0 .31 2.08l.28.28a1.6 1.6 0 0 0 2.08.31l1.02-.64c.42.22.86.4 1.31.56l.18 1.18a1.6 1.6 0 0 0 1.58 1.36h.4a1.6 1.6 0 0 0 1.58-1.36l.18-1.18c.46-.16.9-.34 1.31-.56l1.02.64a1.6 1.6 0 0 0 2.08-.31l.28-.28a1.6 1.6 0 0 0 .31-2.08l-.64-1.02c.22-.42.4-.86.56-1.31l1.18-.18a1.6 1.6 0 0 0 1.36-1.58V12a1.6 1.6 0 0 0-1.36-1.58l-1.18-.18a7.2 7.2 0 0 0-.56-1.31l.64-1.02a1.6 1.6 0 0 0-.31-2.08l-.28-.28a1.6 1.6 0 0 0-2.08-.31l-1.02.64c-.42-.22-.86-.4-1.31-.56l-.18-1.18a1.6 1.6 0 0 0-1.58-1.39Z"></path>
31
+ <circle cx="12" cy="12.2" r="3.15"></circle>
32
+ </svg>
33
+ <span class="visually-hidden" id="live-state">Connecting</span>
34
+ </button>
35
+ <section class="settings-popover" id="settings-popover" aria-label="Local board settings" hidden>
36
+ <div class="settings-heading">
37
+ <p class="eyebrow">Board settings</p>
38
+ <h2>Local preferences</h2>
39
+ </div>
40
+ <div class="setting-row">
41
+ <label for="setting-theme">Theme</label>
42
+ <select id="setting-theme" data-setting="theme">
43
+ <option value="system">System</option>
44
+ <option value="light">Light</option>
45
+ <option value="dark">Dark</option>
46
+ </select>
47
+ </div>
48
+ <div class="setting-row">
49
+ <label for="setting-density">Density</label>
50
+ <select id="setting-density" data-setting="density">
51
+ <option value="comfortable">Comfortable</option>
52
+ <option value="compact">Compact</option>
53
+ </select>
54
+ </div>
55
+ <div class="setting-row">
56
+ <label for="setting-completed">Completed</label>
57
+ <select id="setting-completed" data-setting="completedVisibility">
58
+ <option value="show">Show</option>
59
+ <option value="collapse">Collapse</option>
60
+ </select>
61
+ </div>
62
+ <div class="setting-row">
63
+ <label for="setting-board-open">Open boards</label>
64
+ <select id="setting-board-open" data-setting="boardOpenBehavior">
65
+ <option value="last">Last viewed</option>
66
+ <option value="newest">Newest active</option>
67
+ </select>
68
+ </div>
69
+ <div class="setting-row">
70
+ <label for="setting-motion">Motion</label>
71
+ <select id="setting-motion" data-setting="motion">
72
+ <option value="system">System</option>
73
+ <option value="reduce">Reduce</option>
74
+ <option value="allow">Allow</option>
75
+ </select>
76
+ </div>
77
+ </section>
78
+ </div>
79
+ </div>
80
+ </header>
81
+ <main class="shell">
82
+ <section class="goal-header" aria-labelledby="goal-title">
83
+ <div>
84
+ <p class="eyebrow">Local board</p>
85
+ <h1 id="goal-title">GoalBuddy Board</h1>
86
+ <p id="goal-tranche" class="goal-tranche"></p>
87
+ </div>
88
+ <dl class="goal-meta">
89
+ <div><dt>Status</dt><dd id="goal-status">Unknown</dd></div>
90
+ <div><dt>Active</dt><dd id="goal-active">None</dd></div>
91
+ <div><dt>Updated</dt><dd id="goal-updated">Waiting</dd></div>
92
+ </dl>
93
+ </section>
94
+ <section class="board" id="board" aria-label="Goal task board"></section>
95
+ </main>
96
+ <div class="modal" id="task-modal" hidden>
97
+ <button class="modal-scrim" type="button" data-close-modal aria-label="Close task detail"></button>
98
+ <article class="modal-panel" role="dialog" aria-modal="true" aria-labelledby="modal-title">
99
+ <header class="modal-header">
100
+ <div>
101
+ <p class="eyebrow" id="modal-kicker">Task</p>
102
+ <h2 id="modal-title">Task detail</h2>
103
+ </div>
104
+ <button class="icon-button" type="button" data-close-modal aria-label="Close task detail">x</button>
105
+ </header>
106
+ <div class="modal-body" id="modal-body"></div>
107
+ </article>
108
+ </div>
109
+ <script src="./app.js" type="module"></script>
110
+ </body>
111
+ </html>