@quolu/lattice 0.36.1 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +29 -0
- package/README.md +25 -0
- package/bin/lattice-dashboard.mjs +2 -2
- package/package.json +1 -1
- package/src/cli-help.mjs +17 -1
- package/src/todo-cli.mjs +373 -12
- package/src/todo-contracts.mjs +80 -3
- package/src/todo-gantt-html-independence.mjs +29 -3
- package/src/todo-gantt-html-style.mjs +6 -0
- package/src/todo-gantt-html.mjs +28 -6
- package/src/todo-note-store.mjs +492 -0
- package/src/todo-store.mjs +40 -12
package/src/todo-contracts.mjs
CHANGED
|
@@ -4,7 +4,13 @@ import { isCanonicalUtcTimestamp } from './timestamp-contract.mjs';
|
|
|
4
4
|
export const TODO_EVENT_KINDS = Object.freeze([
|
|
5
5
|
'plan_genesis', 'start', 'block', 'unblock', 'done', 'reopen',
|
|
6
6
|
'phase_review', 'phase_accept', 'phase_reject', 'phase_reopen',
|
|
7
|
+
// ADR 0148: 監査していない歴史を「監査なしで閉じた」として明示的に閉じるための専用kind。
|
|
8
|
+
// phase_review/accept/reject/reopenと同じv3 tail event shape(phase_id持ち)に収め、
|
|
9
|
+
// 新しいevent schema版は作らない。
|
|
10
|
+
'phase_close_unaudited',
|
|
7
11
|
]);
|
|
12
|
+
export const TODO_NOTE_EVENT_SCHEMA = 'lattice.todo_note_event.v1';
|
|
13
|
+
export const TODO_NOTE_CONTEXT_SCHEMA = 'lattice.todo_note_context.v1';
|
|
8
14
|
export const TODO_LIMITS = Object.freeze({
|
|
9
15
|
tasksPerPlan: 512,
|
|
10
16
|
edgesPerPlan: 2_048,
|
|
@@ -12,11 +18,14 @@ export const TODO_LIMITS = Object.freeze({
|
|
|
12
18
|
journalSegmentBytes: 1_048_576,
|
|
13
19
|
snapshotBytes: 8_388_608,
|
|
14
20
|
narrativeSectionBytes: 262_144,
|
|
21
|
+
noteBodyBytes: 16_384,
|
|
22
|
+
noteContextBytes: 65_536,
|
|
15
23
|
});
|
|
16
24
|
|
|
17
25
|
const DIGEST = /^[0-9a-f]{64}$/;
|
|
18
26
|
const IDENTIFIER = /^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$/;
|
|
19
27
|
const CONTROL = /[\u0000-\u001f\u007f]/u;
|
|
28
|
+
const NOTE_FORBIDDEN_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u;
|
|
20
29
|
|
|
21
30
|
export const isTodoDigest = (value) => typeof value === 'string' && DIGEST.test(value);
|
|
22
31
|
export const isTodoIdentifier = (value) => typeof value === 'string' && IDENTIFIER.test(value);
|
|
@@ -85,6 +94,67 @@ export function todoSelfDigest(value, field) {
|
|
|
85
94
|
return digestTodoArtifact(projection);
|
|
86
95
|
}
|
|
87
96
|
|
|
97
|
+
const noteBody = (value) => typeof value === 'string' && value.length > 0
|
|
98
|
+
&& Buffer.byteLength(value, 'utf8') <= TODO_LIMITS.noteBodyBytes
|
|
99
|
+
&& !NOTE_FORBIDDEN_CONTROL.test(value);
|
|
100
|
+
|
|
101
|
+
/** lifecycle journalとは独立したtask note event v1を検証する。 */
|
|
102
|
+
export function validateTodoNoteEvent(value) {
|
|
103
|
+
try {
|
|
104
|
+
return exactRecord(value, [
|
|
105
|
+
'schema', 'project_id', 'plan_key', 'task_id', 'plan_version', 'sequence',
|
|
106
|
+
'previous_digest', 'actor', 'recorded_at', 'body', 'supersedes', 'event_digest',
|
|
107
|
+
]) && value.schema === TODO_NOTE_EVENT_SCHEMA
|
|
108
|
+
&& isTodoIdentifier(value.project_id) && isTodoIdentifier(value.plan_key)
|
|
109
|
+
&& isTodoIdentifier(value.task_id) && isTodoIdentifier(value.plan_version)
|
|
110
|
+
&& Number.isSafeInteger(value.sequence) && value.sequence >= 1
|
|
111
|
+
&& (value.sequence === 1 ? value.previous_digest === null : isTodoDigest(value.previous_digest))
|
|
112
|
+
&& actor(value.actor) && isStrictTodoTimestamp(value.recorded_at) && noteBody(value.body)
|
|
113
|
+
&& (value.supersedes === null || isTodoDigest(value.supersedes))
|
|
114
|
+
&& isTodoDigest(value.event_digest) && value.supersedes !== value.event_digest
|
|
115
|
+
&& value.event_digest === todoSelfDigest(value, 'event_digest');
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function noteContextEntry(value) {
|
|
122
|
+
return exactRecord(value, [
|
|
123
|
+
'event_digest', 'origin_plan_version', 'origin_task_id', 'actor', 'recorded_at',
|
|
124
|
+
'body', 'supersedes', 'superseded_by', 'correction_state',
|
|
125
|
+
]) && isTodoDigest(value.event_digest) && isTodoIdentifier(value.origin_plan_version)
|
|
126
|
+
&& isTodoIdentifier(value.origin_task_id) && actor(value.actor)
|
|
127
|
+
&& isStrictTodoTimestamp(value.recorded_at) && noteBody(value.body)
|
|
128
|
+
&& (value.supersedes === null || isTodoDigest(value.supersedes))
|
|
129
|
+
&& (value.superseded_by === null || isTodoDigest(value.superseded_by))
|
|
130
|
+
&& ['current', 'superseded'].includes(value.correction_state)
|
|
131
|
+
&& ((value.correction_state === 'current' && value.superseded_by === null)
|
|
132
|
+
|| (value.correction_state === 'superseded' && isTodoDigest(value.superseded_by)));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 個別ToDo詳細とstart結果へ必ず同梱するbounded note contextを検証する。 */
|
|
136
|
+
export function validateTodoNoteContext(value) {
|
|
137
|
+
try {
|
|
138
|
+
if (!exactRecord(value, [
|
|
139
|
+
'schema', 'project_id', 'plan_key', 'task_id', 'notes', 'note_head_digest',
|
|
140
|
+
'overflow_count', 'full_history_command', 'context_digest',
|
|
141
|
+
]) || value.schema !== TODO_NOTE_CONTEXT_SCHEMA
|
|
142
|
+
|| !isTodoIdentifier(value.project_id) || !isTodoIdentifier(value.plan_key)
|
|
143
|
+
|| !isTodoIdentifier(value.task_id) || !Array.isArray(value.notes)
|
|
144
|
+
|| value.notes.length > TODO_LIMITS.tasksPerPlan || !value.notes.every(noteContextEntry)
|
|
145
|
+
|| !(value.note_head_digest === null || isTodoDigest(value.note_head_digest))
|
|
146
|
+
|| !isNonNegativeSafeInteger(value.overflow_count)
|
|
147
|
+
|| value.full_history_command !== `lattice todo note list --plan ${value.plan_key} --task ${value.task_id} --json`
|
|
148
|
+
|| !isTodoDigest(value.context_digest)
|
|
149
|
+
|| value.context_digest !== todoSelfDigest(value, 'context_digest')) return false;
|
|
150
|
+
if ((value.notes.length === 0) !== (value.note_head_digest === null)) return false;
|
|
151
|
+
return value.notes.reduce((bytes, note) => bytes + Buffer.byteLength(note.body, 'utf8'), 0)
|
|
152
|
+
<= TODO_LIMITS.noteContextBytes;
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
88
158
|
const nullableDigest = (value) => value === null || isTodoDigest(value);
|
|
89
159
|
const nullableText = (value) => value === null || (typeof value === 'string' && value.length > 0 && Buffer.byteLength(value) <= 16_384);
|
|
90
160
|
const actor = (value) => exactRecord(value, ['host', 'session', 'agent'])
|
|
@@ -345,6 +415,10 @@ function validPayload(event) {
|
|
|
345
415
|
'reason', 'target_decision_digest', 'override_reason',
|
|
346
416
|
]) && nullableText(payload.reason) && payload.reason !== null
|
|
347
417
|
&& isTodoDigest(payload.target_decision_digest) && nullableText(payload.override_reason);
|
|
418
|
+
// ADR 0148裁定1: 監査なしで閉じたことの理由は必須(payload.reason !== null)。証拠は無い
|
|
419
|
+
// ——監査していないというのが事実であり、evidenceを要求すると「監査した体」を装う経路になる。
|
|
420
|
+
if (event.kind === 'phase_close_unaudited') return exactRecord(payload, ['reason'])
|
|
421
|
+
&& nullableText(payload.reason) && payload.reason !== null;
|
|
348
422
|
return false;
|
|
349
423
|
}
|
|
350
424
|
|
|
@@ -392,7 +466,8 @@ function validPhaseStateMigration(value) {
|
|
|
392
466
|
&& (entry.state_policy === 'reset' ? entry.state === null
|
|
393
467
|
: exactRecord(entry.state, [
|
|
394
468
|
'status', 'review_event_digest', 'decision_event_digest', 'decision_evidence',
|
|
395
|
-
]) && ['locked', 'active', 'gate_ready', 'reviewing', 'accepted', 'rejected']
|
|
469
|
+
]) && ['locked', 'active', 'gate_ready', 'reviewing', 'accepted', 'rejected', 'closed_unaudited']
|
|
470
|
+
.includes(entry.state.status)
|
|
396
471
|
&& nullableDigest(entry.state.review_event_digest)
|
|
397
472
|
&& nullableDigest(entry.state.decision_event_digest)
|
|
398
473
|
&& (entry.state.decision_evidence === null || evidence(entry.state.decision_evidence))))
|
|
@@ -421,7 +496,8 @@ export function validateTodoEvent(value) {
|
|
|
421
496
|
]) && value.kind === 'plan_genesis' && value.task_id === null && value.phase_id === null
|
|
422
497
|
&& isTodoDigest(value.revision_digest) && validStateMigration(value.state_migration)
|
|
423
498
|
&& validPhaseStateMigration(value.phase_state_migration);
|
|
424
|
-
const phaseKind = ['phase_review', 'phase_accept', 'phase_reject', 'phase_reopen']
|
|
499
|
+
const phaseKind = ['phase_review', 'phase_accept', 'phase_reject', 'phase_reopen', 'phase_close_unaudited']
|
|
500
|
+
.includes(value?.kind);
|
|
425
501
|
return (v1 || v2 || v3 || v4) && isTodoIdentifier(value.project_id)
|
|
426
502
|
&& isTodoIdentifier(value.plan_key) && isTodoIdentifier(value.plan_version)
|
|
427
503
|
&& isNonNegativeSafeInteger(value.sequence) && nullableDigest(value.previous_digest)
|
|
@@ -491,7 +567,8 @@ export function validateTodoSnapshot(value) {
|
|
|
491
567
|
&& value.phases.every((entry) => exactRecord(entry, [
|
|
492
568
|
'phase_id', 'status', 'review_event_digest', 'decision_event_digest', 'decision_evidence',
|
|
493
569
|
]) && isTodoIdentifier(entry.phase_id)
|
|
494
|
-
&& ['locked', 'active', 'gate_ready', 'reviewing', 'accepted', 'rejected']
|
|
570
|
+
&& ['locked', 'active', 'gate_ready', 'reviewing', 'accepted', 'rejected', 'closed_unaudited']
|
|
571
|
+
.includes(entry.status)
|
|
495
572
|
&& nullableDigest(entry.review_event_digest) && nullableDigest(entry.decision_event_digest)
|
|
496
573
|
&& (entry.decision_evidence === null || evidence(entry.decision_evidence)))
|
|
497
574
|
&& value.phases.every((entry, index) => index === 0
|
|
@@ -1,4 +1,25 @@
|
|
|
1
1
|
import { DOCUMENT_STATUS, SEVERABILITY_LABEL, escapeHtmlAttribute, escapeHtmlText, foldIndex, planActivity, presentationLookup, refKey, renderPhaseProgress, renderRelationList, renderSeamProposalOverview, renderTaskIndex, statusMarkup, taskReference } from './todo-gantt-html-shared.mjs';
|
|
2
|
+
import { renderTodoMarkdown } from './todo-markdown-renderer.mjs';
|
|
3
|
+
|
|
4
|
+
function renderNoteContext(context) {
|
|
5
|
+
if (context === null) {
|
|
6
|
+
return '<section class="work-log"><h2>作業記録</h2><p class="note-warning">作業記録を読み取れません。概要の読取警告を確認してください。</p></section>';
|
|
7
|
+
}
|
|
8
|
+
if (context.notes.length === 0) {
|
|
9
|
+
return '<section class="work-log"><h2>作業記録</h2><p>記録はありません。</p></section>';
|
|
10
|
+
}
|
|
11
|
+
const entries = context.notes.map((note) => {
|
|
12
|
+
const rendered = renderTodoMarkdown(note.body);
|
|
13
|
+
const filtering = rendered.discarded.length === 0 ? ''
|
|
14
|
+
: '<p class="note-warning">安全上表示できない要素を除外しました。</p>';
|
|
15
|
+
const correction = note.correction_state === 'superseded'
|
|
16
|
+
? `訂正済み(後継 ${note.superseded_by.slice(0, 12)}…)` : '現行';
|
|
17
|
+
return `<article class="work-log-entry"><header><span>${escapeHtmlText(note.recorded_at)}</span><span>${escapeHtmlText(correction)}</span></header><div class="work-log-body">${rendered.html}</div>${filtering}<p class="work-log-origin">来歴: ${escapeHtmlText(note.origin_plan_version)}/${escapeHtmlText(note.origin_task_id)}・記録者 ${escapeHtmlText(`${note.actor.host}/${note.actor.agent}`)}</p></article>`;
|
|
18
|
+
}).join('');
|
|
19
|
+
const overflow = context.overflow_count === 0 ? ''
|
|
20
|
+
: `<p class="note-warning">ほか ${context.overflow_count}件は上限のため省略。全履歴: <code>${escapeHtmlText(context.full_history_command)}</code></p>`;
|
|
21
|
+
return `<section class="work-log"><h2>作業記録</h2>${entries}${overflow}<p class="work-log-head">note head: <code>${escapeHtmlText(context.note_head_digest ?? 'none')}</code></p></section>`;
|
|
22
|
+
}
|
|
2
23
|
|
|
3
24
|
/**
|
|
4
25
|
* 図の外が語るための独立性要約を、plan単位の投影から引ける形へ畳む(ADR 0129 Decision 3)。
|
|
@@ -63,7 +84,9 @@ export function renderIndependenceNote(ref, node, summary) {
|
|
|
63
84
|
return `<p class="readiness-note"><strong>並列可否:</strong> 要直列です。</p><ul class="independence-conflicts">${items}</ul>`;
|
|
64
85
|
}
|
|
65
86
|
|
|
66
|
-
export function renderRightPane(
|
|
87
|
+
export function renderRightPane(
|
|
88
|
+
sections, layout, presentation, readModel, notesEnabled = false, noteWarnings = [],
|
|
89
|
+
) {
|
|
67
90
|
const lookup = presentationLookup(presentation);
|
|
68
91
|
const sectionByKey = new Map(sections.map((section) => [refKey(section.ref), section]));
|
|
69
92
|
const nodeByKey = new Map(layout.nodes.map((node) => [refKey(node.ref), node]));
|
|
@@ -104,7 +127,9 @@ export function renderRightPane(sections, layout, presentation, readModel) {
|
|
|
104
127
|
const dispatchSummary = `${readyHeadline}${independenceNote}`;
|
|
105
128
|
const activeLinks = active.length === 0 ? '<p>作業中の工程はありません。</p>'
|
|
106
129
|
: `<ul class="active-list">${active.map((section) => `<li><button type="button" data-select-node-key="${escapeHtmlAttribute(refKey(section.ref))}">${escapeHtmlText(taskReference(section, lookup))} — ${escapeHtmlText(section.task.title)}</button></li>`).join('')}</ul>`;
|
|
107
|
-
const
|
|
130
|
+
const noteWarningMarkup = noteWarnings.length === 0 ? ''
|
|
131
|
+
: `<section class="gantt-warning"><h2>作業記録の読取警告</h2><ul>${noteWarnings.map((warning) => `<li><code>${escapeHtmlText(warning.plan_key)}</code>: <strong>${escapeHtmlText(warning.code)}</strong> — ${escapeHtmlText(warning.message)}</li>`).join('')}</ul></section>`;
|
|
132
|
+
const overview = `<section class="right-overview" data-right-panel="overview"><h1>工程を選択してください</h1><p>左の依存工程図から工程を選ぶと、題名・状態・前提・後続を表示します。</p><div class="status-summary"><span>☐ 未着手 ${counts.pending}</span><span>▶ 作業中 ${counts['in-progress']}</span><span>✅ 完了 ${counts.done}</span><span>⛔ ブロック中 ${counts.blocked}</span></div>${noteWarningMarkup}${dispatchSummary}${renderSeamProposalOverview(layout)}${renderPhaseProgress(readModel)}<h2>作業中</h2>${activeLinks}</section>`;
|
|
108
133
|
const details = sections.map((section) => {
|
|
109
134
|
const key = refKey(section.ref);
|
|
110
135
|
const node = nodeByKey.get(key);
|
|
@@ -126,7 +151,8 @@ export function renderRightPane(sections, layout, presentation, readModel) {
|
|
|
126
151
|
// Say it plainly when the reader will not find this ToDo on the diagram.
|
|
127
152
|
const foldedNote = !folds.has(key) ? ''
|
|
128
153
|
: '<p class="fold-note">完走済みのため図には描いていません。図に出すには <code>lattice todo gantt --scope all</code> を実行してください。</p>';
|
|
129
|
-
|
|
154
|
+
const workLog = notesEnabled ? renderNoteContext(section.noteContext) : '';
|
|
155
|
+
return `<article class="task-detail" data-detail-key="${escapeHtmlAttribute(key)}" hidden><header><span class="detail-status status-${escapeHtmlAttribute(section.state.status)}">${escapeHtmlText(status.mark)} ${escapeHtmlText(status.label)}</span><span class="detail-reference">${escapeHtmlText(taskReference(section, lookup))}</span></header><h1>${escapeHtmlText(section.task.title)}</h1><p class="detail-category"><strong>カテゴリ:</strong> ${escapeHtmlText(category)}</p>${categoryDescription}<p><strong>正規ID:</strong> <code>${escapeHtmlText(`${section.ref.plan_key}/${section.task.task_id}`)}</code></p>${blockedReason}${readiness}${independenceNote}${foldedNote}<section><h2>前提工程</h2>${renderRelationList(incoming.get(key), sectionByKey, lookup, '登録済みの前提工程はありません。', folds)}</section><section><h2>後続工程</h2>${renderRelationList(outgoing.get(key), sectionByKey, lookup, '登録済みの後続工程はありません。', folds)}</section>${workLog}<p class="anchor-status">${escapeHtmlText(anchorText)}</p><details class="task-diagnostics"><summary>開発者向け診断</summary><dl><dt>canonical ref</dt><dd><code>${escapeHtmlText(`${section.ref.project_id}/${section.ref.plan_key}/${section.task.task_id}`)}</code></dd><dt>anchor</dt><dd>${escapeHtmlText(section.anchorOutcome.anchored ? 'verified' : section.anchorOutcome.reason)}</dd></dl></details></article>`;
|
|
130
156
|
}).join('');
|
|
131
157
|
const taskIndex = renderTaskIndex(sections, lookup, folds, planActivity(readModel));
|
|
132
158
|
return `<div class="right-toolbar"><button type="button" data-show-overview>概要</button><button type="button" data-show-selected hidden>選択工程へ戻る</button><button type="button" data-show-task-index>全工程一覧</button></div><div class="right-content">${overview}<div data-right-panel="details" hidden>${details}</div><section class="task-index" data-right-panel="task-index" hidden><h1>全工程</h1><p>Latticeに登録された全工程を現在の状態とともに表示しています。planは動いているものを最終活動の新しい順で上に、完走したものを古い順で下にまとめ、plan内は登録順です。</p>${taskIndex}</section></div>`;
|
|
@@ -56,6 +56,12 @@ body{display:grid;grid-template-rows:minmax(0,1fr);height:100vh;margin:0;backgro
|
|
|
56
56
|
.active-list,.relation-list{margin:0;padding:0;list-style:none}.active-list li+li,.relation-list li+li{margin-top:8px}
|
|
57
57
|
.active-list button{width:100%}.anchor-status,.readiness-note,.category-description,.relation-empty{color:var(--text-secondary)}
|
|
58
58
|
.task-detail>header{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:8px}.detail-status,.detail-reference{font-size:12px;font-weight:600}.detail-reference{color:var(--text-secondary)}
|
|
59
|
+
.work-log{margin-top:24px;padding-top:1px;border-top:1px solid var(--border)}
|
|
60
|
+
.work-log-entry{margin-top:10px;padding:12px;border:1px solid var(--border);border-left:4px solid var(--accent);background:var(--surface-2)}
|
|
61
|
+
.work-log-entry>header{display:flex;flex-wrap:wrap;justify-content:space-between;gap:6px 12px;color:var(--text-secondary);font-size:12px;font-weight:600}
|
|
62
|
+
.work-log-body{overflow-wrap:anywhere}.work-log-body pre{max-width:100%;overflow:auto;padding:8px;background:var(--surface-1)}.work-log-body code{overflow-wrap:anywhere}
|
|
63
|
+
.work-log-origin,.work-log-head,.note-warning{color:var(--text-secondary);font-size:12px;overflow-wrap:anywhere}.note-warning{color:var(--critical)}
|
|
64
|
+
.gantt-warning{margin:16px 0;padding:12px;border:1px solid var(--critical);background:var(--surface-2)}.gantt-warning h2{margin:0 0 8px}.gantt-warning ul{margin:0;padding-left:20px}
|
|
59
65
|
.relation-list button{display:flex;width:100%;flex-direction:column}.relation-list button span{font-weight:400}.relation-kind{display:block;margin-top:4px;color:var(--text-secondary);font-size:12px}
|
|
60
66
|
.task-diagnostics{margin:16px 0;color:var(--text-secondary);font-size:12px}.task-diagnostics summary{cursor:pointer;color:var(--text-primary);font-weight:600}.task-diagnostics dl{display:grid;grid-template-columns:auto 1fr;gap:4px 12px}.task-diagnostics dd{margin:0;overflow-wrap:anywhere}
|
|
61
67
|
.task-index>h1{margin:0 0 8px;font-size:19px;font-weight:650;line-height:1.45}.task-index>p{margin:0 0 24px;color:var(--text-secondary)}
|
package/src/todo-gantt-html.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { renderDiagramLegend, renderRightPane } from './todo-gantt-html-independ
|
|
|
6
6
|
import { escapeHtmlAttribute, escapeHtmlText, refKey } from './todo-gantt-html-shared.mjs';
|
|
7
7
|
import { CSS } from './todo-gantt-html-style.mjs';
|
|
8
8
|
|
|
9
|
-
export const TODO_GANTT_RENDERER_VERSION = 'lattice.todo_gantt_renderer.
|
|
9
|
+
export const TODO_GANTT_RENDERER_VERSION = 'lattice.todo_gantt_renderer.v18';
|
|
10
10
|
export const TODO_GANTT_PROSE_MAX_BYTES = 8 * 1024 * 1024;
|
|
11
11
|
export const TODO_GANTT_HTML_MAX_BYTES = 24 * 1024 * 1024;
|
|
12
12
|
|
|
@@ -40,9 +40,12 @@ function digest(value) {
|
|
|
40
40
|
* still read, because anchor verification needs them, and their size is still
|
|
41
41
|
* bounded — but they are not rendered into the page.
|
|
42
42
|
*/
|
|
43
|
-
function normalizeSections(readModel, narratives, anchorOutcomes) {
|
|
43
|
+
function normalizeSections(readModel, narratives, anchorOutcomes, noteContexts) {
|
|
44
44
|
const supplied = new Map(narratives.map((entry) => [refKey(entry.ref), entry]));
|
|
45
45
|
const outcomes = new Map(anchorOutcomes.map((entry) => [refKey(entry.ref), entry]));
|
|
46
|
+
const notes = noteContexts === null ? null : new Map(noteContexts.map((entry) => [
|
|
47
|
+
refKey({ project_id: entry.project_id, plan_key: entry.plan_key, task_id: entry.task_id }), entry,
|
|
48
|
+
]));
|
|
46
49
|
const result = [];
|
|
47
50
|
const counted = new Set();
|
|
48
51
|
let proseBytes = 0;
|
|
@@ -69,7 +72,20 @@ function normalizeSections(readModel, narratives, anchorOutcomes) {
|
|
|
69
72
|
ref, narrative_ref: narrativeRef, anchored: false, reason: 'anchor_missing',
|
|
70
73
|
origin_line: task.narrative_anchor?.origin_line ?? null,
|
|
71
74
|
};
|
|
72
|
-
|
|
75
|
+
const noteContext = notes?.get(refKey(ref)) ?? null;
|
|
76
|
+
if (noteContext !== null) {
|
|
77
|
+
proseBytes += noteContext.notes.reduce((bytes, note) => (
|
|
78
|
+
bytes + Buffer.byteLength(note.body, 'utf8')
|
|
79
|
+
), 0);
|
|
80
|
+
if (proseBytes > TODO_GANTT_PROSE_MAX_BYTES) {
|
|
81
|
+
throw new TodoGanttRenderError('TODO_SCALE_EXCEEDED', 'todo gantt embedded prose limit exceeded', {
|
|
82
|
+
prose_bytes: proseBytes, prose_limit: TODO_GANTT_PROSE_MAX_BYTES,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
result.push({
|
|
87
|
+
ref, task, state: states.get(task.task_id), narrativeRef, anchorOutcome, noteContext,
|
|
88
|
+
});
|
|
73
89
|
}
|
|
74
90
|
}
|
|
75
91
|
return { sections: result, proseBytes };
|
|
@@ -132,19 +148,23 @@ const CONTROLLER = `
|
|
|
132
148
|
|
|
133
149
|
export function renderTodoGanttHtml({
|
|
134
150
|
readModel, layout, narratives = [], anchorOutcomes = [], presentation = null, metadata = {},
|
|
135
|
-
expandedLayout = null,
|
|
151
|
+
expandedLayout = null, noteContexts = null, noteWarnings = [],
|
|
136
152
|
}) {
|
|
137
153
|
if (readModel?.schema !== 'lattice.todo_store_read.v1' || !Array.isArray(readModel.members)) {
|
|
138
154
|
throw new TypeError('readModel must be lattice.todo_store_read.v1');
|
|
139
155
|
}
|
|
140
156
|
if (!Array.isArray(narratives)) throw new TypeError('narratives must be an array');
|
|
141
157
|
if (!Array.isArray(anchorOutcomes)) throw new TypeError('anchorOutcomes must be an array');
|
|
158
|
+
if (!(noteContexts === null || Array.isArray(noteContexts))) {
|
|
159
|
+
throw new TypeError('noteContexts must be null or an array');
|
|
160
|
+
}
|
|
161
|
+
if (!Array.isArray(noteWarnings)) throw new TypeError('noteWarnings must be an array');
|
|
142
162
|
if (presentation !== null
|
|
143
163
|
&& (presentation?.schema !== 'lattice.todo_gantt_presentation_model.v1'
|
|
144
164
|
|| presentation.project_id !== readModel.project_id)) {
|
|
145
165
|
throw new TypeError('presentation must be lattice.todo_gantt_presentation_model.v1');
|
|
146
166
|
}
|
|
147
|
-
const normalized = normalizeSections(readModel, narratives, anchorOutcomes);
|
|
167
|
+
const normalized = normalizeSections(readModel, narratives, anchorOutcomes, noteContexts);
|
|
148
168
|
const displayName = projectDisplayName(readModel, metadata);
|
|
149
169
|
const svg = renderTodoGanttSvg(layout, { presentation });
|
|
150
170
|
// The expanded diagram travels with the page so the badge can bring the
|
|
@@ -153,7 +173,9 @@ export function renderTodoGanttHtml({
|
|
|
153
173
|
const diagrams = expandedSvg === ''
|
|
154
174
|
? `<div data-diagram="live">${svg}</div>`
|
|
155
175
|
: `<div data-diagram="live">${svg}</div><div data-diagram="expanded" hidden>${expandedSvg}</div>`;
|
|
156
|
-
const rightPane = renderRightPane(
|
|
176
|
+
const rightPane = renderRightPane(
|
|
177
|
+
normalized.sections, layout, presentation, readModel, noteContexts !== null, noteWarnings,
|
|
178
|
+
);
|
|
157
179
|
const staticData = serializeJsonForScript({
|
|
158
180
|
renderer_version: TODO_GANTT_RENDERER_VERSION,
|
|
159
181
|
metadata,
|