sandtable 0.3.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.
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Conventions Viewer — renders abbreviations and terms in the footer bar.
3
+ * v2: Checks `configured` flag; shows placeholder until user manually configures.
4
+ */
5
+ function renderConventions(conventions) {
6
+ var container = document.getElementById('conventions-bar');
7
+ if (!container) return;
8
+
9
+ // v2: check configured flag — show placeholder if not configured
10
+ if (conventions.configured === false ||
11
+ (!('configured' in conventions) && (!conventions.abbreviations || conventions.abbreviations.length === 0) && (!conventions.terms || conventions.terms.length === 0))) {
12
+ container.innerHTML = '<span class="conventions-placeholder">' +
13
+ '待配置 — ' +
14
+ '<span title="编辑 data/conventions.json,手动添加 abbreviations 和 terms 条目,然后将 configured 设为 true">如何配置?</span>' +
15
+ '</span>';
16
+ return;
17
+ }
18
+
19
+ // v1 compat or configured:true — render normally
20
+ var abbrItems = (conventions.abbreviations || []).map(function(a) {
21
+ return { label: a.short, tooltip: a.full, kind: 'abbr' };
22
+ });
23
+ var termItems = (conventions.terms || []).map(function(t) {
24
+ return { label: t.term, tooltip: t.def, kind: 'term' };
25
+ });
26
+ var items = abbrItems.concat(termItems).slice(0, 8);
27
+
28
+ if (items.length === 0) {
29
+ container.innerHTML = '<span style="color:#999;">暂无约定数据</span>';
30
+ return;
31
+ }
32
+
33
+ container.innerHTML = items.map(function(item) {
34
+ var cls = item.kind === 'term' ? ' convention-tag term-tag' : 'convention-tag';
35
+ return '<span class="' + cls + '" title="' + escapeHtml(item.tooltip) + '">' + escapeHtml(item.label) + '</span>';
36
+ }).join(' · ');
37
+ }
@@ -0,0 +1,147 @@
1
+ // ---- Shared Utilities ----
2
+ function escapeHtml(str) {
3
+ if (!str) return '';
4
+ const div = document.createElement('div');
5
+ div.textContent = String(str);
6
+ return div.innerHTML;
7
+ }
8
+
9
+ function statusIcon(status) {
10
+ const icons = {
11
+ completed: '\u2705', in_progress: '\u23F3',
12
+ pending: '\u2B1C', blocked: '\uD83D\uDEAB',
13
+ cancelled: '\u274C',
14
+ };
15
+ return icons[status] || '\u2B1C';
16
+ }
17
+
18
+ // ---- v2 Data Loading ----
19
+ async function loadAllData() {
20
+ const paths = {
21
+ timeline: '../data/timeline.json',
22
+ roadmap: '../data/roadmap.json',
23
+ journalIndex: '../data/journal-index.json',
24
+ conventions: '../data/conventions.json',
25
+ brief: '../data/brief.json',
26
+ };
27
+
28
+ const results = {};
29
+ for (const [key, url] of Object.entries(paths)) {
30
+ try {
31
+ const resp = await fetch(url);
32
+ if (!resp.ok) throw new Error('HTTP ' + resp.status);
33
+ results[key] = await resp.json();
34
+ } catch (err) {
35
+ console.warn('Failed to load ' + url + ':', err.message);
36
+ results[key] = getDefault(key);
37
+ }
38
+ }
39
+
40
+ // v2 compat: if timeline.json missing, merge v1 data
41
+ if (!results.timeline || !results.timeline.elements) {
42
+ results.timeline = mergeV1Data(results.roadmap, results.journalIndex);
43
+ }
44
+
45
+ return results;
46
+ }
47
+
48
+ function mergeV1Data(roadmap, journalIndex) {
49
+ const elements = [];
50
+
51
+ for (const phase of (roadmap.phases || [])) {
52
+ const phaseEl = {
53
+ id: phase.id || 'phase-default',
54
+ kind: 'primary', elementType: 'phase',
55
+ name: phase.name,
56
+ status: phase.status || 'pending',
57
+ timeGroup: phase.status === 'completed' ? 'past' : 'current',
58
+ timeLabel: '', date: null,
59
+ source: { file: '', title: '' },
60
+ summary: '', tags: [], related: [],
61
+ children: [], order: phase.order || 0,
62
+ };
63
+
64
+ for (const ms of (phase.milestones || [])) {
65
+ const msEl = {
66
+ id: ms.id, kind: 'primary', elementType: 'milestone',
67
+ name: ms.name,
68
+ status: ms.status || 'pending',
69
+ timeGroup: ms.status === 'completed' ? 'past' : 'current',
70
+ timeLabel: '', date: null,
71
+ source: { file: ms.owner || '', title: '' },
72
+ summary: '', tags: [], related: [],
73
+ children: [],
74
+ };
75
+
76
+ for (const st of (ms.subtasks || [])) {
77
+ msEl.children.push({
78
+ id: st.id, kind: 'primary', elementType: 'subtask',
79
+ name: st.name,
80
+ status: st.status || 'pending',
81
+ timeGroup: st.status === 'completed' ? 'past' : 'current',
82
+ timeLabel: '', date: null,
83
+ source: { file: '', title: '' },
84
+ summary: '', tags: [], related: [],
85
+ children: [],
86
+ });
87
+ }
88
+ phaseEl.children.push(msEl);
89
+ }
90
+ elements.push(phaseEl);
91
+ }
92
+
93
+ for (const evt of (journalIndex.events || [])) {
94
+ elements.push({
95
+ id: 'journal-' + elements.length,
96
+ kind: 'secondary', elementType: evt.type || 'journal',
97
+ name: evt.title,
98
+ status: evt.status || 'unknown',
99
+ timeGroup: 'past',
100
+ timeLabel: evt.date || '', date: evt.date || null,
101
+ source: { file: evt.source || '', title: evt.title },
102
+ summary: evt.summary || '',
103
+ tags: evt.tags || [], related: evt.related || [],
104
+ children: [], order: 0,
105
+ });
106
+ }
107
+
108
+ return {
109
+ project: roadmap.project || 'Unknown',
110
+ updated: roadmap.updated || new Date().toISOString(),
111
+ brief: roadmap.brief || '',
112
+ elements,
113
+ filterTypes: [
114
+ { type: 'journal', label: '日志记录', count: (journalIndex.events || []).length, defaultEnabled: false },
115
+ ],
116
+ display: { secondaryTypesDefaultOff: true, conventionsManualOnly: true, maxBriefLength: 200 },
117
+ };
118
+ }
119
+
120
+ function getDefault(key) {
121
+ const defaults = {
122
+ timeline: {
123
+ project: 'Unknown', updated: new Date().toISOString(),
124
+ brief: '未找到 timeline.json。运行 `node src/cli/sandtable.js build` 生成。',
125
+ elements: [], filterTypes: [],
126
+ },
127
+ roadmap: { project: 'Unknown', updated: '', brief: '', phases: [] },
128
+ journalIndex: { events: [] },
129
+ conventions: {
130
+ configured: false,
131
+ message: '待配置',
132
+ abbreviations: [], terms: [],
133
+ },
134
+ brief: { updated: '', text: '' },
135
+ };
136
+ return defaults[key] || {};
137
+ }
138
+
139
+ // ---- Header Update ----
140
+ function updateHeader(timeline, brief) {
141
+ var el = document.getElementById('update-time');
142
+ if (el) el.textContent = '更新:' + (timeline.updated || '').substring(0, 16).replace('T', ' ');
143
+ el = document.getElementById('brief-text');
144
+ if (el) el.textContent = brief.text || timeline.brief || '暂无简报';
145
+ }
146
+
147
+ // ---- Category Tab Management ----
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Event Stream Renderer — §11 7 大事件类 + 8 字段 schema + 3 级优先级
3
+ */
4
+
5
+ var EVENT_TYPE_CLASS = {
6
+ '对齐与拍板': 'badge-decision',
7
+ '规格演进': 'badge-spec',
8
+ '代码变更': 'badge-code',
9
+ '测试与质量': 'badge-test',
10
+ '审批与交接': 'badge-approval',
11
+ '运维与基建': 'badge-ops',
12
+ '教训沉淀': 'badge-lesson',
13
+ };
14
+
15
+ var PRIORITY_CLASS = {
16
+ '必记': 'priority-must',
17
+ '应记': 'priority-should',
18
+ '可记': 'priority-can',
19
+ };
20
+
21
+ function renderEventStream(events) {
22
+ var container = document.getElementById('event-stream-container');
23
+ if (!container) return;
24
+
25
+ var now = new Date().toISOString();
26
+
27
+ // Show all events, no time filtering
28
+ var filtered = events || [];
29
+
30
+ if (filtered.length === 0) {
31
+ container.innerHTML = '<div class="empty-state"><h3>暂无事件</h3><p>事件流将在这里显示。运行 build 刷新数据。</p></div>';
32
+ return;
33
+ }
34
+
35
+ // Group by date
36
+ var dateGroups = {};
37
+ var dateOrder = [];
38
+ for (var i = 0; i < filtered.length; i++) {
39
+ var ev = filtered[i];
40
+ var dateKey = (ev.timestamp || '').substring(0, 10);
41
+ if (!dateGroups[dateKey]) { dateGroups[dateKey] = []; dateOrder.push(dateKey); }
42
+ dateGroups[dateKey].push(ev);
43
+ }
44
+
45
+ var html = '<div class="event-stream">';
46
+ for (var di = 0; di < dateOrder.length; di++) {
47
+ var dk = dateOrder[di];
48
+ html += '<div class="event-date-group">' + escapeHtml(dk) + '</div>';
49
+
50
+ var dayEvents = dateGroups[dk];
51
+ for (var ei = 0; ei < dayEvents.length; ei++) {
52
+ var ev = dayEvents[ei];
53
+ var typeClass = EVENT_TYPE_CLASS[ev.type] || 'badge-code';
54
+ var priorityClass = PRIORITY_CLASS[ev.priority] || 'priority-can';
55
+ var mustRecordClass = ev.priority === '必记' ? ' must-record' : '';
56
+
57
+ // Determine time phase for coloring
58
+ var timeClass = 'time-past';
59
+ var ts = ev.timestamp || '';
60
+ if (ts > now) { timeClass = 'time-future'; }
61
+ else if (ts > new Date(Date.now() - 86400000).toISOString()) { timeClass = 'time-current'; }
62
+
63
+ // Build ref links
64
+ var refsHtml = '';
65
+ if (ev.ref) {
66
+ if (ev.ref.commit) {
67
+ refsHtml += '<span class="event-ref-link commit-ref">' + escapeHtml(ev.ref.commit) + '</span>';
68
+ }
69
+ if (ev.ref.doc) {
70
+ refsHtml += '<span class="event-ref-link doc-ref" data-doc="' + escapeHtml(ev.ref.doc) + '" onclick="openFloatingPreview(\'' + escapeHtml(ev.ref.doc) + '\')">' + escapeHtml(ev.ref.doc.substring(0, 40)) + '</span>';
71
+ }
72
+ }
73
+
74
+ // Tags
75
+ var tagsHtml = '';
76
+ if (ev.tags && ev.tags.length > 0) {
77
+ for (var ti = 0; ti < ev.tags.length; ti++) {
78
+ tagsHtml += '<span class="event-tag">' + escapeHtml(ev.tags[ti]) + '</span>';
79
+ }
80
+ }
81
+
82
+ var timeDisplay = (ev.timestamp || '').substring(11, 16) || '';
83
+
84
+ html += '<div class="event-item' + mustRecordClass + ' ' + timeClass + ' type-' + (ev.type || '') + '">' +
85
+ '<div class="event-item-header">' +
86
+ '<span class="event-item-title">' +
87
+ '<span class="event-priority-dot ' + priorityClass + '" title="' + escapeHtml(ev.priority || '可记') + '"></span> ' +
88
+ escapeHtml(ev.title || '') +
89
+ '</span>' +
90
+ '<span class="event-item-meta">' +
91
+ '<span class="event-type-badge ' + typeClass + '">' + escapeHtml(ev.type || '') + '</span>' +
92
+ '</span>' +
93
+ '</div>' +
94
+ '<div class="event-item-body">' +
95
+ (timeDisplay ? '<span class="event-time-text">' + escapeHtml(timeDisplay) + '</span>' : '') +
96
+ refsHtml +
97
+ (ev.actor ? '<span class="event-actor">' + escapeHtml(ev.actor) + '</span>' : '') +
98
+ (ev.impact ? '<span class="event-impact-' + (ev.impact || 'low') + '">' + escapeHtml(ev.impact) + '</span>' : '') +
99
+ tagsHtml +
100
+ '</div>' +
101
+ '</div>';
102
+ }
103
+ }
104
+ html += '</div>';
105
+ container.innerHTML = html;
106
+ }
107
+
108
+ // ---- Floating Panel ----
109
+ function openFloatingPreview(docPath) {
110
+ var panel = document.getElementById('floating-panel');
111
+ var title = document.getElementById('floating-title');
112
+ var content = document.getElementById('floating-content');
113
+ var overlay = document.getElementById('floating-overlay');
114
+
115
+ if (!overlay) {
116
+ overlay = document.createElement('div');
117
+ overlay.id = 'floating-overlay';
118
+ overlay.className = 'hidden';
119
+ document.body.appendChild(overlay);
120
+ overlay.addEventListener('click', closeFloatingPreview);
121
+ }
122
+
123
+ title.textContent = docPath;
124
+ content.textContent = '加载中...';
125
+
126
+ // Fetch .md content from server
127
+ fetch('/' + encodeURI(docPath))
128
+ .then(function(resp) {
129
+ if (!resp.ok) throw new Error('HTTP ' + resp.status);
130
+ return resp.text();
131
+ })
132
+ .then(function(text) {
133
+ // Strip SUMMARY block for cleaner display
134
+ var clean = text.replace(/<!--\s*SUMMARY[\s\S]*?-->/g, '').trim();
135
+ content.textContent = clean;
136
+ })
137
+ .catch(function(err) {
138
+ content.textContent = '加载失败:' + err.message;
139
+ });
140
+
141
+ panel.classList.remove('hidden');
142
+ overlay.classList.remove('hidden');
143
+ }
144
+
145
+ function closeFloatingPreview() {
146
+ var panel = document.getElementById('floating-panel');
147
+ var overlay = document.getElementById('floating-overlay');
148
+ if (panel) panel.classList.add('hidden');
149
+ if (overlay) overlay.classList.add('hidden');
150
+ }
151
+
152
+ // Close button
153
+ document.addEventListener('DOMContentLoaded', function() {
154
+ var closeBtn = document.getElementById('floating-close');
155
+ if (closeBtn) {
156
+ closeBtn.addEventListener('click', closeFloatingPreview);
157
+ }
158
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * FilterController — manages secondary element checkbox toggles with "全选" support
3
+ */
4
+ class FilterController {
5
+ constructor(filterTypes) {
6
+ this.filterTypes = filterTypes || [];
7
+ this.enabled = new Set();
8
+ this.listeners = [];
9
+
10
+ for (var i = 0; i < this.filterTypes.length; i++) {
11
+ if (this.filterTypes[i].defaultEnabled) {
12
+ this.enabled.add(this.filterTypes[i].type);
13
+ }
14
+ }
15
+
16
+ this.render();
17
+ }
18
+
19
+ render() {
20
+ var container = document.getElementById('filter-checkboxes');
21
+ if (!container) return;
22
+
23
+ if (this.filterTypes.length === 0) {
24
+ container.innerHTML = '<span style="font-size:11px;color:var(--text-muted)">无次要元素</span>';
25
+ return;
26
+ }
27
+
28
+ var allChecked = this.enabled.size === this.filterTypes.length;
29
+ var html = '<label class="filter-checkbox" style="font-weight:600">' +
30
+ '<input type="checkbox" id="filter-select-all" value="_all_"' + (allChecked ? ' checked' : '') + '>' +
31
+ '全选' +
32
+ '</label>';
33
+
34
+ for (var i = 0; i < this.filterTypes.length; i++) {
35
+ var ft = this.filterTypes[i];
36
+ var checked = this.enabled.has(ft.type) ? ' checked' : '';
37
+ html += '<label class="filter-checkbox">' +
38
+ '<input type="checkbox" class="filter-item" value="' + escapeHtml(ft.type) + '"' + checked + '>' +
39
+ escapeHtml(ft.label) + ' (' + ft.count + ')' +
40
+ '</label>';
41
+ }
42
+ container.innerHTML = html;
43
+
44
+ var self = this;
45
+
46
+ // "全选" handler
47
+ var selectAll = container.querySelector('#filter-select-all');
48
+ if (selectAll) {
49
+ selectAll.addEventListener('change', function() {
50
+ var items = container.querySelectorAll('.filter-item');
51
+ if (this.checked) {
52
+ for (var i = 0; i < items.length; i++) {
53
+ items[i].checked = true;
54
+ self.enabled.add(items[i].value);
55
+ }
56
+ } else {
57
+ for (var i = 0; i < items.length; i++) {
58
+ items[i].checked = false;
59
+ self.enabled.delete(items[i].value);
60
+ }
61
+ }
62
+ self.notify();
63
+ self._syncSelectAll();
64
+ });
65
+ }
66
+
67
+ // Individual checkbox handlers
68
+ var items = container.querySelectorAll('.filter-item');
69
+ for (var i = 0; i < items.length; i++) {
70
+ items[i].addEventListener('change', function() {
71
+ if (this.checked) {
72
+ self.enabled.add(this.value);
73
+ } else {
74
+ self.enabled.delete(this.value);
75
+ }
76
+ self.notify();
77
+ self._syncSelectAll();
78
+ });
79
+ }
80
+ }
81
+
82
+ _syncSelectAll() {
83
+ var selectAll = document.getElementById('filter-select-all');
84
+ if (selectAll) {
85
+ selectAll.checked = this.enabled.size === this.filterTypes.length;
86
+ }
87
+ }
88
+
89
+ getEnabled() {
90
+ return new Set(this.enabled);
91
+ }
92
+
93
+ onChange(fn) {
94
+ this.listeners.push(fn);
95
+ }
96
+
97
+ notify() {
98
+ for (var i = 0; i < this.listeners.length; i++) {
99
+ this.listeners[i](new Set(this.enabled));
100
+ }
101
+ }
102
+ }
@@ -0,0 +1,29 @@
1
+ function renderTimeline(journalIndex) {
2
+ const container = document.getElementById('timeline-container');
3
+
4
+ if (!journalIndex.events || journalIndex.events.length === 0) {
5
+ container.innerHTML = `
6
+ <div class="empty-state">
7
+ <h3>暂无历史记录</h3>
8
+ <p>当 docs/ 下的 .md 文件包含 SUMMARY 摘要块,且运行 build 后,
9
+ 这里会显示按时间排序的项目事件瀑布流。</p>
10
+ </div>`;
11
+ return;
12
+ }
13
+
14
+ let html = '<div class="timeline">';
15
+ for (const event of journalIndex.events) {
16
+ html += `
17
+ <div class="timeline-event">
18
+ <div class="timeline-date">${event.date}</div>
19
+ <div class="timeline-title">
20
+ ${event.title}
21
+ <span class="timeline-status ${event.status}">${event.status}</span>
22
+ </div>
23
+ <div class="timeline-summary">${event.summary || ''}</div>
24
+ ${event.tags.length > 0 ? `<div class="timeline-tags">tags: ${event.tags.join(' ')}</div>` : ''}
25
+ </div>`;
26
+ }
27
+ html += '</div>';
28
+ container.innerHTML = html;
29
+ }
@@ -0,0 +1,72 @@
1
+ function renderRoadmap(roadmap) {
2
+ const container = document.getElementById('roadmap-container');
3
+
4
+ if (!roadmap.phases || roadmap.phases.length === 0) {
5
+ container.innerHTML = `
6
+ <div class="empty-state">
7
+ <h3>暂无 Roadmap 数据</h3>
8
+ <p>请在 docs/plan/ 下创建 roadmap.md、milestones.md、task-board.md,
9
+ 然后运行 <code>npx sandtable build</code> 生成数据。</p>
10
+ </div>`;
11
+ updateProgressBadge(roadmap);
12
+ return;
13
+ }
14
+
15
+ let html = '';
16
+ for (const phase of roadmap.phases) {
17
+ html += `
18
+ <div class="phase-card">
19
+ <div class="phase-header ${phase.status}">
20
+ <span>${statusIcon(phase.status)} ${phase.name}</span>
21
+ <span>${phase.status}</span>
22
+ </div>
23
+ <div class="phase-body">`;
24
+
25
+ for (const ms of (phase.milestones || [])) {
26
+ html += `
27
+ <div style="padding:4px 0; font-weight:600; font-size:13px;">
28
+ ${statusIcon(ms.status)} ${ms.id} · ${ms.name}
29
+ <span class="milestone-owner">${ms.owner || ''}</span>
30
+ </div>`;
31
+
32
+ for (const st of (ms.subtasks || [])) {
33
+ html += `
34
+ <div class="milestone-item">
35
+ <span class="milestone-status">${statusIcon(st.status)}</span>
36
+ <span>${st.name}</span>
37
+ <span class="milestone-owner">${st.type || ''}</span>
38
+ </div>`;
39
+ }
40
+ }
41
+
42
+ html += `</div></div>`;
43
+ }
44
+
45
+ container.innerHTML = html;
46
+ updateProgressBadge(roadmap);
47
+ }
48
+
49
+ function updateProgressBadge(roadmap) {
50
+ const badge = document.getElementById('progress-badge');
51
+ let completed = 0;
52
+ let total = 0;
53
+ for (const phase of roadmap.phases) {
54
+ for (const ms of (phase.milestones || [])) {
55
+ total++;
56
+ if (ms.status === 'completed') completed++;
57
+ for (const st of (ms.subtasks || [])) {
58
+ total++;
59
+ if (st.status === 'completed') completed++;
60
+ }
61
+ }
62
+ }
63
+ if (total > 0) {
64
+ badge.textContent = `${completed}/${total} 完成`;
65
+ badge.style.background = completed === total ? '#e8f5e9' : '#fff3e0';
66
+ badge.style.color = completed === total ? '#2e7d32' : '#e65100';
67
+ } else {
68
+ badge.textContent = '无任务';
69
+ badge.style.background = '#f5f5f5';
70
+ badge.style.color = '#999';
71
+ }
72
+ }