@pi-unipi/kanboard 0.1.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/README.md +137 -0
- package/package.json +54 -0
- package/skills/kanboard-doctor/SKILL.md +71 -0
- package/ui/components/checklist.ts +58 -0
- package/ui/components/copy-button.ts +23 -0
- package/ui/components/status-badge.ts +21 -0
- package/ui/layouts/base.ts +68 -0
- package/ui/milestone/page.ts +140 -0
- package/ui/static/app.js +73 -0
- package/ui/static/style.css +729 -0
- package/ui/workflow/page.ts +182 -0
package/README.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# @pi-unipi/kanboard
|
|
2
|
+
|
|
3
|
+
Visualization layer for unipi workflow data. Kanboard provides an HTTP server with htmx + Alpine.js UI, modular parsers for all workflow document types, two web pages (Milestones + Workflow), a TUI overlay with tasks list and kanban board, and a doctor skill for parser diagnostics.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Start the kanboard server
|
|
9
|
+
/unipi:kanboard
|
|
10
|
+
|
|
11
|
+
# Diagnose parser issues
|
|
12
|
+
/unipi:kanboard-doctor
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Architecture
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
kanboard/
|
|
19
|
+
├── server/ # HTTP server with port allocation
|
|
20
|
+
│ ├── index.ts # Server core (KanboardServer class)
|
|
21
|
+
│ └── routes/ # Route handlers (milestone, workflow)
|
|
22
|
+
├── parser/ # Document parsers
|
|
23
|
+
│ ├── index.ts # ParserRegistry + createDefaultRegistry()
|
|
24
|
+
│ ├── specs.ts # Spec parser (checklist items)
|
|
25
|
+
│ ├── plans.ts # Plan parser (task statuses)
|
|
26
|
+
│ ├── milestones.ts # Milestone parser
|
|
27
|
+
│ └── remaining.ts # Quick-work, debug, fix, chore, review
|
|
28
|
+
├── ui/ # Web UI
|
|
29
|
+
│ ├── layouts/ # Base HTML layout
|
|
30
|
+
│ ├── static/ # CSS + JS (style.css, app.js)
|
|
31
|
+
│ ├── components/ # Reusable components
|
|
32
|
+
│ ├── milestone/ # Milestone page renderer
|
|
33
|
+
│ └── workflow/ # Workflow page renderer
|
|
34
|
+
├── tui/ # TUI overlay
|
|
35
|
+
│ └── kanboard-overlay.ts
|
|
36
|
+
├── skills/ # Skills
|
|
37
|
+
│ └── kanboard-doctor/ # Parser diagnostics
|
|
38
|
+
├── commands.ts # Command registration
|
|
39
|
+
├── types.ts # Shared TypeScript types
|
|
40
|
+
└── index.ts # Extension entry point
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Web Pages
|
|
44
|
+
|
|
45
|
+
### Milestones (`/`)
|
|
46
|
+
- Displays MILESTONES.md phases with progress bars
|
|
47
|
+
- Checklist items with status indicators (✓ done, ○ todo)
|
|
48
|
+
- Collapsible sections per phase
|
|
49
|
+
- Copy-to-clipboard for `/unipi:milestone-update`
|
|
50
|
+
|
|
51
|
+
### Workflow (`/workflow`)
|
|
52
|
+
- Cards grouped by document type (specs, plans, fixes, etc.)
|
|
53
|
+
- Progress indicators per card
|
|
54
|
+
- Alpine.js filtering by status (All, To Do, In Progress, Done)
|
|
55
|
+
- Copy-to-clipboard for relevant commands
|
|
56
|
+
|
|
57
|
+
## TUI Overlay
|
|
58
|
+
|
|
59
|
+
Two tabs accessible via the kanboard overlay:
|
|
60
|
+
|
|
61
|
+
- **Tasks** — Flat list of all tasks from all documents with status icons
|
|
62
|
+
- **Board** — Kanban columns (To Do / In Progress / Done)
|
|
63
|
+
|
|
64
|
+
### Controls
|
|
65
|
+
- `j/k` — Navigate up/down
|
|
66
|
+
- `h/l` — Switch columns (Board tab)
|
|
67
|
+
- `Tab` or `b` — Switch between Tasks/Board tabs
|
|
68
|
+
- `t` — Switch to Tasks tab
|
|
69
|
+
- `gg/G` — Jump to top/bottom
|
|
70
|
+
- `q/Esc` — Close overlay
|
|
71
|
+
|
|
72
|
+
## API Endpoints
|
|
73
|
+
|
|
74
|
+
| Method | Path | Description |
|
|
75
|
+
|--------|------|-------------|
|
|
76
|
+
| GET | `/` | Milestone page |
|
|
77
|
+
| GET | `/workflow` | Workflow page |
|
|
78
|
+
| GET | `/api/milestones` | Milestone JSON data |
|
|
79
|
+
| GET | `/api/workflow` | Workflow JSON data |
|
|
80
|
+
| POST | `/api/docs/:type/:file/items/:line` | Update item status |
|
|
81
|
+
|
|
82
|
+
## Parser System
|
|
83
|
+
|
|
84
|
+
Kanboard parses 8 document types from `.unipi/docs/`:
|
|
85
|
+
|
|
86
|
+
| Type | Directory | What's Parsed |
|
|
87
|
+
|------|-----------|---------------|
|
|
88
|
+
| Spec | `specs/` | `- [ ]` / `- [x]` checklist items |
|
|
89
|
+
| Plan | `plans/` | `unstarted:` / `in-progress:` / `completed:` statuses |
|
|
90
|
+
| Milestone | `MILESTONES.md` | Phase headers + checklist items |
|
|
91
|
+
| Quick-work | `quick-work/` | Title + checklist items |
|
|
92
|
+
| Debug | `debug/` | Headers + checklists |
|
|
93
|
+
| Fix | `fix/` | Headers + checklists + related debug ref |
|
|
94
|
+
| Chore | `chore/` | Chore steps as checklist items |
|
|
95
|
+
| Review | `reviews/` | Review remarks as checklist items |
|
|
96
|
+
|
|
97
|
+
### Parser Warnings
|
|
98
|
+
|
|
99
|
+
Parsers are resilient — they collect warnings per file and return partial results:
|
|
100
|
+
- Empty checkbox text
|
|
101
|
+
- Malformed checkboxes
|
|
102
|
+
- Missing frontmatter
|
|
103
|
+
- Unparseable lines
|
|
104
|
+
|
|
105
|
+
Warnings are surfaced in the kanboard-doctor skill.
|
|
106
|
+
|
|
107
|
+
## Doctor Skill
|
|
108
|
+
|
|
109
|
+
The `kanboard-doctor` skill runs all parsers and produces a diagnostic report:
|
|
110
|
+
|
|
111
|
+
1. **Run All Parsers** — Parse every document
|
|
112
|
+
2. **Collect Errors** — Group warnings by file
|
|
113
|
+
3. **Present Report** — Structured error listing
|
|
114
|
+
4. **Fix One by One** — Suggest fixes, ask user to confirm
|
|
115
|
+
5. **Re-validate** — Re-run parser after each fix
|
|
116
|
+
|
|
117
|
+
## Server Configuration
|
|
118
|
+
|
|
119
|
+
Default configuration (from `@pi-unipi/core`):
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
KANBOARD_DEFAULTS = {
|
|
123
|
+
PORT: 8165, // Starting port
|
|
124
|
+
MAX_PORT: 8175, // Maximum port to try
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
- Port allocation: tries 8165, increments on EADDRINUSE
|
|
129
|
+
- PID file: `.unipi/kanboard.pid`
|
|
130
|
+
- Graceful shutdown on SIGINT/SIGTERM
|
|
131
|
+
- Static files served from `ui/static/`
|
|
132
|
+
|
|
133
|
+
## Dependencies
|
|
134
|
+
|
|
135
|
+
- `@pi-unipi/core` — Shared constants and utilities
|
|
136
|
+
- `@mariozechner/pi-coding-agent` — Extension API
|
|
137
|
+
- `@mariozechner/pi-tui` — TUI overlay API
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pi-unipi/kanboard",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Visualization layer for unipi workflow — HTTP server with htmx/Alpine.js UI, modular parsers, TUI overlay, and kanban board",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Neuron Mr White",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Neuron-Mr-White/unipi.git",
|
|
11
|
+
"directory": "packages/kanboard"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"pi-package",
|
|
15
|
+
"pi-extension",
|
|
16
|
+
"pi-coding-agent",
|
|
17
|
+
"unipi",
|
|
18
|
+
"kanboard",
|
|
19
|
+
"kanban",
|
|
20
|
+
"visualization",
|
|
21
|
+
"milestones",
|
|
22
|
+
"workflow"
|
|
23
|
+
],
|
|
24
|
+
"files": [
|
|
25
|
+
"src/**/*.ts",
|
|
26
|
+
"ui/**/*.ts",
|
|
27
|
+
"ui/**/*.css",
|
|
28
|
+
"ui/**/*.js",
|
|
29
|
+
"skills/**/*",
|
|
30
|
+
"README.md"
|
|
31
|
+
],
|
|
32
|
+
"pi": {
|
|
33
|
+
"extensions": [
|
|
34
|
+
"index.ts"
|
|
35
|
+
],
|
|
36
|
+
"skills": [
|
|
37
|
+
"skills"
|
|
38
|
+
]
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@pi-unipi/core": "*"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
48
|
+
"@mariozechner/pi-tui": "*"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/node": "^25.6.0",
|
|
52
|
+
"typescript": "^6.0.0"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: kanboard-doctor
|
|
3
|
+
description: "Diagnose and fix kanboard parser issues — validates all workflow documents, reports errors, suggests fixes."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Kanboard Doctor
|
|
7
|
+
|
|
8
|
+
Diagnose parser issues across all workflow documents. Non-destructive — only suggests fixes, asks user to confirm.
|
|
9
|
+
|
|
10
|
+
## Phase 1: Run All Parsers
|
|
11
|
+
|
|
12
|
+
Execute each parser against its document type directory:
|
|
13
|
+
|
|
14
|
+
1. Load the parser registry from `@pi-unipi/kanboard`
|
|
15
|
+
2. Run `registry.parseAll(".unipi/docs")` to parse all documents
|
|
16
|
+
3. Collect all `ParsedDoc` results including their `warnings` arrays
|
|
17
|
+
|
|
18
|
+
## Phase 2: Collect Errors
|
|
19
|
+
|
|
20
|
+
Group warnings and errors by file with line numbers:
|
|
21
|
+
|
|
22
|
+
1. For each `ParsedDoc` with `warnings.length > 0`:
|
|
23
|
+
- Group by `filePath`
|
|
24
|
+
- Include line numbers where available
|
|
25
|
+
- Categorize: malformed checkboxes, empty fields, parse failures
|
|
26
|
+
2. Also flag documents that returned 0 items (may indicate parsing failure)
|
|
27
|
+
|
|
28
|
+
## Phase 3: Present Report
|
|
29
|
+
|
|
30
|
+
Show a structured error report:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
📋 Kanboard Doctor Report
|
|
34
|
+
|
|
35
|
+
Files scanned: N
|
|
36
|
+
Files with issues: M
|
|
37
|
+
|
|
38
|
+
📄 .unipi/docs/specs/example.md
|
|
39
|
+
⚠ Line 15: Empty checkbox text
|
|
40
|
+
⚠ Line 23: Malformed checkbox (missing bracket)
|
|
41
|
+
|
|
42
|
+
📄 .unipi/docs/plans/old-plan.md
|
|
43
|
+
⚠ Line 5: Empty task name after status
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Phase 4: Fix One by One
|
|
47
|
+
|
|
48
|
+
For each issue, suggest a fix and ask user to confirm:
|
|
49
|
+
|
|
50
|
+
1. Show the problematic line with context
|
|
51
|
+
2. Suggest the corrected version
|
|
52
|
+
3. Ask: "Apply this fix? (y/n)"
|
|
53
|
+
4. If yes, apply the fix using the edit tool
|
|
54
|
+
5. Move to next issue
|
|
55
|
+
|
|
56
|
+
**Non-destructive rules:**
|
|
57
|
+
- Never modify without asking
|
|
58
|
+
- Show before/after for each change
|
|
59
|
+
- Allow skipping individual fixes
|
|
60
|
+
- Allow "fix all" for simple patterns (e.g., trailing whitespace)
|
|
61
|
+
|
|
62
|
+
## Phase 5: Re-validate
|
|
63
|
+
|
|
64
|
+
After each fix, re-run the parser on the modified file:
|
|
65
|
+
|
|
66
|
+
1. Parse the file again
|
|
67
|
+
2. Verify the specific error is resolved
|
|
68
|
+
3. Check that no new errors were introduced
|
|
69
|
+
4. Report: "✓ Fixed" or "✗ Still has issues"
|
|
70
|
+
|
|
71
|
+
After all fixes, run full `registry.parseAll()` to confirm clean state.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/kanboard — Checklist Component
|
|
3
|
+
*
|
|
4
|
+
* Reusable checklist renderer with status indicators and copy buttons.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ParsedItem } from "../../types.js";
|
|
8
|
+
|
|
9
|
+
/** Escape HTML */
|
|
10
|
+
function esc(text: string): string {
|
|
11
|
+
return text
|
|
12
|
+
.replace(/&/g, "&")
|
|
13
|
+
.replace(/</g, "<")
|
|
14
|
+
.replace(/>/g, ">")
|
|
15
|
+
.replace(/"/g, """)
|
|
16
|
+
.replace(/'/g, "'");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Status icon */
|
|
20
|
+
function statusIcon(status: string): string {
|
|
21
|
+
switch (status) {
|
|
22
|
+
case "done":
|
|
23
|
+
return "✓";
|
|
24
|
+
case "in-progress":
|
|
25
|
+
return "◐";
|
|
26
|
+
default:
|
|
27
|
+
return "○";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Render a checklist of items.
|
|
33
|
+
*/
|
|
34
|
+
export function renderChecklist(items: ParsedItem[]): string {
|
|
35
|
+
if (items.length === 0) {
|
|
36
|
+
return '<div class="empty-state"><p class="empty-state-text">No items</p></div>';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const rows = items
|
|
40
|
+
.map(
|
|
41
|
+
(item) => `
|
|
42
|
+
<li class="checklist-item">
|
|
43
|
+
<span class="checklist-status ${item.status}" aria-label="${item.status}">
|
|
44
|
+
<span aria-hidden="true">${statusIcon(item.status)}</span>
|
|
45
|
+
<span class="visually-hidden">${item.status}</span>
|
|
46
|
+
</span>
|
|
47
|
+
<span class="checklist-text${item.status === "done" ? " done" : ""}">${esc(item.text)}</span>
|
|
48
|
+
${
|
|
49
|
+
item.command
|
|
50
|
+
? `<button class="copy-btn" onclick="copyToClipboard('${esc(item.command)}', event)" aria-label="Copy ${esc(item.command)} to clipboard">${esc(item.command)}</button>`
|
|
51
|
+
: ""
|
|
52
|
+
}
|
|
53
|
+
</li>`,
|
|
54
|
+
)
|
|
55
|
+
.join("\n");
|
|
56
|
+
|
|
57
|
+
return `<ul class="checklist">${rows}</ul>`;
|
|
58
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/kanboard — Copy Button Component
|
|
3
|
+
*
|
|
4
|
+
* Reusable copy-to-clipboard button with visual feedback.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Escape HTML */
|
|
8
|
+
function esc(text: string): string {
|
|
9
|
+
return text
|
|
10
|
+
.replace(/&/g, "&")
|
|
11
|
+
.replace(/</g, "<")
|
|
12
|
+
.replace(/>/g, ">")
|
|
13
|
+
.replace(/"/g, """)
|
|
14
|
+
.replace(/'/g, "'");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Render a copy-to-clipboard button.
|
|
19
|
+
*/
|
|
20
|
+
export function renderCopyButton(text: string, label?: string): string {
|
|
21
|
+
const displayLabel = label ?? (text.length > 40 ? text.slice(0, 37) + "..." : text);
|
|
22
|
+
return `<button class="copy-btn" onclick="copyToClipboard('${esc(text)}', event)" title="${esc(text)}" aria-label="Copy ${esc(displayLabel)} to clipboard">${esc(displayLabel)}</button>`;
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/kanboard — Status Badge Component
|
|
3
|
+
*
|
|
4
|
+
* Color-coded status badge for items.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Status badge labels */
|
|
8
|
+
const BADGE_LABELS: Record<string, string> = {
|
|
9
|
+
done: "Done",
|
|
10
|
+
"in-progress": "In Progress",
|
|
11
|
+
todo: "To Do",
|
|
12
|
+
reviewed: "Reviewed",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Render a status badge.
|
|
17
|
+
*/
|
|
18
|
+
export function renderStatusBadge(status: string): string {
|
|
19
|
+
const label = BADGE_LABELS[status] ?? status;
|
|
20
|
+
return `<span class="badge badge-${status}">${label}</span>`;
|
|
21
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/kanboard — Base HTML Layout
|
|
3
|
+
*
|
|
4
|
+
* Shared layout for all kanboard web pages.
|
|
5
|
+
* Includes htmx + Alpine.js CDN, Google Fonts, and responsive layout.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Active page type */
|
|
9
|
+
export type ActivePage = "milestones" | "workflow";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Render the full HTML layout wrapping page content.
|
|
13
|
+
*/
|
|
14
|
+
export function renderLayout(
|
|
15
|
+
title: string,
|
|
16
|
+
content: string,
|
|
17
|
+
activePage: ActivePage,
|
|
18
|
+
): string {
|
|
19
|
+
return `<!DOCTYPE html>
|
|
20
|
+
<html lang="en">
|
|
21
|
+
<head>
|
|
22
|
+
<meta charset="UTF-8">
|
|
23
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
24
|
+
<title>${escapeHtml(title)} — Kanboard</title>
|
|
25
|
+
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
|
26
|
+
<script defer src="https://unpkg.com/alpinejs@3.14.3/dist/cdn.min.js"></script>
|
|
27
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
28
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
29
|
+
<link href="https://fonts.googleapis.com/css2?family=Bodoni+Moda:ital,opsz,wght@1,6..96,400..900&family=Onest:wght@100..900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
30
|
+
<link rel="stylesheet" href="/static/style.css">
|
|
31
|
+
</head>
|
|
32
|
+
<body>
|
|
33
|
+
<nav class="navbar">
|
|
34
|
+
<div class="nav-brand">
|
|
35
|
+
<span class="nav-icon">✦</span>
|
|
36
|
+
<span class="nav-title">Kanboard</span>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="nav-links">
|
|
39
|
+
<a href="/" class="nav-link ${activePage === "milestones" ? "active" : ""}" ${activePage === "milestones" ? 'aria-current="page"' : ""}>
|
|
40
|
+
Milestones
|
|
41
|
+
</a>
|
|
42
|
+
<a href="/workflow" class="nav-link ${activePage === "workflow" ? "active" : ""}" ${activePage === "workflow" ? 'aria-current="page"' : ""}>
|
|
43
|
+
Workflow
|
|
44
|
+
</a>
|
|
45
|
+
</div>
|
|
46
|
+
</nav>
|
|
47
|
+
|
|
48
|
+
<main class="container">
|
|
49
|
+
${content}
|
|
50
|
+
</main>
|
|
51
|
+
|
|
52
|
+
<footer class="footer">
|
|
53
|
+
<span>pi-unipi / kanboard</span>
|
|
54
|
+
</footer>
|
|
55
|
+
|
|
56
|
+
<script src="/static/app.js"></script>
|
|
57
|
+
</body>
|
|
58
|
+
</html>`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Escape HTML special characters */
|
|
62
|
+
function escapeHtml(text: string): string {
|
|
63
|
+
return text
|
|
64
|
+
.replace(/&/g, "&")
|
|
65
|
+
.replace(/</g, "<")
|
|
66
|
+
.replace(/>/g, ">")
|
|
67
|
+
.replace(/"/g, """);
|
|
68
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/kanboard — Milestone Page
|
|
3
|
+
*
|
|
4
|
+
* Renders the milestone page with phases, progress bars, and checklists.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ParsedDoc, ParsedItem } from "../../types.js";
|
|
8
|
+
import { renderLayout } from "../layouts/base.js";
|
|
9
|
+
|
|
10
|
+
/** Escape HTML special characters */
|
|
11
|
+
function esc(text: string): string {
|
|
12
|
+
return text
|
|
13
|
+
.replace(/&/g, "&")
|
|
14
|
+
.replace(/</g, "<")
|
|
15
|
+
.replace(/>/g, ">")
|
|
16
|
+
.replace(/"/g, """);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Render status icon for an item */
|
|
20
|
+
function statusIcon(status: string): string {
|
|
21
|
+
switch (status) {
|
|
22
|
+
case "done":
|
|
23
|
+
return "✓";
|
|
24
|
+
case "in-progress":
|
|
25
|
+
return "◐";
|
|
26
|
+
default:
|
|
27
|
+
return "○";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Render a single checklist item */
|
|
32
|
+
function renderItem(item: ParsedItem): string {
|
|
33
|
+
const doneClass = item.status === "done" ? " done" : "";
|
|
34
|
+
const commandBtn = item.command
|
|
35
|
+
? `<button class="copy-btn" onclick="copyToClipboard('${esc(item.command)}', event)" aria-label="Copy ${esc(item.command)} to clipboard">${esc(item.command)}</button>`
|
|
36
|
+
: "";
|
|
37
|
+
|
|
38
|
+
return `
|
|
39
|
+
<li class="checklist-item">
|
|
40
|
+
<span class="checklist-status ${item.status}" aria-label="${item.status}">
|
|
41
|
+
<span aria-hidden="true">${statusIcon(item.status)}</span>
|
|
42
|
+
<span class="visually-hidden">${item.status}</span>
|
|
43
|
+
</span>
|
|
44
|
+
<span class="checklist-text${doneClass}">${esc(item.text)}</span>
|
|
45
|
+
${commandBtn}
|
|
46
|
+
</li>`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Render the milestone page */
|
|
50
|
+
export function renderMilestonePage(docs: ParsedDoc[]): string {
|
|
51
|
+
if (docs.length === 0) {
|
|
52
|
+
const content = `
|
|
53
|
+
<div class="page-header">
|
|
54
|
+
<h1 class="page-title">Milestones</h1>
|
|
55
|
+
<p class="page-subtitle">Track project progress</p>
|
|
56
|
+
</div>
|
|
57
|
+
<div class="empty-state">
|
|
58
|
+
<div class="empty-state-icon">✦</div>
|
|
59
|
+
<p class="empty-state-text">No MILESTONES.md found</p>
|
|
60
|
+
</div>`;
|
|
61
|
+
return renderLayout("Milestones", content, "milestones");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const doc = docs[0];
|
|
65
|
+
const totalItems = doc.items.length;
|
|
66
|
+
const doneItems = doc.items.filter((i) => i.status === "done").length;
|
|
67
|
+
const percent = totalItems > 0 ? Math.round((doneItems / totalItems) * 100) : 0;
|
|
68
|
+
|
|
69
|
+
const phases = groupByPhase(doc.items);
|
|
70
|
+
|
|
71
|
+
let phasesHtml = "";
|
|
72
|
+
for (const [phase, items] of Object.entries(phases)) {
|
|
73
|
+
const phaseTotal = items.length;
|
|
74
|
+
const phaseDone = items.filter((i) => i.status === "done").length;
|
|
75
|
+
const phasePercent = phaseTotal > 0 ? Math.round((phaseDone / phaseTotal) * 100) : 0;
|
|
76
|
+
|
|
77
|
+
phasesHtml += `
|
|
78
|
+
<div class="section">
|
|
79
|
+
<button type="button" class="section-header" aria-expanded="true" onclick="toggleSection(event)">
|
|
80
|
+
<span class="section-toggle">▶</span>
|
|
81
|
+
<div class="section-title-wrap">
|
|
82
|
+
<h2 class="section-title">${esc(phase)}</h2>
|
|
83
|
+
</div>
|
|
84
|
+
<span class="section-count">${phaseDone}/${phaseTotal}</span>
|
|
85
|
+
<div class="progress-bar" style="--progress: ${phasePercent};">
|
|
86
|
+
<div class="progress-fill"></div>
|
|
87
|
+
</div>
|
|
88
|
+
</button>
|
|
89
|
+
<div class="section-content">
|
|
90
|
+
<ul class="checklist">
|
|
91
|
+
${items.map(renderItem).join("\n")}
|
|
92
|
+
</ul>
|
|
93
|
+
</div>
|
|
94
|
+
</div>`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const content = `
|
|
98
|
+
<div class="page-header">
|
|
99
|
+
<h1 class="page-title">${esc(doc.title)}</h1>
|
|
100
|
+
<p class="page-subtitle">${doneItems}/${totalItems} items complete</p>
|
|
101
|
+
<div class="progress-bar" style="--progress: ${percent};">
|
|
102
|
+
<div class="progress-fill"></div>
|
|
103
|
+
</div>
|
|
104
|
+
<div class="page-header-stat">${percent}% complete</div>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
${phasesHtml}
|
|
108
|
+
|
|
109
|
+
${doc.warnings.length > 0 ? renderWarnings(doc.warnings) : ""}`;
|
|
110
|
+
|
|
111
|
+
return renderLayout("Milestones", content, "milestones");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Group items by their phase prefix [Phase Name] */
|
|
115
|
+
function groupByPhase(items: ParsedItem[]): Record<string, ParsedItem[]> {
|
|
116
|
+
const phases: Record<string, ParsedItem[]> = {};
|
|
117
|
+
|
|
118
|
+
for (const item of items) {
|
|
119
|
+
const match = item.text.match(/^\[(.+?)\]\s*(.*)$/);
|
|
120
|
+
if (match) {
|
|
121
|
+
const phase = match[1];
|
|
122
|
+
const text = match[2];
|
|
123
|
+
if (!phases[phase]) phases[phase] = [];
|
|
124
|
+
phases[phase].push({ ...item, text });
|
|
125
|
+
} else {
|
|
126
|
+
if (!phases["Other"]) phases["Other"] = [];
|
|
127
|
+
phases["Other"].push(item);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return phases;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Render warnings list */
|
|
135
|
+
function renderWarnings(warnings: string[]): string {
|
|
136
|
+
return `
|
|
137
|
+
<div class="warnings">
|
|
138
|
+
${warnings.map((w) => `<div class="warning-item">${esc(w)}</div>`).join("\n")}
|
|
139
|
+
</div>`;
|
|
140
|
+
}
|
package/ui/static/app.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/* @pi-unipi/kanboard — Client-side JavaScript */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Copy text to clipboard and show feedback.
|
|
5
|
+
*/
|
|
6
|
+
function copyToClipboard(text, event) {
|
|
7
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
8
|
+
const btn = event.currentTarget;
|
|
9
|
+
btn.classList.add("copied");
|
|
10
|
+
const original = btn.innerHTML;
|
|
11
|
+
btn.innerHTML = '<span>copied</span>';
|
|
12
|
+
setTimeout(() => {
|
|
13
|
+
btn.classList.remove("copied");
|
|
14
|
+
btn.innerHTML = original;
|
|
15
|
+
}, 1800);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Toggle a section's expanded state with animation.
|
|
21
|
+
*/
|
|
22
|
+
function toggleSection(event) {
|
|
23
|
+
const header = event.currentTarget;
|
|
24
|
+
const content = header.nextElementSibling;
|
|
25
|
+
if (!content) return;
|
|
26
|
+
|
|
27
|
+
const wasOpen = header.getAttribute("aria-expanded") === "true";
|
|
28
|
+
header.setAttribute("aria-expanded", wasOpen ? "false" : "true");
|
|
29
|
+
|
|
30
|
+
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
31
|
+
|
|
32
|
+
if (!wasOpen) {
|
|
33
|
+
content.style.display = "block";
|
|
34
|
+
if (!prefersReducedMotion) {
|
|
35
|
+
content.style.opacity = "0";
|
|
36
|
+
content.style.transform = "translateY(-4px)";
|
|
37
|
+
requestAnimationFrame(() => {
|
|
38
|
+
content.style.transition = "opacity 0.2s ease, transform 0.2s ease";
|
|
39
|
+
content.style.opacity = "1";
|
|
40
|
+
content.style.transform = "translateY(0)";
|
|
41
|
+
});
|
|
42
|
+
} else {
|
|
43
|
+
content.style.opacity = "1";
|
|
44
|
+
content.style.transform = "translateY(0)";
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
if (!prefersReducedMotion) {
|
|
48
|
+
content.style.transition = "opacity 0.15s ease";
|
|
49
|
+
content.style.opacity = "0";
|
|
50
|
+
setTimeout(() => {
|
|
51
|
+
content.style.display = "none";
|
|
52
|
+
}, 150);
|
|
53
|
+
} else {
|
|
54
|
+
content.style.display = "none";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Alpine.js data for filtering items by status.
|
|
61
|
+
*/
|
|
62
|
+
function kanboardFilters() {
|
|
63
|
+
return {
|
|
64
|
+
filter: "all",
|
|
65
|
+
setFilter(f) {
|
|
66
|
+
this.filter = f;
|
|
67
|
+
},
|
|
68
|
+
isVisible(status) {
|
|
69
|
+
if (this.filter === "all") return true;
|
|
70
|
+
return status === this.filter;
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|