goalbuddy 0.3.2 → 0.3.6
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.
- package/README.md +55 -5
- package/RELEASE-0.3.5.md +324 -0
- package/goalbuddy/SKILL.md +40 -13
- package/goalbuddy/agents/README.md +1 -1
- package/goalbuddy/agents/goal_judge.toml +33 -17
- package/goalbuddy/agents/goal_scout.toml +34 -14
- package/goalbuddy/agents/goal_worker.toml +36 -16
- package/goalbuddy/extend/local-goal-board/README.md +8 -4
- package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/goal.md +3 -0
- package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/notes/.gitkeep +1 -0
- package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/state.yaml +60 -0
- package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/goal.md +3 -0
- package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/notes/.gitkeep +1 -0
- package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/state.yaml +52 -0
- package/goalbuddy/extend/local-goal-board/extension.yaml +6 -4
- package/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs +1188 -31
- package/goalbuddy/extend/local-goal-board/scripts/local-goal-board.mjs +389 -54
- package/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs +479 -5
- package/goalbuddy/scripts/check-goal-state.mjs +192 -6
- package/goalbuddy/scripts/parallel-plan.mjs +191 -0
- package/goalbuddy/scripts/render-task-prompt.mjs +305 -0
- package/goalbuddy/templates/agents.md +5 -4
- package/goalbuddy/templates/goal.md +18 -4
- package/goalbuddy/templates/state.yaml +14 -1
- package/internal/assets/goalbuddy-v0.3.5-release.png +0 -0
- package/internal/cli/goal-maker.mjs +172 -9
- package/package.json +3 -2
- package/plugins/goalbuddy/.claude-plugin/plugin.json +2 -2
- package/plugins/goalbuddy/.codex-plugin/plugin.json +4 -4
- package/plugins/goalbuddy/README.md +5 -3
- package/plugins/goalbuddy/agents/goal-judge.md +35 -16
- package/plugins/goalbuddy/agents/goal-scout.md +38 -13
- package/plugins/goalbuddy/agents/goal-worker.md +37 -14
- package/plugins/goalbuddy/skills/goalbuddy/SKILL.md +40 -13
- package/plugins/goalbuddy/skills/goalbuddy/agents/README.md +1 -1
- package/plugins/goalbuddy/skills/goalbuddy/agents/goal_judge.toml +33 -17
- package/plugins/goalbuddy/skills/goalbuddy/agents/goal_scout.toml +34 -14
- package/plugins/goalbuddy/skills/goalbuddy/agents/goal_worker.toml +36 -16
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/README.md +8 -4
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/goal.md +3 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/notes/.gitkeep +1 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/state.yaml +60 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/goal.md +3 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/notes/.gitkeep +1 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/state.yaml +52 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/extension.yaml +6 -4
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs +1188 -31
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/local-goal-board.mjs +389 -54
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs +479 -5
- package/plugins/goalbuddy/skills/goalbuddy/scripts/check-goal-state.mjs +192 -6
- package/plugins/goalbuddy/skills/goalbuddy/scripts/parallel-plan.mjs +191 -0
- package/plugins/goalbuddy/skills/goalbuddy/scripts/render-task-prompt.mjs +305 -0
- package/plugins/goalbuddy/skills/goalbuddy/templates/agents.md +5 -4
- package/plugins/goalbuddy/skills/goalbuddy/templates/goal.md +18 -4
- package/plugins/goalbuddy/skills/goalbuddy/templates/state.yaml +14 -1
package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
|
|
6
6
|
const VALID_STATUSES = new Set(["queued", "active", "blocked", "done"]);
|
|
@@ -26,7 +26,8 @@ export async function loadGoalBoard(goalDir) {
|
|
|
26
26
|
return normalizeGoalBoard(parseGoalStateText(text), root);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
export function createBoardPayload(goalDir) {
|
|
29
|
+
export function createBoardPayload(goalDir, options = {}) {
|
|
30
|
+
const includeSubgoals = options.includeSubgoals !== false;
|
|
30
31
|
const root = resolve(goalDir);
|
|
31
32
|
const statePath = join(root, "state.yaml");
|
|
32
33
|
if (!existsSync(statePath)) {
|
|
@@ -36,7 +37,9 @@ export function createBoardPayload(goalDir) {
|
|
|
36
37
|
const document = parseGoalStateText(readFileSync(statePath, "utf8"));
|
|
37
38
|
const board = normalizeGoalBoard(document, root);
|
|
38
39
|
const noteIndex = loadNotes(root);
|
|
39
|
-
const tasks = board.tasks
|
|
40
|
+
const tasks = board.tasks
|
|
41
|
+
.map((task) => attachTaskNote(task, noteIndex))
|
|
42
|
+
.map((task) => includeSubgoals ? attachTaskSubgoal(task, root) : task);
|
|
40
43
|
const columns = buildColumns(tasks);
|
|
41
44
|
const stateStat = statSync(statePath);
|
|
42
45
|
|
|
@@ -107,7 +110,7 @@ export function normalizeTask(task, index) {
|
|
|
107
110
|
}
|
|
108
111
|
|
|
109
112
|
const id = cleanText(task.id);
|
|
110
|
-
const status =
|
|
113
|
+
const status = normalizeTaskStatus(task.status);
|
|
111
114
|
if (!id) throw new GoalBoardError(`Task ${index + 1} is missing id.`);
|
|
112
115
|
if (!VALID_STATUSES.has(status)) {
|
|
113
116
|
throw new GoalBoardError(`Task ${id} has unsupported status "${status}".`);
|
|
@@ -128,6 +131,7 @@ export function normalizeTask(task, index) {
|
|
|
128
131
|
allowedFiles: normalizeStringList(task.allowed_files),
|
|
129
132
|
verify: normalizeStringList(task.verify),
|
|
130
133
|
stopIf: normalizeStringList(task.stop_if),
|
|
134
|
+
subgoal: normalizeSubgoal(task.subgoal),
|
|
131
135
|
receipt: normalizeReceipt(task.receipt),
|
|
132
136
|
};
|
|
133
137
|
}
|
|
@@ -170,6 +174,42 @@ function attachTaskNote(task, noteIndex) {
|
|
|
170
174
|
};
|
|
171
175
|
}
|
|
172
176
|
|
|
177
|
+
function attachTaskSubgoal(task, goalDir) {
|
|
178
|
+
if (!task.subgoal) return task;
|
|
179
|
+
const childStatePath = resolve(goalDir, task.subgoal.path);
|
|
180
|
+
validateChildSubgoalPath(task, goalDir, childStatePath);
|
|
181
|
+
const childGoalDir = dirname(childStatePath);
|
|
182
|
+
if (!existsSync(childStatePath)) {
|
|
183
|
+
throw new GoalBoardError(`Missing sub-goal state for ${task.id}: ${task.subgoal.path}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
...task,
|
|
188
|
+
subgoal: {
|
|
189
|
+
...task.subgoal,
|
|
190
|
+
board: createBoardPayload(childGoalDir, { includeSubgoals: false }),
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function validateChildSubgoalPath(task, goalDir, childStatePath) {
|
|
196
|
+
if (task.subgoal.depth !== 1) {
|
|
197
|
+
throw new GoalBoardError(`Invalid sub-goal depth for ${task.id}: only depth 1 is supported.`);
|
|
198
|
+
}
|
|
199
|
+
const childRelativePath = relative(goalDir, childStatePath);
|
|
200
|
+
if (!isInsideRoot(childRelativePath)) {
|
|
201
|
+
throw new GoalBoardError(`Invalid sub-goal path for ${task.id}: ${task.subgoal.path} must stay inside the goal root.`);
|
|
202
|
+
}
|
|
203
|
+
const parts = childRelativePath.split(/[\\/]+/);
|
|
204
|
+
if (parts.length !== 3 || parts[0] !== "subgoals" || parts[2] !== "state.yaml") {
|
|
205
|
+
throw new GoalBoardError(`Invalid sub-goal path for ${task.id}: ${task.subgoal.path} must be subgoals/<slug>/state.yaml.`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isInsideRoot(relativePath) {
|
|
210
|
+
return relativePath && relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath);
|
|
211
|
+
}
|
|
212
|
+
|
|
173
213
|
function loadNotes(goalDir) {
|
|
174
214
|
const notesDir = join(goalDir, "notes");
|
|
175
215
|
if (!existsSync(notesDir)) return {};
|
|
@@ -215,6 +255,19 @@ function normalizeReceipt(receipt) {
|
|
|
215
255
|
};
|
|
216
256
|
}
|
|
217
257
|
|
|
258
|
+
function normalizeSubgoal(subgoal) {
|
|
259
|
+
if (!subgoal || typeof subgoal !== "object" || Array.isArray(subgoal)) return null;
|
|
260
|
+
return {
|
|
261
|
+
status: cleanText(subgoal.status || ""),
|
|
262
|
+
path: cleanText(subgoal.path || ""),
|
|
263
|
+
owner: cleanText(subgoal.owner || ""),
|
|
264
|
+
createdFrom: cleanText(subgoal.created_from || ""),
|
|
265
|
+
depth: Number(subgoal.depth || 0),
|
|
266
|
+
rollupReceipt: cleanText(subgoal.rollup_receipt || ""),
|
|
267
|
+
board: null,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
218
271
|
function normalizeCommands(commands) {
|
|
219
272
|
if (!commands) return [];
|
|
220
273
|
if (!Array.isArray(commands)) return [cleanText(commands)].filter(Boolean).map((cmd) => ({ cmd, status: "" }));
|
|
@@ -228,8 +281,33 @@ function normalizeCommands(commands) {
|
|
|
228
281
|
}
|
|
229
282
|
|
|
230
283
|
function titleForTask(task) {
|
|
284
|
+
if (task.title) return compactTaskTitle(task.title);
|
|
231
285
|
const objective = cleanText(task.objective || "Untitled task");
|
|
232
|
-
return objective
|
|
286
|
+
return compactTaskTitle(objective);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function compactTaskTitle(value) {
|
|
290
|
+
const text = cleanText(value).replace(/\.$/, "");
|
|
291
|
+
const routeMatch = text.match(/^Implement\b.*?\s(\/[A-Za-z0-9_./:-]+)\s+(route|queue slice|slice)\b/i);
|
|
292
|
+
if (routeMatch) return truncateTitle(`Implement ${routeMatch[1]} ${routeMatch[2]}`);
|
|
293
|
+
|
|
294
|
+
const firstClause = text
|
|
295
|
+
.split(/(?<=[.!?])\s+|\s+(?:Use only|Add|Match|Render|Clearly label|Do not)\b/i)[0]
|
|
296
|
+
.replace(/\bas the next first-milestone slice\b/gi, "")
|
|
297
|
+
.replace(/\bblocker documentation\b/gi, "blocker docs")
|
|
298
|
+
.replace(/\benv\/setup notes\b/gi, "setup notes")
|
|
299
|
+
.replace(/\s+/g, " ")
|
|
300
|
+
.replace(/[.;:,]\s*$/, "")
|
|
301
|
+
.trim();
|
|
302
|
+
|
|
303
|
+
return truncateTitle(firstClause || text);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function truncateTitle(value, maxLength = 82) {
|
|
307
|
+
const text = cleanText(value).replace(/\.$/, "");
|
|
308
|
+
if (text.length <= maxLength) return text;
|
|
309
|
+
const shortened = text.slice(0, maxLength + 1).replace(/\s+\S*$/, "").trim();
|
|
310
|
+
return `${shortened || text.slice(0, maxLength).trim()}...`;
|
|
233
311
|
}
|
|
234
312
|
|
|
235
313
|
function columnForStatus(status) {
|
|
@@ -254,14 +332,222 @@ function cleanText(value) {
|
|
|
254
332
|
return String(value ?? "").trim();
|
|
255
333
|
}
|
|
256
334
|
|
|
335
|
+
function normalizeTaskStatus(value) {
|
|
336
|
+
const status = cleanText(value);
|
|
337
|
+
if (status === "complete" || status === "completed") return "done";
|
|
338
|
+
return status;
|
|
339
|
+
}
|
|
340
|
+
|
|
257
341
|
export function parseGoalStateText(text) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
342
|
+
try {
|
|
343
|
+
const lines = tokenizeYaml(text);
|
|
344
|
+
if (!lines.length) throw new GoalBoardError("Goal state is empty.");
|
|
345
|
+
const [value, nextIndex] = parseBlock(lines, 0, lines[0].indent);
|
|
346
|
+
if (nextIndex < lines.length) {
|
|
347
|
+
throw new GoalBoardError(`Could not parse line ${lines[nextIndex].number}.`);
|
|
348
|
+
}
|
|
349
|
+
return value;
|
|
350
|
+
} catch (error) {
|
|
351
|
+
if (error instanceof GoalBoardError && canRecoverBoardSubset(error)) {
|
|
352
|
+
return parseGoalBoardSubset(text);
|
|
353
|
+
}
|
|
354
|
+
throw error;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function canRecoverBoardSubset(error) {
|
|
359
|
+
return /Could not parse line|Expected key\/value pair|Expected mapping|Block scalar YAML/.test(error.message);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function parseGoalBoardSubset(text) {
|
|
363
|
+
const tasks = parseTaskSubsets(text);
|
|
364
|
+
if (!tasks.length) throw new GoalBoardError("Missing non-empty tasks list.");
|
|
365
|
+
return {
|
|
366
|
+
version: parseYamlScalar(findTopLevelScalar(text, "version") || "2"),
|
|
367
|
+
goal: {
|
|
368
|
+
title: parseYamlScalar(findNestedScalar(text, "goal", "title") || "Untitled goal"),
|
|
369
|
+
slug: parseYamlScalar(findNestedScalar(text, "goal", "slug") || "untitled-goal"),
|
|
370
|
+
kind: parseYamlScalar(findNestedScalar(text, "goal", "kind") || "open_ended"),
|
|
371
|
+
tranche: parseYamlScalar(findNestedScalar(text, "goal", "tranche") || ""),
|
|
372
|
+
status: parseYamlScalar(findNestedScalar(text, "goal", "status") || "active"),
|
|
373
|
+
},
|
|
374
|
+
active_task: parseYamlScalar(findTopLevelScalar(text, "active_task") || ""),
|
|
375
|
+
tasks,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function parseTaskSubsets(text) {
|
|
380
|
+
const tasksText = findTopLevelSection(text, "tasks");
|
|
381
|
+
if (!tasksText) return [];
|
|
382
|
+
const taskBlocks = [];
|
|
383
|
+
let current = [];
|
|
384
|
+
for (const line of tasksText.split("\n")) {
|
|
385
|
+
if (/^ - id:/.test(line)) {
|
|
386
|
+
if (current.length) taskBlocks.push(current.join("\n"));
|
|
387
|
+
current = [line];
|
|
388
|
+
} else if (current.length) {
|
|
389
|
+
current.push(line);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (current.length) taskBlocks.push(current.join("\n"));
|
|
393
|
+
return taskBlocks.map((block) => ({
|
|
394
|
+
id: parseYamlScalar(findTaskScalar(block, "id") || ""),
|
|
395
|
+
type: parseYamlScalar(findTaskScalar(block, "type") || "pm"),
|
|
396
|
+
assignee: parseYamlScalar(findTaskScalar(block, "assignee") || ""),
|
|
397
|
+
status: parseYamlScalar(findTaskScalar(block, "status") || "queued"),
|
|
398
|
+
title: parseYamlScalar(findTaskScalar(block, "title") || ""),
|
|
399
|
+
objective: parseYamlScalar(findTaskScalar(block, "objective") || ""),
|
|
400
|
+
inputs: findTaskList(block, "inputs"),
|
|
401
|
+
constraints: findTaskList(block, "constraints"),
|
|
402
|
+
expected_output: findTaskList(block, "expected_output"),
|
|
403
|
+
allowed_files: findTaskList(block, "allowed_files"),
|
|
404
|
+
verify: findTaskList(block, "verify"),
|
|
405
|
+
stop_if: findTaskList(block, "stop_if"),
|
|
406
|
+
subgoal: findTaskSubgoal(block),
|
|
407
|
+
receipt: findTaskReceipt(block),
|
|
408
|
+
}));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function findTopLevelScalar(text, key) {
|
|
412
|
+
return findScalar(text, new RegExp(`^${escapeRegExp(key)}:\\s*(.*?)\\s*$`, "m"));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function findNestedScalar(text, section, key) {
|
|
416
|
+
return findScalar(findTopLevelSection(text, section), new RegExp(`^ ${escapeRegExp(key)}:\\s*(.*?)\\s*$`, "m"));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function findTaskScalar(text, key) {
|
|
420
|
+
if (key === "id") return findScalar(text, /^ - id:\s*(.*?)\s*$/m);
|
|
421
|
+
return findScalar(text, new RegExp(`^ ${escapeRegExp(key)}:\\s*(.*?)\\s*$`, "m"));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function findScalar(text, pattern) {
|
|
425
|
+
const match = String(text || "").match(pattern);
|
|
426
|
+
return match ? match[1] : "";
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function findTopLevelSection(text, key) {
|
|
430
|
+
const lines = String(text || "").replace(/\r\n/g, "\n").split("\n");
|
|
431
|
+
const start = lines.findIndex((line) => line.trim() === `${key}:`);
|
|
432
|
+
if (start === -1) return "";
|
|
433
|
+
const section = [];
|
|
434
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
435
|
+
const line = lines[index];
|
|
436
|
+
if (/^\S/.test(line)) break;
|
|
437
|
+
section.push(line);
|
|
438
|
+
}
|
|
439
|
+
return section.join("\n");
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function findIndentedSection(text, key, indent) {
|
|
443
|
+
const lines = String(text || "").replace(/\r\n/g, "\n").split("\n");
|
|
444
|
+
const prefix = " ".repeat(indent);
|
|
445
|
+
const start = lines.findIndex((line) => line.trim() === `${key}:` && line.startsWith(prefix));
|
|
446
|
+
if (start === -1) return "";
|
|
447
|
+
const section = [];
|
|
448
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
449
|
+
const line = lines[index];
|
|
450
|
+
if (line.trim() && !line.startsWith(`${prefix} `)) break;
|
|
451
|
+
section.push(line);
|
|
452
|
+
}
|
|
453
|
+
return section.join("\n");
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function findTaskList(text, key) {
|
|
457
|
+
const inline = findTaskScalar(text, key);
|
|
458
|
+
if (inline) {
|
|
459
|
+
const parsed = parseYamlScalar(inline);
|
|
460
|
+
if (Array.isArray(parsed)) return parsed.map(cleanText).filter(Boolean);
|
|
461
|
+
return cleanText(parsed) ? [cleanText(parsed)] : [];
|
|
462
|
+
}
|
|
463
|
+
const section = findIndentedSection(text, key, 4);
|
|
464
|
+
return section
|
|
465
|
+
.split("\n")
|
|
466
|
+
.map((line) => line.match(/^ -\s*(.*?)\s*$/)?.[1] || "")
|
|
467
|
+
.map(parseYamlScalar)
|
|
468
|
+
.map(cleanText)
|
|
469
|
+
.filter(Boolean);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function findTaskSubgoal(text) {
|
|
473
|
+
const inline = findTaskScalar(text, "subgoal");
|
|
474
|
+
if (inline && parseYamlScalar(inline) === null) return null;
|
|
475
|
+
const section = findIndentedSection(text, "subgoal", 4);
|
|
476
|
+
if (!section) return null;
|
|
477
|
+
return {
|
|
478
|
+
status: parseYamlScalar(findScalar(section, /^ status:\s*(.*?)\s*$/m) || "active"),
|
|
479
|
+
path: parseYamlScalar(findScalar(section, /^ path:\s*(.*?)\s*$/m) || ""),
|
|
480
|
+
owner: parseYamlScalar(findScalar(section, /^ owner:\s*(.*?)\s*$/m) || ""),
|
|
481
|
+
created_from: parseYamlScalar(findScalar(section, /^ created_from:\s*(.*?)\s*$/m) || ""),
|
|
482
|
+
depth: parseYamlScalar(findScalar(section, /^ depth:\s*(.*?)\s*$/m) || "1"),
|
|
483
|
+
rollup_receipt: parseYamlScalar(findScalar(section, /^ rollup_receipt:\s*(.*?)\s*$/m) || "null"),
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function findTaskReceipt(text) {
|
|
488
|
+
const inline = findTaskScalar(text, "receipt");
|
|
489
|
+
if (inline && parseYamlScalar(inline) === null) return null;
|
|
490
|
+
const section = findIndentedSection(text, "receipt", 4);
|
|
491
|
+
if (!section) return null;
|
|
492
|
+
return {
|
|
493
|
+
result: parseYamlScalar(findScalar(section, /^ result:\s*(.*?)\s*$/m) || ""),
|
|
494
|
+
summary: parseYamlScalar(findScalar(section, /^ summary:\s*(.*?)\s*$/m) || ""),
|
|
495
|
+
decision: parseYamlScalar(findScalar(section, /^ decision:\s*(.*?)\s*$/m) || ""),
|
|
496
|
+
note: parseYamlScalar(findScalar(section, /^ note:\s*(.*?)\s*$/m) || ""),
|
|
497
|
+
changed_files: findReceiptList(section, "changed_files"),
|
|
498
|
+
commands: findReceiptCommands(section),
|
|
499
|
+
evidence: [],
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function findReceiptList(text, key) {
|
|
504
|
+
const section = findIndentedSection(text, key, 6);
|
|
505
|
+
return section
|
|
506
|
+
.split("\n")
|
|
507
|
+
.map((line) => line.match(/^ -\s*(.*?)\s*$/)?.[1] || "")
|
|
508
|
+
.map(parseYamlScalar)
|
|
509
|
+
.map(cleanText)
|
|
510
|
+
.filter(Boolean);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function findReceiptCommands(text) {
|
|
514
|
+
const section = findIndentedSection(text, "commands", 6);
|
|
515
|
+
const blocks = [];
|
|
516
|
+
let current = [];
|
|
517
|
+
for (const line of section.split("\n")) {
|
|
518
|
+
if (/^ - cmd:/.test(line)) {
|
|
519
|
+
if (current.length) blocks.push(current.join("\n"));
|
|
520
|
+
current = [line];
|
|
521
|
+
} else if (current.length) {
|
|
522
|
+
current.push(line);
|
|
523
|
+
}
|
|
263
524
|
}
|
|
264
|
-
|
|
525
|
+
if (current.length) blocks.push(current.join("\n"));
|
|
526
|
+
return blocks.map((block) => ({
|
|
527
|
+
cmd: parseYamlScalar(findScalar(block, /^ - cmd:\s*(.*?)\s*$/m) || ""),
|
|
528
|
+
status: parseYamlScalar(findScalar(block, /^ status:\s*(.*?)\s*$/m) || ""),
|
|
529
|
+
note: parseYamlScalar(findScalar(block, /^ note:\s*(.*?)\s*$/m) || ""),
|
|
530
|
+
}));
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function parseYamlScalar(value) {
|
|
534
|
+
const text = stripComment(String(value ?? "")).trim();
|
|
535
|
+
if (!text) return "";
|
|
536
|
+
try {
|
|
537
|
+
return parseScalar(text);
|
|
538
|
+
} catch {
|
|
539
|
+
if (
|
|
540
|
+
(text.startsWith("\"") && text.endsWith("\"")) ||
|
|
541
|
+
(text.startsWith("'") && text.endsWith("'"))
|
|
542
|
+
) {
|
|
543
|
+
return text.slice(1, -1);
|
|
544
|
+
}
|
|
545
|
+
return text;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function escapeRegExp(value) {
|
|
550
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
265
551
|
}
|
|
266
552
|
|
|
267
553
|
function tokenizeYaml(text) {
|
|
@@ -452,11 +738,75 @@ function boardHtml() {
|
|
|
452
738
|
</head>
|
|
453
739
|
<body>
|
|
454
740
|
<header class="topbar">
|
|
455
|
-
<div class="
|
|
456
|
-
<
|
|
457
|
-
|
|
741
|
+
<div class="topbar-primary">
|
|
742
|
+
<div class="brand" aria-label="Goal Buddy">
|
|
743
|
+
<img class="brand-mark" src="./goalbuddy-mark.png" alt="GoalBuddy">
|
|
744
|
+
<span class="brand-name">Goal Buddy</span>
|
|
745
|
+
<span class="live-dot" id="live-dot" aria-hidden="true"></span>
|
|
746
|
+
</div>
|
|
747
|
+
<nav class="board-switcher is-empty" aria-label="Local GoalBuddy boards">
|
|
748
|
+
<label for="board-switcher">Board</label>
|
|
749
|
+
<select id="board-switcher" aria-label="Switch local board"></select>
|
|
750
|
+
</nav>
|
|
751
|
+
</div>
|
|
752
|
+
<div class="header-tools">
|
|
753
|
+
<a class="github-stars" href="https://github.com/tolibear/goalbuddy" target="_blank" rel="noreferrer" aria-label="Open GoalBuddy on GitHub">
|
|
754
|
+
<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>
|
|
755
|
+
<span id="github-stars">Stars</span>
|
|
756
|
+
</a>
|
|
757
|
+
<div class="settings-wrap">
|
|
758
|
+
<button class="settings-button" id="settings-button" type="button" aria-expanded="false" aria-controls="settings-popover">
|
|
759
|
+
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
760
|
+
<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>
|
|
761
|
+
<circle cx="12" cy="12.2" r="3.15"></circle>
|
|
762
|
+
</svg>
|
|
763
|
+
<span class="visually-hidden" id="live-state">Connecting</span>
|
|
764
|
+
</button>
|
|
765
|
+
<section class="settings-popover" id="settings-popover" aria-label="Local board settings" hidden>
|
|
766
|
+
<div class="settings-heading">
|
|
767
|
+
<p class="eyebrow">Board settings</p>
|
|
768
|
+
<h2>Local preferences</h2>
|
|
769
|
+
</div>
|
|
770
|
+
<div class="setting-row">
|
|
771
|
+
<label for="setting-theme">Theme</label>
|
|
772
|
+
<select id="setting-theme" data-setting="theme">
|
|
773
|
+
<option value="system">System</option>
|
|
774
|
+
<option value="light">Light</option>
|
|
775
|
+
<option value="dark">Dark</option>
|
|
776
|
+
</select>
|
|
777
|
+
</div>
|
|
778
|
+
<div class="setting-row">
|
|
779
|
+
<label for="setting-density">Density</label>
|
|
780
|
+
<select id="setting-density" data-setting="density">
|
|
781
|
+
<option value="comfortable">Comfortable</option>
|
|
782
|
+
<option value="compact">Compact</option>
|
|
783
|
+
</select>
|
|
784
|
+
</div>
|
|
785
|
+
<div class="setting-row">
|
|
786
|
+
<label for="setting-completed">Completed</label>
|
|
787
|
+
<select id="setting-completed" data-setting="completedVisibility">
|
|
788
|
+
<option value="show">Show</option>
|
|
789
|
+
<option value="collapse">Collapse</option>
|
|
790
|
+
</select>
|
|
791
|
+
</div>
|
|
792
|
+
<div class="setting-row">
|
|
793
|
+
<label for="setting-board-open">Open boards</label>
|
|
794
|
+
<select id="setting-board-open" data-setting="boardOpenBehavior">
|
|
795
|
+
<option value="last">Last viewed</option>
|
|
796
|
+
<option value="newest">Newest active</option>
|
|
797
|
+
</select>
|
|
798
|
+
</div>
|
|
799
|
+
<div class="setting-row">
|
|
800
|
+
<label for="setting-motion">Motion</label>
|
|
801
|
+
<select id="setting-motion" data-setting="motion">
|
|
802
|
+
<option value="system">System</option>
|
|
803
|
+
<option value="reduce">Reduce</option>
|
|
804
|
+
<option value="allow">Allow</option>
|
|
805
|
+
</select>
|
|
806
|
+
</div>
|
|
807
|
+
</section>
|
|
808
|
+
</div>
|
|
458
809
|
</div>
|
|
459
|
-
<div class="live-state" id="live-state">Connecting</div>
|
|
460
810
|
</header>
|
|
461
811
|
<main class="shell">
|
|
462
812
|
<section class="goal-header" aria-labelledby="goal-title">
|
|
@@ -508,9 +858,94 @@ function boardCss() {
|
|
|
508
858
|
--red-text: #9f2f2d;
|
|
509
859
|
--yellow-bg: #fbf3db;
|
|
510
860
|
--yellow-text: #956400;
|
|
861
|
+
--active-surface: #fbfdfe;
|
|
511
862
|
font-family: "SF Pro Display", "Geist Sans", "Helvetica Neue", Arial, sans-serif;
|
|
512
863
|
}
|
|
513
864
|
|
|
865
|
+
:root[data-theme="dark"] {
|
|
866
|
+
color-scheme: dark;
|
|
867
|
+
--canvas: #07101f;
|
|
868
|
+
--surface: #101a2d;
|
|
869
|
+
--surface-muted: #0c1525;
|
|
870
|
+
--ink: #f7f9fc;
|
|
871
|
+
--muted: #9aa7bf;
|
|
872
|
+
--line: #26334a;
|
|
873
|
+
--blue-bg: #173653;
|
|
874
|
+
--blue-text: #9ed8ff;
|
|
875
|
+
--green-bg: #143929;
|
|
876
|
+
--green-text: #a6e8bf;
|
|
877
|
+
--red-bg: #3a1d22;
|
|
878
|
+
--red-text: #ffb2b9;
|
|
879
|
+
--yellow-bg: #3a3014;
|
|
880
|
+
--yellow-text: #f6d878;
|
|
881
|
+
--active-surface: #0f2031;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
@media (prefers-color-scheme: dark) {
|
|
885
|
+
:root[data-theme="system"] {
|
|
886
|
+
color-scheme: dark;
|
|
887
|
+
--canvas: #07101f;
|
|
888
|
+
--surface: #101a2d;
|
|
889
|
+
--surface-muted: #0c1525;
|
|
890
|
+
--ink: #f7f9fc;
|
|
891
|
+
--muted: #9aa7bf;
|
|
892
|
+
--line: #26334a;
|
|
893
|
+
--blue-bg: #173653;
|
|
894
|
+
--blue-text: #9ed8ff;
|
|
895
|
+
--green-bg: #143929;
|
|
896
|
+
--green-text: #a6e8bf;
|
|
897
|
+
--red-bg: #3a1d22;
|
|
898
|
+
--red-text: #ffb2b9;
|
|
899
|
+
--yellow-bg: #3a3014;
|
|
900
|
+
--yellow-text: #f6d878;
|
|
901
|
+
--active-surface: #0f2031;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
:root[data-theme="system"] .topbar {
|
|
905
|
+
border-color: rgba(61, 76, 108, 0.86);
|
|
906
|
+
background: rgba(13, 23, 41, 0.84);
|
|
907
|
+
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.28);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
:root[data-theme="system"] .brand {
|
|
911
|
+
color: var(--ink);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
:root[data-theme="system"] .board-switcher select,
|
|
915
|
+
:root[data-theme="system"] .github-stars,
|
|
916
|
+
:root[data-theme="system"] .settings-button {
|
|
917
|
+
border-color: rgba(61, 76, 108, 0.9);
|
|
918
|
+
background: rgba(16, 26, 45, 0.78);
|
|
919
|
+
color: var(--ink);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
:root[data-theme="system"] .settings-popover {
|
|
923
|
+
border-color: rgba(61, 76, 108, 0.96);
|
|
924
|
+
background: rgba(16, 26, 45, 0.96);
|
|
925
|
+
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.28);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
:root[data-theme="system"] .setting-row select {
|
|
929
|
+
background: var(--surface);
|
|
930
|
+
color: var(--ink);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
:root[data-theme="system"] .goal-tranche,
|
|
934
|
+
:root[data-theme="system"] .task-title,
|
|
935
|
+
:root[data-theme="system"] .setting-row select {
|
|
936
|
+
color: var(--ink);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
:root[data-theme="system"] .task-card.is-active {
|
|
940
|
+
background: linear-gradient(var(--active-surface), var(--active-surface)) padding-box,
|
|
941
|
+
linear-gradient(110deg, #78d7ff, #6c63ff, #78f2b9, #78d7ff) border-box;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
:root[data-theme="system"] .task-card.is-active::after {
|
|
945
|
+
background: var(--active-surface);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
514
949
|
* { box-sizing: border-box; }
|
|
515
950
|
|
|
516
951
|
body {
|
|
@@ -526,18 +961,46 @@ textarea {
|
|
|
526
961
|
font: inherit;
|
|
527
962
|
}
|
|
528
963
|
|
|
964
|
+
a {
|
|
965
|
+
color: inherit;
|
|
966
|
+
text-decoration: none;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
select,
|
|
970
|
+
button {
|
|
971
|
+
font: inherit;
|
|
972
|
+
}
|
|
973
|
+
|
|
529
974
|
.topbar {
|
|
530
975
|
position: sticky;
|
|
531
|
-
top:
|
|
976
|
+
top: 16px;
|
|
532
977
|
z-index: 10;
|
|
533
978
|
display: flex;
|
|
534
979
|
align-items: center;
|
|
535
980
|
justify-content: space-between;
|
|
536
981
|
gap: 16px;
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
982
|
+
width: min(1392px, calc(100% - 48px));
|
|
983
|
+
min-height: 64px;
|
|
984
|
+
margin: 0 auto;
|
|
985
|
+
padding: 10px 12px 10px 18px;
|
|
986
|
+
border: 1px solid rgba(219, 226, 240, 0.86);
|
|
987
|
+
border-radius: 999px;
|
|
988
|
+
background: rgba(255, 255, 255, 0.78);
|
|
989
|
+
box-shadow: 0 18px 48px rgba(30, 40, 72, 0.1);
|
|
990
|
+
backdrop-filter: blur(22px);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
:root[data-theme="dark"] .topbar {
|
|
994
|
+
border-color: rgba(61, 76, 108, 0.86);
|
|
995
|
+
background: rgba(13, 23, 41, 0.84);
|
|
996
|
+
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.28);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
.topbar-primary {
|
|
1000
|
+
display: inline-flex;
|
|
1001
|
+
align-items: center;
|
|
1002
|
+
gap: 24px;
|
|
1003
|
+
min-width: 0;
|
|
541
1004
|
}
|
|
542
1005
|
|
|
543
1006
|
.brand {
|
|
@@ -546,12 +1009,18 @@ textarea {
|
|
|
546
1009
|
gap: 10px;
|
|
547
1010
|
color: #071236;
|
|
548
1011
|
font-weight: 800;
|
|
1012
|
+
min-width: fit-content;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
:root[data-theme="dark"] .brand {
|
|
1016
|
+
color: var(--ink);
|
|
549
1017
|
}
|
|
550
1018
|
|
|
551
1019
|
.brand-mark {
|
|
552
1020
|
display: block;
|
|
553
|
-
width:
|
|
554
|
-
height:
|
|
1021
|
+
width: 38px;
|
|
1022
|
+
height: 38px;
|
|
1023
|
+
filter: drop-shadow(0 8px 13px rgba(87, 76, 210, 0.18));
|
|
555
1024
|
}
|
|
556
1025
|
|
|
557
1026
|
.brand-name {
|
|
@@ -559,7 +1028,198 @@ textarea {
|
|
|
559
1028
|
letter-spacing: 0;
|
|
560
1029
|
}
|
|
561
1030
|
|
|
562
|
-
.
|
|
1031
|
+
.board-switcher {
|
|
1032
|
+
display: flex;
|
|
1033
|
+
align-items: center;
|
|
1034
|
+
justify-content: flex-start;
|
|
1035
|
+
gap: 8px;
|
|
1036
|
+
min-width: 0;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
.board-switcher label {
|
|
1040
|
+
color: var(--muted);
|
|
1041
|
+
font-size: 11px;
|
|
1042
|
+
font-weight: 700;
|
|
1043
|
+
letter-spacing: 0.08em;
|
|
1044
|
+
text-transform: uppercase;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
.board-switcher select {
|
|
1048
|
+
width: min(280px, 100%);
|
|
1049
|
+
min-width: 0;
|
|
1050
|
+
min-height: 38px;
|
|
1051
|
+
border: 1px solid rgba(219, 226, 240, 0.9);
|
|
1052
|
+
border-radius: 999px;
|
|
1053
|
+
padding: 0 34px 0 14px;
|
|
1054
|
+
background: rgba(255, 255, 255, 0.72);
|
|
1055
|
+
color: #2f3c59;
|
|
1056
|
+
font-weight: 700;
|
|
1057
|
+
font-size: 14px;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
:root[data-theme="dark"] .board-switcher select,
|
|
1061
|
+
:root[data-theme="dark"] .github-stars,
|
|
1062
|
+
:root[data-theme="dark"] .settings-button {
|
|
1063
|
+
border-color: rgba(61, 76, 108, 0.9);
|
|
1064
|
+
background: rgba(16, 26, 45, 0.78);
|
|
1065
|
+
color: var(--ink);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
.board-switcher.is-empty {
|
|
1069
|
+
display: none;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
.header-tools {
|
|
1073
|
+
display: inline-flex;
|
|
1074
|
+
align-items: center;
|
|
1075
|
+
justify-content: flex-end;
|
|
1076
|
+
gap: 10px;
|
|
1077
|
+
min-width: fit-content;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
.github-stars,
|
|
1081
|
+
.settings-button {
|
|
1082
|
+
display: inline-flex;
|
|
1083
|
+
align-items: center;
|
|
1084
|
+
justify-content: center;
|
|
1085
|
+
min-height: 44px;
|
|
1086
|
+
border: 1px solid rgba(219, 226, 240, 0.9);
|
|
1087
|
+
border-radius: 999px;
|
|
1088
|
+
background: rgba(255, 255, 255, 0.72);
|
|
1089
|
+
color: #2f3c59;
|
|
1090
|
+
font-weight: 800;
|
|
1091
|
+
transition: transform 180ms ease, color 180ms ease, border-color 180ms ease, background 180ms ease;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
.github-stars {
|
|
1095
|
+
gap: 7px;
|
|
1096
|
+
padding: 0 15px;
|
|
1097
|
+
font-size: 14px;
|
|
1098
|
+
white-space: nowrap;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
.github-stars:hover,
|
|
1102
|
+
.settings-button:hover {
|
|
1103
|
+
transform: translateY(-2px);
|
|
1104
|
+
color: #071236;
|
|
1105
|
+
border-color: rgba(79, 70, 216, 0.26);
|
|
1106
|
+
background: #fff;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
.github-stars svg {
|
|
1110
|
+
width: 16px;
|
|
1111
|
+
height: 16px;
|
|
1112
|
+
color: #4f46d8;
|
|
1113
|
+
fill: currentColor;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
.settings-wrap {
|
|
1117
|
+
position: relative;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
.settings-button {
|
|
1121
|
+
position: relative;
|
|
1122
|
+
gap: 8px;
|
|
1123
|
+
width: 44px;
|
|
1124
|
+
padding: 0;
|
|
1125
|
+
cursor: pointer;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
.settings-button svg {
|
|
1129
|
+
width: 18px;
|
|
1130
|
+
height: 18px;
|
|
1131
|
+
fill: none;
|
|
1132
|
+
stroke: currentColor;
|
|
1133
|
+
stroke-width: 1.8;
|
|
1134
|
+
stroke-linejoin: round;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
.live-dot {
|
|
1138
|
+
width: 8px;
|
|
1139
|
+
height: 8px;
|
|
1140
|
+
border: 2px solid #fff;
|
|
1141
|
+
border-radius: 999px;
|
|
1142
|
+
background: #1f9d69;
|
|
1143
|
+
box-shadow: 0 0 0 4px rgba(31, 157, 105, 0.12);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
.live-dot.offline {
|
|
1147
|
+
background: var(--yellow-text);
|
|
1148
|
+
box-shadow: 0 0 0 4px rgba(149, 100, 0, 0.12);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
.settings-popover {
|
|
1152
|
+
position: absolute;
|
|
1153
|
+
top: calc(100% + 10px);
|
|
1154
|
+
right: 0;
|
|
1155
|
+
width: min(320px, calc(100vw - 32px));
|
|
1156
|
+
padding: 16px;
|
|
1157
|
+
border: 1px solid rgba(219, 226, 240, 0.96);
|
|
1158
|
+
border-radius: 18px;
|
|
1159
|
+
background: rgba(255, 255, 255, 0.96);
|
|
1160
|
+
box-shadow: 0 24px 64px rgba(30, 40, 72, 0.16);
|
|
1161
|
+
backdrop-filter: blur(20px);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
:root[data-theme="dark"] .settings-popover {
|
|
1165
|
+
border-color: rgba(61, 76, 108, 0.96);
|
|
1166
|
+
background: rgba(16, 26, 45, 0.96);
|
|
1167
|
+
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.28);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
.settings-popover[hidden] {
|
|
1171
|
+
display: none;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
.settings-heading {
|
|
1175
|
+
margin-bottom: 12px;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
.settings-heading .eyebrow {
|
|
1179
|
+
margin-bottom: 6px;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
.settings-heading h2 {
|
|
1183
|
+
margin: 0;
|
|
1184
|
+
font-size: 20px;
|
|
1185
|
+
letter-spacing: 0;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
.setting-row {
|
|
1189
|
+
display: grid;
|
|
1190
|
+
gap: 6px;
|
|
1191
|
+
margin-top: 12px;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
.setting-row label {
|
|
1195
|
+
color: var(--muted);
|
|
1196
|
+
font-size: 12px;
|
|
1197
|
+
font-weight: 800;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
.setting-row select {
|
|
1201
|
+
min-height: 38px;
|
|
1202
|
+
border: 1px solid var(--line);
|
|
1203
|
+
border-radius: 8px;
|
|
1204
|
+
padding: 0 10px;
|
|
1205
|
+
background: #fff;
|
|
1206
|
+
color: #2f3437;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
:root[data-theme="dark"] .setting-row select {
|
|
1210
|
+
background: var(--surface);
|
|
1211
|
+
color: var(--ink);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
.visually-hidden {
|
|
1215
|
+
position: absolute;
|
|
1216
|
+
width: 1px;
|
|
1217
|
+
height: 1px;
|
|
1218
|
+
overflow: hidden;
|
|
1219
|
+
clip: rect(0 0 0 0);
|
|
1220
|
+
white-space: nowrap;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
563
1223
|
.badge {
|
|
564
1224
|
display: inline-flex;
|
|
565
1225
|
align-items: center;
|
|
@@ -625,6 +1285,12 @@ h1 {
|
|
|
625
1285
|
line-height: 1.55;
|
|
626
1286
|
}
|
|
627
1287
|
|
|
1288
|
+
:root[data-theme="dark"] .goal-tranche,
|
|
1289
|
+
:root[data-theme="dark"] .task-title,
|
|
1290
|
+
:root[data-theme="dark"] .setting-row select {
|
|
1291
|
+
color: var(--ink);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
628
1294
|
.goal-meta {
|
|
629
1295
|
display: grid;
|
|
630
1296
|
grid-template-columns: repeat(3, minmax(94px, auto));
|
|
@@ -708,6 +1374,7 @@ h1 {
|
|
|
708
1374
|
}
|
|
709
1375
|
|
|
710
1376
|
.task-card {
|
|
1377
|
+
position: relative;
|
|
711
1378
|
width: 100%;
|
|
712
1379
|
min-height: 138px;
|
|
713
1380
|
display: flex;
|
|
@@ -720,10 +1387,16 @@ h1 {
|
|
|
720
1387
|
color: inherit;
|
|
721
1388
|
text-align: left;
|
|
722
1389
|
cursor: pointer;
|
|
1390
|
+
overflow: hidden;
|
|
723
1391
|
transition: transform 160ms ease, border-color 160ms ease;
|
|
724
1392
|
will-change: transform, opacity;
|
|
725
1393
|
}
|
|
726
1394
|
|
|
1395
|
+
.task-card > * {
|
|
1396
|
+
position: relative;
|
|
1397
|
+
z-index: 1;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
727
1400
|
.task-card:hover {
|
|
728
1401
|
border-color: #d1d0cc;
|
|
729
1402
|
transform: translateY(-1px);
|
|
@@ -736,10 +1409,80 @@ h1 {
|
|
|
736
1409
|
}
|
|
737
1410
|
|
|
738
1411
|
.task-card.is-active {
|
|
739
|
-
border-color:
|
|
1412
|
+
border-color: transparent;
|
|
1413
|
+
background: linear-gradient(#fbfdfe, #fbfdfe) padding-box,
|
|
1414
|
+
linear-gradient(110deg, #78d7ff, #4f46d8, #78f2b9, #78d7ff) border-box;
|
|
1415
|
+
box-shadow: 0 14px 38px rgba(31, 108, 159, 0.12);
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
.task-card.is-active::before {
|
|
1419
|
+
position: absolute;
|
|
1420
|
+
inset: -2px;
|
|
1421
|
+
z-index: 0;
|
|
1422
|
+
content: "";
|
|
1423
|
+
background: conic-gradient(from 0deg, transparent 0 58%, rgba(79, 70, 216, 0.28), rgba(120, 215, 255, 0.44), transparent 78% 100%);
|
|
1424
|
+
opacity: 0.86;
|
|
1425
|
+
animation: active-card-orbit 2.8s linear infinite;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
.task-card.is-active::after {
|
|
1429
|
+
position: absolute;
|
|
1430
|
+
inset: 2px;
|
|
1431
|
+
z-index: 0;
|
|
1432
|
+
content: "";
|
|
1433
|
+
border-radius: 6px;
|
|
740
1434
|
background: #fbfdfe;
|
|
741
1435
|
}
|
|
742
1436
|
|
|
1437
|
+
:root[data-theme="dark"] .task-card.is-active {
|
|
1438
|
+
background: linear-gradient(var(--active-surface), var(--active-surface)) padding-box,
|
|
1439
|
+
linear-gradient(110deg, #78d7ff, #6c63ff, #78f2b9, #78d7ff) border-box;
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
:root[data-theme="dark"] .task-card.is-active::after {
|
|
1443
|
+
background: var(--active-surface);
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
:root[data-density="compact"] .shell {
|
|
1447
|
+
padding-top: 20px;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
:root[data-density="compact"] .board {
|
|
1451
|
+
gap: 12px;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
:root[data-density="compact"] .column-header {
|
|
1455
|
+
padding: 12px;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
:root[data-density="compact"] .card-list {
|
|
1459
|
+
gap: 8px;
|
|
1460
|
+
padding: 10px;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
:root[data-density="compact"] .task-card {
|
|
1464
|
+
min-height: 110px;
|
|
1465
|
+
gap: 9px;
|
|
1466
|
+
padding: 11px;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
:root[data-density="compact"] .task-title {
|
|
1470
|
+
font-size: 14px;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
:root[data-completed-visibility="collapse"] .column[data-column-id="completed"] .card-list {
|
|
1474
|
+
display: none;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
:root[data-completed-visibility="collapse"] .column[data-column-id="completed"] {
|
|
1478
|
+
max-height: 80px;
|
|
1479
|
+
overflow: hidden;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
@keyframes active-card-orbit {
|
|
1483
|
+
to { transform: rotate(360deg); }
|
|
1484
|
+
}
|
|
1485
|
+
|
|
743
1486
|
.task-card.is-moving {
|
|
744
1487
|
border-color: #c2b8ff;
|
|
745
1488
|
}
|
|
@@ -776,6 +1519,14 @@ h1 {
|
|
|
776
1519
|
.badge.status-done { background: var(--green-bg); color: var(--green-text); }
|
|
777
1520
|
.badge.status-blocked { background: var(--red-bg); color: var(--red-text); }
|
|
778
1521
|
.badge.role { background: var(--yellow-bg); color: var(--yellow-text); }
|
|
1522
|
+
.badge.subgoal { background: #ece8ff; color: #5c43c6; }
|
|
1523
|
+
.badge.subgoal.status-blocked { background: var(--red-bg); color: var(--red-text); }
|
|
1524
|
+
.badge.subgoal.status-done { background: var(--green-bg); color: var(--green-text); }
|
|
1525
|
+
|
|
1526
|
+
:root[data-theme="dark"] .badge.subgoal {
|
|
1527
|
+
background: #263052;
|
|
1528
|
+
color: #c7d2ff;
|
|
1529
|
+
}
|
|
779
1530
|
|
|
780
1531
|
.empty {
|
|
781
1532
|
padding: 18px;
|
|
@@ -783,10 +1534,47 @@ h1 {
|
|
|
783
1534
|
font-size: 14px;
|
|
784
1535
|
}
|
|
785
1536
|
|
|
1537
|
+
.board-error {
|
|
1538
|
+
grid-column: 1 / -1;
|
|
1539
|
+
padding: 18px;
|
|
1540
|
+
border: 1px solid var(--red-border);
|
|
1541
|
+
border-radius: 8px;
|
|
1542
|
+
background: var(--red-bg);
|
|
1543
|
+
color: var(--text);
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
.board-error h2 {
|
|
1547
|
+
margin: 0 0 8px;
|
|
1548
|
+
font-size: 16px;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
.board-error p {
|
|
1552
|
+
margin: 0;
|
|
1553
|
+
color: var(--muted);
|
|
1554
|
+
}
|
|
1555
|
+
|
|
786
1556
|
@media (prefers-reduced-motion: reduce) {
|
|
1557
|
+
.github-stars,
|
|
1558
|
+
.settings-button,
|
|
787
1559
|
.task-card {
|
|
788
1560
|
transition: none;
|
|
789
1561
|
}
|
|
1562
|
+
|
|
1563
|
+
.task-card.is-active::before {
|
|
1564
|
+
animation: none;
|
|
1565
|
+
opacity: 0.26;
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
:root[data-motion="reduce"] .github-stars,
|
|
1570
|
+
:root[data-motion="reduce"] .settings-button,
|
|
1571
|
+
:root[data-motion="reduce"] .task-card {
|
|
1572
|
+
transition: none;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
:root[data-motion="reduce"] .task-card.is-active::before {
|
|
1576
|
+
animation: none;
|
|
1577
|
+
opacity: 0.26;
|
|
790
1578
|
}
|
|
791
1579
|
|
|
792
1580
|
.modal[hidden] {
|
|
@@ -811,7 +1599,7 @@ h1 {
|
|
|
811
1599
|
|
|
812
1600
|
.modal-panel {
|
|
813
1601
|
position: relative;
|
|
814
|
-
width: min(
|
|
1602
|
+
width: min(1080px, 100%);
|
|
815
1603
|
max-height: min(760px, calc(100vh - 48px));
|
|
816
1604
|
overflow: auto;
|
|
817
1605
|
border: 1px solid var(--line);
|
|
@@ -896,10 +1684,94 @@ h1 {
|
|
|
896
1684
|
.detail-section ul {
|
|
897
1685
|
margin: 0;
|
|
898
1686
|
padding-left: 18px;
|
|
899
|
-
color:
|
|
1687
|
+
color: var(--ink);
|
|
900
1688
|
line-height: 1.55;
|
|
901
1689
|
}
|
|
902
1690
|
|
|
1691
|
+
.detail-section li {
|
|
1692
|
+
color: var(--ink);
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
.subgoal-section {
|
|
1696
|
+
border: 1px solid var(--line);
|
|
1697
|
+
border-radius: 8px;
|
|
1698
|
+
padding: 14px;
|
|
1699
|
+
background: var(--surface-muted);
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
.subgoal-header {
|
|
1703
|
+
display: flex;
|
|
1704
|
+
align-items: start;
|
|
1705
|
+
justify-content: space-between;
|
|
1706
|
+
gap: 12px;
|
|
1707
|
+
margin-bottom: 12px;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
.subgoal-title {
|
|
1711
|
+
margin: 0 0 4px;
|
|
1712
|
+
font-size: 15px;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
.subgoal-meta {
|
|
1716
|
+
margin: 0;
|
|
1717
|
+
color: var(--muted);
|
|
1718
|
+
font-size: 12px;
|
|
1719
|
+
line-height: 1.45;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
.subgoal-board {
|
|
1723
|
+
display: grid;
|
|
1724
|
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
1725
|
+
gap: 10px;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
.subgoal-column {
|
|
1729
|
+
min-width: 0;
|
|
1730
|
+
border: 1px solid var(--line);
|
|
1731
|
+
border-radius: 8px;
|
|
1732
|
+
background: var(--surface);
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
.subgoal-column-header {
|
|
1736
|
+
display: flex;
|
|
1737
|
+
align-items: center;
|
|
1738
|
+
justify-content: space-between;
|
|
1739
|
+
gap: 8px;
|
|
1740
|
+
padding: 10px;
|
|
1741
|
+
border-bottom: 1px solid var(--line);
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
.subgoal-column-header h4 {
|
|
1745
|
+
margin: 0;
|
|
1746
|
+
font-size: 12px;
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
.subgoal-card-list {
|
|
1750
|
+
display: grid;
|
|
1751
|
+
gap: 8px;
|
|
1752
|
+
padding: 8px;
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
.subgoal-task-card {
|
|
1756
|
+
min-height: 74px;
|
|
1757
|
+
border: 1px solid var(--line);
|
|
1758
|
+
border-radius: 7px;
|
|
1759
|
+
padding: 9px;
|
|
1760
|
+
background: var(--surface);
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
.subgoal-task-card.is-active {
|
|
1764
|
+
border-color: #8e9cff;
|
|
1765
|
+
background: var(--active-surface);
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
.subgoal-task-title {
|
|
1769
|
+
margin: 6px 0 0;
|
|
1770
|
+
color: var(--ink);
|
|
1771
|
+
font-size: 12px;
|
|
1772
|
+
line-height: 1.35;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
903
1775
|
pre.note {
|
|
904
1776
|
overflow: auto;
|
|
905
1777
|
margin: 0;
|
|
@@ -907,7 +1779,7 @@ pre.note {
|
|
|
907
1779
|
border: 1px solid var(--line);
|
|
908
1780
|
border-radius: 8px;
|
|
909
1781
|
background: var(--canvas);
|
|
910
|
-
color:
|
|
1782
|
+
color: var(--ink);
|
|
911
1783
|
font-family: "Geist Mono", "SF Mono", monospace;
|
|
912
1784
|
font-size: 12px;
|
|
913
1785
|
line-height: 1.55;
|
|
@@ -926,10 +1798,27 @@ pre.note {
|
|
|
926
1798
|
.board {
|
|
927
1799
|
grid-template-columns: 1fr;
|
|
928
1800
|
}
|
|
1801
|
+
|
|
1802
|
+
.subgoal-board {
|
|
1803
|
+
grid-template-columns: 1fr;
|
|
1804
|
+
}
|
|
929
1805
|
}
|
|
930
1806
|
|
|
931
1807
|
@media (max-width: 640px) {
|
|
932
|
-
.topbar
|
|
1808
|
+
.topbar {
|
|
1809
|
+
align-items: flex-start;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
.topbar-primary {
|
|
1813
|
+
flex: 1;
|
|
1814
|
+
flex-wrap: wrap;
|
|
1815
|
+
gap: 10px 14px;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
.board-switcher select {
|
|
1819
|
+
width: 100%;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
933
1822
|
.shell {
|
|
934
1823
|
padding-left: 14px;
|
|
935
1824
|
padding-right: 14px;
|
|
@@ -949,22 +1838,69 @@ pre.note {
|
|
|
949
1838
|
function boardJs() {
|
|
950
1839
|
return `let currentBoard = null;
|
|
951
1840
|
let eventSource = null;
|
|
1841
|
+
let currentSettings = null;
|
|
952
1842
|
|
|
953
1843
|
const boardEl = document.getElementById("board");
|
|
954
1844
|
const liveStateEl = document.getElementById("live-state");
|
|
1845
|
+
const liveDotEl = document.getElementById("live-dot");
|
|
1846
|
+
const boardSwitcherEl = document.getElementById("board-switcher");
|
|
1847
|
+
const settingsButtonEl = document.getElementById("settings-button");
|
|
1848
|
+
const settingsPopoverEl = document.getElementById("settings-popover");
|
|
1849
|
+
const githubStarsEl = document.getElementById("github-stars");
|
|
955
1850
|
const modalEl = document.getElementById("task-modal");
|
|
956
1851
|
const modalTitleEl = document.getElementById("modal-title");
|
|
957
1852
|
const modalKickerEl = document.getElementById("modal-kicker");
|
|
958
1853
|
const modalBodyEl = document.getElementById("modal-body");
|
|
1854
|
+
const settingsStorageKey = "goalbuddy.localBoardSettings.v1";
|
|
1855
|
+
const settingsDefaults = {
|
|
1856
|
+
theme: "system",
|
|
1857
|
+
density: "comfortable",
|
|
1858
|
+
completedVisibility: "show",
|
|
1859
|
+
boardOpenBehavior: "last",
|
|
1860
|
+
motion: "system",
|
|
1861
|
+
lastBoardPath: "",
|
|
1862
|
+
};
|
|
1863
|
+
const settingsOptions = {
|
|
1864
|
+
theme: new Set(["system", "light", "dark"]),
|
|
1865
|
+
density: new Set(["comfortable", "compact"]),
|
|
1866
|
+
completedVisibility: new Set(["show", "collapse"]),
|
|
1867
|
+
boardOpenBehavior: new Set(["last", "newest"]),
|
|
1868
|
+
motion: new Set(["system", "reduce", "allow"]),
|
|
1869
|
+
};
|
|
959
1870
|
|
|
960
1871
|
document.addEventListener("click", (event) => {
|
|
961
1872
|
const card = event.target.closest("[data-task-id]");
|
|
962
1873
|
if (card) openTask(card.dataset.taskId);
|
|
963
1874
|
if (event.target.matches("[data-close-modal]")) closeModal();
|
|
1875
|
+
if (settingsPopoverEl.hidden) return;
|
|
1876
|
+
if (!event.target.closest(".settings-wrap")) closeSettings();
|
|
964
1877
|
});
|
|
965
1878
|
|
|
966
1879
|
document.addEventListener("keydown", (event) => {
|
|
967
|
-
if (event.key === "Escape")
|
|
1880
|
+
if (event.key === "Escape") {
|
|
1881
|
+
closeModal();
|
|
1882
|
+
closeSettings();
|
|
1883
|
+
}
|
|
1884
|
+
});
|
|
1885
|
+
|
|
1886
|
+
boardSwitcherEl.addEventListener("change", () => {
|
|
1887
|
+
if (boardSwitcherEl.value && boardSwitcherEl.value !== window.location.href) {
|
|
1888
|
+
window.location.href = boardSwitcherEl.value;
|
|
1889
|
+
}
|
|
1890
|
+
});
|
|
1891
|
+
|
|
1892
|
+
settingsButtonEl.addEventListener("click", () => {
|
|
1893
|
+
if (settingsPopoverEl.hidden) {
|
|
1894
|
+
openSettings();
|
|
1895
|
+
} else {
|
|
1896
|
+
closeSettings();
|
|
1897
|
+
}
|
|
1898
|
+
});
|
|
1899
|
+
|
|
1900
|
+
settingsPopoverEl.addEventListener("change", (event) => {
|
|
1901
|
+
const control = event.target.closest("[data-setting]");
|
|
1902
|
+
if (!control) return;
|
|
1903
|
+
saveSettings({ ...currentSettings, [control.dataset.setting]: control.value });
|
|
968
1904
|
});
|
|
969
1905
|
|
|
970
1906
|
async function loadBoard() {
|
|
@@ -973,6 +1909,122 @@ async function loadBoard() {
|
|
|
973
1909
|
renderBoard(await response.json());
|
|
974
1910
|
}
|
|
975
1911
|
|
|
1912
|
+
async function loadBoardSwitcher() {
|
|
1913
|
+
const response = await fetch("../api/boards", { cache: "no-store" });
|
|
1914
|
+
if (!response.ok) return;
|
|
1915
|
+
const payload = await response.json();
|
|
1916
|
+
renderBoardSwitcher(payload.boards || []);
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
async function loadSettings() {
|
|
1920
|
+
try {
|
|
1921
|
+
const response = await fetch("../api/settings", { cache: "no-store" });
|
|
1922
|
+
if (!response.ok) throw new Error("Settings request failed");
|
|
1923
|
+
const payload = await response.json();
|
|
1924
|
+
currentSettings = normalizeSettings(payload.settings);
|
|
1925
|
+
window.localStorage?.setItem(settingsStorageKey, JSON.stringify(currentSettings));
|
|
1926
|
+
} catch {
|
|
1927
|
+
currentSettings = readStoredSettings();
|
|
1928
|
+
}
|
|
1929
|
+
applySettings(currentSettings);
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
async function saveSettings(nextSettings) {
|
|
1933
|
+
currentSettings = normalizeSettings(nextSettings);
|
|
1934
|
+
window.localStorage?.setItem(settingsStorageKey, JSON.stringify(currentSettings));
|
|
1935
|
+
applySettings(currentSettings);
|
|
1936
|
+
try {
|
|
1937
|
+
const response = await fetch("../api/settings", {
|
|
1938
|
+
method: "PUT",
|
|
1939
|
+
headers: { "Content-Type": "application/json" },
|
|
1940
|
+
body: JSON.stringify({ settings: currentSettings }),
|
|
1941
|
+
});
|
|
1942
|
+
if (!response.ok) throw new Error("Settings save failed");
|
|
1943
|
+
const payload = await response.json();
|
|
1944
|
+
currentSettings = normalizeSettings(payload.settings);
|
|
1945
|
+
window.localStorage?.setItem(settingsStorageKey, JSON.stringify(currentSettings));
|
|
1946
|
+
applySettings(currentSettings);
|
|
1947
|
+
} catch {
|
|
1948
|
+
// Keep the localStorage fallback active when the local settings API is unavailable.
|
|
1949
|
+
}
|
|
1950
|
+
return currentSettings;
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
function readStoredSettings() {
|
|
1954
|
+
try {
|
|
1955
|
+
return normalizeSettings(JSON.parse(window.localStorage?.getItem(settingsStorageKey) || "{}"));
|
|
1956
|
+
} catch {
|
|
1957
|
+
return { ...settingsDefaults };
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
function normalizeSettings(settings) {
|
|
1962
|
+
const normalized = { ...settingsDefaults };
|
|
1963
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings)) return normalized;
|
|
1964
|
+
for (const [key, allowed] of Object.entries(settingsOptions)) {
|
|
1965
|
+
if (allowed.has(settings[key])) normalized[key] = settings[key];
|
|
1966
|
+
}
|
|
1967
|
+
if (typeof settings.lastBoardPath === "string" && /^\\/[a-z0-9][a-z0-9-]*\\/$/.test(settings.lastBoardPath)) {
|
|
1968
|
+
normalized.lastBoardPath = settings.lastBoardPath;
|
|
1969
|
+
}
|
|
1970
|
+
return normalized;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
function applySettings(settings) {
|
|
1974
|
+
const normalized = normalizeSettings(settings);
|
|
1975
|
+
document.documentElement.dataset.theme = normalized.theme;
|
|
1976
|
+
document.documentElement.dataset.density = normalized.density;
|
|
1977
|
+
document.documentElement.dataset.completedVisibility = normalized.completedVisibility;
|
|
1978
|
+
document.documentElement.dataset.boardOpenBehavior = normalized.boardOpenBehavior;
|
|
1979
|
+
document.documentElement.dataset.motion = normalized.motion;
|
|
1980
|
+
for (const control of settingsPopoverEl.querySelectorAll("[data-setting]")) {
|
|
1981
|
+
control.value = normalized[control.dataset.setting] || settingsDefaults[control.dataset.setting];
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
function rememberCurrentBoard() {
|
|
1986
|
+
const boardPath = normalizePath(window.location.pathname);
|
|
1987
|
+
if (!/^\\/[a-z0-9][a-z0-9-]*\\/$/.test(boardPath)) return;
|
|
1988
|
+
const nextSettings = normalizeSettings({ ...currentSettings, lastBoardPath: boardPath });
|
|
1989
|
+
currentSettings = nextSettings;
|
|
1990
|
+
window.localStorage?.setItem(settingsStorageKey, JSON.stringify(nextSettings));
|
|
1991
|
+
fetch("../api/settings", {
|
|
1992
|
+
method: "PUT",
|
|
1993
|
+
headers: { "Content-Type": "application/json" },
|
|
1994
|
+
body: JSON.stringify({ settings: nextSettings }),
|
|
1995
|
+
}).catch(() => {});
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
function openSettings() {
|
|
1999
|
+
settingsPopoverEl.hidden = false;
|
|
2000
|
+
settingsButtonEl.setAttribute("aria-expanded", "true");
|
|
2001
|
+
settingsPopoverEl.querySelector("[data-setting]")?.focus();
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
function closeSettings() {
|
|
2005
|
+
settingsPopoverEl.hidden = true;
|
|
2006
|
+
settingsButtonEl.setAttribute("aria-expanded", "false");
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
function formatStars(count) {
|
|
2010
|
+
if (count >= 1000) return \`\${(count / 1000).toFixed(count >= 10000 ? 0 : 1)}k\`;
|
|
2011
|
+
return String(count);
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
async function loadGithubStars() {
|
|
2015
|
+
if (!githubStarsEl) return;
|
|
2016
|
+
try {
|
|
2017
|
+
const response = await fetch("https://api.github.com/repos/tolibear/goalbuddy", {
|
|
2018
|
+
headers: { Accept: "application/vnd.github+json" },
|
|
2019
|
+
});
|
|
2020
|
+
if (!response.ok) throw new Error("GitHub API unavailable");
|
|
2021
|
+
const repo = await response.json();
|
|
2022
|
+
githubStarsEl.textContent = \`\${formatStars(repo.stargazers_count)} stars\`;
|
|
2023
|
+
} catch {
|
|
2024
|
+
githubStarsEl.textContent = "GitHub";
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
|
|
976
2028
|
function connectEvents() {
|
|
977
2029
|
eventSource = new EventSource("./events");
|
|
978
2030
|
eventSource.addEventListener("board", (event) => {
|
|
@@ -1000,6 +2052,11 @@ function renderBoard(board) {
|
|
|
1000
2052
|
document.getElementById("goal-active").textContent = board.goal.activeTask || "None";
|
|
1001
2053
|
document.getElementById("goal-updated").textContent = new Date(board.generatedAt).toLocaleTimeString();
|
|
1002
2054
|
|
|
2055
|
+
if (board.error) {
|
|
2056
|
+
boardEl.replaceChildren(renderBoardError(board.error));
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
|
|
1003
2060
|
const delay = movingTaskIds.size ? 260 : 0;
|
|
1004
2061
|
window.setTimeout(() => {
|
|
1005
2062
|
boardEl.replaceChildren(...board.columns.map(renderColumn));
|
|
@@ -1007,6 +2064,29 @@ function renderBoard(board) {
|
|
|
1007
2064
|
}, delay);
|
|
1008
2065
|
}
|
|
1009
2066
|
|
|
2067
|
+
function renderBoardError(message) {
|
|
2068
|
+
const node = el("section", "board-error");
|
|
2069
|
+
node.append(
|
|
2070
|
+
el("h2", "", "GoalBuddy could not parse this board"),
|
|
2071
|
+
el("p", "", message),
|
|
2072
|
+
);
|
|
2073
|
+
return node;
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
function renderBoardSwitcher(boards) {
|
|
2077
|
+
boardSwitcherEl.closest(".board-switcher").classList.toggle("is-empty", boards.length <= 1);
|
|
2078
|
+
const currentPath = normalizePath(window.location.pathname);
|
|
2079
|
+
const options = boards.map((board) => {
|
|
2080
|
+
const option = document.createElement("option");
|
|
2081
|
+
option.value = board.url;
|
|
2082
|
+
option.textContent = boardOptionLabel(board);
|
|
2083
|
+
const boardPath = normalizePath(new URL(board.url, window.location.href).pathname);
|
|
2084
|
+
if (boardPath === currentPath) option.selected = true;
|
|
2085
|
+
return option;
|
|
2086
|
+
});
|
|
2087
|
+
boardSwitcherEl.replaceChildren(...options);
|
|
2088
|
+
}
|
|
2089
|
+
|
|
1010
2090
|
function renderColumn(column) {
|
|
1011
2091
|
const section = el("section", "column");
|
|
1012
2092
|
section.dataset.columnId = column.id;
|
|
@@ -1037,6 +2117,7 @@ function renderCard(task) {
|
|
|
1037
2117
|
|
|
1038
2118
|
const footer = el("div", "card-footer");
|
|
1039
2119
|
footer.append(el("span", "badge role", task.assignee || task.type || "PM"));
|
|
2120
|
+
if (task.subgoal) footer.append(subgoalBadge(task.subgoal));
|
|
1040
2121
|
if (task.receipt?.present) footer.append(el("span", "badge status-done", "Receipt"));
|
|
1041
2122
|
|
|
1042
2123
|
button.append(topline, el("h3", "task-title", task.title), footer);
|
|
@@ -1156,6 +2237,7 @@ function renderTaskDetail(task) {
|
|
|
1156
2237
|
grid.append(item);
|
|
1157
2238
|
}
|
|
1158
2239
|
root.append(grid);
|
|
2240
|
+
if (task.subgoal) root.append(renderSubgoal(task.subgoal));
|
|
1159
2241
|
root.append(detailText("Objective", task.objective));
|
|
1160
2242
|
root.append(detailList("Inputs", task.inputs));
|
|
1161
2243
|
root.append(detailList("Constraints", task.constraints));
|
|
@@ -1176,6 +2258,61 @@ function renderTaskDetail(task) {
|
|
|
1176
2258
|
return root;
|
|
1177
2259
|
}
|
|
1178
2260
|
|
|
2261
|
+
function renderSubgoal(subgoal) {
|
|
2262
|
+
const section = el("section", "detail-section subgoal-section");
|
|
2263
|
+
const header = el("div", "subgoal-header");
|
|
2264
|
+
const titleWrap = el("div");
|
|
2265
|
+
const board = subgoal.board;
|
|
2266
|
+
titleWrap.append(
|
|
2267
|
+
el("h3", "subgoal-title", board?.goal?.title || "Sub-goal"),
|
|
2268
|
+
el("p", "subgoal-meta", [
|
|
2269
|
+
subgoal.path,
|
|
2270
|
+
subgoal.owner ? \`owner: \${subgoal.owner}\` : "",
|
|
2271
|
+
subgoal.depth ? \`depth: \${subgoal.depth}\` : "",
|
|
2272
|
+
].filter(Boolean).join(" · ")),
|
|
2273
|
+
);
|
|
2274
|
+
header.append(titleWrap, subgoalBadge(subgoal));
|
|
2275
|
+
section.append(header);
|
|
2276
|
+
|
|
2277
|
+
if (!board?.columns?.length) {
|
|
2278
|
+
section.append(el("p", "", "No child board payload."));
|
|
2279
|
+
return section;
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
const boardEl = el("div", "subgoal-board");
|
|
2283
|
+
for (const column of board.columns) {
|
|
2284
|
+
const columnEl = el("section", "subgoal-column");
|
|
2285
|
+
const columnHeader = el("header", "subgoal-column-header");
|
|
2286
|
+
columnHeader.append(el("h4", "", column.title), el("span", "column-count", String(column.tasks.length)));
|
|
2287
|
+
const list = el("div", "subgoal-card-list");
|
|
2288
|
+
if (column.tasks.length === 0) {
|
|
2289
|
+
list.append(el("p", "empty", "No cards"));
|
|
2290
|
+
} else {
|
|
2291
|
+
for (const task of column.tasks) list.append(renderSubgoalTask(task));
|
|
2292
|
+
}
|
|
2293
|
+
columnEl.append(columnHeader, list);
|
|
2294
|
+
boardEl.append(columnEl);
|
|
2295
|
+
}
|
|
2296
|
+
section.append(boardEl);
|
|
2297
|
+
|
|
2298
|
+
if (subgoal.rollupReceipt) {
|
|
2299
|
+
section.append(detailText("Roll-up Receipt", subgoal.rollupReceipt));
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
return section;
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
function renderSubgoalTask(task) {
|
|
2306
|
+
const card = el("article", \`subgoal-task-card \${task.active ? "is-active" : ""}\`);
|
|
2307
|
+
const topline = el("div", "card-topline");
|
|
2308
|
+
topline.append(el("span", "task-id", task.id), statusBadge(task.status));
|
|
2309
|
+
const footer = el("div", "card-footer");
|
|
2310
|
+
footer.append(el("span", "badge role", task.assignee || task.type || "PM"));
|
|
2311
|
+
if (task.receipt?.present) footer.append(el("span", "badge status-done", "Receipt"));
|
|
2312
|
+
card.append(topline, el("h4", "subgoal-task-title", task.title), footer);
|
|
2313
|
+
return card;
|
|
2314
|
+
}
|
|
2315
|
+
|
|
1179
2316
|
function detailText(title, value) {
|
|
1180
2317
|
const section = el("section", "detail-section");
|
|
1181
2318
|
section.append(el("h3", "", title), el("p", "", value || "None"));
|
|
@@ -1200,9 +2337,24 @@ function statusBadge(status) {
|
|
|
1200
2337
|
return el("span", \`badge status-\${status}\`, label);
|
|
1201
2338
|
}
|
|
1202
2339
|
|
|
2340
|
+
function subgoalBadge(subgoal) {
|
|
2341
|
+
return el("span", \`badge subgoal status-\${subgoal.status}\`, \`Sub-goal \${subgoal.status || "linked"}\`);
|
|
2342
|
+
}
|
|
2343
|
+
|
|
1203
2344
|
function setLiveState(text, live) {
|
|
1204
2345
|
liveStateEl.textContent = text;
|
|
1205
|
-
|
|
2346
|
+
liveDotEl.classList.toggle("offline", !live);
|
|
2347
|
+
settingsButtonEl.setAttribute("aria-label", \`Settings. Board status: \${text}\`);
|
|
2348
|
+
settingsButtonEl.title = \`Settings · \${text}\`;
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
function normalizePath(pathname) {
|
|
2352
|
+
return pathname.endsWith("/") ? pathname : pathname + "/";
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
function boardOptionLabel(board) {
|
|
2356
|
+
const title = board.title || board.slug || board.goalDir || "GoalBuddy board";
|
|
2357
|
+
return /[/\\\\]subgoals[/\\\\]/.test(board.goalDir || "") ? \`Child: \${title}\` : title;
|
|
1206
2358
|
}
|
|
1207
2359
|
|
|
1208
2360
|
function el(tag, className = "", text = "") {
|
|
@@ -1212,9 +2364,14 @@ function el(tag, className = "", text = "") {
|
|
|
1212
2364
|
return node;
|
|
1213
2365
|
}
|
|
1214
2366
|
|
|
1215
|
-
|
|
2367
|
+
loadSettings()
|
|
2368
|
+
.then(loadBoard)
|
|
1216
2369
|
.then(() => {
|
|
1217
2370
|
setLiveState("Live", true);
|
|
2371
|
+
rememberCurrentBoard();
|
|
2372
|
+
loadGithubStars();
|
|
2373
|
+
loadBoardSwitcher();
|
|
2374
|
+
window.setInterval(loadBoardSwitcher, 5000);
|
|
1218
2375
|
connectEvents();
|
|
1219
2376
|
})
|
|
1220
2377
|
.catch((error) => {
|