source_monitor 0.13.1 → 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.
- checksums.yaml +4 -4
- data/.claude/skills/sm-configuration-setting/reference/settings-catalog.md +1 -0
- data/.claude/skills/sm-configure/SKILL.md +8 -1
- data/.claude/skills/sm-configure/reference/configuration-reference.md +11 -0
- data/.claude/skills/sm-host-setup/SKILL.md +13 -3
- data/.claude/skills/sm-host-setup/reference/initializer-template.md +11 -0
- data/.claude/skills/sm-host-setup/reference/setup-checklist.md +9 -1
- data/.claude/skills/sm-upgrade/reference/version-history.md +12 -0
- data/CHANGELOG.md +15 -0
- data/Gemfile.lock +1 -1
- data/README.md +3 -3
- data/VERSION +1 -1
- data/app/controllers/source_monitor/application_controller.rb +73 -14
- data/app/views/layouts/source_monitor/application.html.erb +6 -0
- data/docs/configuration.md +18 -1
- data/docs/deployment.md +1 -1
- data/docs/goals/engine-hardening/.goalbuddy-board/app.js +543 -0
- data/docs/goals/engine-hardening/.goalbuddy-board/goalbuddy-mark.png +0 -0
- data/docs/goals/engine-hardening/.goalbuddy-board/index.html +111 -0
- data/docs/goals/engine-hardening/.goalbuddy-board/styles.css +991 -0
- data/docs/goals/engine-hardening/goal.md +97 -0
- data/docs/goals/engine-hardening/notes/T001-spec-validation.md +37 -0
- data/docs/goals/engine-hardening/state.yaml +324 -0
- data/docs/setup.md +3 -3
- data/docs/upgrade.md +27 -0
- data/lib/generators/source_monitor/install/templates/source_monitor.rb.tt +10 -0
- data/lib/source_monitor/configuration/authentication_settings.rb +5 -1
- data/lib/source_monitor/security/authentication.rb +10 -0
- data/lib/source_monitor/version.rb +1 -1
- data/source_monitor.gemspec +7 -2
- metadata +8 -65
- data/.claude/agent-memory/vbw-vbw-debugger/MEMORY.md +0 -15
- data/.claude/agent-memory/vbw-vbw-dev/MEMORY.md +0 -34
- data/.claude/agent-memory/vbw-vbw-lead/MEMORY.md +0 -49
- data/.claude/agents/rails-concern.md +0 -464
- data/.claude/agents/rails-controller.md +0 -424
- data/.claude/agents/rails-hotwire.md +0 -446
- data/.claude/agents/rails-implement.md +0 -374
- data/.claude/agents/rails-job.md +0 -334
- data/.claude/agents/rails-lint.md +0 -294
- data/.claude/agents/rails-mailer.md +0 -371
- data/.claude/agents/rails-migration.md +0 -449
- data/.claude/agents/rails-model.md +0 -420
- data/.claude/agents/rails-policy.md +0 -443
- data/.claude/agents/rails-presenter.md +0 -427
- data/.claude/agents/rails-query.md +0 -412
- data/.claude/agents/rails-review.md +0 -490
- data/.claude/agents/rails-service.md +0 -458
- data/.claude/agents/rails-state-records.md +0 -465
- data/.claude/agents/rails-tdd.md +0 -314
- data/.claude/agents/rails-test.md +0 -441
- data/.claude/agents/rails-view-component.md +0 -418
- data/.claude/commands/rails-audit.md +0 -77
- data/.claude/commands/release.md +0 -366
- data/.claude/hooks/block-secrets.sh +0 -52
- data/.claude/settings.json +0 -85
- data/.claude/skills/action-cable-patterns/SKILL.md +0 -296
- data/.claude/skills/action-mailer-patterns/SKILL.md +0 -295
- data/.claude/skills/active-storage-setup/SKILL.md +0 -311
- data/.claude/skills/api-versioning/SKILL.md +0 -294
- data/.claude/skills/authentication-flow/SKILL.md +0 -335
- data/.claude/skills/authentication-flow/reference/current.md +0 -248
- data/.claude/skills/authentication-flow/reference/passwordless.md +0 -253
- data/.claude/skills/authentication-flow/reference/sessions.md +0 -201
- data/.claude/skills/authorization-pundit/SKILL.md +0 -462
- data/.claude/skills/caching-strategies/SKILL.md +0 -350
- data/.claude/skills/database-migrations/SKILL.md +0 -354
- data/.claude/skills/form-object-patterns/SKILL.md +0 -399
- data/.claude/skills/hotwire-patterns/SKILL.md +0 -247
- data/.claude/skills/hotwire-patterns/reference/stimulus.md +0 -307
- data/.claude/skills/hotwire-patterns/reference/tailwind-integration.md +0 -112
- data/.claude/skills/hotwire-patterns/reference/turbo-frames.md +0 -158
- data/.claude/skills/hotwire-patterns/reference/turbo-streams.md +0 -218
- data/.claude/skills/i18n-patterns/SKILL.md +0 -320
- data/.claude/skills/install/SKILL.md +0 -367
- data/.claude/skills/performance-optimization/SKILL.md +0 -311
- data/.claude/skills/rails-architecture/SKILL.md +0 -259
- data/.claude/skills/rails-architecture/reference/error-handling.md +0 -333
- data/.claude/skills/rails-architecture/reference/event-tracking.md +0 -142
- data/.claude/skills/rails-architecture/reference/layer-interactions.md +0 -417
- data/.claude/skills/rails-architecture/reference/multi-tenancy.md +0 -152
- data/.claude/skills/rails-architecture/reference/query-patterns.md +0 -342
- data/.claude/skills/rails-architecture/reference/service-patterns.md +0 -286
- data/.claude/skills/rails-architecture/reference/state-records.md +0 -250
- data/.claude/skills/rails-architecture/reference/testing-strategy.md +0 -326
- data/.claude/skills/rails-concern/SKILL.md +0 -399
- data/.claude/skills/rails-controller/SKILL.md +0 -336
- data/.claude/skills/rails-model-generator/SKILL.md +0 -321
- data/.claude/skills/rails-model-generator/reference/validations.md +0 -298
- data/.claude/skills/rails-presenter/SKILL.md +0 -274
- data/.claude/skills/rails-query-object/SKILL.md +0 -289
- data/.claude/skills/rails-service-object/SKILL.md +0 -349
- data/.claude/skills/solid-queue-setup/SKILL.md +0 -307
- data/.claude/skills/tdd-cycle/SKILL.md +0 -359
- data/.claude/skills/viewcomponent-patterns/SKILL.md +0 -333
|
@@ -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
|
+
|
|
Binary file
|
|
@@ -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>
|