@seanmars/tospec 0.4.2
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/LICENSE +21 -0
- package/README.md +114 -0
- package/assets/dashboard/app.js +1032 -0
- package/assets/dashboard/chart.umd.js +14 -0
- package/assets/dashboard/index.html +30 -0
- package/assets/dashboard/style.css +675 -0
- package/bin/tospec.js +5 -0
- package/dist/cli/index.d.ts +5 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +330 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/commands/config.d.ts +36 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +486 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/dashboard.d.ts +40 -0
- package/dist/commands/dashboard.d.ts.map +1 -0
- package/dist/commands/dashboard.js +385 -0
- package/dist/commands/dashboard.js.map +1 -0
- package/dist/commands/shared-output.d.ts +24 -0
- package/dist/commands/shared-output.d.ts.map +1 -0
- package/dist/commands/shared-output.js +51 -0
- package/dist/commands/shared-output.js.map +1 -0
- package/dist/commands/show.d.ts +17 -0
- package/dist/commands/show.d.ts.map +1 -0
- package/dist/commands/show.js +164 -0
- package/dist/commands/show.js.map +1 -0
- package/dist/commands/validate.d.ts +51 -0
- package/dist/commands/validate.d.ts.map +1 -0
- package/dist/commands/validate.js +416 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/commands/workflow/index.d.ts +17 -0
- package/dist/commands/workflow/index.d.ts.map +1 -0
- package/dist/commands/workflow/index.js +12 -0
- package/dist/commands/workflow/index.js.map +1 -0
- package/dist/commands/workflow/instructions.d.ts +35 -0
- package/dist/commands/workflow/instructions.d.ts.map +1 -0
- package/dist/commands/workflow/instructions.js +359 -0
- package/dist/commands/workflow/instructions.js.map +1 -0
- package/dist/commands/workflow/new-change.d.ts +17 -0
- package/dist/commands/workflow/new-change.d.ts.map +1 -0
- package/dist/commands/workflow/new-change.js +97 -0
- package/dist/commands/workflow/new-change.js.map +1 -0
- package/dist/commands/workflow/schemas.d.ts +10 -0
- package/dist/commands/workflow/schemas.d.ts.map +1 -0
- package/dist/commands/workflow/schemas.js +33 -0
- package/dist/commands/workflow/schemas.js.map +1 -0
- package/dist/commands/workflow/shared.d.ts +63 -0
- package/dist/commands/workflow/shared.d.ts.map +1 -0
- package/dist/commands/workflow/shared.js +111 -0
- package/dist/commands/workflow/shared.js.map +1 -0
- package/dist/commands/workflow/status.d.ts +14 -0
- package/dist/commands/workflow/status.d.ts.map +1 -0
- package/dist/commands/workflow/status.js +77 -0
- package/dist/commands/workflow/status.js.map +1 -0
- package/dist/commands/workflow/templates.d.ts +16 -0
- package/dist/commands/workflow/templates.d.ts.map +1 -0
- package/dist/commands/workflow/templates.js +60 -0
- package/dist/commands/workflow/templates.js.map +1 -0
- package/dist/core/archive.d.ts +33 -0
- package/dist/core/archive.d.ts.map +1 -0
- package/dist/core/archive.js +585 -0
- package/dist/core/archive.js.map +1 -0
- package/dist/core/artifact-graph/graph.d.ts +56 -0
- package/dist/core/artifact-graph/graph.d.ts.map +1 -0
- package/dist/core/artifact-graph/graph.js +141 -0
- package/dist/core/artifact-graph/graph.js.map +1 -0
- package/dist/core/artifact-graph/index.d.ts +9 -0
- package/dist/core/artifact-graph/index.d.ts.map +1 -0
- package/dist/core/artifact-graph/index.js +14 -0
- package/dist/core/artifact-graph/index.js.map +1 -0
- package/dist/core/artifact-graph/instruction-loader.d.ts +188 -0
- package/dist/core/artifact-graph/instruction-loader.d.ts.map +1 -0
- package/dist/core/artifact-graph/instruction-loader.js +243 -0
- package/dist/core/artifact-graph/instruction-loader.js.map +1 -0
- package/dist/core/artifact-graph/outputs.d.ts +14 -0
- package/dist/core/artifact-graph/outputs.d.ts.map +1 -0
- package/dist/core/artifact-graph/outputs.js +39 -0
- package/dist/core/artifact-graph/outputs.js.map +1 -0
- package/dist/core/artifact-graph/resolver.d.ts +81 -0
- package/dist/core/artifact-graph/resolver.d.ts.map +1 -0
- package/dist/core/artifact-graph/resolver.js +257 -0
- package/dist/core/artifact-graph/resolver.js.map +1 -0
- package/dist/core/artifact-graph/schema.d.ts +13 -0
- package/dist/core/artifact-graph/schema.d.ts.map +1 -0
- package/dist/core/artifact-graph/schema.js +108 -0
- package/dist/core/artifact-graph/schema.js.map +1 -0
- package/dist/core/artifact-graph/state.d.ts +12 -0
- package/dist/core/artifact-graph/state.d.ts.map +1 -0
- package/dist/core/artifact-graph/state.js +31 -0
- package/dist/core/artifact-graph/state.js.map +1 -0
- package/dist/core/artifact-graph/types.d.ts +55 -0
- package/dist/core/artifact-graph/types.d.ts.map +1 -0
- package/dist/core/artifact-graph/types.js +38 -0
- package/dist/core/artifact-graph/types.js.map +1 -0
- package/dist/core/available-tools.d.ts +17 -0
- package/dist/core/available-tools.d.ts.map +1 -0
- package/dist/core/available-tools.js +43 -0
- package/dist/core/available-tools.js.map +1 -0
- package/dist/core/change-metadata/index.d.ts +2 -0
- package/dist/core/change-metadata/index.d.ts.map +1 -0
- package/dist/core/change-metadata/index.js +2 -0
- package/dist/core/change-metadata/index.js.map +1 -0
- package/dist/core/change-metadata/schema.d.ts +12 -0
- package/dist/core/change-metadata/schema.d.ts.map +1 -0
- package/dist/core/change-metadata/schema.js +15 -0
- package/dist/core/change-metadata/schema.js.map +1 -0
- package/dist/core/change-presenter.d.ts +24 -0
- package/dist/core/change-presenter.d.ts.map +1 -0
- package/dist/core/change-presenter.js +135 -0
- package/dist/core/change-presenter.js.map +1 -0
- package/dist/core/change-status-policy.d.ts +35 -0
- package/dist/core/change-status-policy.d.ts.map +1 -0
- package/dist/core/change-status-policy.js +34 -0
- package/dist/core/change-status-policy.js.map +1 -0
- package/dist/core/command-generation/adapters/claude.d.ts +14 -0
- package/dist/core/command-generation/adapters/claude.d.ts.map +1 -0
- package/dist/core/command-generation/adapters/claude.js +38 -0
- package/dist/core/command-generation/adapters/claude.js.map +1 -0
- package/dist/core/command-generation/adapters/cursor.d.ts +14 -0
- package/dist/core/command-generation/adapters/cursor.d.ts.map +1 -0
- package/dist/core/command-generation/adapters/cursor.js +31 -0
- package/dist/core/command-generation/adapters/cursor.js.map +1 -0
- package/dist/core/command-generation/adapters/index.d.ts +8 -0
- package/dist/core/command-generation/adapters/index.d.ts.map +1 -0
- package/dist/core/command-generation/adapters/index.js +8 -0
- package/dist/core/command-generation/adapters/index.js.map +1 -0
- package/dist/core/command-generation/generator.d.ts +21 -0
- package/dist/core/command-generation/generator.d.ts.map +1 -0
- package/dist/core/command-generation/generator.js +27 -0
- package/dist/core/command-generation/generator.js.map +1 -0
- package/dist/core/command-generation/index.d.ts +22 -0
- package/dist/core/command-generation/index.d.ts.map +1 -0
- package/dist/core/command-generation/index.js +24 -0
- package/dist/core/command-generation/index.js.map +1 -0
- package/dist/core/command-generation/registry.d.ts +24 -0
- package/dist/core/command-generation/registry.d.ts.map +1 -0
- package/dist/core/command-generation/registry.js +38 -0
- package/dist/core/command-generation/registry.js.map +1 -0
- package/dist/core/command-generation/types.d.ts +56 -0
- package/dist/core/command-generation/types.d.ts.map +1 -0
- package/dist/core/command-generation/types.js +8 -0
- package/dist/core/command-generation/types.js.map +1 -0
- package/dist/core/command-generation/yaml.d.ts +22 -0
- package/dist/core/command-generation/yaml.d.ts.map +1 -0
- package/dist/core/command-generation/yaml.js +38 -0
- package/dist/core/command-generation/yaml.js.map +1 -0
- package/dist/core/config-prompts.d.ts +9 -0
- package/dist/core/config-prompts.d.ts.map +1 -0
- package/dist/core/config-prompts.js +34 -0
- package/dist/core/config-prompts.js.map +1 -0
- package/dist/core/config-schema.d.ts +87 -0
- package/dist/core/config-schema.d.ts.map +1 -0
- package/dist/core/config-schema.js +239 -0
- package/dist/core/config-schema.js.map +1 -0
- package/dist/core/config.d.ts +19 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +14 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/converters/json-converter.d.ts +6 -0
- package/dist/core/converters/json-converter.d.ts.map +1 -0
- package/dist/core/converters/json-converter.js +51 -0
- package/dist/core/converters/json-converter.js.map +1 -0
- package/dist/core/dashboard-activity.d.ts +50 -0
- package/dist/core/dashboard-activity.d.ts.map +1 -0
- package/dist/core/dashboard-activity.js +147 -0
- package/dist/core/dashboard-activity.js.map +1 -0
- package/dist/core/dashboard-data.d.ts +100 -0
- package/dist/core/dashboard-data.d.ts.map +1 -0
- package/dist/core/dashboard-data.js +368 -0
- package/dist/core/dashboard-data.js.map +1 -0
- package/dist/core/global-config.d.ts +51 -0
- package/dist/core/global-config.d.ts.map +1 -0
- package/dist/core/global-config.js +124 -0
- package/dist/core/global-config.js.map +1 -0
- package/dist/core/id.d.ts +17 -0
- package/dist/core/id.d.ts.map +1 -0
- package/dist/core/id.js +30 -0
- package/dist/core/id.js.map +1 -0
- package/dist/core/init.d.ts +34 -0
- package/dist/core/init.d.ts.map +1 -0
- package/dist/core/init.js +495 -0
- package/dist/core/init.js.map +1 -0
- package/dist/core/list.d.ts +12 -0
- package/dist/core/list.d.ts.map +1 -0
- package/dist/core/list.js +192 -0
- package/dist/core/list.js.map +1 -0
- package/dist/core/migrate.d.ts +52 -0
- package/dist/core/migrate.d.ts.map +1 -0
- package/dist/core/migrate.js +300 -0
- package/dist/core/migrate.js.map +1 -0
- package/dist/core/parsers/change-parser.d.ts +19 -0
- package/dist/core/parsers/change-parser.d.ts.map +1 -0
- package/dist/core/parsers/change-parser.js +204 -0
- package/dist/core/parsers/change-parser.js.map +1 -0
- package/dist/core/parsers/markdown-parser.d.ts +29 -0
- package/dist/core/parsers/markdown-parser.d.ts.map +1 -0
- package/dist/core/parsers/markdown-parser.js +181 -0
- package/dist/core/parsers/markdown-parser.js.map +1 -0
- package/dist/core/parsers/requirement-blocks.d.ts +37 -0
- package/dist/core/parsers/requirement-blocks.d.ts.map +1 -0
- package/dist/core/parsers/requirement-blocks.js +201 -0
- package/dist/core/parsers/requirement-blocks.js.map +1 -0
- package/dist/core/parsers/requirement-text.d.ts +55 -0
- package/dist/core/parsers/requirement-text.d.ts.map +1 -0
- package/dist/core/parsers/requirement-text.js +136 -0
- package/dist/core/parsers/requirement-text.js.map +1 -0
- package/dist/core/parsers/spec-structure.d.ts +9 -0
- package/dist/core/parsers/spec-structure.d.ts.map +1 -0
- package/dist/core/parsers/spec-structure.js +88 -0
- package/dist/core/parsers/spec-structure.js.map +1 -0
- package/dist/core/planning-home.d.ts +16 -0
- package/dist/core/planning-home.d.ts.map +1 -0
- package/dist/core/planning-home.js +67 -0
- package/dist/core/planning-home.js.map +1 -0
- package/dist/core/profiles.d.ts +24 -0
- package/dist/core/profiles.d.ts.map +1 -0
- package/dist/core/profiles.js +27 -0
- package/dist/core/profiles.js.map +1 -0
- package/dist/core/project-config.d.ts +100 -0
- package/dist/core/project-config.d.ts.map +1 -0
- package/dist/core/project-config.js +389 -0
- package/dist/core/project-config.js.map +1 -0
- package/dist/core/references.d.ts +40 -0
- package/dist/core/references.d.ts.map +1 -0
- package/dist/core/references.js +11 -0
- package/dist/core/references.js.map +1 -0
- package/dist/core/root-selection.d.ts +57 -0
- package/dist/core/root-selection.d.ts.map +1 -0
- package/dist/core/root-selection.js +98 -0
- package/dist/core/root-selection.js.map +1 -0
- package/dist/core/schemas/base.schema.d.ts +13 -0
- package/dist/core/schemas/base.schema.d.ts.map +1 -0
- package/dist/core/schemas/base.schema.js +18 -0
- package/dist/core/schemas/base.schema.js.map +1 -0
- package/dist/core/schemas/change.schema.d.ts +73 -0
- package/dist/core/schemas/change.schema.d.ts.map +1 -0
- package/dist/core/schemas/change.schema.js +31 -0
- package/dist/core/schemas/change.schema.js.map +1 -0
- package/dist/core/schemas/index.d.ts +4 -0
- package/dist/core/schemas/index.d.ts.map +1 -0
- package/dist/core/schemas/index.js +4 -0
- package/dist/core/schemas/index.js.map +1 -0
- package/dist/core/schemas/spec.schema.d.ts +18 -0
- package/dist/core/schemas/spec.schema.d.ts.map +1 -0
- package/dist/core/schemas/spec.schema.js +15 -0
- package/dist/core/schemas/spec.schema.js.map +1 -0
- package/dist/core/shared/index.d.ts +8 -0
- package/dist/core/shared/index.d.ts.map +1 -0
- package/dist/core/shared/index.js +8 -0
- package/dist/core/shared/index.js.map +1 -0
- package/dist/core/shared/skill-generation.d.ts +63 -0
- package/dist/core/shared/skill-generation.d.ts.map +1 -0
- package/dist/core/shared/skill-generation.js +125 -0
- package/dist/core/shared/skill-generation.js.map +1 -0
- package/dist/core/shared/tool-detection.d.ts +96 -0
- package/dist/core/shared/tool-detection.d.ts.map +1 -0
- package/dist/core/shared/tool-detection.js +207 -0
- package/dist/core/shared/tool-detection.js.map +1 -0
- package/dist/core/spec-presenter.d.ts +17 -0
- package/dist/core/spec-presenter.d.ts.map +1 -0
- package/dist/core/spec-presenter.js +98 -0
- package/dist/core/spec-presenter.js.map +1 -0
- package/dist/core/specs-apply.d.ts +78 -0
- package/dist/core/specs-apply.d.ts.map +1 -0
- package/dist/core/specs-apply.js +429 -0
- package/dist/core/specs-apply.js.map +1 -0
- package/dist/core/templates/index.d.ts +8 -0
- package/dist/core/templates/index.d.ts.map +1 -0
- package/dist/core/templates/index.js +9 -0
- package/dist/core/templates/index.js.map +1 -0
- package/dist/core/templates/skill-templates.d.ts +14 -0
- package/dist/core/templates/skill-templates.d.ts.map +1 -0
- package/dist/core/templates/skill-templates.js +13 -0
- package/dist/core/templates/skill-templates.js.map +1 -0
- package/dist/core/templates/types.d.ts +20 -0
- package/dist/core/templates/types.d.ts.map +1 -0
- package/dist/core/templates/types.js +5 -0
- package/dist/core/templates/types.js.map +1 -0
- package/dist/core/templates/workflows/apply.d.ts +10 -0
- package/dist/core/templates/workflows/apply.d.ts.map +1 -0
- package/dist/core/templates/workflows/apply.js +81 -0
- package/dist/core/templates/workflows/apply.js.map +1 -0
- package/dist/core/templates/workflows/archive.d.ts +10 -0
- package/dist/core/templates/workflows/archive.d.ts.map +1 -0
- package/dist/core/templates/workflows/archive.js +75 -0
- package/dist/core/templates/workflows/archive.js.map +1 -0
- package/dist/core/templates/workflows/explore.d.ts +10 -0
- package/dist/core/templates/workflows/explore.d.ts.map +1 -0
- package/dist/core/templates/workflows/explore.js +75 -0
- package/dist/core/templates/workflows/explore.js.map +1 -0
- package/dist/core/templates/workflows/issue.d.ts +10 -0
- package/dist/core/templates/workflows/issue.d.ts.map +1 -0
- package/dist/core/templates/workflows/issue.js +89 -0
- package/dist/core/templates/workflows/issue.js.map +1 -0
- package/dist/core/templates/workflows/propose.d.ts +10 -0
- package/dist/core/templates/workflows/propose.d.ts.map +1 -0
- package/dist/core/templates/workflows/propose.js +77 -0
- package/dist/core/templates/workflows/propose.js.map +1 -0
- package/dist/core/templates/workflows/sync.d.ts +14 -0
- package/dist/core/templates/workflows/sync.d.ts.map +1 -0
- package/dist/core/templates/workflows/sync.js +86 -0
- package/dist/core/templates/workflows/sync.js.map +1 -0
- package/dist/core/templates/workflows/update.d.ts +10 -0
- package/dist/core/templates/workflows/update.d.ts.map +1 -0
- package/dist/core/templates/workflows/update.js +99 -0
- package/dist/core/templates/workflows/update.js.map +1 -0
- package/dist/core/templates/workflows/verify.d.ts +9 -0
- package/dist/core/templates/workflows/verify.d.ts.map +1 -0
- package/dist/core/templates/workflows/verify.js +44 -0
- package/dist/core/templates/workflows/verify.js.map +1 -0
- package/dist/core/update.d.ts +46 -0
- package/dist/core/update.d.ts.map +1 -0
- package/dist/core/update.js +296 -0
- package/dist/core/update.js.map +1 -0
- package/dist/core/validation/constants.d.ts +34 -0
- package/dist/core/validation/constants.d.ts.map +1 -0
- package/dist/core/validation/constants.js +40 -0
- package/dist/core/validation/constants.js.map +1 -0
- package/dist/core/validation/section-validator.d.ts +19 -0
- package/dist/core/validation/section-validator.d.ts.map +1 -0
- package/dist/core/validation/section-validator.js +59 -0
- package/dist/core/validation/section-validator.js.map +1 -0
- package/dist/core/validation/types.d.ts +18 -0
- package/dist/core/validation/types.d.ts.map +1 -0
- package/dist/core/validation/types.js +2 -0
- package/dist/core/validation/types.js.map +1 -0
- package/dist/core/validation/validator.d.ts +51 -0
- package/dist/core/validation/validator.d.ts.map +1 -0
- package/dist/core/validation/validator.js +469 -0
- package/dist/core/validation/validator.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/prompts/searchable-multi-select.d.ts +28 -0
- package/dist/prompts/searchable-multi-select.d.ts.map +1 -0
- package/dist/prompts/searchable-multi-select.js +158 -0
- package/dist/prompts/searchable-multi-select.js.map +1 -0
- package/dist/ui/welcome-screen.d.ts +5 -0
- package/dist/ui/welcome-screen.d.ts.map +1 -0
- package/dist/ui/welcome-screen.js +22 -0
- package/dist/ui/welcome-screen.js.map +1 -0
- package/dist/utils/change-metadata.d.ts +55 -0
- package/dist/utils/change-metadata.d.ts.map +1 -0
- package/dist/utils/change-metadata.js +141 -0
- package/dist/utils/change-metadata.js.map +1 -0
- package/dist/utils/change-utils.d.ts +73 -0
- package/dist/utils/change-utils.d.ts.map +1 -0
- package/dist/utils/change-utils.js +194 -0
- package/dist/utils/change-utils.js.map +1 -0
- package/dist/utils/command-references.d.ts +18 -0
- package/dist/utils/command-references.d.ts.map +1 -0
- package/dist/utils/command-references.js +20 -0
- package/dist/utils/command-references.js.map +1 -0
- package/dist/utils/file-system.d.ts +41 -0
- package/dist/utils/file-system.d.ts.map +1 -0
- package/dist/utils/file-system.js +320 -0
- package/dist/utils/file-system.js.map +1 -0
- package/dist/utils/index.d.ts +6 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +9 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/interactive.d.ts +18 -0
- package/dist/utils/interactive.d.ts.map +1 -0
- package/dist/utils/interactive.js +21 -0
- package/dist/utils/interactive.js.map +1 -0
- package/dist/utils/item-discovery.d.ts +14 -0
- package/dist/utils/item-discovery.d.ts.map +1 -0
- package/dist/utils/item-discovery.js +104 -0
- package/dist/utils/item-discovery.js.map +1 -0
- package/dist/utils/link.d.ts +22 -0
- package/dist/utils/link.d.ts.map +1 -0
- package/dist/utils/link.js +85 -0
- package/dist/utils/link.js.map +1 -0
- package/dist/utils/match.d.ts +3 -0
- package/dist/utils/match.d.ts.map +1 -0
- package/dist/utils/match.js +22 -0
- package/dist/utils/match.js.map +1 -0
- package/dist/utils/shell-detection.d.ts +20 -0
- package/dist/utils/shell-detection.d.ts.map +1 -0
- package/dist/utils/shell-detection.js +41 -0
- package/dist/utils/shell-detection.js.map +1 -0
- package/dist/utils/task-progress.d.ts +32 -0
- package/dist/utils/task-progress.d.ts.map +1 -0
- package/dist/utils/task-progress.js +129 -0
- package/dist/utils/task-progress.js.map +1 -0
- package/dist/utils/timestamp.d.ts +9 -0
- package/dist/utils/timestamp.d.ts.map +1 -0
- package/dist/utils/timestamp.js +18 -0
- package/dist/utils/timestamp.js.map +1 -0
- package/package.json +55 -0
- package/schemas/issue/schema.yaml +34 -0
- package/schemas/issue/templates/spec.md +13 -0
- package/schemas/issue/templates/task.md +21 -0
- package/schemas/issue/templates/ticket.md +2 -0
- package/schemas/sdd/schema.yaml +55 -0
- package/schemas/sdd/templates/design.md +15 -0
- package/schemas/sdd/templates/proposal.md +15 -0
- package/schemas/sdd/templates/spec.md +13 -0
- package/schemas/sdd/templates/tasks.md +11 -0
- package/schemas/sdd/templates/ticket.md +2 -0
|
@@ -0,0 +1,1032 @@
|
|
|
1
|
+
// tospec dashboard — vanilla SPA. No framework, no build, no external resources.
|
|
2
|
+
// Charts: vendored Chart.js (assets/chart.umd.js), themed via CSS variables.
|
|
3
|
+
|
|
4
|
+
const view = document.getElementById('view');
|
|
5
|
+
const rootPathEl = document.getElementById('root-path');
|
|
6
|
+
const connEl = document.getElementById('conn-status');
|
|
7
|
+
const refreshBtn = document.getElementById('refresh-btn');
|
|
8
|
+
const themeSelect = document.getElementById('theme-select');
|
|
9
|
+
|
|
10
|
+
let overview = null; // cached /api/overview payload
|
|
11
|
+
let activity = null; // cached /api/activity payload (git series + health)
|
|
12
|
+
let activityRange = '30'; // '30' | '90' | '365' | 'all' — overview chart window
|
|
13
|
+
|
|
14
|
+
// --- theme (persisted choice: system | light | dark | dracula) -------------
|
|
15
|
+
|
|
16
|
+
const THEME_KEY = 'tospec-theme';
|
|
17
|
+
const systemDark = window.matchMedia('(prefers-color-scheme: dark)');
|
|
18
|
+
|
|
19
|
+
// Apply a user choice: 'system' resolves to light/dark from the OS; the rest
|
|
20
|
+
// map straight to a data-theme value.
|
|
21
|
+
function applyTheme(choice) {
|
|
22
|
+
const resolved = choice === 'system' ? (systemDark.matches ? 'dark' : 'light') : choice;
|
|
23
|
+
document.documentElement.setAttribute('data-theme', resolved);
|
|
24
|
+
themeSelect.value = choice;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const savedTheme = localStorage.getItem(THEME_KEY) || 'system';
|
|
28
|
+
applyTheme(savedTheme);
|
|
29
|
+
|
|
30
|
+
// Theme switches rebuild the route: charts read CSS variables at build time,
|
|
31
|
+
// so they must be recreated to pick up the new palette (rare event, morph
|
|
32
|
+
// loss is fine). The initial applyTheme above must NOT rebuild — chart state
|
|
33
|
+
// (liveCharts, ov) is declared further down and would still be in its TDZ.
|
|
34
|
+
systemDark.addEventListener('change', () => {
|
|
35
|
+
if ((localStorage.getItem(THEME_KEY) || 'system') === 'system') {
|
|
36
|
+
applyTheme('system');
|
|
37
|
+
rebuildRoute();
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
themeSelect.addEventListener('change', () => {
|
|
41
|
+
localStorage.setItem(THEME_KEY, themeSelect.value);
|
|
42
|
+
applyTheme(themeSelect.value);
|
|
43
|
+
rebuildRoute();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// --- tiny helpers ----------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
async function api(path, options) {
|
|
49
|
+
const res = await fetch(path, options);
|
|
50
|
+
if (!res.ok) {
|
|
51
|
+
let detail = 'HTTP ' + res.status;
|
|
52
|
+
try {
|
|
53
|
+
const body = await res.json();
|
|
54
|
+
if (body && body.error) detail = body.error;
|
|
55
|
+
} catch {
|
|
56
|
+
// no JSON body; keep the status line
|
|
57
|
+
}
|
|
58
|
+
const err = new Error(detail);
|
|
59
|
+
err.status = res.status;
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
return res.json();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Writes a task checkbox back to its markdown file. */
|
|
66
|
+
function postTask(file, line, done) {
|
|
67
|
+
return api('/api/task', {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { 'content-type': 'application/json' },
|
|
70
|
+
body: JSON.stringify({ file, line, done }),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Minimal DOM builder; all text goes in as textContent (XSS-safe). */
|
|
75
|
+
function el(tag, props = {}, children = []) {
|
|
76
|
+
const node = document.createElement(tag);
|
|
77
|
+
for (const [k, v] of Object.entries(props)) {
|
|
78
|
+
if (v == null) continue;
|
|
79
|
+
if (k === 'class') node.className = v;
|
|
80
|
+
else if (k === 'text') node.textContent = v;
|
|
81
|
+
else node.setAttribute(k, v);
|
|
82
|
+
}
|
|
83
|
+
for (const child of [].concat(children)) {
|
|
84
|
+
if (child == null) continue;
|
|
85
|
+
node.append(child.nodeType ? child : document.createTextNode(String(child)));
|
|
86
|
+
}
|
|
87
|
+
return node;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function emptyNote(text) {
|
|
91
|
+
return el('p', { class: 'empty', text });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function relTime(iso) {
|
|
95
|
+
const then = new Date(iso).getTime();
|
|
96
|
+
if (Number.isNaN(then)) return '';
|
|
97
|
+
const mins = Math.floor((Date.now() - then) / 60000);
|
|
98
|
+
const hours = Math.floor(mins / 60);
|
|
99
|
+
const days = Math.floor(hours / 24);
|
|
100
|
+
if (days > 30) return new Date(iso).toLocaleDateString();
|
|
101
|
+
if (days > 0) return days + 'd ago';
|
|
102
|
+
if (hours > 0) return hours + 'h ago';
|
|
103
|
+
if (mins > 0) return mins + 'm ago';
|
|
104
|
+
return 'just now';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** `20260713_123649` -> `2026-07-13 12:36`. */
|
|
108
|
+
function fmtStamp(s) {
|
|
109
|
+
const m = /^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})$/.exec(s || '');
|
|
110
|
+
return m ? `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}` : s || '';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function typeBadge(type) {
|
|
114
|
+
const label = type === 'requirement' ? 'sdd' : type || '?';
|
|
115
|
+
return el('span', { class: 'badge badge-' + (type || 'unknown'), text: label });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function artifactBlocks(done, total) {
|
|
119
|
+
const wrap = el('span', { class: 'blocks' });
|
|
120
|
+
for (let i = 0; i < total; i++) {
|
|
121
|
+
wrap.append(el('span', { class: 'block ' + (i < done ? 'block-done' : 'block-todo') }));
|
|
122
|
+
}
|
|
123
|
+
return wrap;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function setConnected(ok) {
|
|
127
|
+
connEl.textContent = ok ? '' : 'disconnected';
|
|
128
|
+
connEl.classList.toggle('disconnected', !ok);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- charts (Chart.js, themed via CSS variables) ----------------------------
|
|
132
|
+
|
|
133
|
+
const hasChart = typeof Chart !== 'undefined';
|
|
134
|
+
const liveCharts = new Set();
|
|
135
|
+
|
|
136
|
+
function cssVar(name) {
|
|
137
|
+
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function newChart(canvas, config) {
|
|
141
|
+
const chart = new Chart(canvas, config);
|
|
142
|
+
liveCharts.add(chart);
|
|
143
|
+
return chart;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function destroyCharts() {
|
|
147
|
+
for (const chart of liveCharts) chart.destroy();
|
|
148
|
+
liveCharts.clear();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Shared recessive axes/legend styling read from the active theme. */
|
|
152
|
+
function chartTheme() {
|
|
153
|
+
return {
|
|
154
|
+
text: cssVar('--text'),
|
|
155
|
+
muted: cssVar('--muted'),
|
|
156
|
+
grid: cssVar('--border'),
|
|
157
|
+
surface: cssVar('--surface'),
|
|
158
|
+
series1: cssVar('--chart-1'),
|
|
159
|
+
series2: cssVar('--chart-2'),
|
|
160
|
+
done: cssVar('--done'),
|
|
161
|
+
ready: cssVar('--ready'),
|
|
162
|
+
blocked: cssVar('--blocked'),
|
|
163
|
+
track: cssVar('--border'),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Doughnut center text (e.g. "68%"), drawn in the theme's text color. */
|
|
168
|
+
const centerTextPlugin = {
|
|
169
|
+
id: 'centerText',
|
|
170
|
+
afterDraw(chart, _args, opts) {
|
|
171
|
+
if (!opts || !opts.text) return;
|
|
172
|
+
const meta = chart.getDatasetMeta(0);
|
|
173
|
+
const arc = meta.data[0];
|
|
174
|
+
if (!arc) return;
|
|
175
|
+
const { ctx } = chart;
|
|
176
|
+
ctx.save();
|
|
177
|
+
ctx.font = '600 16px system-ui, sans-serif';
|
|
178
|
+
ctx.fillStyle = opts.color;
|
|
179
|
+
ctx.textAlign = 'center';
|
|
180
|
+
ctx.textBaseline = 'middle';
|
|
181
|
+
ctx.fillText(opts.text, arc.x, arc.y);
|
|
182
|
+
ctx.restore();
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
if (hasChart) Chart.register(centerTextPlugin);
|
|
186
|
+
|
|
187
|
+
/** 30-day activity line chart: solid tospec series + dashed repo context. */
|
|
188
|
+
function buildActivityChart(canvas, tospecLabel) {
|
|
189
|
+
const t = chartTheme();
|
|
190
|
+
return newChart(canvas, {
|
|
191
|
+
type: 'line',
|
|
192
|
+
data: {
|
|
193
|
+
labels: [],
|
|
194
|
+
datasets: [
|
|
195
|
+
{
|
|
196
|
+
label: tospecLabel,
|
|
197
|
+
data: [],
|
|
198
|
+
borderColor: t.series1,
|
|
199
|
+
backgroundColor: t.series1,
|
|
200
|
+
borderWidth: 2,
|
|
201
|
+
pointRadius: 2,
|
|
202
|
+
pointHitRadius: 10,
|
|
203
|
+
tension: 0.2,
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
label: 'all commits',
|
|
207
|
+
data: [],
|
|
208
|
+
borderColor: t.series2,
|
|
209
|
+
backgroundColor: t.series2,
|
|
210
|
+
borderWidth: 2,
|
|
211
|
+
borderDash: [5, 4], // secondary encoding: identity is never color-alone
|
|
212
|
+
pointRadius: 2,
|
|
213
|
+
pointHitRadius: 10,
|
|
214
|
+
tension: 0.2,
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
},
|
|
218
|
+
options: {
|
|
219
|
+
responsive: true,
|
|
220
|
+
maintainAspectRatio: false,
|
|
221
|
+
interaction: { mode: 'index', intersect: false },
|
|
222
|
+
plugins: {
|
|
223
|
+
legend: { labels: { color: t.muted, boxWidth: 18, boxHeight: 2 } },
|
|
224
|
+
tooltip: { intersect: false },
|
|
225
|
+
},
|
|
226
|
+
scales: {
|
|
227
|
+
x: {
|
|
228
|
+
ticks: { color: t.muted, maxTicksLimit: 8, maxRotation: 0 },
|
|
229
|
+
grid: { display: false },
|
|
230
|
+
},
|
|
231
|
+
y: {
|
|
232
|
+
beginAtZero: true,
|
|
233
|
+
ticks: { color: t.muted, precision: 0 },
|
|
234
|
+
grid: { color: t.grid },
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function updateActivityChart(chart, days) {
|
|
242
|
+
// mm-dd is ambiguous once the window spans more than a year.
|
|
243
|
+
const short = days.length <= 366;
|
|
244
|
+
chart.data.labels = days.map((d) => (short ? d.date.slice(5) : d.date));
|
|
245
|
+
chart.data.datasets[0].data = days.map((d) => d.tospec);
|
|
246
|
+
chart.data.datasets[1].data = days.map((d) => d.repo);
|
|
247
|
+
chart.update();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Status doughnut (done/ready/blocked) with a completion % in the center. */
|
|
251
|
+
function buildStatusDoughnut(canvas, labels, colors) {
|
|
252
|
+
const t = chartTheme();
|
|
253
|
+
return newChart(canvas, {
|
|
254
|
+
type: 'doughnut',
|
|
255
|
+
data: {
|
|
256
|
+
labels,
|
|
257
|
+
datasets: [
|
|
258
|
+
{
|
|
259
|
+
data: labels.map(() => 0),
|
|
260
|
+
backgroundColor: colors,
|
|
261
|
+
borderColor: t.surface, // 2px surface gap between segments
|
|
262
|
+
borderWidth: 2,
|
|
263
|
+
},
|
|
264
|
+
],
|
|
265
|
+
},
|
|
266
|
+
options: {
|
|
267
|
+
responsive: true,
|
|
268
|
+
maintainAspectRatio: false,
|
|
269
|
+
cutout: '70%',
|
|
270
|
+
plugins: {
|
|
271
|
+
legend: { position: 'bottom', labels: { color: t.muted, boxWidth: 10 } },
|
|
272
|
+
centerText: { text: '', color: t.text },
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function updateStatusDoughnut(chart, values, centerText) {
|
|
279
|
+
chart.data.datasets[0].data = values;
|
|
280
|
+
chart.options.plugins.centerText.text = centerText;
|
|
281
|
+
chart.update();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// --- dynamics (count-up, flash) ---------------------------------------------
|
|
285
|
+
|
|
286
|
+
/** Animates an element's number from its last shown value to `to` (~400ms). */
|
|
287
|
+
function countUp(node, to) {
|
|
288
|
+
const from = Number(node.dataset.value || 0);
|
|
289
|
+
node.dataset.value = String(to);
|
|
290
|
+
if (from === to) {
|
|
291
|
+
node.textContent = String(to);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const start = performance.now();
|
|
295
|
+
const dur = 400;
|
|
296
|
+
function tick(now) {
|
|
297
|
+
const p = Math.min(1, (now - start) / dur);
|
|
298
|
+
const eased = 1 - (1 - p) * (1 - p);
|
|
299
|
+
node.textContent = String(Math.round(from + (to - from) * eased));
|
|
300
|
+
if (p < 1) requestAnimationFrame(tick);
|
|
301
|
+
}
|
|
302
|
+
requestAnimationFrame(tick);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Briefly highlights a row after an SSE-driven change. */
|
|
306
|
+
function flash(node) {
|
|
307
|
+
node.classList.add('flash');
|
|
308
|
+
node.addEventListener('animationend', () => node.classList.remove('flash'), { once: true });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Previous snapshots keyed for change detection (what to flash after updates).
|
|
312
|
+
let prevChangeStamps = null; // Map<name, lastModified>
|
|
313
|
+
let prevTicketFiles = null; // Set<file>
|
|
314
|
+
|
|
315
|
+
// --- data loading ------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
async function loadOverview() {
|
|
318
|
+
try {
|
|
319
|
+
overview = await api('/api/overview');
|
|
320
|
+
setConnected(true);
|
|
321
|
+
rootPathEl.textContent = overview.root;
|
|
322
|
+
rootPathEl.setAttribute('title', overview.root);
|
|
323
|
+
if (isOverviewRoute()) renderOverview();
|
|
324
|
+
} catch (e) {
|
|
325
|
+
setConnected(false);
|
|
326
|
+
if (isOverviewRoute() && !overview) {
|
|
327
|
+
const msg = e.status
|
|
328
|
+
? `Failed to load overview: ${e.message}`
|
|
329
|
+
: 'Cannot reach the dashboard server.';
|
|
330
|
+
view.replaceChildren(el('p', { class: 'error', text: msg }));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function loadActivity() {
|
|
336
|
+
try {
|
|
337
|
+
activity = await api('/api/activity?days=' + activityRange);
|
|
338
|
+
} catch {
|
|
339
|
+
activity = null; // health strip and charts degrade below
|
|
340
|
+
}
|
|
341
|
+
if (isOverviewRoute()) renderOverview();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// --- overview: skeleton + update ---------------------------------------------
|
|
345
|
+
|
|
346
|
+
// The overview keeps one DOM skeleton alive across data refreshes so chart
|
|
347
|
+
// instances survive and Chart.js morphs to new values (SSE-driven dynamics).
|
|
348
|
+
let ov = null;
|
|
349
|
+
|
|
350
|
+
function card(label) {
|
|
351
|
+
const count = el('span', { class: 'card-count', text: '0' });
|
|
352
|
+
const node = el('div', { class: 'card' }, [
|
|
353
|
+
count,
|
|
354
|
+
el('span', { class: 'card-label', text: label }),
|
|
355
|
+
]);
|
|
356
|
+
return { node, count };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function chartPanel(title, height) {
|
|
360
|
+
const canvas = el('canvas');
|
|
361
|
+
const wrap = el('div', { class: 'chart-wrap' }, [canvas]);
|
|
362
|
+
wrap.style.height = height + 'px';
|
|
363
|
+
const note = el('p', { class: 'empty chart-note', text: '' });
|
|
364
|
+
note.style.display = 'none';
|
|
365
|
+
const panel = el('div', { class: 'chart-panel' }, [
|
|
366
|
+
el('h2', { class: 'section-title', text: title }),
|
|
367
|
+
wrap,
|
|
368
|
+
note,
|
|
369
|
+
]);
|
|
370
|
+
return { panel, canvas, wrap, note };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// --- task progress -----------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
// Names of changes whose task list is expanded. Module-level so an SSE refresh,
|
|
376
|
+
// which rebuilds these rows, doesn't collapse what the user opened.
|
|
377
|
+
const expandedTasks = new Set();
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* One task, as a checkbox that writes straight back to its markdown file. The
|
|
381
|
+
* file is the source of truth: a failed write reverts the box rather than
|
|
382
|
+
* leaving the UI claiming something the file doesn't say.
|
|
383
|
+
*/
|
|
384
|
+
function taskItem(task) {
|
|
385
|
+
const item = el('label', { class: 'task-item' + (task.done ? ' task-item-done' : '') });
|
|
386
|
+
// Mirror the file's own nesting; 2 spaces of markdown indent reads as one step.
|
|
387
|
+
if (task.indent) item.style.marginLeft = task.indent * 8 + 'px';
|
|
388
|
+
const box = el('input', { type: 'checkbox', class: 'task-box' });
|
|
389
|
+
box.checked = task.done;
|
|
390
|
+
item.append(box, el('span', { class: 'task-text', text: task.text }));
|
|
391
|
+
|
|
392
|
+
box.addEventListener('change', async () => {
|
|
393
|
+
const want = box.checked;
|
|
394
|
+
box.disabled = true;
|
|
395
|
+
item.querySelectorAll('.task-error').forEach((n) => n.remove());
|
|
396
|
+
try {
|
|
397
|
+
await postTask(task.file, task.line, want);
|
|
398
|
+
item.classList.toggle('task-item-done', want);
|
|
399
|
+
// The file watcher normally refreshes us; reload anyway so the counts move
|
|
400
|
+
// even where the watch is unavailable.
|
|
401
|
+
await loadOverview();
|
|
402
|
+
} catch (e) {
|
|
403
|
+
box.checked = !want;
|
|
404
|
+
item.append(el('span', { class: 'task-error', text: 'save failed: ' + e.message }));
|
|
405
|
+
} finally {
|
|
406
|
+
box.disabled = false;
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
return item;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** A change's tasks, headed by their file path only when they span several. */
|
|
413
|
+
function taskListBody(change) {
|
|
414
|
+
const body = el('div', { class: 'task-body' });
|
|
415
|
+
const files = [...new Set(change.tasks.map((t) => t.file))];
|
|
416
|
+
for (const file of files) {
|
|
417
|
+
if (files.length > 1) body.append(el('p', { class: 'task-file mono', text: file }));
|
|
418
|
+
for (const task of change.tasks) {
|
|
419
|
+
if (task.file === file) body.append(taskItem(task));
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return body;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Collapsed: name + bar + `x/y`. Expanded: every task, each one clickable. */
|
|
426
|
+
function taskChangeRow(c) {
|
|
427
|
+
const { completed, total } = c.taskProgress;
|
|
428
|
+
if (total === 0) {
|
|
429
|
+
return el('div', { class: 'row task-none' }, [
|
|
430
|
+
el('span', { class: 'row-name', text: c.name }),
|
|
431
|
+
el('span', { class: 'row-meta', text: 'no tasks' }),
|
|
432
|
+
]);
|
|
433
|
+
}
|
|
434
|
+
const details = el('details', { class: 'task-change' });
|
|
435
|
+
if (expandedTasks.has(c.name)) details.setAttribute('open', '');
|
|
436
|
+
details.append(
|
|
437
|
+
el('summary', {}, [
|
|
438
|
+
el('span', { class: 'row-name', text: c.name }),
|
|
439
|
+
el('progress', { class: 'task-bar', value: String(completed), max: String(total) }),
|
|
440
|
+
el('span', { class: 'task-count', text: completed + '/' + total }),
|
|
441
|
+
c.artifactSummary.blocked > 0
|
|
442
|
+
? el('span', { class: 'badge badge-blocked-warn', text: 'blocked' })
|
|
443
|
+
: null,
|
|
444
|
+
]),
|
|
445
|
+
taskListBody(c)
|
|
446
|
+
);
|
|
447
|
+
details.addEventListener('toggle', () => {
|
|
448
|
+
if (details.open) expandedTasks.add(c.name);
|
|
449
|
+
else expandedTasks.delete(c.name);
|
|
450
|
+
});
|
|
451
|
+
return details;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function updateTaskProgress() {
|
|
455
|
+
const s = ov.taskSection;
|
|
456
|
+
s.replaceChildren(el('h2', { class: 'section-title', text: 'Task progress' }));
|
|
457
|
+
if (!overview.changes.length) {
|
|
458
|
+
s.append(emptyNote('No active changes.'));
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
for (const c of overview.changes) s.append(taskChangeRow(c));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function buildOverviewSkeleton() {
|
|
465
|
+
destroyCharts();
|
|
466
|
+
ov = {
|
|
467
|
+
health: el('div', { class: 'health' }),
|
|
468
|
+
cards: {
|
|
469
|
+
changes: card('Changes'),
|
|
470
|
+
tickets: card('Tickets'),
|
|
471
|
+
specs: card('Specs'),
|
|
472
|
+
archived: card('Archived'),
|
|
473
|
+
},
|
|
474
|
+
activity: chartPanel('Activity', 180),
|
|
475
|
+
doughnut: chartPanel('Artifacts', 180),
|
|
476
|
+
taskSection: el('section', { class: 'panel', id: 'task-progress-panel' }),
|
|
477
|
+
changesSection: el('section', { class: 'panel', id: 'changes-panel' }),
|
|
478
|
+
ticketsSection: el('section', { class: 'panel', id: 'tickets-panel' }),
|
|
479
|
+
specsSection: el('section', { class: 'panel' }),
|
|
480
|
+
archiveSlot: el('div'),
|
|
481
|
+
archiveCount: -1,
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const cards = el('section', { class: 'cards' }, [
|
|
485
|
+
ov.cards.changes.node,
|
|
486
|
+
ov.cards.tickets.node,
|
|
487
|
+
ov.cards.specs.node,
|
|
488
|
+
ov.cards.archived.node,
|
|
489
|
+
]);
|
|
490
|
+
const chartsGrid = el('section', { class: 'charts-grid' }, [
|
|
491
|
+
ov.activity.panel,
|
|
492
|
+
ov.doughnut.panel,
|
|
493
|
+
]);
|
|
494
|
+
|
|
495
|
+
view.replaceChildren(
|
|
496
|
+
ov.health,
|
|
497
|
+
cards,
|
|
498
|
+
chartsGrid,
|
|
499
|
+
ov.taskSection,
|
|
500
|
+
ov.changesSection,
|
|
501
|
+
ov.ticketsSection,
|
|
502
|
+
ov.specsSection,
|
|
503
|
+
ov.archiveSlot
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
// Window selector lives in the panel title; changing it refetches the series.
|
|
507
|
+
const range = el('select', { class: 'range-select', title: 'Activity window' }, [
|
|
508
|
+
el('option', { value: '30', text: '30 days' }),
|
|
509
|
+
el('option', { value: '90', text: '90 days' }),
|
|
510
|
+
el('option', { value: '365', text: '1 year' }),
|
|
511
|
+
el('option', { value: 'all', text: 'All history' }),
|
|
512
|
+
]);
|
|
513
|
+
range.value = activityRange;
|
|
514
|
+
range.addEventListener('change', () => {
|
|
515
|
+
activityRange = range.value;
|
|
516
|
+
loadActivity();
|
|
517
|
+
});
|
|
518
|
+
ov.activity.panel.firstChild.append(range);
|
|
519
|
+
|
|
520
|
+
if (hasChart) {
|
|
521
|
+
const t = chartTheme();
|
|
522
|
+
ov.activityChart = buildActivityChart(ov.activity.canvas, 'tospec/');
|
|
523
|
+
ov.doughnutChart = buildStatusDoughnut(
|
|
524
|
+
ov.doughnut.canvas,
|
|
525
|
+
['done', 'ready', 'blocked'],
|
|
526
|
+
[t.done, t.ready, t.blocked]
|
|
527
|
+
);
|
|
528
|
+
} else {
|
|
529
|
+
ov.activity.wrap.style.display = 'none';
|
|
530
|
+
ov.doughnut.wrap.style.display = 'none';
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** True when a change's last modification is older than the stale threshold. */
|
|
535
|
+
function isStale(change, staleDays) {
|
|
536
|
+
const age = Date.now() - new Date(change.lastModified).getTime();
|
|
537
|
+
return age > staleDays * 86400000;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function healthItem(text, targetId) {
|
|
541
|
+
const item = el('a', { class: 'health-item', href: '#', text });
|
|
542
|
+
item.addEventListener('click', (e) => {
|
|
543
|
+
e.preventDefault();
|
|
544
|
+
const target = document.getElementById(targetId);
|
|
545
|
+
if (target) target.scrollIntoView({ behavior: 'smooth' });
|
|
546
|
+
});
|
|
547
|
+
return item;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function updateHealthStrip(staleDays) {
|
|
551
|
+
const items = [];
|
|
552
|
+
const staleChanges = overview.changes.filter((c) => isStale(c, staleDays));
|
|
553
|
+
if (staleChanges.length) {
|
|
554
|
+
items.push(
|
|
555
|
+
healthItem(
|
|
556
|
+
`${staleChanges.length} change(s) inactive > ${staleDays} days`,
|
|
557
|
+
'changes-panel'
|
|
558
|
+
)
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
const blocked = overview.changes.reduce((n, c) => n + c.artifactSummary.blocked, 0);
|
|
562
|
+
if (blocked) {
|
|
563
|
+
items.push(healthItem(`${blocked} blocked artifact(s)`, 'changes-panel'));
|
|
564
|
+
}
|
|
565
|
+
const parseErrors = overview.tickets.filter((t) => t.parseError).length;
|
|
566
|
+
if (parseErrors) {
|
|
567
|
+
items.push(healthItem(`${parseErrors} ticket parse error(s)`, 'tickets-panel'));
|
|
568
|
+
}
|
|
569
|
+
if (activity && activity.available && activity.specDrift) {
|
|
570
|
+
items.push(el('span', { class: 'health-item', text: 'spec drift: code moved, tospec/ did not (7d)' }));
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
ov.health.classList.toggle('health-warn', items.length > 0);
|
|
574
|
+
ov.health.classList.toggle('health-ok', items.length === 0);
|
|
575
|
+
if (items.length === 0) {
|
|
576
|
+
ov.health.replaceChildren(el('span', { class: 'health-item', text: 'All good — no stale changes, blocked artifacts, or parse errors' }));
|
|
577
|
+
} else {
|
|
578
|
+
ov.health.replaceChildren(...items);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function changeRow(c, staleDays, changed) {
|
|
583
|
+
const row = el('a', { class: 'row change-row', href: '#/changes/' + encodeURIComponent(c.name) });
|
|
584
|
+
row.append(el('span', { class: 'row-name', text: c.name }));
|
|
585
|
+
row.append(typeBadge(c.type));
|
|
586
|
+
if (c.artifactSummary.blocked > 0) {
|
|
587
|
+
row.append(el('span', { class: 'badge badge-blocked-warn', text: 'blocked' }));
|
|
588
|
+
}
|
|
589
|
+
if (isStale(c, staleDays)) {
|
|
590
|
+
row.append(el('span', { class: 'badge badge-stale', text: 'stale' }));
|
|
591
|
+
}
|
|
592
|
+
row.append(artifactBlocks(c.artifactSummary.done, c.artifactSummary.total));
|
|
593
|
+
row.append(el('span', { class: 'row-meta', text: `${c.artifactSummary.done}/${c.artifactSummary.total} artifacts` }));
|
|
594
|
+
row.append(el('span', { class: 'row-meta', text: `tasks ${c.taskProgress.completed}/${c.taskProgress.total}` }));
|
|
595
|
+
row.append(el('span', { class: 'row-time', text: relTime(c.lastModified) }));
|
|
596
|
+
if (changed) flash(row);
|
|
597
|
+
return row;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function updateChangesSection(staleDays) {
|
|
601
|
+
const s = ov.changesSection;
|
|
602
|
+
s.replaceChildren(el('h2', { class: 'section-title', text: 'Changes' }));
|
|
603
|
+
if (!overview.changes.length) {
|
|
604
|
+
s.append(emptyNote('No active changes. Create one with: tospec new change <name>'));
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const changedNames = new Set();
|
|
608
|
+
if (prevChangeStamps) {
|
|
609
|
+
for (const c of overview.changes) {
|
|
610
|
+
const prev = prevChangeStamps.get(c.name);
|
|
611
|
+
if (prev !== undefined && prev !== c.lastModified) changedNames.add(c.name);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
for (const c of overview.changes) {
|
|
615
|
+
s.append(changeRow(c, staleDays, changedNames.has(c.name)));
|
|
616
|
+
}
|
|
617
|
+
prevChangeStamps = new Map(overview.changes.map((c) => [c.name, c.lastModified]));
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function updateTicketsSection() {
|
|
621
|
+
const s = ov.ticketsSection;
|
|
622
|
+
s.replaceChildren(el('h2', { class: 'section-title', text: 'Tickets' }));
|
|
623
|
+
if (!overview.tickets.length) {
|
|
624
|
+
s.append(emptyNote('No tickets.'));
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
for (const t of overview.tickets) {
|
|
628
|
+
const row = el('div', { class: 'row ticket-row' + (t.parseError ? ' row-error' : '') });
|
|
629
|
+
row.append(el('span', { class: 'row-time mono', text: fmtStamp(t.date) }));
|
|
630
|
+
row.append(el('span', { class: 'row-name', text: t.title }));
|
|
631
|
+
if (t.type) row.append(el('span', { class: 'badge badge-' + t.type, text: t.type }));
|
|
632
|
+
if (t.change) {
|
|
633
|
+
row.append(el('a', {
|
|
634
|
+
class: 'ticket-change',
|
|
635
|
+
href: '#/changes/' + encodeURIComponent(t.change),
|
|
636
|
+
text: t.change,
|
|
637
|
+
}));
|
|
638
|
+
}
|
|
639
|
+
if (t.parseError) row.append(el('span', { class: 'parse-error', text: 'parse error' }));
|
|
640
|
+
else if (t.summary) row.append(el('span', { class: 'ticket-summary', text: t.summary }));
|
|
641
|
+
if (prevTicketFiles && !prevTicketFiles.has(t.file)) flash(row);
|
|
642
|
+
s.append(row);
|
|
643
|
+
}
|
|
644
|
+
prevTicketFiles = new Set(overview.tickets.map((t) => t.file));
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function updateSpecsSection() {
|
|
648
|
+
const s = ov.specsSection;
|
|
649
|
+
s.replaceChildren(el('h2', { class: 'section-title', text: 'Specs' }));
|
|
650
|
+
if (!overview.specs.length) {
|
|
651
|
+
s.append(emptyNote('No specs.'));
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
for (const sp of overview.specs) {
|
|
655
|
+
const row = el('a', { class: 'row spec-row', href: '#/specs/' + encodeURIComponent(sp.id) });
|
|
656
|
+
row.append(el('span', { class: 'row-name mono', text: sp.id }));
|
|
657
|
+
row.append(el('span', { class: 'row-meta', text: `requirements ${sp.requirementCount}` }));
|
|
658
|
+
s.append(row);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function renderArchive(body, data) {
|
|
663
|
+
const changes = data.changes || [];
|
|
664
|
+
const tickets = data.tickets || [];
|
|
665
|
+
body.replaceChildren();
|
|
666
|
+
if (!changes.length && !tickets.length) {
|
|
667
|
+
body.append(emptyNote('Nothing archived.'));
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
if (changes.length) {
|
|
671
|
+
body.append(el('h4', { text: 'Changes' }));
|
|
672
|
+
for (const c of changes) {
|
|
673
|
+
body.append(el('div', { class: 'row archive-row' }, [
|
|
674
|
+
el('span', { class: 'row-name', text: c.name || c.dirName }),
|
|
675
|
+
el('span', { class: 'row-time mono', text: fmtStamp(c.stamp) }),
|
|
676
|
+
]));
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
if (tickets.length) {
|
|
680
|
+
body.append(el('h4', { text: 'Tickets' }));
|
|
681
|
+
for (const t of tickets) {
|
|
682
|
+
body.append(el('div', { class: 'row archive-row' }, [
|
|
683
|
+
el('span', { class: 'row-time mono', text: fmtStamp(t.date) }),
|
|
684
|
+
el('span', { class: 'row-name', text: t.title }),
|
|
685
|
+
]));
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function archiveSection(count) {
|
|
691
|
+
const details = el('details', { class: 'archive' });
|
|
692
|
+
details.append(el('summary', {}, ['Archive (' + count + ')']));
|
|
693
|
+
const body = el('div', { class: 'archive-body' });
|
|
694
|
+
body.append(emptyNote('Loading...'));
|
|
695
|
+
details.append(body);
|
|
696
|
+
let loaded = false;
|
|
697
|
+
details.addEventListener('toggle', async () => {
|
|
698
|
+
if (!details.open || loaded) return;
|
|
699
|
+
loaded = true;
|
|
700
|
+
try {
|
|
701
|
+
renderArchive(body, await api('/api/archive'));
|
|
702
|
+
} catch {
|
|
703
|
+
loaded = false;
|
|
704
|
+
body.replaceChildren(emptyNote('Failed to load archive.'));
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
return details;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function updateOverview() {
|
|
711
|
+
const staleDays = (activity && activity.staleDays) || 14;
|
|
712
|
+
|
|
713
|
+
updateHealthStrip(staleDays);
|
|
714
|
+
|
|
715
|
+
countUp(ov.cards.changes.count, overview.counts.changes);
|
|
716
|
+
countUp(ov.cards.tickets.count, overview.counts.tickets);
|
|
717
|
+
countUp(ov.cards.specs.count, overview.counts.specs);
|
|
718
|
+
countUp(ov.cards.archived.count, overview.counts.archived);
|
|
719
|
+
|
|
720
|
+
// Activity chart (hidden with a note when git is unavailable — ADR D6).
|
|
721
|
+
if (hasChart) {
|
|
722
|
+
if (activity && activity.available) {
|
|
723
|
+
ov.activity.wrap.style.display = '';
|
|
724
|
+
ov.activity.note.style.display = 'none';
|
|
725
|
+
updateActivityChart(ov.activityChart, activity.days);
|
|
726
|
+
} else {
|
|
727
|
+
ov.activity.wrap.style.display = 'none';
|
|
728
|
+
ov.activity.note.textContent = activity
|
|
729
|
+
? `No git activity (${activity.reason || 'unavailable'}).`
|
|
730
|
+
: 'Loading activity...';
|
|
731
|
+
ov.activity.note.style.display = '';
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const done = overview.changes.reduce((n, c) => n + c.artifactSummary.done, 0);
|
|
735
|
+
const blocked = overview.changes.reduce((n, c) => n + c.artifactSummary.blocked, 0);
|
|
736
|
+
const total = overview.changes.reduce((n, c) => n + c.artifactSummary.total, 0);
|
|
737
|
+
const ready = total - done - blocked;
|
|
738
|
+
if (total > 0) {
|
|
739
|
+
ov.doughnut.wrap.style.display = '';
|
|
740
|
+
ov.doughnut.note.style.display = 'none';
|
|
741
|
+
updateStatusDoughnut(
|
|
742
|
+
ov.doughnutChart,
|
|
743
|
+
[done, ready, blocked],
|
|
744
|
+
Math.round((done / total) * 100) + '%'
|
|
745
|
+
);
|
|
746
|
+
} else {
|
|
747
|
+
ov.doughnut.wrap.style.display = 'none';
|
|
748
|
+
ov.doughnut.note.textContent = 'No artifacts.';
|
|
749
|
+
ov.doughnut.note.style.display = '';
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
updateTaskProgress();
|
|
755
|
+
updateChangesSection(staleDays);
|
|
756
|
+
updateTicketsSection();
|
|
757
|
+
updateSpecsSection();
|
|
758
|
+
|
|
759
|
+
// The archive accordion lazy-loads on expand; rebuild only when the count
|
|
760
|
+
// changes so an open accordion isn't collapsed by every SSE refresh.
|
|
761
|
+
if (ov.archiveCount !== overview.counts.archived) {
|
|
762
|
+
ov.archiveCount = overview.counts.archived;
|
|
763
|
+
ov.archiveSlot.replaceChildren(archiveSection(overview.counts.archived));
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function renderOverview() {
|
|
768
|
+
if (!overview) {
|
|
769
|
+
view.replaceChildren(emptyNote('Loading...'));
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
if (!ov || !view.contains(ov.health)) buildOverviewSkeleton();
|
|
773
|
+
updateOverview();
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// --- detail views ----------------------------------------------------------
|
|
777
|
+
|
|
778
|
+
function leaveOverview() {
|
|
779
|
+
ov = null;
|
|
780
|
+
destroyCharts();
|
|
781
|
+
prevChangeStamps = null;
|
|
782
|
+
prevTicketFiles = null;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function backLink() {
|
|
786
|
+
return el('a', { class: 'back', href: '#/', text: '← back' });
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function metaItem(label, value) {
|
|
790
|
+
return el('span', { class: 'meta-item' }, [
|
|
791
|
+
el('span', { class: 'meta-label', text: label }),
|
|
792
|
+
el('span', { class: 'meta-value', text: value }),
|
|
793
|
+
]);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** File accordion: renders `/api/render` HTML lazily on first expand. */
|
|
797
|
+
function fileAccordion(file) {
|
|
798
|
+
const details = el('details', { class: 'file' });
|
|
799
|
+
details.append(el('summary', {}, [
|
|
800
|
+
el('span', { class: 'file-path mono', text: file.path }),
|
|
801
|
+
file.artifactId ? el('span', { class: 'badge badge-artifact', text: file.artifactId }) : null,
|
|
802
|
+
]));
|
|
803
|
+
const body = el('div', { class: 'rendered' });
|
|
804
|
+
details.append(body);
|
|
805
|
+
let loaded = false;
|
|
806
|
+
details.addEventListener('toggle', async () => {
|
|
807
|
+
if (!details.open || loaded) return;
|
|
808
|
+
loaded = true;
|
|
809
|
+
body.textContent = 'Loading...';
|
|
810
|
+
try {
|
|
811
|
+
const data = await api('/api/render?file=' + encodeURIComponent(file.path));
|
|
812
|
+
body.innerHTML = data.html; // controlled container; server-rendered markdown
|
|
813
|
+
} catch (e) {
|
|
814
|
+
loaded = false;
|
|
815
|
+
body.textContent = 'Failed to render (' + (e.status || 'error') + ').';
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
return details;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** Detail-page chart block: task ring + artifact stacked bar + change activity. */
|
|
822
|
+
function detailCharts(detail, name) {
|
|
823
|
+
if (!hasChart) return null;
|
|
824
|
+
const t = chartTheme();
|
|
825
|
+
const grid = el('section', { class: 'charts-grid charts-grid-3' });
|
|
826
|
+
|
|
827
|
+
// Task completion ring.
|
|
828
|
+
const task = chartPanel('Tasks', 150);
|
|
829
|
+
grid.append(task.panel);
|
|
830
|
+
const taskChart = buildStatusDoughnut(task.canvas, ['completed', 'remaining'], [t.series1, t.track]);
|
|
831
|
+
const { completed, total } = detail.taskProgress;
|
|
832
|
+
if (total > 0) {
|
|
833
|
+
updateStatusDoughnut(taskChart, [completed, total - completed], Math.round((completed / total) * 100) + '%');
|
|
834
|
+
} else {
|
|
835
|
+
task.wrap.style.display = 'none';
|
|
836
|
+
task.note.textContent = 'No tasks.';
|
|
837
|
+
task.note.style.display = '';
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// Artifact status stacked bar (single row, status palette).
|
|
841
|
+
const art = chartPanel('Artifacts', 150);
|
|
842
|
+
grid.append(art.panel);
|
|
843
|
+
const counts = { done: 0, ready: 0, blocked: 0 };
|
|
844
|
+
for (const a of detail.artifacts) counts[a.status] = (counts[a.status] || 0) + 1;
|
|
845
|
+
if (detail.artifacts.length > 0) {
|
|
846
|
+
newChart(art.canvas, {
|
|
847
|
+
type: 'bar',
|
|
848
|
+
data: {
|
|
849
|
+
labels: [''],
|
|
850
|
+
datasets: [
|
|
851
|
+
{ label: 'done', data: [counts.done], backgroundColor: t.done, borderRadius: 4, barThickness: 18 },
|
|
852
|
+
{ label: 'ready', data: [counts.ready], backgroundColor: t.ready, borderRadius: 4, barThickness: 18 },
|
|
853
|
+
{ label: 'blocked', data: [counts.blocked], backgroundColor: t.blocked, borderRadius: 4, barThickness: 18 },
|
|
854
|
+
],
|
|
855
|
+
},
|
|
856
|
+
options: {
|
|
857
|
+
indexAxis: 'y',
|
|
858
|
+
responsive: true,
|
|
859
|
+
maintainAspectRatio: false,
|
|
860
|
+
plugins: { legend: { position: 'bottom', labels: { color: t.muted, boxWidth: 10 } } },
|
|
861
|
+
scales: {
|
|
862
|
+
x: { stacked: true, ticks: { color: t.muted, precision: 0 }, grid: { display: false } },
|
|
863
|
+
y: { stacked: true, ticks: { display: false }, grid: { display: false } },
|
|
864
|
+
},
|
|
865
|
+
},
|
|
866
|
+
});
|
|
867
|
+
} else {
|
|
868
|
+
art.wrap.style.display = 'none';
|
|
869
|
+
art.note.textContent = 'No artifacts.';
|
|
870
|
+
art.note.style.display = '';
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// This change's 30-day git activity (lazy: fetched after mount).
|
|
874
|
+
const act = chartPanel('Activity (30 days)', 150);
|
|
875
|
+
grid.append(act.panel);
|
|
876
|
+
act.wrap.style.display = 'none';
|
|
877
|
+
act.note.textContent = 'Loading activity...';
|
|
878
|
+
act.note.style.display = '';
|
|
879
|
+
api('/api/activity?change=' + encodeURIComponent(name))
|
|
880
|
+
.then((data) => {
|
|
881
|
+
if (!view.contains(act.panel)) return; // user navigated away
|
|
882
|
+
if (data.available) {
|
|
883
|
+
act.note.style.display = 'none';
|
|
884
|
+
act.wrap.style.display = '';
|
|
885
|
+
updateActivityChart(buildActivityChart(act.canvas, 'this change'), data.days);
|
|
886
|
+
} else {
|
|
887
|
+
act.note.textContent = `No git activity (${data.reason || 'unavailable'}).`;
|
|
888
|
+
}
|
|
889
|
+
})
|
|
890
|
+
.catch(() => {
|
|
891
|
+
act.note.textContent = 'Failed to load activity.';
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
return grid;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
async function renderChangeDetail(name) {
|
|
898
|
+
leaveOverview();
|
|
899
|
+
view.replaceChildren(backLink(), emptyNote('Loading...'));
|
|
900
|
+
let detail;
|
|
901
|
+
try {
|
|
902
|
+
detail = await api('/api/changes/' + encodeURIComponent(name));
|
|
903
|
+
} catch (e) {
|
|
904
|
+
const msg = e.status === 404 ? `Change "${name}" not found.` : 'Failed to load change.';
|
|
905
|
+
view.replaceChildren(backLink(), emptyNote(msg));
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const meta = el('div', { class: 'meta' }, [
|
|
910
|
+
metaItem('schema', detail.metadata.schema || '—'),
|
|
911
|
+
detail.metadata.type ? metaItem('type', detail.metadata.type) : null,
|
|
912
|
+
detail.metadata.created ? metaItem('created', detail.metadata.created) : null,
|
|
913
|
+
detail.metadata.goal ? metaItem('goal', detail.metadata.goal) : null,
|
|
914
|
+
]);
|
|
915
|
+
|
|
916
|
+
const artifacts = el('section', { class: 'panel' });
|
|
917
|
+
artifacts.append(el('h3', { text: 'Artifacts' }));
|
|
918
|
+
for (const a of detail.artifacts) {
|
|
919
|
+
const row = el('div', { class: 'artifact-row' }, [
|
|
920
|
+
el('span', { class: 'dot dot-' + a.status }),
|
|
921
|
+
el('span', { class: 'artifact-id mono', text: a.id }),
|
|
922
|
+
el('span', { class: 'artifact-status', text: a.status }),
|
|
923
|
+
]);
|
|
924
|
+
if (a.status === 'blocked' && a.missingDeps && a.missingDeps.length) {
|
|
925
|
+
row.append(el('span', { class: 'blocked-by', text: 'blocked by: ' + a.missingDeps.join(', ') }));
|
|
926
|
+
}
|
|
927
|
+
artifacts.append(row);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const files = el('section', { class: 'panel' });
|
|
931
|
+
files.append(el('h3', { text: 'Files' }));
|
|
932
|
+
if (!detail.files.length) files.append(emptyNote('No files yet.'));
|
|
933
|
+
for (const f of detail.files) files.append(fileAccordion(f));
|
|
934
|
+
|
|
935
|
+
const nodes = [
|
|
936
|
+
backLink(),
|
|
937
|
+
el('h2', { text: detail.name }),
|
|
938
|
+
meta,
|
|
939
|
+
el('p', { class: 'task-progress', text: `Tasks ${detail.taskProgress.completed}/${detail.taskProgress.total}` }),
|
|
940
|
+
detailCharts(detail, name),
|
|
941
|
+
artifacts,
|
|
942
|
+
files,
|
|
943
|
+
].filter(Boolean);
|
|
944
|
+
view.replaceChildren(...nodes);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
async function renderSpecDetail(id) {
|
|
948
|
+
leaveOverview();
|
|
949
|
+
view.replaceChildren(backLink(), el('h2', { text: id }), emptyNote('Loading...'));
|
|
950
|
+
const nodes = [backLink(), el('h2', { text: id })];
|
|
951
|
+
|
|
952
|
+
try {
|
|
953
|
+
const spec = await api('/api/specs/' + encodeURIComponent(id));
|
|
954
|
+
nodes.push(el('p', { class: 'muted', text: `requirements ${spec.requirements.length}` }));
|
|
955
|
+
} catch (e) {
|
|
956
|
+
if (e.status === 404) {
|
|
957
|
+
view.replaceChildren(backLink(), emptyNote(`Spec "${id}" not found.`));
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const body = el('div', { class: 'rendered' });
|
|
963
|
+
body.textContent = 'Loading...';
|
|
964
|
+
nodes.push(body);
|
|
965
|
+
view.replaceChildren(...nodes);
|
|
966
|
+
|
|
967
|
+
try {
|
|
968
|
+
const data = await api('/api/render?file=' + encodeURIComponent(`tospec/specs/${id}/spec.md`));
|
|
969
|
+
body.innerHTML = data.html; // controlled container; server-rendered markdown
|
|
970
|
+
} catch {
|
|
971
|
+
body.textContent = 'Failed to render spec.';
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// --- routing ---------------------------------------------------------------
|
|
976
|
+
|
|
977
|
+
function isOverviewRoute() {
|
|
978
|
+
const h = location.hash;
|
|
979
|
+
return h === '' || h === '#' || h === '#/';
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function router() {
|
|
983
|
+
const h = location.hash;
|
|
984
|
+
if (h.startsWith('#/changes/')) {
|
|
985
|
+
renderChangeDetail(decodeURIComponent(h.slice('#/changes/'.length)));
|
|
986
|
+
} else if (h.startsWith('#/specs/')) {
|
|
987
|
+
renderSpecDetail(decodeURIComponent(h.slice('#/specs/'.length)));
|
|
988
|
+
} else {
|
|
989
|
+
renderOverview();
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/** Full route rebuild (theme change): drop the skeleton so charts re-theme. */
|
|
994
|
+
function rebuildRoute() {
|
|
995
|
+
if (!view) return;
|
|
996
|
+
if (isOverviewRoute()) {
|
|
997
|
+
ov = null;
|
|
998
|
+
destroyCharts();
|
|
999
|
+
if (overview) renderOverview();
|
|
1000
|
+
} else {
|
|
1001
|
+
router();
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
window.addEventListener('hashchange', router);
|
|
1006
|
+
refreshBtn.addEventListener('click', () => {
|
|
1007
|
+
if (isOverviewRoute()) {
|
|
1008
|
+
loadOverview();
|
|
1009
|
+
loadActivity();
|
|
1010
|
+
} else {
|
|
1011
|
+
router();
|
|
1012
|
+
}
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
// Live updates: the server pushes a `change` event on any tospec file edit, so
|
|
1016
|
+
// the UI refreshes itself without polling or a manual reload. EventSource
|
|
1017
|
+
// reconnects on its own; we only reflect the link state.
|
|
1018
|
+
function subscribe() {
|
|
1019
|
+
const es = new EventSource('/api/events');
|
|
1020
|
+
es.addEventListener('change', () => {
|
|
1021
|
+
loadOverview(); // refreshes counts/lists/charts (morph + flash on overview)
|
|
1022
|
+
loadActivity();
|
|
1023
|
+
if (!isOverviewRoute()) router(); // refresh the open detail view too
|
|
1024
|
+
});
|
|
1025
|
+
es.onopen = () => setConnected(true);
|
|
1026
|
+
es.onerror = () => setConnected(false);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
router();
|
|
1030
|
+
loadOverview();
|
|
1031
|
+
loadActivity();
|
|
1032
|
+
subscribe();
|