diff --git a/.gitignore b/.gitignore index 25fbf5a..0d0bbcd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,8 @@ node_modules/ coverage/ +/data/ +__pycache__/ +*.pyc +.venv/ +venv/ +_site/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 60b8558..6f0d51f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## SQLite persistence (2026-06-15) + +### Added +- `server.py` - Flask app on port 8090 serving `index.html` and `/api/board` +- `db.py` - SQLite storage in `data/taskboard.db` +- `seed_data.py` - default team/tasks seeded on first run +- `requirements.txt` - Flask dependency +- `src/lib/board-sync.js` - background load/save (500ms debounce after edits) + +### Changed +- `src/app/main.js` - restored main-branch UI boot (sync `renderAll()`), persistence loads in background +- `src/lib/board-sync.js` - thin save/load layer; only replaces data when server has a real board +- Removed `board-store.js` / `sample-tasks.js` split that broke the initial render + +### Reasoning +- Single JSON blob in SQLite keeps v1 simple and matches the in-memory tree model +- Debounced PUT after `snap()` covers all task mutations without touching every handler +- Python fits the existing port-8090 hosting setup + +### Fixed +- `seed_data.py` nested `make()` was re-processing already-built child nodes, stripping due/size/children +- `board-sync.js` now rejects corrupt server boards so inline sample data is kept + ## Testing infrastructure (2026-06-15) ### Added diff --git a/README.md b/README.md index 1cd5d75..83cf226 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,28 @@ # Task Management Tool -TaskBoard prototype: a single-page task planner with gantt timeline, balance-scale dashboard, voice capture, and transcript extraction. +TaskBoard prototype: a single-page task planner with gantt timeline, balance-scale dashboard, voice capture, and transcript extraction. SQLite-backed persistence when served via `server.py`. AI-perc:47% -## Run locally +## Run locally (with persistence) -Open `index.html` in a browser, or serve the repo root: +```bash +pip install -r requirements.txt +python server.py +``` + +Open http://localhost:8090 + +Data is stored in `data/taskboard.db` (created on first run). + +## Run locally (static only, no persistence) ```bash npx --yes serve . ``` +Edits will not survive refresh without the Python server. + ## Development Install dependencies and run tests: @@ -27,6 +38,14 @@ Watch mode: npm run test:watch ``` +## API (Python server) + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/board` | Load people, tasks, and config | +| PUT | `/api/board` | Save full board state | +| GET | `/api/health` | Health check | + ## Project layout | Path | Purpose | @@ -34,9 +53,20 @@ npm run test:watch | `index.html` | UI markup and styles | | `src/app/main.js` | Application logic (DOM, rendering, interactions) | | `src/data/constants.js` | Team roster, clients, sizes, colors | -| `src/lib/` | Testable pure functions (domain, tree, dates, capture) | +| `src/lib/board-sync.js` | Server load/save (non-blocking) | +| `server.py` | Flask app + SQLite API | | `tests/` | Vitest unit tests | +## Deploy on your server + +1. Pull/copy this repo to the server +2. `pip install -r requirements.txt` +3. Run `python server.py --host 0.0.0.0 --port 8090` +4. Ensure `data/` is writable and backed up +5. Use a process manager (systemd) so the server survives reboots + +Optional: set `OPENAI_API_KEY` in the environment when you add `/extract` later. + ## Before opening a PR 1. Run `npm run ci` and confirm all checks pass. @@ -67,10 +97,12 @@ After checks pass, a bot comment on the PR includes a link like: `https://summon-rnd.github.io/task-manager/` +Note: GitHub Pages serves static files only. Persistence requires the Python server on your remote host. + ### Local preview ```bash gh pr checkout npm install -npx --yes serve . +python server.py ``` diff --git a/db.py b/db.py new file mode 100644 index 0000000..bfeec38 --- /dev/null +++ b/db.py @@ -0,0 +1,75 @@ +import json +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path + +from seed_data import default_board + +DB_PATH = Path(__file__).resolve().parent / "data" / "taskboard.db" + + +def connect(): + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def init_db(): + with connect() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS board_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + data TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + row = conn.execute("SELECT data FROM board_state WHERE id = 1").fetchone() + if row is None: + save_board(conn, default_board()) + else: + board = json.loads(row["data"]) + if not board.get("tasks") or not board.get("people"): + save_board(conn, default_board()) + + +def get_board(conn=None): + own = conn is None + if own: + conn = connect() + try: + row = conn.execute("SELECT data, updated_at FROM board_state WHERE id = 1").fetchone() + if row is None: + board = default_board() + save_board(conn, board) + return board, datetime.now(timezone.utc).isoformat() + board = json.loads(row["data"]) + if not board.get("tasks") or not board.get("people"): + board = default_board() + save_board(conn, board) + return board, row["updated_at"] + finally: + if own: + conn.close() + + +def save_board(conn, board): + now = datetime.now(timezone.utc).isoformat() + payload = json.dumps(board, ensure_ascii=False) + conn.execute( + """ + INSERT INTO board_state (id, data, updated_at) + VALUES (1, ?, ?) + ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at + """, + (payload, now), + ) + conn.commit() + return now + + +def put_board(board): + with connect() as conn: + return save_board(conn, board) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bbedbcb --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +flask>=3.0,<4 diff --git a/seed_data.py b/seed_data.py new file mode 100644 index 0000000..dcbdcaf --- /dev/null +++ b/seed_data.py @@ -0,0 +1,303 @@ +"""Default board state used when the database is empty.""" + +import random + + +PEOPLE = { + "jn": {"name": "Jean", "initials": "JN", "color": "#27a468", "role": "Finances", "al": ["jean"]}, + "fd": { + "name": "Florian", + "initials": "FD", + "color": "#3b6ef6", + "role": "Customer outreach, raising money, and recruitment", + "al": ["florian", "flo", "fluorine", "florine", "florent", "floriane"], + }, + "ia": { + "name": "Iannis", + "initials": "IA", + "color": "#e8930c", + "role": "Building the robot and operating system", + "al": ["iannis", "yannis", "yanis", "ianis", "ioannis", "janice", "janis", "yanni", "ennis"], + }, + "ak": { + "name": "Akshat", + "initials": "AK", + "color": "#9b59d0", + "role": "Obstacle avoidance and autonomous locomotion", + "al": ["akshat", "akshad", "akshot", "axat", "akshut"], + }, + "sk": { + "name": "Sanket", + "initials": "SK", + "color": "#d4488e", + "role": "Control and embedded systems", + "al": ["sanket", "sankeet", "sankit", "sunket", "sanke"], + }, + "lm": { + "name": "Liam", + "initials": "LM", + "color": "#0ea5b7", + "role": "General / operations", + "al": ["liam", "leam"], + }, + "ly": { + "name": "Leynaïck", + "initials": "LY", + "color": "#647acb", + "role": "Electronics (intern)", + "al": ["leynaïck", "leynaick", "lenaick", "laynaick", "leinaick", "lenix", "laynick"], + }, +} + +CLIENTS = [ + {"name": "Onet", "al": ["onet", "o net", "onnet", "aunet"]}, + {"name": "Derichebourg", "al": ["derichebourg", "de riche bourg", "derichbourg", "derich bourg", "deurichebourg"]}, + {"name": "NSI", "al": ["nsi", "n s i", "ensi", "n.s.i"]}, + {"name": "Areas", "al": ["areas", "aréas", "arias", "ariane", "arrears"]}, + {"name": "JCDecaux", "al": ["jcdecaux", "jc decaux", "jcd", "jic decaux", "jaycee decaux", "jay c decaux"]}, +] + +HARDWARE_VOCAB = [ + "Robstride motors: RS00, RS02, RS03, RS04, EL05", + "Feetech motors (all models)", + "Hub motors (used for the wheels - primary locomotion)", + "D-Wave board (custom hardware board in the current robots)", +] + +DOMAIN_RULES = [ + {"o": "ak", "kw": ["obstacle", "avoidance", "autonom", "navigation", "locomot", "path planning", "slam", "perception", "mapping"]}, + {"o": "sk", "kw": ["control", "embedded", "firmware", "motor control", "pid", "actuator", "can bus", "servo", "rs0", "rs00", "rs02", "rs03", "rs04", "el05", "feetech", "hub motor", "motor"]}, + {"o": "ia", "kw": ["operating system", " os ", "assembly", "chassis", "mechanical", "integration", "build the robot", "robot build", "frame"]}, + {"o": "ly", "kw": ["electronic", "d-wave", "dwave", "board", "power", "wiring", "pcb", "circuit", "battery", "soldering", "harness"]}, + {"o": "jn", "kw": ["budget", "invoice", "finance", "cost", "payment", "accounting", "payroll"]}, + {"o": "fd", "kw": ["client", "outreach", "fundrais", "recruit", "hiring", "pilot", "sales", "demo", "investor", "contract"]}, +] + + +def _t(uid, title, owner, **opts): + node = { + "id": uid, + "title": title, + "owner": owner, + "priority": opts.get("p", "med"), + "due": opts.get("d"), + "start": opts.get("st"), + "size": opts.get("s"), + "done": opts.get("done", False), + "doneAt": None, + "children": opts.get("c", []), + "open": opts.get("open", False), + } + if opts.get("done"): + node["doneAt"] = opts.get("done_at", "2026-06-12") + return node + + +def _build_tasks(): + uid = 0 + + def t(title, owner, **opts): + nonlocal uid + uid += 1 + children = opts.pop("c", []) + node = _t(uid, title, owner, **opts) + node["children"] = [t(**child) if isinstance(child, dict) and "title" in child else child for child in children] + if children and all(isinstance(c, dict) for c in children): + built = [] + for child in children: + uid += 1 + sub_children = child.pop("c", []) + sub = _t(uid, child["title"], child["owner"], **{k: v for k, v in child.items() if k not in ("title", "owner")}) + sub["children"] = [] + for gc in sub_children: + uid += 1 + sub["children"].append(_t(uid, gc["title"], gc["owner"], **{k: v for k, v in gc.items() if k not in ("title", "owner")})) + built.append(sub) + node["children"] = built + return node + + # Build manually to match the original nested structure exactly + def make(title, owner, **opts): + nonlocal uid + uid += 1 + kids = opts.pop("c", []) + node = _t(uid, title, owner, **opts) + node["children"] = kids + return node + + tasks = [ + make( + "Derichebourg pilot - sorting robot", + "ia", + p="high", + d="2026-07-10", + open=True, + c=[ + make( + "Integrate RS03 drive motors", + "sk", + p="high", + d="2026-06-16", + s="l", + open=True, + c=[ + make("Mount RS03 motors and couplers", "sk", done=True, d="2026-06-08", s="m"), + make("Wire motor CAN bus to controller", "sk", d="2026-06-14", s="m"), + make("Calibrate RS03 torque limits", "sk", d="2026-06-17", s="s"), + ], + ), + make( + "Tune obstacle avoidance for the sorting line", + "ak", + p="high", + d="2026-06-19", + s="l", + open=True, + c=[ + make("Collect depth data along the conveyor", "ak", done=True, d="2026-06-10", s="m"), + make("Train avoidance model", "ak", d="2026-06-18", s="l"), + make("Field test near the conveyor", "ak", d="2026-06-22", s="m"), + ], + ), + make( + "Fix D-Wave board brownout under load", + "ly", + p="high", + d="2026-06-12", + s="m", + open=True, + c=[ + make("Diagnose the power regulator", "ly", done=True, d="2026-06-11", s="s"), + make("Replace regulator and retest", "ly", d="2026-06-13", s="m"), + ], + ), + make("Approve motor procurement budget", "jn", d="2026-06-15", s="s"), + make("Coordinate on-site pilot install", "fd", d="2026-06-30", s="l"), + ], + ), + make( + "JCDecaux pilot - billboard servicing", + "ak", + p="high", + d="2026-07-20", + open=True, + c=[ + make( + "Design board-mount manipulator arm", + "ia", + d="2026-06-23", + s="l", + open=True, + c=[ + make("CAD the arm linkage", "ia", d="2026-06-18", s="m"), + make("Source Feetech servos for the arm", "lm", d="2026-06-20", s="s"), + ], + ), + make( + "Autonomous navigation between billboards", + "ak", + p="high", + d="2026-06-26", + s="xl", + open=True, + c=[ + make("Build city route planner", "ak", d="2026-06-24", s="l"), + make("GPS waypoint following", "ak", d="2026-06-25", s="m"), + ], + ), + make("Hub-motor sizing for outdoor terrain", "sk", d="2026-06-20", s="m"), + make("Demo prep for JCDecaux", "fd", d="2026-06-27", s="s"), + ], + ), + make( + "Onet pilot - floor-cleaning autonomy", + "ak", + d="2026-08-01", + open=True, + c=[ + make("Map the Onet facility floorplan", "ak", d="2026-06-21", s="m"), + make( + "Integrate Feetech servos for the brush arm", + "sk", + d="2026-06-24", + s="m", + open=True, + c=[ + make("Mount the brush assembly", "lm", d="2026-06-22", s="s"), + make("Tune servo sweep pattern", "sk", d="2026-06-25", s="s"), + ], + ), + make("Safety e-stop wiring", "ly", p="high", d="2026-06-16", s="s"), + ], + ), + make( + "RoboOS v2 - core platform", + "ia", + p="high", + d="2026-07-31", + open=True, + c=[ + make( + "Migrate OS to RS04 motor drivers", + "sk", + p="high", + d="2026-06-24", + s="l", + open=True, + c=[ + make("Port CAN driver to RS04", "sk", d="2026-06-22", s="m"), + make("Bench-test RS04 closed loop", "sk", d="2026-06-23", s="m"), + ], + ), + make("Real-time locomotion controller", "ak", d="2026-06-29", s="l"), + make( + "Evaluate EL05 actuators", + "sk", + d="2026-06-17", + s="m", + open=True, + c=[ + make("Run EL05 load tests", "sk", done=True, d="2026-06-09", s="s"), + make("Compare EL05 vs RS02 efficiency", "sk", d="2026-06-18", s="s"), + ], + ), + make("Nightly build + hardware-in-the-loop rig", "ia", d="2026-06-21", s="m"), + make("Assemble robot chassis v2", "ia", d="2026-07-06", s="l"), + ], + ), + make( + "NSI pilot - inventory scanning", + "ia", + d="2026-07-15", + open=True, + c=[ + make("Scoping follow-up with NSI", "fd", d="2026-06-19", s="s"), + make("Barcode scanner integration", "sk", d="2026-06-28", s="m"), + make("Aisle navigation tuning", "ak", d="2026-07-02", s="m"), + ], + ), + ] + + def walk(nodes, parent=None, depth=0): + rng = random.Random(7) + keys = list(PEOPLE.keys()) + for n in nodes: + if depth >= 2 and parent: + n["owner"] = parent["owner"] if rng.random() < 0.7 else keys[int(rng.random() * len(keys))] + walk(n["children"], n, depth + 1) + + walk(tasks) + return tasks, uid + + +def default_board(): + tasks, uid = _build_tasks() + return { + "people": PEOPLE, + "tasks": tasks, + "clients": CLIENTS, + "hardware_vocab": HARDWARE_VOCAB, + "domain_rules": DOMAIN_RULES, + "uid": uid, + "today": "2026-06-12", + } diff --git a/server.py b/server.py new file mode 100644 index 0000000..efde959 --- /dev/null +++ b/server.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""TaskBoard server: static files + SQLite-backed board API.""" + +import argparse +from pathlib import Path + +from flask import Flask, jsonify, request, send_from_directory + +import db + +ROOT = Path(__file__).resolve().parent + +app = Flask(__name__, static_folder=str(ROOT), static_url_path="") + + +@app.get("/api/board") +def api_get_board(): + board, updated_at = db.get_board() + return jsonify({**board, "updated_at": updated_at}) + + +@app.put("/api/board") +def api_put_board(): + payload = request.get_json(silent=True) + if not isinstance(payload, dict): + return jsonify({"error": "expected JSON object"}), 400 + required = ("people", "tasks") + missing = [k for k in required if k not in payload] + if missing: + return jsonify({"error": f"missing fields: {', '.join(missing)}"}), 400 + board = { + "people": payload["people"], + "tasks": payload["tasks"], + "clients": payload.get("clients", []), + "hardware_vocab": payload.get("hardware_vocab", []), + "domain_rules": payload.get("domain_rules", []), + "uid": payload.get("uid", 0), + "today": payload.get("today"), + } + updated_at = db.put_board(board) + return jsonify({"ok": True, "updated_at": updated_at}) + + +@app.get("/api/health") +def api_health(): + return jsonify({"ok": True}) + + +@app.get("/") +def index(): + return send_from_directory(ROOT, "index.html") + + +@app.get("/") +def static_files(path): + target = ROOT / path + if target.is_file(): + return send_from_directory(ROOT, path) + return send_from_directory(ROOT, "index.html") + + +def main(): + parser = argparse.ArgumentParser(description="Run the TaskBoard server") + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=8090) + args = parser.parse_args() + db.init_db() + app.run(host=args.host, port=args.port, debug=False) + + +if __name__ == "__main__": + main() diff --git a/src/app/main.js b/src/app/main.js index a08db94..e1f4f53 100644 --- a/src/app/main.js +++ b/src/app/main.js @@ -14,6 +14,7 @@ import { cap1, stripCaptions, findOwnerId, findDue, findSize, normalizeProposal, mockTranscript, isoCap, } from "../lib/capture.js"; +import { startBoardSync } from "../lib/board-sync.js"; /* ================= sample data ================= */ /* al = ASR aliases: common Whisper mishearings of each name. @@ -22,7 +23,7 @@ import { const RESP_MAP_TEXT = buildRespMapText(); const VOCAB_TEXT = buildVocabText(); -const { T } = createTaskFactory(); +const { T, setUid, getUid } = createTaskFactory(); const DATA = [ /* ---- Client pilot: Derichebourg (waste-sorting robot) ---- */ @@ -121,10 +122,11 @@ const GRIP_SVG='{ if(e.key==="Escape"){ closeSheet(); closeCapture(); closeTeam(); closeBarMenu(); closeTranscript(); closeReview(); hideTip(); return; } if((e.ctrlKey||e.metaKey)&&!e.shiftKey&&e.key.toLowerCase()==="z"){ @@ -1512,9 +1514,9 @@ function renderTeam(){ ${p.photo?``:""}`).join(""); } function uploadPhoto(k,input){ const f=input.files&&input.files[0]; if(!f) return; - const r=new FileReader(); r.onload=e=>{ PEOPLE[k].photo=e.target.result; renderTeam(); renderAll(); }; + const r=new FileReader(); r.onload=e=>{ PEOPLE[k].photo=e.target.result; renderTeam(); renderAll(); requestSave(); }; r.readAsDataURL(f); } -function removePhoto(k){ delete PEOPLE[k].photo; renderTeam(); renderAll(); } +function removePhoto(k){ delete PEOPLE[k].photo; renderTeam(); renderAll(); requestSave(); } /* ================= search ================= */ function doSearch(){ @@ -1598,3 +1600,11 @@ const _globals = { delSub, commitCapture, toggleListen, stopListen, renderAll, moveTask, setKeyVal, }; Object.assign(window, _globals); + +startBoardSync({ + data: DATA, + getUid, + setUid, + renderAll, + onReady: (save) => { requestSave = save; }, +}); diff --git a/src/lib/board-sync.js b/src/lib/board-sync.js new file mode 100644 index 0000000..84235d1 --- /dev/null +++ b/src/lib/board-sync.js @@ -0,0 +1,110 @@ +import { CLIENTS, DOMAIN_RULES, HARDWARE_VOCAB, PEOPLE, TODAY } from "../data/constants.js"; +import { flat } from "./tree.js"; + +function maxTaskId(nodes) { + let m = 0; + flat(nodes, (n) => { + if (n.id > m) m = n.id; + }); + return m; +} + +export function boardPayload(data, getUid) { + return { + people: PEOPLE, + tasks: data, + clients: CLIENTS, + hardware_vocab: HARDWARE_VOCAB, + domain_rules: DOMAIN_RULES, + uid: getUid ? getUid() : maxTaskId(data), + today: TODAY.toISOString().slice(0, 10), + }; +} + +export function applyBoard(board, data, setUid) { + if (!board || typeof board !== "object") return false; + if (!board.tasks?.length || !Object.keys(board.people || {}).length) return false; + + let leaves = 0; + let withDue = 0; + flat(board.tasks, (n) => { + if (!n.children?.length) { + leaves += 1; + if (n.due) withDue += 1; + } + }); + if (!leaves || withDue < leaves * 0.3) return false; + + Object.keys(PEOPLE).forEach((k) => delete PEOPLE[k]); + Object.assign(PEOPLE, board.people); + + data.splice(0, data.length, ...board.tasks); + + if (Array.isArray(board.clients)) { + CLIENTS.splice(0, CLIENTS.length, ...board.clients); + } + if (Array.isArray(board.hardware_vocab)) { + HARDWARE_VOCAB.splice(0, HARDWARE_VOCAB.length, ...board.hardware_vocab); + } + if (Array.isArray(board.domain_rules)) { + DOMAIN_RULES.splice(0, DOMAIN_RULES.length, ...board.domain_rules); + } + + const uid = Math.max(board.uid || 0, maxTaskId(data)); + if (setUid) setUid(uid); + return true; +} + +export function startBoardSync({ data, getUid, setUid, renderAll, onReady }) { + let boardReady = false; + let saveTimer = null; + let saveInFlight = false; + let saveQueued = false; + + const payload = () => boardPayload(data, getUid); + + const doSave = async () => { + if (!boardReady) return; + if (saveInFlight) { + saveQueued = true; + return; + } + saveInFlight = true; + try { + const res = await fetch("/api/board", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload()), + }); + if (!res.ok) throw new Error("save failed"); + } catch (e) { + console.error("Board save failed", e); + } finally { + saveInFlight = false; + if (saveQueued) { + saveQueued = false; + scheduleSave(); + } + } + }; + + function scheduleSave() { + if (!boardReady) return; + clearTimeout(saveTimer); + saveTimer = setTimeout(doSave, 500); + } + + (async () => { + try { + const res = await fetch("/api/board"); + if (!res.ok) throw new Error("load failed"); + if (applyBoard(await res.json(), data, setUid)) { + boardReady = true; + renderAll(); + } + } catch (e) { + console.warn("Board load skipped, using built-in sample data.", e); + } + onReady(scheduleSave); + })(); +} diff --git a/src/lib/tree.js b/src/lib/tree.js index 41fa62c..e3d41cc 100644 --- a/src/lib/tree.js +++ b/src/lib/tree.js @@ -13,7 +13,12 @@ export function createTaskFactory() { children: opts.c || [], open: opts.open || false, }); - return { T, resetUid: () => { uid = 0; }, getUid: () => uid }; + return { + T, + resetUid: () => { uid = 0; }, + getUid: () => uid, + setUid: (v) => { uid = v; }, + }; } export const flat = (nodes, fn, depth = 0, path = []) =>