tenbo-dashboard 0.1.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.md +32 -0
- package/bin/tenbo-dashboard.mjs +67 -0
- package/index.html +12 -0
- package/package.json +75 -0
- package/scripts/compute-metrics.test.ts +35 -0
- package/scripts/compute-metrics.ts +46 -0
- package/scripts/next-id.test.ts +128 -0
- package/scripts/next-id.ts +183 -0
- package/scripts/validate-cli.test.ts +152 -0
- package/scripts/validate-cli.ts +181 -0
- package/src/App.tsx +152 -0
- package/src/ErrorBoundary.tsx +25 -0
- package/src/api/client.ts +41 -0
- package/src/api/lib/frontmatterScan.ts +37 -0
- package/src/api/lib/health/agingTodos.test.ts +32 -0
- package/src/api/lib/health/agingTodos.ts +97 -0
- package/src/api/lib/health/archCompliance.test.ts +26 -0
- package/src/api/lib/health/archCompliance.ts +53 -0
- package/src/api/lib/health/collectAll.test.ts +49 -0
- package/src/api/lib/health/collectAll.ts +78 -0
- package/src/api/lib/health/config.test.ts +45 -0
- package/src/api/lib/health/config.ts +54 -0
- package/src/api/lib/health/coupling.test.ts +40 -0
- package/src/api/lib/health/coupling.ts +66 -0
- package/src/api/lib/health/deadCode.test.ts +23 -0
- package/src/api/lib/health/deadCode.ts +76 -0
- package/src/api/lib/health/docDrift.test.ts +54 -0
- package/src/api/lib/health/docDrift.ts +99 -0
- package/src/api/lib/health/hotspotFiles.test.ts +49 -0
- package/src/api/lib/health/hotspotFiles.ts +70 -0
- package/src/api/lib/health/importGraph.test.ts +29 -0
- package/src/api/lib/health/importGraph.ts +51 -0
- package/src/api/lib/health/layerFiles.test.ts +46 -0
- package/src/api/lib/health/layerFiles.ts +40 -0
- package/src/api/lib/health/redundancy.test.ts +24 -0
- package/src/api/lib/health/redundancy.ts +162 -0
- package/src/api/lib/health/testCoverage.test.ts +33 -0
- package/src/api/lib/health/testCoverage.ts +55 -0
- package/src/api/lib/health/types.test.ts +29 -0
- package/src/api/lib/health/types.ts +85 -0
- package/src/api/lib/http.ts +34 -0
- package/src/api/lib/metrics.test.ts +81 -0
- package/src/api/lib/metrics.ts +78 -0
- package/src/api/lib/metricsRefresh.test.ts +54 -0
- package/src/api/lib/metricsRefresh.ts +147 -0
- package/src/api/lib/phases.test.ts +76 -0
- package/src/api/lib/phases.ts +48 -0
- package/src/api/lib/relationships.ts +65 -0
- package/src/api/lib/repoRoot.ts +12 -0
- package/src/api/lib/tenboFs.layerContent.test.ts +36 -0
- package/src/api/lib/tenboFs.ts +237 -0
- package/src/api/lib/tenboFs.v2.test.ts +86 -0
- package/src/api/lib/tenboFs.workspaceContent.test.ts +42 -0
- package/src/api/lib/validator.docUpdate.test.ts +99 -0
- package/src/api/lib/validator.phases.test.ts +98 -0
- package/src/api/lib/validator.relationships.test.ts +126 -0
- package/src/api/lib/validator.specLinks.test.ts +96 -0
- package/src/api/lib/validator.ts +311 -0
- package/src/api/lib/validator.v2.test.ts +55 -0
- package/src/api/lib/yamlOrdered.ts +60 -0
- package/src/api/plugin.ts +31 -0
- package/src/api/routes/items.ts +25 -0
- package/src/api/routes/layerContent.ts +22 -0
- package/src/api/routes/layerDocs.ts +22 -0
- package/src/api/routes/open.ts +18 -0
- package/src/api/routes/related.ts +11 -0
- package/src/api/routes/reorder.ts +15 -0
- package/src/api/routes/state.ts +23 -0
- package/src/api/routes/watch.ts +31 -0
- package/src/css-modules.d.ts +4 -0
- package/src/hooks/useApiPatch.ts +9 -0
- package/src/hooks/useLayerContent.ts +25 -0
- package/src/hooks/usePrompt.ts +23 -0
- package/src/hooks/useTenboState.ts +35 -0
- package/src/main.tsx +13 -0
- package/src/prompts/populateLayer.ts +12 -0
- package/src/router/routes.ts +98 -0
- package/src/router/useHashRoute.test.ts +67 -0
- package/src/router/useHashRoute.ts +15 -0
- package/src/styles.css +228 -0
- package/src/test-setup.ts +1 -0
- package/src/types.ts +202 -0
- package/src/ui/DotGrid.module.css +7 -0
- package/src/ui/DotGrid.tsx +87 -0
- package/src/ui/EmptyState.module.css +11 -0
- package/src/ui/EmptyState.tsx +10 -0
- package/src/ui/FindingModal/EvidenceSection.tsx +51 -0
- package/src/ui/FindingModal/FindingModal.module.css +31 -0
- package/src/ui/FindingModal/FindingModal.test.tsx +36 -0
- package/src/ui/FindingModal/HeadlineSection.tsx +17 -0
- package/src/ui/FindingModal/RelatedFindingsSection.tsx +29 -0
- package/src/ui/FindingModal/SuggestionSection.tsx +13 -0
- package/src/ui/FindingModal/index.tsx +36 -0
- package/src/ui/HealthPage/Digest.tsx +29 -0
- package/src/ui/HealthPage/FindingRow.tsx +21 -0
- package/src/ui/HealthPage/HealthPage.module.css +45 -0
- package/src/ui/HealthPage/HealthPage.test.tsx +49 -0
- package/src/ui/HealthPage/HealthPage.tsx +66 -0
- package/src/ui/HealthPage/LayerCard.tsx +36 -0
- package/src/ui/HealthPage/SeverityIcon.tsx +29 -0
- package/src/ui/HealthPage/SeverityLegend.tsx +27 -0
- package/src/ui/HealthPage/severity.test.ts +46 -0
- package/src/ui/HealthPage/severity.ts +21 -0
- package/src/ui/ItemCard.module.css +131 -0
- package/src/ui/ItemCard.tsx +117 -0
- package/src/ui/ItemModal/DescriptionField.tsx +22 -0
- package/src/ui/ItemModal/EnrichmentSections.tsx +49 -0
- package/src/ui/ItemModal/ItemModal.module.css +200 -0
- package/src/ui/ItemModal/LinksSection.tsx +26 -0
- package/src/ui/ItemModal/NotesSection.tsx +18 -0
- package/src/ui/ItemModal/PhasesSection.tsx +79 -0
- package/src/ui/ItemModal/RelatedSection.tsx +26 -0
- package/src/ui/ItemModal/RelationshipsSection.tsx +69 -0
- package/src/ui/ItemModal/StatusLayerControls.tsx +43 -0
- package/src/ui/ItemModal/TitleField.tsx +21 -0
- package/src/ui/ItemModal/index.tsx +117 -0
- package/src/ui/KanbanColumn.module.css +20 -0
- package/src/ui/KanbanColumn.tsx +36 -0
- package/src/ui/LayerDetailPage/LayerDetailPage.module.css +11 -0
- package/src/ui/LayerDetailPage/LayerDetailPage.test.tsx +30 -0
- package/src/ui/LayerDetailPage/LayerDetailPage.tsx +59 -0
- package/src/ui/LayerDetailPage/SignalSection.tsx +33 -0
- package/src/ui/LayerDrawer.module.css +60 -0
- package/src/ui/LayerDrawer.tsx +69 -0
- package/src/ui/LayerKanban.module.css +45 -0
- package/src/ui/LayerKanban.tsx +65 -0
- package/src/ui/LayerPage/FillThisInButton.tsx +11 -0
- package/src/ui/LayerPage/LayerPage.module.css +52 -0
- package/src/ui/LayerPage/LayerPage.tsx +76 -0
- package/src/ui/LayerPage/MetricsBadges.tsx +18 -0
- package/src/ui/LayerPage/isTemplateOnly.test.ts +45 -0
- package/src/ui/LayerPage/isTemplateOnly.ts +19 -0
- package/src/ui/LayerPage/tabs/CodeMapTab.tsx +19 -0
- package/src/ui/LayerPage/tabs/DeepDivesTab.tsx +35 -0
- package/src/ui/LayerPage/tabs/IntentTab.tsx +19 -0
- package/src/ui/LayerPage/tabs/PlainEnglishTab.tsx +19 -0
- package/src/ui/Markdown.module.css +115 -0
- package/src/ui/Markdown.tsx +27 -0
- package/src/ui/RoadmapPage/RoadmapPage.module.css +93 -0
- package/src/ui/RoadmapPage/RoadmapPage.tsx +255 -0
- package/src/ui/ScopePage/ScopePage.module.css +72 -0
- package/src/ui/ScopePage/ScopePage.tsx +131 -0
- package/src/ui/SummaryStrip.module.css +18 -0
- package/src/ui/SummaryStrip.tsx +16 -0
- package/src/ui/TaskList.module.css +136 -0
- package/src/ui/TaskList.tsx +91 -0
- package/src/ui/TopBar.module.css +78 -0
- package/src/ui/TopBar.tsx +48 -0
- package/src/ui/WorkspacePage/WorkspacePage.module.css +24 -0
- package/src/ui/WorkspacePage/WorkspacePage.tsx +36 -0
- package/src/ui/WorkspacePage/tabs/GlossaryTab.tsx +6 -0
- package/src/ui/WorkspacePage/tabs/OverviewTab.tsx +28 -0
- package/src/ui/WorkspacePage/tabs/PrinciplesTab.tsx +6 -0
- package/tsconfig.json +18 -0
- package/vite.config.ts +9 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { Finding } from './types';
|
|
3
|
+
import { SEVERITY_RANK, SIGNAL_WEIGHTS_DEFAULT } from './types';
|
|
4
|
+
|
|
5
|
+
describe('health types', () => {
|
|
6
|
+
it('SEVERITY_RANK orders critical > warning > info', () => {
|
|
7
|
+
expect(SEVERITY_RANK.critical).toBeGreaterThan(SEVERITY_RANK.warning);
|
|
8
|
+
expect(SEVERITY_RANK.warning).toBeGreaterThan(SEVERITY_RANK.info);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('SIGNAL_WEIGHTS_DEFAULT places dead-code first', () => {
|
|
12
|
+
expect(SIGNAL_WEIGHTS_DEFAULT[0]).toBe('dead-code');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('Finding type has the expected shape', () => {
|
|
16
|
+
const f: Finding = {
|
|
17
|
+
id: 'x.dead-code.foo',
|
|
18
|
+
signal: 'dead-code',
|
|
19
|
+
severity: 'critical',
|
|
20
|
+
confidence: 'high',
|
|
21
|
+
layer: 'x',
|
|
22
|
+
target: 'apps/editor/foo.ts',
|
|
23
|
+
headline: 'foo has no consumers',
|
|
24
|
+
suggestion: { summary: 'Delete file', rationale: 'unused', action_kind: 'delete-file' },
|
|
25
|
+
details: { kind: 'dead-code', exports: [], last_imported_commit: null, git_age_days: 0 },
|
|
26
|
+
};
|
|
27
|
+
expect(f.signal).toBe('dead-code');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export type Severity = 'critical' | 'warning' | 'info';
|
|
2
|
+
|
|
3
|
+
export type Confidence = 'high' | 'medium' | 'low';
|
|
4
|
+
|
|
5
|
+
export type Signal =
|
|
6
|
+
| 'hotspot-files'
|
|
7
|
+
| 'dead-code'
|
|
8
|
+
| 'coupling'
|
|
9
|
+
| 'doc-drift'
|
|
10
|
+
| 'test-coverage'
|
|
11
|
+
| 'aging-todos'
|
|
12
|
+
| 'architecture-compliance'
|
|
13
|
+
| 'redundancy';
|
|
14
|
+
|
|
15
|
+
export type ActionKind =
|
|
16
|
+
| 'delete-file'
|
|
17
|
+
| 'unexport'
|
|
18
|
+
| 'split-file'
|
|
19
|
+
| 'extract-shared'
|
|
20
|
+
| 'move-file'
|
|
21
|
+
| 'update-doc'
|
|
22
|
+
| 'add-test'
|
|
23
|
+
| 'resolve-todo';
|
|
24
|
+
|
|
25
|
+
export interface Suggestion {
|
|
26
|
+
summary: string; // imperative one-liner
|
|
27
|
+
rationale: string; // why
|
|
28
|
+
action_kind: ActionKind;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Signal-specific details payloads (discriminated union)
|
|
32
|
+
export type FindingDetails =
|
|
33
|
+
| { kind: 'hotspot-files'; loc: number; top_functions: { name: string; loc: number }[]; commits_30d: number; split_candidates: string[] }
|
|
34
|
+
| { kind: 'dead-code'; exports: string[]; last_imported_commit: string | null; git_age_days: number }
|
|
35
|
+
| { kind: 'coupling'; source_file: string; target_file: string; import_lines: number[]; crosses_public_api: boolean }
|
|
36
|
+
| { 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
|
+
| { kind: 'test-coverage'; suggested_test_path: string }
|
|
38
|
+
| { kind: 'aging-todos'; line: number; age_days: number; commit_hash: string; author: string; text: string; context: string }
|
|
39
|
+
| { kind: 'architecture-compliance'; expected_path_pattern: string; actual_path: string; rule: string }
|
|
40
|
+
| { kind: 'redundancy'; copies: { path: string; lines: [number, number] }[]; similarity_pct: number };
|
|
41
|
+
|
|
42
|
+
export interface Finding {
|
|
43
|
+
id: string; // "<layer>.<signal>.<short-target>"
|
|
44
|
+
signal: Signal;
|
|
45
|
+
severity: Severity;
|
|
46
|
+
confidence: Confidence;
|
|
47
|
+
layer: string;
|
|
48
|
+
target: string; // primary file path (repo-relative)
|
|
49
|
+
headline: string;
|
|
50
|
+
suggestion: Suggestion;
|
|
51
|
+
details: FindingDetails;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const SEVERITY_RANK: Record<Severity, number> = {
|
|
55
|
+
critical: 3,
|
|
56
|
+
warning: 2,
|
|
57
|
+
info: 1,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const SIGNAL_WEIGHTS_DEFAULT: Signal[] = [
|
|
61
|
+
'dead-code',
|
|
62
|
+
'hotspot-files',
|
|
63
|
+
'coupling',
|
|
64
|
+
'architecture-compliance',
|
|
65
|
+
'redundancy',
|
|
66
|
+
'doc-drift',
|
|
67
|
+
'test-coverage',
|
|
68
|
+
'aging-todos',
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
export const CONFIDENCE_RANK: Record<Confidence, number> = {
|
|
72
|
+
high: 3,
|
|
73
|
+
medium: 2,
|
|
74
|
+
low: 1,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Compile-time guarantee that FindingDetails has exactly one variant per Signal.
|
|
78
|
+
// If Signal gains a value or FindingDetails loses one (or vice versa),
|
|
79
|
+
// these aliases will fail to type-check and the build breaks.
|
|
80
|
+
type _SignalsCoverDetails = Exclude<Signal, FindingDetails['kind']> extends never ? true : never;
|
|
81
|
+
type _DetailsCoverSignals = Exclude<FindingDetails['kind'], Signal> extends never ? true : never;
|
|
82
|
+
const _signalCoverage: _SignalsCoverDetails = true;
|
|
83
|
+
const _detailsCoverage: _DetailsCoverSignals = true;
|
|
84
|
+
void _signalCoverage;
|
|
85
|
+
void _detailsCoverage;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
|
|
3
|
+
export function readBody<T = unknown>(req: IncomingMessage): Promise<T> {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
let buf = '';
|
|
6
|
+
req.on('data', (c: Buffer) => (buf += c));
|
|
7
|
+
req.on('end', () => {
|
|
8
|
+
try { resolve(JSON.parse(buf || '{}') as T); } catch (e) { reject(e); }
|
|
9
|
+
});
|
|
10
|
+
req.on('error', reject);
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function json(res: ServerResponse, body: unknown, status = 200): void {
|
|
15
|
+
res.statusCode = status;
|
|
16
|
+
res.setHeader('Content-Type', 'application/json');
|
|
17
|
+
res.end(JSON.stringify(body));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function error(res: ServerResponse, status: number, message: string): void {
|
|
21
|
+
json(res, { error: message }, status);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Wraps a handler so any thrown error becomes a 500 JSON response. */
|
|
25
|
+
export function withErrorHandling<T extends (...args: any[]) => any>(handler: T): T {
|
|
26
|
+
return (async (...args: Parameters<T>) => {
|
|
27
|
+
const [, res] = args as unknown as [IncomingMessage, ServerResponse];
|
|
28
|
+
try {
|
|
29
|
+
return await handler(...args);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
error(res, 500, String(err));
|
|
32
|
+
}
|
|
33
|
+
}) as T;
|
|
34
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { describe, it, expect } from 'vitest';
|
|
5
|
+
import { computeScopeMetrics, globMatches } from './metrics';
|
|
6
|
+
import type { Scope } from '../../types';
|
|
7
|
+
|
|
8
|
+
function tmp(structure: Record<string, string>) {
|
|
9
|
+
const root = mkdtempSync(path.join(tmpdir(), 'tenbo-metrics-'));
|
|
10
|
+
for (const [rel, content] of Object.entries(structure)) {
|
|
11
|
+
const full = path.join(root, rel);
|
|
12
|
+
mkdirSync(path.dirname(full), { recursive: true });
|
|
13
|
+
writeFileSync(full, content);
|
|
14
|
+
}
|
|
15
|
+
return root;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('computeScopeMetrics', () => {
|
|
19
|
+
it('counts files matching glob and totals lines', () => {
|
|
20
|
+
const root = tmp({
|
|
21
|
+
'src/a.ts': 'one\ntwo\nthree\n',
|
|
22
|
+
'src/b.ts': 'one\n',
|
|
23
|
+
});
|
|
24
|
+
const scope: Scope = {
|
|
25
|
+
id: 'e', path: '.', description: 'x',
|
|
26
|
+
layers: [{ id: 'l', name: 'L', description: 'x', files: ['src/**/*.ts'], dependencies: { outbound: ['x'] } } as any],
|
|
27
|
+
items: [],
|
|
28
|
+
};
|
|
29
|
+
const m = computeScopeMetrics(root, scope, {});
|
|
30
|
+
expect(m.layers.l.file_count).toBe(2);
|
|
31
|
+
expect(m.layers.l.total_lines).toBe(4);
|
|
32
|
+
expect(m.layers.l.outbound_deps).toBe(1);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('computes intent_age_days from layerDocs mtime', () => {
|
|
36
|
+
const sevenDaysAgo = Date.now() - 7 * 24 * 3600 * 1000;
|
|
37
|
+
const m = computeScopeMetrics('/x', {
|
|
38
|
+
id: 'e', path: '.', description: 'x',
|
|
39
|
+
layers: [{ id: 'l', name: 'L', description: 'x', files: [] } as any],
|
|
40
|
+
items: [],
|
|
41
|
+
}, { 'e/l': { hasIntent: true, hasCodeMap: false, intentMtime: sevenDaysAgo, codeMapMtime: null, intentEmpty: false } });
|
|
42
|
+
expect(m.layers.l.intent_age_days).toBe(7);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('matches `src/**/*.ts` across zero and many intermediate segments', () => {
|
|
46
|
+
const root = tmp({
|
|
47
|
+
'src/a.ts': 'x\n',
|
|
48
|
+
'src/sub/b.ts': 'x\n',
|
|
49
|
+
'src/sub/deep/c.ts': 'x\n',
|
|
50
|
+
});
|
|
51
|
+
const scope: Scope = {
|
|
52
|
+
id: 'e', path: '.', description: 'x',
|
|
53
|
+
layers: [{ id: 'l', name: 'L', description: 'x', files: ['src/**/*.ts'] } as any],
|
|
54
|
+
items: [],
|
|
55
|
+
};
|
|
56
|
+
const m = computeScopeMetrics(root, scope, {});
|
|
57
|
+
expect(m.layers.l.file_count).toBe(3);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('globMatches', () => {
|
|
62
|
+
it('handles `src/**/*.ts` with zero or many segments', () => {
|
|
63
|
+
expect(globMatches('src/**/*.ts', 'src/a.ts')).toBe(true);
|
|
64
|
+
expect(globMatches('src/**/*.ts', 'src/sub/a.ts')).toBe(true);
|
|
65
|
+
expect(globMatches('src/**/*.ts', 'src/sub/deep/a.ts')).toBe(true);
|
|
66
|
+
expect(globMatches('src/**/*.ts', 'other/a.ts')).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('handles leading `**/` matching root', () => {
|
|
70
|
+
expect(globMatches('**/foo.ts', 'foo.ts')).toBe(true);
|
|
71
|
+
expect(globMatches('**/foo.ts', 'dir/foo.ts')).toBe(true);
|
|
72
|
+
expect(globMatches('**/foo.ts', 'dir/sub/foo.ts')).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('honors `*` segment-non-crossing', () => {
|
|
76
|
+
expect(globMatches('src/*.ts', 'src/a.ts')).toBe(true);
|
|
77
|
+
expect(globMatches('src/*.ts', 'src/sub/a.ts')).toBe(false);
|
|
78
|
+
expect(globMatches('*.ts', 'a.ts')).toBe(true);
|
|
79
|
+
expect(globMatches('*.ts', 'sub/a.ts')).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { LayerDocs, Scope, ScopeMetrics, LayerMetrics } from '../../types';
|
|
4
|
+
|
|
5
|
+
function matchGlob(repoRoot: string, scopePath: string, globs: string[]): string[] {
|
|
6
|
+
const root = path.resolve(repoRoot, scopePath);
|
|
7
|
+
const matches: string[] = [];
|
|
8
|
+
if (!existsSync(root)) return matches;
|
|
9
|
+
function walk(dir: string) {
|
|
10
|
+
let entries: string[];
|
|
11
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
12
|
+
for (const entry of entries.sort()) {
|
|
13
|
+
const full = path.join(dir, entry);
|
|
14
|
+
let st;
|
|
15
|
+
try { st = statSync(full); } catch { continue; }
|
|
16
|
+
if (st.isDirectory()) walk(full);
|
|
17
|
+
else {
|
|
18
|
+
const rel = path.relative(root, full).split(path.sep).join('/');
|
|
19
|
+
if (globs.some(g => globMatches(g, rel))) matches.push(full);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
walk(root);
|
|
24
|
+
return matches;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function globMatches(glob: string, rel: string): boolean {
|
|
28
|
+
// Convert glob to RegExp. `/**/` collapses to "any number of path segments
|
|
29
|
+
// including zero" so `src/**/*.ts` matches both `src/a.ts` and `src/foo/a.ts`.
|
|
30
|
+
// A leading `**/` similarly collapses, so `**/foo.ts` matches both `foo.ts`
|
|
31
|
+
// (root) and `dir/foo.ts`. Bare `**` becomes `.*`; `*` becomes `[^/]*`.
|
|
32
|
+
// Placeholders protect each transformation from later passes.
|
|
33
|
+
const escaped = glob
|
|
34
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
35
|
+
.replace(/^\*\*\//, '__LEADGLOBSTAR__')
|
|
36
|
+
.replace(/\/\*\*\//g, '__SLASHGLOBSTARSLASH__')
|
|
37
|
+
.replace(/\*\*/g, '__GLOBSTAR__')
|
|
38
|
+
.replace(/\*/g, '[^/]*')
|
|
39
|
+
.replace(/__GLOBSTAR__/g, '.*')
|
|
40
|
+
.replace(/__SLASHGLOBSTARSLASH__/g, '(?:/|/.*/)')
|
|
41
|
+
.replace(/__LEADGLOBSTAR__/g, '(?:|.*/)');
|
|
42
|
+
return new RegExp('^' + escaped + '$').test(rel);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function lineCount(filePath: string): number {
|
|
46
|
+
const content = readFileSync(filePath, 'utf8');
|
|
47
|
+
if (content.length === 0) return 0;
|
|
48
|
+
return content.split('\n').length - (content.endsWith('\n') ? 1 : 0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function computeScopeMetrics(
|
|
52
|
+
repoRoot: string,
|
|
53
|
+
scope: Scope,
|
|
54
|
+
layerDocs: Record<string, LayerDocs>,
|
|
55
|
+
): ScopeMetrics {
|
|
56
|
+
const layers: Record<string, LayerMetrics> = {};
|
|
57
|
+
for (const layer of scope.layers) {
|
|
58
|
+
const matched = matchGlob(repoRoot, scope.path, layer.files ?? []);
|
|
59
|
+
const totalLines = matched.reduce((sum, f) => sum + lineCount(f), 0);
|
|
60
|
+
const outbound = layer.dependencies?.outbound?.length ?? 0;
|
|
61
|
+
const docs = layerDocs[`${scope.id}/${layer.id}`];
|
|
62
|
+
const intentAgeDays = docs?.intentMtime != null
|
|
63
|
+
? Math.floor((Date.now() - docs.intentMtime) / (24 * 3600 * 1000))
|
|
64
|
+
: null;
|
|
65
|
+
const layerItems = scope.items.filter(i => i.layer === layer.id || i.layers?.includes(layer.id));
|
|
66
|
+
const nowItems = layerItems.filter(i => i.status === 'now').length;
|
|
67
|
+
const pctNow = layerItems.length === 0 ? 0 : Math.round((nowItems / layerItems.length) * 100);
|
|
68
|
+
layers[layer.id] = {
|
|
69
|
+
file_count: matched.length,
|
|
70
|
+
total_lines: totalLines,
|
|
71
|
+
outbound_deps: outbound,
|
|
72
|
+
deep_dive_count: 0, // populated by readDeepDiveCount when wired up
|
|
73
|
+
intent_age_days: intentAgeDays,
|
|
74
|
+
pct_roadmap_in_now: pctNow,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return { generated_at: new Date().toISOString(), layers, findings: [] };
|
|
78
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, statSync, utimesSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { ensureFresh } from './metricsRefresh.js';
|
|
6
|
+
|
|
7
|
+
let dir: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
dir = mkdtempSync(path.join(tmpdir(), 'tenbo-mr-'));
|
|
11
|
+
const scope = path.join(dir, '.tenbo', 'scopes', 'editor');
|
|
12
|
+
const layer = path.join(scope, 'layers', 'foo');
|
|
13
|
+
mkdirSync(layer, { recursive: true });
|
|
14
|
+
writeFileSync(path.join(dir, '.tenbo', 'workspace.yaml'), 'scopes:\n - id: editor\n path: scopes/editor\n');
|
|
15
|
+
writeFileSync(path.join(scope, 'architecture.yaml'), 'scope: editor\nlayers:\n - id: foo\n name: Foo\n description: A small layer.\n files: ["src/**"]\n dependencies: { inbound: [], outbound: [], external: [] }\n');
|
|
16
|
+
writeFileSync(path.join(scope, 'roadmap.yaml'), 'items: []\n');
|
|
17
|
+
writeFileSync(path.join(layer, 'intent.md'), '# Foo\n');
|
|
18
|
+
writeFileSync(path.join(layer, 'code-map.md'), '# Foo map\n');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
22
|
+
|
|
23
|
+
describe('ensureFresh', () => {
|
|
24
|
+
it('writes metrics.json when it is missing', async () => {
|
|
25
|
+
const metricsPath = path.join(dir, '.tenbo', 'scopes', 'editor', 'metrics.json');
|
|
26
|
+
expect(existsSync(metricsPath)).toBe(false);
|
|
27
|
+
const result = await ensureFresh(dir, 'editor');
|
|
28
|
+
expect(existsSync(metricsPath)).toBe(true);
|
|
29
|
+
expect(result.layers).toBeDefined();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('does not rewrite metrics.json when it is fresh', async () => {
|
|
33
|
+
const metricsPath = path.join(dir, '.tenbo', 'scopes', 'editor', 'metrics.json');
|
|
34
|
+
await ensureFresh(dir, 'editor');
|
|
35
|
+
const firstMtime = statSync(metricsPath).mtimeMs;
|
|
36
|
+
const future = new Date(firstMtime + 1000);
|
|
37
|
+
utimesSync(metricsPath, future, future);
|
|
38
|
+
const newMtime = statSync(metricsPath).mtimeMs;
|
|
39
|
+
await ensureFresh(dir, 'editor');
|
|
40
|
+
expect(statSync(metricsPath).mtimeMs).toBe(newMtime);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('rewrites metrics.json when a tracked file is newer', async () => {
|
|
44
|
+
const metricsPath = path.join(dir, '.tenbo', 'scopes', 'editor', 'metrics.json');
|
|
45
|
+
await ensureFresh(dir, 'editor');
|
|
46
|
+
// Age metrics.json well into the past so the staleness check fires.
|
|
47
|
+
const stale = new Date(Date.now() - 60_000);
|
|
48
|
+
utimesSync(metricsPath, stale, stale);
|
|
49
|
+
const aged = statSync(metricsPath).mtimeMs;
|
|
50
|
+
await ensureFresh(dir, 'editor');
|
|
51
|
+
const after = statSync(metricsPath).mtimeMs;
|
|
52
|
+
expect(after).toBeGreaterThan(aged);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { parse as parseSimple } from 'yaml';
|
|
4
|
+
import { computeScopeMetrics } from './metrics.js';
|
|
5
|
+
import { collectAll } from './health/collectAll.js';
|
|
6
|
+
import { loadHealthConfig } from './health/config.js';
|
|
7
|
+
import type { LayerDocs, Scope, ScopeMetrics, Item, Layer } from '../../types.js';
|
|
8
|
+
|
|
9
|
+
const TRACKED_FILES = ['architecture.yaml', 'roadmap.yaml'];
|
|
10
|
+
const TRACKED_LAYER_FILES = ['intent.md', 'code-map.md'];
|
|
11
|
+
const FS_MTIME_TOLERANCE_MS = 1000;
|
|
12
|
+
|
|
13
|
+
function safeMtime(p: string): number {
|
|
14
|
+
try { return statSync(p).mtimeMs; } catch { return 0; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function latestMtimeUnder(scopeDir: string, repoRoot: string, scopePath: string): number {
|
|
18
|
+
let latest = 0;
|
|
19
|
+
for (const f of TRACKED_FILES) {
|
|
20
|
+
latest = Math.max(latest, safeMtime(path.join(scopeDir, f)));
|
|
21
|
+
}
|
|
22
|
+
const layersDir = path.join(scopeDir, 'layers');
|
|
23
|
+
let layerEntries: string[] = [];
|
|
24
|
+
try { layerEntries = readdirSync(layersDir); } catch { layerEntries = []; }
|
|
25
|
+
for (const layer of layerEntries) {
|
|
26
|
+
const layerDir = path.join(layersDir, layer);
|
|
27
|
+
let isDir = false;
|
|
28
|
+
try { isDir = statSync(layerDir).isDirectory(); } catch { continue; }
|
|
29
|
+
if (!isDir) continue;
|
|
30
|
+
for (const f of TRACKED_LAYER_FILES) {
|
|
31
|
+
latest = Math.max(latest, safeMtime(path.join(layerDir, f)));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Health findings depend on the scope's source files and the health config.
|
|
35
|
+
// Without these, edits to source code (or threshold tuning) would be served
|
|
36
|
+
// from a stale metrics.json cache.
|
|
37
|
+
latest = Math.max(latest, safeMtime(path.join(repoRoot, '.tenbo', 'health.config.yaml')));
|
|
38
|
+
latest = Math.max(latest, latestMtimeInTree(path.resolve(repoRoot, scopePath)));
|
|
39
|
+
return latest;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function latestMtimeInTree(root: string): number {
|
|
43
|
+
let latest = 0;
|
|
44
|
+
let entries: string[];
|
|
45
|
+
try { entries = readdirSync(root); } catch { return 0; }
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
if (entry === 'node_modules' || entry === '.git' || entry === 'dist' || entry === 'build') continue;
|
|
48
|
+
const full = path.join(root, entry);
|
|
49
|
+
let st;
|
|
50
|
+
try { st = statSync(full); } catch { continue; }
|
|
51
|
+
if (st.isDirectory()) {
|
|
52
|
+
latest = Math.max(latest, latestMtimeInTree(full));
|
|
53
|
+
} else {
|
|
54
|
+
latest = Math.max(latest, st.mtimeMs);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return latest;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readScope(repoRoot: string, scopeId: string): Scope {
|
|
61
|
+
const tenbo = path.join(repoRoot, '.tenbo');
|
|
62
|
+
const wsText = readFileSync(path.join(tenbo, 'workspace.yaml'), 'utf8');
|
|
63
|
+
const ws = parseSimple(wsText) as { scopes?: { id: string; path: string; description?: string }[] };
|
|
64
|
+
const ref = ws.scopes?.find(s => s.id === scopeId);
|
|
65
|
+
if (!ref) throw new Error(`scope not found in workspace.yaml: ${scopeId}`);
|
|
66
|
+
const archText = readFileSync(path.join(tenbo, 'scopes', scopeId, 'architecture.yaml'), 'utf8');
|
|
67
|
+
const arch = parseSimple(archText) as { layers?: Layer[] };
|
|
68
|
+
const roadText = readFileSync(path.join(tenbo, 'scopes', scopeId, 'roadmap.yaml'), 'utf8');
|
|
69
|
+
const road = parseSimple(roadText) as { items?: Item[] };
|
|
70
|
+
return {
|
|
71
|
+
id: ref.id,
|
|
72
|
+
path: ref.path,
|
|
73
|
+
description: ref.description ?? '',
|
|
74
|
+
layers: arch.layers ?? [],
|
|
75
|
+
items: road.items ?? [],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readLayerDocsForScope(repoRoot: string, scopeId: string): Record<string, LayerDocs> {
|
|
80
|
+
const dir = path.join(repoRoot, '.tenbo', 'scopes', scopeId, 'layers');
|
|
81
|
+
if (!existsSync(dir)) return {};
|
|
82
|
+
const out: Record<string, LayerDocs> = {};
|
|
83
|
+
for (const entry of readdirSync(dir)) {
|
|
84
|
+
const full = path.join(dir, entry);
|
|
85
|
+
let entrySt;
|
|
86
|
+
try { entrySt = statSync(full); } catch { continue; }
|
|
87
|
+
if (!entrySt.isDirectory()) continue;
|
|
88
|
+
const intentPath = path.join(full, 'intent.md');
|
|
89
|
+
const codeMapPath = path.join(full, 'code-map.md');
|
|
90
|
+
const intentExists = existsSync(intentPath);
|
|
91
|
+
const codeMapExists = existsSync(codeMapPath);
|
|
92
|
+
let intentEmpty = false;
|
|
93
|
+
if (intentExists) {
|
|
94
|
+
try {
|
|
95
|
+
const stripped = readFileSync(intentPath, 'utf8')
|
|
96
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
97
|
+
.replace(/^#.*$/gm, '')
|
|
98
|
+
.trim();
|
|
99
|
+
intentEmpty = stripped.length === 0;
|
|
100
|
+
} catch {
|
|
101
|
+
intentEmpty = false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
out[`${scopeId}/${entry}`] = {
|
|
105
|
+
hasIntent: intentExists,
|
|
106
|
+
hasCodeMap: codeMapExists,
|
|
107
|
+
intentMtime: intentExists ? statSync(intentPath).mtimeMs : null,
|
|
108
|
+
codeMapMtime: codeMapExists ? statSync(codeMapPath).mtimeMs : null,
|
|
109
|
+
intentEmpty,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface EnsureFreshOptions {
|
|
116
|
+
force?: boolean;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function ensureFresh(repoRoot: string, scopeId: string, opts: EnsureFreshOptions = {}): Promise<ScopeMetrics> {
|
|
120
|
+
const scopeDir = path.join(repoRoot, '.tenbo', 'scopes', scopeId);
|
|
121
|
+
const metricsPath = path.join(scopeDir, 'metrics.json');
|
|
122
|
+
const scope = readScope(repoRoot, scopeId);
|
|
123
|
+
const latestSource = latestMtimeUnder(scopeDir, repoRoot, scope.path);
|
|
124
|
+
const metricsMtime = safeMtime(metricsPath);
|
|
125
|
+
|
|
126
|
+
// Tolerance absorbs filesystem mtime precision loss (some FSes round to seconds)
|
|
127
|
+
// when comparing the metrics.json mtime against the latest tracked source mtime.
|
|
128
|
+
if (!opts.force && metricsMtime > 0 && metricsMtime + FS_MTIME_TOLERANCE_MS >= latestSource) {
|
|
129
|
+
try {
|
|
130
|
+
const raw = readFileSync(metricsPath, 'utf8');
|
|
131
|
+
return JSON.parse(raw) as ScopeMetrics;
|
|
132
|
+
} catch {
|
|
133
|
+
// Fall through to recompute on parse error (truncated/corrupt cache).
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const layerDocs = readLayerDocsForScope(repoRoot, scopeId);
|
|
138
|
+
const baseMetrics = computeScopeMetrics(repoRoot, scope, layerDocs);
|
|
139
|
+
const healthConfig = loadHealthConfig(repoRoot);
|
|
140
|
+
const findings = await collectAll(repoRoot, scope, healthConfig);
|
|
141
|
+
const metrics: ScopeMetrics = { ...baseMetrics, findings };
|
|
142
|
+
// Atomic write: rename is atomic on POSIX so partial writes can't corrupt the cache.
|
|
143
|
+
const tmpPath = metricsPath + '.tmp';
|
|
144
|
+
writeFileSync(tmpPath, JSON.stringify(metrics, null, 2));
|
|
145
|
+
renameSync(tmpPath, metricsPath);
|
|
146
|
+
return metrics;
|
|
147
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { derivePhaseStatus, effectiveStatus, phaseProgress, isIsoDate } from './phases';
|
|
3
|
+
import type { Item, Phase } from '../../types';
|
|
4
|
+
|
|
5
|
+
const ph = (id: number, status: Phase['status'], extra: Partial<Phase> = {}): Phase => ({
|
|
6
|
+
id,
|
|
7
|
+
title: `phase ${id}`,
|
|
8
|
+
status,
|
|
9
|
+
...extra,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe('derivePhaseStatus', () => {
|
|
13
|
+
it('returns done when every phase is done', () => {
|
|
14
|
+
expect(derivePhaseStatus([ph(1, 'done'), ph(2, 'done')])).toBe('done');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('returns now if any phase is now (even with later mixed in)', () => {
|
|
18
|
+
expect(derivePhaseStatus([ph(1, 'done'), ph(2, 'now'), ph(3, 'later')])).toBe('now');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('returns next when no now but some next', () => {
|
|
22
|
+
expect(derivePhaseStatus([ph(1, 'done'), ph(2, 'next'), ph(3, 'later')])).toBe('next');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('returns later when only later/done remain', () => {
|
|
26
|
+
expect(derivePhaseStatus([ph(1, 'done'), ph(2, 'later')])).toBe('later');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns later for an empty list', () => {
|
|
30
|
+
expect(derivePhaseStatus([])).toBe('later');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('effectiveStatus', () => {
|
|
35
|
+
it('uses stored status when phases are absent', () => {
|
|
36
|
+
const item = { id: 'ed-001', title: 't', status: 'next', description: 'd' } as Item;
|
|
37
|
+
expect(effectiveStatus(item)).toBe('next');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('uses derived status when phases are present', () => {
|
|
41
|
+
const item = {
|
|
42
|
+
id: 'ed-001',
|
|
43
|
+
title: 't',
|
|
44
|
+
status: 'later',
|
|
45
|
+
description: 'd',
|
|
46
|
+
phases: [ph(1, 'done'), ph(2, 'now')],
|
|
47
|
+
} as Item;
|
|
48
|
+
expect(effectiveStatus(item)).toBe('now');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('phaseProgress', () => {
|
|
53
|
+
it('counts done and total and surfaces the active phase', () => {
|
|
54
|
+
const phases = [ph(1, 'done'), ph(2, 'now'), ph(3, 'later')];
|
|
55
|
+
const p = phaseProgress(phases);
|
|
56
|
+
expect(p.done).toBe(1);
|
|
57
|
+
expect(p.total).toBe(3);
|
|
58
|
+
expect(p.active?.id).toBe(2);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('omits active when no phase is now', () => {
|
|
62
|
+
const p = phaseProgress([ph(1, 'done'), ph(2, 'next')]);
|
|
63
|
+
expect(p.active).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('isIsoDate', () => {
|
|
68
|
+
it('accepts YYYY-MM-DD', () => {
|
|
69
|
+
expect(isIsoDate('2026-04-27')).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
it('rejects other formats', () => {
|
|
72
|
+
expect(isIsoDate('2026-4-27')).toBe(false);
|
|
73
|
+
expect(isIsoDate('04/27/2026')).toBe(false);
|
|
74
|
+
expect(isIsoDate('2026-04-27T00:00:00Z')).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the optional `phases:` field on roadmap items.
|
|
3
|
+
*
|
|
4
|
+
* Roll-up rule (single source of truth, mirrored in the validator and the viewer):
|
|
5
|
+
* - `done` if every phase has status `done`
|
|
6
|
+
* - `now` if any phase has status `now`
|
|
7
|
+
* - else `next` if any phase has status `next`
|
|
8
|
+
* - else `later`
|
|
9
|
+
*
|
|
10
|
+
* Items without `phases:` are unaffected — their explicit `status:` is the source
|
|
11
|
+
* of truth.
|
|
12
|
+
*/
|
|
13
|
+
import type { Item, Phase, Status } from '../../types';
|
|
14
|
+
|
|
15
|
+
export const VALID_PHASE_STATUSES: ReadonlySet<Status> = new Set(['now', 'next', 'later', 'done']);
|
|
16
|
+
|
|
17
|
+
/** YYYY-MM-DD shape check. Does not validate calendar correctness. */
|
|
18
|
+
export function isIsoDate(s: string): boolean {
|
|
19
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(s);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Compute a derived item status from a non-empty phase list. */
|
|
23
|
+
export function derivePhaseStatus(phases: Phase[]): Status {
|
|
24
|
+
if (phases.length === 0) return 'later';
|
|
25
|
+
if (phases.every((p) => p.status === 'done')) return 'done';
|
|
26
|
+
if (phases.some((p) => p.status === 'now')) return 'now';
|
|
27
|
+
if (phases.some((p) => p.status === 'next')) return 'next';
|
|
28
|
+
return 'later';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Returns the effective status for an item — derived if phases present, else stored. */
|
|
32
|
+
export function effectiveStatus(item: Item): Status {
|
|
33
|
+
if (item.phases && item.phases.length > 0) return derivePhaseStatus(item.phases);
|
|
34
|
+
return item.status;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PhaseProgress {
|
|
38
|
+
done: number;
|
|
39
|
+
total: number;
|
|
40
|
+
/** Phase currently `now`, if any. Useful for highlighting. */
|
|
41
|
+
active?: Phase;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function phaseProgress(phases: Phase[]): PhaseProgress {
|
|
45
|
+
const done = phases.filter((p) => p.status === 'done').length;
|
|
46
|
+
const active = phases.find((p) => p.status === 'now');
|
|
47
|
+
return { done, total: phases.length, ...(active ? { active } : {}) };
|
|
48
|
+
}
|