tenbo-dashboard 0.4.1 → 0.5.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/bin/tenbo-dashboard.mjs +3 -0
- package/package.json +1 -1
- package/scripts/archive.ts +88 -0
- package/src/api/lib/health/agingSuperseded.ts +76 -0
- package/src/api/lib/health/collectAll.ts +17 -4
- package/src/api/lib/health/docDrift.test.ts +18 -0
- package/src/api/lib/health/docDrift.ts +4 -1
- package/src/api/lib/health/layerFiles.test.ts +5 -5
- package/src/api/lib/health/types.ts +5 -1
- package/src/api/lib/tenboFs.ts +45 -1
- package/src/api/lib/validator.ts +30 -0
- package/src/api/plugin.ts +2 -0
- package/src/api/routes/archive.ts +60 -0
- package/src/router/useHashRoute.test.ts +5 -3
- package/src/types.ts +7 -0
- package/src/ui/ArchivedContext.ts +12 -0
- package/src/ui/ItemCard.module.css +18 -0
- package/src/ui/ItemCard.tsx +13 -7
- package/src/ui/LayerDetailPage/LayerDetailPage.tsx +1 -0
- package/src/ui/RoadmapPage/RoadmapPage.module.css +22 -0
- package/src/ui/RoadmapPage/RoadmapPage.tsx +47 -9
- package/src/ui/SummaryStrip.tsx +1 -1
package/bin/tenbo-dashboard.mjs
CHANGED
|
@@ -39,6 +39,7 @@ const commands = {
|
|
|
39
39
|
metrics: 'scripts/compute-metrics.ts',
|
|
40
40
|
'init-check': 'scripts/init-check.ts',
|
|
41
41
|
sync: 'scripts/sync.ts',
|
|
42
|
+
archive: 'scripts/archive.ts',
|
|
42
43
|
};
|
|
43
44
|
|
|
44
45
|
// Documented aliases for the bare-launch behavior. Users (and agents) reading
|
|
@@ -79,6 +80,8 @@ Usage:
|
|
|
79
80
|
tenbo-dashboard init-check Strict completeness check for fresh init (errors on missing skeletons, file_count:0, etc)
|
|
80
81
|
tenbo-dashboard next-id <prefix> Allocate next roadmap item ID
|
|
81
82
|
tenbo-dashboard metrics --all Compute scope metrics
|
|
83
|
+
tenbo-dashboard archive Move old done/dropped items to roadmap-archive.yaml
|
|
84
|
+
[--scope <id>] [--days 30] [--max 20]
|
|
82
85
|
tenbo-dashboard --version Print package version
|
|
83
86
|
tenbo-dashboard help Show this help
|
|
84
87
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* archive.ts — CLI to move done/dropped roadmap items to roadmap-archive.yaml.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* npx tenbo-dashboard archive [--scope <id>] [--days 30] [--max 20]
|
|
6
|
+
*
|
|
7
|
+
* --days (default 30): only archive done/dropped items whose doc_update is older than N days.
|
|
8
|
+
* --max (default 20): only trigger if done+dropped count exceeds this threshold per scope.
|
|
9
|
+
* --scope: limit to one scope (default: all scopes).
|
|
10
|
+
*/
|
|
11
|
+
import { findRepoRoot } from '../src/api/lib/repoRoot';
|
|
12
|
+
import { readState, archiveItems } from '../src/api/lib/tenboFs';
|
|
13
|
+
import type { Item } from '../src/types';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
function parseArgs(argv: string[]): { scope?: string; days: number; max: number } {
|
|
17
|
+
let scope: string | undefined;
|
|
18
|
+
let days = 30;
|
|
19
|
+
let max = 20;
|
|
20
|
+
for (let i = 0; i < argv.length; i++) {
|
|
21
|
+
if (argv[i] === '--scope' && argv[i + 1]) {
|
|
22
|
+
scope = argv[++i];
|
|
23
|
+
} else if (argv[i] === '--days' && argv[i + 1]) {
|
|
24
|
+
days = parseInt(argv[++i], 10);
|
|
25
|
+
} else if (argv[i] === '--max' && argv[i + 1]) {
|
|
26
|
+
max = parseInt(argv[++i], 10);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { scope, days, max };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isOlderThanDays(docUpdate: string | undefined, days: number): boolean {
|
|
33
|
+
if (!docUpdate) return false;
|
|
34
|
+
const match = docUpdate.match(/^\d{4}-\d{2}-\d{2}$/);
|
|
35
|
+
if (!match) return false;
|
|
36
|
+
const itemDate = new Date(docUpdate);
|
|
37
|
+
const cutoff = new Date();
|
|
38
|
+
cutoff.setDate(cutoff.getDate() - days);
|
|
39
|
+
return itemDate <= cutoff;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getArchivableIds(items: Item[], days: number, maxThreshold: number): string[] {
|
|
43
|
+
const doneDropped = items.filter(i => i.status === 'done' || i.status === 'dropped');
|
|
44
|
+
if (doneDropped.length <= maxThreshold) return [];
|
|
45
|
+
return doneDropped
|
|
46
|
+
.filter(i => isOlderThanDays(i.doc_update, days))
|
|
47
|
+
.map(i => i.id);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isMain(): boolean {
|
|
51
|
+
try {
|
|
52
|
+
const invoked = process.argv[1] ? path.resolve(process.argv[1]) : '';
|
|
53
|
+
const here = path.resolve(new URL(import.meta.url).pathname);
|
|
54
|
+
return invoked === here;
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (isMain()) {
|
|
61
|
+
const args = parseArgs(process.argv.slice(2));
|
|
62
|
+
const cwd = process.cwd();
|
|
63
|
+
const repoRoot = findRepoRoot(cwd) ?? path.resolve(cwd, '..', '..');
|
|
64
|
+
|
|
65
|
+
const state = readState(repoRoot);
|
|
66
|
+
const scopesToProcess = args.scope
|
|
67
|
+
? state.scopes.filter(s => s.id === args.scope)
|
|
68
|
+
: state.scopes;
|
|
69
|
+
|
|
70
|
+
if (args.scope && scopesToProcess.length === 0) {
|
|
71
|
+
process.stderr.write(`Error: scope '${args.scope}' not found.\n`);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let totalArchived = 0;
|
|
76
|
+
for (const scope of scopesToProcess) {
|
|
77
|
+
const ids = getArchivableIds(scope.items, args.days, args.max);
|
|
78
|
+
if (ids.length > 0) {
|
|
79
|
+
archiveItems(repoRoot, scope.id, ids);
|
|
80
|
+
console.log(`Archived ${ids.length} items from scope '${scope.id}' to roadmap-archive.yaml`);
|
|
81
|
+
totalArchived += ids.length;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (totalArchived === 0) {
|
|
86
|
+
console.log('No items eligible for archiving.');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Item, Scope } from '../../../types';
|
|
2
|
+
import type { Finding } from './types';
|
|
3
|
+
|
|
4
|
+
const THIRTY_DAYS_MS = 30 * 24 * 3600 * 1000;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Surfaces `later` items that are >30 days old and reference at least one
|
|
8
|
+
* done/dropped item (via spawned_from, superseded_by, or related).
|
|
9
|
+
* These are likely stale — either they should be dropped, completed, or
|
|
10
|
+
* explicitly re-justified with an updated note.
|
|
11
|
+
*/
|
|
12
|
+
export function analyzeAgingSuperseded(
|
|
13
|
+
scope: Scope,
|
|
14
|
+
allItems: Map<string, Item>,
|
|
15
|
+
now: number = Date.now(),
|
|
16
|
+
): Finding[] {
|
|
17
|
+
const findings: Finding[] = [];
|
|
18
|
+
|
|
19
|
+
for (const item of scope.items) {
|
|
20
|
+
if (item.status !== 'later') continue;
|
|
21
|
+
|
|
22
|
+
const ageDays = itemAgeDays(item, now);
|
|
23
|
+
if (ageDays < 30) continue;
|
|
24
|
+
|
|
25
|
+
const refs = collectReferences(item);
|
|
26
|
+
if (refs.length === 0) continue;
|
|
27
|
+
|
|
28
|
+
const referencedDoneOrDropped = refs
|
|
29
|
+
.map(id => allItems.get(id))
|
|
30
|
+
.filter((ref): ref is Item => ref !== undefined && (ref.status === 'done' || ref.status === 'dropped'))
|
|
31
|
+
.map(ref => ({ id: ref.id, status: ref.status }));
|
|
32
|
+
|
|
33
|
+
if (referencedDoneOrDropped.length === 0) continue;
|
|
34
|
+
|
|
35
|
+
findings.push({
|
|
36
|
+
id: `${item.layer ?? scope.id}.aging-superseded.${item.id}`,
|
|
37
|
+
signal: 'aging-superseded',
|
|
38
|
+
severity: ageDays > 90 ? 'warning' : 'info',
|
|
39
|
+
confidence: 'medium',
|
|
40
|
+
layer: item.layer ?? scope.layers[0]?.id ?? scope.id,
|
|
41
|
+
target: item.id,
|
|
42
|
+
headline: `"${item.title}" is ${ageDays}d old in later, references ${referencedDoneOrDropped.length} done/dropped item(s)`,
|
|
43
|
+
suggestion: {
|
|
44
|
+
summary: 'Mark dropped, mark done, or update with a note explaining the delay',
|
|
45
|
+
rationale: `This item has been in "later" for ${ageDays} days and references items that have already shipped or been dropped. It may be stale.`,
|
|
46
|
+
action_kind: 'triage-item',
|
|
47
|
+
},
|
|
48
|
+
details: {
|
|
49
|
+
kind: 'aging-superseded',
|
|
50
|
+
item_id: item.id,
|
|
51
|
+
item_title: item.title,
|
|
52
|
+
age_days: ageDays,
|
|
53
|
+
referenced_items: referencedDoneOrDropped,
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return findings;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function collectReferences(item: Item): string[] {
|
|
62
|
+
const refs: string[] = [];
|
|
63
|
+
if (item.spawned_from) refs.push(item.spawned_from);
|
|
64
|
+
if (item.superseded_by) refs.push(item.superseded_by);
|
|
65
|
+
if (item.related) refs.push(...item.related);
|
|
66
|
+
return refs;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function itemAgeDays(item: Item, now: number): number {
|
|
70
|
+
const docUpdate = item.doc_update;
|
|
71
|
+
if (docUpdate && /^\d{4}-\d{2}-\d{2}$/.test(docUpdate)) {
|
|
72
|
+
const ts = new Date(docUpdate).getTime();
|
|
73
|
+
return Math.floor((now - ts) / (24 * 3600 * 1000));
|
|
74
|
+
}
|
|
75
|
+
return THIRTY_DAYS_MS / (24 * 3600 * 1000) + 1; // assume old enough if no date
|
|
76
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Scope } from '../../../types';
|
|
1
|
+
import type { Item, Scope } from '../../../types';
|
|
2
2
|
import type { Finding, Signal } from './types';
|
|
3
3
|
import type { HealthConfig } from './config';
|
|
4
4
|
import { resolveLayerFiles } from './layerFiles';
|
|
@@ -12,10 +12,12 @@ import type { ImportGraph } from './importGraph';
|
|
|
12
12
|
import { analyzeDeadCode } from './deadCode';
|
|
13
13
|
import { analyzeCoupling } from './coupling';
|
|
14
14
|
import { analyzeRedundancy } from './redundancy';
|
|
15
|
+
import { analyzeAgingSuperseded } from './agingSuperseded';
|
|
15
16
|
|
|
16
17
|
interface AnalyzerArgs {
|
|
17
18
|
repoRoot: string;
|
|
18
19
|
scopeId: string;
|
|
20
|
+
scopePath?: string;
|
|
19
21
|
layerId: string;
|
|
20
22
|
files: string[];
|
|
21
23
|
config: HealthConfig;
|
|
@@ -26,7 +28,8 @@ type AnalyzerFn = (args: AnalyzerArgs) => Finding[];
|
|
|
26
28
|
const ANALYZERS: Record<Signal, AnalyzerFn | null> = {
|
|
27
29
|
'hotspot-files': ({ repoRoot, layerId, files, config }) => analyzeHotspotFiles(repoRoot, layerId, files, config),
|
|
28
30
|
'aging-todos': ({ repoRoot, layerId, files, config }) => analyzeAgingTodos(repoRoot, layerId, files, config),
|
|
29
|
-
'
|
|
31
|
+
'aging-superseded': null, // scope-wide item analyzer, dispatched after the layer loop
|
|
32
|
+
'doc-drift': ({ repoRoot, scopeId, layerId, files, scopePath }) => analyzeDocDrift(repoRoot, scopeId, layerId, files, scopePath),
|
|
30
33
|
'test-coverage': ({ repoRoot, layerId, files }) => analyzeTestCoverage(repoRoot, layerId, files),
|
|
31
34
|
'architecture-compliance': ({ layerId, files }) => analyzeArchCompliance(layerId, files),
|
|
32
35
|
'dead-code': ({ repoRoot, layerId, files, graph }) => graph ? analyzeDeadCode(repoRoot, layerId, files, graph) : [],
|
|
@@ -34,7 +37,7 @@ const ANALYZERS: Record<Signal, AnalyzerFn | null> = {
|
|
|
34
37
|
'redundancy': null, // Phase 4
|
|
35
38
|
};
|
|
36
39
|
|
|
37
|
-
export async function collectAll(repoRoot: string, scope: Scope, config: HealthConfig): Promise<Finding[]> {
|
|
40
|
+
export async function collectAll(repoRoot: string, scope: Scope, config: HealthConfig, allItems?: Map<string, Item>): Promise<Finding[]> {
|
|
38
41
|
const filesByLayer = resolveLayerFiles(repoRoot, scope);
|
|
39
42
|
const allTsFiles = Object.values(filesByLayer).flat().filter(f => /\.tsx?$/.test(f));
|
|
40
43
|
let graph: ImportGraph | undefined;
|
|
@@ -51,7 +54,7 @@ export async function collectAll(repoRoot: string, scope: Scope, config: HealthC
|
|
|
51
54
|
if (!fn) continue;
|
|
52
55
|
if (opted.has(signal)) continue;
|
|
53
56
|
try {
|
|
54
|
-
out.push(...fn({ repoRoot, scopeId: scope.id, layerId: layer.id, files, config, graph }));
|
|
57
|
+
out.push(...fn({ repoRoot, scopeId: scope.id, scopePath: scope.path, layerId: layer.id, files, config, graph }));
|
|
55
58
|
} catch (e) {
|
|
56
59
|
// Analyzer failures don't break the whole report. Log and continue.
|
|
57
60
|
console.error(`[health] analyzer ${signal} failed for layer ${layer.id}:`, e);
|
|
@@ -74,5 +77,15 @@ export async function collectAll(repoRoot: string, scope: Scope, config: HealthC
|
|
|
74
77
|
return [] as Finding[];
|
|
75
78
|
});
|
|
76
79
|
out.push(...redundancyFindings.filter(f => !(config.ignore.layer_signals[f.layer] ?? []).includes('redundancy')));
|
|
80
|
+
|
|
81
|
+
// Aging-superseded is item-based (not file-based) — run once per scope
|
|
82
|
+
try {
|
|
83
|
+
const itemMap = allItems ?? new Map(scope.items.map(i => [i.id, i]));
|
|
84
|
+
const agingFindings = analyzeAgingSuperseded(scope, itemMap);
|
|
85
|
+
out.push(...agingFindings.filter(f => !(config.ignore.layer_signals[f.layer] ?? []).includes('aging-superseded')));
|
|
86
|
+
} catch (e) {
|
|
87
|
+
console.error('[health] aging-superseded failed:', e);
|
|
88
|
+
}
|
|
89
|
+
|
|
77
90
|
return out;
|
|
78
91
|
}
|
|
@@ -31,6 +31,24 @@ describe('analyzeDocDrift', () => {
|
|
|
31
31
|
rmSync(root, { recursive: true, force: true });
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
+
it('does NOT emit missing-ref when file exists under the scope path', () => {
|
|
35
|
+
const root = mkdtempSync(path.join(tmpdir(), 'drift-'));
|
|
36
|
+
const scopePath = 'tenbo-dashboard';
|
|
37
|
+
mkdirSync(path.join(root, scopePath, 'src'), { recursive: true });
|
|
38
|
+
writeFileSync(path.join(root, scopePath, 'src/main.tsx'), 'export default 1;\n');
|
|
39
|
+
mkdirSync(path.join(root, '.tenbo/scopes/dashboard/layers/lyr'), { recursive: true });
|
|
40
|
+
writeFileSync(
|
|
41
|
+
path.join(root, '.tenbo/scopes/dashboard/layers/lyr/code-map.md'),
|
|
42
|
+
'Entry point: `src/main.tsx`.\n',
|
|
43
|
+
);
|
|
44
|
+
const findings = analyzeDocDrift(root, 'dashboard', 'lyr', [], scopePath);
|
|
45
|
+
const broken = findings.find(f =>
|
|
46
|
+
f.details.kind === 'doc-drift' && f.details.drift_type === 'missing-ref'
|
|
47
|
+
);
|
|
48
|
+
expect(broken).toBeUndefined();
|
|
49
|
+
rmSync(root, { recursive: true, force: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
34
52
|
it('emits unreferenced-file info finding when a layer file is not mentioned in code-map.md', () => {
|
|
35
53
|
const root = mkdtempSync(path.join(tmpdir(), 'drift-'));
|
|
36
54
|
mkdirSync(path.join(root, 'apps/editor/src'), { recursive: true });
|
|
@@ -22,6 +22,7 @@ export function analyzeDocDrift(
|
|
|
22
22
|
scopeId: string,
|
|
23
23
|
layerId: string,
|
|
24
24
|
layerFiles: string[],
|
|
25
|
+
scopePath?: string,
|
|
25
26
|
): Finding[] {
|
|
26
27
|
const docPath = path.join(repoRoot, '.tenbo/scopes', scopeId, 'layers', layerId, 'code-map.md');
|
|
27
28
|
if (!existsSync(docPath)) return [];
|
|
@@ -34,7 +35,9 @@ export function analyzeDocDrift(
|
|
|
34
35
|
// 1. missing-ref: file in code-map.md doesn't exist on disk
|
|
35
36
|
const broken: string[] = [];
|
|
36
37
|
for (const ref of refs) {
|
|
37
|
-
|
|
38
|
+
const existsAtRoot = existsSync(path.join(repoRoot, ref));
|
|
39
|
+
const existsAtScope = scopePath ? existsSync(path.join(repoRoot, scopePath, ref)) : false;
|
|
40
|
+
if (!existsAtRoot && !existsAtScope) broken.push(ref);
|
|
38
41
|
}
|
|
39
42
|
if (broken.length > 0) {
|
|
40
43
|
const severity = broken.length >= 3 ? 'critical' : 'warning';
|
|
@@ -8,16 +8,16 @@ describe('resolveLayerFiles', () => {
|
|
|
8
8
|
|
|
9
9
|
it('returns repo-relative paths bucketed by layer', () => {
|
|
10
10
|
if (!repoRoot) throw new Error('Could not find repo root');
|
|
11
|
-
// Use a real fixture: this repo's
|
|
11
|
+
// Use a real fixture: this repo's health directory which has known .ts files.
|
|
12
12
|
const scope: Scope = {
|
|
13
|
-
id: '
|
|
14
|
-
path: '
|
|
13
|
+
id: 'dashboard',
|
|
14
|
+
path: 'tenbo-dashboard/src/api/lib/health',
|
|
15
15
|
description: '',
|
|
16
16
|
layers: [{
|
|
17
17
|
id: 'visual-canvas',
|
|
18
18
|
name: 'Visual Canvas',
|
|
19
19
|
description: '',
|
|
20
|
-
files: ['
|
|
20
|
+
files: ['*.ts'],
|
|
21
21
|
dependencies: { inbound: [], outbound: [], external: [] },
|
|
22
22
|
}],
|
|
23
23
|
items: [],
|
|
@@ -26,7 +26,7 @@ describe('resolveLayerFiles', () => {
|
|
|
26
26
|
expect(result['visual-canvas']).toBeDefined();
|
|
27
27
|
expect(result['visual-canvas'].length).toBeGreaterThan(0);
|
|
28
28
|
// Paths are repo-relative
|
|
29
|
-
expect(result['visual-canvas'][0]).toMatch(/^
|
|
29
|
+
expect(result['visual-canvas'][0]).toMatch(/^tenbo-dashboard\/src\/api\/lib\/health\//);
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
it('returns empty array for layer with no matching files', () => {
|
|
@@ -9,6 +9,7 @@ export type Signal =
|
|
|
9
9
|
| 'doc-drift'
|
|
10
10
|
| 'test-coverage'
|
|
11
11
|
| 'aging-todos'
|
|
12
|
+
| 'aging-superseded'
|
|
12
13
|
| 'architecture-compliance'
|
|
13
14
|
| 'redundancy';
|
|
14
15
|
|
|
@@ -20,7 +21,8 @@ export type ActionKind =
|
|
|
20
21
|
| 'move-file'
|
|
21
22
|
| 'update-doc'
|
|
22
23
|
| 'add-test'
|
|
23
|
-
| 'resolve-todo'
|
|
24
|
+
| 'resolve-todo'
|
|
25
|
+
| 'triage-item';
|
|
24
26
|
|
|
25
27
|
export interface Suggestion {
|
|
26
28
|
summary: string; // imperative one-liner
|
|
@@ -36,6 +38,7 @@ export type FindingDetails =
|
|
|
36
38
|
| { kind: 'doc-drift'; drift_type: 'missing-ref' | 'unreferenced-file' | 'stale-section'; doc_path: string; doc_mtime_iso: string | null; code_mtime_iso: string | null; affected_files: string[] }
|
|
37
39
|
| { kind: 'test-coverage'; suggested_test_path: string }
|
|
38
40
|
| { kind: 'aging-todos'; line: number; age_days: number; commit_hash: string; author: string; text: string; context: string }
|
|
41
|
+
| { kind: 'aging-superseded'; item_id: string; item_title: string; age_days: number; referenced_items: { id: string; status: string }[] }
|
|
39
42
|
| { kind: 'architecture-compliance'; expected_path_pattern: string; actual_path: string; rule: string }
|
|
40
43
|
| { kind: 'redundancy'; copies: { path: string; lines: [number, number] }[]; similarity_pct: number };
|
|
41
44
|
|
|
@@ -66,6 +69,7 @@ export const SIGNAL_WEIGHTS_DEFAULT: Signal[] = [
|
|
|
66
69
|
'doc-drift',
|
|
67
70
|
'test-coverage',
|
|
68
71
|
'aging-todos',
|
|
72
|
+
'aging-superseded',
|
|
69
73
|
];
|
|
70
74
|
|
|
71
75
|
export const CONFIDENCE_RANK: Record<Confidence, number> = {
|
package/src/api/lib/tenboFs.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, readdirSync, existsSync, renameSync, statSync, type Stats } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { parse as parseSimple } from 'yaml';
|
|
3
|
+
import { parse as parseSimple, stringify as yamlStringify } from 'yaml';
|
|
4
4
|
import { parseYaml, stringifyYaml, patchSeqItem, reorderSeqItems } from './yamlOrdered';
|
|
5
5
|
import { getCached, invalidate as invalidateCache } from './parseCache';
|
|
6
6
|
import type { TenboState, Scope, Item, Layer, CrossCutting, LayerDocs, ScopeMetrics, WorkspaceContent, LayerContent } from '../../types';
|
|
@@ -70,6 +70,13 @@ function readRoadmap(repoRoot: string, scopeId: string): Item[] {
|
|
|
70
70
|
return data?.items ?? [];
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
export function readRoadmapArchive(repoRoot: string, scopeId: string): Item[] {
|
|
74
|
+
const file = path.join(tenboDir(repoRoot), 'scopes', scopeId, 'roadmap-archive.yaml');
|
|
75
|
+
if (!existsSync(file)) return [];
|
|
76
|
+
const data = getCached(file, 'roadmap-archive-yaml', (text) => parseSimple(text) as any);
|
|
77
|
+
return data?.items ?? [];
|
|
78
|
+
}
|
|
79
|
+
|
|
73
80
|
function readNarratives(repoRoot: string, scopeId: string): Record<string, string> {
|
|
74
81
|
const dir = path.join(tenboDir(repoRoot), 'scopes', scopeId, 'layers');
|
|
75
82
|
if (!existsSync(dir)) return {};
|
|
@@ -194,6 +201,7 @@ export function readState(repoRoot: string): TenboState {
|
|
|
194
201
|
description: ref.description,
|
|
195
202
|
layers: readArchitecture(repoRoot, ref.id),
|
|
196
203
|
items: readRoadmap(repoRoot, ref.id),
|
|
204
|
+
archivedItems: readRoadmapArchive(repoRoot, ref.id),
|
|
197
205
|
}));
|
|
198
206
|
const narratives = scopes.reduce<Record<string, string>>((acc, s) => {
|
|
199
207
|
return Object.assign(acc, readNarratives(repoRoot, s.id));
|
|
@@ -242,3 +250,39 @@ export function reorderItems(repoRoot: string, scopeId: string, idsInOrder: stri
|
|
|
242
250
|
reorderSeqItems(doc, 'items', idsInOrder);
|
|
243
251
|
atomicWrite(file, stringifyYaml(doc));
|
|
244
252
|
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Move items from roadmap.yaml to roadmap-archive.yaml for the given scope.
|
|
256
|
+
* Creates the archive file if it doesn't exist. Uses atomic writes for both files.
|
|
257
|
+
*/
|
|
258
|
+
export function archiveItems(repoRoot: string, scopeId: string, itemIds: string[]): void {
|
|
259
|
+
if (itemIds.length === 0) return;
|
|
260
|
+
|
|
261
|
+
const roadmapFile = path.join(tenboDir(repoRoot), 'scopes', scopeId, 'roadmap.yaml');
|
|
262
|
+
const archiveFile = path.join(tenboDir(repoRoot), 'scopes', scopeId, 'roadmap-archive.yaml');
|
|
263
|
+
|
|
264
|
+
// Read roadmap and find matching items
|
|
265
|
+
const roadmapText = readFileSync(roadmapFile, 'utf8');
|
|
266
|
+
const roadmapData = parseSimple(roadmapText) as any;
|
|
267
|
+
const allItems: Item[] = roadmapData?.items ?? [];
|
|
268
|
+
|
|
269
|
+
const idsToArchive = new Set(itemIds);
|
|
270
|
+
const itemsToArchive = allItems.filter(i => idsToArchive.has(i.id));
|
|
271
|
+
const remainingItems = allItems.filter(i => !idsToArchive.has(i.id));
|
|
272
|
+
|
|
273
|
+
if (itemsToArchive.length === 0) return;
|
|
274
|
+
|
|
275
|
+
// Read existing archive
|
|
276
|
+
let archiveItems: Item[] = [];
|
|
277
|
+
if (existsSync(archiveFile)) {
|
|
278
|
+
const archiveData = parseSimple(readFileSync(archiveFile, 'utf8')) as any;
|
|
279
|
+
archiveItems = archiveData?.items ?? [];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Append archived items
|
|
283
|
+
archiveItems.push(...itemsToArchive);
|
|
284
|
+
|
|
285
|
+
// Write both files atomically
|
|
286
|
+
atomicWrite(roadmapFile, yamlStringify({ items: remainingItems }, { lineWidth: 0, blockQuote: 'literal' }));
|
|
287
|
+
atomicWrite(archiveFile, yamlStringify({ items: archiveItems }, { lineWidth: 0, blockQuote: 'literal' }));
|
|
288
|
+
}
|
package/src/api/lib/validator.ts
CHANGED
|
@@ -22,12 +22,38 @@ export function validate(state: TenboState): ValidateResult {
|
|
|
22
22
|
// Collect every known roadmap-item id across scopes + cross-cutting roadmap.
|
|
23
23
|
// Used by the relationship validator (`spawned_from`, `related`) to flag
|
|
24
24
|
// references that don't resolve to anything in the workspace.
|
|
25
|
+
// Also includes archived items so references to them don't produce warnings.
|
|
25
26
|
const allItemIds = new Set<string>();
|
|
26
27
|
for (const scope of state.scopes) {
|
|
27
28
|
for (const item of scope.items) allItemIds.add(item.id);
|
|
29
|
+
for (const item of scope.archivedItems ?? []) allItemIds.add(item.id);
|
|
28
30
|
}
|
|
29
31
|
for (const item of state.crossCuttingRoadmap ?? []) allItemIds.add(item.id);
|
|
30
32
|
|
|
33
|
+
const validateSupersededBy = (item: Item, scopeId?: string) => {
|
|
34
|
+
if (item.superseded_by === undefined) return;
|
|
35
|
+
const sb = item.superseded_by;
|
|
36
|
+
if (typeof sb !== 'string' || !ITEM_ID_RE.test(sb)) {
|
|
37
|
+
errors.push({ level: 'error', message: `item "${item.id}" superseded_by "${sb}" has invalid id format`, scope: scopeId, itemId: item.id });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (sb === item.id) {
|
|
41
|
+
errors.push({ level: 'error', message: `item "${item.id}" superseded_by cannot reference itself`, scope: scopeId, itemId: item.id });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (!allItemIds.has(sb)) {
|
|
45
|
+
warnings.push({ level: 'warning', message: `${item.id} superseded_by references unknown id '${sb}'`, scope: scopeId, itemId: item.id });
|
|
46
|
+
}
|
|
47
|
+
if (item.status !== 'dropped') {
|
|
48
|
+
warnings.push({ level: 'warning', message: `${item.id} has superseded_by but status is "${item.status}" (expected "dropped")`, scope: scopeId, itemId: item.id });
|
|
49
|
+
}
|
|
50
|
+
// Cycle check: if the superseder also has superseded_by pointing back
|
|
51
|
+
const superseder = [...state.scopes.flatMap(s => s.items), ...(state.crossCuttingRoadmap ?? [])].find(i => i.id === sb);
|
|
52
|
+
if (superseder?.superseded_by === item.id) {
|
|
53
|
+
errors.push({ level: 'error', message: `cycle: "${item.id}" superseded_by "${sb}" which is superseded_by "${item.id}"`, scope: scopeId, itemId: item.id });
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
31
57
|
const validateRelationships = (item: Item, scopeId?: string) => {
|
|
32
58
|
if (item.spawned_from !== undefined) {
|
|
33
59
|
const sf = item.spawned_from;
|
|
@@ -213,6 +239,9 @@ export function validate(state: TenboState): ValidateResult {
|
|
|
213
239
|
// Relationship validation (Phase 5 of x-001).
|
|
214
240
|
validateRelationships(item, scope.id);
|
|
215
241
|
|
|
242
|
+
// Superseded-by validation (sk-015).
|
|
243
|
+
validateSupersededBy(item, scope.id);
|
|
244
|
+
|
|
216
245
|
// Spec-link lifecycle validation (Phase 6 of x-001).
|
|
217
246
|
validateSpecLinks(item, scope.id);
|
|
218
247
|
|
|
@@ -304,6 +333,7 @@ export function validate(state: TenboState): ValidateResult {
|
|
|
304
333
|
}
|
|
305
334
|
}
|
|
306
335
|
validateRelationships(item);
|
|
336
|
+
validateSupersededBy(item);
|
|
307
337
|
validateSpecLinks(item);
|
|
308
338
|
}
|
|
309
339
|
|
package/src/api/plugin.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { openRoute } from './routes/open';
|
|
|
8
8
|
import { watchRoute } from './routes/watch';
|
|
9
9
|
import { layerDocsRoute } from './routes/layerDocs';
|
|
10
10
|
import { layerContentRoute } from './routes/layerContent';
|
|
11
|
+
import { archiveRoute } from './routes/archive';
|
|
11
12
|
|
|
12
13
|
export function tenboApiPlugin(): Plugin {
|
|
13
14
|
return {
|
|
@@ -28,6 +29,7 @@ export function tenboApiPlugin(): Plugin {
|
|
|
28
29
|
server.httpServer?.on('close', () => { void watch.close(); });
|
|
29
30
|
server.middlewares.use(layerDocsRoute(repoRoot));
|
|
30
31
|
server.middlewares.use(layerContentRoute(repoRoot));
|
|
32
|
+
server.middlewares.use(archiveRoute(repoRoot));
|
|
31
33
|
},
|
|
32
34
|
};
|
|
33
35
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Connect } from 'vite';
|
|
2
|
+
import { readState, archiveItems } from '../lib/tenboFs';
|
|
3
|
+
import { readBody, json, withErrorHandling } from '../lib/http';
|
|
4
|
+
import type { Item } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface ArchiveRequest {
|
|
7
|
+
scopeId?: string;
|
|
8
|
+
days?: number;
|
|
9
|
+
maxThreshold?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ArchiveResultEntry {
|
|
13
|
+
archived: string[];
|
|
14
|
+
scopeId: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isOlderThanDays(docUpdate: string | undefined, days: number): boolean {
|
|
18
|
+
if (!docUpdate) return false;
|
|
19
|
+
const match = docUpdate.match(/^\d{4}-\d{2}-\d{2}$/);
|
|
20
|
+
if (!match) return false;
|
|
21
|
+
const itemDate = new Date(docUpdate);
|
|
22
|
+
const cutoff = new Date();
|
|
23
|
+
cutoff.setDate(cutoff.getDate() - days);
|
|
24
|
+
return itemDate <= cutoff;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getArchivableIds(items: Item[], days: number, maxThreshold: number): string[] {
|
|
28
|
+
const doneDropped = items.filter(i => i.status === 'done' || i.status === 'dropped');
|
|
29
|
+
if (doneDropped.length <= maxThreshold) return [];
|
|
30
|
+
return doneDropped
|
|
31
|
+
.filter(i => isOlderThanDays(i.doc_update, days))
|
|
32
|
+
.map(i => i.id);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function archiveRoute(repoRoot: string): Connect.NextHandleFunction {
|
|
36
|
+
return withErrorHandling(async (req, res, next) => {
|
|
37
|
+
if (req.url !== '/api/archive' || req.method !== 'POST') return next();
|
|
38
|
+
|
|
39
|
+
const body = await readBody<ArchiveRequest>(req);
|
|
40
|
+
const days = body.days ?? 30;
|
|
41
|
+
const maxThreshold = body.maxThreshold ?? 20;
|
|
42
|
+
|
|
43
|
+
const state = readState(repoRoot);
|
|
44
|
+
const results: ArchiveResultEntry[] = [];
|
|
45
|
+
|
|
46
|
+
const scopesToProcess = body.scopeId
|
|
47
|
+
? state.scopes.filter(s => s.id === body.scopeId)
|
|
48
|
+
: state.scopes;
|
|
49
|
+
|
|
50
|
+
for (const scope of scopesToProcess) {
|
|
51
|
+
const ids = getArchivableIds(scope.items, days, maxThreshold);
|
|
52
|
+
if (ids.length > 0) {
|
|
53
|
+
archiveItems(repoRoot, scope.id, ids);
|
|
54
|
+
results.push({ archived: ids, scopeId: scope.id });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
json(res, results);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -2,9 +2,11 @@ import { describe, it, expect } from 'vitest';
|
|
|
2
2
|
import { parseHash, formatRoute } from './routes.js';
|
|
3
3
|
|
|
4
4
|
describe('parseHash', () => {
|
|
5
|
-
it('defaults to
|
|
6
|
-
expect(parseHash('')).toEqual({ kind: '
|
|
7
|
-
expect(parseHash('#/')).toEqual({ kind: '
|
|
5
|
+
it('defaults to roadmap on empty hash', () => {
|
|
6
|
+
expect(parseHash('')).toEqual({ kind: 'roadmap' });
|
|
7
|
+
expect(parseHash('#/')).toEqual({ kind: 'roadmap' });
|
|
8
|
+
});
|
|
9
|
+
it('defaults to docs project overview on #/docs', () => {
|
|
8
10
|
expect(parseHash('#/docs')).toEqual({ kind: 'docs-project', tab: 'overview' });
|
|
9
11
|
});
|
|
10
12
|
it('parses docs project tab', () => {
|
package/src/types.ts
CHANGED
|
@@ -64,6 +64,12 @@ export interface Item {
|
|
|
64
64
|
* the new item should set `spawned_from` to the dispatching item's id.
|
|
65
65
|
*/
|
|
66
66
|
spawned_from?: string;
|
|
67
|
+
/**
|
|
68
|
+
* The id of the item that replaced/superseded this one. When set, the item's
|
|
69
|
+
* status should be `dropped`. The validator enforces this constraint and checks
|
|
70
|
+
* for cycles and dangling references.
|
|
71
|
+
*/
|
|
72
|
+
superseded_by?: string;
|
|
67
73
|
/**
|
|
68
74
|
* Peer relationships. Any number of ids in the same format. Order is not significant.
|
|
69
75
|
* Not parent–child — for that use `spawned_from`.
|
|
@@ -102,6 +108,7 @@ export interface Scope {
|
|
|
102
108
|
description: string;
|
|
103
109
|
layers: Layer[];
|
|
104
110
|
items: Item[];
|
|
111
|
+
archivedItems?: Item[];
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
export interface CrossCutting {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Set of item IDs that came from roadmap-archive.yaml.
|
|
5
|
+
* Provided by RoadmapPage so any descendant (ItemCard, TaskList row)
|
|
6
|
+
* can style archived items without prop drilling.
|
|
7
|
+
*/
|
|
8
|
+
export const ArchivedIdsContext = createContext<ReadonlySet<string>>(new Set());
|
|
9
|
+
|
|
10
|
+
export function useArchivedIds(): ReadonlySet<string> {
|
|
11
|
+
return useContext(ArchivedIdsContext);
|
|
12
|
+
}
|
|
@@ -20,6 +20,15 @@
|
|
|
20
20
|
.cardDone:hover {
|
|
21
21
|
opacity: 1;
|
|
22
22
|
}
|
|
23
|
+
|
|
24
|
+
/* Archived items are even more subtle — dashed border signals "read-only historical". */
|
|
25
|
+
.cardArchived {
|
|
26
|
+
opacity: 0.45;
|
|
27
|
+
border: 1px dashed var(--console-mist);
|
|
28
|
+
}
|
|
29
|
+
.cardArchived:hover {
|
|
30
|
+
opacity: 0.8;
|
|
31
|
+
}
|
|
23
32
|
.card:focus-visible {
|
|
24
33
|
outline: 2px solid var(--signal-cyan);
|
|
25
34
|
outline-offset: 2px;
|
|
@@ -68,6 +77,15 @@
|
|
|
68
77
|
white-space: nowrap;
|
|
69
78
|
}
|
|
70
79
|
|
|
80
|
+
.archivedChip {
|
|
81
|
+
display: inline-flex;
|
|
82
|
+
align-items: center;
|
|
83
|
+
color: var(--console-smoke);
|
|
84
|
+
padding: 1px 4px;
|
|
85
|
+
border-radius: var(--r-sm);
|
|
86
|
+
background: var(--console-soot);
|
|
87
|
+
}
|
|
88
|
+
|
|
71
89
|
.titleInput {
|
|
72
90
|
width: 100%;
|
|
73
91
|
font: inherit;
|
package/src/ui/ItemCard.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
2
|
import { useSortable } from '@dnd-kit/sortable';
|
|
3
3
|
import { CSS } from '@dnd-kit/utilities';
|
|
4
|
-
import { CornerLeftUp, ArrowLeftRight } from 'lucide-react';
|
|
4
|
+
import { Archive, CornerLeftUp, ArrowLeftRight } from 'lucide-react';
|
|
5
5
|
import type { Item } from '../types';
|
|
6
6
|
import { effectiveStatus, phaseProgress } from '../api/lib/phases';
|
|
7
|
+
import { useArchivedIds } from './ArchivedContext';
|
|
7
8
|
import styles from './ItemCard.module.css';
|
|
8
9
|
|
|
9
10
|
interface Props {
|
|
@@ -18,21 +19,21 @@ export function ItemCard({ item, onClick, onTitleEdit, onDescEdit }: Props) {
|
|
|
18
19
|
const dragStyle = {
|
|
19
20
|
transform: CSS.Translate.toString(transform),
|
|
20
21
|
transition,
|
|
21
|
-
|
|
22
|
+
...(isDragging ? { opacity: 0.4 } : {}),
|
|
22
23
|
};
|
|
23
24
|
const [editing, setEditing] = useState<null | 'title' | 'desc'>(null);
|
|
24
25
|
const phases = item.phases ?? [];
|
|
25
26
|
const progress = phases.length > 0 ? phaseProgress(phases) : null;
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
28
|
+
const archivedIds = useArchivedIds();
|
|
29
|
+
const isArchived = archivedIds.has(item.id);
|
|
30
|
+
const status = effectiveStatus(item);
|
|
31
|
+
const isDimmed = status === 'done' || status === 'dropped';
|
|
31
32
|
|
|
32
33
|
return (
|
|
33
34
|
<div
|
|
34
35
|
ref={setNodeRef}
|
|
35
|
-
className={`${styles.card}${
|
|
36
|
+
className={`${styles.card}${isDimmed ? ` ${styles.cardDone}` : ''}${isArchived ? ` ${styles.cardArchived}` : ''}`}
|
|
36
37
|
style={dragStyle}
|
|
37
38
|
{...attributes}
|
|
38
39
|
{...listeners}
|
|
@@ -46,6 +47,11 @@ export function ItemCard({ item, onClick, onTitleEdit, onDescEdit }: Props) {
|
|
|
46
47
|
<div className={styles.id}>
|
|
47
48
|
{item.priority && <span className={`${styles.priority} ${styles[item.priority]}`}>{item.priority.toUpperCase()}</span>}
|
|
48
49
|
{item.id}
|
|
50
|
+
{isArchived && (
|
|
51
|
+
<span className={styles.archivedChip} title="Archived item" aria-label="Archived">
|
|
52
|
+
<Archive size={10} strokeWidth={1.75} />
|
|
53
|
+
</span>
|
|
54
|
+
)}
|
|
49
55
|
{item.spawned_from && (
|
|
50
56
|
<span
|
|
51
57
|
className={styles.relChip}
|
|
@@ -19,6 +19,7 @@ const SIGNAL_LABELS: Record<Signal, string> = {
|
|
|
19
19
|
'doc-drift': 'Doc drift',
|
|
20
20
|
'test-coverage': 'Test coverage',
|
|
21
21
|
'aging-todos': 'Aging TODOs',
|
|
22
|
+
'aging-superseded': 'Stale later items',
|
|
22
23
|
'architecture-compliance': 'Architecture compliance',
|
|
23
24
|
'redundancy': 'Redundancy',
|
|
24
25
|
};
|
|
@@ -91,3 +91,25 @@
|
|
|
91
91
|
.viewBtn:hover:not(.viewBtnActive) {
|
|
92
92
|
color: var(--console-snow);
|
|
93
93
|
}
|
|
94
|
+
|
|
95
|
+
/* Archive toggle — pill style, matches viewBtn feel */
|
|
96
|
+
.archiveBtn {
|
|
97
|
+
display: inline-flex;
|
|
98
|
+
align-items: center;
|
|
99
|
+
gap: 5px;
|
|
100
|
+
padding: 4px 10px;
|
|
101
|
+
border-radius: var(--r-md);
|
|
102
|
+
font-size: 12px;
|
|
103
|
+
font-weight: 500;
|
|
104
|
+
color: var(--console-fog);
|
|
105
|
+
background: var(--console-iron);
|
|
106
|
+
transition: background var(--dur-state) var(--ease), color var(--dur-state) var(--ease);
|
|
107
|
+
white-space: nowrap;
|
|
108
|
+
}
|
|
109
|
+
.archiveBtn:hover {
|
|
110
|
+
color: var(--console-snow);
|
|
111
|
+
}
|
|
112
|
+
.archiveBtnActive {
|
|
113
|
+
background: var(--console-mist);
|
|
114
|
+
color: var(--signal-cyan);
|
|
115
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useMemo, useState } from 'react';
|
|
2
|
-
import { Columns2, List } from 'lucide-react';
|
|
2
|
+
import { Archive, Columns2, List } from 'lucide-react';
|
|
3
3
|
import { DndContext, type DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
|
4
4
|
import { LayerKanban } from '../LayerKanban';
|
|
5
5
|
import { TaskList, type FlatItem } from '../TaskList';
|
|
@@ -7,6 +7,7 @@ import { effectiveStatus } from '../../api/lib/phases';
|
|
|
7
7
|
import { tenboApi } from '../../api/client';
|
|
8
8
|
import type { Route } from '../../router/routes';
|
|
9
9
|
import type { Item, Layer, Status, TenboState } from '../../types';
|
|
10
|
+
import { ArchivedIdsContext } from '../ArchivedContext';
|
|
10
11
|
import styles from './RoadmapPage.module.css';
|
|
11
12
|
|
|
12
13
|
const GENERAL_SCOPE = 'general';
|
|
@@ -42,12 +43,25 @@ export function RoadmapPage({ state, scopeFilter, layerFilter, navigate, onCardC
|
|
|
42
43
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
|
|
43
44
|
const [view, setView] = useState<'kanban' | 'list'>('kanban');
|
|
44
45
|
const [search, setSearch] = useState('');
|
|
46
|
+
const [showArchived, setShowArchived] = useState(false);
|
|
47
|
+
|
|
48
|
+
/** IDs of archived items so we can tag them visually. */
|
|
49
|
+
const archivedIds = useMemo(() => {
|
|
50
|
+
const s = new Set<string>();
|
|
51
|
+
for (const scope of state.scopes) for (const i of scope.archivedItems ?? []) s.add(i.id);
|
|
52
|
+
return s;
|
|
53
|
+
}, [state]);
|
|
54
|
+
|
|
55
|
+
const totalArchived = archivedIds.size;
|
|
45
56
|
|
|
46
57
|
const itemBySource = useMemo(() => {
|
|
47
58
|
const m = new Map<string, { scopeId: string; item: Item }>();
|
|
48
|
-
for (const s of state.scopes)
|
|
59
|
+
for (const s of state.scopes) {
|
|
60
|
+
for (const i of s.items) m.set(i.id, { scopeId: s.id, item: i });
|
|
61
|
+
if (showArchived) for (const i of s.archivedItems ?? []) m.set(i.id, { scopeId: s.id, item: i });
|
|
62
|
+
}
|
|
49
63
|
return m;
|
|
50
|
-
}, [state]);
|
|
64
|
+
}, [state, showArchived]);
|
|
51
65
|
|
|
52
66
|
const crossCuttingItems = state.crossCuttingRoadmap ?? [];
|
|
53
67
|
const showGeneral = !scopeFilter || scopeFilter === GENERAL_SCOPE;
|
|
@@ -63,8 +77,12 @@ export function RoadmapPage({ state, scopeFilter, layerFilter, navigate, onCardC
|
|
|
63
77
|
item.title.toLowerCase().includes(q) ||
|
|
64
78
|
(item.description ?? '').toLowerCase().includes(q);
|
|
65
79
|
|
|
80
|
+
/** Merge active + archived items per scope when toggle is on. */
|
|
81
|
+
const scopeItems = (s: typeof state.scopes[number]) =>
|
|
82
|
+
showArchived ? [...s.items, ...(s.archivedItems ?? [])] : s.items;
|
|
83
|
+
|
|
66
84
|
const allItems = [
|
|
67
|
-
...visibleScopes.flatMap((s) => s
|
|
85
|
+
...visibleScopes.flatMap((s) => scopeItems(s)).filter((i) => (!layerFilter || i.layer === layerFilter) && matchesSearch(i)),
|
|
68
86
|
...visibleGeneral.filter(matchesSearch),
|
|
69
87
|
];
|
|
70
88
|
|
|
@@ -72,7 +90,7 @@ export function RoadmapPage({ state, scopeFilter, layerFilter, navigate, onCardC
|
|
|
72
90
|
const result: FlatItem[] = [];
|
|
73
91
|
for (const scope of visibleScopes) {
|
|
74
92
|
const layerMap = new Map(scope.layers.map(l => [l.id, l.name]));
|
|
75
|
-
for (const item of scope
|
|
93
|
+
for (const item of scopeItems(scope)) {
|
|
76
94
|
if (layerFilter && item.layer !== layerFilter) continue;
|
|
77
95
|
if (!matchesSearch(item)) continue;
|
|
78
96
|
result.push({ item, scopeId: scope.id, layerName: item.layer ? layerMap.get(item.layer) : undefined });
|
|
@@ -83,7 +101,7 @@ export function RoadmapPage({ state, scopeFilter, layerFilter, navigate, onCardC
|
|
|
83
101
|
}
|
|
84
102
|
return result;
|
|
85
103
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
86
|
-
}, [state, scopeFilter, layerFilter, search]);
|
|
104
|
+
}, [state, scopeFilter, layerFilter, search, showArchived]);
|
|
87
105
|
|
|
88
106
|
const handleDragEnd = async (e: DragEndEvent) => {
|
|
89
107
|
const activeId = String(e.active.id);
|
|
@@ -109,6 +127,7 @@ export function RoadmapPage({ state, scopeFilter, layerFilter, navigate, onCardC
|
|
|
109
127
|
};
|
|
110
128
|
|
|
111
129
|
return (
|
|
130
|
+
<ArchivedIdsContext.Provider value={archivedIds}>
|
|
112
131
|
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
|
113
132
|
<div className={styles.stickyBar}>
|
|
114
133
|
<RoadmapFilterBar
|
|
@@ -121,6 +140,9 @@ export function RoadmapPage({ state, scopeFilter, layerFilter, navigate, onCardC
|
|
|
121
140
|
allItems={allItems}
|
|
122
141
|
search={search}
|
|
123
142
|
onSearchChange={setSearch}
|
|
143
|
+
showArchived={showArchived}
|
|
144
|
+
onToggleArchived={() => setShowArchived((v) => !v)}
|
|
145
|
+
archivedCount={totalArchived}
|
|
124
146
|
/>
|
|
125
147
|
</div>
|
|
126
148
|
<main style={{ padding: view === 'list' ? '16px 0 0' : 16 }}>
|
|
@@ -142,7 +164,7 @@ export function RoadmapPage({ state, scopeFilter, layerFilter, navigate, onCardC
|
|
|
142
164
|
layer={layer}
|
|
143
165
|
depth={depth}
|
|
144
166
|
alwaysOpen={!!layerFilter}
|
|
145
|
-
items={scope.
|
|
167
|
+
items={scopeItems(scope).filter((i) => i.layer === layer.id && matchesSearch(i))}
|
|
146
168
|
onCardClick={(item) => onCardClick(scope.id, item)}
|
|
147
169
|
onTitleEdit={(id, title) => onPatch(scope.id, id, { title })}
|
|
148
170
|
onDescEdit={(id, desc) => onPatch(scope.id, id, { description: desc })}
|
|
@@ -166,10 +188,11 @@ export function RoadmapPage({ state, scopeFilter, layerFilter, navigate, onCardC
|
|
|
166
188
|
)}
|
|
167
189
|
</main>
|
|
168
190
|
</DndContext>
|
|
191
|
+
</ArchivedIdsContext.Provider>
|
|
169
192
|
);
|
|
170
193
|
}
|
|
171
194
|
|
|
172
|
-
function RoadmapFilterBar({ scopes, scopeFilter, layerFilter, navigate, view, onViewChange, allItems, search, onSearchChange }: {
|
|
195
|
+
function RoadmapFilterBar({ scopes, scopeFilter, layerFilter, navigate, view, onViewChange, allItems, search, onSearchChange, showArchived, onToggleArchived, archivedCount }: {
|
|
173
196
|
scopes: TenboState['scopes'];
|
|
174
197
|
scopeFilter: string | undefined;
|
|
175
198
|
layerFilter: string | undefined;
|
|
@@ -179,9 +202,12 @@ function RoadmapFilterBar({ scopes, scopeFilter, layerFilter, navigate, view, on
|
|
|
179
202
|
allItems: Item[];
|
|
180
203
|
search: string;
|
|
181
204
|
onSearchChange: (v: string) => void;
|
|
205
|
+
showArchived: boolean;
|
|
206
|
+
onToggleArchived: () => void;
|
|
207
|
+
archivedCount: number;
|
|
182
208
|
}) {
|
|
183
209
|
const activeScope = scopeFilter && scopeFilter !== GENERAL_SCOPE ? scopes.find((s) => s.id === scopeFilter) : undefined;
|
|
184
|
-
const counts = { now: 0, next: 0, later: 0, done: 0 };
|
|
210
|
+
const counts = { now: 0, next: 0, later: 0, done: 0, dropped: 0 };
|
|
185
211
|
for (const i of allItems) counts[effectiveStatus(i)]++;
|
|
186
212
|
|
|
187
213
|
return (
|
|
@@ -242,6 +268,18 @@ function RoadmapFilterBar({ scopes, scopeFilter, layerFilter, navigate, view, on
|
|
|
242
268
|
aria-label="Search tasks by title"
|
|
243
269
|
/>
|
|
244
270
|
|
|
271
|
+
{archivedCount > 0 && (
|
|
272
|
+
<button
|
|
273
|
+
className={`${styles.archiveBtn} ${showArchived ? styles.archiveBtnActive : ''}`}
|
|
274
|
+
onClick={onToggleArchived}
|
|
275
|
+
aria-pressed={showArchived}
|
|
276
|
+
title={showArchived ? 'Hide archived items' : `Show ${archivedCount} archived item${archivedCount === 1 ? '' : 's'}`}
|
|
277
|
+
>
|
|
278
|
+
<Archive size={13} strokeWidth={1.75} />
|
|
279
|
+
{archivedCount}
|
|
280
|
+
</button>
|
|
281
|
+
)}
|
|
282
|
+
|
|
245
283
|
{/* Counts — far right */}
|
|
246
284
|
<div className={styles.filterSpacer} />
|
|
247
285
|
<div className={styles.counts}>
|
package/src/ui/SummaryStrip.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import { effectiveStatus } from '../api/lib/phases';
|
|
|
3
3
|
import styles from './SummaryStrip.module.css';
|
|
4
4
|
|
|
5
5
|
export function SummaryStrip({ items }: { items: Item[] }) {
|
|
6
|
-
const counts = { now: 0, next: 0, later: 0, done: 0 };
|
|
6
|
+
const counts = { now: 0, next: 0, later: 0, done: 0, dropped: 0 };
|
|
7
7
|
for (const i of items) counts[effectiveStatus(i)]++;
|
|
8
8
|
return (
|
|
9
9
|
<div className={styles.strip}>
|