@shivaedev/work-board 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +70 -11
  3. package/dist/attention/overviewHtml.d.ts +3 -0
  4. package/dist/attention/overviewHtml.d.ts.map +1 -0
  5. package/dist/attention/overviewHtml.js +41 -0
  6. package/dist/attention/overviewHtml.js.map +1 -0
  7. package/dist/attention/requests.d.ts +24 -0
  8. package/dist/attention/requests.d.ts.map +1 -0
  9. package/dist/attention/requests.js +40 -0
  10. package/dist/attention/requests.js.map +1 -0
  11. package/dist/attention/style.d.ts +2 -0
  12. package/dist/attention/style.d.ts.map +1 -0
  13. package/dist/attention/style.js +10 -0
  14. package/dist/attention/style.js.map +1 -0
  15. package/dist/board.d.ts.map +1 -1
  16. package/dist/board.js +6 -1
  17. package/dist/board.js.map +1 -1
  18. package/dist/cli.js +1 -1
  19. package/dist/files/changes.d.ts.map +1 -1
  20. package/dist/files/changes.js +4 -9
  21. package/dist/files/changes.js.map +1 -1
  22. package/dist/files/home.d.ts.map +1 -1
  23. package/dist/files/home.js +4 -0
  24. package/dist/files/home.js.map +1 -1
  25. package/dist/files/list.d.ts +7 -0
  26. package/dist/files/list.d.ts.map +1 -1
  27. package/dist/files/list.js +23 -13
  28. package/dist/files/list.js.map +1 -1
  29. package/dist/files/markdownPath.d.ts +3 -0
  30. package/dist/files/markdownPath.d.ts.map +1 -0
  31. package/dist/files/markdownPath.js +31 -0
  32. package/dist/files/markdownPath.js.map +1 -0
  33. package/dist/files/watchDirectories.d.ts +9 -0
  34. package/dist/files/watchDirectories.d.ts.map +1 -0
  35. package/dist/files/watchDirectories.js +33 -0
  36. package/dist/files/watchDirectories.js.map +1 -0
  37. package/dist/history/baseline.d.ts +19 -0
  38. package/dist/history/baseline.d.ts.map +1 -0
  39. package/dist/history/baseline.js +40 -0
  40. package/dist/history/baseline.js.map +1 -0
  41. package/dist/history/compare.d.ts +14 -0
  42. package/dist/history/compare.d.ts.map +1 -0
  43. package/dist/history/compare.js +47 -0
  44. package/dist/history/compare.js.map +1 -0
  45. package/dist/history/documents.d.ts +10 -0
  46. package/dist/history/documents.d.ts.map +1 -0
  47. package/dist/history/documents.js +15 -0
  48. package/dist/history/documents.js.map +1 -0
  49. package/dist/history/fields.d.ts +3 -0
  50. package/dist/history/fields.d.ts.map +1 -0
  51. package/dist/history/fields.js +27 -0
  52. package/dist/history/fields.js.map +1 -0
  53. package/dist/history/html.d.ts +4 -0
  54. package/dist/history/html.d.ts.map +1 -0
  55. package/dist/history/html.js +22 -0
  56. package/dist/history/html.js.map +1 -0
  57. package/dist/history/limits.d.ts +3 -0
  58. package/dist/history/limits.d.ts.map +1 -0
  59. package/dist/history/limits.js +3 -0
  60. package/dist/history/limits.js.map +1 -0
  61. package/dist/history/response.d.ts +4 -0
  62. package/dist/history/response.d.ts.map +1 -0
  63. package/dist/history/response.js +20 -0
  64. package/dist/history/response.js.map +1 -0
  65. package/dist/history/schema.d.ts +24 -0
  66. package/dist/history/schema.d.ts.map +1 -0
  67. package/dist/history/schema.js +19 -0
  68. package/dist/history/schema.js.map +1 -0
  69. package/dist/history/script.d.ts +2 -0
  70. package/dist/history/script.d.ts.map +1 -0
  71. package/dist/history/script.js +125 -0
  72. package/dist/history/script.js.map +1 -0
  73. package/dist/http/assets.d.ts.map +1 -1
  74. package/dist/http/assets.js +2 -0
  75. package/dist/http/assets.js.map +1 -1
  76. package/dist/http/history.d.ts +7 -0
  77. package/dist/http/history.d.ts.map +1 -0
  78. package/dist/http/history.js +45 -0
  79. package/dist/http/history.js.map +1 -0
  80. package/dist/http/overview.d.ts +5 -0
  81. package/dist/http/overview.d.ts.map +1 -0
  82. package/dist/http/overview.js +13 -0
  83. package/dist/http/overview.js.map +1 -0
  84. package/dist/http/page.d.ts +2 -2
  85. package/dist/http/page.d.ts.map +1 -1
  86. package/dist/http/page.js +5 -6
  87. package/dist/http/page.js.map +1 -1
  88. package/dist/metadata/attentionProblems.d.ts +4 -0
  89. package/dist/metadata/attentionProblems.d.ts.map +1 -0
  90. package/dist/metadata/attentionProblems.js +16 -0
  91. package/dist/metadata/attentionProblems.js.map +1 -0
  92. package/dist/metadata/backlinks.d.ts.map +1 -1
  93. package/dist/metadata/backlinks.js +5 -0
  94. package/dist/metadata/backlinks.js.map +1 -1
  95. package/dist/metadata/fields.d.ts +8 -0
  96. package/dist/metadata/fields.d.ts.map +1 -1
  97. package/dist/metadata/problems.d.ts.map +1 -1
  98. package/dist/metadata/problems.js +3 -1
  99. package/dist/metadata/problems.js.map +1 -1
  100. package/dist/metadata/relationshipLabel.d.ts.map +1 -1
  101. package/dist/metadata/relationshipLabel.js +1 -0
  102. package/dist/metadata/relationshipLabel.js.map +1 -1
  103. package/dist/metadata/schema.d.ts +10 -0
  104. package/dist/metadata/schema.d.ts.map +1 -1
  105. package/dist/metadata/schema.js +9 -0
  106. package/dist/metadata/schema.js.map +1 -1
  107. package/dist/page/attentionDetails.d.ts +4 -0
  108. package/dist/page/attentionDetails.d.ts.map +1 -0
  109. package/dist/page/attentionDetails.js +13 -0
  110. package/dist/page/attentionDetails.js.map +1 -0
  111. package/dist/page/client.d.ts +1 -1
  112. package/dist/page/client.d.ts.map +1 -1
  113. package/dist/page/client.js +1 -0
  114. package/dist/page/client.js.map +1 -1
  115. package/dist/page/metadataDetails.d.ts.map +1 -1
  116. package/dist/page/metadataDetails.js +2 -0
  117. package/dist/page/metadataDetails.js.map +1 -1
  118. package/dist/page/navigationScript.js +1 -1
  119. package/dist/page/shell.d.ts +1 -1
  120. package/dist/page/shell.d.ts.map +1 -1
  121. package/dist/page/shell.js +2 -2
  122. package/dist/page/shell.js.map +1 -1
  123. package/dist/page/stateScript.d.ts.map +1 -1
  124. package/dist/page/stateScript.js +6 -0
  125. package/dist/page/stateScript.js.map +1 -1
  126. package/dist/page/style.d.ts.map +1 -1
  127. package/dist/page/style.js +2 -1
  128. package/dist/page/style.js.map +1 -1
  129. package/dist/search/attentionEntries.d.ts +4 -0
  130. package/dist/search/attentionEntries.d.ts.map +1 -0
  131. package/dist/search/attentionEntries.js +20 -0
  132. package/dist/search/attentionEntries.js.map +1 -0
  133. package/dist/search/metadataEntries.d.ts.map +1 -1
  134. package/dist/search/metadataEntries.js +2 -0
  135. package/dist/search/metadataEntries.js.map +1 -1
  136. package/dist/search/snapshot.d.ts +2 -2
  137. package/dist/search/snapshot.d.ts.map +1 -1
  138. package/dist/search/snapshot.js +4 -5
  139. package/dist/search/snapshot.js.map +1 -1
  140. package/package.json +5 -4
  141. package/src/attention/overviewHtml.ts +44 -0
  142. package/src/attention/requests.ts +64 -0
  143. package/src/attention/style.ts +9 -0
  144. package/src/board.ts +7 -2
  145. package/src/cli.ts +1 -1
  146. package/src/files/changes.ts +4 -16
  147. package/src/files/home.ts +4 -0
  148. package/src/files/list.ts +25 -14
  149. package/src/files/markdownPath.ts +31 -0
  150. package/src/files/watchDirectories.ts +60 -0
  151. package/src/history/baseline.ts +43 -0
  152. package/src/history/compare.ts +54 -0
  153. package/src/history/documents.ts +21 -0
  154. package/src/history/fields.ts +32 -0
  155. package/src/history/html.ts +23 -0
  156. package/src/history/limits.ts +2 -0
  157. package/src/history/response.ts +23 -0
  158. package/src/history/schema.ts +36 -0
  159. package/src/history/script.ts +124 -0
  160. package/src/http/assets.ts +2 -0
  161. package/src/http/history.ts +50 -0
  162. package/src/http/overview.ts +19 -0
  163. package/src/http/page.ts +6 -6
  164. package/src/metadata/attentionProblems.ts +18 -0
  165. package/src/metadata/backlinks.ts +7 -0
  166. package/src/metadata/problems.ts +5 -1
  167. package/src/metadata/relationshipLabel.ts +1 -0
  168. package/src/metadata/schema.ts +9 -0
  169. package/src/page/attentionDetails.ts +18 -0
  170. package/src/page/client.ts +1 -0
  171. package/src/page/metadataDetails.ts +2 -0
  172. package/src/page/navigationScript.ts +1 -1
  173. package/src/page/shell.ts +11 -3
  174. package/src/page/stateScript.ts +6 -0
  175. package/src/page/style.ts +2 -1
  176. package/src/search/attentionEntries.ts +22 -0
  177. package/src/search/metadataEntries.ts +2 -0
  178. package/src/search/snapshot.ts +4 -5
@@ -0,0 +1,43 @@
1
+ import { Option, Schema } from "effect";
2
+ import type { Snapshot } from "#search/snapshot.ts";
3
+ import { MAX_AGE, MAX_BYTES } from "./limits.ts";
4
+ import { Baseline } from "./schema.ts";
5
+
6
+ const JsonBaseline = Schema.fromJsonString(Baseline);
7
+ function byteLength(text: string): number {
8
+ return new TextEncoder().encode(text).length;
9
+ }
10
+
11
+ export function readBaseline(raw: string | null, now: number) {
12
+ if (raw === null) {
13
+ return { baseline: undefined, discard: false, reason: "No previous snapshot. Start remembering changes to compare future source observations." };
14
+ }
15
+ if (byteLength(raw) > MAX_BYTES) {
16
+ return { baseline: undefined, discard: true, reason: "Remembered snapshot exceeds 2 MiB; history is unavailable." };
17
+ }
18
+ const decoded = Schema.decodeUnknownOption(JsonBaseline, { onExcessProperty: "error" })(raw);
19
+ if (Option.isNone(decoded)) {
20
+ return { baseline: undefined, discard: true, reason: "Remembered snapshot is invalid or unsupported; history is unavailable." };
21
+ }
22
+ const elapsed = now - Date.parse(decoded.value.recordedAt);
23
+ if (elapsed < 0) {
24
+ return { baseline: undefined, discard: true, reason: "Remembered observation is in the future; history is unavailable." };
25
+ }
26
+ if (elapsed >= MAX_AGE) {
27
+ return { baseline: undefined, discard: true, reason: "Remembered snapshot expired after 30 days; start again to establish a new baseline." };
28
+ }
29
+ return { baseline: decoded.value, discard: false, reason: "" };
30
+ }
31
+
32
+ export function observeBaseline(snapshot: Snapshot, now: number): string | undefined {
33
+ if (snapshot.unavailable.length > 0) {
34
+ return undefined;
35
+ }
36
+ const baseline: Baseline = {
37
+ documents: snapshot.documents.map((document) => ({ file: document.file, source: (document.parsed.raw ?? "") + document.parsed.body })),
38
+ recordedAt: new Date(now).toISOString(),
39
+ version: 1,
40
+ };
41
+ const raw = Schema.encodeSync(JsonBaseline)(baseline);
42
+ return byteLength(raw) <= MAX_BYTES ? raw : undefined;
43
+ }
@@ -0,0 +1,54 @@
1
+ import { type HistoryDocument, historyDocuments, historyIdentities } from "./documents.ts";
2
+ import { historyFields } from "./fields.ts";
3
+ import type { Baseline } from "./schema.ts";
4
+
5
+ export interface SourceChange {
6
+ readonly after?: HistoryDocument;
7
+ readonly before?: HistoryDocument;
8
+ readonly kind: "added" | "removed" | "changed";
9
+ readonly reasons: readonly string[];
10
+ }
11
+
12
+ export function compareHistory(baseline: Baseline, current: Baseline) {
13
+ const before = historyDocuments(baseline);
14
+ const after = historyDocuments(current);
15
+ const oldIds = historyIdentities(before);
16
+ const newIds = historyIdentities(after);
17
+ const paired = new Set<HistoryDocument>();
18
+ const remaining = new Set(after);
19
+ const changes: SourceChange[] = [];
20
+ function pair(old: HistoryDocument, next: HistoryDocument) {
21
+ paired.add(old);
22
+ remaining.delete(next);
23
+ if (old.source !== next.source || old.file !== next.file) {
24
+ changes.push({ after: next, before: old, kind: "changed", reasons: historyFields(old, next) });
25
+ }
26
+ }
27
+ for (const [id, old] of oldIds) {
28
+ const next = newIds.get(id);
29
+ if (old && next) {
30
+ pair(old, next);
31
+ }
32
+ }
33
+ const byFile = new Map([...remaining].map((document) => [document.file, document]));
34
+ for (const old of before) {
35
+ if (paired.has(old)) {
36
+ continue;
37
+ }
38
+ const next = byFile.get(old.file);
39
+ if (next) {
40
+ pair(old, next);
41
+ } else {
42
+ changes.push({ before: old, kind: "removed", reasons: ["Source absent from the complete current observation"] });
43
+ }
44
+ }
45
+ for (const next of remaining) {
46
+ changes.push({ after: next, kind: "added", reasons: ["Source added since the remembered observation"] });
47
+ }
48
+ const issues = [
49
+ ...[...oldIds].filter(([, document]) => !document).map(([id]) => `Duplicate ID ${id} in remembered sources; no identity winner selected.`),
50
+ ...[...newIds].filter(([, document]) => !document).map(([id]) => `Duplicate ID ${id} in current sources; no identity winner selected.`),
51
+ ];
52
+ changes.sort((a, b) => (a.after?.file ?? a.before?.file ?? "").localeCompare(b.after?.file ?? b.before?.file ?? "", "en"));
53
+ return { changes, issues, newIds };
54
+ }
@@ -0,0 +1,21 @@
1
+ import { metadataParse, type ParsedMetadata } from "#metadata/parse.ts";
2
+ import type { Baseline } from "./schema.ts";
3
+
4
+ export interface HistoryDocument {
5
+ readonly file: string;
6
+ readonly parsed: ParsedMetadata;
7
+ readonly source: string;
8
+ }
9
+ export function historyDocuments(baseline: Baseline): readonly HistoryDocument[] {
10
+ return baseline.documents.map((document) => ({ ...document, parsed: metadataParse(document.source) }));
11
+ }
12
+ export function historyIdentities(documents: readonly HistoryDocument[]): ReadonlyMap<string, HistoryDocument | undefined> {
13
+ const ids = new Map<string, HistoryDocument | undefined>();
14
+ for (const document of documents) {
15
+ const id = document.parsed.fields.id;
16
+ if (id) {
17
+ ids.set(id, ids.has(id) ? undefined : document);
18
+ }
19
+ }
20
+ return ids;
21
+ }
@@ -0,0 +1,32 @@
1
+ import type { Metadata } from "#metadata/schema.ts";
2
+ import type { HistoryDocument } from "./documents.ts";
3
+
4
+ const labels: ReadonlyArray<readonly [keyof Metadata, string]> = [
5
+ ["attention", "Recorded attention requests changed"],
6
+ ["criteria", "Criteria changed"],
7
+ ["evidence", "Recorded evidence changed (not verified acceptance)"],
8
+ ["id", "Item ID changed"],
9
+ ["items", "Declared board membership changed"],
10
+ ["kind", "Item kind changed"],
11
+ ["nextAction", "Next action changed"],
12
+ ["owner", "Recorded owner changed (not authorship)"],
13
+ ["relationships", "Explicit relationships changed"],
14
+ ["status", "Recorded status changed (not acceptance)"],
15
+ ];
16
+
17
+ export function historyFields(before: HistoryDocument, after: HistoryDocument): readonly string[] {
18
+ const changes = labels.flatMap(([field, label]) =>
19
+ JSON.stringify(before.parsed.fields[field]) === JSON.stringify(after.parsed.fields[field]) ? [] : [label],
20
+ );
21
+ if (before.parsed.body !== after.parsed.body) {
22
+ const decision = before.parsed.fields.kind === "decision" || after.parsed.fields.kind === "decision";
23
+ changes.push(decision ? "Decision Markdown changed" : "Markdown body changed");
24
+ }
25
+ if (changes.length === 0 && before.source !== after.source) {
26
+ changes.push("Other frontmatter content or source formatting changed; no further interpretation");
27
+ }
28
+ if (before.file !== after.file) {
29
+ changes.unshift("Source path changed for the same unique item ID");
30
+ }
31
+ return changes;
32
+ }
@@ -0,0 +1,23 @@
1
+ import { fileUrl } from "#files/url.ts";
2
+ import { escapeHtml } from "#page/escape.ts";
3
+ import { compareHistory, type SourceChange } from "./compare.ts";
4
+ import type { Baseline } from "./schema.ts";
5
+
6
+ function source(label: string, text: string, key: string): string {
7
+ return `<details data-key="${escapeHtml(key)}:${label}" data-history-source="${label.toLowerCase()}"><summary>${label} source (plain text)</summary><pre class="history-source">${escapeHtml(text)}</pre></details>`;
8
+ }
9
+ function changeHtml(change: SourceChange): string {
10
+ const file = change.after?.file ?? change.before?.file ?? "";
11
+ const key = change.before ? `before:${change.before.file}` : `added:${file}`;
12
+ return `<article class="history-change" data-key="${escapeHtml(key)}"><h2>${change.kind}: ${escapeHtml(file)}</h2>${change.before && change.after && change.before.file !== file ? `<p>Previously ${escapeHtml(change.before.file)}</p>` : ""}<ul>${change.reasons.map((reason) => `<li>${escapeHtml(reason)}</li>`).join("")}</ul>${change.after ? `<a href="${escapeHtml(fileUrl(change.after.file))}">Read current source</a>` : "<p>Removed source; no current document link.</p>"}${change.before ? source("Remembered", change.before.source, key) : ""}${change.after ? source("Current", change.after.source, key) : ""}</article>`;
13
+ }
14
+ export function historyHtml(before: Baseline, current: Baseline): string {
15
+ const result = compareHistory(before, current);
16
+ const counts = ["added", "removed", "changed"]
17
+ .map((kind) => `${result.changes.filter((change) => change.kind === kind).length} ${kind}`)
18
+ .join(" · ");
19
+ return `<p>Compared with your observation at <time>${escapeHtml(before.recordedAt)}</time>.</p><p role="status">${counts}</p>${result.changes.length === 0 ? "<p>No source changes since that observation.</p>" : ""}${result.issues.length > 0 ? `<aside aria-label="Identity limitations"><ul>${result.issues.map((issue) => `<li>${escapeHtml(issue)}</li>`).join("")}</ul><p>Ambiguous identities are compared by source path only.</p></aside>` : ""}${result.changes.map(changeHtml).join("")}`;
20
+ }
21
+ export function changesHtml(): string {
22
+ return `<section class="history"><h1>Changes since seen</h1><p>Compare current Markdown with one observation you explicitly remember in this browser. This is an observed source comparison, not an event journal, Git history, authorship, or verified acceptance.</p><p>Remembered source can include old or deleted content. One snapshot per workspace, up to 2 MiB, expires after 30 days and is removed on your next workspace visit. Mark seen replaces it; Clear forgets it. No source writes or cloud storage.</p><div class="history-controls"><button id="history-mark" type="button" disabled>Start remembering changes</button><button id="history-clear" type="button" disabled>Clear remembered history</button><span id="history-storage" role="status"></span></div><noscript><p>Remembering and comparing changes requires JavaScript and browser storage. Markdown, Overview and Work remain readable.</p></noscript><div id="changes-content" aria-live="polite"><p>No comparison has been loaded.</p></div></section><style>.history-controls{display:flex;flex-wrap:wrap;gap:.6rem;align-items:center}.history-change{border-top:1px solid var(--border);padding:1rem 0}.history-source{white-space:pre-wrap;overflow-wrap:anywhere;max-height:32rem;overflow:auto}.history h2{overflow-wrap:anywhere}</style>`;
23
+ }
@@ -0,0 +1,2 @@
1
+ export const MAX_BYTES = 2 * 1024 * 1024;
2
+ export const MAX_AGE = 30 * 24 * 60 * 60 * 1000;
@@ -0,0 +1,23 @@
1
+ import { escapeHtml } from "#page/escape.ts";
2
+ import type { Snapshot } from "#search/snapshot.ts";
3
+ import { observeBaseline, readBaseline } from "./baseline.ts";
4
+ import { historyHtml } from "./html.ts";
5
+ import type { HistoryOutput } from "./schema.ts";
6
+
7
+ export function historyResponse(raw: string | null, now: number, snapshot?: Snapshot): HistoryOutput {
8
+ const prior = readBaseline(raw, now);
9
+ const output: HistoryOutput = { discardBaseline: prior.discard, html: `<p>${escapeHtml(prior.reason)}</p>`, reason: prior.reason, snapshot: null };
10
+ if (snapshot === undefined) {
11
+ return output;
12
+ }
13
+ const currentRaw = observeBaseline(snapshot, now);
14
+ if (currentRaw === undefined) {
15
+ const reason =
16
+ snapshot.unavailable.length > 0
17
+ ? "Current workspace observation is incomplete; no comparison or removals are inferred. Your valid remembered snapshot is preserved."
18
+ : "Complete current snapshot exceeds 2 MiB; no comparison or partial history is retained.";
19
+ return { ...output, html: `<p role="status">${escapeHtml(reason)}</p>`, reason };
20
+ }
21
+ const current = readBaseline(currentRaw, now).baseline;
22
+ return { ...output, html: prior.baseline && current ? historyHtml(prior.baseline, current) : output.html, snapshot: currentRaw };
23
+ }
@@ -0,0 +1,36 @@
1
+ import { Schema } from "effect";
2
+ import { isMarkdown } from "#files/list.ts";
3
+
4
+ const File = Schema.String.check(
5
+ Schema.makeFilter(
6
+ (value) =>
7
+ (isMarkdown(value) && !value.includes("\\") && !value.includes("\0") && value.split("/").every((part) => part.length > 0 && part !== ".."))
8
+ || "Expected a relative readable Markdown path",
9
+ ),
10
+ );
11
+ const ObservedTime = Schema.String.check(
12
+ Schema.makeFilter(
13
+ (value) =>
14
+ (Number.isFinite(Date.parse(value)) && /^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/u.exec(value) !== null)
15
+ || "Expected an ISO observation time with timezone",
16
+ ),
17
+ );
18
+ export const Baseline = Schema.Struct({
19
+ documents: Schema.Array(Schema.Struct({ file: File, source: Schema.String })).check(
20
+ Schema.makeFilter(
21
+ (values) => new Set(values.map((value) => value.file)).size === values.length || "Duplicate source paths are not a complete observation",
22
+ ),
23
+ ),
24
+ recordedAt: ObservedTime,
25
+ version: Schema.Literal(1),
26
+ }).pipe(Schema.encodeKeys({ recordedAt: "recorded_at" }));
27
+ export type Baseline = typeof Baseline.Type;
28
+
29
+ export const HistoryInput = Schema.Struct({ action: Schema.Literals(["check", "compare", "observe"]), baseline: Schema.NullOr(Schema.String) });
30
+ export const HistoryOutput = Schema.Struct({
31
+ discardBaseline: Schema.Boolean,
32
+ html: Schema.String,
33
+ reason: Schema.String,
34
+ snapshot: Schema.NullOr(Schema.String),
35
+ });
36
+ export type HistoryOutput = typeof HistoryOutput.Type;
@@ -0,0 +1,124 @@
1
+ export function historyScript(): string {
2
+ return `
3
+ import { swap, remember } from "/_board/swap.js";
4
+ import { captureReading, restoreReading } from "/_board/reading-state.js";
5
+ const key = "work-board:changes:" + document.documentElement.dataset.workspace;
6
+ let memory;
7
+ let storageNotice = "";
8
+ let unavailableHtml = "";
9
+ let generation = 0;
10
+ let controller;
11
+ let observing = null;
12
+ const baseline = () => {
13
+ if (memory !== undefined) return memory;
14
+ try { return localStorage.getItem(key); }
15
+ catch { memory = null; storageNotice = "Browser storage unavailable; history applies to this page only."; return memory; }
16
+ };
17
+ const forget = () => {
18
+ memory = null;
19
+ try { localStorage.removeItem(key); memory = undefined; }
20
+ catch { storageNotice = "Cleared for this page, but browser storage could not be cleared. Reload may restore older data."; }
21
+ };
22
+ const keep = (raw) => {
23
+ memory = raw;
24
+ try { localStorage.setItem(key, raw); memory = undefined; storageNotice = ""; }
25
+ catch {
26
+ storageNotice = "Snapshot remembered for this page only; browser storage is unavailable.";
27
+ try { localStorage.removeItem(key); }
28
+ catch { storageNotice += " Reload may restore older data."; }
29
+ }
30
+ };
31
+ const controls = (available, busy = false) => {
32
+ const mark = document.getElementById("history-mark");
33
+ const clear = document.getElementById("history-clear");
34
+ if (!mark || !clear) return;
35
+ const exists = baseline() !== null;
36
+ mark.textContent = exists ? "Mark current workspace seen" : "Start remembering changes";
37
+ mark.disabled = busy || !available;
38
+ clear.disabled = busy || !exists;
39
+ document.getElementById("history-storage").textContent = storageNotice;
40
+ };
41
+ const render = (html) => {
42
+ const content = document.getElementById("changes-content");
43
+ if (!content) return;
44
+ const reading = captureReading();
45
+ const next = content.cloneNode(false);
46
+ next.innerHTML = html;
47
+ swap(content, next);
48
+ remember(content);
49
+ restoreReading({ ...reading, selection: null }, false);
50
+ };
51
+ const query = async (action) => {
52
+ const mine = ++generation;
53
+ controller?.abort();
54
+ controller = new AbortController();
55
+ const raw = baseline();
56
+ const response = await fetch("/_board/history", {
57
+ method: "POST", headers: { "content-type": "application/json" },
58
+ body: JSON.stringify({ action, baseline: raw }), signal: controller.signal, cache: "no-store",
59
+ });
60
+ if (!response.ok) throw new Error("History query failed");
61
+ const result = await response.json();
62
+ if (mine !== generation) return null;
63
+ if (result.discardBaseline && baseline() === raw) { forget(); unavailableHtml = result.html; }
64
+ return result;
65
+ };
66
+ const refresh = async () => {
67
+ if (observing !== null) return;
68
+ const visible = document.getElementById("doc")?.dataset.view === "changes";
69
+ try {
70
+ const result = await query(visible ? "compare" : "check");
71
+ if (!result) return;
72
+ if (visible && document.getElementById("doc")?.dataset.view === "changes") {
73
+ render(baseline() === null && unavailableHtml ? unavailableHtml : result.html);
74
+ controls(result.snapshot !== null);
75
+ }
76
+ } catch (error) {
77
+ if (error.name === "AbortError") return;
78
+ if (visible) {
79
+ render("<p>History is unavailable: the current observation could not be loaded. No comparison or removals are inferred.</p>");
80
+ controls(false);
81
+ }
82
+ }
83
+ };
84
+ document.addEventListener("click", async (event) => {
85
+ if (event.target.closest?.("#history-clear")) {
86
+ observing = null;
87
+ ++generation;
88
+ controller?.abort();
89
+ forget();
90
+ unavailableHtml = "";
91
+ await refresh();
92
+ }
93
+ if (event.target.closest?.("#history-mark")) {
94
+ if (observing !== null) return;
95
+ const mine = generation + 1;
96
+ observing = mine;
97
+ controls(false, true);
98
+ try {
99
+ const result = await query("observe");
100
+ if (!result) return;
101
+ if (result.snapshot === null) { render(result.html); controls(false); return; }
102
+ keep(result.snapshot);
103
+ unavailableHtml = "";
104
+ } catch (error) {
105
+ if (error.name !== "AbortError") { render("<p>Could not remember the current observation. Your previous baseline is preserved.</p>"); controls(false); }
106
+ return;
107
+ } finally { if (observing === mine) observing = null; }
108
+ await refresh();
109
+ }
110
+ });
111
+ document.addEventListener("board-page", refresh);
112
+ document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); });
113
+ window.addEventListener("storage", (event) => {
114
+ if (event.key === key || event.key === null) {
115
+ observing = null;
116
+ ++generation;
117
+ controller?.abort();
118
+ memory = undefined; storageNotice = ""; unavailableHtml = ""; refresh();
119
+ }
120
+ });
121
+ setInterval(() => { if (baseline() !== null) refresh(); }, 60000);
122
+ refresh();
123
+ `;
124
+ }
@@ -1,6 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { Data, Effect, FileSystem, Path } from "effect";
3
3
  import type { HttpRouter, HttpServerRequest } from "effect/unstable/http";
4
+ import { historyScript } from "#history/script.ts";
4
5
  import { client, preferences } from "#page/client.ts";
5
6
  import { diagrams } from "#page/diagrams.ts";
6
7
  import { libraryScript } from "#page/libraryScript.ts";
@@ -18,6 +19,7 @@ export const ASSETS: ReadonlyArray<readonly [HttpRouter.PathInput, string, strin
18
19
  ["/_board/style.css", style, "text/css"],
19
20
  ["/_board/saved-views.js", savedScript(), "text/javascript"],
20
21
  ["/_board/client.js", client, "text/javascript"],
22
+ ["/_board/history.js", historyScript(), "text/javascript"],
21
23
  ["/_board/preferences.js", preferences, "text/javascript"],
22
24
  ["/_board/search.js", searchScript(), "text/javascript"],
23
25
  ["/_board/search-results.js", searchResultsScript(), "text/javascript"],
@@ -0,0 +1,50 @@
1
+ import { Clock, Effect, FileSystem, Schema } from "effect";
2
+ import { HttpIncomingMessage, HttpServerRequest } from "effect/unstable/http";
3
+ import type { Changes } from "#files/changes.ts";
4
+ import { changesHtml } from "#history/html.ts";
5
+ import { MAX_BYTES } from "#history/limits.ts";
6
+ import { historyResponse } from "#history/response.ts";
7
+ import { HistoryInput, HistoryOutput } from "#history/schema.ts";
8
+ import { navHtml } from "#page/nav.ts";
9
+ import { shell } from "#page/shell.ts";
10
+ import type { searchSnapshot } from "#search/snapshot.ts";
11
+ import { respond } from "./respond.ts";
12
+
13
+ export function changesPage(changes: Changes, home: string | undefined) {
14
+ return Effect.fn("WorkBoard.changesPage")(function* () {
15
+ const nav = navHtml(yield* changes.files, "", home);
16
+ return respond(shell("Changes", nav, changesHtml(), changes.realRoot, true, undefined, "changes"), "text/html");
17
+ });
18
+ }
19
+ function sameOrigin(request: HttpServerRequest.HttpServerRequest): boolean {
20
+ const origin = request.headers.origin;
21
+ if (origin === undefined) {
22
+ return true;
23
+ }
24
+ try {
25
+ const url = new URL(origin);
26
+ return url.protocol === "http:" && url.host === request.headers.host;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+ export function history(index: Effect.Success<ReturnType<typeof searchSnapshot>>) {
32
+ return Effect.fn("WorkBoard.history")(function* (request: HttpServerRequest.HttpServerRequest) {
33
+ if (!sameOrigin(request)) {
34
+ return respond("Local same-origin requests only", "text/plain", 403);
35
+ }
36
+ const decoded = yield* HttpServerRequest.schemaBodyJson(HistoryInput, { onExcessProperty: "error" }).pipe(
37
+ Effect.provideService(HttpServerRequest.HttpServerRequest, request),
38
+ Effect.provideService(HttpIncomingMessage.MaxBodySize, FileSystem.Size(4 * MAX_BYTES + 4096)),
39
+ Effect.result,
40
+ );
41
+ if (decoded._tag === "Failure") {
42
+ return respond("Invalid history query", "text/plain", 400);
43
+ }
44
+ const input = decoded.success;
45
+ const now = yield* Clock.currentTimeMillis;
46
+ const snapshot = input.action === "check" ? undefined : yield* index;
47
+ const output = historyResponse(input.baseline, now, snapshot);
48
+ return respond(Schema.encodeSync(Schema.fromJsonString(HistoryOutput))(output), "application/json");
49
+ });
50
+ }
@@ -0,0 +1,19 @@
1
+ import { Effect } from "effect";
2
+ import { overviewHtml } from "#attention/overviewHtml.ts";
3
+ import type { Changes } from "#files/changes.ts";
4
+ import { navHtml } from "#page/nav.ts";
5
+ import { shell } from "#page/shell.ts";
6
+ import type { searchSnapshot } from "#search/snapshot.ts";
7
+ import { respond } from "./respond.ts";
8
+
9
+ export function overview(index: Effect.Success<ReturnType<typeof searchSnapshot>>, changes: Changes, home: string | undefined) {
10
+ return Effect.fn("WorkBoard.overview")(function* () {
11
+ const snapshot = yield* index;
12
+ const nav = navHtml(yield* changes.files, "", home);
13
+ return respond(
14
+ shell("Overview", nav, overviewHtml(snapshot), changes.realRoot, true, undefined, "overview"),
15
+ "text/html",
16
+ snapshot.unavailable.length > 0 ? 503 : 200,
17
+ );
18
+ });
19
+ }
package/src/http/page.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { Effect, FileSystem, Option, Path } from "effect";
1
+ import { Effect, FileSystem, Option } from "effect";
2
2
  import type { HttpServerRequest } from "effect/unstable/http";
3
3
  import type { Changes } from "#files/changes.ts";
4
- import { type MarkdownFile, within } from "#files/list.ts";
4
+ import type { MarkdownFile } from "#files/list.ts";
5
+ import { markdownPath } from "#files/markdownPath.ts";
5
6
  import { metadataModel } from "#metadata/model.ts";
6
7
  import { metadataParse } from "#metadata/parse.ts";
7
8
  import { boardHtml } from "#page/board.ts";
@@ -33,12 +34,11 @@ const message = (text: string): string => `<p class="empty">${escapeHtml(text)}<
33
34
  export const page = (options: PageOptions, changes: Changes, index: Effect.Success<ReturnType<typeof searchSnapshot>>) => {
34
35
  const body = Effect.fn("WorkBoard.pageBody")(function* (file: MarkdownFile, expectedIdentity?: string) {
35
36
  const fs = yield* FileSystem.FileSystem;
36
- const path = yield* Path.Path;
37
- const real = yield* Effect.option(fs.realPath(path.join(options.root, file.path)));
38
- if (Option.isNone(real) || !within(changes.realRoot, path.sep, real.value)) {
37
+ const real = yield* markdownPath(options.root, changes.realRoot, file.path);
38
+ if (real === undefined) {
39
39
  return Option.none();
40
40
  }
41
- const source = yield* fs.readFileString(real.value);
41
+ const source = yield* fs.readFileString(real);
42
42
  const parsed = metadataParse(source);
43
43
  let identity: string | undefined;
44
44
  const snapshot = yield* index;
@@ -0,0 +1,18 @@
1
+ import type { MetadataDocument } from "./model.ts";
2
+ import type { Diagnostic } from "./parse.ts";
3
+
4
+ export function attentionProblems(document: MetadataDocument): readonly Diagnostic[] {
5
+ const { fields, lines } = document.parsed;
6
+ const requests = fields.attention ?? [];
7
+ return requests.flatMap((request, index) => {
8
+ const line = lines[`attention.${index}.id`] ?? lines.attention ?? 2;
9
+ const problems: Diagnostic[] = [];
10
+ if (!fields.id) {
11
+ problems.push({ field: "attention", line, message: "Attention requests require an explicit item ID; this source remains unclassified." });
12
+ }
13
+ if (requests.filter((other) => other.id === request.id).length > 1) {
14
+ problems.push({ field: "attention", line, message: `Duplicate attention request ID ${request.id}; neither request is selected.` });
15
+ }
16
+ return problems;
17
+ });
18
+ }
@@ -10,6 +10,13 @@ export interface Backlink {
10
10
  function references(document: MetadataDocument, model: MetadataModel) {
11
11
  const { fields, lines } = document.parsed;
12
12
  return [
13
+ ...(fields.attention?.flatMap((request, index) =>
14
+ request.unblocks.map((target, targetIndex) => ({
15
+ kind: "attention target",
16
+ line: lines[`attention.${index}.unblocks.${targetIndex}`],
17
+ target: referenceFile(target, model),
18
+ })),
19
+ ) ?? []),
13
20
  ...(fields.relationships?.map((link, index) => ({
14
21
  kind: link.kind,
15
22
  line: lines[`relationships.${index}.target`],
@@ -1,3 +1,4 @@
1
+ import { attentionProblems } from "./attentionProblems.ts";
1
2
  import type { MetadataDocument } from "./model.ts";
2
3
  import type { Diagnostic } from "./parse.ts";
3
4
 
@@ -26,6 +27,9 @@ function criterionProblems(document: MetadataDocument): readonly Diagnostic[] {
26
27
  function referenceProblems(document: MetadataDocument, ids: ReadonlyMap<string, readonly MetadataDocument[]>): readonly Diagnostic[] {
27
28
  const { fields, lines } = document.parsed;
28
29
  const references = [
30
+ ...(fields.attention?.flatMap((request, index) =>
31
+ request.unblocks.map((target, targetIndex) => ({ field: "attention", key: `attention.${index}.unblocks.${targetIndex}`, target })),
32
+ ) ?? []),
29
33
  ...(fields.relationships?.map((link, index) => ({ field: "relationships", key: `relationships.${index}.target`, target: link.target })) ?? []),
30
34
  ...(fields.items?.map((target, index) => ({ field: "items", key: `items.${index}`, target })) ?? []),
31
35
  ...(fields.evidence?.flatMap((item, index) =>
@@ -39,7 +43,7 @@ function referenceProblems(document: MetadataDocument, ids: ReadonlyMap<string,
39
43
  }
40
44
  export function metadataProblems(document: MetadataDocument, ids: ReadonlyMap<string, readonly MetadataDocument[]>, unavailable: readonly string[]) {
41
45
  const { parsed } = document;
42
- const problems = [...parsed.diagnostics, ...criterionProblems(document)];
46
+ const problems = [...parsed.diagnostics, ...criterionProblems(document), ...attentionProblems(document)];
43
47
  const matches = parsed.fields.id ? ids.get(parsed.fields.id) : undefined;
44
48
  if (matches && matches.length > 1) {
45
49
  problems.push({
@@ -1,4 +1,5 @@
1
1
  const labels = new Map([
2
+ ["attention target", "Attention request"],
2
3
  ["board member", "Board membership"],
3
4
  ["criterion evidence", "Criterion evidence"],
4
5
  ["depends_on", "Depends on"],
@@ -3,6 +3,14 @@ import { Schema } from "effect";
3
3
  export const Identity = Schema.String.check(Schema.isPattern(/^[a-zA-Z\d][a-zA-Z\d._-]*$/u));
4
4
  export const Reference = Schema.String.check(Schema.isPattern(/^[a-zA-Z\d][a-zA-Z\d._-]*(?:#[a-zA-Z\d][a-zA-Z\d._-]*)?$/u));
5
5
  const Text = Schema.String.check(Schema.isPattern(/\S/u));
6
+ const Attention = Schema.Struct({
7
+ id: Identity,
8
+ kind: Schema.Literals(["decision", "review", "blocker"]),
9
+ reason: Text,
10
+ responseFrom: Schema.Array(Text).check(Schema.isMinLength(1)),
11
+ state: Schema.Literals(["open", "closed"]),
12
+ unblocks: Schema.Array(Reference).check(Schema.isMinLength(1)),
13
+ }).pipe(Schema.encodeKeys({ responseFrom: "response_from" }));
6
14
  const Criterion = Schema.Struct({ id: Identity, text: Text });
7
15
  const Relationship = Schema.Struct({ kind: Schema.Literals(["implements", "informs", "depends_on", "relates_to"]), target: Reference });
8
16
  const Evidence = Schema.Struct({
@@ -23,6 +31,7 @@ const Evidence = Schema.Struct({
23
31
  }).pipe(Schema.encodeKeys({ checkedRevision: "checked_revision", observedAt: "observed_at" }));
24
32
 
25
33
  export const Metadata = Schema.Struct({
34
+ attention: Schema.optional(Schema.Array(Attention)),
26
35
  criteria: Schema.optional(Schema.Array(Criterion)),
27
36
  evidence: Schema.optional(Schema.Array(Evidence)),
28
37
  id: Schema.optional(Identity),
@@ -0,0 +1,18 @@
1
+ import { attentionSource } from "#attention/requests.ts";
2
+ import type { MetadataModel } from "#metadata/model.ts";
3
+ import type { ParsedMetadata } from "#metadata/parse.ts";
4
+ import { escapeHtml } from "./escape.ts";
5
+ import { referenceHtml } from "./metadataLinks.ts";
6
+
7
+ export function attentionDetails(parsed: ParsedMetadata, file: string, model: MetadataModel): string {
8
+ const requests = parsed.fields.attention ?? [];
9
+ if (requests.length === 0) {
10
+ return "";
11
+ }
12
+ return `<h3>Recorded attention requests</h3><p>Open/closed is source state. Closing a request does not establish acceptance or record an answer.</p><ul>${requests
13
+ .map(
14
+ (request, index) =>
15
+ `<li id="${attentionSource({ file, parsed }, index)}" tabindex="-1"><b>${escapeHtml(request.kind)} · ${escapeHtml(request.id)} · ${escapeHtml(request.state)}</b><p>${escapeHtml(request.reason)}</p><p>Response needed from: ${request.responseFrom.map(escapeHtml).join(", ")}</p><p>Recorded to unblock: ${request.unblocks.map((target) => referenceHtml(target, target, model)).join(", ")}</p><p>Source: ${escapeHtml(file)}:${parsed.lines[`attention.${index}`] ?? parsed.lines.attention ?? 2}</p></li>`,
16
+ )
17
+ .join("")}</ul>`;
18
+ }
@@ -1,5 +1,6 @@
1
1
  export const client = `
2
2
  import "/_board/saved-views.js";
3
+ import "/_board/history.js";
3
4
  import "/_board/preferences.js";
4
5
  import "/_board/search.js";
5
6
  import { renderDiagrams } from "/_board/diagrams.js";
@@ -1,6 +1,7 @@
1
1
  import type { MetadataModel } from "#metadata/model.ts";
2
2
  import type { ParsedMetadata } from "#metadata/parse.ts";
3
3
  import { relationshipLabel } from "#metadata/relationshipLabel.ts";
4
+ import { attentionDetails } from "./attentionDetails.ts";
4
5
  import { criteriaEvidence } from "./criteriaEvidence.ts";
5
6
  import { escapeHtml } from "./escape.ts";
6
7
  import { referenceHtml, valueHtml } from "./metadataLinks.ts";
@@ -18,6 +19,7 @@ export function metadataDetails(parsed: ParsedMetadata, file: string, model: Met
18
19
  : "",
19
20
 
20
21
  criteriaEvidence(parsed, model),
22
+ attentionDetails(parsed, file, model),
21
23
  recordedEvidence(parsed, file, model),
22
24
  ].join("");
23
25
  return details;
@@ -86,7 +86,7 @@ document.addEventListener("click", (event) => {
86
86
  const link = event.target.closest?.("a[href]");
87
87
  if (!link || event.defaultPrevented || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || link.hasAttribute("download") || (link.target && link.target !== "_self")) return;
88
88
  const target = new URL(link.href, location.href);
89
- if (target.origin !== location.origin || !(target.pathname === "/" || (target.pathname === "/_board/work" || target.pathname.startsWith("/_board/item/")) || /\\.md$/i.test(target.pathname))) return;
89
+ if (target.origin !== location.origin || !(target.pathname === "/" || (target.pathname === "/_board/changes" || target.pathname === "/_board/overview" || target.pathname === "/_board/work" || target.pathname.startsWith("/_board/item/")) || /\\.md$/i.test(target.pathname))) return;
90
90
  event.preventDefault();
91
91
  navigate(target);
92
92
  });