Initial version

This commit is contained in:
Ilia Sharin
2026-05-05 18:42:06 -04:00
commit a2a3b7b72b
21 changed files with 14258 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Build / bundle artifacts
dist/
build/
out/
*.min.js
*.min.css
# Environment & secrets
.env
.env.local
.env.*.local
# OS
.DS_Store
Thumbs.db
Desktop.ini
# Editor
.vscode/
.idea/
*.swp
*.swo
*~
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
*.log
# Cache
.cache/
.parcel-cache/
.eslintcache
.stylelintcache
.npm/
# Python (server/)
__pycache__/
*.py[cod]
*.pyo
.venv/
venv/
*.egg-info/
# Backlog data (personal — don't commit)
backlog.md
backups/
design/
stats.jsonl
+221
View File
@@ -0,0 +1,221 @@
# Personal Backlog
A minimalist, single-user task manager where **a single Markdown file is your database**.
No cloud. No accounts. No vendor lock-in. Your tasks live in a plain `backlog.md` file you can read, edit, and version-control with any tool you already use.
## Why Not Just Use...
| Concern | Todoist / TickTick / Notion | This App |
|---------|-----------------------------|----------|
| Where's my data? | Their servers | A single `.md` file on *your* disk |
| Online required? | Yes (cloud sync) | Never — even the server is localhost-only |
| Vendor lock-in? | You need their app to read your data | It's a `.md` file — read it in VS Code, Vim, `cat` |
| Can I `git commit` my tasks? | No | Yes — it's a text file |
| Can I sync across devices? | Built-in cloud | Any file sync — Dropbox, rsync, git, USB, anything |
| Price | $5–6/mo | Free, forever |
**If you've ever wanted your task list to be just a file — this is it.**
## Quick Start
### Option A: Python Server (all browsers)
Works with Chrome, Firefox, Safari — any browser. The server is a single Python file with zero dependencies.
```bash
cd personal-backlog
python3 server/server.py --port 8080
```
Then open **http://localhost:8080** in your browser.
The server creates `backlog.md`, `backups/`, and `stats.jsonl` in its own directory by default. To store data elsewhere:
```bash
python3 server/server.py --port 8080 --dir ~/my-backlog
```
### Option B: Standalone HTML (Chrome / Edge only)
No Python needed. Just open the HTML file:
```bash
open webapp/index-style-v2.html
```
Or serve it with any static file server:
```bash
cd webapp && python3 -m http.server 3000
# Then open http://localhost:3000/index-style-v2.html
```
On first launch, the browser will prompt you to **select a folder**. This is required because browsers can't access your filesystem without explicit permission. Create a folder (or pick an existing one) and the app will create `backlog.md`, `backups/`, and `stats.jsonl` inside it.
## How It Works
### The Markdown File
All your tasks live in `backlog.md`, structured as:
```markdown
# Backlog
<!-- SECTION: ENTRIES -->
- [ ] [P0] Ship landing page *(due: 2025-06-01, priority: P0, progress: 50)*
- [x] Design mockups *(priority: P0, progress: 100)*
- [/] Implement frontend *(priority: P1, progress: 30)*
- [!] [P1] API integration *(priority: P1, reason: waiting for keys)*
- [>] [P2] Blog post *(priority: P2, due: 2025-07-15)*
<!-- SECTION: HISTORY -->
| Timestamp | Item ID | Action | Details |
|-----------|---------|--------|---------|
| 2025-05-10T14:32:00Z | i-m1 | status_changed | open → done |
<!-- SECTION: INTEGRITY -->
<!-- saved: 2025-05-10T14:35:12Z | checksum: sha256:abc123... | entries: 3 | history: 1 -->
```
You can edit this file in any text editor. The app detects external changes and reloads automatically.
### Features
- **4-level nesting** — Area → Project → Task → Sub-task
- **6 statuses** — open, in-progress (`/`), blocked (`!`), postponed (`>`), done (`x`), cancelled (`-`)
- **Priorities** — P0 (burning) through P3, with drag-and-drop reordering within each priority
- **Progress tracking** — 0–100% per task; done auto-sets to 100%
- **Due dates** — with overdue highlighting
- **Tags** — free-form labels with autocomplete
- **Quick search** — instant text search with hierarchical parent visibility
- **Integrity checks** — SHA-256 checksum on every save; warning-only on mismatch
- **Automatic backups** — timestamped on every save, rotating retention
- **Stats & metrics** — items created/completed, avg time in-progress, most active project — all from real history data
- **Import/Export** — Markdown or JSON, with checksum validation
- **Admin page** — health monitoring, backup browser, stats overview, manual actions
## Two Storage Modes
The app detects which mode to use automatically:
| | API Server | Direct File Access |
|---|---|---|
| **How to start** | Run `python3 server/server.py` | Open `webapp/index-style-v2.html` in Chrome/Edge |
| **Works in** | Any browser | Chrome, Edge only |
| **File access via** | HTTP REST API | File System Access API |
| **Folder picker** | Not needed | Required on first launch |
| **LAN access** | Yes (phone, tablet) | No (local browser only) |
| **Admin shows paths** | Yes (full path) | Folder name only |
### Why the Folder Picker? (Direct Mode)
When you open the HTML file directly, the browser has no access to your filesystem. The **File System Access API** (`showDirectoryPicker()`) is the only way to read and write local files from a web page. You grant permission once; the handle is stored in IndexedDB so it persists across reloads.
**To reset the folder** (pick a different one or clear saved permissions):
1. Open DevTools → Application → IndexedDB → delete the `pb-storage-v2` database
2. Reload the page — you'll be prompted to pick a folder again
## Coherency Between Python and Standalone Versions
Both versions read and write the **exact same `backlog.md` format**. The Parser and Serializer are identical in logic. This means you can:
1. **Start with the Python server** — add tasks, create structure
2. **Shut down the server** — open the same `backlog.md` location via the standalone HTML
3. **Switch freely** — edits in one mode are visible in the other
### How to Switch Modes
**From API server to standalone:**
1. Stop the server (`Ctrl+C`)
2. Open `webapp/index-style-v2.html` in Chrome/Edge
3. When prompted, select the folder that contains your `backlog.md` (e.g. the `server/` directory or wherever `--dir` pointed)
**From standalone to API server:**
1. Note which folder your `backlog.md` lives in
2. Start the server pointing to that folder:
```bash
python3 server/server.py --dir /path/to/your/folder
```
3. Open `http://localhost:8080`
### Important Notes
- **Don't run both modes simultaneously** against the same `backlog.md` — the last writer wins and you may lose edits.
- **External edits** (Vim, VS Code, etc.) are detected automatically via checksum polling every 5 seconds. If you have unsaved changes in the app, you'll get a conflict resolution dialog.
- The **checksum is a save indicator**, not a gate. If it mismatches (e.g. you edited the file by hand), the app still loads it — just with a yellow warning banner. The next save recalculates a correct checksum.
## Project Structure
```
personal-backlog/
├── README.md
├── doc/
│ ├── requirements/requirements.md # Functional & non-functional requirements
│ └── architecture/
│ ├── architecture.md # High-level architecture
│ └── tdd.md # Technical Design Document
├── server/
│ ├── server.py # Python REST API server (~420 LoC, stdlib only)
│ ├── backlog.md # Master data file (created on first run)
│ ├── backups/ # Automatic timestamped backups
│ └── stats.jsonl # Append-only analytics log
├── web/ # V2 source code (React 18 + JSX) + build tooling
│ ├── index.html # Dev entry point (React + Babel from CDN)
│ ├── styles.css # All CSS
│ ├── storage.jsx # Parser, ApiBackend, DirectBackend, SyncPoller
│ ├── helpers.jsx # Utility functions, event handling
│ ├── app.jsx # Root App component, state, save/load lifecycle
│ ├── tree.jsx # Task tree rendering
│ ├── dialogs.jsx # Modal dialogs
│ ├── admin.jsx # Admin dashboard
│ ├── filter-panel.jsx # Filter sidebar
│ ├── tweaks-panel.jsx # Settings panel
│ ├── data.jsx # Seed data (test-only, not loaded by default)
│ ├── bundle.js # Build tool: multi-file JSX → single HTML
│ ├── package.json # @babel/core + @babel/preset-react
│ └── node_modules/
├── webapp/ # Built output — ready to open or deploy
│ └── index-style-v2.html # Self-contained single-file SPA (~348 KB)
└── design/
├── design-v1/ # V1 source (archived, vanilla JS)
└── archive/ # Earlier prototypes
```
## Development
### Running the Dev Server (in-browser Babel)
```bash
cd web
python3 -m http.server 9000
# Open http://localhost:9000 — React + Babel load from CDN, no build step needed
```
### Building the Production Bundle
```bash
cd web
npm install # once, to install @babel/core + @babel/preset-react
node bundle.js index.html ../webapp/index-style-v2.html
```
The bundler pre-compiles all JSX, swaps React dev CDN builds for production minified builds, removes Babel entirely, and writes a single self-contained HTML file to `webapp/`.
See [`doc/architecture/tdd.md`](doc/architecture/tdd.md) for full technical details.
### Requirements
- **Runtime**: Python 3.8+ (server), any modern browser
- **Development**: Node.js 18+ (for the bundler only)
- **Bundler deps**: `cd web && npm install`
## License
Personal use. Do whatever you want with it.
+350
View File
@@ -0,0 +1,350 @@
# Personal Backlog — Architecture
## 1. Philosophy
> **One deliverable.** The app ships as a single self-contained HTML file (`webapp/index-style-v2.html`). It talks to the filesystem either through a minimal localhost Python server **or** directly via the browser's File System Access API — no cloud, no accounts, no external CDNs at runtime.
>
> The user chooses the mode by how they open the app: run `python3 server/server.py` for universal browser support, or open the HTML file directly in Chrome/Edge for a zero-install experience.
>
> *The source code lives in `web/` as React 18 + JSX files and is compiled to the single-file artifact by `web/bundle.js`. The build step is only needed when changing source; users only ever touch the output.*
## 2. Deployment Topology
### Mode A — API Server (universal browsers)
```
┌─────────────────────────────────────┐
│ Browser (Chrome / Firefox / Safari)│
│ ───────────────────────────────── │
│ Single self-contained HTML file │
│ ├─ inline CSS │
│ ├─ inline React 18 + app JS │
│ └─ inline SVG icons │
└──────────────┬──────────────────────┘
│ HTTP (localhost)
┌──────────────▼──────────────────────┐
│ Python File Server (~420 LoC) │
│ Python 3 stdlib, zero pip deps │
│ Serves HTML + REST API for disk │
└──────────────┬──────────────────────┘
│ read / write / watch
┌──────────┼──────────┐
▼ ▼ ▼
backlog.md backups/ stats.jsonl
(master) (rotating) (append-only)
```
### Mode B — Direct File Access (Chrome/Edge, zero server)
```
┌────────────────────────────────────────┐
│ Browser (Chrome / Edge) │
│ ──────────────────────────────────── │
│ HTML file opened via file:// or │
│ served by any static file server │
│ ├─ inline CSS │
│ ├─ inline React 18 + app JS │
│ └─ inline SVG icons │
└──────────────┬─────────────────────────┘
│ File System Access API
│ (showDirectoryPicker)
┌──────────┼──────────┐
▼ ▼ ▼
backlog.md backups/ stats.jsonl
(master) (rotating) (append-only)
```
> **Note:** Firefox and Safari do not support the File System Access API. Opening the HTML file directly in those browsers starts the app in read-only mode with a warning banner. Use Mode A (Python server) for full functionality in any browser.
## 3. Technology Stack
| Layer | Choice | Rationale |
|-------|--------|-----------|
| **Frontend runtime** | React 18 (production UMD, inlined) | Component model for complex UI; inlined so there are no runtime CDN requests. |
| **Frontend source** | JSX + CSS in `web/` | Multi-file development ergonomics; compiled to single HTML by bundler. |
| **Build tooling** | `bundle.js` + `@babel/core` | Pre-compiles JSX, inlines prod React builds, removes Babel. Dev-only dependency. |
| **Styling** | External `styles.css` (inlined at build) | Edited as plain CSS; inlined into the bundle so the output file is self-contained. |
| **Icons / graphics** | Inline SVG | Scalable, styleable with CSS, no network requests. |
| **Server (API mode)** | Python 3 `http.server` | Ships with macOS/Linux/Windows; zero pip installs. |
| **Server (Direct mode)** | File System Access API | Native browser API for Chrome/Edge; zero install. Not available in Firefox/Safari. |
| **Master storage** | Markdown file (`backlog.md`) | Human-readable, diff-friendly, matches requirements exactly. |
| **Backup storage** | Filesystem directory (`backups/`) | Simple rotation via filename sorting. |
| **Stats storage** | JSONL file (`stats.jsonl`) | Append-only, no locking complexity, human-readable, trivial to parse. |
| **Transport** | HTTP/1.1 + JSON | Universally supported, trivial to debug with curl. |
## 4. Frontend Architecture
The source code is split across files in `web/` and compiled to a single self-contained HTML file in `webapp/`. Logical modules are organized into three layers so that UI components and business logic never know which storage backend is active.
### 4.1 Layered Module Map
```
┌─────────────────────────────────────────────────────────────┐
│ PRESENTATION LAYER │
│ ───────────────── │
│ <App>, <TreeItem>, <AdminPage>, <FilterPanel>, │
│ <TweaksPanel>, <ItemDialog>, <ConfirmDialog>, │
│ <ImportExportDialog> │
│ React components — only read/write data through props │
│ and callbacks passed down from App. │
├─────────────────────────────────────────────────────────────┤
│ DOMAIN / BUSINESS LOGIC LAYER │
│ ──────────────────────────── │
│ Parser, buildDataFromStorage, filter logic in App │
│ Parse markdown → data tree; serialize data tree → markdown │
│ Compute stats, enforce Parent Visibility Rule. │
├─────────────────────────────────────────────────────────────┤
│ INFRASTRUCTURE LAYER │
│ ──────────────────── │
│ Storage, ApiBackend, DirectBackend, SyncPoller │
│ Route reads/writes to disk (API or File System API). │
└─────────────────────────────────────────────────────────────┘
```
### 4.2 Source File Reference
| File | Layer | Responsibility |
|------|-------|----------------|
| `app.jsx` | App | Root `<App>` component. All state lives here via React hooks. Storage init, save/load lifecycle, filter state, debounced autosave. |
| `storage.jsx` | Infrastructure + Domain | `Parser` (parse/serialize markdown), `ApiBackend`, `DirectBackend`, `Storage` (detect + delegate), `SyncPoller`, `buildDataFromStorage`. |
| `tree.jsx` | Presentation | Recursive `<TreeItem>` component. Expand/collapse, drag-and-drop reorder, status glyphs, progress bars. |
| `admin.jsx` | Presentation | `<AdminPage>` — health card, storage stats, backup browser, stats chart, manual actions. |
| `filter-panel.jsx` | Presentation | Left sidebar: status/priority/tag/date filters with multi-select. |
| `tweaks-panel.jsx` | Presentation | Settings slide-out: density, accent hue, status glyph style. |
| `dialogs.jsx` | Presentation | `<ItemDialog>`, `<ConfirmDialog>`, `<ImportExportDialog>`. |
| `helpers.jsx` | Domain | `walkTree`, `findItem`, `countAll`, `countByStatus`, `useTweaks` hook, formatting utilities. |
### 4.3 State Management
All state lives in the `<App>` component via React hooks. No external state library.
```javascript
const [data, setData] = useState(buildEmptyData);
// data = { entries, history, meta, health, stats, backups }
const [storageMode, setStorageMode] = useState('local'); // 'api' | 'direct' | 'local'
const [filters, setFilters] = useState({ statuses, priorities, ... });
const [expandedMap, setExpandedMap] = useState({}); // id → bool, persisted to localStorage
const [saveState, setSaveState] = useState({ status, lastSaved });
```
Only `expandedMap` is persisted to `localStorage`. All backlog data is always sourced from `backlog.md`.
### 4.4 Rendering Strategy
React 18 reconciliation. The full tree re-renders on data or filter changes; React diffs and patches only changed DOM nodes. Filters are applied in the render path — the canonical `data.entries` is never mutated by filtering.
Responsive breakpoints:
- Desktop: sidebar filter panel + main tree.
- Mobile (< 768 px): filter panel becomes a collapsible drawer.
### 4.5 Dual-Mode Storage
`Storage` is a thin router that picks the active backend at boot time.
**Detection order:**
1. Probe `GET http://localhost:8080/api/health`. If it responds within 500 ms → use `ApiBackend`.
2. Else if `window.showDirectoryPicker` is available → use `DirectBackend`.
3. Else show a message: "Please run `python3 server.py` or open this page in Chrome/Edge."
#### ApiBackend
- `load()` → `GET /api/backlog`
- `save(content)` → `POST /api/backlog`
- `listBackups()` → `GET /api/backups`
- `restoreBackup(name)` → `POST /api/backups/restore`
- `getStats()` → `GET /api/stats`
- `appendStats(event)` → `POST /api/stats`
#### DirectBackend
- On first launch the user is prompted to pick a **directory** via `showDirectoryPicker()`.
- The directory handle is stored in IndexedDB (`pb-storage-v2` → `handles` → `root`).
- On reload the handle is retrieved from IndexedDB; `queryPermission()` checks silently — no user gesture needed if permission is still active. If it has lapsed, a connect button is shown.
- `load()` → `dirHandle.getFileHandle('backlog.md').getFile()` → read text.
- `save(content)` → `dirHandle.getFileHandle('backlog.md', { create: true }).createWritable()` → write → close.
- `listBackups()` → iterate `dirHandle.getDirectoryHandle('backups', { create: true })`.
- `restoreBackup(name)` → copy backup file handle content over master.
- `getStats()` / `appendStats()` → read/write `stats.jsonl` via the same directory handle.
- Backup rotation runs in the frontend (same algorithm as server) after every successful save.
## 5. Backend Architecture
### 5.1 Server (`server.py`)
A single Python file (~420 LoC) extending `http.server.BaseHTTPRequestHandler`. Zero external dependencies — stdlib only.
Responsibilities:
1. Serve `index-style-v2.html` for `GET /`.
2. Handle REST API routes.
3. Perform atomic file writes.
4. Manage backup rotation.
5. Append stats events.
6. Compute SHA-256 integrity markers on write; verify on read for warning purposes only (never block loading).
### 5.2 REST API
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/` | Serve `index-style-v2.html` |
| `GET` | `/api/backlog?checksum=<optional>` | Return current markdown content + checksum. If `checksum` matches, return `304 Not Modified`. Checksum mismatch never blocks loading. |
| `POST` | `/api/backlog` | Accept JSON `{ content }`. Write atomically, create backup, verify integrity marker is syntactically valid, return new checksum. |
| `GET` | `/api/backups` | List backups: `[{ name, size, timestamp, valid }]`. |
| `GET` | `/api/backups/<name>` | Download a backup file. |
| `POST` | `/api/backups/restore` | Body `{ name }`. Copy backup over master after checking backup is readable. |
| `POST` | `/api/export` | Body `{ format: "json" }`. Return structured dump. |
| `POST` | `/api/import` | Multipart upload. Validate, then replace master. |
| `POST` | `/api/stats` | Body `{ event, payload }`. Append one line to `stats.jsonl`. |
| `GET` | `/api/stats?from=&to=&aggregate=` | Read stats events and/or roll-ups. |
| `GET` | `/api/health` | `{ status, lastSave, lastBackup, masterSize, backupCount }`. |
### 5.3 Atomic Write Sequence
```
Client POSTs new markdown content
│
▼
Server writes to backlog.md.tmp
│
▼
Server parses tmp file, verifies integrity marker is syntactically valid
│
▼
Server copies tmp → backups/backlog_YYYY-MM-DD_HH-mm-ss.md
│
▼
Server runs backup rotation (prune old files)
│
▼
Server renames tmp → backlog.md
│
▼
Server appends "save_completed" event to stats.jsonl
│
▼
Server returns { ok: true, checksum, saved }
```
### 5.4 Backup Rotation Algorithm
```python
def rotate_backups(backups_dir):
files = sorted(glob("backlog_*.md"), key=extract_timestamp)
# Keep everything for 7 days
for f in files:
if age(f) <= 7 days:
continue
# After 7 days: keep only the newest per calendar day
day = extract_calendar_day(f)
if f != newest_file_for_day(day):
os.remove(f)
# Hard cap: never delete the single most recent backup
```
### 5.5 External Change Detection
- **API mode**: polls `GET /api/backlog?checksum=<local>` every 5 seconds.
- `304 Not Modified` → nothing to do.
- `200 + new content` → file changed externally.
- **Direct mode**: calls `Storage.load()` every 5 seconds and compares checksums.
In both modes:
- If `Store.dirty === false`: auto-reload, show toast notification.
- If `Store.dirty === true`: show conflict modal with options:
- **Overwrite local** (discard unsaved edits, load disk version).
- **Force save** (overwrite disk with local version).
- **Download both** (save local as file, then reload disk version).
## 6. Data Layer Details
### 6.1 Master File (`backlog.md`)
Exactly as specified in [Requirements §3.2](../requirements/requirements.md). The server and client both know how to parse and generate the three sections (`ENTRIES`, `HISTORY`, `INTEGRITY`).
**Checksum algorithm:**
```
sha256( utf8_bytes( entries_section + "\n" + history_section ) )
```
The integrity marker comment is excluded from the hash.
**Checksum policy:**
- On **save**, the writer computes the hash and appends the marker. This proves the file was written completely.
- On **load**, the reader computes the hash and compares it to the stored marker.
- If they match → green status, no banner.
- If they mismatch → **yellow warning banner** ("Checksum mismatch — file was edited outside the app"). The file still loads normally. The next save will overwrite the marker with the correct value.
- If the marker is missing → same yellow warning.
### 6.2 Stats File (`stats.jsonl`)
One JSON object per line, newline-delimited:
```jsonl
{"t":"2025-05-10T14:32:01Z","e":"item_created","d":{"id":"task-1","level":3,"project":"proj-1"}}
{"t":"2025-05-10T14:35:00Z","e":"save_completed","d":{"size":12400,"ms":45}}
```
The server exposes a simple aggregator that reads the last 90 days of lines and computes roll-ups on demand. For large files, a memory-mapped or seek-from-end strategy can be used.
## 7. Safety Considerations
This is a single-user local tool, not a production service. The only real threats are data loss and file corruption.
| Concern | Mitigation |
|---------|------------|
| **Data loss** | Atomic writes (temp file + rename) + automatic backup on every save + rotating backup retention. |
| **File corruption** | Integrity marker proves the file was written completely; parser validates structure before accepting. |
| **Path traversal** | Backup names are generated by the server, never from user input. Restore validates the file exists and is readable. |
| **Network exposure** | Server binds to `0.0.0.0` for convenience (access from phone/tablet on same LAN). No auth — this is intentional for a personal tool. |
## 8. Build & Distribution
The repository contains:
```
personal-backlog/
├── server/
│ └── server.py # Python REST API server (stdlib only)
├── web/ # V2 source (JSX + CSS + bundler)
├── webapp/
│ └── index-style-v2.html # Self-contained SPA (built artifact)
├── backlog.md # master data file (created on first save)
├── backups/ # created automatically
└── stats.jsonl # created automatically
```
### 8.1 Running the App
**Mode A — API Server (all browsers):**
```bash
python3 server/server.py --port 8080 --dir ~/my-backlog
```
Then open `http://localhost:8080`.
**Mode B — Direct Access (Chrome/Edge, zero server):**
```bash
open webapp/index-style-v2.html
```
The app will prompt you to pick a directory. Create a folder, select it, and the app will create `backlog.md`, `backups/`, and `stats.jsonl` inside it.
**Building the bundle from source:**
```bash
cd web && npm install
node bundle.js index.html ../webapp/index-style-v2.html
```
### 8.2 First-Time Setup
- **API mode**: If `backlog.md` does not exist in the server `--dir`, the server creates a blank template with an empty `ENTRIES` section, an empty `HISTORY` table, and a valid `INTEGRITY` marker.
- **Direct mode**: If the selected directory does not contain `backlog.md`, the frontend creates the same blank template via `DirectBackend`.
## 9. Performance Budget
| Target | Limit | How |
|--------|-------|-----|
| Initial load | < 200 KB transferred | Single HTML, no external assets. |
| Startup time | < 1 s for ≤ 1 MB backlog | Parse markdown in one pass; lazy-load admin charts. |
| Save latency | < 300 ms | Atomic rename is instant; SHA-256 of 1 MB is ~5 ms. |
| Render tree | < 50 ms for 500 items | Reuse DOM nodes where possible; virtual scrolling optional future enhancement. |
| Poll overhead | Negligible | 304 responses are empty body; checksum is cached server-side. |
## 10. Future Extensibility (No-Regret Decisions)
1. **Dark mode** — CSS custom properties (`:root` variables) are used for all colors, so a theme switch is a one-line class toggle.
2. **Multi-user** — The server already validates checksums; adding a simple session token or Basic Auth header would be trivial if needed later.
3. **Firefox/Safari direct access** — If those browsers ever gain write access via File System Access API, `DirectBackend` will work without code changes.
+623
View File
@@ -0,0 +1,623 @@
# Technical Design Document — Personal Backlog
## 1. Overview
This document describes the full technical implementation of the Personal Backlog application — a single-user, locally-hosted task manager backed by a single Markdown file. It covers both the HTML frontend implementations (v1 vanilla JS and v2 React 18), the Python server, the bundling pipeline, and the data contract that ties them together.
## 2. Source Code Structure
```
personal-backlog/
├── server/
│ └── server.py # Python REST API server (~420 LoC)
│
├── web/ # V2 source code (React 18 + JSX) + build tooling
│ ├── index.html # Dev entry point (React + Babel from CDN)
│ ├── styles.css # All CSS
│ ├── storage.jsx # Parser, ApiBackend, DirectBackend, Storage, SyncPoller
│ ├── helpers.jsx # walkTree, findItem, useTweaks hook, event utilities
│ ├── app.jsx # Root <App> component, state, save/load lifecycle
│ ├── tree.jsx # <TreeItem> recursive component
│ ├── dialogs.jsx # Modal dialogs (add/edit/delete/import/export)
│ ├── admin.jsx # Admin dashboard page
│ ├── filter-panel.jsx # Left sidebar: status/priority/tag/date filters
│ ├── tweaks-panel.jsx # Settings panel (density, accent hue, status style)
│ ├── data.jsx # Seed data for testing (not loaded by default)
│ ├── bundle.js # Build tool: multi-file JSX → single-file HTML
│ ├── package.json # Declares @babel/core, @babel/preset-react
│ └── node_modules/
│
├── webapp/ # Built output — ready to open or deploy
│ └── index-style-v2.html # Self-contained single-file SPA (~348 KB)
│
├── design/
│ ├── design-v1/ # V1 source (archived, vanilla JS)
│ └── archive/ # Earlier prototypes
│
└── doc/
├── requirements/requirements.md
└── architecture/
├── architecture.md
└── tdd.md # This file
```
## 3. Data Contract — `backlog.md` Format
The Markdown file is the sole source of truth. Both the Python server and the JavaScript frontend must parse and produce this exact format.
### 3.1 File Structure
```markdown
# Backlog
<!-- SECTION: ENTRIES -->
- [ ] [P0] Task title *(due: 2025-06-01, priority: P0, progress: 50)*
- [x] [P1] Sub-task *(priority: P1, progress: 100)*
- [!] [P1] Blocked task *(priority: P1, reason: waiting for API keys)*
<!-- SECTION: HISTORY -->
| Timestamp | Item ID | Action | Details |
|-----------|---------|--------|---------|
| 2025-05-10T14:32:00Z | i-m1 | status_changed | open → done |
<!-- SECTION: INTEGRITY -->
<!-- saved: 2025-05-10T14:35:12Z | checksum: sha256:abc123... | entries: 42 | history: 128 -->
```
Three mandatory section markers: `<!-- SECTION: ENTRIES -->`, `<!-- SECTION: HISTORY -->`, `<!-- SECTION: INTEGRITY -->`.
### 3.2 Entry Line Format
```
<indent>- [<glyph>] [<priority>] <title> *(<metadata>)*
```
| Part | Format | Example |
|------|--------|---------|
| Indent | 2 spaces per level | ` ` (level 2) |
| Glyph | `[ ]` `[x]` `[!]` `[>]` `[/]` `[-]` | `[x]` |
| Priority prefix | `[P0]` through `[P3]`, followed by space | `[P0] ` |
| Title | Free text | `Ship landing page` |
| Metadata | `*(key: value, ...)*` | `*(due: 2025-06-01, progress: 50)*` |
**Glyph-to-status mapping:**
| Glyph | Status |
|-------|--------|
| `[ ]` | `open` |
| `[/]` | `in-progress` |
| `[!]` | `blocked` |
| `[>]` | `postponed` |
| `[x]` | `done` |
| `[-]` | `cancelled` |
**Metadata keys** (all optional, comma-separated inside `*(...)*`):
| Key | Value format | Default |
|-----|-------------|---------|
| `due` | ISO date `YYYY-MM-DD` | null |
| `priority` | `P0`–`P3` | `P1` |
| `progress` | Integer `0–100` | `0` |
| `reason` | Free text (for blocked items) | null |
| `tags` | Space-separated words | [] |
### 3.3 Checksum Algorithm
```
payload = entries_section_text.trim() + "\n" + history_section_text.trim()
hash = "sha256:" + SHA-256(UTF-8(payload))
```
The `entries_section_text` is everything between `<!-- SECTION: ENTRIES -->` and `<!-- SECTION: HISTORY -->`, excluding the markers themselves. Similarly for history. The integrity marker comment is excluded from the hash.
**Policy:** On save, the writer computes and writes the hash. On load, the reader verifies it. Mismatch = yellow warning banner, never blocks loading. Next save overwrites with the correct hash.
### 3.4 Parser Regex (Critical Implementation Detail)
The parser extracts metadata from entry lines using this regex:
```javascript
const metaM = raw.match(/^(.*?)\s*\*\((.*)\)\*\s*$/);
```
This matches the `*(...)*` wrapper at the end of a line. The trailing `\*\s*$` is essential — the metadata format wraps with `)*` (paren + asterisk), not just `)`. A previous bug where the regex ended with `\)\s*$` caused the entire metadata string to be absorbed into the title on every save/load cycle.
The priority prefix is extracted separately:
```javascript
const pm = title.match(/^\[(P\d)\]\s*/);
if (pm) { priority = pm[1]; title = title.slice(pm[0].length); }
```
### 3.5 Serializer Output Format
The serializer writes entries in this format:
```
<indent>- [<glyph>] [<priority>] <title> *(<metadata>)*
```
**Serialization rules:**
- Priority prefix `[Pn]` is always written before the title
- Metadata `*(...)*` is appended only when at least one metadata field is non-default
- `priority: P1` is omitted from metadata (it's the default, already shown as prefix)
- `progress: 0` is omitted from metadata (default)
- `progress` is only included when > 0
## 4. Frontend Implementation — V2 (React 18)
### 4.1 Component Tree
```
<App>
├── <FilterPanel> (left sidebar)
├── Main area
│ ├── Header + search bar
│ ├── <TreeItem> (recursive, one per entry)
│ │ └── <TreeItem> ... (children)
│ └── Add-item button
├── <AdminPage> (gear icon route)
│ ├── System Health card
│ ├── Storage card (with filesystem paths)
│ ├── Backup Browser
│ ├── Stats Overview
│ └── Manual Actions
├── <TweaksPanel> (settings slide-out)
├── <ItemDialog> (add/edit modal)
├── <ConfirmDialog> (confirmation modal)
└── <ImportExportDialog> (import/export modal)
```
### 4.2 State Management
All state lives in `App` component via React hooks. No external state library.
```javascript
const [data, setData] = useState(buildEmptyData);
const [storageMode, setStorageMode] = useState('local'); // 'api' | 'direct' | 'local'
const [filters, setFilters] = useState({statuses, priorities, tags, dueRange, scope, text});
const [expandedMap, setExpandedMap] = useState({}); // id → bool
const [saveState, setSaveState] = useState({status, lastSaved});
```
**Key invariant:** `data` always reflects the latest saved or loaded state. `isDirtyRef` tracks whether there are unsaved local edits (for conflict detection).
Only UI state (expanded/collapsed map) is persisted to `localStorage`. Backlog data is always sourced from `backlog.md`.
### 4.3 Data Flow
```
User action (edit, status change, reorder)
│
▼
Mutate data object → setData(newData)
│
├─► Immediate re-render (React)
│
└─► Debounced save (300ms)
│
▼
Parser.serialize(data) → markdown string
│
▼
Storage.save(markdown) → backend writes to disk
│
▼
SyncPoller.lastChecksum updated
```
### 4.4 Storage Module (`storage.jsx`)
This is the infrastructure layer — all filesystem access is routed through here.
#### Parser
The `Parser` object provides `parse(text)` and `serialize(data)`:
- **`parse(text)`** — Splits text by section markers, parses entries into a tree, parses history table rows, verifies checksum. Returns `{ entries, history, meta, checksumOk }`.
- **`serialize(data)`** — Rebuilds markdown from the tree, recomputes SHA-256, writes integrity marker. Returns the full markdown string.
Both are async because they use `crypto.subtle.digest()` for SHA-256.
#### Storage Backend Detection
```javascript
Storage.detect() → 'api' | 'direct' | 'local'
```
Detection order:
1. Probe `GET /api/health` with 800ms timeout → `ApiBackend`
2. Check `typeof window.showDirectoryPicker === 'function'` → `DirectBackend`
3. Fall back to `'local'` (localStorage-only, no persistence)
#### ApiBackend
| Method | HTTP | Endpoint |
|--------|------|----------|
| `load()` | `GET` | `/api/backlog` → `{ content, checksum }` |
| `save(content)` | `POST` | `/api/backlog` body `{ content }` → `{ ok, checksum, saved }` |
| `listBackups()` | `GET` | `/api/backups` → `{ backups: [...] }` |
| `restoreBackup(name)` | `POST` | `/api/backups/restore` body `{ name }` → `{ ok }` |
| `getHealthInfo()` | `GET` | `/api/health` → `{ masterSize, backupCount, masterPath, backupsPath, ... }` |
In API mode, `location.protocol` must not be `file:` — detection skips API when opening the HTML file directly.
#### DirectBackend
Uses the File System Access API (Chrome/Edge only):
| Operation | Implementation |
|-----------|---------------|
| Directory handle persistence | IndexedDB (`pb-storage-v2` → `handles` → `root`) |
| Auto-reconnect | `dirHandle.queryPermission({ mode: 'readwrite' })` — silent, no user gesture |
| Manual connect | `dirHandle.requestPermission({ mode: 'readwrite' })` — requires user gesture |
| Read file | `dirHandle.getFileHandle('backlog.md').getFile().text()` |
| Write file | `dirHandle.getFileHandle('backlog.md', {create:true}).createWritable()` |
| List backups | Iterate `dirHandle.getDirectoryHandle('backups')` entries |
| Write backup | Write to `backups/backlog_YYYY-MM-DD-HH-mm-ss.md` |
**Reset:** Delete the `pb-storage-v2` IndexedDB database, then reload the page.
#### SyncPoller
Polls every 5 seconds via `Storage.load()`. Compares the checksum from the loaded content against `lastChecksum`:
- **Checksum unchanged** → no-op
- **Checksum changed, no local edits** → auto-reload, show toast
- **Checksum changed, local edits exist** → show warning toast ("File changed externally — you have unsaved edits")
### 4.5 App Initialization Flow
```
App mounts
│
▼
Storage.detect()
│
├─► 'api' ─► applyStorageData()
│ │
│ ├─ Storage.load()
│ ├─ Storage.listBackups()
│ ├─ Storage.getHealthInfo()
│ ├─ Parser.parse(content)
│ ├─ buildDataFromStorage()
│ ├─ setData(newData)
│ └─ SyncPoller.start()
│
├─► 'direct' ─► tryAutoConnect()
│ │
│ ├─ success ─► applyStorageData() (same as api)
│ └─ fail ─► setNeedsConnect(true), show connect button
│
└─► 'local' ─► setIsLoading(false), use empty data
```
**`isCancelled` pattern:** The `applyStorageData` function accepts an `isCancelled` function (not a boolean) so it can check the cancellation state after each async operation. This prevents setting state on an unmounted component:
```javascript
await applyStorageData(mode, () => cancelled);
```
### 4.6 buildDataFromStorage
This function assembles the complete data object consumed by the UI:
```javascript
async function buildDataFromStorage(parsed, backups, storageMode, sizeInfo)
```
Returns:
```javascript
{
entries, // Hierarchical task tree with levels assigned
history, // Audit log rows
meta, // Integrity marker data
health: {
integrityOk, lastSave, lastBackup, masterSize, backupDirSize,
backupCount, statsSize, historySize, historyOldest,
mode, // 'API server' | 'Direct (File System API)' | 'localStorage only'
masterPath, // Filesystem path (API mode only, null otherwise)
backupsPath, // Backup directory path (API mode only, null otherwise)
},
stats: {
createdThisWeek, completedThisWeek, avgInProgressDays,
mostActiveProject, completionByDay, createdByDay, statusMix,
},
backups, // Backup file list with metadata
}
```
**Progress migration:** When loading, items with missing or non-numeric `progress` get default values based on their status (done→100, in-progress→50, blocked→25, etc.). Items with `status === 'done'` are always forced to `progress: 100`.
## 5. Frontend Implementation — V1 (Vanilla JS, archived)
The V1 implementation is archived at `design/archive/index-v1.html` as a single self-contained file (~41 KB). It uses vanilla JavaScript with direct DOM manipulation — no React, no virtual DOM, no build step.
### 5.1 Architecture
The V1 follows the same three-layer architecture (Presentation → Domain → Infrastructure) but all modules are inlined in a single `<script>` block within the HTML file. Key modules:
| Module | Role |
|--------|------|
| `Parser` | Parse/serialize markdown (identical logic to V2) |
| `Store` | In-memory state, mutations, change events |
| `Renderer` | DOM tree rendering |
| `Storage` | Backend detection and routing |
| `ApiBackend` | HTTP fetch to server API |
| `DirectBackend` | File System Access API |
The V1 Parser uses the same regex for metadata extraction:
```javascript
const metaM = raw.match(/^(.*?)\s*\*\((.*)\)\*\s*$/);
```
### 5.2 Differences from V2
| Aspect | V1 | V2 |
|--------|----|----|
| Framework | Vanilla JS | React 18 |
| DOM updates | Direct manipulation | React reconciliation |
| Source files | Single HTML file | Multi-file JSX + CSS |
| CSS | Inline `<style>` | External `styles.css` (42 KB) |
| Bundle size | ~41 KB | ~346 KB |
| Admin page | Basic | Full dashboard with stats |
| Filter panel | Simple | Advanced with tag autocomplete |
| Settings | None | Tweaks panel (density, accent hue, etc.) |
Both versions use the same `backlog.md` format and are compatible with both storage backends.
## 6. Python Server (`server.py`)
### 6.1 Overview
A single-file HTTP server (~420 LoC) built on Python 3's `http.server` module. Zero external dependencies — only stdlib imports.
### 6.2 Configuration
```python
class Config:
dir # Root data directory (resolved Path)
port # Listen port
master # Path to backlog.md
backups_dir # Path to backups/
stats_file # Path to stats.jsonl
web_dir # Path to web/ (static files to serve)
```
Command-line arguments:
- `--port` (default: 8080) — Listen port
- `--dir` (default: directory of server.py) — Data directory
- `--web-dir` (default: `../webapp/`) — Static file directory
### 6.3 REST API
| Method | Path | Request | Response |
|--------|------|---------|----------|
| `GET` | `/api/health` | — | `{ status, lastSave, lastBackup, masterSize, backupCount, masterPath, backupsPath }` |
| `GET` | `/api/backlog` | — | `{ content, checksum }` |
| `POST` | `/api/backlog` | `{ content }` | `{ ok, checksum, saved }` |
| `GET` | `/api/backups` | — | `{ backups: [{ name, size, timestamp, valid }] }` |
| `GET` | `/api/backups/<name>` | — | Raw markdown file download |
| `POST` | `/api/backups/restore` | `{ name }` | `{ ok }` |
| `POST` | `/api/export` | `{ format: "json" \| "markdown" }` | JSON dump or raw markdown |
| `POST` | `/api/import` | `{ content }` | `{ ok }` |
| `POST` | `/api/stats` | `{ event, payload }` | `{ ok }` |
| `GET` | `/api/stats?from=&to=` | — | `{ events: [...] }` |
Non-API paths serve static files from `web_dir` with MIME type detection. The root path `/` serves `web_dir/index.html`.
### 6.4 Atomic Write Sequence
```
1. Write content to backlog.md.tmp
2. Parse tmp file to verify it's structurally valid
3. Copy tmp → backups/backlog_YYYY-MM-DD_HH-MM-SS-mmm.md
4. Run backup rotation (prune old files)
5. Atomic rename: tmp → backlog.md
6. Append save_completed event to stats.jsonl
7. Return { ok: true, checksum, saved }
```
### 6.5 Backup Rotation
```python
def rotate_backups():
# Keep all backups ≤ 7 days old
# After 7 days: keep only the newest backup per calendar day
# Never delete the single most recent backup
```
### 6.6 CORS & Security
- All responses include `Access-Control-Allow-Origin: *` (single-user local tool, no CSRF protection)
- `OPTIONS` requests return `204 No Content` with CORS headers
- Server binds to `0.0.0.0` for LAN access (phone/tablet)
- Path traversal protection: static file requests are resolved against `web_dir` and checked with `relative_to()`
- No authentication — intentional for a personal local tool
### 6.7 Stats File (`stats.jsonl`)
Append-only JSON Lines file:
```jsonl
{"t":"2025-05-10T14:32:01Z","e":"item_created","d":{"id":"task-1","level":3,"project":"proj-1"}}
{"t":"2025-05-10T14:35:00Z","e":"save_completed","d":{"size":12400,"ms":45}}
```
Fields: `t` (ISO timestamp), `e` (event type), `d` (payload).
## 7. Bundling Pipeline
### 7.1 Purpose
The V2 source code is split across multiple JSX and CSS files for developer ergonomics. The bundler collapses everything into a single HTML file that can be opened offline with zero setup.
### 7.2 Bundle Script (`bundle.js`)
Location: `web/bundle.js` (~234 LoC)
**Prerequisites:**
- Node.js 18+ (uses global `fetch` for CDN downloads)
- `npm install` in `web/` directory (installs `@babel/core` + `@babel/preset-react`)
**Usage:**
```bash
cd web
node bundle.js index.html ../webapp/index-style-v2.html
```
### 7.3 Bundle Process (Step by Step)
```
1. Read index.html
2. Inline stylesheets:
For each <link rel="stylesheet" href="...">:
- If local file: replace with <style>...</style>
- If remote URL: keep as-is
✓ styles.css → <style>
3. Process scripts:
For each <script src="...">:
a) Remote CDN with a swap rule:
- React dev → fetch React production min, inline as <script>
- ReactDOM dev → fetch ReactDOM production min, inline as <script>
- @babel/standalone → remove entirely (no longer needed)
b) Local file with type="text/babel":
- Read file content
- Compile JSX → plain JS via @babel/core + @babel/preset-react
- Strip type="text/babel" attribute
- Inline as <script>...</script>
c) Local file without babel:
- Read and inline as <script>...</script>
d) Remote URL with no swap rule:
- Keep as-is
✓ helpers.jsx (JSX→JS)
✓ storage.jsx (JSX→JS)
✓ tweaks-panel.jsx (JSX→JS)
✓ filter-panel.jsx (JSX→JS)
✓ tree.jsx (JSX→JS)
✓ dialogs.jsx (JSX→JS)
✓ admin.jsx (JSX→JS)
✓ app.jsx (JSX→JS)
4. Compile inline babel blocks:
For each <script type="text/babel">...</script>:
- Compile JSX → JS
- Strip type="text/babel" attribute
✓ ReactDOM.createRoot boot script
5. Write output file
```
### 7.4 CDN Swap Table
| Source CDN URL | Action |
|---------------|--------|
| `unpkg.com/react-dom@*` | Fetch `react-dom.production.min.js`, inline |
| `unpkg.com/react@*` | Fetch `react.production.min.js`, inline |
| `unpkg.com/@babel/standalone@*` | Remove tag entirely |
Production builds are smaller than development builds. The Babel compiler is removed because all JSX has been pre-compiled.
### 7.5 HTML Comment Awareness
The bundler skips all processing for content inside HTML comments (`<!-- ... -->`). This means commented-out `<script>` or `<link>` tags are left untouched, which is important for the data.jsx seed script that is commented out by default in `index.html`.
### 7.6 Script Escaping
Content inlined into `<script>` tags has `</script>` replaced with `<\/script>` to prevent the browser from prematurely closing the script block. Similarly, `</style>` is escaped in inlined CSS.
## 8. Key Data Structures
### 8.1 Entry Item
```javascript
{
id: "i-m1abc", // Unique ID: "i-" + base36 counter
level: 1, // 1–4 (Area→Project→Task→Sub-task)
title: "Ship landing page",
status: "open", // open | in-progress | blocked | postponed | done | cancelled
priority: "P0", // P0 | P1 | P2 | P3
due: "2025-06-01", // ISO date or null
reason: null, // Free text (blocked items)
tags: ["urgent"], // Array of strings
progress: 50, // 0–100 integer
collapsed: false, // UI expand/collapse state
children: [], // Nested entry items
}
```
### 8.2 History Row
```javascript
{
timestamp: "2025-05-10T14:32:00Z",
itemId: "i-m1abc",
action: "status_changed", // status_changed | item_created | item_deleted | item_moved
details: "open → done",
}
```
### 8.3 Integrity Meta
```javascript
{
saved: "2025-05-10T14:35:12Z",
checksum: "sha256:abc123...",
entryCount: 42,
historyCount: 128,
}
```
## 9. Cross-Version Compatibility
### 9.1 V1 ↔ V2
Both versions read and write the same `backlog.md` format. They can be used interchangeably against the same data file. The V2 file (`webapp/index-style-v2.html`) can be served by the Python server or opened directly in Chrome/Edge. The archived V1 file (`design/archive/index-v1.html`) is no longer actively maintained.
### 9.2 API Server ↔ Direct File Access
Both storage backends read/write the same `backlog.md` file on disk. The only difference is *how* they access it:
| Aspect | API Server | Direct File Access |
|--------|-----------|-------------------|
| Read | `GET /api/backlog` → JSON | `dirHandle.getFileHandle().getFile().text()` |
| Write | `POST /api/backlog` ← JSON | `dirHandle.getFileHandle({create:true}).createWritable()` |
| Backup | Server-side copy | Frontend writes to `backups/` dir handle |
| Health | `GET /api/health` | Read file size from `getFile()` |
| Paths shown | Yes (server knows filesystem) | No (browser doesn't expose paths) |
### 9.3 Switching Modes
To switch from API server to direct file access:
1. Stop the server
2. Open `webapp/index-style-v2.html` in Chrome/Edge
3. Select the folder containing your `backlog.md`
To switch from direct file access to API server:
1. Note the folder path where `backlog.md` lives
2. Start `python3 server/server.py --dir /that/folder`
3. Open `http://localhost:8080`
**Important:** Never run both modes simultaneously against the same file — last writer wins.
## 10. Performance Considerations
| Metric | Target | Implementation |
|--------|--------|---------------|
| Initial load | < 1s for ≤1MB file | Single-pass parser, async SHA-256 |
| Save latency | < 300ms | Atomic rename (server), writable stream (direct) |
| Poll overhead | Negligible | 5s interval, checksum comparison, short-circuits on match |
| Render | < 50ms for 500 items | React reconciliation, targeted updates |
| Bundle size | < 400 KB | Production React builds, no source maps |
## 11. Known Limitations
1. **File System Access API** is Chrome/Edge only. Firefox and Safari users must use the Python server.
2. **No concurrent access protection.** If two browser tabs or a browser + external editor write simultaneously, the last writer wins. The SyncPoller detects external changes but cannot prevent race conditions.
3. **No dark mode** yet. CSS custom properties are used throughout, making it a one-line toggle when needed.
5. **DirectBackend backup rotation** does not prune old backups — only the API server does rotation.
+200
View File
@@ -0,0 +1,200 @@
# Personal Backlog — Requirements
## 1. Purpose
A simplistic, single-user, locally-hosted web UI for tracking personal projects, tasks, and their completion state. All persistent data lives in **one human-readable Markdown file** that contains the full backlog, status history, and audit log. The application loads this file on startup and re-saves it on every change.
## 2. Core Principles
| # | Principle |
|---|-----------|
| 1 | **Markdown as the database** — The `.md` file is the sole source of truth. It must remain readable in any text editor. |
| 2 | **Atomic save with integrity marker** — Every successful write ends with a verifiable marker so the app (and a human) can confirm the file was saved completely. |
| 3 | **Automatic rotating backups** — At least one week of backups must be kept automatically. |
| 4 | **No external runtime dependencies** — The app runs locally in a browser; storage is the filesystem (or a local dev server). |
## 3. Data Model & Hierarchy
### 3.1 Nesting Levels
The backlog supports **up to 4 levels** of nesting:
```
Level 1 — Area / Theme (optional)
Level 2 — Project
Level 3 — Task
Level 4 — Sub-task
```
*One-off tasks* may sit at Level 2 (as a standalone item with no children) or Level 3.
### 3.2 Markdown File Format (Normative)
The master file is divided into **three sections**:
```markdown
# Backlog
<!-- SECTION: ENTRIES -->
## [P1] 🌐 My Project
- [ ] Task A *(due: 2025-06-01, status: open)*
- [x] Sub-task A.1 *(done: 2025-05-10)*
- [!] Task B *(status: blocked, reason: waiting for API keys)*
## [P2] 📦 Another Project
...
<!-- SECTION: HISTORY -->
| Timestamp | Item ID | Action | Details |
|-----------|---------|--------|---------|
| 2025-05-10T14:32:00Z | task-a-1 | status_changed | open → done |
<!-- SECTION: INTEGRITY -->
<!-- saved: 2025-05-10T14:35:12Z | checksum: sha256:abc123... | entries: 42 | history: 128 -->
```
**Rules:**
- `<!-- SECTION: ENTRIES -->` contains the live backlog tree.
- `<!-- SECTION: HISTORY -->` contains an append-only audit table of every mutation.
- `<!-- SECTION: INTEGRITY -->` contains the save marker:
- `saved`: ISO-8601 timestamp of the write.
- `checksum`: SHA-256 over the concatenation of `ENTRIES` + `HISTORY`.
- `entries`: count of backlog items.
- `history`: count of history rows.
- The checksum is a **save-success indicator**, not a hard gate. If the marker is missing or the checksum does not match, the app loads the file anyway but shows a non-blocking warning banner (e.g., "File was edited outside the app — checksum mismatch"). The next save will rewrite a correct marker.
## 4. Functional Requirements
### 4.1 Statuses (REQ-F-001)
Each item has a **status** drawn from the following set:
| Status | Glyph | Meaning |
|--------|-------|---------|
| `open` | `[ ]` | Not started yet |
| `in-progress` | `[/]` | Actively being worked on |
| `blocked` | `[!]` | Cannot proceed; requires external action |
| `postponed` | `[>]` | Intentionally deferred |
| `done` | `[x]` | Completed |
| `cancelled` | `[-]` | No longer relevant |
- Changing status appends a row to the **HISTORY** section.
- `blocked` must allow a short free-text `reason` field.
### 4.1a Progress (REQ-F-001a)
- Every item carries an optional **progress** percentage: `0–100`.
- New tasks default to `0%`.
- Setting status to `done` automatically sets progress to `100%`.
- `blocked` and `cancelled` items may retain any progress value (e.g., partially done before being blocked).
- Progress is stored inline in the Markdown metadata as `progress: N`.
- The UI shows a small progress bar next to the task title.
### 4.2 Priorities & Ordering (REQ-F-002)
- Each item carries a **priority** label: `P0` (burning), `P1`, `P2`, or `P3`.
- Within the **same priority**, items are ordered manually via **drag-and-drop**.
- The UI renders items in priority-descending, then manual-order order.
- `P0` items are visually highlighted (e.g., fire emoji 🔥 or a distinct border) to mark "hot / burning" status.
### 4.3 Due Dates (REQ-F-003)
- Optional `due` date per item (ISO-8601 date: `YYYY-MM-DD`).
- Overdue items are visually flagged.
### 4.4 Tags & Labels (REQ-F-004)
- Items may have free-form **tags** (e.g., `#urgent`, `#research`).
- Tags are stored inline in the Markdown item metadata.
- The UI offers **autocomplete** based on the most recently used tags across the backlog.
- Tags are filterable (multi-select) alongside status and priority.
### 4.5 Filtering & Search (REQ-F-005)
The UI must provide filters for:
- Status (multi-select)
- Priority (multi-select)
- Due date range (`overdue`, `today`, `this week`, `this month`, `custom`)
- Full-text search across titles, descriptions, block reasons, and tags
- Scope: `all`, `top-level only`, or `current project`
**Parent Visibility Rule:** When a filter or quicksearch matches a task at any level, all ancestor projects/items up to the root must remain visible in the tree so the user understands the hierarchical context. Conversely, if a project matches a filter, it may be shown collapsed unless the filter also matches its children.
### 4.6 Mutations & Save (REQ-F-006)
- Every create, update, delete, reorder, or status change triggers a **full re-save** of the master Markdown file.
- The save is **atomic**: write to a temp file, verify the integrity marker, then rename over the original.
- On save failure, the UI shows a non-dismissible banner and keeps the in-memory state intact so the user can retry or export.
### 4.7 External File Monitoring (REQ-F-007)
- The app **watches the master Markdown file** for external changes (e.g., edited via another editor, synced via Dropbox/Git).
- If the file changes on disk and passes the integrity check, the app reloads it automatically and notifies the user.
- If an external change conflicts with unsaved local edits, the UI presents a diff/merge choice rather than silently overwriting.
### 4.8 Import & Export (REQ-F-008)
- **Export**: the user may export the current backlog as a Markdown file or as JSON (full structured dump including history).
- **Import**: the user may import a previously exported Markdown or JSON file.
- Import validates schema (for JSON) and warns on checksum mismatch (for Markdown), but never blocks loading a manually edited file.
## 5. Backup & Recovery
### 5.1 Automatic Rotating Backups (REQ-B-001)
- On every successful save, a timestamped copy is written to a `./backups/` directory.
- Naming convention: `backlog_YYYY-MM-DD_HH-mm-ss.md`
- Rotation policy: keep **at least 7 days** of backups. Older backups may be pruned automatically, but the most recent backup from each calendar day must be preserved for 30 days.
### 5.2 Recovery UI (REQ-B-002)
- The **Admin / Maintenance** page (see §6) lists available backups with:
- Timestamp
- File size
- Entry count
- Integrity check result (valid / corrupt)
- The user may:
- Preview a backup read-only.
- Restore a backup (with a confirmation modal).
- Download any backup as a file.
## 6. Admin & Maintenance Page (REQ-A-001)
A dedicated route/page in the SPA (`/admin` or similar) accessible from a gear icon. It must provide:
| Widget | Content |
|--------|---------|
| **System Health** | Integrity of current file, last save timestamp, last backup timestamp. |
| **Storage Stats** | Master file size, backup directory size, number of backups. |
| **Backup Browser** | List, preview, restore, download backups (§5.2). |
| **Stats Overview** | Key metrics sourced from the stats database (§7): items created/completed this week, average time in `in-progress`, most active project, etc. |
| **Manual Actions** | Force save, force backup, compact history (collapse old `done` items into a summary row). |
## 7. Stats & Metrics Database (REQ-S-001)
### 7.1 Purpose
A separate **append-only** stats store (implementation-agnostic: JSONL file, SQLite, or IndexedDB) that captures usage metrics and enables the admin widgets. It is *not* the source of truth for backlog data; it is purely analytical.
### 7.2 Events to Capture
- `item_created` — id, timestamp, level, project
- `item_status_changed` — id, timestamp, old_status, new_status, duration_in_previous_status
- `item_deleted` — id, timestamp, final_status
- `item_moved` — id, timestamp, old_parent, new_parent
- `item_reordered` — id, timestamp
- `save_completed` — timestamp, file_size, duration_ms
- `backup_created` — timestamp, file_size
- `filter_used` — timestamp, criteria JSON
- `page_view` — timestamp, route
### 7.3 Retention & Privacy
- Retain raw events for 90 days.
- Aggregate older data into daily/weekly roll-ups.
- All data stays local; no telemetry is sent externally.
## 8. Non-Functional Requirements
| ID | Requirement |
|----|-------------|
| REQ-NF-001 | The app must work fully offline after initial load. |
| REQ-NF-002 | Startup time (file load + render) must be < 1 s for a file ≤ 1 MB. |
| REQ-NF-003 | The Markdown file must be valid CommonMark and render legibly in any Markdown viewer. |
| REQ-NF-004 | All dates/times are stored in UTC, displayed in the user's local timezone. |
| REQ-NF-005 | The UI must be keyboard-navigable (expand/collapse, toggle status, quick filter shortcuts). |
| REQ-NF-006 | The UI must be responsive and usable on screens down to 375 px width (mobile). |
## 9. Out of Scope / Future Considerations
1. **Recurring tasks** — Not supported; all items are one-time.
2. **Third-party integrations** — Conversion scripts can be written against the Markdown or JSON export format if needed.
3. **Theme / appearance** — Light theme only for the initial release. CSS custom properties are used throughout so that a dark mode or custom theme can be added later without structural changes.
+418
View File
@@ -0,0 +1,418 @@
#!/usr/bin/env python3
"""Personal Backlog - lightweight file server + REST API.
Zero dependencies (Python 3 stdlib only).
Usage: python3 server.py --port 8080 --dir ./data
"""
import argparse
import hashlib
import json
import os
import re
import shutil
import sys
from datetime import datetime, timezone
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from urllib.parse import parse_qs, urlparse
class Config:
def __init__(self, directory: Path, port: int, web_dir: Path | None = None):
self.dir = directory.resolve()
self.port = port
self.master = self.dir / "backlog.md"
self.backups_dir = self.dir / "backups"
self.stats_file = self.dir / "stats.jsonl"
self.web_dir = web_dir.resolve() if web_dir else Path(__file__).parent.parent / "webapp"
def ensure_dirs(self):
self.dir.mkdir(parents=True, exist_ok=True)
self.backups_dir.mkdir(exist_ok=True)
CONFIG: Config = None # type: ignore[assignment]
# ---------------------------------------------------------------------------
# Markdown integrity helpers
# ---------------------------------------------------------------------------
def compute_checksum(entries_text: str, history_text: str) -> str:
payload = entries_text + "\n" + history_text
h = hashlib.sha256(payload.encode("utf-8")).hexdigest()
return f"sha256:{h}"
def parse_markdown_sections(text: str):
"""Split markdown into entries, history, and integrity sections.
Returns (entries_text, history_text, integrity_meta dict or None).
"""
# Find section markers
entries_start = text.find("<!-- SECTION: ENTRIES -->")
history_start = text.find("<!-- SECTION: HISTORY -->")
integrity_start = text.find("<!-- SECTION: INTEGRITY -->")
if entries_start == -1:
# No sections found — treat entire text as entries
return text, "", None
entries_text = text[entries_start:history_start if history_start != -1 else len(text)]
history_text = ""
integrity_meta = None
if history_start != -1:
end = integrity_start if integrity_start != -1 else len(text)
history_text = text[history_start:end]
if integrity_start != -1:
integrity_block = text[integrity_start:]
# Parse comment like <!-- saved: ... | checksum: ... | entries: ... | history: ... -->
m = re.search(r"saved:\s*([^|]+?)\s*\|\s*checksum:\s*([^|]+?)\s*\|\s*entries:\s*(\d+)\s*\|\s*history:\s*(\d+)", integrity_block)
if m:
integrity_meta = {
"saved": m.group(1).strip(),
"checksum": m.group(2).strip(),
"entries": int(m.group(3)),
"history": int(m.group(4)),
}
return entries_text, history_text, integrity_meta
def make_integrity_marker(entries_text: str, history_text: str) -> str:
checksum = compute_checksum(entries_text, history_text)
entry_count = len(re.findall(r"^[-*] \[", entries_text, re.MULTILINE))
history_count = len([l for l in history_text.splitlines() if l.startswith("|") and "Timestamp" not in l])
saved = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
return f"<!-- saved: {saved} | checksum: {checksum} | entries: {entry_count} | history: {history_count} -->"
def build_markdown(entries_text: str, history_text: str) -> str:
marker = make_integrity_marker(entries_text, history_text)
return f"# Backlog\n\n<!-- SECTION: ENTRIES -->\n\n{entries_text}\n\n<!-- SECTION: HISTORY -->\n\n{history_text}\n\n<!-- SECTION: INTEGRITY -->\n\n{marker}\n"
# ---------------------------------------------------------------------------
# File operations
# ---------------------------------------------------------------------------
def read_master() -> dict:
if not CONFIG.master.exists():
return {"content": build_markdown("", "| Timestamp | Item ID | Action | Details |\n|-----------|---------|--------|---------|"), "checksum": "", "size": 0}
text = CONFIG.master.read_text(encoding="utf-8")
entries, history, meta = parse_markdown_sections(text)
checksum = meta["checksum"] if meta else ""
return {"content": text, "checksum": checksum, "size": len(text.encode("utf-8"))}
def write_master(content: str) -> dict:
"""Atomic write with backup."""
tmp = CONFIG.master.with_suffix(".md.tmp")
# Write temp
tmp.write_text(content, encoding="utf-8")
# Verify it parses
parse_markdown_sections(content)
# Create backup (millis to avoid collisions)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H-%M-%S")
millis = datetime.now(timezone.utc).strftime("%f")[:3]
backup_name = f"backlog_{timestamp}-{millis}.md"
backup_path = CONFIG.backups_dir / backup_name
shutil.copy2(tmp, backup_path)
rotate_backups()
# Atomic rename
tmp.replace(CONFIG.master)
# Stats
append_stats({"t": datetime.now(timezone.utc).isoformat(), "e": "save_completed", "d": {"size": len(content.encode("utf-8"))}})
_, _, meta = parse_markdown_sections(content)
return {"ok": True, "checksum": meta["checksum"] if meta else "", "saved": meta["saved"] if meta else ""}
def rotate_backups():
files = sorted(CONFIG.backups_dir.glob("backlog_*.md"), key=lambda p: p.stat().st_mtime)
if not files:
return
now = datetime.now(timezone.utc)
for f in files[:-1]: # never delete the most recent
age_days = (now.timestamp() - f.stat().st_mtime) / 86400
if age_days <= 7:
continue
day = f.name.split("_")[1] # YYYY-MM-DD
same_day = [x for x in files if x.name.startswith(f"backlog_{day}")]
if f != max(same_day, key=lambda p: p.stat().st_mtime):
f.unlink()
def list_backups() -> list:
result = []
for f in sorted(CONFIG.backups_dir.glob("backlog_*.md"), key=lambda p: p.stat().st_mtime, reverse=True):
text = f.read_text(encoding="utf-8")
_, _, meta = parse_markdown_sections(text)
result.append({
"name": f.name,
"size": f.stat().st_size,
"timestamp": datetime.fromtimestamp(f.stat().st_mtime, tz=timezone.utc).isoformat(),
"valid": meta is not None,
})
return result
def restore_backup(name: str) -> dict:
src = CONFIG.backups_dir / name
if not src.exists():
return {"ok": False, "error": "Backup not found"}
text = src.read_text(encoding="utf-8")
parse_markdown_sections(text) # validate readable
shutil.copy2(src, CONFIG.master)
return {"ok": True}
# ---------------------------------------------------------------------------
# Stats
# ---------------------------------------------------------------------------
def append_stats(event: dict):
with open(CONFIG.stats_file, "a", encoding="utf-8") as f:
f.write(json.dumps(event, ensure_ascii=False) + "\n")
def read_stats(from_iso: str = None, to_iso: str = None) -> list:
if not CONFIG.stats_file.exists():
return []
events = []
with open(CONFIG.stats_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
ev = json.loads(line)
t = ev.get("t", "")
if from_iso and t < from_iso:
continue
if to_iso and t > to_iso:
continue
events.append(ev)
except json.JSONDecodeError:
continue
return events
# ---------------------------------------------------------------------------
# HTTP Handler
# ---------------------------------------------------------------------------
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
# Suppress default logging
pass
def _json_response(self, data: dict, status: int = 200):
body = json.dumps(data).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
self.wfile.write(body)
def _text_response(self, text: str, status: int = 200, content_type: str = "text/plain"):
body = text.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
def _file_response(self, path: Path, content_type: str = "application/octet-stream"):
if not path.exists():
self._json_response({"error": "Not found"}, 404)
return
data = path.read_bytes()
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(data)
def _read_json_body(self) -> dict:
length = int(self.headers.get("Content-Length", 0))
if length == 0:
return {}
body = self.rfile.read(length).decode("utf-8")
return json.loads(body)
def do_OPTIONS(self):
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
def do_GET(self):
parsed = urlparse(self.path)
path = parsed.path
qs = parse_qs(parsed.query)
# Serve static files from web_dir for / and any non-API path
if not path.startswith("/api/"):
# Map / → index.html, otherwise strip leading /
rel = "index-style-v2.html" if path == "/" else path.lstrip("/")
target = (CONFIG.web_dir / rel).resolve()
# Safety: stay inside web_dir
try:
target.relative_to(CONFIG.web_dir)
except ValueError:
self._json_response({"error": "Forbidden"}, 403)
return
if target.exists() and target.is_file():
ext = target.suffix.lower()
mime = {
".html": "text/html", ".css": "text/css",
".js": "application/javascript", ".jsx": "application/javascript",
".json": "application/json", ".md": "text/markdown",
".png": "image/png", ".svg": "image/svg+xml",
}.get(ext, "application/octet-stream")
self._file_response(target, mime)
else:
self._json_response({"error": "Not found"}, 404)
return
if path == "/api/health":
info = read_master()
backups = list_backups()
self._json_response({
"status": "ok",
"lastSave": info.get("meta", {}).get("saved", "") if isinstance(info, dict) else "",
"lastBackup": backups[0]["timestamp"] if backups else "",
"masterSize": CONFIG.master.stat().st_size if CONFIG.master.exists() else 0,
"backupCount": len(backups),
"masterPath": str(CONFIG.master),
"backupsPath": str(CONFIG.backups_dir),
})
return
if path == "/api/backlog":
info = read_master()
client_checksum = qs.get("checksum", [None])[0]
if client_checksum and client_checksum == info["checksum"]:
self.send_response(304)
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
return
self._json_response({"content": info["content"], "checksum": info["checksum"]})
return
if path == "/api/backups":
self._json_response({"backups": list_backups()})
return
if path.startswith("/api/backups/"):
name = path[len("/api/backups/"):]
backup_path = CONFIG.backups_dir / name
self._file_response(backup_path, "text/markdown")
return
if path == "/api/stats":
events = read_stats(qs.get("from", [None])[0], qs.get("to", [None])[0])
self._json_response({"events": events})
return
self._json_response({"error": "Not found"}, 404)
def do_POST(self):
parsed = urlparse(self.path)
path = parsed.path
if path == "/api/backlog":
body = self._read_json_body()
content = body.get("content", "")
result = write_master(content)
self._json_response(result)
return
if path == "/api/backups/restore":
body = self._read_json_body()
result = restore_backup(body.get("name", ""))
self._json_response(result)
return
if path == "/api/export":
body = self._read_json_body()
fmt = body.get("format", "json")
info = read_master()
if fmt == "json":
entries, history, meta = parse_markdown_sections(info["content"])
self._json_response({
"format": "json",
"exported_at": datetime.now(timezone.utc).isoformat(),
"entries_raw": entries,
"history_raw": history,
"integrity": meta,
})
else:
self._text_response(info["content"], content_type="text/markdown")
return
if path == "/api/import":
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length).decode("utf-8")
try:
data = json.loads(raw)
content = data.get("content", "")
except json.JSONDecodeError:
content = raw # assume markdown
parse_markdown_sections(content) # validate readable
result = write_master(content)
self._json_response(result)
return
if path == "/api/stats":
body = self._read_json_body()
append_stats(body)
self._json_response({"ok": True})
return
self._json_response({"error": "Not found"}, 404)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="Personal Backlog Server")
parser.add_argument("--port", type=int, default=8080, help="Port to listen on")
parser.add_argument("--dir", type=str, default=str(Path(__file__).parent),
help="Directory for backlog.md, backups/, stats.jsonl (default: same dir as server.py)")
parser.add_argument("--web-dir", type=str, default=None, help="Directory to serve static files from (default: webapp/)")
args = parser.parse_args()
global CONFIG
web_dir = Path(args.web_dir) if args.web_dir else None
CONFIG = Config(Path(args.dir), args.port, web_dir)
CONFIG.ensure_dirs()
if not CONFIG.master.exists():
blank = build_markdown("", "| Timestamp | Item ID | Action | Details |\n|-----------|---------|--------|---------|")
CONFIG.master.write_text(blank, encoding="utf-8")
print(f"[init] Created blank {CONFIG.master}")
server = HTTPServer(("0.0.0.0", args.port), Handler)
print(f"[server] Listening on http://0.0.0.0:{args.port}")
print(f"[server] Data dir: {CONFIG.dir}")
print(f"[server] Web dir: {CONFIG.web_dir}")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n[server] Shutting down.")
server.shutdown()
if __name__ == "__main__":
main()
+626
View File
@@ -0,0 +1,626 @@
// Admin / Maintenance page: health, storage stats, backup browser, stats, manual actions.
// ---- Range-aware stats engine -----------------------------------------------
// Generates deterministic per-bucket counts based on seed numbers so switching
// range feels real (each range animates between fixed shapes, not random noise).
const RANGE_OPTIONS = [
{ key: "week", label: "This week", buckets: 7, bucketLabel: "d", granularity: "day" },
{ key: "month", label: "This month", buckets: 5, bucketLabel: "w", granularity: "week" },
{ key: "year", label: "This year", buckets: 12, bucketLabel: "m", granularity: "month" },
{ key: "7d", label: "Last 7d", buckets: 7, bucketLabel: "d", granularity: "day" },
{ key: "30d", label: "Last 30d", buckets: 30, bucketLabel: "d", granularity: "day" },
{ key: "365d", label: "Last 365d", buckets: 12, bucketLabel: "m", granularity: "month" },
];
// Returns [{start, end}] bucket boundaries matching bucketLabelsFor(rangeKey)
function bucketBounds(rangeKey, refDate) {
const d = new Date(refDate);
if (rangeKey === 'week') {
const dow = (d.getDay() + 6) % 7;
const mon = new Date(d.getFullYear(), d.getMonth(), d.getDate() - dow);
return Array.from({length: 7}, (_, i) => {
const s = new Date(mon); s.setDate(mon.getDate() + i);
const e = new Date(s); e.setDate(s.getDate() + 1);
return { start: s, end: e };
});
}
if (rangeKey === '7d') {
return Array.from({length: 7}, (_, i) => {
const offset = 6 - i;
const s = new Date(d.getFullYear(), d.getMonth(), d.getDate() - offset);
const e = new Date(s); e.setDate(s.getDate() + 1);
return { start: s, end: e };
});
}
if (rangeKey === '30d') {
return Array.from({length: 30}, (_, i) => {
const offset = 29 - i;
const s = new Date(d.getFullYear(), d.getMonth(), d.getDate() - offset);
const e = new Date(s); e.setDate(s.getDate() + 1);
return { start: s, end: e };
});
}
if (rangeKey === 'month') {
const y = d.getFullYear(), mo = d.getMonth();
return Array.from({length: 5}, (_, i) => ({
start: new Date(y, mo, 1 + i * 7),
end: i < 4 ? new Date(y, mo, 1 + (i + 1) * 7) : new Date(y, mo + 1, 1),
}));
}
if (rangeKey === 'year') {
const y = d.getFullYear();
return Array.from({length: 12}, (_, i) => ({
start: new Date(y, i, 1),
end: new Date(y, i + 1, 1),
}));
}
if (rangeKey === '365d') {
return Array.from({length: 12}, (_, i) => {
const offset = 11 - i;
const y = d.getFullYear(), mo = d.getMonth();
return {
start: new Date(y, mo - offset, 1),
end: new Date(y, mo - offset + 1, 1),
};
});
}
return [];
}
function countInBuckets(history, buckets) {
const keys = ['created', 'in-progress', 'blocked', 'postponed', 'done', 'cancelled'];
const out = Object.fromEntries(keys.map(k => [k, Array(buckets.length).fill(0)]));
for (const h of history) {
const ts = new Date(h.timestamp);
const idx = buckets.findIndex(b => ts >= b.start && ts < b.end);
if (idx < 0) continue;
if (h.action === 'item_created') {
out.created[idx]++;
} else if (h.action === 'status_changed') {
const m = h.details.match(/→\s*(\S[\s\S]*?)$/);
if (m) {
const ns = m[1].trim();
if (ns in out) out[ns][idx]++;
}
}
}
return out;
}
function bucketLabelsFor(rangeKey) {
const today = new Date();
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const days = ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"];
if (rangeKey === "week") {
return days;
}
if (rangeKey === "7d") {
const out = [];
for (let i = 6; i >= 0; i--) {
const d = new Date(today); d.setDate(today.getDate() - i);
out.push(days[(d.getDay() + 6) % 7]);
}
return out;
}
if (rangeKey === "30d") {
const out = [];
for (let i = 29; i >= 0; i--) {
const d = new Date(today); d.setDate(today.getDate() - i);
out.push(String(d.getDate()));
}
return out;
}
if (rangeKey === "month") {
return ["W1","W2","W3","W4","W5"];
}
if (rangeKey === "year") {
return months;
}
if (rangeKey === "365d") {
const out = [];
for (let i = 11; i >= 0; i--) {
const d = new Date(today); d.setMonth(today.getMonth() - i);
out.push(months[d.getMonth()]);
}
return out;
}
return [];
}
// Status keys in the order we want them stacked (bottom → top).
// "created" is a synthetic bucket: items that entered the system in that period.
const CHART_STATUSES = [
{ key: "created", label: "Created", color: "oklch(0.78 0.13 220)" }, // sky cyan-blue
{ key: "in-progress", label: "In progress", color: "oklch(0.72 0.14 270)" }, // lavender
{ key: "blocked", label: "Blocked", color: "oklch(0.68 0.20 28)" }, // red — fixed (0° HSL)
{ key: "postponed", label: "Postponed", color: "oklch(0.83 0.16 90)" }, // bright golden yellow — bisector of red↔green (≈70° HSL)
{ key: "done", label: "Done", color: "oklch(0.68 0.16 150)" }, // green — fixed (140° HSL)
{ key: "cancelled", label: "Cancelled", color: "oklch(0.62 0.04 280)" }, // slate
];
function computeRangeStats(rangeKey, dataStats, history) {
const opt = RANGE_OPTIONS.find(r => r.key === rangeKey);
const labels = bucketLabelsFor(rangeKey);
const now = new Date();
// Prior-period reference: shift back by one equivalent period
const priorRef = new Date(now);
if (rangeKey === 'week' || rangeKey === '7d') priorRef.setDate(priorRef.getDate() - 7);
else if (rangeKey === 'month' || rangeKey === '30d') priorRef.setMonth(priorRef.getMonth() - 1);
else if (rangeKey === 'year' || rangeKey === '365d') priorRef.setFullYear(priorRef.getFullYear() - 1);
const curr = countInBuckets(history, bucketBounds(rangeKey, now));
const prior = countInBuckets(history, bucketBounds(rangeKey, priorRef));
const series = {
created: curr.created,
'in-progress': curr['in-progress'],
blocked: curr.blocked,
postponed: curr.postponed,
done: curr.done,
cancelled: curr.cancelled,
};
const sum = arr => arr.reduce((a, b) => a + b, 0);
const totalCreated = sum(curr.created);
const totalCompleted = sum(curr.done);
const priorCreated = sum(prior.created);
const priorCompleted = sum(prior.done);
const pct = (c, p) => p === 0 ? (c > 0 ? 100 : 0) : Math.round(((c - p) / p) * 100);
const periodWeeks = { week: 1, '7d': 1, month: 4.3, '30d': 4.3, year: 52, '365d': 52 }[rangeKey];
const throughput = +(totalCompleted / periodWeeks).toFixed(1);
const priorThroughput = +(priorCompleted / periodWeeks).toFixed(1);
const completionRate = totalCreated > 0 ? Math.round((totalCompleted / totalCreated) * 100) : 0;
const priorCompletionRate = priorCreated > 0 ? Math.round((priorCompleted / priorCreated) * 100) : 0;
const cycle = dataStats.avgInProgressDays ?? 0;
return {
opt,
labels,
series,
totals: { created: totalCreated, completed: totalCompleted },
kpis: {
created: { value: totalCreated, delta: pct(totalCreated, priorCreated) },
completed: { value: totalCompleted, delta: pct(totalCompleted, priorCompleted) },
throughput: { value: throughput, delta: pct(throughput, priorThroughput), unit: '/wk' },
cycle: { value: cycle, delta: 0, unit: 'd', deltaInverse: true },
rate: { value: completionRate, delta: completionRate - priorCompletionRate, unit: '%' },
},
};
}
function StatsOverview({ dataStats, mostActiveProject, history }) {
const [range, setRange] = useState("week");
const [chartMode, setChartMode] = useState("cumulative"); // cumulative | breakdown
const [hiddenStatuses, setHiddenStatuses] = useState(new Set());
const stats = useMemo(() => computeRangeStats(range, dataStats, history), [range, dataStats, history]);
const visibleStatuses = CHART_STATUSES.filter(s => !hiddenStatuses.has(s.key));
// Both modes show stacked totals; max is bucket sum across visible series.
const maxBar = useMemo(() => {
let m = 1;
for (let i = 0; i < stats.labels.length; i++) {
const s = visibleStatuses.reduce((acc, st) => acc + stats.series[st.key][i], 0);
if (s > m) m = s;
}
return m;
}, [stats, visibleStatuses]);
const dense = stats.labels.length > 12;
const labelStride = dense ? Math.ceil(stats.labels.length / 8) : 1;
const toggleStatus = (key) => {
setHiddenStatuses(prev => {
const next = new Set(prev);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
};
return (
<section className="card span-2 stats-card">
<div className="card-head stats-head">
<div>
<h3>Stats overview</h3>
<span className="card-sub">{stats.opt.label.toLowerCase()} · most active in <strong>{mostActiveProject}</strong></span>
</div>
<div className="range-switch" role="tablist" aria-label="Stats range">
{RANGE_OPTIONS.map(opt => (
<button
key={opt.key}
role="tab"
aria-selected={range === opt.key}
className={`range-btn ${range === opt.key ? "active" : ""}`}
onClick={() => setRange(opt.key)}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="kpi-grid">
<Kpi label="Created" {...stats.kpis.created}/>
<Kpi label="Completed" {...stats.kpis.completed}/>
<Kpi label="Throughput" {...stats.kpis.throughput}/>
<Kpi label="Avg cycle" {...stats.kpis.cycle}/>
<Kpi label="Completion rate" {...stats.kpis.rate}/>
</div>
<div className="chart-head">
<div className="chart-legend" role="group" aria-label="Toggle status series">
{chartMode === "breakdown" ? (
CHART_STATUSES.map(s => {
const off = hiddenStatuses.has(s.key);
return (
<button
key={s.key}
className={`legend-chip ${off ? "off" : ""}`}
onClick={() => toggleStatus(s.key)}
title={off ? `Show ${s.label}` : `Hide ${s.label}`}
>
<span className="lg-swatch" style={{ background: s.color }}/>
{s.label}
</button>
);
})
) : (
<span className="legend-chip static">
<span className="lg-swatch" style={{ background: "var(--accent)" }}/>
Total activity
</span>
)}
</div>
<div className="mode-switch" role="tablist" aria-label="Chart mode">
<button
className={`mode-btn ${chartMode === "cumulative" ? "active" : ""}`}
onClick={() => setChartMode("cumulative")}
aria-selected={chartMode === "cumulative"}
role="tab"
title="Cumulative — single bar per bucket"
>
<ModeIconCumulative/> Cumulative
</button>
<button
className={`mode-btn ${chartMode === "breakdown" ? "active" : ""}`}
onClick={() => setChartMode("breakdown")}
aria-selected={chartMode === "breakdown"}
role="tab"
title="Breakdown — stacked segments per status"
>
<ModeIconBreakdown/> Breakdown
</button>
</div>
</div>
<div className={`stack-bars buckets-${stats.labels.length} mode-${chartMode}`}>
{stats.labels.map((lbl, i) => {
const showLabel = i % labelStride === 0;
const bucketTotal = visibleStatuses.reduce((acc, st) => acc + stats.series[st.key][i], 0);
const tip = chartMode === "breakdown"
? visibleStatuses.map(st => `${st.label} ${stats.series[st.key][i]}`).join(" · ")
: `total ${bucketTotal}`;
// Pre-compute % heights and bottoms for breakdown so segments stack
// without flex rounding errors. Bottom = sum of segments below.
let acc = 0;
const segs = chartMode === "breakdown"
? visibleStatuses.map(st => {
const v = stats.series[st.key][i];
if (v === 0) return null;
const heightPct = (v / maxBar) * 100;
const bottomPct = (acc / maxBar) * 100;
acc += v;
return { st, v, heightPct, bottomPct };
}).filter(Boolean)
: [];
return (
<div key={i} className="stack-col" title={`${lbl} — ${tip}`}>
{chartMode === "breakdown" ? (
<div className="stack-stack">
{segs.map((s, idx) => (
<div
key={s.st.key}
className={`stack-seg ${idx === segs.length - 1 ? "top" : ""}`}
style={{
background: s.st.color,
height: `${s.heightPct}%`,
bottom: `${s.bottomPct}%`,
}}
aria-label={`${s.st.label} ${s.v}`}
/>
))}
</div>
) : (
<div className="stack-stack cumulative-stack">
<div
className="stack-seg cumulative top"
style={{ height: `${(bucketTotal / maxBar) * 100}%`, bottom: 0 }}
aria-label={`total ${bucketTotal}`}
/>
</div>
)}
<div className="stack-cap">
{showLabel ? lbl : ""}
{showLabel && bucketTotal > 0 && (
<span className="stack-total">{bucketTotal}</span>
)}
</div>
</div>
);
})}
</div>
</section>
);
}
const ModeIconCumulative = () => (
<svg width="14" height="12" viewBox="0 0 14 12" fill="none" aria-hidden>
<rect x="0.5" y="6" width="3" height="5" fill="currentColor"/>
<rect x="5.5" y="3" width="3" height="8" fill="currentColor"/>
<rect x="10.5" y="5" width="3" height="6" fill="currentColor"/>
</svg>
);
const ModeIconBreakdown = () => (
<svg width="14" height="12" viewBox="0 0 14 12" fill="none" aria-hidden>
<rect x="1" y="6" width="3" height="5" fill="currentColor" opacity="0.4"/>
<rect x="1" y="2" width="3" height="4" fill="currentColor"/>
<rect x="5.5" y="4" width="3" height="7" fill="currentColor" opacity="0.4"/>
<rect x="5.5" y="1" width="3" height="3" fill="currentColor"/>
<rect x="10" y="7" width="3" height="4" fill="currentColor" opacity="0.4"/>
<rect x="10" y="3" width="3" height="4" fill="currentColor"/>
</svg>
);
function Kpi({ label, value, delta, unit, accent, deltaInverse }) {
// For metrics where lower is better (cycle time), invert sign
const positive = deltaInverse ? delta < 0 : delta > 0;
const negative = deltaInverse ? delta > 0 : delta < 0;
const arrow = delta === 0 ? "→" : positive ? "▲" : "▼";
const cls = delta === 0 ? "flat" : positive ? "up" : "down";
const formatted = Math.abs(delta) === 0 ? "0" : `${Math.abs(delta)}${unit === "%" ? "pp" : "%"}`;
return (
<div className={`kpi ${accent ? "kpi-accent" : ""}`}>
<div className="kpi-label">{label}</div>
<div className="kpi-value">
{value}{unit && <span className="kpi-unit">{unit}</span>}
</div>
<div className={`kpi-delta ${cls}`}>
<span className="kpi-arrow">{arrow}</span> {formatted} <span className="kpi-vs">vs prior</span>
</div>
</div>
);
}
function AdminPage({ data, onClose, history, onForceSave, onForceBackup, onCompact, onRestore, onDownloadBackup }) {
const [previewBackup, setPreviewBackup] = useState(null);
const counts = countByStatus(data.entries);
const total = countAll(data.entries);
const maxStat = Math.max(1, ...Object.values(data.stats.statusMix));
return (
<div className="admin">
<div className="admin-head">
<div>
<div className="eyebrow">Admin · Maintenance</div>
<h2>System overview</h2>
</div>
<button className="btn-secondary" onClick={onClose}>← Back to backlog</button>
</div>
<div className="admin-grid">
{/* Health */}
<section className="card span-2">
<div className="card-head">
<h3>System health</h3>
<span className={`pill ${data.health.integrityOk ? "ok" : "warn"}`}>
{data.health.integrityOk ? "✓ integrity ok" : "checksum mismatch"}
</span>
</div>
<dl className="kv">
<div><dt>Last save</dt><dd>{fmtTimestamp(data.health.lastSave)}</dd></div>
<div><dt>Last backup</dt><dd>{fmtTimestamp(data.health.lastBackup)}</dd></div>
<div><dt>Storage mode</dt><dd>{data.health.mode}</dd></div>
{data.health.masterPath && (
<div><dt>File path</dt><dd className="mono small">{data.health.masterPath}</dd></div>
)}
<div><dt>Checksum</dt><dd className="mono small">{data.meta.checksum.slice(0, 32)}…</dd></div>
<div><dt>Saved at</dt><dd className="mono small">{data.meta.saved}</dd></div>
<div><dt>Entries / history</dt><dd>{data.meta.entryCount} · {data.meta.historyCount}</dd></div>
</dl>
</section>
{/* Storage */}
<section className="card">
<div className="card-head">
<h3>Storage</h3>
<span className="card-sub mono">{fmtBytes(data.health.masterSize + data.health.backupDirSize + data.health.statsSize + data.health.historySize)} total</span>
</div>
<dl className="kv">
<div><dt>backlog.md</dt><dd>{fmtBytes(data.health.masterSize)} · {data.meta.entryCount} entries</dd></div>
{data.health.masterPath && (
<div><dt>Path</dt><dd className="mono small">{data.health.masterPath}</dd></div>
)}
<div><dt>history.jsonl</dt><dd>{fmtBytes(data.health.historySize)} · {data.meta.historyCount.toLocaleString()} events</dd></div>
<div><dt>backups/</dt><dd>{fmtBytes(data.health.backupDirSize)} · {data.health.backupCount} files</dd></div>
{data.health.backupsPath && (
<div><dt>Backups</dt><dd className="mono small">{data.health.backupsPath}</dd></div>
)}
<div><dt>stats.jsonl</dt><dd>{fmtBytes(data.health.statsSize)} · 90-day rollup</dd></div>
<div><dt>Oldest event</dt><dd className="mono small">{fmtTimestamp(data.health.historyOldest)}</dd></div>
</dl>
</section>
{/* Manual actions */}
<section className="card">
<div className="card-head"><h3>Manual actions</h3></div>
<div className="action-list">
<button className="action-btn" onClick={onForceSave}>
<Icon name="save" size={14}/>
<span className="action-text">
<span className="action-label">Force save</span>
<span className="action-meta">last save {fmtRelative(data.health.lastSave)}</span>
</span>
</button>
<button className="action-btn" onClick={onForceBackup}>
<Icon name="archive" size={14}/>
<span className="action-text">
<span className="action-label">Force backup</span>
<span className="action-meta">{data.health.backupCount} files · {fmtBytes(data.health.backupDirSize)}</span>
</span>
</button>
<button className="action-btn" onClick={onCompact}>
<Icon name="refresh" size={14}/>
<span className="action-text">
<span className="action-label">Compact history</span>
<span className="action-meta">{data.meta.historyCount.toLocaleString()} events · {fmtBytes(data.health.historySize)} · since {fmtShortDate(data.health.historyOldest)}</span>
</span>
</button>
</div>
</section>
{/* Stats overview */}
<StatsOverview dataStats={data.stats} mostActiveProject={data.stats.mostActiveProject} history={history}/>
{/* Status mix */}
<section className="card">
<div className="card-head"><h3>Status mix</h3></div>
<div className="status-mix">
{STATUSES.map(s => {
const v = data.stats.statusMix[s.key] || 0;
return (
<div key={s.key} className="mix-row">
<span className={`glyph status-${s.key}`}>{s.glyph}</span>
<span className="mix-label">{s.label}</span>
<span className="mix-bar">
<span className="mix-fill" style={{ width: `${(v / maxStat) * 100}%` }} />
</span>
<span className="mix-num">{v}</span>
</div>
);
})}
</div>
</section>
{/* Backup browser */}
<section className="card span-3">
<div className="card-head">
<h3>Backup browser</h3>
<span className="card-sub">{data.backups.length} files · keeping ≥7 days, ≥30 days of dailies</span>
</div>
<table className="backup-table">
<thead>
<tr>
<th>Filename</th>
<th>Timestamp</th>
<th>Size</th>
<th>Entries</th>
<th>Integrity</th>
<th></th>
</tr>
</thead>
<tbody>
{data.backups.map(b => (
<tr key={b.name} className={previewBackup === b.name ? "active" : ""}>
<td className="mono">{b.name}</td>
<td>{fmtTimestamp(b.timestamp)}</td>
<td>{fmtBytes(b.size)}</td>
<td>{b.entries ?? <span className="muted">—</span>}</td>
<td>
{b.valid
? <span className="pill mini ok">✓ valid</span>
: <span className="pill mini warn"><Icon name="warn" size={10}/> mismatch</span>}
</td>
<td className="row-actions-cell">
<button className="row-btn" onClick={() => setPreviewBackup(previewBackup === b.name ? null : b.name)}>
<Icon name="eye" size={12}/> Preview
</button>
<button className="row-btn" onClick={() => onDownloadBackup(b.name)}>
<Icon name="download" size={12}/> Download
</button>
<button className="row-btn primary" onClick={() => onRestore(b)}>
Restore
</button>
</td>
</tr>
))}
</tbody>
</table>
{previewBackup && (
<div className="backup-preview">
<div className="preview-head">
<span className="mono">{previewBackup}</span>
<span className="card-sub">read-only preview</span>
</div>
<pre className="preview-body">{`# Backlog
<!-- SECTION: ENTRIES -->
## [P0] 🔥 Personal backlog app
- [x] Markdown parser + integrity marker *(done: 2026-04-29)*
- [x] Tokenize three sections
- [x] SHA-256 over entries+history
- [/] Tree renderer with parent-visibility filter *(due: 2026-05-04)*
- [ ] Expand / collapse animations
- [ ] Drag handle within priority group *(due: 2026-05-06)*
- [!] File System Access API direct mode *(reason: Need to test on Edge — no machine yet)*
…
<!-- SECTION: HISTORY -->
| Timestamp | Item ID | Action | Details |
…
<!-- SECTION: INTEGRITY -->
<!-- saved: 2026-04-30T19:44:02Z | checksum: sha256:8a1c3e… | entries: 27 | history: 10 -->`}</pre>
</div>
)}
</section>
{/* Recent history */}
<section className="card span-3">
<div className="card-head">
<h3>Audit log</h3>
<span className="card-sub">
<strong>{data.meta.historyCount.toLocaleString()}</strong> events ·
{" "}{fmtBytes(data.health.historySize)} ·
{" "}since {fmtShortDate(data.health.historyOldest)} ·
{" "}showing latest 10
</span>
</div>
<table className="history-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Item ID</th>
<th>Action</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{history.slice(0, 10).map((h, i) => (
<tr key={i}>
<td className="mono small">{h.timestamp}</td>
<td className="mono small">{h.itemId}</td>
<td><span className="pill mini neutral">{h.action.replace(/_/g, " ")}</span></td>
<td>{h.details}</td>
</tr>
))}
</tbody>
</table>
</section>
</div>
</div>
);
}
window.AdminPage = AdminPage;
+918
View File
@@ -0,0 +1,918 @@
// Main App — top shell, state management, real storage load/save flow.
const { useState: useStateMain, useEffect: useEffectMain, useMemo: useMemoMain, useRef: useRefMain } = React;
const LS_KEY = "personal-backlog-state-v1";
function loadLocalState() {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch { return null; }
}
// Only UI state (which rows are expanded) is persisted locally.
// Backlog data always lives in backlog.md — never in localStorage.
function saveLocalState(expandedMap) {
try { localStorage.setItem(LS_KEY, JSON.stringify({ expandedMap })); } catch { /* quota / private mode */ }
}
// Empty starting state — used when there is no saved data and no seed data loaded.
function buildEmptyData() {
const days = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'].map(day => ({ day, count: 0 }));
return {
entries: [],
history: [],
meta: { saved: null, checksum: '—', entryCount: 0, historyCount: 0 },
health: {
integrityOk: true, lastSave: null, lastBackup: null,
masterSize: 0, backupDirSize: 0, backupCount: 0,
statsSize: 0, historySize: 0, historyOldest: null,
mode: 'localStorage only',
},
stats: {
createdThisWeek: 0, completedThisWeek: 0, avgInProgressDays: null,
mostActiveProject: '—',
completionByDay: days.map(d => ({ ...d })),
createdByDay: days.map(d => ({ ...d })),
statusMix: { open: 0, 'in-progress': 0, blocked: 0, postponed: 0, done: 0, cancelled: 0 },
},
backups: [],
};
}
function App() {
const [data, setData] = useStateMain(buildEmptyData);
const [storageMode, setStorageMode] = useStateMain('local'); // 'api' | 'direct' | 'local'
const [isLoading, setIsLoading] = useStateMain(true);
const [view, setView] = useStateMain('backlog');
const [filters, setFilters] = useStateMain({ statuses: [], priorities: [], tags: [], dueRange: null, scope: 'all', text: '' });
const [expandedMap, setExpandedMap] = useStateMain(() => loadLocalState()?.expandedMap ?? {});
const [saveState, setSaveState] = useStateMain({ status: 'idle', lastSaved: data.health?.lastSave || null });
const [toast, setToast] = useStateMain(null);
const [showWarning, setShowWarning] = useStateMain(false);
const [importExportOpen, setImportExportOpen] = useStateMain(false);
const [itemDialog, setItemDialog] = useStateMain(null);
const [confirm, setConfirm] = useStateMain(null);
const [needsConnect, setNeedsConnect] = useStateMain(false);
const [tweaks, setTweak] = useTweaks({
accent_hue: 35,
density: 'comfortable',
show_ids: false,
paper_texture: true,
status_style: 'color',
sort_mode: 'priority',
});
// Refs for async callbacks that need latest state without stale closures.
const latestData = useRefMain(data);
const latestExpandedMap = useRefMain(expandedMap);
const isDirtyRef = useRefMain(false);
const saveTimerRef = useRefMain(null);
useEffectMain(() => { latestData.current = data; }, [data]);
useEffectMain(() => { latestExpandedMap.current = expandedMap; }, [expandedMap]);
useEffectMain(() => {
document.documentElement.style.setProperty('--accent-hue', tweaks.accent_hue);
document.documentElement.dataset.density = tweaks.density;
document.documentElement.dataset.showIds = tweaks.show_ids ? 'true' : 'false';
document.documentElement.dataset.paper = tweaks.paper_texture ? 'true' : 'false';
}, [tweaks]);
// ---- Storage initialisation (runs once on mount) ----
useEffectMain(() => {
let cancelled = false;
async function initStorage() {
try {
const mode = await Storage.detect();
setStorageMode(mode);
if (mode === 'local') { setIsLoading(false); return; }
if (mode === 'direct') {
// Try silent reconnect — works if permission is still active from a previous session.
const ok = await Storage.tryAutoConnect();
if (!ok) {
// Can't connect without a user gesture — show the connect button instead of blocking.
setIsLoading(false);
setNeedsConnect(true);
return;
}
}
// API mode needs no init — server is already running.
if (cancelled) return;
await applyStorageData(mode, () => cancelled);
} catch (e) {
if (cancelled) return;
setStorageMode('local');
showToast('Storage init failed: ' + e.message, 'err');
} finally {
if (!cancelled) setIsLoading(false);
}
}
async function applyStorageData(mode, isCancelled) {
const [raw, backups, sizeInfo] = await Promise.all([
Storage.load(),
Storage.listBackups(),
Storage.getHealthInfo(),
]);
if (isCancelled?.()) return;
const parsed = await Parser.parse(raw?.content || '');
const newData = await buildDataFromStorage(parsed, backups, mode || Storage.mode, sizeInfo);
if (isCancelled?.()) return;
setData(newData);
if (!loadLocalState()?.expandedMap) {
const em = {};
walkTree(newData.entries, it => { em[it.id] = !it.collapsed; });
setExpandedMap(em);
}
if (!parsed.checksumOk && parsed.meta) setShowWarning(true);
SyncPoller.lastChecksum = parsed.meta?.checksum || '';
SyncPoller.start({
isDirty: () => isDirtyRef.current,
onExternalChange: async (kind, freshParsed) => {
if (kind === 'warn') {
showToast('File changed externally — you have unsaved edits', 'warn');
} else if (kind === 'reload' && freshParsed) {
const [nb, ns] = await Promise.all([Storage.listBackups(), Storage.getHealthInfo()]);
setData(await buildDataFromStorage(freshParsed, nb, Storage.mode, ns));
showToast('Reloaded from disk');
}
},
});
}
initStorage();
return () => { cancelled = true; SyncPoller.stop(); };
}, []);
// Persist expanded/collapsed row state locally. Data itself lives in backlog.md.
useEffectMain(() => {
saveLocalState(latestExpandedMap.current);
}, [expandedMap]);
const showToast = (msg, kind = 'ok') => {
setToast({ msg, kind, t: Date.now() });
setTimeout(() => setToast(t => (t && Date.now() - t.t >= 2400) ? null : t), 2500);
};
// ---- Connect to backlog.md (user-triggered, called from the connect banner) ----
const handleConnect = async () => {
try {
await Storage.connect();
const [raw, backups, sizeInfo] = await Promise.all([
Storage.load(),
Storage.listBackups(),
Storage.getHealthInfo(),
]);
const parsed = await Parser.parse(raw?.content || '');
const newData = await buildDataFromStorage(parsed, backups, Storage.mode, sizeInfo);
setData(newData);
if (!loadLocalState()?.expandedMap) {
const em = {};
walkTree(newData.entries, it => { em[it.id] = !it.collapsed; });
setExpandedMap(em);
}
if (!parsed.checksumOk && parsed.meta) setShowWarning(true);
SyncPoller.lastChecksum = parsed.meta?.checksum || '';
SyncPoller.start({
isDirty: () => isDirtyRef.current,
onExternalChange: async (kind, freshParsed) => {
if (kind === 'warn') {
showToast('File changed externally — you have unsaved edits', 'warn');
} else if (kind === 'reload' && freshParsed) {
const [nb, ns] = await Promise.all([Storage.listBackups(), Storage.getHealthInfo()]);
setData(await buildDataFromStorage(freshParsed, nb, Storage.mode, ns));
showToast('Reloaded from disk');
}
},
});
setNeedsConnect(false);
} catch (e) {
showToast('Connect failed: ' + e.message, 'err');
}
};
// ---- Real async save ----
const triggerSave = (label) => {
isDirtyRef.current = true;
setSaveState(prev => ({ ...prev, status: 'saving' }));
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(async () => {
const d = latestData.current;
const em = latestExpandedMap.current;
try {
if (Storage.isConnected()) {
const content = await Parser.serialize({ entries: d.entries, history: d.history });
await Storage.save(content);
// Keep SyncPoller in sync with our own save to avoid false external-change triggers.
const cm = content.match(/checksum:\s*(sha256:[a-f0-9]+)/);
if (cm) SyncPoller.lastChecksum = cm[1];
}
saveLocalState(em);
isDirtyRef.current = false;
const now = new Date().toISOString();
setSaveState({ status: 'saved', lastSaved: now });
setData(prev => ({
...prev,
health: { ...prev.health, lastSave: now },
meta: { ...prev.meta, saved: now },
}));
if (label) showToast(label);
} catch (e) {
setSaveState(prev => ({ ...prev, status: 'error' }));
showToast('Save failed: ' + e.message, 'err');
}
}, 600);
};
const mutate = (fn) => {
isDirtyRef.current = true;
setData(d => { const c = structuredClone(d); fn(c); return c; });
};
// ---- Helpers ----
function findParentList(items, id, parent = null) {
for (const it of items) {
if (it.id === id) return { list: items, parent };
if (it.children?.length) {
const r = findParentList(it.children, id, it);
if (r) return r;
}
}
return null;
}
const recentTags = useMemoMain(() => {
const counts = {};
walkTree(data.entries, it => (it.tags || []).forEach(t => counts[t] = (counts[t] || 0) + 1));
return Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([t]) => t);
}, [data]);
// ---- Mutations ----
const onMutate = {
setStatus: (id, status) => {
mutate(d => {
const it = findItem(d.entries, id);
if (!it || it.status === status) return;
d.history.unshift({ timestamp: new Date().toISOString(), itemId: id, action: 'status_changed', details: `${it.status} → ${status}` });
const wasDone = it.status === 'done';
it.status = status;
if (status !== 'blocked') it.reason = null;
if (status === 'done') it.progress = 100;
else if (wasDone && (it.progress ?? 0) >= 100) it.progress = 75;
});
triggerSave();
},
setPriority: (id, priority) => {
mutate(d => {
const it = findItem(d.entries, id);
if (!it || it.priority === priority) return;
d.history.unshift({ timestamp: new Date().toISOString(), itemId: id, action: 'priority_changed', details: `${it.priority} → ${priority}` });
it.priority = priority;
});
triggerSave();
},
setProgress: (id, progress) => {
mutate(d => {
const it = findItem(d.entries, id);
if (!it) return;
const v = snapProgress(progress);
if ((it.progress ?? 0) === v) return;
const before = it.progress ?? 0;
it.progress = v;
d.history.unshift({ timestamp: new Date().toISOString(), itemId: id, action: 'progress_changed', details: `${before}% → ${v}%` });
});
triggerSave();
},
moveWithinPriority: (id, dir) => {
mutate(d => {
const r = findParentList(d.entries, id);
if (!r) return;
const i = r.list.findIndex(x => x.id === id);
if (i < 0) return;
const me = r.list[i];
let j = i + dir;
while (j >= 0 && j < r.list.length && r.list[j].priority !== me.priority) j += dir;
if (j < 0 || j >= r.list.length) return;
[r.list[i], r.list[j]] = [r.list[j], r.list[i]];
d.history.unshift({ timestamp: new Date().toISOString(), itemId: id, action: 'item_reordered', details: `moved ${dir < 0 ? 'up' : 'down'}` });
});
triggerSave();
},
reorder: (draggedId, targetId) => {
mutate(d => {
const src = findParentList(d.entries, draggedId);
const tgt = findParentList(d.entries, targetId);
if (!src || !tgt || src.list !== tgt.list) return;
const draggedIdx = src.list.findIndex(x => x.id === draggedId);
const dragged = src.list[draggedIdx];
src.list.splice(draggedIdx, 1);
const newTargetIdx = tgt.list.findIndex(x => x.id === targetId);
tgt.list.splice(newTargetIdx, 0, dragged);
d.history.unshift({ timestamp: new Date().toISOString(), itemId: draggedId, action: 'item_reordered', details: `dropped before ${targetId}` });
});
triggerSave('Reordered');
},
addChild: (parentId) => setItemDialog({ mode: 'add-child', parentId, initial: null }),
addRoot: () => setItemDialog({ mode: 'add', parentId: null, initial: null }),
editItem: (id) => {
const it = findItem(data.entries, id);
if (!it) return;
setItemDialog({ mode: 'edit', itemId: id, initial: it });
},
deleteItem: (id) => {
const it = findItem(data.entries, id);
if (!it) return;
const childCount = (() => { let n = 0; walkTree([it], () => n++); return n - 1; })();
setConfirm({
title: 'Delete this item?',
message: <>Delete <strong>{it.title}</strong>{childCount > 0 ? ` and ${childCount} sub-item${childCount > 1 ? 's' : ''}` : ''}?</>,
detail: <span className="muted">This will be recorded in the history log.</span>,
confirmLabel: 'Delete',
danger: true,
onConfirm: () => {
mutate(d => {
function remove(list) {
const i = list.findIndex(x => x.id === id);
if (i >= 0) { list.splice(i, 1); return true; }
for (const x of list) if (x.children?.length && remove(x.children)) return true;
return false;
}
remove(d.entries);
d.history.unshift({ timestamp: new Date().toISOString(), itemId: id, action: 'item_deleted', details: `final: ${it.status}` });
});
setConfirm(null);
triggerSave('Deleted');
},
});
},
};
const submitItemDialog = (vals) => {
if (itemDialog.mode === 'edit') {
mutate(d => {
const x = findItem(d.entries, itemDialog.itemId);
if (!x) return;
const before = { priority: x.priority, status: x.status };
Object.assign(x, vals);
if (before.status !== vals.status)
d.history.unshift({ timestamp: new Date().toISOString(), itemId: x.id, action: 'status_changed', details: `${before.status} → ${vals.status}` });
if (before.priority !== vals.priority)
d.history.unshift({ timestamp: new Date().toISOString(), itemId: x.id, action: 'priority_changed', details: `${before.priority} → ${vals.priority}` });
});
triggerSave('Saved');
} else {
const newId = 'n-' + Math.random().toString(36).slice(2, 8);
mutate(d => {
const node = { id: newId, level: 1, ...vals, children: [], collapsed: false };
if (itemDialog.mode === 'add-child' && itemDialog.parentId) {
const parent = findItem(d.entries, itemDialog.parentId);
if (parent) {
parent.children = parent.children || [];
node.level = (parent.level || 1) + 1;
parent.children.push(node);
setExpandedMap(m => ({ ...m, [parent.id]: true }));
}
} else {
d.entries.push(node);
}
d.history.unshift({ timestamp: new Date().toISOString(), itemId: newId, action: 'item_created', details: vals.title });
});
triggerSave('Added');
}
setItemDialog(null);
};
const setExpanded = (id, val) => setExpandedMap(m => ({ ...m, [id]: val }));
const tagsList = useMemoMain(() => allTags(data.entries), [data]);
const counts = useMemoMain(() => countByStatus(data.entries), [data]);
const filtered = useMemoMain(() => {
const f = { ...filters, text: filters.text?.trim() || '' };
const noFilters = !f.statuses?.length && !f.priorities?.length && !f.tags?.length && !f.dueRange && !f.text;
let tree = noFilters ? data.entries : filterTree(data.entries, f);
tree = structuredClone(tree);
if (tweaks.sort_mode === 'priority') {
function sortRecur(list) {
list.sort((a, b) => PRIORITIES.indexOf(a.priority) - PRIORITIES.indexOf(b.priority));
list.forEach(it => it.children?.length && sortRecur(it.children));
}
sortRecur(tree);
}
if (f.scope === 'top') tree = tree.map(it => ({ ...it, children: [] }));
return tree;
}, [data, filters, tweaks.sort_mode]);
const filteredCount = useMemoMain(() => countAll(filtered), [filtered]);
const totalCount = useMemoMain(() => countAll(data.entries), [data]);
// ---- Keyboard shortcuts ----
useEffectMain(() => {
const onKey = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === '/' && !e.metaKey) { e.preventDefault(); document.querySelector('.search-input')?.focus(); }
if (e.key === 'g') setView('backlog');
if (e.key === 'a') setView('admin');
if (e.key === 'n') onMutate.addRoot();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
// ---- Restore from backup ----
const handleRestore = (b) => {
setConfirm({
title: 'Restore this backup?',
danger: false,
message: <>Overwrite <code className="mono">backlog.md</code> with the contents of this backup.</>,
detail: (
<>
<div className="mono small">{b.name}</div>
{!b.valid && (
<div className="restore-warn">
<Icon name="warn" size={14}/>
<div>
<div className="restore-warn-title">Checksum mismatch on this backup</div>
<div className="restore-warn-body">The file may be partially written or edited externally.</div>
</div>
</div>
)}
</>
),
confirmLabel: 'Restore',
onConfirm: async () => {
setConfirm(null);
try {
if (Storage.isConnected()) {
const result = await Storage.restoreBackup(b.name);
if (!result.ok) throw new Error(result.error || 'Restore failed');
const [raw, backups, sizeInfo] = await Promise.all([Storage.load(), Storage.listBackups(), Storage.getHealthInfo()]);
const parsed = await Parser.parse(raw?.content || '');
const newData = await buildDataFromStorage(parsed, backups, storageMode, sizeInfo);
setData(newData);
SyncPoller.lastChecksum = parsed.meta?.checksum || '';
isDirtyRef.current = false;
}
showToast(`Restored from ${b.name.slice(0, 28)}…`);
} catch (e) {
showToast('Restore failed: ' + e.message, 'err');
}
},
});
};
// ---- Import entries from parsed content ----
const handleImport = ({ entries, history }) => {
mutate(d => {
d.entries = entries;
if (history?.length) d.history = [...history, ...d.history];
d.history.unshift({ timestamp: new Date().toISOString(), itemId: 'system', action: 'imported', details: `${entries.length} top-level entries` });
});
setImportExportOpen(false);
triggerSave('Imported');
};
const saveLabel = saveState.status === 'saving' ? 'Saving…'
: saveState.status === 'error' ? 'Save failed'
: `Saved · ${fmtTimestamp(saveState.lastSaved)}`;
const hasFilters = filters.text || filters.statuses?.length || filters.priorities?.length || filters.tags?.length || filters.dueRange;
if (isLoading) {
return (
<div className="app" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
<div style={{ textAlign: 'center', color: 'var(--ink-3)' }}>
<div style={{ fontSize: 22, marginBottom: 8 }}>Loading…</div>
<div style={{ fontSize: 13 }}>Connecting to storage</div>
</div>
</div>
);
}
return (
<div className="app">
<StatusStyleContext.Provider value={tweaks.status_style}>
<Header
view={view} setView={setView}
saveState={saveState} saveLabel={saveLabel}
storageMode={storageMode}
searchValue={filters.text}
onSearch={(v) => setFilters({ ...filters, text: v })}
onForceSave={() => triggerSave('Saved manually')}
onOpenImportExport={() => setImportExportOpen(true)}
/>
{storageMode === 'local' && (
<div className="banner warn" style={{gap:8}}>
<Icon name="warn" size={14}/>
<span>
This browser can't access local files directly — <strong>changes won't be saved.</strong>{' '}
Open in Chrome or Edge to save without a server, or run{' '}
<code className="mono">python3 server/server.py</code> for any browser.
</span>
</div>
)}
{needsConnect && (
<div className="banner info">
<Icon name="folder" size={14}/>
No <code className="mono">backlog.md</code> connected — grant one-time folder access, then reloads are silent.
<button className="btn-primary" style={{marginLeft:'auto',flexShrink:0,fontSize:12,padding:'3px 10px'}}
onClick={handleConnect}>Open folder…</button>
</div>
)}
{showWarning && (
<div className="banner warn">
<Icon name="warn" size={14}/>
File was edited outside the app — checksum mismatch. The next save will rewrite a correct marker.
<button className="banner-close" onClick={() => setShowWarning(false)}>dismiss</button>
</div>
)}
{view === 'backlog' ? (
<div className="main">
<FilterPanel filters={filters} setFilters={setFilters} tagsList={tagsList} counts={counts}/>
<section className="content">
<div className="content-sticky">
<div className="content-head">
<div>
<div className="eyebrow">Backlog</div>
<h1 className="content-title">
{hasFilters ? <>{filteredCount} of {totalCount} items</> : <>{totalCount} items</>}
</h1>
</div>
<div className="content-head-actions">
<div className="seg seg-mini" title="Order">
<button className={`seg-btn ${tweaks.sort_mode === 'priority' ? 'active' : ''}`}
onClick={() => setTweak('sort_mode', 'priority')}>By priority</button>
<button className={`seg-btn ${tweaks.sort_mode === 'manual' ? 'active' : ''}`}
onClick={() => setTweak('sort_mode', 'manual')}>Manual</button>
</div>
<button className="btn-primary" onClick={onMutate.addRoot}>
<Icon name="plus" size={12}/> New item
</button>
</div>
</div>
<ViewChips filters={filters} setFilters={setFilters}/>
<div className="legend">
<span className="legend-cap">Status:</span>
{STATUSES.map(s => (
<span key={s.key} className="legend-item">
<StatusIcon status={s.key} size={13}/>
<span>{s.label}</span>
</span>
))}
<span className="legend-sep">·</span>
<span className="legend-hint">
{tweaks.sort_mode === 'manual' ? <>Drag rows to reorder · </> : <>Auto-sorted by priority · drag/arrows reorder within priority · </>}
<kbd>/</kbd> search · <kbd>n</kbd> new
</span>
</div>
</div>
<div className="content-scroll">
{filtered.length === 0 ? (
<div className="empty-state">
<div className="empty-glyph">∅</div>
<div>{hasFilters ? 'No items match these filters.' : 'No items yet. Press n or click "+ New item" to start.'}</div>
</div>
) : (
<BacklogTree
items={filtered}
expandedMap={expandedMap}
setExpanded={setExpanded}
onMutate={onMutate}
query={filters.text?.trim() || ''}
statusStyle={tweaks.status_style}
manualOrder={tweaks.sort_mode === 'manual'}
/>
)}
</div>
</section>
</div>
) : (
<AdminPage
data={data}
history={data.history}
onClose={() => setView('backlog')}
onForceSave={() => triggerSave('Force-saved')}
onForceBackup={() => triggerSave('Force-backed up')}
onCompact={() => {
mutate(d => {
if (d.history.length > 200) d.history = d.history.slice(0, 200);
});
triggerSave('History compacted');
}}
onRestore={handleRestore}
onDownloadBackup={(name) => {
if (Storage.mode === 'api') {
const a = document.createElement('a');
a.href = `/api/backups/${encodeURIComponent(name)}`;
a.download = name;
a.click();
} else {
showToast(`${name.slice(0, 28)}… — download not available in direct mode`);
}
}}
/>
)}
<ItemDialog
open={!!itemDialog}
mode={itemDialog?.mode}
initial={itemDialog?.initial}
recentTags={recentTags}
onClose={() => setItemDialog(null)}
onSubmit={submitItemDialog}
/>
<ConfirmDialog
open={!!confirm}
title={confirm?.title}
message={confirm?.message}
detail={confirm?.detail}
confirmLabel={confirm?.confirmLabel}
danger={confirm?.danger}
onCancel={() => setConfirm(null)}
onConfirm={confirm?.onConfirm}
/>
<ImportExportDialog
open={importExportOpen}
data={data}
storageMode={storageMode}
onClose={() => setImportExportOpen(false)}
onImport={handleImport}
/>
{toast && (
<div className={`toast ${toast.kind}`}><Icon name="check" size={12}/> {toast.msg}</div>
)}
<TweaksPanel title="Tweaks">
<TweakSection title="Status icons">
<TweakRadio label="Style"
value={tweaks.status_style}
options={[
{ value: 'flat', label: 'Flat' },
{ value: 'ascii', label: 'ASCII' },
{ value: 'color', label: 'Color' },
{ value: 'emoji', label: 'Emoji' },
]}
onChange={v => setTweak('status_style', v)}/>
</TweakSection>
<TweakSection title="Order">
<TweakRadio label="Sort"
value={tweaks.sort_mode}
options={[
{ value: 'priority', label: 'By priority' },
{ value: 'manual', label: 'Manual' },
]}
onChange={v => setTweak('sort_mode', v)}/>
</TweakSection>
<TweakSection title="Accent">
<TweakSlider label="Hue" value={tweaks.accent_hue} min={0} max={360} step={1}
onChange={v => setTweak('accent_hue', v)} formatValue={v => `${v}°`}/>
</TweakSection>
<TweakSection title="Layout">
<TweakRadio label="Density"
value={tweaks.density}
options={[{ value: 'comfortable', label: 'Comfortable' }, { value: 'compact', label: 'Compact' }]}
onChange={v => setTweak('density', v)}/>
<TweakToggle label="Paper texture" checked={tweaks.paper_texture} onChange={v => setTweak('paper_texture', v)}/>
</TweakSection>
<TweakSection title="Demo">
<TweakButton label="Show checksum-mismatch banner" onClick={() => setShowWarning(true)}/>
<TweakButton label="Simulate external file change" onClick={() => showToast('File reloaded from disk')}/>
{window.SEED_DATA && (
<TweakButton label="Load sample / test data" onClick={() => {
const seed = structuredClone(window.SEED_DATA);
const defaults = { done: 100, cancelled: 0, 'in-progress': 50, blocked: 25, postponed: 25, open: 0 };
walkTree(seed.entries, it => {
if (typeof it.progress !== 'number') it.progress = defaults[it.status] ?? 0;
else if (it.status === 'done') it.progress = 100;
});
setData(seed);
const em = {};
walkTree(seed.entries, it => { em[it.id] = !it.collapsed; });
setExpandedMap(em);
showToast('Sample data loaded');
}}/>
)}
</TweakSection>
</TweaksPanel>
</StatusStyleContext.Provider>
</div>
);
}
// ----- View chips (saved searches) -----
const VIEW_CHIPS = [
{ key: 'all', label: 'All', statuses: [] },
{ key: 'open', label: 'Open', statuses: ['open'] },
{ key: 'wip', label: 'In progress', statuses: ['in-progress'] },
{ key: 'active', label: 'Active', statuses: ['open', 'in-progress', 'blocked', 'postponed'], hint: 'everything not done or cancelled' },
{ key: 'blocked', label: 'Blocked', statuses: ['blocked'] },
{ key: 'closed', label: 'Closed', statuses: ['done', 'cancelled'], hint: 'done + cancelled' },
];
function ViewChips({ filters, setFilters }) {
const cur = filters.statuses || [];
const same = (a, b) => a.length === b.length && a.every(x => b.includes(x));
const activeKey = VIEW_CHIPS.find(v => same(v.statuses, cur))?.key;
return (
<div className="view-chips" role="tablist" aria-label="Saved views">
{VIEW_CHIPS.map(v => (
<button key={v.key}
role="tab"
aria-selected={activeKey === v.key}
className={`view-chip ${activeKey === v.key ? 'active' : ''}`}
title={v.hint || v.label}
onClick={() => setFilters({ ...filters, statuses: v.statuses })}>
{v.label}
</button>
))}
</div>
);
}
// ----- Header -----
function Header({ view, setView, saveState, saveLabel, storageMode, searchValue, onSearch, onOpenImportExport }) {
const modeIcon = storageMode === 'api' ? '⚡' : storageMode === 'direct' ? '📁' : '💾';
return (
<header className="header">
<div className="brand">
<span className="brand-glyph"><StatusIcon status="in-progress" size={16}/></span>
<span className="brand-name">backlog</span>
<span className="brand-sub mono" title={`Storage: ${storageMode}`}>backlog.md {modeIcon}</span>
</div>
<nav className="tabs">
<button className={`tab ${view === 'backlog' ? 'active' : ''}`} onClick={() => setView('backlog')}>
<Icon name="list" size={13}/> Backlog
</button>
<button className={`tab ${view === 'admin' ? 'active' : ''}`} onClick={() => setView('admin')}>
<Icon name="cog" size={13}/> Admin
</button>
</nav>
<div className="search-wrap">
<Icon name="search" size={14}/>
<input className="search-input"
placeholder="Search titles, tags, reasons… ( / )"
value={searchValue}
onChange={(e) => onSearch(e.target.value)}/>
{searchValue && <button className="search-clear" onClick={() => onSearch('')}>×</button>}
</div>
<div className="header-right">
<button className="header-btn" onClick={onOpenImportExport} title="Import / Export">
<Icon name="archive" size={13}/> Import/Export
</button>
<div className={`save-indicator ${saveState.status}`}>
<span className={`save-dot ${saveState.status}`}/>
{saveLabel}
</div>
</div>
</header>
);
}
// ----- Import / Export dialog -----
function ImportExportDialog({ open, data, storageMode, onClose, onImport }) {
const { useState: useStateD, useEffect: useEffectD, useRef: useRefD } = React;
const [tab, setTab] = useStateD('md');
const [copied, setCopied] = useStateD(false);
const [mdContent, setMdContent] = useStateD('');
const fileInputRef = useRefD(null);
// Async-generate markdown when dialog opens or data changes.
useEffectD(() => {
if (!open || !data) return;
let cancelled = false;
Parser.serialize({ entries: data.entries, history: data.history })
.then(md => { if (!cancelled) setMdContent(md); })
.catch(() => {});
return () => { cancelled = true; };
}, [open, data]);
const jsonContent = React.useMemo(() => {
if (!data) return '';
return JSON.stringify({ entries: data.entries, history: data.history, meta: data.meta }, null, 2);
}, [data]);
const currentContent = tab === 'md' ? mdContent : jsonContent;
const filename = tab === 'md' ? 'backlog.md' : 'backlog_export.json';
const mimeType = tab === 'md' ? 'text/markdown' : 'application/json';
const copyContent = async () => {
try { await navigator.clipboard.writeText(currentContent); }
catch {
const ta = Object.assign(document.createElement('textarea'), {
value: currentContent, style: 'position:fixed;opacity:0',
});
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); } catch {}
document.body.removeChild(ta);
}
setCopied(true);
setTimeout(() => setCopied(false), 1400);
};
const downloadFile = () => {
const blob = new Blob([currentContent], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'), { href: url, download: filename });
a.click();
URL.revokeObjectURL(url);
};
const handleFileInput = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = '';
try {
const text = await file.text();
let entries, history;
if (file.name.endsWith('.json')) {
const obj = JSON.parse(text);
entries = obj.entries || [];
history = obj.history || [];
} else {
const parsed = await Parser.parse(text);
entries = parsed.entries;
history = parsed.history;
}
onImport({ entries, history });
} catch (err) {
alert('Import failed: ' + err.message);
}
};
return (
<Dialog open={open} onClose={onClose} width={760} labelledBy="dlg-ie-title">
<DialogHeader id="dlg-ie-title" eyebrow="Backup & sync" title="Import / Export" onClose={onClose}/>
<div className="dlg-body">
<div className="ie-tabs">
<button className={`ie-tab ${tab === 'md' ? 'active' : ''}`} onClick={() => setTab('md')}>
Markdown <span className="ie-tab-sub">.md</span>
</button>
<button className={`ie-tab ${tab === 'json' ? 'active' : ''}`} onClick={() => setTab('json')}>
JSON <span className="ie-tab-sub">structured</span>
</button>
<button
className={`ie-copy-btn ${copied ? 'copied' : ''}`}
onClick={copyContent}
title={`Copy ${tab === 'md' ? 'Markdown' : 'JSON'} to clipboard`}
aria-live="polite"
>
{copied
? <><Icon name="check" size={13}/> <span>Copied</span></>
: <><Icon name="copy" size={13}/> <span>Copy</span></>}
</button>
</div>
<pre className="export-pre">{currentContent || '…generating…'}</pre>
</div>
<div className="dlg-foot">
<div>
<input ref={fileInputRef} type="file" accept=".md,.json" style={{ display: 'none' }} onChange={handleFileInput}/>
<button className="btn-secondary" onClick={() => fileInputRef.current?.click()}>
<Icon name="upload" size={12}/> Import file…
</button>
</div>
<div className="dlg-foot-actions">
<button className="btn-secondary" onClick={onClose}>Close</button>
<button className="btn-primary" onClick={downloadFile}>
<Icon name="download" size={12}/> Download {tab === 'md' ? '.md' : '.json'}
</button>
</div>
</div>
</Dialog>
);
}
window.App = App;
+233
View File
@@ -0,0 +1,233 @@
#!/usr/bin/env node
/**
* bundle.js — Flatten an HTML app and all its local deps into a single
* self-contained .html artifact, ready to open offline.
*
* What it does:
* - Inlines every local <script src> and <link rel="stylesheet" href>.
* - Pre-compiles JSX (type="text/babel") to plain JS using @babel/core,
* then removes the Babel CDN <script> tag — it's no longer needed.
* - Replaces React/ReactDOM dev CDN builds with production minified builds
* (much smaller; fetched once per run).
* - Skips anything inside HTML comments — commented-out blocks stay inert.
* - Leaves other remote URLs (fonts, etc.) untouched.
*
* Usage:
* node bundle.js <input.html> [output.html]
*
* Example:
* node bundle.js index.html ../webapp/index-style-v2.html
*
* Requirements:
* Node 18+ (uses global fetch to pull prod React builds).
* Run `npm install` once to get @babel/core + @babel/preset-react.
*/
const fs = require('fs');
const path = require('path');
// JSX compiler — graceful no-op if not installed.
// Require the preset as a module reference so Babel doesn't try to resolve it
// by name from cwd (which may differ from __dirname when invoked from another dir).
let babel, babelPresetReact;
try {
babel = require('@babel/core');
babelPresetReact = require('@babel/preset-react');
} catch { /* npm install to enable */ }
// ---- CDN substitution table -------------------------------------------------
// key: substring present in the CDN src URL
// value: prod URL to fetch & inline, or null to remove the tag entirely
const CDN_SWAP = [
{ match: 'unpkg.com/react-dom@', prod: 'https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js' },
{ match: 'unpkg.com/react@', prod: 'https://unpkg.com/react@18.3.1/umd/react.production.min.js' },
{ match: 'unpkg.com/@babel/standalone', prod: null }, // removed — we pre-compile
];
// react-dom must come before react in the list so substring matching works correctly
// (react-dom URL also contains "react@")
// ---- Helpers -----------------------------------------------------------------
const args = process.argv.slice(2).filter(a => !a.startsWith('--'));
if (args.length < 1) {
console.error('Usage: node bundle.js <input.html> [output.html]');
process.exit(1);
}
const inputPath = args[0];
const inputDir = path.dirname(path.resolve(inputPath));
const inputBase = path.basename(inputPath, '.html');
const outputPath = args[1] || path.join(inputDir, `${inputBase} (single file).html`);
function isRemote(url) { return /^(https?:)?\/\//i.test(url) || url.startsWith('data:'); }
function readLocal(ref) {
const full = path.resolve(inputDir, ref);
if (!fs.existsSync(full)) { console.warn(` ⚠ missing: ${ref}`); return null; }
return fs.readFileSync(full, 'utf8');
}
async function fetchRemote(url) {
console.log(` ⤓ fetching ${url}`);
const r = await fetch(url);
if (!r.ok) { console.warn(` ⚠ ${r.status} ${url}`); return null; }
return r.text();
}
// Returns [start, end] pairs for every <!-- ... --> span in html.
function commentSpans(html) {
const spans = [];
const re = /<!--[\s\S]*?-->/g;
let m;
while ((m = re.exec(html)) !== null) spans.push([m.index, m.index + m[0].length]);
return spans;
}
function insideComment(pos, spans) {
return spans.some(([s, e]) => pos >= s && pos < e);
}
// Escape </script inside a <script> block so the browser doesn't close it early.
function escForScript(src) { return src.replace(/<\/script/gi, '<\\/script'); }
function escForStyle(src) { return src.replace(/<\/style/gi, '<\\/style'); }
// Pre-compile JSX → JS. Falls back to raw source if babel isn't installed.
function compileJSX(src, filename) {
if (!babel || !babelPresetReact) return { code: src, compiled: false };
try {
const result = babel.transformSync(src, {
presets: [babelPresetReact],
filename,
sourceMaps: false,
compact: false,
});
return { code: result.code, compiled: true };
} catch (e) {
console.warn(` ⚠ JSX compile error in ${filename}: ${e.message}`);
return { code: src, compiled: false };
}
}
// ---- Inline local stylesheets -----------------------------------------------
async function inlineStylesheets(html) {
const comments = commentSpans(html);
const re = /<link\b([^>]*?)\brel\s*=\s*["']stylesheet["']([^>]*?)>/gi;
const matches = [...html.matchAll(re)];
let out = '', cursor = 0;
for (const m of matches) {
if (insideComment(m.index, comments)) {
out += html.slice(cursor, m.index + m[0].length);
cursor = m.index + m[0].length;
continue;
}
out += html.slice(cursor, m.index);
const hrefM = (m[1] + ' ' + m[2]).match(/\bhref\s*=\s*["']([^"']+)["']/i);
const href = hrefM?.[1];
const body = href && !isRemote(href) ? readLocal(href) : null;
if (body) { console.log(` ✓ stylesheet ${href}`); out += `<style>\n${escForStyle(body)}\n</style>`; }
else out += m[0];
cursor = m.index + m[0].length;
}
return out + html.slice(cursor);
}
// ---- Inline / swap / remove <script src="..."> tags -------------------------
async function inlineScripts(html) {
const comments = commentSpans(html);
const re = /<script\b([^>]*)\bsrc\s*=\s*["']([^"']+)["']([^>]*)>\s*<\/script>/gi;
const matches = [...html.matchAll(re)];
let out = '', cursor = 0;
for (const m of matches) {
// Keep everything inside HTML comments verbatim.
if (insideComment(m.index, comments)) {
out += html.slice(cursor, m.index + m[0].length);
cursor = m.index + m[0].length;
continue;
}
out += html.slice(cursor, m.index);
const [full, attrsBefore, src, attrsAfter] = m;
const allAttrs = (attrsBefore + ' ' + attrsAfter).trim();
const isBabel = /type\s*=\s*["']text\/babel["']/i.test(allAttrs);
if (isRemote(src)) {
// Look for a CDN substitution rule.
const rule = CDN_SWAP.find(r => src.includes(r.match));
if (rule === undefined) {
out += full; // unknown CDN — keep as-is
} else if (rule.prod === null) {
console.log(` ✗ removed ${src}`);
// tag dropped
} else {
const body = await fetchRemote(rule.prod);
if (body) { console.log(` ✓ inlined prod build`); out += `<script>\n${escForScript(body)}\n</script>`; }
else out += full;
}
} else {
// Local file.
let body = readLocal(src);
if (body == null) { out += full; }
else {
let label = src;
if (isBabel) {
const r = compileJSX(body, src);
body = r.code;
label = r.compiled ? `${src} (JSX→JS)` : src;
}
console.log(` ✓ script ${label}`);
// Strip type="text/babel", integrity, crossorigin — content has changed.
const attrs = allAttrs
.replace(/\btype\s*=\s*["']text\/babel["']/gi, '')
.replace(/\bintegrity\s*=\s*["'][^"']*["']/gi, '')
.replace(/\bcrossorigin\s*=\s*["'][^"']*["']/gi, '')
.replace(/\s+/g, ' ').trim();
out += `<script${attrs ? ' ' + attrs : ''}>\n${escForScript(body)}\n</script>`;
}
}
cursor = m.index + full.length;
}
return out + html.slice(cursor);
}
// ---- Compile remaining inline <script type="text/babel">…</script> blocks ---
function compileInlineBabel(html) {
const comments = commentSpans(html);
const re = /(<script\b[^>]*\btype\s*=\s*["']text\/babel["'][^>]*>)([\s\S]*?)(<\/script>)/gi;
return html.replace(re, (full, open, body, close, offset) => {
if (insideComment(offset, comments)) return full;
const { code, compiled } = compileJSX(body, 'inline.jsx');
if (!compiled) return full;
const attrs = open
.replace(/^<script\b/i, '')
.replace(/>$/, '')
.replace(/\btype\s*=\s*["']text\/babel["']/gi, '')
.replace(/\s+/g, ' ').trim();
console.log(' ✓ inline babel block compiled');
return `<script${attrs ? ' ' + attrs : ''}>\n${escForScript(code)}\n${close}`;
});
}
// ---- Main -------------------------------------------------------------------
(async () => {
if (babel && babelPresetReact) console.log('JSX: pre-compile enabled (@babel/core)');
else console.log('JSX: no @babel/core — run npm install to enable');
console.log(`\nReading ${inputPath}…`);
let html = fs.readFileSync(inputPath, 'utf8');
console.log('\nStylesheets:');
html = await inlineStylesheets(html);
console.log('\nScripts:');
html = await inlineScripts(html);
if (babel) {
console.log('\nInline Babel blocks:');
html = compileInlineBabel(html);
}
fs.writeFileSync(outputPath, html);
const kb = (fs.statSync(outputPath).size / 1024).toFixed(0);
console.log(`\n✔ wrote ${outputPath} (${kb} KB)`);
})();
+319
View File
@@ -0,0 +1,319 @@
// TEST-ONLY seed data for the Personal Backlog prototype.
// Not loaded by default — only included when running in test/demo mode.
// To activate: uncomment the data.jsx <script> tag in index.html.
// When loaded, exposes window.SEED_DATA; the app detects it and shows a
// "Load sample data" button in the Tweaks panel.
//
// Realistic personal-project material: side projects, household, learning.
// Statuses: open, in-progress, blocked, postponed, done, cancelled
// Priorities: P0, P1, P2, P3
// Up to 4 levels: Area → Project → Task → Sub-task
const SEED_DATA = {
entries: [
{
id: "area-side",
level: 1,
title: "Side projects",
priority: "P1",
status: "in-progress", progress: 50,
due: null,
tags: [],
reason: null,
collapsed: false,
children: [
{
id: "proj-backlog",
level: 2,
title: "Personal backlog app",
priority: "P0",
status: "in-progress", progress: 65,
due: "2026-05-12",
tags: ["build", "tooling"],
reason: null,
collapsed: false,
children: [
{
id: "t-parser",
level: 3,
title: "Markdown parser + integrity marker",
priority: "P0",
status: "done", progress: 100,
due: "2026-04-28",
tags: ["build"],
reason: null,
collapsed: false,
children: [
{ id: "st-tokenize", level: 4, title: "Tokenize three sections", priority: "P0", status: "done", progress: 100, due: null, tags: [], reason: null, children: [] },
{ id: "st-checksum", level: 4, title: "SHA-256 over entries+history", priority: "P0", status: "done", progress: 100, due: null, tags: [], reason: null, children: [] }
]
},
{
id: "t-renderer",
level: 3,
title: "Tree renderer with parent-visibility filter",
priority: "P0",
status: "in-progress", progress: 55,
due: "2026-05-04",
tags: ["build", "ui"],
reason: null,
collapsed: false,
children: [
{ id: "st-collapse", level: 4, title: "Expand / collapse animations", priority: "P1", status: "open", progress: 25, due: null, tags: ["ui"], reason: null, children: [] },
{ id: "st-dnd", level: 4, title: "Drag handle within priority group", priority: "P1", status: "open", progress: 0, due: "2026-05-06", tags: ["ui"], reason: null, children: [] }
]
},
{
id: "t-fsapi",
level: 3,
title: "File System Access API direct mode",
priority: "P1",
status: "blocked", progress: 50,
due: "2026-05-10",
tags: ["research"],
reason: "Need to test on Edge — no machine yet",
children: []
},
{
id: "t-admin",
level: 3,
title: "Admin page — backup browser + restore",
priority: "P2",
status: "open", progress: 0,
due: "2026-05-20",
tags: ["build", "ui"],
reason: null,
children: []
},
{
id: "t-stats",
level: 3,
title: "stats.jsonl rollups (90-day retention)",
priority: "P3",
status: "postponed", progress: 25,
due: null,
tags: ["build"],
reason: null,
children: []
}
]
},
{
id: "proj-cookbook",
level: 2,
title: "Family cookbook static site",
priority: "P2",
status: "in-progress", progress: 30,
due: "2026-06-15",
tags: ["writing"],
reason: null,
collapsed: true,
children: [
{ id: "t-recipes", level: 3, title: "Transcribe Mom's index cards", priority: "P1", status: "in-progress", progress: 75, due: "2026-05-30", tags: ["writing"], reason: null, children: [] },
{ id: "t-photos", level: 3, title: "Photograph each finished dish", priority: "P2", status: "open", progress: 0, due: null, tags: ["photo"], reason: null, children: [] },
{ id: "t-print", level: 3, title: "Test print layout (8.5×11)", priority: "P3", status: "open", progress: 0, due: null, tags: [], reason: null, children: [] }
]
}
]
},
{
id: "area-house",
level: 1,
title: "Home & life admin",
priority: "P1",
status: "in-progress", progress: 50,
due: null,
tags: [],
reason: null,
collapsed: false,
children: [
{
id: "proj-tax",
level: 2,
title: "File 2025 taxes",
priority: "P0",
status: "in-progress", progress: 50,
due: "2026-04-15",
tags: ["urgent", "finance"],
reason: null,
collapsed: false,
children: [
{ id: "t-w2", level: 3, title: "Gather W-2s and 1099s", priority: "P0", status: "done", progress: 100, due: "2026-03-10", tags: ["finance"], reason: null, children: [] },
{ id: "t-deduct", level: 3, title: "Itemize charitable deductions", priority: "P0", status: "in-progress", progress: 50, due: "2026-04-10", tags: ["finance"], reason: null, children: [] },
{ id: "t-cpa", level: 3, title: "Schedule call with CPA", priority: "P0", status: "blocked", progress: 25, due: "2026-04-12", tags: ["urgent"], reason: "CPA out until Apr 8", children: [] }
]
},
{
id: "proj-house",
level: 2,
title: "Apartment maintenance",
priority: "P2",
status: "in-progress", progress: 50,
due: null,
tags: [],
reason: null,
collapsed: false,
children: [
{ id: "t-filter", level: 3, title: "Replace HVAC filter", priority: "P1", status: "open", progress: 0, due: "2026-05-05", tags: ["recurring"], reason: null, children: [] },
{ id: "t-sink", level: 3, title: "Fix leaky kitchen sink", priority: "P2", status: "open", progress: 0, due: null, tags: [], reason: null, children: [] },
{ id: "t-storage", level: 3, title: "Donate boxes from closet", priority: "P3", status: "postponed", progress: 25, due: null, tags: [], reason: null, children: [] }
]
}
]
},
{
id: "area-learn",
level: 1,
title: "Learning",
priority: "P2",
status: "in-progress", progress: 50,
due: null,
tags: [],
reason: null,
collapsed: false,
children: [
{
id: "proj-rust",
level: 2,
title: "Rust in 30 days",
priority: "P2",
status: "in-progress", progress: 40,
due: "2026-07-01",
tags: ["learning", "rust"],
reason: null,
collapsed: true,
children: [
{ id: "t-book", level: 3, title: "Read chapters 1–6", priority: "P2", status: "done", progress: 100, due: "2026-04-20", tags: ["rust"], reason: null, children: [] },
{ id: "t-cli", level: 3, title: "Build a small CLI tool", priority: "P2", status: "open", progress: 0, due: "2026-06-01", tags: ["rust"], reason: null, children: [] }
]
},
{
id: "proj-piano",
level: 2,
title: "Pick piano back up",
priority: "P3",
status: "cancelled", progress: 0,
due: null,
tags: [],
reason: null,
collapsed: true,
children: []
}
]
},
{
// standalone task at level 2 (no children)
id: "oneoff-passport",
level: 2,
title: "Renew passport",
priority: "P1",
status: "open", progress: 0,
due: "2026-05-15",
tags: ["urgent"],
reason: null,
children: []
}
],
history: [
{ timestamp: "2026-05-01T08:12:30Z", itemId: "t-deduct", action: "status_changed", details: "open → in-progress" },
{ timestamp: "2026-04-30T19:44:02Z", itemId: "t-cpa", action: "status_changed", details: "open → blocked (CPA out until Apr 8)" },
{ timestamp: "2026-04-30T14:08:55Z", itemId: "t-renderer", action: "status_changed", details: "open → in-progress" },
{ timestamp: "2026-04-29T22:01:11Z", itemId: "st-checksum", action: "status_changed", details: "in-progress → done" },
{ timestamp: "2026-04-29T18:30:00Z", itemId: "t-parser", action: "status_changed", details: "in-progress → done" },
{ timestamp: "2026-04-28T09:15:42Z", itemId: "t-book", action: "status_changed", details: "in-progress → done" },
{ timestamp: "2026-04-27T11:22:18Z", itemId: "proj-piano", action: "status_changed", details: "postponed → cancelled" },
{ timestamp: "2026-04-25T13:50:09Z", itemId: "oneoff-passport", action: "item_created", details: "Renew passport" },
{ timestamp: "2026-04-24T07:33:50Z", itemId: "t-w2", action: "status_changed", details: "in-progress → done" },
{ timestamp: "2026-04-22T16:18:27Z", itemId: "t-recipes", action: "status_changed", details: "open → in-progress" },
{ timestamp: "2026-04-20T10:00:00Z", itemId: "proj-backlog", action: "item_created", details: "Personal backlog app" }
],
meta: {
saved: "2026-05-01T08:12:30Z",
checksum: "sha256:9f2c4a1b6e88d7f0a3c5b2e9d1f7a4c8b6e2d5f1a9c7b3e0d8f2a5c1b4e7d3f9",
entryCount: 27,
historyCount: 1284
},
backups: [
{ name: "backlog_2026-05-01_08-12-30.md", size: 18432, timestamp: "2026-05-01T08:12:30Z", entries: 27, valid: true },
{ name: "backlog_2026-04-30_19-44-02.md", size: 18380, timestamp: "2026-04-30T19:44:02Z", entries: 27, valid: true },
{ name: "backlog_2026-04-30_14-08-55.md", size: 18290, timestamp: "2026-04-30T14:08:55Z", entries: 27, valid: true },
{ name: "backlog_2026-04-29_22-01-11.md", size: 18102, timestamp: "2026-04-29T22:01:11Z", entries: 26, valid: true },
{ name: "backlog_2026-04-29_18-30-00.md", size: 18044, timestamp: "2026-04-29T18:30:00Z", entries: 26, valid: true },
{ name: "backlog_2026-04-28_09-15-42.md", size: 17988, timestamp: "2026-04-28T09:15:42Z", entries: 26, valid: true },
{ name: "backlog_2026-04-27_11-22-18.md", size: 17920, timestamp: "2026-04-27T11:22:18Z", entries: 26, valid: true },
{ name: "backlog_2026-04-25_13-50-09.md", size: 17812, timestamp: "2026-04-25T13:50:09Z", entries: 25, valid: false },
{ name: "backlog_2026-04-22_16-18-27.md", size: 17640, timestamp: "2026-04-22T16:18:27Z", entries: 24, valid: true }
],
health: {
integrityOk: true,
lastSave: "2026-05-01T08:12:30Z",
lastBackup: "2026-05-01T08:12:30Z",
masterSize: 18432,
backupDirSize: 162608,
backupCount: 9,
statsSize: 48210,
historySize: 84320,
historyOldest: "2025-11-14T09:02:11Z",
mode: "API server"
},
stats: {
createdThisWeek: 6,
completedThisWeek: 4,
avgInProgressDays: 2.4,
mostActiveProject: "Personal backlog app",
completionByDay: [
{ day: "Mon", count: 1 },
{ day: "Tue", count: 0 },
{ day: "Wed", count: 2 },
{ day: "Thu", count: 1 },
{ day: "Fri", count: 0 },
{ day: "Sat", count: 0 },
{ day: "Sun", count: 0 }
],
statusMix: { open: 9, "in-progress": 6, blocked: 2, postponed: 2, done: 7, cancelled: 1 }
}
};
// --- Inflate seed with a realistic-sized tag vocabulary, so the autocomplete
// has hundreds of entries to search through (mirrors a power-user backlog).
(function inflateTags() {
const POOL = [
// domains
"frontend","backend","infra","devops","mobile","ios","android","desktop","cli","web","api","sdk",
// languages / stacks
"js","ts","python","rust","go","sql","postgres","sqlite","redis","docker","k8s","aws","gcp","linux","macos","windows",
// work types
"research","spike","prototype","design","qa","testing","ci","release","launch","onboarding","docs","writing","talk","meeting",
// life
"home","family","kids","cooking","recipes","photo","garden","travel","car","health","fitness","reading","music","piano","guitar",
// finance / admin
"finance","tax","budget","invoice","insurance","subscription","passport","dmv","banking",
// priority signals
"urgent","quick-win","blocked-on-vendor","followup","waiting","external",
// affinity tags
"energy-low","deep-work","errand","weekend","evening","morning",
// specifics
"v2","v3","p0-week","oncall","review","rfc","interview","hiring","1-1","retro","planning","roadmap","sprint","standup"
];
// Gather all leaf+branch items
const all = [];
(function walk(arr) { for (const it of arr) { all.push(it); if (it.children) walk(it.children); } })(SEED_DATA.entries);
let seed = 1;
const rand = () => { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; };
for (const it of all) {
const existing = new Set(it.tags || []);
const n = 1 + Math.floor(rand() * 3); // 1–3 extra tags
for (let i = 0; i < n; i++) {
const t = POOL[Math.floor(rand() * POOL.length)];
existing.add(t);
}
it.tags = [...existing];
}
})();
window.SEED_DATA = SEED_DATA;
+372
View File
@@ -0,0 +1,372 @@
// Stylish dialog primitives + concrete dialogs (Edit/Add Item, Confirm).
const { useState: useStateD, useEffect: useEffectD, useRef: useRefD } = React;
function Dialog({ open, onClose, children, width = 460, labelledBy }) {
useEffectD(() => {
if (!open) return;
const onKey = (e) => { if (e.key === "Escape") onClose?.(); };
document.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [open, onClose]);
if (!open) return null;
return (
<div className="dlg-backdrop" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose?.(); }}>
<div className="dlg" role="dialog" aria-modal="true" aria-labelledby={labelledBy} style={{ maxWidth: width }}>
{children}
</div>
</div>
);
}
function DialogHeader({ eyebrow, title, onClose, id }) {
return (
<div className="dlg-head">
<div>
{eyebrow && <div className="eyebrow">{eyebrow}</div>}
<h3 id={id} className="dlg-title">{title}</h3>
</div>
{onClose && (
<button className="dlg-close" onClick={onClose} aria-label="Close">
<svg width="14" height="14" viewBox="0 0 16 16"><path d="M3 3l10 10M13 3L3 13" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>
</button>
)}
</div>
);
}
// --- Item editor (used for both edit and add) ---
function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] }) {
const [title, setTitle] = useStateD("");
const [priority, setPriority] = useStateD("P2");
const [status, setStatus] = useStateD("open");
const [due, setDue] = useStateD("");
const [tags, setTags] = useStateD([]);
const [tagInput, setTagInput] = useStateD("");
const [reason, setReason] = useStateD("");
const [progress, setProgress] = useStateD(0);
const titleRef = useRefD(null);
useEffectD(() => {
if (!open) return;
setTitle(initial?.title || "");
setPriority(initial?.priority || "P2");
setStatus(initial?.status || "open");
setDue(initial?.due || "");
setTags(initial?.tags || []);
setTagInput("");
setReason(initial?.reason || "");
setProgress(initial?.progress ?? 0);
setTimeout(() => titleRef.current?.focus(), 50);
}, [open, initial]);
const addTag = (t) => {
const v = t.trim().replace(/^#/, "");
if (!v) return;
if (tags.includes(v)) return;
setTags([...tags, v]);
};
const removeTag = (t) => setTags(tags.filter(x => x !== t));
const submit = (e) => {
e?.preventDefault();
if (!title.trim()) return;
// Reconcile progress with status before submitting.
let outProgress = snapProgress(progress);
if (status === "done") outProgress = 100;
onSubmit({
title: title.trim(),
priority, status,
due: due || null,
tags,
reason: status === "blocked" ? reason.trim() || null : null,
progress: outProgress
});
};
const tagSuggestions = recentTags.filter(t => !tags.includes(t)).slice(0, 8);
return (
<Dialog open={open} onClose={onClose} width={520} labelledBy="dlg-item-title">
<form onSubmit={submit}>
<DialogHeader
id="dlg-item-title"
eyebrow={mode === "edit" ? "Edit item" : (mode === "add-child" ? "Add sub-item" : "New item")}
title={mode === "edit" ? "Edit details" : "Create item"}
onClose={onClose}
/>
<div className="dlg-body">
<div className="field">
<label className="field-label">Title</label>
<input
ref={titleRef}
className="text-input"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="What needs to happen?"
required
/>
</div>
<div className="field-row">
<div className="field">
<label className="field-label">Priority</label>
<div className="seg">
{PRIORITIES.map(p => (
<button type="button" key={p}
className={`seg-btn pri-${p} ${priority === p ? "active" : ""}`}
onClick={() => setPriority(p)}>
{p === "P0" && <span className="p0-dot"/>}
{p}
</button>
))}
</div>
</div>
<div className="field">
<label className="field-label">Due</label>
<input className="text-input" type="date" value={due} onChange={(e) => setDue(e.target.value)}/>
</div>
</div>
<div className="field">
<label className="field-label">Status</label>
<div className="status-grid">
{STATUSES.map(s => (
<button type="button" key={s.key}
className={`status-card ${status === s.key ? "active" : ""}`}
onClick={() => setStatus(s.key)}>
<span className={`status-card-icon status-${s.key}`}>
<StatusIcon status={s.key} size={16}/>
</span>
<span className="status-card-label">{s.label}</span>
</button>
))}
</div>
</div>
{status !== "cancelled" && (
<div className="field">
<label className="field-label">
Completion
<span className="field-hint">
{status === "done"
? "locked at 100% while marked done"
: "rough estimate — set 100% to mark done"}
</span>
</label>
<div className="progress-row">
<div className={`seg progress-seg ${status === "done" ? "locked" : ""}`}>
{PROGRESS_STEPS.map(p => (
<button type="button" key={p}
className={`seg-btn ${progress === p ? "active" : ""}`}
onClick={() => setProgress(p)}
disabled={status === "done"}>
{p}%
</button>
))}
</div>
<ProgressGauge value={status === "done" ? 100 : progress} size="md"/>
</div>
</div>
)}
{status === "blocked" && (
<div className="field">
<label className="field-label">Block reason</label>
<input className="text-input" value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Waiting for…"/>
</div>
)}
<div className="field">
<label className="field-label">Tags <span className="field-hint">type a name, press Enter or comma to add</span></label>
<TagAutocompleteInput
tags={tags}
setTags={setTags}
tagInput={tagInput}
setTagInput={setTagInput}
addTag={addTag}
removeTag={removeTag}
allTags={recentTags}
/>
</div>
</div>
<div className="dlg-foot">
<span className="dlg-hint">⌘↩ to save · Esc to cancel</span>
<div className="dlg-foot-actions">
<button type="button" className="btn-secondary" onClick={onClose}>Cancel</button>
<button type="submit" className="btn-primary" disabled={!title.trim()}>
{mode === "edit" ? "Save changes" : "Create item"}
</button>
</div>
</div>
</form>
</Dialog>
);
}
// --- Confirm dialog ---
function ConfirmDialog({ open, title, message, detail, confirmLabel = "Confirm", danger = false, onCancel, onConfirm }) {
return (
<Dialog open={open} onClose={onCancel} width={420} labelledBy="dlg-confirm-title">
<DialogHeader id="dlg-confirm-title" eyebrow="Confirm" title={title} onClose={onCancel}/>
<div className="dlg-body">
<p className="dlg-msg">{message}</p>
{detail && <div className="dlg-detail">{detail}</div>}
</div>
<div className="dlg-foot">
<span/>
<div className="dlg-foot-actions">
<button type="button" className="btn-secondary" onClick={onCancel}>Cancel</button>
<button type="button"
className={danger ? "btn-danger" : "btn-primary"}
onClick={onConfirm} autoFocus>{confirmLabel}</button>
</div>
</div>
</Dialog>
);
}
// --- Tag autocomplete (typed input + filtered dropdown) ---
function TagAutocompleteInput({ tags, setTags, tagInput, setTagInput, addTag, removeTag, allTags }) {
const [focused, setFocused] = useStateD(false);
const [highlighted, setHighlighted] = useStateD(0);
const inputRef = useRefD(null);
const wrapRef = useRefD(null);
const q = tagInput.trim().toLowerCase().replace(/^#/, "");
// suggestions: filter out already-applied; rank exact prefix > contains
const pool = (allTags || []).filter(t => !tags.includes(t));
let suggestions;
if (q) {
const starts = pool.filter(t => t.toLowerCase().startsWith(q));
const contains = pool.filter(t => !t.toLowerCase().startsWith(q) && t.toLowerCase().includes(q));
suggestions = [...starts, ...contains].slice(0, 8);
} else {
suggestions = pool.slice(0, 8); // recents
}
const exactMatch = q && pool.some(t => t.toLowerCase() === q);
const showCreate = q && !exactMatch && !tags.includes(q);
// index 0 = "create new" (if shown), then suggestions
const items = [
...(showCreate ? [{ kind: "create", value: q }] : []),
...suggestions.map(t => ({ kind: "suggestion", value: t }))
];
useEffectD(() => { setHighlighted(0); }, [tagInput, focused]);
const pick = (it) => {
if (!it) return;
addTag(it.value);
setTagInput("");
setHighlighted(0);
};
const onKey = (e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
if (items.length) setHighlighted((h) => (h + 1) % items.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (items.length) setHighlighted((h) => (h - 1 + items.length) % items.length);
} else if (e.key === "Enter") {
e.preventDefault();
if (items[highlighted]) pick(items[highlighted]);
else if (tagInput.trim()) { addTag(tagInput); setTagInput(""); }
} else if (e.key === "," || e.key === "Tab") {
if (tagInput.trim()) {
e.preventDefault();
if (items[highlighted]) pick(items[highlighted]);
else { addTag(tagInput); setTagInput(""); }
}
} else if (e.key === "Backspace" && !tagInput && tags.length) {
removeTag(tags[tags.length - 1]);
} else if (e.key === "Escape") {
if (tagInput) { setTagInput(""); e.preventDefault(); }
else inputRef.current?.blur();
}
};
return (
<div className="tag-ac" ref={wrapRef}>
<div className={`tag-input ${focused ? "focused" : ""}`} onClick={(e) => {
if (e.target === e.currentTarget) inputRef.current?.focus();
}}>
{tags.map(t => (
<span key={t} className="tag-pill">
#{t}
<button type="button" onClick={() => removeTag(t)} aria-label={`Remove ${t}`}>×</button>
</span>
))}
<input
ref={inputRef}
className="tag-input-field"
placeholder={tags.length ? "+ add tag" : "e.g. urgent, frontend, errand"}
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setTimeout(() => setFocused(false), 150)}
onKeyDown={onKey}
autoComplete="off"
aria-autocomplete="list"
aria-expanded={focused && items.length > 0}
/>
</div>
{focused && items.length > 0 && (
<div className="tag-ac-pop" role="listbox">
{!q && (
<div className="tag-ac-cap">Recent</div>
)}
{items.map((it, i) => (
<button
key={`${it.kind}-${it.value}`}
type="button"
role="option"
aria-selected={i === highlighted}
className={`tag-ac-item ${i === highlighted ? "highlighted" : ""} ${it.kind === "create" ? "create" : ""}`}
onMouseEnter={() => setHighlighted(i)}
onMouseDown={(e) => { e.preventDefault(); pick(it); }}
>
{it.kind === "create" ? (
<>
<Icon name="plus" size={11}/>
<span>Create <strong>#{it.value}</strong></span>
</>
) : (
<span>{q ? <TagMatchHL text={it.value} match={q}/> : `#${it.value}`}</span>
)}
</button>
))}
<div className="tag-ac-foot">
<span><kbd>↑</kbd><kbd>↓</kbd> to navigate</span>
<span><kbd>↵</kbd> to add</span>
<span><kbd>esc</kbd> to dismiss</span>
</div>
</div>
)}
</div>
);
}
function TagMatchHL({ text, match }) {
const i = text.toLowerCase().indexOf(match);
if (i < 0) return `#${text}`;
return (
<>
#{text.slice(0, i)}<span className="hl">{text.slice(i, i + match.length)}</span>{text.slice(i + match.length)}
</>
);
}
window.Dialog = Dialog;
window.ItemDialog = ItemDialog;
window.ConfirmDialog = ConfirmDialog;
+196
View File
@@ -0,0 +1,196 @@
// Filter sidebar: status, priority, due, tags + scope.
function FilterPanel({ filters, setFilters, tagsList, counts }) {
const toggle = (key, val) => {
const cur = new Set(filters[key] || []);
cur.has(val) ? cur.delete(val) : cur.add(val);
setFilters({ ...filters, [key]: [...cur] });
};
const isActive = (key, val) => (filters[key] || []).includes(val);
return (
<aside className="filter-panel">
<div className="filter-section">
<div className="filter-label">Status</div>
<div className="filter-list">
{STATUSES.map(s => (
<button
key={s.key}
className={`filter-chip ${isActive("statuses", s.key) ? "active" : ""}`}
onClick={() => toggle("statuses", s.key)}
>
<StatusIcon status={s.key} size={13}/>
<span className="filter-name">{s.label}</span>
<span className="filter-count">{counts[s.key] || 0}</span>
</button>
))}
</div>
</div>
<div className="filter-section">
<div className="filter-label">Priority</div>
<div className="filter-list filter-list-row">
{PRIORITIES.map(p => (
<button
key={p}
className={`filter-chip mini ${isActive("priorities", p) ? "active" : ""} pri-${p}`}
onClick={() => toggle("priorities", p)}
>
{p === "P0" && <span className="p0-dot" />}
{p}
</button>
))}
</div>
</div>
<div className="filter-section">
<div className="filter-label">Due</div>
<div className="filter-list">
{[
{ k: "overdue", n: "Overdue" },
{ k: "today", n: "Today" },
{ k: "week", n: "This week" },
{ k: "month", n: "This month" }
].map(o => (
<button
key={o.k}
className={`filter-chip ${filters.dueRange === o.k ? "active" : ""}`}
onClick={() => setFilters({ ...filters, dueRange: filters.dueRange === o.k ? null : o.k })}
>
<span className="filter-name">{o.n}</span>
</button>
))}
</div>
</div>
<TagFilter
tagsList={tagsList}
selected={filters.tags || []}
onToggle={(t) => toggle("tags", t)}
/>
<div className="filter-section">
<div className="filter-label">Scope</div>
<div className="filter-list">
{[
{ k: "all", n: "All items" },
{ k: "top", n: "Top-level only" }
].map(o => (
<button
key={o.k}
className={`filter-chip ${(filters.scope || "all") === o.k ? "active" : ""}`}
onClick={() => setFilters({ ...filters, scope: o.k })}
>
<span className="filter-name">{o.n}</span>
</button>
))}
</div>
</div>
{(filters.statuses?.length || filters.priorities?.length || filters.tags?.length || filters.dueRange) && (
<button className="clear-btn"
onClick={() => setFilters({ statuses: [], priorities: [], tags: [], dueRange: null, scope: filters.scope, text: filters.text })}>
Clear filters
</button>
)}
</aside>
);
}
// ---- Tag filter with autocomplete (handles hundreds of tags) ----
function TagFilter({ tagsList, selected, onToggle }) {
const [q, setQ] = React.useState("");
const [focused, setFocused] = React.useState(false);
const inputRef = React.useRef(null);
if (!tagsList.length) return null;
const ql = q.trim().toLowerCase();
const filtered = ql
? tagsList.filter(t => t.toLowerCase().includes(ql)).slice(0, 50)
: tagsList.slice(0, 12); // top dozen by frequency when not searching
const exact = ql && tagsList.includes(ql);
const TOTAL = tagsList.length;
return (
<div className="filter-section">
<div className="filter-label">
Tags <span className="tag-total mono">{TOTAL}</span>
</div>
{/* Selected tags chips (always visible if any) */}
{selected.length > 0 && (
<div className="tag-selected">
{selected.map(t => (
<button key={t} className="tag-chip active" onClick={() => onToggle(t)} title="Remove">
#{t}
<span className="tag-x">×</span>
</button>
))}
</div>
)}
{/* Search field */}
<div className={`tag-search ${focused ? "focused" : ""}`}>
<Icon name="search" size={12}/>
<input
ref={inputRef}
className="tag-search-input"
placeholder={`Search ${TOTAL} tags…`}
value={q}
onChange={(e) => setQ(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setTimeout(() => setFocused(false), 120)}
onKeyDown={(e) => {
if (e.key === "Enter" && filtered.length > 0) {
onToggle(filtered[0]);
setQ("");
} else if (e.key === "Escape") {
setQ("");
e.currentTarget.blur();
}
}}
/>
{q && <button className="tag-search-clear" onClick={() => { setQ(""); inputRef.current?.focus(); }}>×</button>}
</div>
{/* Results / suggestions */}
<div className="tag-results">
{filtered.length === 0 ? (
<div className="tag-empty">No tags match "{q}"</div>
) : (
<>
{filtered.map(t => {
const isSelected = selected.includes(t);
return (
<button
key={t}
className={`tag-chip ${isSelected ? "active" : ""}`}
onClick={() => onToggle(t)}
title={isSelected ? "Remove from filter" : "Add to filter"}
>
{ql && !isSelected ? <TagHighlight text={t} match={ql}/> : `#${t}`}
</button>
);
})}
{!ql && tagsList.length > 12 && (
<span className="tag-more mono">+{tagsList.length - 12} more — type to find</span>
)}
</>
)}
</div>
</div>
);
}
function TagHighlight({ text, match }) {
const i = text.toLowerCase().indexOf(match);
if (i < 0) return `#${text}`;
return (
<>
#{text.slice(0, i)}<span className="hl">{text.slice(i, i + match.length)}</span>{text.slice(i + match.length)}
</>
);
}
window.FilterPanel = FilterPanel;
+367
View File
@@ -0,0 +1,367 @@
// Shared helpers, status definitions, filter engine, icons.
const STATUSES = [
{ key: "open", glyph: "[ ]", label: "Open" },
{ key: "in-progress", glyph: "[/]", label: "In progress" },
{ key: "blocked", glyph: "[!]", label: "Blocked" },
{ key: "postponed", glyph: "[>]", label: "Postponed" },
{ key: "done", glyph: "[x]", label: "Done" },
{ key: "cancelled", glyph: "[-]", label: "Cancelled" }
];
const STATUS_BY_KEY = Object.fromEntries(STATUSES.map(s => [s.key, s]));
const STATUS_ORDER = STATUSES.map(s => s.key);
const PRIORITIES = ["P0", "P1", "P2", "P3"];
// ---- date helpers ----
function parseDate(s) { return s ? new Date(s + "T12:00:00Z") : null; }
function daysBetween(a, b) { return Math.round((a - b) / 86400000); }
function isOverdue(item) {
if (!item.due || item.status === "done" || item.status === "cancelled") return false;
return parseDate(item.due) < new Date();
}
function dueRelative(item) {
if (!item.due) return null;
const d = daysBetween(parseDate(item.due), new Date());
if (d < 0) return `${-d}d overdue`;
if (d === 0) return "today";
if (d === 1) return "tomorrow";
if (d < 7) return `in ${d}d`;
if (d < 30) return `in ${Math.round(d/7)}w`;
return item.due;
}
function fmtTimestamp(iso) {
if (!iso) return "—";
const d = new Date(iso);
const diff = (Date.now() - d.getTime()) / 1000;
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.round(diff/60)}m ago`;
if (diff < 86400) return `${Math.round(diff/3600)}h ago`;
if (diff < 86400*7) return `${Math.round(diff/86400)}d ago`;
return d.toISOString().slice(0, 10);
}
function fmtBytes(b) {
if (b < 1024) return `${b} B`;
if (b < 1024*1024) return `${(b/1024).toFixed(1)} KB`;
return `${(b/1024/1024).toFixed(2)} MB`;
}
const fmtRelative = fmtTimestamp;
function fmtShortDate(iso) {
if (!iso) return "—";
const d = new Date(iso);
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
return `${months[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
}
// ---- tree helpers ----
function walkTree(items, fn, depth = 0, parent = null) {
for (const it of items) {
fn(it, depth, parent);
if (it.children?.length) walkTree(it.children, fn, depth + 1, it);
}
}
function findItem(items, id) {
for (const it of items) {
if (it.id === id) return it;
if (it.children?.length) {
const f = findItem(it.children, id);
if (f) return f;
}
}
return null;
}
function allTags(items) {
const counts = new Map();
walkTree(items, it => (it.tags || []).forEach(t => counts.set(t, (counts.get(t) || 0) + 1)));
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([t]) => t);
}
function countAll(items) {
let n = 0; walkTree(items, () => n++); return n;
}
function countByStatus(items) {
const c = Object.fromEntries(STATUSES.map(s => [s.key, 0]));
walkTree(items, it => { c[it.status] = (c[it.status] || 0) + 1; });
return c;
}
// ---- filter engine with parent-visibility rule ----
function filterTree(items, f) {
function matches(it) {
if (f.statuses?.length && !f.statuses.includes(it.status)) return false;
if (f.priorities?.length && !f.priorities.includes(it.priority)) return false;
if (f.tags?.length && !(it.tags || []).some(t => f.tags.includes(t))) return false;
if (f.dueRange) {
const overdue = isOverdue(it);
const d = it.due ? daysBetween(parseDate(it.due), new Date()) : null;
if (f.dueRange === "overdue" && !overdue) return false;
if (f.dueRange === "today" && !(d === 0)) return false;
if (f.dueRange === "week" && !(d !== null && d >= 0 && d <= 7)) return false;
if (f.dueRange === "month" && !(d !== null && d >= 0 && d <= 30)) return false;
}
if (f.text) {
const q = f.text.toLowerCase();
const hay = [it.title, it.reason, ...(it.tags || [])].filter(Boolean).join(" ").toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
}
function recur(list) {
const out = [];
for (const it of list) {
const kids = it.children?.length ? recur(it.children) : [];
const self = matches(it);
if (self || kids.length) {
out.push({ ...it, children: kids, _matches: self });
}
}
return out;
}
return recur(items);
}
// ---- status visuals (4 styles, switchable via Tweaks) ----
// styles: "flat" (minimal monochrome SVG, inherits status color),
// "ascii" (original [/], [x] etc),
// "color" (filled colored icons, ChatGPT-style — green check, red blocked, etc),
// "emoji" (expressive native emoji — ✅ 🚧 ⏸ etc)
// Self-contained color palette for the "color" style — does NOT inherit currentColor.
const STATUS_COLORS = {
"open": { stroke: "oklch(0.62 0.01 70)", fill: "transparent" },
"in-progress": { stroke: "oklch(0.55 0.16 230)", fill: "oklch(0.62 0.16 230)" },
"blocked": { stroke: "oklch(0.50 0.20 28)", fill: "oklch(0.62 0.20 28)" },
"postponed": { stroke: "oklch(0.55 0.13 290)", fill: "oklch(0.65 0.13 290)" },
"done": { stroke: "oklch(0.48 0.16 145)", fill: "oklch(0.60 0.16 145)" },
"cancelled": { stroke: "oklch(0.55 0.01 70)", fill: "oklch(0.65 0.01 70)" },
};
// Expressive emoji pack — chosen for clarity at small sizes (avoid round colored dots).
const STATUS_EMOJI = {
"open": "⬜",
"in-progress": "✏️",
"blocked": "🚧",
"postponed": "⏸️",
"done": "✅",
"cancelled": "❌",
};
// Context so every StatusIcon in the tree picks up the current style automatically.
const StatusStyleContext = React.createContext("flat");
function StatusIcon({ status, style, size = 14 }) {
const ctx = React.useContext(StatusStyleContext);
style = style || ctx || "flat";
if (style === "ascii") {
return <span className={`glyph-ascii status-${status}`}>{STATUS_BY_KEY[status].glyph}</span>;
}
if (style === "color") {
return <ColorStatusIcon status={status} size={size}/>;
}
if (style === "emoji") {
return <span className="glyph-emoji" style={{ fontSize: size + 2, lineHeight: 1 }}>{STATUS_EMOJI[status]}</span>;
}
// flat — minimalistic SVG icons (square family, no circles)
const c = "currentColor";
const wrap = (children) => (
<svg width={size + 2} height={size + 2} viewBox="0 0 16 16" className={`glyph-flat status-${status}`}>
{children}
</svg>
);
switch (status) {
case "open":
// empty square outline
return wrap(<rect x="2.5" y="2.5" width="11" height="11" rx="2" fill="none" stroke={c} strokeWidth="1.4"/>);
case "in-progress":
// half-filled square (left filled)
return wrap(<>
<rect x="2.5" y="2.5" width="11" height="11" rx="2" fill="none" stroke={c} strokeWidth="1.4"/>
<path d="M3.5 3.5 H8 V12.5 H3.5 Z" fill={c}/>
</>);
case "blocked":
// filled square with horizontal bar (no entry)
return wrap(<>
<rect x="2.5" y="2.5" width="11" height="11" rx="2" fill={c}/>
<rect x="4.5" y="7.2" width="7" height="1.6" rx="0.5" fill="white"/>
</>);
case "postponed":
// square with right-pointing chevron (deferred)
return wrap(<>
<rect x="2.5" y="2.5" width="11" height="11" rx="2" fill="none" stroke={c} strokeWidth="1.4"/>
<polyline points="6,5.5 9,8 6,10.5" fill="none" stroke={c} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</>);
case "done":
// filled square with checkmark
return wrap(<>
<rect x="2.5" y="2.5" width="11" height="11" rx="2" fill={c}/>
<polyline points="5,8 7,10.2 11,5.8" fill="none" stroke="white" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/>
</>);
case "cancelled":
// outlined square with single clean diagonal slash
return wrap(<>
<rect x="2.5" y="2.5" width="11" height="11" rx="2" fill="none" stroke={c} strokeWidth="1.4"/>
<line x1="3.5" y1="12.5" x2="12.5" y2="3.5" stroke={c} strokeWidth="1.6" strokeLinecap="round"/>
</>);
default: return null;
}
}
// ---- color status icons (ChatGPT / Linear / GitHub style) ----
// Filled circles with semantic colors and white glyphs. Self-coloring,
// not affected by currentColor — looks good on any background.
function ColorStatusIcon({ status, size = 14 }) {
const c = STATUS_COLORS[status] || STATUS_COLORS.open;
const w = size + 2;
const cx = 8, cy = 8, r = 6.4;
switch (status) {
case "open":
// empty circle — thin gray ring
return (
<svg width={w} height={w} viewBox="0 0 16 16" className={`glyph-color status-${status}`}>
<circle cx={cx} cy={cy} r={r} fill="none" stroke={c.stroke} strokeWidth="1.5"/>
</svg>
);
case "in-progress":
// half-filled pie (clockwise 50%) — Linear-style "in progress"
return (
<svg width={w} height={w} viewBox="0 0 16 16" className={`glyph-color status-${status}`}>
<circle cx={cx} cy={cy} r={r} fill="none" stroke={c.fill} strokeWidth="1.5"/>
<path d={`M 8 1.6 A 6.4 6.4 0 0 1 8 14.4 Z`} fill={c.fill}/>
</svg>
);
case "blocked":
// red filled circle with white horizontal bar
return (
<svg width={w} height={w} viewBox="0 0 16 16" className={`glyph-color status-${status}`}>
<circle cx={cx} cy={cy} r={r} fill={c.fill}/>
<rect x="4.6" y="7.2" width="6.8" height="1.6" rx="0.8" fill="white"/>
</svg>
);
case "postponed":
// purple filled circle with white right-chevron
return (
<svg width={w} height={w} viewBox="0 0 16 16" className={`glyph-color status-${status}`}>
<circle cx={cx} cy={cy} r={r} fill={c.fill}/>
<polyline points="6.4,5.4 9.4,8 6.4,10.6" fill="none" stroke="white" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
case "done":
// green filled circle with white check
return (
<svg width={w} height={w} viewBox="0 0 16 16" className={`glyph-color status-${status}`}>
<circle cx={cx} cy={cy} r={r} fill={c.fill}/>
<polyline points="5,8.2 7.2,10.3 11,5.9" fill="none" stroke="white" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
case "cancelled":
// gray filled circle with white X
return (
<svg width={w} height={w} viewBox="0 0 16 16" className={`glyph-color status-${status}`}>
<circle cx={cx} cy={cy} r={r} fill={c.fill}/>
<g stroke="white" strokeWidth="1.6" strokeLinecap="round">
<line x1="5.6" y1="5.6" x2="10.4" y2="10.4"/>
<line x1="10.4" y1="5.6" x2="5.6" y2="10.4"/>
</g>
</svg>
);
default: return null;
}
}
// ---- icons (inline SVG) ----
const Icon = ({ name, size = 14 }) => {
const paths = {
chevron: <polyline points="5,8 10,13 15,8" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>,
chevronRight: <polyline points="8,5 13,10 8,15" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>,
plus: <g stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><line x1="10" y1="4" x2="10" y2="16"/><line x1="4" y1="10" x2="16" y2="10"/></g>,
search: <g fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><circle cx="9" cy="9" r="5"/><line x1="13" y1="13" x2="17" y2="17"/></g>,
drag: <g fill="currentColor"><circle cx="7" cy="5" r="1.2"/><circle cx="13" cy="5" r="1.2"/><circle cx="7" cy="10" r="1.2"/><circle cx="13" cy="10" r="1.2"/><circle cx="7" cy="15" r="1.2"/><circle cx="13" cy="15" r="1.2"/></g>,
trash: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M5 6h10M8 6V4h4v2M6 6l1 10h6l1-10"/></g>,
cog: <g fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="10" cy="10" r="2.6"/><path d="M10 2v2M10 16v2M2 10h2M16 10h2M4.3 4.3l1.4 1.4M14.3 14.3l1.4 1.4M4.3 15.7l1.4-1.4M14.3 5.7l1.4-1.4"/></g>,
list: <g fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><line x1="6" y1="6" x2="16" y2="6"/><line x1="6" y1="10" x2="16" y2="10"/><line x1="6" y1="14" x2="16" y2="14"/><circle cx="3.5" cy="6" r="0.6" fill="currentColor"/><circle cx="3.5" cy="10" r="0.6" fill="currentColor"/><circle cx="3.5" cy="14" r="0.6" fill="currentColor"/></g>,
check: <polyline points="4,10 8,14 16,5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>,
download: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M10 3v10M5 9l5 5 5-5M3 17h14"/></g>,
upload: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M10 16V4M5 9l5-5 5 5M3 17h14"/></g>,
save: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"><path d="M4 4h9l3 3v9H4z"/><path d="M7 4v4h6V4M7 16v-5h6v5"/></g>,
refresh: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 10a7 7 0 0 1 12-5l2 2"/><path d="M17 4v3h-3"/><path d="M17 10a7 7 0 0 1-12 5l-2-2"/><path d="M3 16v-3h3"/></g>,
warn: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M10 3l8 14H2z"/><line x1="10" y1="8" x2="10" y2="12"/><circle cx="10" cy="14.5" r="0.6" fill="currentColor"/></g>,
eye: <g fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M2 10s3-5 8-5 8 5 8 5-3 5-8 5-8-5-8-5z"/><circle cx="10" cy="10" r="2"/></g>,
archive: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"><rect x="3" y="5" width="14" height="3"/><path d="M4 8v9h12V8"/><line x1="8" y1="11" x2="12" y2="11"/></g>,
edit: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 14v2h2l8-8-2-2-8 8z"/><path d="M12 4l2 2"/></g>,
addChild: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 4v6a3 3 0 0 0 3 3h4"/><line x1="14" y1="13" x2="14" y2="17"/><line x1="12" y1="15" x2="16" y2="15"/></g>,
copy: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="7" y="3" width="10" height="12" rx="1.5"/><path d="M13 17H4a1 1 0 0 1-1-1V6"/></g>,
statusDots: <g fill="currentColor"><circle cx="5" cy="10" r="1.6"/><circle cx="10" cy="10" r="1.6"/><circle cx="15" cy="10" r="1.6"/></g>
};
return (
<svg width={size} height={size} viewBox="0 0 20 20" style={{ display: "inline-block", verticalAlign: "middle", flexShrink: 0 }}>
{paths[name]}
</svg>
);
};
// --- Progress (completion %) ---
// Quantized to 0/25/50/75/100 — coarse on purpose; tasks are rarely "73% done".
const PROGRESS_STEPS = [0, 25, 50, 75, 100];
function snapProgress(p) {
if (p == null || isNaN(p)) return 0;
if (p <= 0) return 0;
if (p >= 100) return 100;
// round to nearest 25
return Math.round(p / 25) * 25;
}
// Effective progress: done is always 100 regardless of stored value;
// cancelled returns null (don't render).
function progressFor(item) {
if (item.status === "done") return 100;
if (item.status === "cancelled") return null;
return snapProgress(item.progress ?? 0);
}
// When progress should be visible inline. Done shows nothing (line-through is enough).
// Open shows only if user explicitly set >0.
function shouldShowProgress(item) {
if (item.status === "cancelled") return false;
if (item.status === "done") return false;
if (item.status === "open") return (item.progress ?? 0) > 0;
return true; // in-progress, blocked, postponed (always visible, including at 100%)
}
// Click to advance through 0/25/50/75/100, wrapping.
function cycleProgress(p) {
const i = PROGRESS_STEPS.indexOf(snapProgress(p));
return PROGRESS_STEPS[(i + 1) % PROGRESS_STEPS.length];
}
// 4-segment gauge (visual representation of 0/25/50/75/100).
// 0 = empty, 100 = all 4 filled.
function ProgressGauge({ value, onClick, interactive = false, size = "sm" }) {
const v = snapProgress(value);
const filled = v / 25; // 0..4
const segs = [0, 1, 2, 3].map(i => i < filled);
return (
<span
className={`gauge ${interactive ? "gauge-interactive" : ""} gauge-${size} gauge-v-${v}`}
onClick={onClick}
role={interactive ? "button" : undefined}
tabIndex={interactive ? 0 : undefined}
title={`${v}% complete${interactive ? " — click to change" : ""}`}
aria-label={`Progress ${v} percent`}
>
{segs.map((on, i) => (
<span key={i} className={`gauge-seg ${on ? "on" : ""}`}/>
))}
<span className="gauge-num">{v}%</span>
</span>
);
}
Object.assign(window, {
STATUSES, STATUS_BY_KEY, STATUS_ORDER, PRIORITIES,
parseDate, daysBetween, isOverdue, dueRelative, fmtTimestamp, fmtBytes, fmtRelative, fmtShortDate,
walkTree, findItem, allTags, countAll, countByStatus, filterTree,
Icon, StatusIcon, ColorStatusIcon, STATUS_COLORS, STATUS_EMOJI, StatusStyleContext,
PROGRESS_STEPS, snapProgress, progressFor, shouldShowProgress, cycleProgress, ProgressGauge
});
+51
View File
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="en" data-density="comfortable" data-show-ids="false" data-paper="true">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Personal Backlog</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<template id="__bundler_thumbnail">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<rect width="200" height="200" fill="#f7f3ec"/>
<g transform="translate(60,60)" stroke="#3a3530" stroke-width="3" fill="none" stroke-linecap="round">
<rect x="0" y="0" width="80" height="80" rx="4" fill="#ffffff"/>
<line x1="14" y1="22" x2="66" y2="22"/>
<line x1="14" y1="40" x2="56" y2="40"/>
<line x1="14" y1="58" x2="60" y2="58"/>
<circle cx="8" cy="22" r="2.5" fill="#3a3530"/>
<circle cx="8" cy="40" r="2.5" fill="#3a3530"/>
<circle cx="8" cy="58" r="2.5" fill="#3a3530"/>
</g>
</svg>
</template>
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<!-- data.jsx: test-only seed data — not loaded by default.
To run with sample data, uncomment the line below:
<script type="text/babel" src="data.jsx"></script> -->
<script type="text/babel" src="helpers.jsx"></script>
<script type="text/babel" src="storage.jsx"></script>
<script type="text/babel" src="tweaks-panel.jsx"></script>
<script type="text/babel" src="filter-panel.jsx"></script>
<script type="text/babel" src="tree.jsx"></script>
<script type="text/babel" src="dialogs.jsx"></script>
<script type="text/babel" src="admin.jsx"></script>
<script type="text/babel" src="app.jsx"></script>
<script type="text/babel">
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<App />);
</script>
</body>
</html>
+620
View File
@@ -0,0 +1,620 @@
{
"name": "personal-backlog-bundler",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "personal-backlog-bundler",
"version": "1.0.0",
"dependencies": {
"@babel/core": "^7.29.0",
"@babel/preset-react": "^7.28.5"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.28.5",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/compat-data": {
"version": "7.29.3",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz",
"integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/core": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-compilation-targets": "^7.28.6",
"@babel/helper-module-transforms": "^7.28.6",
"@babel/helpers": "^7.28.6",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/traverse": "^7.29.0",
"@babel/types": "^7.29.0",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
"json5": "^2.2.3",
"semver": "^6.3.1"
},
"engines": {
"node": ">=6.9.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/babel"
}
},
"node_modules/@babel/generator": {
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-annotate-as-pure": {
"version": "7.27.3",
"resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz",
"integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.27.3"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-compilation-targets": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.28.6",
"@babel/helper-validator-option": "^7.27.1",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-globals": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-imports": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.28.6",
"@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.28.6",
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/traverse": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0"
}
},
"node_modules/@babel/helper-plugin-utils": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
"integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-option": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helpers": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
"license": "MIT",
"dependencies": {
"@babel/template": "^7.28.6",
"@babel/types": "^7.29.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/plugin-syntax-jsx": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz",
"integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==",
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-react-display-name": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz",
"integrity": "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==",
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-react-jsx": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz",
"integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==",
"license": "MIT",
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.27.3",
"@babel/helper-module-imports": "^7.28.6",
"@babel/helper-plugin-utils": "^7.28.6",
"@babel/plugin-syntax-jsx": "^7.28.6",
"@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-react-jsx-development": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz",
"integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==",
"license": "MIT",
"dependencies": {
"@babel/plugin-transform-react-jsx": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-react-pure-annotations": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz",
"integrity": "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==",
"license": "MIT",
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/preset-react": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.28.5.tgz",
"integrity": "sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==",
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-validator-option": "^7.27.1",
"@babel/plugin-transform-react-display-name": "^7.28.0",
"@babel/plugin-transform-react-jsx": "^7.27.1",
"@babel/plugin-transform-react-jsx-development": "^7.27.1",
"@babel/plugin-transform-react-pure-annotations": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/parser": "^7.28.6",
"@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/types": "^7.29.0",
"debug": "^4.3.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.27",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz",
"integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/browserslist": {
"version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
"electron-to-chromium": "^1.5.328",
"node-releases": "^2.0.36",
"update-browserslist-db": "^1.2.3"
},
"bin": {
"browserslist": "cli.js"
},
"engines": {
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001791",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
"integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "CC-BY-4.0"
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.349",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz",
"integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==",
"license": "ISC"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
},
"engines": {
"node": ">=6"
}
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"license": "ISC",
"dependencies": {
"yallist": "^3.0.2"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.38",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
"integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"escalade": "^3.2.0",
"picocolors": "^1.1.1"
},
"bin": {
"update-browserslist-db": "cli.js"
},
"peerDependencies": {
"browserslist": ">= 4.21.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"license": "ISC"
}
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "personal-backlog-bundler",
"version": "1.0.0",
"description": "Flattens design-v2 into a single self-contained HTML artifact",
"private": true,
"dependencies": {
"@babel/core": "^7.29.0",
"@babel/preset-react": "^7.28.5"
}
}
+543
View File
@@ -0,0 +1,543 @@
// Storage layer: Parser, ApiBackend, DirectBackend, Storage detection, SyncPoller.
// Compatible with web/index.html and server/server.py file format.
//
// File format (backlog.md):
// # Backlog
// <!-- SECTION: ENTRIES -->
// - [ ] [P1] Title *(due: ..., reason: ..., tags: tag1 tag2, progress: 50)*
// - [x] [P0] Child *(progress: 100)*
// <!-- SECTION: HISTORY -->
// | Timestamp | Item ID | Action | Details |
// <!-- SECTION: INTEGRITY -->
// <!-- saved: ... | checksum: sha256:... | entries: N | history: N -->
// ---- Parser ----
const Parser = {
async parse(text) {
if (!text || !text.trim()) {
return { entries: [], history: [], meta: null, checksumOk: false };
}
const entStart = text.indexOf('<!-- SECTION: ENTRIES -->');
const histStart = text.indexOf('<!-- SECTION: HISTORY -->');
const intStart = text.indexOf('<!-- SECTION: INTEGRITY -->');
const entriesText = entStart !== -1
? text.slice(entStart, histStart !== -1 ? histStart : intStart !== -1 ? intStart : text.length)
: text;
const historyText = histStart !== -1
? text.slice(histStart, intStart !== -1 ? intStart : text.length)
: '';
// Parse integrity marker
let meta = null;
let checksumOk = false;
if (intStart !== -1) {
const m = text.slice(intStart).match(
/saved:\s*([^|]+?)\s*\|\s*checksum:\s*([^|]+?)\s*\|\s*entries:\s*(\d+)\s*\|\s*history:\s*(\d+)/
);
if (m) {
meta = {
saved: m[1].trim(),
checksum: m[2].trim(),
entryCount: +m[3],
historyCount: +m[4],
};
// Strip section-marker prefix + surrounding whitespace to get raw content,
// matching server.py's compute_checksum() which uses raw (marker-free) payload.
const rawEnt = entriesText.slice('<!-- SECTION: ENTRIES -->'.length).trim();
const rawHist = historyText.slice('<!-- SECTION: HISTORY -->'.length).trim();
const payload = rawEnt + '\n' + rawHist;
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload));
const hash = 'sha256:' + Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
checksumOk = hash === meta.checksum;
}
}
// Parse history table
const history = [];
for (const line of historyText.split('\n')) {
if (!line.trim().startsWith('|')) continue;
const parts = line.split('|').map(s => s.trim()).filter(Boolean);
if (parts.length >= 4 && parts[0] !== 'Timestamp' && !parts[0].match(/^-+$/)) {
history.push({ timestamp: parts[0], itemId: parts[1], action: parts[2], details: parts[3] });
}
}
// Parse entries (indented markdown list)
const GLYPH_MAP = { ' ': 'open', 'x': 'done', '!': 'blocked', '>': 'postponed', '/': 'in-progress', '-': 'cancelled' };
let idSeq = Date.now();
const uid = () => 'i-' + (++idSeq).toString(36);
const entries = [];
const stack = [{ children: entries, depth: -1, level: 0 }];
for (const line of entriesText.split('\n')) {
const m = line.match(/^(\s*)[-*]\s*\[([ x!>/\-])\]\s*(.*)$/);
if (!m) continue;
const depth = m[1].length / 2;
const status = GLYPH_MAP[m[2]] || 'open';
const raw = m[3].trim();
let title = raw, due = null, reason = null, tags = [], priority = 'P1', progress = 0;
const metaM = raw.match(/^(.*?)\s*\*\((.*)\)\*\s*$/);
if (metaM) {
title = metaM[1].trim();
for (const p of metaM[2].split(',').map(s => s.trim())) {
if (p.startsWith('due:')) due = p.slice(4).trim();
else if (p.startsWith('reason:')) reason = p.slice(7).trim();
else if (p.startsWith('tags:')) tags = p.slice(5).trim().split(/\s+/).filter(Boolean);
else if (p.startsWith('priority:')) priority = p.slice(9).trim().toUpperCase();
else if (p.startsWith('progress:')) progress = parseInt(p.slice(9).trim(), 10) || 0;
}
}
// Priority prefix [P0] embedded in title
const pm = title.match(/^\[(P\d)\]\s*/);
if (pm) { priority = pm[1]; title = title.slice(pm[0].length); }
while (stack.length > 1 && stack[stack.length - 1].depth >= depth) stack.pop();
const parentLevel = stack[stack.length - 1].level;
const item = {
id: uid(),
level: parentLevel + 1,
title, status, priority, due, reason, tags,
progress: status === 'done' ? 100 : progress,
collapsed: false,
children: [],
};
stack[stack.length - 1].children.push(item);
stack.push({ children: item.children, depth, level: item.level });
}
return { entries, history, meta, checksumOk };
},
async serialize(data) {
const entriesText = this._serializeEntries(data.entries);
const historyText = this._serializeHistory(data.history);
const payload = entriesText + '\n' + historyText;
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload));
const hash = 'sha256:' + Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
const saved = new Date().toISOString().slice(0, 19) + 'Z';
const nEntries = this._countItems(data.entries);
const nHistory = data.history.length;
const marker = `<!-- saved: ${saved} | checksum: ${hash} | entries: ${nEntries} | history: ${nHistory} -->`;
return `# Backlog\n\n<!-- SECTION: ENTRIES -->\n\n${entriesText}\n\n<!-- SECTION: HISTORY -->\n\n${historyText}\n\n<!-- SECTION: INTEGRITY -->\n\n${marker}\n`;
},
_serializeEntries(items, depth = 0) {
const indent = ' '.repeat(depth);
const GLYPH = { open: ' ', 'in-progress': '/', blocked: '!', postponed: '>', done: 'x', cancelled: '-' };
const lines = [];
for (const it of items) {
const meta = [];
if (it.due) meta.push(`due: ${it.due}`);
if (it.reason) meta.push(`reason: ${it.reason}`);
if (it.tags?.length) meta.push(`tags: ${it.tags.join(' ')}`);
if (it.priority && it.priority !== 'P1') meta.push(`priority: ${it.priority}`);
if ((it.progress ?? 0) > 0) meta.push(`progress: ${it.progress}`);
const metaStr = meta.length ? ` *(${meta.join(', ')})*` : '';
const prefix = it.priority ? `[${it.priority}] ` : '';
lines.push(`${indent}- [${GLYPH[it.status] || ' '}] ${prefix}${it.title}${metaStr}`);
if (it.children?.length) {
lines.push(...this._serializeEntries(it.children, depth + 1).split('\n'));
}
}
return lines.join('\n');
},
_serializeHistory(rows) {
const lines = ['| Timestamp | Item ID | Action | Details |', '|-----------|---------|--------|---------|'];
for (const r of rows) lines.push(`| ${r.timestamp} | ${r.itemId} | ${r.action} | ${r.details} |`);
return lines.join('\n');
},
_countItems(items) {
let n = 0;
for (const it of items) { n++; n += this._countItems(it.children || []); }
return n;
},
};
// ---- API Backend (python server.py) ----
const ApiBackend = {
async detect() {
if (location.protocol === 'file:') return false; // no server in file:// context
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 800);
try {
const r = await fetch('/api/health', { signal: ctrl.signal });
clearTimeout(t);
return r.ok;
} catch { clearTimeout(t); return false; }
},
async load() {
const r = await fetch('/api/backlog');
if (!r.ok) throw new Error('API load failed: ' + r.status);
return r.json(); // { content, checksum }
},
async save(content) {
const r = await fetch('/api/backlog', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
});
if (!r.ok) throw new Error('API save failed: ' + r.status);
return r.json(); // { ok, checksum, saved }
},
async listBackups() {
try {
const r = await fetch('/api/backups');
if (!r.ok) return [];
return (await r.json()).backups || [];
} catch { return []; }
},
async restoreBackup(name) {
const r = await fetch('/api/backups/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
return r.ok ? r.json() : { ok: false };
},
async getHealthInfo() {
try {
const r = await fetch('/api/health');
if (!r.ok) return {};
const j = await r.json();
return {
masterSize: j.masterSize || 0,
statsSize: 0,
masterPath: j.masterPath || '',
backupsPath: j.backupsPath || '',
};
} catch { return {}; }
},
};
// ---- Direct File Backend (File System Access API) ----
const DirectBackend = {
dirHandle: null,
_DB_NAME: 'pb-storage-v2',
_STORE: 'handles',
_KEY: 'root',
async detect() {
return typeof window.showDirectoryPicker === 'function';
},
// Silent reconnect — no user gesture. Returns true if the stored handle
// is still accessible and permission is already granted.
async tryAutoConnect() {
this.dirHandle = await this._loadHandle();
if (!this.dirHandle) return false;
try {
return (await this.dirHandle.queryPermission({ mode: 'readwrite' })) === 'granted';
} catch { return false; }
},
// Explicit connect — requires a user gesture (button click).
// Shows the directory picker on first use; re-prompts for permission on subsequent uses.
async connect() {
let handle = await this._loadHandle();
if (!handle) {
handle = await window.showDirectoryPicker({ mode: 'readwrite' });
await this._saveHandle(handle);
}
const perm = await handle.requestPermission({ mode: 'readwrite' });
if (perm !== 'granted') throw new Error('Permission denied');
this.dirHandle = handle;
},
_loadHandle() {
return new Promise(res => {
const req = indexedDB.open(this._DB_NAME, 1);
req.onupgradeneeded = e => e.target.result.createObjectStore(this._STORE);
req.onsuccess = e => {
const get = e.target.result.transaction(this._STORE, 'readonly').objectStore(this._STORE).get(this._KEY);
get.onsuccess = () => res(get.result || null);
get.onerror = () => res(null);
};
req.onerror = () => res(null);
});
},
_saveHandle(handle) {
return new Promise((res, rej) => {
const req = indexedDB.open(this._DB_NAME, 1);
req.onsuccess = e => {
const tx = e.target.result.transaction(this._STORE, 'readwrite');
tx.objectStore(this._STORE).put(handle, this._KEY);
tx.oncomplete = () => res();
tx.onerror = () => rej(tx.error);
};
});
},
async _readFile(name) {
try {
const h = await this.dirHandle.getFileHandle(name);
return (await h.getFile()).text();
} catch { return null; }
},
async _writeFile(name, content, dirHandle) {
const dir = dirHandle || this.dirHandle;
const h = await dir.getFileHandle(name, { create: true });
const w = await h.createWritable();
await w.write(content);
await w.close();
},
async load() {
const content = await this._readFile('backlog.md');
return { content: content || '', checksum: '' };
},
async save(content) {
await this._writeFile('backlog.md', content);
// Create backup
try {
const ts = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
const dir = await this.dirHandle.getDirectoryHandle('backups', { create: true });
await this._writeFile(`backlog_${ts}.md`, content, dir);
} catch { /* backup failure is non-fatal */ }
return { ok: true };
},
async listBackups() {
try {
const dir = await this.dirHandle.getDirectoryHandle('backups', { create: true });
const backups = [];
for await (const [name, h] of dir.entries()) {
if (!name.startsWith('backlog_') || !name.endsWith('.md')) continue;
const f = await h.getFile();
const text = await f.text();
backups.push({
name,
size: f.size,
timestamp: new Date(f.lastModified).toISOString(),
valid: text.includes('<!-- SECTION: INTEGRITY -->'),
});
}
return backups.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
} catch { return []; }
},
async restoreBackup(name) {
try {
const dir = await this.dirHandle.getDirectoryHandle('backups');
const h = await dir.getFileHandle(name);
const text = await (await h.getFile()).text();
await this._writeFile('backlog.md', text);
return { ok: true };
} catch (e) { return { ok: false, error: e.message }; }
},
async getHealthInfo() {
try {
const f = await (await this.dirHandle.getFileHandle('backlog.md')).getFile();
return { masterSize: f.size, statsSize: 0, dirName: this.dirHandle?.name ?? null };
} catch { return { masterSize: 0, statsSize: 0, dirName: this.dirHandle?.name ?? null }; }
},
};
// ---- Storage — detect backend and delegate ----
const Storage = {
backend: null,
mode: 'local', // 'api' | 'direct' | 'local'
async detect() {
if (await ApiBackend.detect()) {
this.backend = ApiBackend;
this.mode = 'api';
return 'api';
}
if (await DirectBackend.detect()) {
this.backend = DirectBackend;
this.mode = 'direct';
return 'direct';
}
this.mode = 'local';
return 'local';
},
// Try to reconnect silently (no user gesture). Returns true if connected.
// For API mode this is always true; for Direct it checks the stored handle.
async tryAutoConnect() {
if (this.backend === DirectBackend) return DirectBackend.tryAutoConnect();
return true;
},
// Explicit connect — call from a user-gesture handler (button click).
async connect() {
if (this.backend === DirectBackend) await DirectBackend.connect();
},
// True only when a backend is detected AND fully initialised (dirHandle set for Direct).
isConnected() {
if (!this.backend) return false;
if (this.backend === DirectBackend) return !!DirectBackend.dirHandle;
return true;
},
async load() { return this.backend?.load() ?? null; },
async save(content) { return this.backend?.save(content) ?? null; },
async listBackups() { return this.backend?.listBackups() ?? []; },
async restoreBackup(name) { return this.backend?.restoreBackup(name) ?? { ok: false }; },
async getHealthInfo() { return this.backend?.getHealthInfo?.() ?? {}; },
};
// ---- SyncPoller — detect external file changes (5s interval) ----
const SyncPoller = {
_timer: null,
lastChecksum: '',
_onExternalChange: null,
_isDirty: null,
start({ onExternalChange, isDirty }) {
this._onExternalChange = onExternalChange;
this._isDirty = isDirty;
this._timer = setInterval(() => this._tick(), 5000);
},
stop() {
if (this._timer) { clearInterval(this._timer); this._timer = null; }
},
async _tick() {
if (Storage.mode === 'local' || !Storage.backend) return;
try {
const raw = await Storage.load();
if (!raw?.content) return;
const parsed = await Parser.parse(raw.content);
const cs = parsed.meta?.checksum || '';
if (!cs || cs === this.lastChecksum) return;
this.lastChecksum = cs;
if (this._isDirty?.()) {
this._onExternalChange?.('warn', null);
} else {
this._onExternalChange?.('reload', parsed);
}
} catch { /* silent on poll errors */ }
},
};
// ---- buildDataFromStorage — assemble the full App data object from parsed content ----
async function buildDataFromStorage(parsed, backups, storageMode, sizeInfo = {}) {
const { entries, history, meta, checksumOk } = parsed;
// Assign level field (derived from tree depth, not stored in file)
function setLevels(items, level) {
for (const it of items) {
it.level = level;
if (it.children?.length) setLevels(it.children, level + 1);
}
}
setLevels(entries, 1);
// Migrate: ensure progress field is always a number
const progressDefaults = { done: 100, cancelled: 0, 'in-progress': 50, blocked: 25, postponed: 25, open: 0 };
walkTree(entries, it => {
if (typeof it.progress !== 'number') it.progress = progressDefaults[it.status] ?? 0;
else if (it.status === 'done') it.progress = 100;
});
// Status counts
const statusMix = { open: 0, 'in-progress': 0, blocked: 0, postponed: 0, done: 0, cancelled: 0 };
walkTree(entries, it => { statusMix[it.status] = (statusMix[it.status] || 0) + 1; });
// Most active item by history event count
const actCounts = {};
walkTree(entries, it => { actCounts[it.id] = 0; });
for (const h of history) { if (h.itemId in actCounts) actCounts[h.itemId]++; }
const topId = Object.entries(actCounts).sort((a, b) => b[1] - a[1])[0]?.[0];
const topItem = topId ? findItem(entries, topId) : null;
const mostActiveProject = topItem?.title || entries[0]?.title || '—';
// Recent activity (last 7 days)
const sevenAgo = new Date(Date.now() - 7 * 86400000).toISOString();
const recentH = history.filter(h => h.timestamp >= sevenAgo);
const createdThisWeek = recentH.filter(h => h.action === 'item_created').length;
const completedThisWeek = recentH.filter(h => h.action === 'status_changed' && h.details.includes('→ done')).length;
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const completionByDay = dayNames.map(day => ({ day, count: 0 }));
const createdByDay = dayNames.map(day => ({ day, count: 0 }));
for (const h of recentH) {
const dow = (new Date(h.timestamp).getDay() + 6) % 7; // 0=Mon
if (h.action === 'status_changed' && h.details.includes('→ done')) completionByDay[dow].count++;
if (h.action === 'item_created') createdByDay[dow].count++;
}
// Average days spent in-progress: pair "→ in-progress" with next "in-progress →" per item.
const ipStart = {}; // itemId → start timestamp ms
const ipDurations = [];
for (const h of [...history].reverse()) { // chronological order
if (h.action !== 'status_changed') continue;
if (h.details.includes('→ in-progress')) ipStart[h.itemId] = new Date(h.timestamp).getTime();
else if (h.details.startsWith('in-progress →') && ipStart[h.itemId]) {
const days = (new Date(h.timestamp).getTime() - ipStart[h.itemId]) / 86400000;
if (days >= 0 && days < 365) ipDurations.push(days);
delete ipStart[h.itemId];
}
}
const avgInProgressDays = ipDurations.length > 0
? +(ipDurations.reduce((a, b) => a + b, 0) / ipDurations.length).toFixed(1)
: null;
const historyOldest = history.length > 0 ? history[history.length - 1].timestamp : null;
const backupDirSize = backups.reduce((s, b) => s + (b.size || 0), 0);
const historySize = new TextEncoder().encode(JSON.stringify(history)).length;
const modeLabel = storageMode === 'api' ? 'API server'
: storageMode === 'direct' ? 'Direct (File System API)'
: 'localStorage only';
return {
entries,
history,
meta: meta || { saved: null, checksum: '—', entryCount: countAll(entries), historyCount: history.length },
health: {
integrityOk: checksumOk,
lastSave: meta?.saved || null,
lastBackup: backups[0]?.timestamp || null,
masterSize: sizeInfo.masterSize || 0,
backupDirSize,
backupCount: backups.length,
statsSize: sizeInfo.statsSize || 0,
historySize,
historyOldest,
mode: modeLabel,
masterPath: sizeInfo.masterPath || (sizeInfo.dirName ? `${sizeInfo.dirName}/backlog.md` : null),
backupsPath: sizeInfo.backupsPath || (sizeInfo.dirName ? `${sizeInfo.dirName}/backups/` : null),
},
stats: {
createdThisWeek,
completedThisWeek,
avgInProgressDays,
mostActiveProject,
completionByDay,
createdByDay,
statusMix,
},
backups,
};
}
Object.assign(window, {
Parser,
ApiBackend,
DirectBackend,
Storage,
SyncPoller,
buildDataFromStorage,
});
+1063
View File
File diff suppressed because it is too large Load Diff
+238
View File
@@ -0,0 +1,238 @@
// Backlog tree view: rows, expand/collapse, status cycling, drag handle, add/delete.
const { useState, useRef, useEffect, useMemo } = React;
// Inline priority popover
function PriorityPicker({ value, onChange, onClose }) {
const ref = useRef(null);
useEffect(() => {
const close = (e) => { if (!ref.current?.contains(e.target)) onClose(); };
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [onClose]);
return (
<div className="pri-pop" ref={ref} onClick={(e) => e.stopPropagation()}>
{PRIORITIES.map(p => (
<button key={p} className={`pri-pop-item pri-${p} ${value === p ? "active" : ""}`}
onClick={() => { onChange(p); onClose(); }}>
{p === "P0" && <span className="p0-dot"/>}
<span>{p}</span>
<span className="pri-pop-hint">{p === "P0" ? "Burning" : p === "P1" ? "High" : p === "P2" ? "Normal" : "Low"}</span>
</button>
))}
</div>
);
}
// Inline status popover (replaces ●●● menu)
function StatusPopover({ value, style, onChange, onClose, anchorRect }) {
const ref = useRef(null);
useEffect(() => {
const close = (e) => { if (!ref.current?.contains(e.target)) onClose(); };
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [onClose]);
return (
<div className="status-pop" ref={ref} onClick={(e) => e.stopPropagation()}>
{STATUSES.map(s => (
<button key={s.key} className={`status-pop-item ${value === s.key ? "active" : ""}`}
onClick={() => { onChange(s.key); onClose(); }}>
<StatusIcon status={s.key} style={style} size={14}/>
<span>{s.label}</span>
</button>
))}
</div>
);
}
// Single tree row
function ItemRow({ item, depth, expanded, hasChildren, statusStyle,
onToggle, onSetStatus, onAddChild, onDelete, onEdit, onSetPriority,
onMoveUp, onMoveDown, canMoveUp, canMoveDown,
onDragStart, onDragOver, onDrop,
dimmed, query, dropTarget }) {
const [statusOpen, setStatusOpen] = useState(false);
const [priOpen, setPriOpen] = useState(false);
const overdue = isOverdue(item);
const isP0 = item.priority === "P0" && item.status !== "done" && item.status !== "cancelled";
const isDone = item.status === "done" || item.status === "cancelled";
const renderTitle = () => {
if (!query) return item.title;
const i = item.title.toLowerCase().indexOf(query.toLowerCase());
if (i < 0) return item.title;
return (<>
{item.title.slice(0, i)}
<mark className="hl">{item.title.slice(i, i + query.length)}</mark>
{item.title.slice(i + query.length)}
</>);
};
return (
<div
className={`row level-${depth} ${dimmed ? "dimmed" : ""} ${isP0 ? "p0" : ""} ${dropTarget ? "drop-target" : ""}`}
draggable
onDragStart={(e) => onDragStart?.(e, item)}
onDragOver={(e) => onDragOver?.(e, item)}
onDrop={(e) => onDrop?.(e, item)}
>
<span className="row-rail" style={{ width: depth * 18 }}/>
<button
className={`twisty ${hasChildren ? "" : "empty"}`}
onClick={hasChildren ? onToggle : undefined}
aria-label={expanded ? "Collapse" : "Expand"}
tabIndex={hasChildren ? 0 : -1}
>
{hasChildren && <Icon name={expanded ? "chevron" : "chevronRight"} size={12}/>}
</button>
<span className="drag-handle" title="Drag to reorder within priority"><Icon name="drag" size={14}/></span>
<span className="status-wrap">
<button
className="status-btn"
onClick={() => setStatusOpen(v => !v)}
title={`${STATUS_BY_KEY[item.status].label} — click to change`}
>
<StatusIcon status={item.status} style={statusStyle} size={14}/>
</button>
{statusOpen && (
<StatusPopover value={item.status} style={statusStyle}
onChange={onSetStatus} onClose={() => setStatusOpen(false)}/>
)}
</span>
<span className="pri-wrap">
<button className={`pri pri-${item.priority}`} onClick={() => setPriOpen(v => !v)} title="Change priority">
{item.priority === "P0" && <span className="p0-dot"/>}
{item.priority}
</button>
{priOpen && (
<PriorityPicker value={item.priority}
onChange={(p) => onSetPriority(p)}
onClose={() => setPriOpen(false)}/>
)}
</span>
<span className={`title ${isDone ? "done" : ""}`} onDoubleClick={onEdit}>
{renderTitle()}
</span>
{shouldShowProgress(item) && (
<ProgressGauge value={progressFor(item)} />
)}
{item.due && (
<span className={`due ${overdue ? "overdue" : ""}`}>{dueRelative(item)}</span>
)}
{item.tags?.length > 0 && (
<span className="tags">
{item.tags.map(t => <span key={t} className="tag">#{t}</span>)}
</span>
)}
{item.status === "blocked" && item.reason && (
<span className="reason" title={item.reason}>↳ {item.reason}</span>
)}
<span className="row-spacer"/>
<span className="row-actions">
<button className="icon-btn" onClick={onMoveUp} disabled={!canMoveUp} data-tip="Move up" aria-label="Move up">
<svg width="14" height="14" viewBox="0 0 16 16"><polyline points="4,10 8,5 12,10" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
<button className="icon-btn" onClick={onMoveDown} disabled={!canMoveDown} data-tip="Move down" aria-label="Move down">
<svg width="14" height="14" viewBox="0 0 16 16"><polyline points="4,6 8,11 12,6" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
<span className="action-sep"/>
{depth < 3 && (
<button className="icon-btn" onClick={onAddChild} data-tip="Add sub-item" aria-label="Add sub-item">
<Icon name="addChild" size={14}/>
</button>
)}
<button className="icon-btn" onClick={onEdit} data-tip="Edit item" aria-label="Edit item">
<Icon name="edit" size={13}/>
</button>
<button className="icon-btn danger" onClick={onDelete} data-tip="Delete" aria-label="Delete">
<Icon name="trash" size={13}/>
</button>
</span>
</div>
);
}
// Expanded-state map kept in parent so filtering doesn't lose it.
function BacklogTree({ items, expandedMap, setExpanded, onMutate, query, statusStyle, manualOrder }) {
const [draggedId, setDraggedId] = useState(null);
const [dropTargetId, setDropTargetId] = useState(null);
const onDragStart = (e, item) => {
setDraggedId(item.id);
e.dataTransfer.effectAllowed = "move";
try { e.dataTransfer.setData("text/plain", item.id); } catch (_) {}
};
const onDragOver = (e, item) => {
if (!draggedId || draggedId === item.id) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDropTargetId(item.id);
};
const onDrop = (e, item) => {
e.preventDefault();
if (draggedId && draggedId !== item.id) {
onMutate.reorder(draggedId, item.id);
}
setDraggedId(null); setDropTargetId(null);
};
function renderList(list, depth, parentList) {
return list.map((item, idx) => {
const expanded = expandedMap[item.id] ?? !item.collapsed;
const hasChildren = item.children?.length > 0;
const forceOpen = query && item._matches !== true && hasChildren;
const showKids = (expanded || forceOpen) && hasChildren;
// can-move logic: only swap with adjacent same-priority sibling
const samePri = parentList
.map((x, i) => ({ x, i }))
.filter(({ x }) => x.priority === item.priority);
const myIdx = samePri.findIndex(({ x }) => x.id === item.id);
const canMoveUp = myIdx > 0;
const canMoveDown = myIdx >= 0 && myIdx < samePri.length - 1;
return (
<React.Fragment key={item.id}>
<ItemRow
item={item} depth={depth}
expanded={expanded || forceOpen}
hasChildren={hasChildren}
statusStyle={statusStyle}
dimmed={query && item._matches === false}
dropTarget={dropTargetId === item.id && draggedId !== item.id}
query={query}
onToggle={() => setExpanded(item.id, !expanded)}
onSetStatus={(s) => onMutate.setStatus(item.id, s)}
onSetPriority={(p) => onMutate.setPriority(item.id, p)}
onAddChild={() => onMutate.addChild(item.id)}
onDelete={() => onMutate.deleteItem(item.id)}
onEdit={() => onMutate.editItem(item.id)}
onMoveUp={() => onMutate.moveWithinPriority(item.id, -1)}
onMoveDown={() => onMutate.moveWithinPriority(item.id, +1)}
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
/>
{showKids && renderList(item.children, depth + 1, item.children)}
</React.Fragment>
);
});
}
return <div className="tree">{renderList(items, 0, items)}</div>;
}
window.BacklogTree = BacklogTree;
+425
View File
@@ -0,0 +1,425 @@
// tweaks-panel.jsx
// Reusable Tweaks shell + form-control helpers.
//
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
//
// Usage (in an HTML file that loads React + Babel):
//
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
// "primaryColor": "#D97757",
// "fontSize": 16,
// "density": "regular",
// "dark": false
// }/*EDITMODE-END*/;
//
// function App() {
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
// return (
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
// Hello
// <TweaksPanel>
// <TweakSection label="Typography" />
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
// onChange={(v) => setTweak('fontSize', v)} />
// <TweakRadio label="Density" value={t.density}
// options={['compact', 'regular', 'comfy']}
// onChange={(v) => setTweak('density', v)} />
// <TweakSection label="Theme" />
// <TweakColor label="Primary" value={t.primaryColor}
// onChange={(v) => setTweak('primaryColor', v)} />
// <TweakToggle label="Dark mode" value={t.dark}
// onChange={(v) => setTweak('dark', v)} />
// </TweaksPanel>
// </div>
// );
// }
//
// ─────────────────────────────────────────────────────────────────────────────
const __TWEAKS_STYLE = `
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
background:rgba(250,249,247,.78);color:#29261b;
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
.twk-hd{display:flex;align-items:center;justify-content:space-between;
padding:10px 8px 10px 14px;cursor:move;user-select:none}
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
overflow-y:auto;overflow-x:hidden;min-height:0;
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
.twk-body::-webkit-scrollbar{width:8px}
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
border:2px solid transparent;background-clip:content-box}
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
border:2px solid transparent;background-clip:content-box}
.twk-row{display:flex;flex-direction:column;gap:5px}
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
color:rgba(41,38,27,.72)}
.twk-lbl>span:first-child{font-weight:500}
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
color:rgba(41,38,27,.45);padding:10px 0 0}
.twk-sect:first-child{padding-top:0}
.twk-field{appearance:none;width:100%;height:26px;padding:0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
select.twk-field{padding-right:22px;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
background-repeat:no-repeat;background-position:right 8px center}
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
width:14px;height:14px;border-radius:50%;background:#fff;
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
background:rgba(0,0,0,.06);user-select:none}
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
.twk-seg.dragging .twk-seg-thumb{transition:none}
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
overflow-wrap:anywhere}
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
.twk-toggle[data-on="1"]{background:#34c759}
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
.twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
user-select:none;padding-right:8px}
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
outline:none;color:inherit;-moz-appearance:textfield}
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
-webkit-appearance:none;margin:0}
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
.twk-btn:hover{background:rgba(0,0,0,.88)}
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
background:transparent;flex-shrink:0}
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
`;
// ── useTweaks ───────────────────────────────────────────────────────────────
// Single source of truth for tweak values. setTweak persists via the host
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
function useTweaks(defaults) {
const [values, setValues] = React.useState(defaults);
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
// useState-style call doesn't write a "[object Object]" key into the persisted
// JSON block.
const setTweak = React.useCallback((keyOrEdits, val) => {
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
? keyOrEdits : { [keyOrEdits]: val };
setValues((prev) => ({ ...prev, ...edits }));
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
}, []);
return [values, setTweak];
}
// ── TweaksPanel ─────────────────────────────────────────────────────────────
// Floating shell. Registers the protocol listener BEFORE announcing
// availability — if the announce ran first, the host's activate could land
// before our handler exists and the toolbar toggle would silently no-op.
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
// is what actually hides the panel.
function TweaksPanel({ title = 'Tweaks', children }) {
const [open, setOpen] = React.useState(false);
const dragRef = React.useRef(null);
const offsetRef = React.useRef({ x: 16, y: 16 });
const PAD = 16;
const clampToViewport = React.useCallback(() => {
const panel = dragRef.current;
if (!panel) return;
const w = panel.offsetWidth, h = panel.offsetHeight;
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
offsetRef.current = {
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
};
panel.style.right = offsetRef.current.x + 'px';
panel.style.bottom = offsetRef.current.y + 'px';
}, []);
React.useEffect(() => {
if (!open) return;
clampToViewport();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', clampToViewport);
return () => window.removeEventListener('resize', clampToViewport);
}
const ro = new ResizeObserver(clampToViewport);
ro.observe(document.documentElement);
return () => ro.disconnect();
}, [open, clampToViewport]);
React.useEffect(() => {
const onMsg = (e) => {
const t = e?.data?.type;
if (t === '__activate_edit_mode') setOpen(true);
else if (t === '__deactivate_edit_mode') setOpen(false);
};
window.addEventListener('message', onMsg);
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
return () => window.removeEventListener('message', onMsg);
}, []);
const dismiss = () => {
setOpen(false);
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
};
const onDragStart = (e) => {
const panel = dragRef.current;
if (!panel) return;
const r = panel.getBoundingClientRect();
const sx = e.clientX, sy = e.clientY;
const startRight = window.innerWidth - r.right;
const startBottom = window.innerHeight - r.bottom;
const move = (ev) => {
offsetRef.current = {
x: startRight - (ev.clientX - sx),
y: startBottom - (ev.clientY - sy),
};
clampToViewport();
};
const up = () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
};
if (!open) return null;
return (
<>
<style>{__TWEAKS_STYLE}</style>
<div ref={dragRef} className="twk-panel" data-noncommentable=""
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
<div className="twk-hd" onMouseDown={onDragStart}>
<b>{title}</b>
<button className="twk-x" aria-label="Close tweaks"
onMouseDown={(e) => e.stopPropagation()}
onClick={dismiss}>✕</button>
</div>
<div className="twk-body">{children}</div>
</div>
</>
);
}
// ── Layout helpers ──────────────────────────────────────────────────────────
function TweakSection({ label, children }) {
return (
<>
<div className="twk-sect">{label}</div>
{children}
</>
);
}
function TweakRow({ label, value, children, inline = false }) {
return (
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
<div className="twk-lbl">
<span>{label}</span>
{value != null && <span className="twk-val">{value}</span>}
</div>
{children}
</div>
);
}
// ── Controls ────────────────────────────────────────────────────────────────
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
return (
<TweakRow label={label} value={`${value}${unit}`}>
<input type="range" className="twk-slider" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(Number(e.target.value))} />
</TweakRow>
);
}
function TweakToggle({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
role="switch" aria-checked={!!value}
onClick={() => onChange(!value)}><i /></button>
</div>
);
}
function TweakRadio({ label, value, options, onChange }) {
const trackRef = React.useRef(null);
const [dragging, setDragging] = React.useState(false);
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
const n = opts.length;
// The active value is read by pointer-move handlers attached for the lifetime
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
const valueRef = React.useRef(value);
valueRef.current = value;
const segAt = (clientX) => {
const r = trackRef.current.getBoundingClientRect();
const inner = r.width - 4;
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
return opts[Math.max(0, Math.min(n - 1, i))].value;
};
const onPointerDown = (e) => {
setDragging(true);
const v0 = segAt(e.clientX);
if (v0 !== valueRef.current) onChange(v0);
const move = (ev) => {
if (!trackRef.current) return;
const v = segAt(ev.clientX);
if (v !== valueRef.current) onChange(v);
};
const up = () => {
setDragging(false);
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<TweakRow label={label}>
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
<div className="twk-seg-thumb"
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
width: `calc((100% - 4px) / ${n})` }} />
{opts.map((o) => (
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
{o.label}
</button>
))}
</div>
</TweakRow>
);
}
function TweakSelect({ label, value, options, onChange }) {
return (
<TweakRow label={label}>
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => {
const v = typeof o === 'object' ? o.value : o;
const l = typeof o === 'object' ? o.label : o;
return <option key={v} value={v}>{l}</option>;
})}
</select>
</TweakRow>
);
}
function TweakText({ label, value, placeholder, onChange }) {
return (
<TweakRow label={label}>
<input className="twk-field" type="text" value={value} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
</TweakRow>
);
}
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
const clamp = (n) => {
if (min != null && n < min) return min;
if (max != null && n > max) return max;
return n;
};
const startRef = React.useRef({ x: 0, val: 0 });
const onScrubStart = (e) => {
e.preventDefault();
startRef.current = { x: e.clientX, val: value };
const decimals = (String(step).split('.')[1] || '').length;
const move = (ev) => {
const dx = ev.clientX - startRef.current.x;
const raw = startRef.current.val + dx * step;
const snapped = Math.round(raw / step) * step;
onChange(clamp(Number(snapped.toFixed(decimals))));
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<div className="twk-num">
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
<input type="number" value={value} min={min} max={max} step={step}
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
{unit && <span className="twk-num-unit">{unit}</span>}
</div>
);
}
function TweakColor({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<input type="color" className="twk-swatch" value={value}
onChange={(e) => onChange(e.target.value)} />
</div>
);
}
function TweakButton({ label, onClick, secondary = false }) {
return (
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
onClick={onClick}>{label}</button>
);
}
Object.assign(window, {
useTweaks, TweaksPanel, TweakSection, TweakRow,
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
TweakText, TweakNumber, TweakColor, TweakButton,
});
File diff suppressed because it is too large Load Diff