From 864a5c741be31447f5ec5ada3183dd7629c45e12 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 15 Jun 2026 16:15:07 +0000 Subject: [PATCH 1/7] Add SQLite persistence with Python server on port 8090 - Flask server serves index.html and /api/board endpoints - SQLite stores full board state (people, tasks, config) - Frontend loads on startup and auto-saves after edits - Seed data populated on first run Co-authored-by: Sanket Sharma --- .gitignore | 5 + CHANGELOG.md | 19 +++ README.md | 45 +++++- db.py | 67 +++++++++ requirements.txt | 1 + seed_data.py | 303 +++++++++++++++++++++++++++++++++++++++++ server.py | 72 ++++++++++ src/app/main.js | 108 +++------------ src/data/constants.js | 43 ++---- src/lib/persistence.js | 57 ++++++++ src/lib/tree.js | 7 +- 11 files changed, 595 insertions(+), 132 deletions(-) create mode 100644 db.py create mode 100644 requirements.txt create mode 100644 seed_data.py create mode 100644 server.py create mode 100644 src/lib/persistence.js diff --git a/.gitignore b/.gitignore index 25fbf5a..950e7a7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ node_modules/ coverage/ +data/ +__pycache__/ +*.pyc +.venv/ +venv/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 60b8558..6389e64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # 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/data/board-store.js` - mutable board state loaded from API +- `src/lib/persistence.js` - debounced auto-save (500ms) after edits + +### Changed +- `src/app/main.js` - loads board on startup, saves after mutations +- `index.html` unchanged as ES module entry (logic stays in `src/`) + +### 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 + ## Testing infrastructure (2026-06-15) ### Added diff --git a/README.md b/README.md index 1cd5d75..82f9edc 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,16 +38,36 @@ 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 | |------|---------| | `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/data/board-store.js` | Mutable board state (people, tasks) | +| `src/data/constants.js` | Static sizing, colors, zoom config | +| `src/lib/` | Testable pure functions (domain, tree, dates, capture, persistence) | +| `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 +98,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..600275d --- /dev/null +++ b/db.py @@ -0,0 +1,67 @@ +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()) + + +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() + return json.loads(row["data"]), 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..3c367cc --- /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"] = [make(**child) for child in 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..2f1f812 100644 --- a/src/app/main.js +++ b/src/app/main.js @@ -1,9 +1,10 @@ import { - PEOPLE, TODAY, HARDWARE_VOCAB, CLIENTS, + PEOPLE, TODAY, HARDWARE_VOCAB, CLIENTS, DATA, SIZE_PTS, SIZE_NAMES, LEAD, ZOOMS, GBAR_H, R0G, R1G, SPAN_G, TODAY_PX, C_LATE, C_TODAY, C_RADAR, C_LATER, C_DONE, } from "../data/constants.js"; +import { applyBoard, boardPayload } from "../data/board-store.js"; import { inferOwnerByDomain, canonHardware, findClient, buildRespMapText, buildVocabText, norm as _norm } from "../lib/domain.js"; import { createTaskFactory, flat, findPath as findPathIn, counts, pct, taskDone, @@ -14,92 +15,14 @@ import { cap1, stripCaptions, findOwnerId, findDue, findSize, normalizeProposal, mockTranscript, isoCap, } from "../lib/capture.js"; +import { initApp, scheduleSave } from "../lib/persistence.js"; /* ================= sample data ================= */ /* al = ASR aliases: common Whisper mishearings of each name. In production this mapping is done by the extraction LLM given the roster, plus Whisper initial_prompt biasing ("Team: Jean, Florian, Iannis, …"). */ -const RESP_MAP_TEXT = buildRespMapText(); -const VOCAB_TEXT = buildVocabText(); -const { T } = createTaskFactory(); - -const DATA = [ - /* ---- Client pilot: Derichebourg (waste-sorting robot) ---- */ - T("Derichebourg pilot — sorting robot","ia",{p:"high",d:"2026-07-10",open:true,c:[ - T("Integrate RS03 drive motors","sk",{p:"high",d:"2026-06-16",s:"l",open:true,c:[ - T("Mount RS03 motors and couplers","sk",{done:true,d:"2026-06-08",s:"m"}), - T("Wire motor CAN bus to controller","sk",{d:"2026-06-14",s:"m"}), - T("Calibrate RS03 torque limits","sk",{d:"2026-06-17",s:"s"}), - ]}), - T("Tune obstacle avoidance for the sorting line","ak",{p:"high",d:"2026-06-19",s:"l",open:true,c:[ - T("Collect depth data along the conveyor","ak",{done:true,d:"2026-06-10",s:"m"}), - T("Train avoidance model","ak",{d:"2026-06-18",s:"l"}), - T("Field test near the conveyor","ak",{d:"2026-06-22",s:"m"}), - ]}), - T("Fix D-Wave board brownout under load","ly",{p:"high",d:"2026-06-12",s:"m",open:true,c:[ - T("Diagnose the power regulator","ly",{done:true,d:"2026-06-11",s:"s"}), - T("Replace regulator and retest","ly",{d:"2026-06-13",s:"m"}), - ]}), - T("Approve motor procurement budget","jn",{d:"2026-06-15",s:"s"}), - T("Coordinate on-site pilot install","fd",{d:"2026-06-30",s:"l"}), - ]}), - - /* ---- Client pilot: JCDecaux (billboard-servicing robot) ---- */ - T("JCDecaux pilot — billboard servicing","ak",{p:"high",d:"2026-07-20",open:true,c:[ - T("Design board-mount manipulator arm","ia",{d:"2026-06-23",s:"l",open:true,c:[ - T("CAD the arm linkage","ia",{d:"2026-06-18",s:"m"}), - T("Source Feetech servos for the arm","lm",{d:"2026-06-20",s:"s"}), - ]}), - T("Autonomous navigation between billboards","ak",{p:"high",d:"2026-06-26",s:"xl",open:true,c:[ - T("Build city route planner","ak",{d:"2026-06-24",s:"l"}), - T("GPS waypoint following","ak",{d:"2026-06-25",s:"m"}), - ]}), - T("Hub-motor sizing for outdoor terrain","sk",{d:"2026-06-20",s:"m"}), - T("Demo prep for JCDecaux","fd",{d:"2026-06-27",s:"s"}), - ]}), - - /* ---- Client pilot: Onet (floor-cleaning autonomy) ---- */ - T("Onet pilot — floor-cleaning autonomy","ak",{d:"2026-08-01",open:true,c:[ - T("Map the Onet facility floorplan","ak",{d:"2026-06-21",s:"m"}), - T("Integrate Feetech servos for the brush arm","sk",{d:"2026-06-24",s:"m",open:true,c:[ - T("Mount the brush assembly","lm",{d:"2026-06-22",s:"s"}), - T("Tune servo sweep pattern","sk",{d:"2026-06-25",s:"s"}), - ]}), - T("Safety e-stop wiring","ly",{p:"high",d:"2026-06-16",s:"s"}), - ]}), - - /* ---- Internal R&D: core platform ---- */ - T("RoboOS v2 — core platform","ia",{p:"high",d:"2026-07-31",open:true,c:[ - T("Migrate OS to RS04 motor drivers","sk",{p:"high",d:"2026-06-24",s:"l",open:true,c:[ - T("Port CAN driver to RS04","sk",{d:"2026-06-22",s:"m"}), - T("Bench-test RS04 closed loop","sk",{d:"2026-06-23",s:"m"}), - ]}), - T("Real-time locomotion controller","ak",{d:"2026-06-29",s:"l"}), - T("Evaluate EL05 actuators","sk",{d:"2026-06-17",s:"m",open:true,c:[ - T("Run EL05 load tests","sk",{done:true,d:"2026-06-09",s:"s"}), - T("Compare EL05 vs RS02 efficiency","sk",{d:"2026-06-18",s:"s"}), - ]}), - T("Nightly build + hardware-in-the-loop rig","ia",{d:"2026-06-21",s:"m"}), - T("Assemble robot chassis v2","ia",{d:"2026-07-06",s:"l"}), - ]}), - - /* ---- Client pilot: NSI (inventory-scanning robot) ---- */ - T("NSI pilot — inventory scanning","ia",{d:"2026-07-15",open:true,c:[ - T("Scoping follow-up with NSI","fd",{d:"2026-06-19",s:"s"}), - T("Barcode scanner integration","sk",{d:"2026-06-28",s:"m"}), - T("Aisle navigation tuning","ak",{d:"2026-07-02",s:"m"}), - ]}), -]; - -/* subtasks inherit their parent task's owner by default — with an occasional - (seeded-random) different owner sprinkled in, like a real team would have */ -{ let seed=7; const rnd=()=>(seed=(seed*1103515245+12345)%2147483648)/2147483648; - const keys=Object.keys(PEOPLE); - const walk=(nodes,parent,depth)=>nodes.forEach(n=>{ - if(depth>=2&&parent) n.owner=rnd()<0.7?parent.owner:keys[Math.floor(rnd()*keys.length)]; - walk(n.children,n,depth+1); }); - walk(DATA,null,0); } +const { T, setUid, getUid } = createTaskFactory(); const findPath = (id, nodes = DATA, path = []) => findPathIn(id, nodes, path); const depthOf = (id) => depthOfIn(id, DATA); @@ -121,10 +44,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"){ @@ -1083,10 +1007,10 @@ Rules: - Task titles are concise imperative phrases with NO leading article — "Clean the bathroom", not "a clean the bathroom". Each item has title, plus owner/due/size if stated (else null), plus a "subs" array (empty if none). - SUBTASKS: the word "subtask" ALWAYS means an item inside some existing task's "subs" array — NEVER a new top-level task, no matter how many are added. When the user says "add subtask(s)" / "add N subtasks": if they name or imply a parent ("for the first task", "under clean the bathroom"), use it; if they DON'T name one, attach the subtasks to the LAST task currently in the tasks array. Return that task's complete subs list. Subtasks are leaves (no further subs). Never increase the number of top-level tasks when the user said "subtask". - ASSIGNEE INFERENCE: when no owner is explicitly named for a task/subtask, look at the task's CONTENT and assign the teammate whose responsibility best matches it, using this RESPONSIBILITY MAP: -${RESP_MAP_TEXT} +${buildRespMapText()} Examples: "Install RS03 motor on prototype" → Iannis or Sanket; "Implement obstacle avoidance for the Derichebourg pilot" → Akshat; "Fix D-Wave board power issue" → Leynaïck. Only when nothing in the content maps to a responsibility, fall back to the PROJECT's owner. If the user says "owners same as the project", set every task's and subtask's owner to the project owner (this overrides inference). - DOMAIN VOCABULARY — use these EXACT spellings; never invent or mis-spell hardware or client names: -${VOCAB_TEXT} +${buildVocabText()} Map mis-heard variants to the canonical form (e.g. "RS zero three"/"RS-3" → "RS03", "dwave" → "D-Wave", "jaycee decaux" → "JCDecaux"). - When the user gives an ordered list of due dates/owners "in that order" for the tasks, apply them positionally to the tasks in their current order. - DELETING: you CAN delete. When the user asks to remove/delete a task or subtask (e.g. "delete the two tests you just added", "remove clean the toilet"), put each item's exact current title into the "remove" array. Otherwise "remove" is []. Never say you can't delete. @@ -1366,8 +1290,8 @@ LANGUAGE: the transcript may be English or French — ALL OUTPUT MUST BE IN ENGL - Group work into projects. A customer pilot becomes a project; set its "client" to the matching known client. Pure internal work has client=null. - Each task: a concise imperative title (no leading article), owner, due (YYYY-MM-DD resolved from context.today, else null), size (s/m/l/xl or null), client (if the task is for a known client else null), and a subs array (usually empty). - ASSIGNEE: infer each owner from this RESPONSIBILITY MAP using the task's content; only null if genuinely unclear: -${RESP_MAP_TEXT} -${VOCAB_TEXT} +${buildRespMapText()} +${buildVocabText()} Use the exact hardware/client spellings above; map mis-hearings to the canonical form. - Do NOT re-create work that already exists in context.projects — only return genuinely new items. - assistantSay: one short sentence, e.g. "I identified 2 projects and 7 tasks from this conversation." @@ -1512,9 +1436,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(){ @@ -1585,7 +1509,11 @@ function renderAll(){ renderFilter(); renderDash(); } Object.defineProperty(window, "CAP", { get: () => CAP, configurable: true }); -renderAll(); +requestSave = await initApp({ + applyBoard: (b) => applyBoard(b, setUid), + boardPayload: () => boardPayload(getUid), + renderAll, +}); const _globals = { toggleSearch, openTeam, micFabTap, openTranscript, toggleSettings, toggleSidebar, closeSettings, diff --git a/src/data/constants.js b/src/data/constants.js index 1e6cd9d..cc88532 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -1,38 +1,11 @@ -export const 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"] }, -}; - -export const TODAY = new Date("2026-06-12"); - -export const 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)", -]; - -export const 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"] }, -]; - -export const 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"] }, -]; +export { + PEOPLE, + TODAY, + CLIENTS, + HARDWARE_VOCAB, + DOMAIN_RULES, + DATA, +} from "./board-store.js"; export const SIZE_PTS = { s: 1, m: 2, l: 4, xl: 8 }; export const SIZE_NAMES = { s: "S", m: "M", l: "L", xl: "XL" }; diff --git a/src/lib/persistence.js b/src/lib/persistence.js new file mode 100644 index 0000000..e5d93a6 --- /dev/null +++ b/src/lib/persistence.js @@ -0,0 +1,57 @@ +let boardReady = false; +let saveTimer = null; +let saveInFlight = false; +let saveQueued = false; + +export function isBoardReady() { + return boardReady; +} + +export function scheduleSave(saveBoard) { + if (!boardReady) return; + clearTimeout(saveTimer); + saveTimer = setTimeout(saveBoard, 500); +} + +export async function saveBoard(boardPayload) { + 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(boardPayload()), + }); + if (!res.ok) throw new Error("save failed"); + } catch (e) { + console.error("Board save failed", e); + } finally { + saveInFlight = false; + if (saveQueued) { + saveQueued = false; + scheduleSave(() => saveBoard(boardPayload)); + } + } +} + +export async function initApp({ applyBoard, boardPayload, renderAll }) { + const doSave = () => saveBoard(boardPayload); + try { + const res = await fetch("/api/board"); + if (!res.ok) throw new Error("load failed"); + applyBoard(await res.json()); + } catch (e) { + console.error("Board load failed - is server.py running on port 8090?", e); + document.body.insertAdjacentHTML( + "afterbegin", + '
Could not load board data. Start the server with: python server.py
', + ); + } + boardReady = true; + renderAll(); + return () => scheduleSave(doSave); +} 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 = []) => From bd644efca69cf9afe73ef94eed32f641327ac41f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 16 Jun 2026 09:28:50 +0000 Subject: [PATCH 2/7] Add missing board-store and defaults modules for CI Fix .gitignore so /data/ only ignores the SQLite directory at repo root, not src/data/. The missing modules caused module resolution failures in CI. Co-authored-by: Sanket Sharma --- .gitignore | 2 +- CHANGELOG.md | 2 +- src/data/board-store.js | 68 +++++++++++++++++++++++++++++++++++++++++ src/data/defaults.js | 35 +++++++++++++++++++++ 4 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 src/data/board-store.js create mode 100644 src/data/defaults.js diff --git a/.gitignore b/.gitignore index 950e7a7..bd77215 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ node_modules/ coverage/ -data/ +/data/ __pycache__/ *.pyc .venv/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6389e64..b3650ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - `db.py` - SQLite storage in `data/taskboard.db` - `seed_data.py` - default team/tasks seeded on first run - `requirements.txt` - Flask dependency -- `src/data/board-store.js` - mutable board state loaded from API +- `src/data/board-store.js` and `src/data/defaults.js` - mutable board state loaded from API - `src/lib/persistence.js` - debounced auto-save (500ms) after edits ### Changed diff --git a/src/data/board-store.js b/src/data/board-store.js new file mode 100644 index 0000000..5fd5c7b --- /dev/null +++ b/src/data/board-store.js @@ -0,0 +1,68 @@ +import { + DEFAULT_CLIENTS, + DEFAULT_DOMAIN_RULES, + DEFAULT_HARDWARE_VOCAB, + DEFAULT_PEOPLE, + DEFAULT_TODAY, +} from "./defaults.js"; +import { flat } from "../lib/tree.js"; + +export const PEOPLE = {}; +export const DATA = []; +export const CLIENTS = []; +export const HARDWARE_VOCAB = []; +export const DOMAIN_RULES = []; +export let TODAY = new Date(DEFAULT_TODAY); + +function maxTaskId(nodes) { + let m = 0; + flat(nodes, (n) => { + if (n.id > m) m = n.id; + }); + return m; +} + +export function applyBoard(board, setUid) { + Object.keys(PEOPLE).forEach((k) => delete PEOPLE[k]); + Object.assign(PEOPLE, board.people || {}); + + DATA.splice(0, DATA.length, ...(board.tasks || [])); + + CLIENTS.splice(0, CLIENTS.length, ...(board.clients || [])); + HARDWARE_VOCAB.splice(0, HARDWARE_VOCAB.length, ...(board.hardware_vocab || [])); + DOMAIN_RULES.splice(0, DOMAIN_RULES.length, ...(board.domain_rules || [])); + + const uid = Math.max(board.uid || 0, maxTaskId(DATA)); + if (setUid) setUid(uid); + + if (board.today) TODAY = new Date(board.today); +} + +export function boardPayload(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 initBoardDefaults(setUid) { + applyBoard( + { + people: structuredClone(DEFAULT_PEOPLE), + tasks: [], + clients: structuredClone(DEFAULT_CLIENTS), + hardware_vocab: [...DEFAULT_HARDWARE_VOCAB], + domain_rules: structuredClone(DEFAULT_DOMAIN_RULES), + uid: 0, + today: DEFAULT_TODAY, + }, + setUid, + ); +} + +initBoardDefaults(); diff --git a/src/data/defaults.js b/src/data/defaults.js new file mode 100644 index 0000000..3742983 --- /dev/null +++ b/src/data/defaults.js @@ -0,0 +1,35 @@ +export const DEFAULT_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"] }, +}; + +export const DEFAULT_TODAY = "2026-06-12"; + +export const DEFAULT_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)", +]; + +export const DEFAULT_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"] }, +]; + +export const DEFAULT_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"] }, +]; From 983e4d373c08a2757f07c6e00adc916633227f5a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 16 Jun 2026 09:48:30 +0000 Subject: [PATCH 3/7] Restore sample tasks as offline fallback for static preview The persistence change left DATA empty when /api/board was unavailable (GitHub Pages, static serve). Re-add the original sample projects as a local fallback and only enable auto-save when the server responds. Co-authored-by: Sanket Sharma --- CHANGELOG.md | 2 + src/app/main.js | 4 +- src/data/board-store.js | 26 ++++++------- src/data/sample-tasks.js | 82 ++++++++++++++++++++++++++++++++++++++++ src/lib/persistence.js | 10 ++--- 5 files changed, 104 insertions(+), 20 deletions(-) create mode 100644 src/data/sample-tasks.js diff --git a/CHANGELOG.md b/CHANGELOG.md index b3650ec..20e966a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ ### Changed - `src/app/main.js` - loads board on startup, saves after mutations - `index.html` unchanged as ES module entry (logic stays in `src/`) +- Restored sample task data as offline fallback (GitHub Pages / static serve) +- Removed error banner when local sample data is used ### Reasoning - Single JSON blob in SQLite keeps v1 simple and matches the in-memory tree model diff --git a/src/app/main.js b/src/app/main.js index 2f1f812..c79f80e 100644 --- a/src/app/main.js +++ b/src/app/main.js @@ -4,7 +4,8 @@ import { R0G, R1G, SPAN_G, TODAY_PX, C_LATE, C_TODAY, C_RADAR, C_LATER, C_DONE, } from "../data/constants.js"; -import { applyBoard, boardPayload } from "../data/board-store.js"; +import { applyBoard, boardPayload, initBoardDefaults } from "../data/board-store.js"; +import { buildSampleTasks } from "../data/sample-tasks.js"; import { inferOwnerByDomain, canonHardware, findClient, buildRespMapText, buildVocabText, norm as _norm } from "../lib/domain.js"; import { createTaskFactory, flat, findPath as findPathIn, counts, pct, taskDone, @@ -23,6 +24,7 @@ import { initApp, scheduleSave } from "../lib/persistence.js"; plus Whisper initial_prompt biasing ("Team: Jean, Florian, Iannis, …"). */ const { T, setUid, getUid } = createTaskFactory(); +initBoardDefaults(setUid, () => buildSampleTasks(T)); const findPath = (id, nodes = DATA, path = []) => findPathIn(id, nodes, path); const depthOf = (id) => depthOfIn(id, DATA); diff --git a/src/data/board-store.js b/src/data/board-store.js index 5fd5c7b..e349a7c 100644 --- a/src/data/board-store.js +++ b/src/data/board-store.js @@ -50,19 +50,19 @@ export function boardPayload(getUid) { }; } -export function initBoardDefaults(setUid) { - applyBoard( - { - people: structuredClone(DEFAULT_PEOPLE), - tasks: [], - clients: structuredClone(DEFAULT_CLIENTS), - hardware_vocab: [...DEFAULT_HARDWARE_VOCAB], - domain_rules: structuredClone(DEFAULT_DOMAIN_RULES), - uid: 0, - today: DEFAULT_TODAY, - }, - setUid, - ); +export function initBoardDefaults(setUid, buildTasks) { + Object.keys(PEOPLE).forEach((k) => delete PEOPLE[k]); + Object.assign(PEOPLE, structuredClone(DEFAULT_PEOPLE)); + CLIENTS.splice(0, CLIENTS.length, ...structuredClone(DEFAULT_CLIENTS)); + HARDWARE_VOCAB.splice(0, HARDWARE_VOCAB.length, ...DEFAULT_HARDWARE_VOCAB); + DOMAIN_RULES.splice(0, DOMAIN_RULES.length, ...structuredClone(DEFAULT_DOMAIN_RULES)); + TODAY = new Date(DEFAULT_TODAY); + + const tasks = buildTasks ? buildTasks() : []; + DATA.splice(0, DATA.length, ...tasks); + + const uid = maxTaskId(DATA); + if (setUid) setUid(uid); } initBoardDefaults(); diff --git a/src/data/sample-tasks.js b/src/data/sample-tasks.js new file mode 100644 index 0000000..c7285b7 --- /dev/null +++ b/src/data/sample-tasks.js @@ -0,0 +1,82 @@ +import { PEOPLE } from "./board-store.js"; + +export function buildSampleTasks(T) { + const tasks = [ + T("Derichebourg pilot - sorting robot", "ia", { p: "high", d: "2026-07-10", open: true, c: [ + T("Integrate RS03 drive motors", "sk", { p: "high", d: "2026-06-16", s: "l", open: true, c: [ + T("Mount RS03 motors and couplers", "sk", { done: true, d: "2026-06-08", s: "m" }), + T("Wire motor CAN bus to controller", "sk", { d: "2026-06-14", s: "m" }), + T("Calibrate RS03 torque limits", "sk", { d: "2026-06-17", s: "s" }), + ] }), + T("Tune obstacle avoidance for the sorting line", "ak", { p: "high", d: "2026-06-19", s: "l", open: true, c: [ + T("Collect depth data along the conveyor", "ak", { done: true, d: "2026-06-10", s: "m" }), + T("Train avoidance model", "ak", { d: "2026-06-18", s: "l" }), + T("Field test near the conveyor", "ak", { d: "2026-06-22", s: "m" }), + ] }), + T("Fix D-Wave board brownout under load", "ly", { p: "high", d: "2026-06-12", s: "m", open: true, c: [ + T("Diagnose the power regulator", "ly", { done: true, d: "2026-06-11", s: "s" }), + T("Replace regulator and retest", "ly", { d: "2026-06-13", s: "m" }), + ] }), + T("Approve motor procurement budget", "jn", { d: "2026-06-15", s: "s" }), + T("Coordinate on-site pilot install", "fd", { d: "2026-06-30", s: "l" }), + ] }), + + T("JCDecaux pilot - billboard servicing", "ak", { p: "high", d: "2026-07-20", open: true, c: [ + T("Design board-mount manipulator arm", "ia", { d: "2026-06-23", s: "l", open: true, c: [ + T("CAD the arm linkage", "ia", { d: "2026-06-18", s: "m" }), + T("Source Feetech servos for the arm", "lm", { d: "2026-06-20", s: "s" }), + ] }), + T("Autonomous navigation between billboards", "ak", { p: "high", d: "2026-06-26", s: "xl", open: true, c: [ + T("Build city route planner", "ak", { d: "2026-06-24", s: "l" }), + T("GPS waypoint following", "ak", { d: "2026-06-25", s: "m" }), + ] }), + T("Hub-motor sizing for outdoor terrain", "sk", { d: "2026-06-20", s: "m" }), + T("Demo prep for JCDecaux", "fd", { d: "2026-06-27", s: "s" }), + ] }), + + T("Onet pilot - floor-cleaning autonomy", "ak", { d: "2026-08-01", open: true, c: [ + T("Map the Onet facility floorplan", "ak", { d: "2026-06-21", s: "m" }), + T("Integrate Feetech servos for the brush arm", "sk", { d: "2026-06-24", s: "m", open: true, c: [ + T("Mount the brush assembly", "lm", { d: "2026-06-22", s: "s" }), + T("Tune servo sweep pattern", "sk", { d: "2026-06-25", s: "s" }), + ] }), + T("Safety e-stop wiring", "ly", { p: "high", d: "2026-06-16", s: "s" }), + ] }), + + T("RoboOS v2 - core platform", "ia", { p: "high", d: "2026-07-31", open: true, c: [ + T("Migrate OS to RS04 motor drivers", "sk", { p: "high", d: "2026-06-24", s: "l", open: true, c: [ + T("Port CAN driver to RS04", "sk", { d: "2026-06-22", s: "m" }), + T("Bench-test RS04 closed loop", "sk", { d: "2026-06-23", s: "m" }), + ] }), + T("Real-time locomotion controller", "ak", { d: "2026-06-29", s: "l" }), + T("Evaluate EL05 actuators", "sk", { d: "2026-06-17", s: "m", open: true, c: [ + T("Run EL05 load tests", "sk", { done: true, d: "2026-06-09", s: "s" }), + T("Compare EL05 vs RS02 efficiency", "sk", { d: "2026-06-18", s: "s" }), + ] }), + T("Nightly build + hardware-in-the-loop rig", "ia", { d: "2026-06-21", s: "m" }), + T("Assemble robot chassis v2", "ia", { d: "2026-07-06", s: "l" }), + ] }), + + T("NSI pilot - inventory scanning", "ia", { d: "2026-07-15", open: true, c: [ + T("Scoping follow-up with NSI", "fd", { d: "2026-06-19", s: "s" }), + T("Barcode scanner integration", "sk", { d: "2026-06-28", s: "m" }), + T("Aisle navigation tuning", "ak", { d: "2026-07-02", s: "m" }), + ] }), + ]; + + sprinkleSubtaskOwners(tasks); + return tasks; +} + +function sprinkleSubtaskOwners(nodes) { + let seed = 7; + const rnd = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648; + const keys = Object.keys(PEOPLE); + const walk = (list, parent, depth) => list.forEach((n) => { + if (depth >= 2 && parent) { + n.owner = rnd() < 0.7 ? parent.owner : keys[Math.floor(rnd() * keys.length)]; + } + walk(n.children, n, depth + 1); + }); + walk(nodes, null, 0); +} diff --git a/src/lib/persistence.js b/src/lib/persistence.js index e5d93a6..07d5a7a 100644 --- a/src/lib/persistence.js +++ b/src/lib/persistence.js @@ -40,18 +40,16 @@ export async function saveBoard(boardPayload) { export async function initApp({ applyBoard, boardPayload, renderAll }) { const doSave = () => saveBoard(boardPayload); + let useServer = false; try { const res = await fetch("/api/board"); if (!res.ok) throw new Error("load failed"); applyBoard(await res.json()); + useServer = true; } catch (e) { - console.error("Board load failed - is server.py running on port 8090?", e); - document.body.insertAdjacentHTML( - "afterbegin", - '
Could not load board data. Start the server with: python server.py
', - ); + console.warn("Board load skipped, using local sample data.", e); } - boardReady = true; + boardReady = useServer; renderAll(); return () => scheduleSave(doSave); } From 832e789d7d49cd0026e2622fa68721b3d75ef567 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 16 Jun 2026 10:00:33 +0000 Subject: [PATCH 4/7] Fix broken UI by restoring main-branch boot sequence The persistence refactor replaced inline sample data with an async load that could render an empty board and blocked startup with top-level await. - Restore constants.js and main.js data/init from main - Add board-sync.js for background load/save only - Keep sync renderAll() + window globals like main - Reseed SQLite when stored board is empty Co-authored-by: Sanket Sharma --- CHANGELOG.md | 7 +- README.md | 5 +- _site/index.html | 835 +++++++++++++ _site/src/app/main.js | 1530 +++++++++++++++++++++++ {src => _site/src}/data/board-store.js | 0 _site/src/data/constants.js | 31 + {src => _site/src}/data/defaults.js | 0 {src => _site/src}/data/sample-tasks.js | 0 _site/src/lib/capture.js | 193 +++ _site/src/lib/date-core.js | 14 + _site/src/lib/dates.js | 106 ++ _site/src/lib/domain.js | 49 + {src => _site/src}/lib/persistence.js | 0 _site/src/lib/tree.js | 86 ++ db.py | 10 +- src/app/main.js | 108 +- src/data/constants.js | 43 +- src/lib/board-sync.js | 100 ++ 18 files changed, 3087 insertions(+), 30 deletions(-) create mode 100644 _site/index.html create mode 100644 _site/src/app/main.js rename {src => _site/src}/data/board-store.js (100%) create mode 100644 _site/src/data/constants.js rename {src => _site/src}/data/defaults.js (100%) rename {src => _site/src}/data/sample-tasks.js (100%) create mode 100644 _site/src/lib/capture.js create mode 100644 _site/src/lib/date-core.js create mode 100644 _site/src/lib/dates.js create mode 100644 _site/src/lib/domain.js rename {src => _site/src}/lib/persistence.js (100%) create mode 100644 _site/src/lib/tree.js create mode 100644 src/lib/board-sync.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 20e966a..b9a4ca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,9 @@ - `src/lib/persistence.js` - debounced auto-save (500ms) after edits ### Changed -- `src/app/main.js` - loads board on startup, saves after mutations -- `index.html` unchanged as ES module entry (logic stays in `src/`) -- Restored sample task data as offline fallback (GitHub Pages / static serve) -- Removed error banner when local sample data is used +- `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 diff --git a/README.md b/README.md index 82f9edc..83cf226 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,8 @@ npm run test:watch |------|---------| | `index.html` | UI markup and styles | | `src/app/main.js` | Application logic (DOM, rendering, interactions) | -| `src/data/board-store.js` | Mutable board state (people, tasks) | -| `src/data/constants.js` | Static sizing, colors, zoom config | -| `src/lib/` | Testable pure functions (domain, tree, dates, capture, persistence) | +| `src/data/constants.js` | Team roster, clients, sizes, colors | +| `src/lib/board-sync.js` | Server load/save (non-blocking) | | `server.py` | Flask app + SQLite API | | `tests/` | Vitest unit tests | diff --git a/_site/index.html b/_site/index.html new file mode 100644 index 0000000..a881025 --- /dev/null +++ b/_site/index.html @@ -0,0 +1,835 @@ + + + + + + + +TaskBoard — Prototype + + + + + + + +
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
Today's priorities
+
+
+ +
Show done tasks
+
+
+ +
Show subtasks
+
+
+ +
+ Late + Due today + Work on today, due later + Due later +
+
+
+
+
+
+
+
+
+ + + +
+
+
+ +

New project

+
+
+
+
+
+

Assistant

+
+ + +
+
+
+
+ + + +
+
+
+ +
+ +
+
+

Connect ChatGPT

+

Paste your OpenAI API key to power capture with GPT. It’s kept only in this browser session — never written into the file — and is sent only to OpenAI. Leave blank to use the basic offline parser.

+ +
+ + +
+ +
+
+ +
+
+ +

Process a conversation

+

Paste a client or team conversation. I'll propose projects and tasks for you to review — nothing is created until you approve it.

+ +
+ + +
+
+
+ +
+
+ +

Review & approve

+
+
+
+
+
+ +
+
+ +

Team photos

+

Upload a picture for each teammate. With no photo, their coloured initials are used.

+
+
+
+ +
+
+
+ +
+ +
+
+
+ + + + diff --git a/_site/src/app/main.js b/_site/src/app/main.js new file mode 100644 index 0000000..c79f80e --- /dev/null +++ b/_site/src/app/main.js @@ -0,0 +1,1530 @@ +import { + PEOPLE, TODAY, HARDWARE_VOCAB, CLIENTS, DATA, + SIZE_PTS, SIZE_NAMES, LEAD, ZOOMS, GBAR_H, + R0G, R1G, SPAN_G, TODAY_PX, + C_LATE, C_TODAY, C_RADAR, C_LATER, C_DONE, +} from "../data/constants.js"; +import { applyBoard, boardPayload, initBoardDefaults } from "../data/board-store.js"; +import { buildSampleTasks } from "../data/sample-tasks.js"; +import { inferOwnerByDomain, canonHardware, findClient, buildRespMapText, buildVocabText, norm as _norm } from "../lib/domain.js"; +import { + createTaskFactory, flat, findPath as findPathIn, counts, pct, taskDone, + taskDoneAt as taskDoneAtIn, contains, depthOf as depthOfIn, heightOf, fitsDepth as fitsDepthIn, +} from "../lib/tree.js"; +import { createDateHelpers } from "../lib/dates.js"; +import { + cap1, stripCaptions, findOwnerId, findDue, findSize, + normalizeProposal, mockTranscript, isoCap, +} from "../lib/capture.js"; +import { initApp, scheduleSave } from "../lib/persistence.js"; + +/* ================= sample data ================= */ +/* al = ASR aliases: common Whisper mishearings of each name. + In production this mapping is done by the extraction LLM given the roster, + plus Whisper initial_prompt biasing ("Team: Jean, Florian, Iannis, …"). */ + +const { T, setUid, getUid } = createTaskFactory(); +initBoardDefaults(setUid, () => buildSampleTasks(T)); + +const findPath = (id, nodes = DATA, path = []) => findPathIn(id, nodes, path); +const depthOf = (id) => depthOfIn(id, DATA); +const fitsDepth = (node, destId) => fitsDepthIn(node, destId, DATA); + +/* ================= helpers ================= */ +/* size-weighted progress (0..1): done size-points / total size-points across the leaves */ +const progFrac = (n) => { let done = 0, tot = 0; flat([n], (x) => { if (x.children.length) return; const w = SIZE_PTS[x.size || "m"]; tot += w; if (x.done) done += w; }); return tot ? done / tot : 0; }; +function dueChip(due,done){ if(!due||done) return ""; + const dd=Math.round((new Date(due)-TODAY)/864e5); + const cls=dd<0?"overdue":dd<=3?"soon":""; + const lbl=dd<0?`${-dd}d overdue`:dd===0?"Due today":dd===1?"Due tomorrow":"Due "+new Date(due).toLocaleDateString("en-GB",{day:"numeric",month:"short"}); + return `${lbl}`; } +const prChip=p=>({high:'High',med:'Medium',low:'Low'})[p]; +const av=(pid,cls="sm")=>{const p=PEOPLE[pid];return p.photo + ? `` + : `${p.initials}`;}; +const GRIP_SVG=''; + +/* ================= undo (ctrl/cmd+Z) ================= */ +const UNDO=[]; +let requestSave = () => {}; +function snap(){ UNDO.push(JSON.stringify(DATA)); if(UNDO.length>60) UNDO.shift(); requestSave(); } +function undo(){ if(!UNDO.length) return; + DATA.splice(0,DATA.length,...JSON.parse(UNDO.pop())); + closeSheet(); ding(0); renderAll(); requestSave(); } +document.addEventListener("keydown",e=>{ + if(e.key==="Escape"){ closeSheet(); closeCapture(); closeTeam(); closeBarMenu(); closeTranscript(); closeReview(); hideTip(); return; } + if((e.ctrlKey||e.metaKey)&&!e.shiftKey&&e.key.toLowerCase()==="z"){ + if(/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return; // let fields keep their own undo + e.preventDefault(); undo(); }}); + +/* ================= dashboard ================= */ +let lastTilt=0, ownerFilter="all"; +const want=o=>ownerFilter==="all"||o===ownerFilter; +function setFilter(k){ ownerFilter=k; closeFlyouts(); setTimeout(renderAll,0); } +/* one roll-out open at a time; expandable icons stay lit while their options are out */ +function closeFlyouts(exceptId){ + document.querySelectorAll(".gfctl.open").forEach(c=>{ if(c.id!==exceptId){ c.classList.remove("open"); + if(c.classList.contains("gfexp")){ const b=c.querySelector(".gficon"); if(b)b.classList.remove("on"); } } }); +} +function toggleFlyout(name){ + const c=document.getElementById("ctl-"+name); if(!c) return; + const willOpen=!c.classList.contains("open"); + closeFlyouts(willOpen?c.id:null); + c.classList.toggle("open",willOpen); + const b=c.querySelector(".gficon"); if(b) b.classList.toggle("on",willOpen); +} +let _descTimer=null; +/* toggles flash a one-line description, then it rolls back in */ +function flashDesc(name,text){ + const c=document.getElementById("ctl-"+name); if(!c) return; + closeFlyouts(c.id); + const dd=c.querySelector(".gfdesc"); if(dd&&text) dd.textContent=text; + c.classList.add("open"); clearTimeout(_descTimer); + _descTimer=setTimeout(()=>c.classList.remove("open"),1800); +} +/* click anywhere outside a control closes any open roll-out */ +document.addEventListener("pointerdown",e=>{ if(!e.target.closest(".gfctl")) closeFlyouts(); },true); +/* owners are an always-visible segmented row of avatars (built once, re-rendered on change) */ +function renderFilter(){ + const pop=document.getElementById("gfilterPop"); + if(pop) pop.innerHTML= + ``+ + Object.entries(PEOPLE).map(([k,p])=> + ``).join(""); + // light the collapsed owner icon when a specific person is filtered (so it's clear a filter is on) + const pb=document.getElementById("gpeoplebtn"); if(pb) pb.classList.toggle("gactive",ownerFilter!=="all"); +} +let SCALE_SUB=false; // false = task view (top-level tasks), true = subtask view (leaves) +function setScaleView(sub){ SCALE_SUB=sub; renderDash(); } +const taskDoneAt = (n) => taskDoneAtIn(n); +function renderDash(){ + renderGantt(); + if(!document.getElementById("myday")) return; // this version runs without the scale pane + const sv=document.getElementById("scview"); + if(sv) sv.innerHTML= + ``+ + ``; + const inView=(n,depth)=>SCALE_SUB?!n.children.length:depth===1; + const mine=[]; + flat(DATA,(n,depth,path)=>{ if(!inView(n,depth)||!want(n.owner)||taskDone(n)) return; + const dd=n.due?Math.round((new Date(n.due)-TODAY)/864e5):null; + mine.push({n,proj:(path[0]||n).title,dd,dn:false}); + }); + // balance scale over the selected time window: outstanding work weighs the left arm, + // work finished within the window counterweights on the right + const HZ=ZOOMS[ZOOM].h; + const myLate=mine.filter(x=>x.dd!==null&&x.dd<0).sort((a,b)=>a.dd-b.dd); + const myToday=mine.filter(x=>x.dd===0); + const upcoming=HZ>0?mine.filter(x=>x.dd!==null&&x.dd>0&&x.dd<=HZ).sort((a,b)=>a.dd-b.dd):[]; + const banked=[]; + flat(DATA,(n,depth,path)=>{ if(!inView(n,depth)||!want(n.owner)||!taskDone(n)) return; + const da=taskDoneAt(n); if(!da) return; + const ago=Math.round((TODAY-new Date(da))/864e5); + if(ago>=0&&ago<=Math.max(HZ,0)) banked.push({n,proj:(path[0]||n).title,dn:true}); }); + const PT=x=>SIZE_PTS[x.n.size||"m"]; + const pill=(x,cls,side)=>`
+ + + ${av(x.n.owner,"xs")} +
`; + let lt=0,rt=0; + // angry-birds physics: late = heaviest arm (3×), start-today middle (2×), due-today lightest (1×) + const leftHtml=[ + ...myLate.map(x=>{lt+=PT(x)*3;return pill(x,"late","l");}), + ...myToday.map(x=>{lt+=PT(x)*2;return pill(x,"today","l");}), + ...upcoming.map(x=>{lt+=PT(x)*1;return pill(x,"soon","l");})].join(""); + const rightHtml=banked.map(x=>{rt+=PT(x)*2;return pill(x,"done","r");}).join(""); + let tilt=Math.max(-9,Math.min(9,(lt-rt)*0.8)); + document.getElementById("myday").innerHTML=` +
+
+
${leftHtml}${rightHtml}
+
+
+
+
+
late · due today${HZ>0?` · ${({7:"due this week",21:"due in 3 weeks",42:"due in 6 weeks"})[HZ]}`:""} · ${HZ>0?"done this period":"done today"} ✓
`; + sizeScale(); + settleScale(lastTilt); // position pills synchronously, then animate to the new tilt + if(typeof requestAnimationFrame!=="undefined") + requestAnimationFrame(()=>requestAnimationFrame(()=>applyTilt(tilt))); + lastTilt=tilt; +} +const SPLIT_MQ="(min-width:1100px) and (orientation:landscape)"; +function applyTilt(tilt){ + const b=document.getElementById("beamEl"); if(!b) return; + b.style.transform=`translateX(-50%) rotate(${-tilt}deg)`; + settleScale(tilt); +} +/* physics layout, in plank coordinates: u = distance along the beam, v = height above it. + Pills lie parallel to the plank and slide along it; on the dipping side they jam + against the card wall (which, seen from the plank, is a slanted line — so the wall + column staircases: each box rests a little further up-plank than the one below it). + On the raised side they slide down against the fulcrum. */ +function layoutScale(tilt){ + const wrap=document.getElementById("scaleEl"), beam=document.getElementById("beamEl"), + pile=document.getElementById("pileEl"); + if(!wrap||!beam||!pile) return 999; + const W=wrap.clientWidth||600, H=wrap.clientHeight||235; + const B=beam.offsetWidth||W*0.96, C=B/2; + const tan=Math.tan(Math.abs(tilt)*Math.PI/180); + const els=s=>[...pile.querySelectorAll(".pill.side-"+s)]; + const boxes=[]; + const place=(list,wall,dir,limit,stairs)=>{ + const base=84+(Math.min(Math.abs(tilt),9)/9)*Math.max(H-230,0); // steeper tilt → taller jam + let i=0,col=0,u=wall; + while(i0?u>limit-170:u0&&v+h>cap) break; // column full → next column up-plank + let u0=dir>0?u:u-w; + if(stairs) u0=dir>0?Math.max(u0,wall+v*tan):Math.min(u0,wall-w-v*tan); // wall staircase + u0=dir>0?Math.min(Math.max(u0,0),limit-w):Math.max(Math.min(u0,B-w),limit); + el.style.left=u0+"px"; el.style.bottom=(7+v)+"px"; + boxes.push({u:u0,v,w,h}); + v+=h+5; colW=Math.max(colW,w); i++; + } + u+=dir*(colW+8); col++; + } + }; + const FW=36; // fulcrum keep-out + if(tilt>=-0.5) place(els("l"),0,1,C-FW,tilt>0.5); else place(els("l"),C-FW,-1,0,false); + if(tilt<= 0.5) place(els("r"),B,-1,C+FW,tilt<-0.5); else place(els("r"),C+FW,1,B,false); + // highest pile point in screen space (used to grow the stacked card) + const phi=-tilt*Math.PI/180, cy=H-70; + let minTop=cy; + boxes.forEach(b=>{const yl=-(b.v+b.h+7); + [b.u-C,b.u+b.w-C].forEach(xl=>{ + const y=cy+xl*Math.sin(phi)+yl*Math.cos(phi); + if(yt<0?t:(t<1?t*TW:TW+(t-1)); // day → stretched-day units +const gx=t=>(uDay(t)-R0G)/SPAN_EFFV*100; // day → % position on the track +/* a due date means END of that day, and open work never lives in the past: + — done tasks keep their historical span + — late and due-today tasks ALL span exactly the today box, ending ON the today line + — future tasks start today at the earliest and end at the end of their due day */ +/* re-renders triggered by clicks are deferred out of the input event: mutating the DOM + while Chrome is still dispatching the click can wedge its hover/input pipeline + (frozen cursor + dead hover until a tab switch) */ +const defer=fn=>setTimeout(fn,0); +function toggleShowDone(){ showDone=!showDone; const b=$id("gdonebtn"); if(b)b.classList.toggle("on",showDone); + defer(renderGantt); } +/* the control cluster is FIXED to the viewport so it stays put while the chart scrolls under it. + At the top it sits just under the date ribbon; as the ribbon scrolls away it rises to the top. */ +function placeFloat(){ const g=document.querySelector(".gantt"), fl=$id("gfloat"); + if(!g||!fl) return; const gr=g.getBoundingClientRect(), ax=document.querySelector(".gaxis"); + let top=gr.top+10; + if(ax){ const ar=ax.getBoundingClientRect(); top=Math.max(top,ar.bottom+8); } + fl.style.position="fixed"; + fl.style.right=Math.max(14,(window.innerWidth-gr.right)+14)+"px"; + fl.style.top=top+"px"; } +window.addEventListener("resize",()=>placeFloat()); +const EXP=new Set(); // individually EXPANDED tasks (when the global toggle is off) +const COL=new Set(); // individually COLLAPSED tasks (when the global toggle is on) +/* a task's subtasks are open if: global toggle on AND not individually collapsed, OR + global toggle off AND individually expanded — so the chevron always works either way */ +const subOpen=id=>subsAll?!COL.has(id):EXP.has(id); +function toggleExp(id){ if(subsAll){ COL.has(id)?COL.delete(id):COL.add(id); } + else { EXP.has(id)?EXP.delete(id):EXP.add(id); } defer(renderGantt); } +let subsAll=false; // global show/hide all subtasks +function toggleSubs(){ subsAll=!subsAll; COL.clear(); // start each "show all" fully expanded + const b=$id("gsubbtn"); if(b)b.classList.toggle("on",subsAll); + defer(renderGantt); } +let focusToday=false; // show only today's priorities (late + due today + started) +function toggleFocus(){ focusToday=!focusToday; const b=$id("gfocusbtn"); if(b)b.classList.toggle("on",focusToday); + defer(renderGantt); } +let GVIEW="proj"; // "proj" | "tasks" | "subs" +function setGView(v){ GVIEW=v; defer(renderGantt); } +function setZoom(i){ ZOOM=i; setTimeout(renderAll,0); } // drives the scale window and the gantt zoom +function renderGantt(){ + const VIS=ZOOMS[ZOOM].v; + // fixed-width today box: convert TODAY_PX into day units for the current zoom + panel width. + // On phones the box is narrower so the rest of the timeline isn't squeezed off-screen. + const TPX=(typeof window!=="undefined"&&window.innerWidth<=740)?150:TODAY_PX; + const host=document.querySelector(".gscroll")||document.getElementById("gantt"); + const PW=Math.max((host&&host.clientWidth)||0,360); + const dayPx=Math.max((PW-TPX)/Math.max(VIS-1,1),3); + TW=TPX/dayPx; SPAN_EFFV=SPAN_G+(TW-1); + // calendar axis: month headers + one weekday-letter + number per day (weekly when too tight) + const showDaily=dayPx>=20; + const months=[]; let lastM=-1; + for(let d=0;d<=R1G;d++){ const dt=new Date(dayIso(d)), m=dt.getFullYear()*12+dt.getMonth(); + if(m!==lastM){ lastM=m; months.push({d,label:dt.toLocaleDateString("en-GB",{month:"short",year:"numeric"})}); } } + const dticks=[]; + for(let d=0;d<=R1G;d++){ const dt=new Date(dayIso(d)), wknd=dt.getDay()%6===0; + if(showDaily||dt.getDay()===1||d===0) dticks.push({d,wd:"SMTWTFS"[dt.getDay()],num:dt.getDate(),wknd,today:d===0}); } + const zoomEl=document.getElementById("gzoom"); + if(zoomEl){ // build once; afterwards only toggle classes (keeps the clicked button alive) + if(!zoomEl.childElementCount) + zoomEl.innerHTML=ZOOMS.map((z,i)=>``).join(""); + [...zoomEl.children].forEach((b,i)=>b.classList.toggle("active",ZOOM===i)); + } + // keep the three toggle pictograms lit in line with their state + [["gdonebtn",showDone],["gsubbtn",subsAll],["gfocusbtn",focusToday]].forEach(([id,on])=>{ + const b=document.getElementById(id); if(b) b.classList.toggle("on",on); }); + const gv=document.getElementById("gview"), GVKEYS=["proj","tasks"]; + if(gv){ + if(!gv.childElementCount) + gv.innerHTML=[["proj","By project"],["tasks","Prioritized tasks"]] + .map(([k,l])=>``).join(""); + [...gv.children].forEach((b,i)=>b.classList.toggle("active",GVIEW===GVKEYS[i])); + } + // calendar grid: faint day lines, firmer Monday lines, alternate weeks washed + const wk=[0]; + for(let d=1;d<=R1G;d++) if(new Date(dayIso(d)).getDay()===1) wk.push(d); + wk.push(R1G); + const deco=[]; + for(let i=0;i`); + for(let d=1;d`); } + months.forEach(m=>{ if(m.d>0) deco.push(`
`); }); + const td=new Date(dayIso(0)); + const ord=n=>{const s=["th","st","nd","rd"],v=n%100;return n+(s[(v-20)%10]||s[v]||s[0]);}; + const todayStr="Today, "+td.toLocaleDateString("en-GB",{weekday:"long"})+" "+ord(td.getDate())+" "+td.toLocaleDateString("en-GB",{month:"long"}); + const todayMid=(gx(0)+gx(1))/2; + const rows=[deco.join("")+ + `
+
+
${months.map((m,i)=>{const end=i+1${m.label}`;}).join("")}
+
${dticks.map(t=> t.today + ? `${todayStr}` + : `${t.wd}${t.num}`).join("")}
`]; + let any=false; + /* ctx (priority views only): {proj, parent, col} — bar takes the project colour and the + tooltip carries the full story: where it lives, how big, how important */ + const barRow=(n,extra,isSub,ctx)=>{ // one chart row for a task or a subtask + if(!n.due) return ""; + const {s,e}=spanFor(n), done=isSub?n.done:taskDone(n); + if(eR1G) return ""; + const late=!done&&e<0, [tcs,tce]=barGeom(s,e,done), sz=n.size||"m"; + const h=isSub?Math.max(15,Math.round(GBAR_H[sz]*0.62)):GBAR_H[sz]; // subtasks shorter than tasks + // a task with subtasks shows its duration-weighted completion as a darker fill inside its + // own bar (same two-tone idea as the project summary bar, applied in place) + const hasKids=!isSub&&n.children.length>0, donePct=hasKids?Math.round(progWD(n)*100):0; + const col=barColor(e,s,done); + const fillBg=(hasKids&&!done&&donePct>0) + ? `background-color:${col};background-image:linear-gradient(90deg,rgba(0,0,0,.26) 0 ${donePct}%,rgba(0,0,0,0) ${donePct}% 100%)` + : `background:${col}`; + const tip=`${n.title} · ${SIZE_NAMES[sz]} · ${fmtD(dayIso(e))}${late?' (late)':''}${hasKids?` · ${donePct}% done`:""}`+(ctx + ?` — ${ctx.proj}${ctx.parent?" › "+ctx.parent:""} · ${({high:"high",med:"medium",low:"low"})[n.priority||"med"]} priority · ${PEOPLE[n.owner].name}` + :""); + return `
+
+ + ${av(n.owner,"xs")} + ${n.title} + + +
${extra?`
${extra}
`:""} +
`; + }; + const chevFor=t=>{ const exp=subOpen(t.id); + return t.children.length?``:""; }; + // subtask visibility is controlled only by Show subtasks / per-task expansion — NOT by focus. + // When focus is on AND subtasks are visible, only the urgent subtasks are included. + const subRows=(t,ctx)=>(!subOpen(t.id))?"":t.children + .filter(c=>want(c.owner)&&(showDone||!c.done)&&(!focusToday||isUrgent(c))) + .map(c=>barRow(c,"",true,ctx?{proj:ctx.proj,parent:t.title,col:ctx.col}:null)).join(""); + if(GVIEW==="proj") DATA.forEach(p=>{ + let open=0, maxE=1, anyLeaf=false; + flat([p],n=>{ if(n.children.length||!want(n.owner)) return; + anyLeaf=true; + if(!n.done) open++; + if(n.due) maxE=Math.max(maxE,dayN(n.due)); }); + if(!anyLeaf&&!want(p.owner)) return; + const col=PEOPLE[p.owner].color; + // thin summary bar spanning the project's whole task range (earliest start → latest due) + const sp=rollupSpan(p); + const scs=Math.max(sp.s,R0G), sce=Math.max(Math.min(sp.e+1,R1G),scs+0.5); + const taskRows=[...p.children] + .filter(t=>{ let rel=want(t.owner); + flat([t],x=>{ if(!x.children.length&&want(x.owner)) rel=true; }); return rel; }) + .filter(t=>showDone||!taskDone(t)) + .filter(t=>!focusToday||isUrgent(t)) + /* render in the project's own task order (no date sort) so manual reordering — + from the project window or by dragging a bar — is reflected directly */ + .map(t=>barRow(t,chevFor(t),false,null)+subRows(t,null)).join(""); + if(focusToday&&!taskRows) return; // nothing urgent in this project — hide it + any=true; + const prog=progWD(p), ppc=Math.round(prog*100), spanW=gx(sce)-gx(scs); + // project bar thickness scales with the project's total weight (sum of its leaf size points) + let pPts=0; flat([p],x=>{ if(x.children.length) return; pPts+=SIZE_PTS[x.size||"m"]; }); + const ph=Math.max(7,Math.min(20,Math.round(5+Math.sqrt(pPts)*2.2))); + rows.push(`
+
+ +
+
+ ${ppc}% +
+ ${taskRows}
`); + }); + else{ + // priority views: project boxes drop away; the most urgent & biggest work floats to the top + const wantTask=GVIEW==="tasks", PRW={high:0,med:1,low:2}, cand=[]; + flat(DATA,(n,depth,path)=>{ + if(wantTask?depth!==1:n.children.length>0) return; + let rel=want(n.owner); + if(wantTask&&!rel) flat([n],x=>{ if(!x.children.length&&want(x.owner)) rel=true; }); + if(!rel) return; + if((wantTask?taskDone(n):n.done)&&!showDone) return; + if(!n.due) return; + const {s,e}=barSpan(n); + if(eR1G) return; + const root=path[0]||n, par=path.length>1?path[path.length-2]:null; + cand.push({n,e,root,par}); + }); + cand.sort((a,b)=>a.e-b.e + ||SIZE_PTS[b.n.size||"m"]-SIZE_PTS[a.n.size||"m"] + ||PRW[a.n.priority||"med"]-PRW[b.n.priority||"med"]); + any=cand.length>0; + rows.push('
'); + cand.forEach(({n,root,par})=>{ + const ctx={proj:root.title,col:PEOPLE[root.owner].color, + parent:(!wantTask&&par&&par!==root)?par.title:null}; + rows.push(barRow(n,wantTask?chevFor(n):"",!wantTask,ctx)); + if(wantTask) rows.push(subRows(n,ctx)); + }); + rows.push('
'); + } + document.getElementById("gantt").innerHTML= + `
`+ + rows.join("")+ + (any?"":'
No scheduled tasks for this filter.
')+ + `
`; + const sc=document.querySelector(".gscroll"); + sc.addEventListener("scroll",pinFlags,{passive:true}); + const gpane=document.querySelector(".gantt"); + if(gpane&&!gpane._floatBound){ gpane._floatBound=true; gpane.addEventListener("scroll",placeFloat,{passive:true}); } + pinFlags(); placeFloat(); placeOverflowTitles(); + // unstick Chromium's hover hit-testing after the DOM swap (otherwise tooltips/hover + // stay dead until you move the mouse or switch tabs) + if(typeof requestAnimationFrame!=="undefined") + requestAnimationFrame(kickHover); +} +/* After replacing #gantt's innerHTML, nudge native :hover (the done-dot and resize ears) back + to life with a synchronous reflow. The TOOLTIP is intentionally NOT synthesized here — it is + driven only by real hover events (see the hover module below). Synthesizing it from + elementFromPoint made the tip pop on clicks (e.g. when a filter flyout closed and the + hit-test fell through to a bar behind it). We only clear a now-stale tip. */ +function kickHover(){ + try{ + document.body.style.pointerEvents="none"; + void document.body.offsetHeight; // force synchronous reflow → refresh native :hover + document.body.style.pointerEvents=""; + }catch(e){} + if(typeof tipEl!=="undefined" && tipEl && !tipEl.isConnected) hideTip(); // hovered bar was replaced +} +/* keep project names visible: if a flag's pole is outside the scrolled viewport, + pin the flag to the nearest edge with an arrow; it snaps back when the pole returns */ +/* when a bar is too narrow to show even half its title, hide the inner label and print the + full title as plain text just to the right of the bar (no background) */ +function placeOverflowTitles(){ + document.querySelectorAll("#gantt .gttlout").forEach(x=>x.remove()); + document.querySelectorAll("#gantt .gtrack").forEach(track=>{ + track.querySelectorAll(":scope > .gbar").forEach(bar=>{ + const ttl=bar.querySelector(".ttl"); if(!ttl) return; + const full=ttl.scrollWidth, vis=ttl.clientWidth; + if(full>4 && vis{ + const lbl=row.querySelector(".gsumlbl"), line=row.querySelector(".gsumline"); + if(!lbl||!line) return; + // keep the project name visible: slide the label to the viewport's left edge as its + // range line scrolls past, but never beyond the line's right end + const ll=line.offsetLeft, lr=ll+line.offsetWidth; + let left=Math.max(ll,v0+4); + left=Math.min(left,Math.max(lr-46,ll)); + lbl.style.left=left+"px"; + }); +} +window.addEventListener("resize",()=>{ sizeScale(); applyTilt(lastTilt); defer(renderGantt); }); + +/* --- floating "gripped pill" ghost, shared by bar drags and pop-up task drags --- */ +function makeGhost(text,color){ const g=document.createElement("div"); + g.className="dragghost"; g.textContent=text; + if(color) g.style.background=color; + document.body.appendChild(g); return g; } +function placeGhost(g,e){ g.style.left=(e.clientX+16)+"px"; g.style.top=(e.clientY-34)+"px"; } +const rootOf=id=>findPath(id)[0]; +function projUnder(e,dragId){ + const t=document.elementFromPoint(e.clientX,e.clientY)?.closest?.(".pgroup[data-pid]"); + return t&&+t.dataset.pid!==rootOf(dragId).id?t:null; } +function dropInto(groupEl,id){ const node=detach(id); + findPath(+groupEl.dataset.pid).pop().children.push(node); } + +/* --- bar dragging: move / resize ears / drop on a project pill --- */ +let G=null, suppressCtx=0; +const siblingsOf=id=>{ const p=findPath(id); p.pop(); const par=p.pop(); return par?par.children:DATA; }; +/* right-click (desktop) opens the quick menu — but a touch long-press also fires contextmenu, + and that case already opened the detail popup, so swallow it for ~0.8s after a long-press */ +function barContext(ev,id,rect){ ev.preventDefault(); + if(Date.now()-suppressCtx<800) return; openBarMenu(id,rect); } +function barDown(e,id,mode){ + if(e.button!==undefined && e.button>0) return; // right/middle mouse → let oncontextmenu open the quick menu + e.preventDefault(); e.stopPropagation(); hideTip(); + const el=e.target.closest(".gbar"), track=el.parentElement; + const n=findPath(id).pop(), {s,e:en}=barSpan(n); + const touch=e.pointerType==="touch"; + el.classList.add("dragging"); + G={id,mode,el,n,touch,longTimer:null,ppd:track.getBoundingClientRect().width/SPAN_EFFV, + x0:e.clientX,y0:e.clientY,s0:s,e0:en,s,e:en,moved:false,overProj:null, + axis:mode==="move"?null:"date",reTo:null,reRow:null, // move starts axis-undecided + ghost:mode==="move"?makeGhost(n.title,el.style.background):null}; + if(G.ghost) G.ghost.style.display="none"; // appears once the bar actually moves + // touch: a LONG-PRESS on a bar opens the full detail popup (a short tap opens the quick menu) + if(touch && mode==="move"){ + G.longTimer=setTimeout(()=>{ if(!G||G.moved) return; + document.removeEventListener("pointermove",barMove); + document.removeEventListener("pointerup",barUp); + document.removeEventListener("pointercancel",barUp); + G.el.classList.remove("dragging"); G.el.style.opacity=""; if(G.ghost) G.ghost.remove(); + const nid=G.id; G=null; suppressCtx=Date.now(); // swallow the contextmenu this long-press will also fire + if(navigator.vibrate){ try{navigator.vibrate(8);}catch(_){} } openDetail(nid); + },480); + } + document.addEventListener("pointermove",barMove,{passive:false}); + document.addEventListener("pointerup",barUp); + document.addEventListener("pointercancel",barUp); +} +function clearReMark(){ document.querySelectorAll(".grow.reinsb,.grow.reinsa") + .forEach(x=>x.classList.remove("reinsb","reinsa")); } +/* vertical drag of a bar = reorder among its siblings (or drop into another project) */ +function barReorderMove(ev){ + G.moved=true; G.el.style.opacity=".45"; + if(G.ghost){ G.ghost.style.display=""; G.ghost.textContent="⇅ "+G.n.title; placeGhost(G.ghost,ev); } + clearReMark(); G.reTo=null; G.reRow=null; + if(G.overProj){ G.overProj.classList.remove("gdropover"); G.overProj=null; } + const el=document.elementFromPoint(ev.clientX,ev.clientY); + const bar=el?.closest?.(".gbar[data-tid]"), sibs=siblingsOf(G.id); + if(bar&&+bar.dataset.tid!==G.id&&sibs.some(s=>s.id===+bar.dataset.tid)){ + const row=bar.closest(".grow"), r=bar.getBoundingClientRect(), after=ev.clientY>r.top+r.height/2; + row.classList.add(after?"reinsa":"reinsb"); G.reRow=row; + G.reTo=sibs.findIndex(s=>s.id===+bar.dataset.tid)+(after?1:0); + } else { // not over a sibling → offer to move into another project + const t=projUnder(ev,G.id); G.overProj=t; if(t) t.classList.add("gdropover"); + } +} +function barMove(ev){ + if(!G) return; + ev.preventDefault(); + // any real movement cancels a pending long-press (it's a drag, not a press) + if(G.longTimer&&(Math.abs(ev.clientX-G.x0)>5||Math.abs(ev.clientY-G.y0)>5)){ clearTimeout(G.longTimer); G.longTimer=null; } + if(G.axis===null){ // decide intent on first real movement + const dx=ev.clientX-G.x0, dy=ev.clientY-G.y0; + if(Math.max(Math.abs(dx),Math.abs(dy))<5) return; + G.axis=Math.abs(dy)>Math.abs(dx)*1.25?"reorder":"date"; + } + if(G.axis==="reorder") return barReorderMove(ev); + const dd=Math.round((ev.clientX-G.x0)/G.ppd); + if(dd!==0) G.moved=true; + if(G.mode==="move"){ G.s=G.s0+dd; G.e=G.e0+dd; } + else if(G.mode==="l"){ G.s=Math.min(G.s0+dd,G.e0); } + else { G.e=Math.max(G.e0+dd,G.s0); } + const [cs,ce]=barGeom(G.s,G.e,G.n.done); + G.el.style.left=gx(cs)+"%"; + G.el.style.width=(gx(ce)-gx(cs))+"%"; + if(G.ghost){ G.ghost.style.display=G.moved?"":"none"; + G.ghost.textContent=G.n.title+" · due "+fmtD(dayIso(G.e)); + placeGhost(G.ghost,ev); } + if(G.mode==="move"){ + const t=projUnder(ev,G.id); + if(G.overProj&&G.overProj!==t) G.overProj.classList.remove("gdropover"); + G.overProj=t; if(t) t.classList.add("gdropover"); + } +} +function barUp(e){ + document.removeEventListener("pointermove",barMove); + document.removeEventListener("pointerup",barUp); + document.removeEventListener("pointercancel",barUp); + if(!G) return; // long-press already handled it + if(G.longTimer){ clearTimeout(G.longTimer); G.longTimer=null; } + G.el.classList.remove("dragging"); G.el.style.opacity=""; + if(G.ghost) G.ghost.remove(); + clearReMark(); + if(e&&e.type==="pointercancel"){ if(G.overProj)G.overProj.classList.remove("gdropover"); G=null; return; } + // vertical reorder / cross-project move + if(G.axis==="reorder"){ + const dropped=G.overProj; if(dropped) dropped.classList.remove("gdropover"); + if(dropped&&fitsDepth(G.n,+dropped.dataset.pid)){ snap(); dropInto(dropped,G.id); G=null; ding(2); renderAll(); return; } + const arr=siblingsOf(G.id), from=arr.findIndex(s=>s.id===G.id); let to=G.reTo; + if(to!=null&&from>-1){ if(to>from) to--; if(to!==from){ snap(); const [nd]=arr.splice(from,1); arr.splice(to,0,nd); ding(2); } } + G=null; renderAll(); return; + } + const n=G.n, dropped=G.overProj; + if(dropped) dropped.classList.remove("gdropover"); + if(!G.moved&&!dropped){ // a tap/click that didn't drag + const r=G.el.getBoundingClientRect(), touch=G.touch, mode=G.mode, nid=n.id; G=null; + if(mode!=="move") return; // tap on a resize ear → do nothing + if(touch) openBarMenu(nid,r); // mobile: tap = quick menu (long-press already gives the popup) + else openDetail(nid); // desktop: left-click = full detail popup + return; } + snap(); + if(G.mode==="move"){ n.due=dayIso(G.e); if(n.start) n.start=dayIso(G.s); } + else if(G.mode==="l"){ n.start=dayIso(G.s); } + else { if(!n.start) n.start=dayIso(G.s0); n.due=dayIso(G.e); } + if(dropped) dropInto(dropped,n.id); + G=null; ding(2); renderAll(); +} + +/* --- grip a row in a pop-up. Stay inside the pop-up → reorder the list. + Drag outside it → the pop-up closes and you assign the item to another + project (drop on its box) or task (drop on its bar), depth rules permitting. --- */ +let RD=null; +function rowDown(e,id){ + e.preventDefault(); e.stopPropagation(); hideTip(); + const path=findPath(id), n=path[path.length-1], parent=path[path.length-2]; + RD={id,n,parentId:parent?parent.id:null,box:document.querySelector(".tbox"), + mode:"reorder",over:null,toIdx:null, + ghost:makeGhost(n.title,PEOPLE[n.owner].color)}; + placeGhost(RD.ghost,e); + document.addEventListener("pointermove",rowMove,{passive:false}); + document.addEventListener("pointerup",rowUp); + document.addEventListener("pointercancel",rowUp); +} +function clearRowMark(){ document.querySelectorAll(".ptask.insb,.ptask.insa") + .forEach(x=>x.classList.remove("insb","insa")); } +function rowMove(e){ + e.preventDefault(); placeGhost(RD.ghost,e); + if(RD.mode==="reorder"){ + const r=RD.box&&RD.box.getBoundingClientRect(); + if(!r||e.clientXr.right||e.clientYr.bottom){ + RD.mode="assign"; RD.over=null; RD.toIdx=null; + clearRowMark(); closeSheet(); // left the pop-up → reveal the timeline targets + }else{ + clearRowMark(); RD.over=null; RD.toIdx=null; + const t=document.elementFromPoint(e.clientX,e.clientY)?.closest?.(".ptask[data-cid]"); + if(t&&+t.dataset.cid!==RD.id){ + const tr=t.getBoundingClientRect(), after=e.clientY>tr.top+tr.height/2; + t.classList.add(after?"insa":"insb"); + RD.over=t; + RD.toIdx=[...RD.box.querySelectorAll(".ptask[data-cid]")].indexOf(t)+(after?1:0); + } + return; + } + } + // assign mode: task bars first (finer target), then project boxes + const el=document.elementFromPoint(e.clientX,e.clientY); + let t=el?.closest?.(".gbar[data-tid]")||null; + if(t){ const tid=+t.dataset.tid; + if(tid===RD.id||tid===RD.parentId||contains(RD.n,tid)||!fitsDepth(RD.n,tid)) t=null; } + if(!t){ const g=el?.closest?.(".pgroup[data-pid]"); + if(g){ const pid=+g.dataset.pid; + if(pid!==RD.parentId&&fitsDepth(RD.n,pid)) t=g; } } + if(RD.over&&RD.over!==t) RD.over.classList.remove("gdropover"); + RD.over=t; if(t) t.classList.add("gdropover"); +} +function rowUp(){ + document.removeEventListener("pointermove",rowMove); + document.removeEventListener("pointerup",rowUp); + document.removeEventListener("pointercancel",rowUp); + clearRowMark(); RD.ghost.remove(); + const S=RD; RD=null; + if(S.mode==="reorder"){ + if(S.toIdx!=null&&S.parentId!=null){ + const parent=findPath(S.parentId).pop(); + const from=parent.children.findIndex(c=>c.id===S.id); + let to=S.toIdx; if(to>from) to--; + if(from>-1&&to!==from){ snap(); + const [nd]=parent.children.splice(from,1); + parent.children.splice(to,0,nd); + ding(2); renderAll(); openDetail(S.parentId); } + } + return; // released inside the pop-up: nothing moved, pop-up stays open + } + if(S.over){ S.over.classList.remove("gdropover"); + snap(); const node=detach(S.id); + findPath(+(S.over.dataset.tid||S.over.dataset.pid)).pop().children.push(node); + ding(2); renderAll(); } +} + +/* --- drag a project flag vertically to reorder projects; a plain click opens the popup --- */ +let PD=null; +function projDown(e,pid){ + e.preventDefault(); e.stopPropagation(); hideTip(); + PD={pid,started:false,x0:e.clientX,y0:e.clientY,over:null,pos:null,ghost:null}; + document.addEventListener("pointermove",projMove,{passive:false}); + document.addEventListener("pointerup",projUp); + document.addEventListener("pointercancel",projUp); +} +function clearProjMark(){ document.querySelectorAll(".pgroup.insb,.pgroup.insa") + .forEach(x=>x.classList.remove("insb","insa")); } +function projMove(e){ + e.preventDefault(); + if(!PD.started){ + if(Math.hypot(e.clientX-PD.x0,e.clientY-PD.y0)<6) return; // click tolerance + PD.started=true; + const p=DATA.find(x=>x.id===PD.pid); + PD.ghost=makeGhost("⇅ "+p.title,PEOPLE[p.owner].color); + } + placeGhost(PD.ghost,e); + clearProjMark(); PD.over=null; + const t=document.elementFromPoint(e.clientX,e.clientY)?.closest?.(".pgroup[data-pid]"); + if(t&&+t.dataset.pid!==PD.pid){ + const r=t.getBoundingClientRect(); + PD.pos=e.clientYx.id===pid), [proj]=DATA.splice(from,1); + let to=DATA.findIndex(x=>x.id===+over.dataset.pid); + if(pos==="after") to++; + DATA.splice(to,0,proj); + ding(2); renderAll(); +} + +/* ---- moving tasks between projects/parents (via modal "Move to") ---- */ +/* strict 3-level hierarchy: project (0) -> task (1) -> subtask (2) */ +function detach(id){ const p=findPath(id), node=p.pop(), parent=p.pop(); + const arr=parent?parent.children:DATA; arr.splice(arr.indexOf(node),1); return node; } +function moveInto(id,dest){ if(!dest||id===dest||contains(findPath(id).pop(),dest))return false; + if(!fitsDepth(findPath(id).pop(),dest)) return false; // would exceed 3 levels + const target=findPath(dest).pop(); const node=detach(id); + target.children.push(node); target.open=true; return true; } +function moveTask(id,dest){ snap(); + if(moveInto(id,+dest)){ renderAll(); openDetail(id); } else UNDO.pop(); } + +function toggleDone(id){ snap(); const n=findPath(id).pop(); + const stamp=x=>x.doneAt=x.done?TODAY.toISOString().slice(0,10):null; + if(!n.children.length){ n.done=!n.done; stamp(n); } + else { const target=pct(n)!==100; flat([n],x=>{if(!x.children.length){x.done=target;stamp(x);}}); } + renderAll(); } + +/* ================= task modal — comprehensive & editable ================= */ +/* ===== on-the-go bar menu: quick edits without the full detail sheet ===== */ +let BARMENU=null; +const BM=document.createElement("div"); BM.id="barMenu"; BM.className="barmenu"; document.body.appendChild(BM); +function openBarMenu(id,anchor){ + const path=findPath(id); if(!path) return; const n=path.pop(); + if(anchor&&anchor.getBoundingClientRect) anchor=anchor.getBoundingClientRect(); + if(anchor) BM._anchor={left:anchor.left,right:anchor.right,top:anchor.top,bottom:anchor.bottom}; + const a=BM._anchor||{left:100,right:160,top:100,bottom:130}; + BM.innerHTML=` +
Owner
+
${Object.entries(PEOPLE).map(([k,p])=>``).join("")}
+
Size${["s","m","l","xl"].map(z=>``).join("")}
+
Due
`; + BM.classList.add("show"); BARMENU=id; + const mw=BM.offsetWidth||236, mh=BM.offsetHeight||170, gap=8, vw=window.innerWidth, vh=window.innerHeight; + // sit to the RIGHT of the pill (flip left only if there's no room) + let left=a.right+gap; if(left+mw>vw-8) left=Math.max(8,a.left-mw-gap); + // align with the pill's top and roll down; if that would overflow, align to its bottom and roll up + let top=(a.top+mh<=vh-8)?a.top:Math.max(8,a.bottom-mh); + top=Math.max(8,Math.min(top,vh-mh-8)); + BM.style.left=left+"px"; BM.style.top=top+"px"; +} +function refreshBarMenu(id){ if(BARMENU===id) openBarMenu(id); } +function closeBarMenu(){ BM.classList.remove("show"); BARMENU=null; } +document.addEventListener("pointerdown",e=>{ if(BARMENU&&!e.target.closest("#barMenu")) closeBarMenu(); },true); + +function updTask(id,f,v,quiet){ snap(); const n=findPath(id).pop(); + if(f==="title") n.title=v.trim()||n.title; + else if(f==="owner") n.owner=v; + else if(f==="priority") n.priority=v; + else if(f==="due") n.due=v||null; + else if(f==="start") n.start=v||null; + else if(f==="size") n.size=v||null; + renderAll(); if(!quiet&&f!=="title") openDetail(id); } +function deleteTask(id){ const n=findPath(id).pop(); + if(typeof confirm!=="undefined"&&!confirm('Delete "'+n.title+'"'+(n.children.length?" and its subtasks":"")+"?")) return; + snap(); detach(id); closeSheet(); renderAll(); } +function addChild(id){ const el=document.getElementById("dSubNew"), v=el.value.trim(); if(!v) return; + if(findPath(id).length>=3) return; // subtasks can't have children + snap(); const n=findPath(id).pop(); n.children.push(T(cap1(v),n.owner,{d:n.due||null})); + renderAll(); openDetail(id); } +function openDetail(id){ + const path=findPath(id); if(!path) return; + const n=path[path.length-1], leaf=!n.children.length; + document.getElementById("dCrumb").innerHTML=path.length>1 + ?path.slice(0,-1).map(x=>``).join(" › ")+" ›" + :"Project"; + const ti=document.getElementById("dTitle"); + ti.value=n.title; ti.onchange=e=>updTask(id,"title",e.target.value); + const par=path.length>1?path[path.length-2].id:null; + const mopts=['']; + flat(DATA,(x,depth)=>{ if(contains(n,x.id))return; + if(depth+1+heightOf(n)>2) return; // keep the 3-level hierarchy + mopts.push(``); }); + // Size: leaves carry an editable t-shirt size; projects/parent tasks SHOW the rolled-up + // point total (sum of their leaves' size points) — not a field the user fills in. + let _szPts=0; flat([n],x=>{ if(x.children.length) return; _szPts+=SIZE_PTS[x.size||"m"]; }); + const sizeFld=leaf + ? `
Size
` + : `
Size${_szPts} pts
`; + document.getElementById("dBody").innerHTML=` +
Owner${av(n.owner)}
+
Due${dueChip(n.due,leaf&&n.done)}
+ ${sizeFld} + ${leaf?`
Status +
`:""} +
Move to
+ ${path.length>=3?"":`
${path.length>1?"Subtasks":"Tasks — grip ⠿ to drag onto another project"}
`} + ${n.children.map(ch=>{ const lp=pct(ch), lleaf=!ch.children.length; + let _cp=0; flat([ch],x=>{ if(x.children.length) return; _cp+=SIZE_PTS[x.size||"m"]; }); + const szCtl=lleaf + ? `` + : `${_cp} pts`; + return `
+ + + + ${ownerPill(ch.owner,`updTask(${ch.id},'owner',this.value,true);openDetail(${id})`)} + ${szCtl} + ${dueChip(ch.due,lleaf&&ch.done)}
`;}).join("")} + ${path.length>=3?"":`
`} + `; + document.getElementById("tmodal").classList.add("show"); + document.getElementById("scrim").classList.add("show"); +} +function closeSheet(){ document.getElementById("tmodal").classList.remove("show"); + document.getElementById("scrim").classList.remove("show"); } + +/* ================= conversational capture ================= */ +/* Real product: each turn's transcript + context (roster, project list, today) is POSTed + to /extract, which calls the LLM (ChatGPT) with a JSON schema and returns structured + fields + the next question. Here callExtract() falls back to a local mock so the whole + UX is demoable; swap EXTRACT_URL for the live endpoint and nothing else changes. */ +const $id=x=>document.getElementById(x); +const EXTRACT_URL=null; // e.g. "/extract" once the serverless function is live + +let actx; +function ding(step=0){ try{ + actx=actx||new (window.AudioContext||window.webkitAudioContext)(); + const o=actx.createOscillator(), g=actx.createGain(), t0=actx.currentTime; + o.type="sine"; o.frequency.setValueAtTime(740+step*80,t0); o.frequency.exponentialRampToValueAtTime(1180+step*80,t0+0.08); + g.gain.setValueAtTime(0.0001,t0); g.gain.exponentialRampToValueAtTime(0.09,t0+0.02); + g.gain.exponentialRampToValueAtTime(0.0001,t0+0.35); + o.connect(g); g.connect(actx.destination); o.start(t0); o.stop(t0+0.4); +}catch(e){} } + +let CAP=null, rec=null, listening=false, capTTS=false, capLang="en"; // input language; output is always English +function toggleCapLang(){ capLang=capLang==="en"?"fr":"en"; + $id("langBtn").textContent="🌐 "+capLang.toUpperCase(); + if(listening){ stopListen(); } } + +function openCapture(){ + CAP={turns:[], history:[], draft:{}, pending:null, ready:false, busy:false}; capQueue=[]; + $id("capChat").innerHTML=""; $id("capInput").value=""; + $id("capCard").className="capcard"; $id("capActions").innerHTML=""; + updateKeyBadge(); renderCapCard({pending:null}); // show the (empty) creation card + botSay("Hi! What would you like to do? For example: “new project Roman Pilot, first task get insurance, due Monday.”"); + $id("vmodal").classList.add("show"); $id("vmodal").classList.remove("min"); + if(!getKey()) askKey(); // offer to connect GPT on first use + else setTimeout(()=>$id("capInput").focus(),50); +} +function minimizeCapture(){ stopListen(); $id("vmodal").classList.add("min"); } +function restoreCapture(){ $id("vmodal").classList.remove("min"); setTimeout(()=>{const i=$id("capInput"); i&&i.focus();},50); } +function closeCapture(){ stopListen(); window.speechSynthesis&&speechSynthesis.cancel(); + $id("vmodal").classList.remove("show","min"); CAP=null; } + +function bubble(text,cls){ const b=document.createElement("div"); + b.className="bub "+cls; b.textContent=text; + const c=$id("capChat"); c.appendChild(b); c.scrollTop=c.scrollHeight; return b; } +let lastBotText=""; +const normCap=s=>(s||"").toLowerCase().replace(/[^a-z0-9 ]/g,"").replace(/\s+/g," ").trim(); +function botSay(text){ lastBotText=text; bubble(text,"bot"); if(capTTS) speak(text); } +function speak(text){ try{ if(!window.speechSynthesis) return; + speechSynthesis.cancel(); const u=new SpeechSynthesisUtterance(text); + u.rate=1.05; u.lang="en-US"; + if(listening){ micPaused=true; try{rec.stop();}catch(e){} } // hard-pause the mic so it can't hear itself + speaking=true; + u.onend=u.onerror=()=>{ speaking=false; + if(listening&&micPaused){ micPaused=false; try{rec.start();}catch(e){} } }; + speechSynthesis.speak(u); }catch(e){ speaking=false; micPaused=false; } } +function toggleTTS(){ capTTS=!capTTS; + $id("ttsBtn").textContent=capTTS?"🔊 Voice on":"🔇 Voice off"; + $id("ttsBtn").classList.toggle("on",capTTS); + if(!capTTS&&window.speechSynthesis) speechSynthesis.cancel(); } + +async function sendTurn(){ + if(!CAP||CAP.busy) return; + const el=$id("capInput"), text=el.value.trim(); if(!text) return; + el.value=""; bubble(text,"me"); CAP.turns.push(text); + CAP.busy=true; const think=bubble("…","bot think"); + const r=await callExtract(text,CAP.draft,CAP.pending); + think.remove(); CAP.busy=false; + CAP.draft=r.draft; CAP.pending=r.pending; CAP.ready=r.ready; + botSay(r.assistantSay); + CAP.history.push({role:"user",content:text},{role:"assistant",content:r.assistantSay}); + if(CAP.history.length>16) CAP.history=CAP.history.slice(-16); + renderCapCard(r); +} + +/* ---- sidebar popovers: search (top) and settings (bottom) ---- */ +function closeSidePops(except){ ["sbsearchpop","sbsettingspop"].forEach(id=>{ if(id!==except){ const p=document.getElementById(id); if(p)p.classList.remove("show"); } }); } +function toggleSearch(){ const p=document.getElementById("sbsearchpop"); if(!p)return; closeSidePops("sbsearchpop"); + const open=p.classList.toggle("show"); if(open){ const i=document.getElementById("searchbox"); if(i){i.focus();i.select&&i.select();} } } +function toggleSettings(){ const p=document.getElementById("sbsettingspop"); if(!p)return; closeSidePops("sbsettingspop"); + const open=p.classList.toggle("show"); if(open){ const i=document.getElementById("setKeyInput"); if(i)i.value=getKey(); } } +function closeSettings(){ const p=document.getElementById("sbsettingspop"); if(p)p.classList.remove("show"); } +/* hide / show the whole left rail; the chart reclaims the space and re-lays out once it settles */ +function toggleSidebar(){ const hidden=document.body.classList.toggle("sbhide"); closeSidePops(); + const b=document.querySelector(".sbtoggle"); if(b) b.title=hidden?"Show sidebar":"Hide sidebar"; + placeFloat(); setTimeout(()=>{ if(typeof renderGantt==="function") renderGantt(); placeFloat(); },240); } + +/* ---- OpenAI key, kept in this browser session only (never written to the file) ---- */ +let OAI_KEY=""; +function getKey(){ try{ return sessionStorage.getItem("oai_key")||OAI_KEY; }catch(e){ return OAI_KEY; } } +function setKeyVal(v){ OAI_KEY=v; try{ v?sessionStorage.setItem("oai_key",v):sessionStorage.removeItem("oai_key"); }catch(e){} } +function askKey(){ const has=!!getKey(); + $id("keyInput").value=""; $id("keyInput").placeholder=has?"Key saved — paste a new one to replace":"sk-…"; + $id("clearKey").style.display=has?"inline-flex":"none"; + $id("keyModal").classList.add("show"); setTimeout(()=>$id("keyInput").focus(),50); } +function saveKey(){ const v=$id("keyInput").value.trim(); if(v) setKeyVal(v); + $id("keyInput").value=""; $id("keyModal").classList.remove("show"); updateKeyBadge(); } +function skipKey(){ $id("keyModal").classList.remove("show"); } +function clearKey(){ setKeyVal(""); $id("keyModal").classList.remove("show"); updateKeyBadge(); } +function updateKeyBadge(){ const b=$id("keyBtn"); if(b) b.textContent=getKey()?"🔑 GPT on":"🔑 Key"; } + +/* swappable extraction: a live endpoint, then OpenAI direct (browser key), then the mock */ +const OWNER_IDS=[...Object.keys(PEOPLE),null]; // owner must be a real teammate id, never free text +const OAI_SCHEMA={type:"object",additionalProperties:false, + required:["intent","project","task","tasks","remove","owner","parentId","due","size","pending","ready","assistantSay"], + properties:{ + remove:{type:"array",items:{type:"string"}}, + intent:{type:["string","null"],enum:["create_project","create_task","create_subtask",null]}, + project:{type:["string","null"]}, task:{type:["string","null"]}, + tasks:{type:"array",items:{type:"object",additionalProperties:false, + required:["title","owner","due","size","subs"], + properties:{title:{type:"string"},owner:{type:["string","null"],enum:OWNER_IDS}, + due:{type:["string","null"]},size:{type:["string","null"],enum:["s","m","l","xl",null]}, + subs:{type:"array",items:{type:"object",additionalProperties:false, + required:["title","owner","due","size"], + properties:{title:{type:"string"},owner:{type:["string","null"],enum:OWNER_IDS}, + due:{type:["string","null"]},size:{type:["string","null"],enum:["s","m","l","xl",null]}}}}}}}, + owner:{type:["string","null"],enum:OWNER_IDS}, parentId:{type:["integer","null"]}, + due:{type:["string","null"]}, size:{type:["string","null"],enum:["s","m","l","xl",null]}, + pending:{type:["string","null"]}, ready:{type:"boolean"}, assistantSay:{type:"string"}}}; +function captureContext(){ return {today:isoCap(TODAY), + people:Object.entries(PEOPLE).map(([id,p])=>({id,name:p.name,responsibility:p.role,aka:p.al})), + hardware:HARDWARE_VOCAB, clients:CLIENTS.map(c=>c.name), + projects:DATA.map(p=>({id:p.id,name:p.title,tasks:p.children.map(t=>({id:t.id,name:t.title}))}))}; } +async function callExtract(text,draft,pending){ + if(EXTRACT_URL){ try{ + const res=await fetch(EXTRACT_URL,{method:"POST",headers:{"Content-Type":"application/json"}, + body:JSON.stringify({text,draft,pending,context:captureContext()})}); + return await res.json(); + }catch(e){ /* fall through */ } } + const key=getKey(); + if(key){ try{ return await openaiExtract(text,draft,pending,key); } + catch(e){ bubble("⚠︎ GPT call failed ("+e.message+") — using offline parser.","bot think"); } } + await new Promise(r=>setTimeout(r,300)); // simulate latency + return mockExtract(text,draft,pending); +} +async function openaiExtract(text,draft,pending,key){ + const sys=`You convert a teammate's spoken/typed request into a structured task-capture object for a 3-level planner (project > task > subtask). +LANGUAGE: the user may speak or type in English OR French — understand both perfectly. ALL OUTPUT MUST BE IN ENGLISH: translate every project/task/subtask name to English, and write assistantSay in English, regardless of the input language. +Rules: +- Merge the NEW utterance into the CURRENT draft; KEEP earlier fields unless the user changes them. Never discard the project the user is building. The user may correct any field at any time ("actually call it X", "change owner to Y"). +- intent is one of create_project / create_task / create_subtask, and stays create_project while the user is still building a new project. +- When intent is create_project, collect the project's tasks in the "tasks" array. ALWAYS return the COMPLETE cumulative list — every task added so far in currentDraft.tasks PLUS any new one this turn. If the user says "add a task / first task / another task ...", append a new item; never drop previously added tasks and never switch intent or use parentId. +- Task titles are concise imperative phrases with NO leading article — "Clean the bathroom", not "a clean the bathroom". Each item has title, plus owner/due/size if stated (else null), plus a "subs" array (empty if none). +- SUBTASKS: the word "subtask" ALWAYS means an item inside some existing task's "subs" array — NEVER a new top-level task, no matter how many are added. When the user says "add subtask(s)" / "add N subtasks": if they name or imply a parent ("for the first task", "under clean the bathroom"), use it; if they DON'T name one, attach the subtasks to the LAST task currently in the tasks array. Return that task's complete subs list. Subtasks are leaves (no further subs). Never increase the number of top-level tasks when the user said "subtask". +- ASSIGNEE INFERENCE: when no owner is explicitly named for a task/subtask, look at the task's CONTENT and assign the teammate whose responsibility best matches it, using this RESPONSIBILITY MAP: +${buildRespMapText()} + Examples: "Install RS03 motor on prototype" → Iannis or Sanket; "Implement obstacle avoidance for the Derichebourg pilot" → Akshat; "Fix D-Wave board power issue" → Leynaïck. Only when nothing in the content maps to a responsibility, fall back to the PROJECT's owner. If the user says "owners same as the project", set every task's and subtask's owner to the project owner (this overrides inference). +- DOMAIN VOCABULARY — use these EXACT spellings; never invent or mis-spell hardware or client names: +${buildVocabText()} + Map mis-heard variants to the canonical form (e.g. "RS zero three"/"RS-3" → "RS03", "dwave" → "D-Wave", "jaycee decaux" → "JCDecaux"). +- When the user gives an ordered list of due dates/owners "in that order" for the tasks, apply them positionally to the tasks in their current order. +- DELETING: you CAN delete. When the user asks to remove/delete a task or subtask (e.g. "delete the two tests you just added", "remove clean the toilet"), put each item's exact current title into the "remove" array. Otherwise "remove" is []. Never say you can't delete. +- Use intent create_task / create_subtask ONLY when adding to a project/task that ALREADY EXISTS in context.projects. Then set parentId to that existing id. +- "task" (singular) is only for create_task/create_subtask; for create_project leave "task" null and use "tasks". +- owner MUST be one of the provided people ids, or null. Names are frequently MIS-HEARD by voice transcription — map any spelling variant or mishearing listed in the responsibility map to the correct id (e.g. "Janice"/"Yannis"/"Ioannis" → Iannis "ia"; "Flo"/"Florine" → Florian "fd"; "Sankeet" → Sanket "sk"). Do NOT assign the work to a different real teammate just because the heard name is fuzzy; if you genuinely cannot resolve it, use null rather than guessing the wrong person. +- due: resolve relative dates ("Monday","tomorrow","in 3 days") to absolute YYYY-MM-DD using context.today; else null. size: s/m/l/xl if stated else null. +- pending = the single most useful field still needed ("projectName","taskTitle","parent","owner"), or null if nothing required is missing. Required: create_project needs project(name); create_task/subtask need task(title) and parentId. +- ready = true when required fields are present (a project is ready once it has a name, even with zero tasks). +- assistantSay = one short, natural sentence confirming what you understood and asking the next thing (or noting it's ready). Talk like a helpful colleague, not a form. If you just appended a task, acknowledge it and invite another or Create. +- Use the prior conversation messages to resolve references: "them/those/all of them", "the first one", "same", "same for the subtasks". "Same for X" / "same for the subtasks" means apply the value MOST RECENTLY set or discussed (e.g. the due date you just applied to the tasks) to X — do NOT guess a different attribute. If the last thing set was a due date, "same for the subtasks" sets that same due date on every subtask. +Return ONLY the JSON object.`; + const history=(typeof CAP!=="undefined"&&CAP&&CAP.history)?CAP.history.slice(-12):[]; + const body={model:"gpt-4o-mini",temperature:0, + messages:[{role:"system",content:sys},...history, + {role:"user",content:JSON.stringify({newUtterance:text,currentDraft:draft||{},pendingField:pending,context:captureContext()})}], + response_format:{type:"json_schema",json_schema:{name:"capture",strict:true,schema:OAI_SCHEMA}}}; + const res=await fetch("https://api.openai.com/v1/chat/completions",{method:"POST", + headers:{"Content-Type":"application/json","Authorization":"Bearer "+key}, + body:JSON.stringify(body)}); + if(!res.ok){ throw new Error(res.status+" "+(await res.text()).slice(0,140)); } + const o=JSON.parse((await res.json()).choices[0].message.content); + const clean=s=>s?canonHardware(cap1(s.replace(/^(?:a|an|the)\s+/i,"").trim())):s; + // merge by title: UPDATE fields on existing tasks (so "set size on each task" actually lands), + // APPEND genuinely new ones, and keep tasks GPT didn't mention so nothing is dropped. + // owner falls back to a domain guess from the title when the model left it null. + const cleanSubs=arr=>(arr||[]).map(s=>{const ti=clean(s.title); + return {title:ti,owner:s.owner||inferOwnerByDomain(ti),due:s.due||null,size:s.size||null};}).filter(s=>s.title); + const wantsSub=/\bsub ?-?tasks?\b/i.test(text||""); // user explicitly asked for subtasks + const out=((draft&&draft.tasks)||[]).map(t=>({...t,subs:(t.subs||[]).slice()})); + const idx=new Map(out.map((t,i)=>[(t.title||"").toLowerCase(),i])); + const fresh=[]; + (o.tasks||[]).forEach(t=>{ const ti=clean(t.title); if(!ti) return; const k=ti.toLowerCase(); + if(idx.has(k)){ const e=out[idx.get(k)]; + if(t.owner) e.owner=t.owner; if(t.due) e.due=t.due; if(t.size) e.size=t.size; + if(t.subs&&t.subs.length) e.subs=cleanSubs(t.subs); + } else fresh.push({title:ti,owner:t.owner||inferOwnerByDomain(ti),due:t.due||null,size:t.size||null,subs:cleanSubs(t.subs)}); }); + // safety net: if the user said "subtask", new items belong UNDER the last task, not as top-level tasks + if(wantsSub && out.length){ const last=out[out.length-1]; last.subs=last.subs||[]; + fresh.forEach(f=>last.subs.push({title:f.title,owner:f.owner,due:f.due,size:f.size})); } + else fresh.forEach(f=>{ out.push(f); idx.set(f.title.toLowerCase(),out.length-1); }); + // deletions: drop any task or subtask whose title GPT listed in "remove" + let finalTasks=out; + const rm=new Set((o.remove||[]).map(s=>(s||"").toLowerCase().trim()).filter(Boolean)); + if(rm.size){ finalTasks=out.filter(t=>!rm.has((t.title||"").toLowerCase())); + finalTasks.forEach(t=>{ t.subs=(t.subs||[]).filter(s=>!rm.has((s.title||"").toLowerCase())); }); } + return {draft:{intent:o.intent,project:o.project,task:clean(o.task),tasks:finalTasks,owner:o.owner,parentId:o.parentId,due:o.due,size:o.size}, + pending:o.pending,ready:o.ready,assistantSay:o.assistantSay}; +} + +/* ---- local stand-in for the LLM: understands intent + fills fields across turns ---- */ +function matchProject(t){ let best=null,score=0; + DATA.forEach(p=>{ const n=p.title.toLowerCase(); + if(t.includes(n)){ if(n.length>score){ score=n.length; best=p; } } }); + return best; } + +function mockExtract(text,draft,pending){ + draft=JSON.parse(JSON.stringify(draft||{})); + const raw=text.trim(), t=" "+raw.toLowerCase()+" "; + const newlyAsked=pending; + + // a turn that simply answers the bot's pending question routes wholesale into that field + if(pending==="projectName"){ draft.project=cap1(raw.replace(/[.?!]+$/,"")); } + else if(pending==="taskTitle"){ draft.task=cap1(raw.replace(/^(it'?s|its|to|the task is)\s+/i,"").replace(/[.?!]+$/,"")); } + else if(pending==="owner"){ const o=findOwnerId(t); if(o) draft.owner=o; } + else if(pending==="parent"){ const p=matchProject(t); if(p) draft.parentId=p.id; } + else if(pending==="due"){ const due=findDue(t); if(due) draft.due=due; } + + // mixed-initiative: always scan for explicit signals too (user may over-specify) + if(!draft.intent){ + if(/\bproject\b/.test(t)) draft.intent="create_project"; + else if(/\bsub ?task\b/.test(t)) draft.intent="create_subtask"; + else if(/\btask\b/.test(t)) draft.intent="create_task"; + } + if(!draft.owner){ const o=findOwnerId(t); if(o&&/\b(owner|own|assign|for|by)\b/.test(t)) draft.owner=o; } + const due=findDue(t); if(due) draft.due=due; + const sz=findSize(t); if(sz) draft.size=sz; + // project name from "(project|product) ... (called|named) X" or "project for X" + if(draft.intent==="create_project"&&!draft.project){ + let m=raw.match(/(?:project|product)[^.]*?(?:called|named|name is|is called)\s+(.+?)(?=\s+(?:and|the task|task|due|owner|by|with|subtask)\b|[.,!?]|$)/i) + ||raw.match(/(?:new project)\s+(?:called\s+|named\s+|for\s+)?(.+?)(?=\s+(?:and|the task|task|due|owner|by|with|subtask)\b|[.,!?]|$)/i); + if(m) draft.project=cap1(m[1].trim().replace(/^(?:called|named|is\s+called)\s+/i,"")); } + // explicit (re)naming at any turn — lets the user correct a wrong name ("call it X", "should be called X") + if(draft.intent==="create_project"){ + const rn=raw.match(/(?:call it|name it|rename it to|should be called|it'?s called|the project is(?: called)?|name is(?: called)?)\s+(.+?)(?=\s+(?:and|task|due|owner|by|with|subtask)\b|[.,!?]|$)/i); + if(rn) draft.project=cap1(rn[1].trim().replace(/^(?:called|named)\s+/i,"")); } + draft.tasks=draft.tasks||[]; + // a task mentioned in this turn + const tm=raw.match(/(?:add (?:a |another )?|first |second |third |next )?(?:new )?task\s+(?:called\s+|named\s+|is\s+|to\s+|:\s*)?(.+?)(?=\s+(?:and|due|owner|by|with|subtask|the project)\b|[.,!?]|$)/i); + if(draft.intent==="create_project"){ + // building a project → append the task to its list (don't switch intent) + if(tm){ const v=canonHardware(tm[1].trim().replace(/^(?:called|named)\s+/i,"")); + if(v.length>1) draft.tasks.push({title:cap1(v),owner:findOwnerId(t)||inferOwnerByDomain(v),due:findDue(t),size:findSize(t),subs:[]}); } + } else if(!draft.task){ + if(tm){ const v=tm[1].trim().replace(/^(?:called|named)\s+/i,""); if(v.length>1) draft.task=cap1(v); } + } + // existing-project reference for create_task + if(draft.intent==="create_task"&&!draft.parentId){ const p=matchProject(t); if(p) draft.parentId=p.id; } + // strip a trailing "… to/in/for " the task regex may have swallowed + if(draft.task&&draft.parentId){ const p=findPath(draft.parentId)?.pop(); + if(p){ const esc=p.title.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"); + draft.task=cap1(draft.task.replace(new RegExp("\\s+(?:to|in|for|under)\\s+"+esc+"\\s*$","i"),"").trim()); } } + + return finalize(draft,newlyAsked); +} + +function finalize(draft,justAsked){ + if(!draft.intent) return {draft,pending:null,ready:false, + assistantSay:"Tell me what to create — a project, a task, or a subtask."}; + const have={ projectName:!!draft.project, taskTitle:!!draft.task, + owner:!!draft.owner, parent:!!draft.parentId }; + let order; + if(draft.intent==="create_project") order=[["projectName","What should the project be called?"],["owner","Who owns it?"]]; + else order=[["taskTitle","What’s the task?"],["parent","Which project does it go in?"],["owner","Who owns it?"]]; + const next=order.find(([k])=>!have[k]); + // confirmation fragment for what we understood so far + const bits=[]; + if(draft.project) bits.push("project “"+draft.project+"”"); + if(draft.tasks&&draft.tasks.length) bits.push(draft.tasks.length+(draft.tasks.length>1?" tasks":" task")); + if(draft.task) bits.push("task “"+draft.task+"”"); + if(draft.parentId){ const p=findPath(draft.parentId)?.pop(); if(p) bits.push("in "+p.title); } + if(draft.owner) bits.push("owner "+PEOPLE[draft.owner].name); + if(draft.due) bits.push("due "+new Date(draft.due).toLocaleDateString("en-GB",{day:"numeric",month:"short"})); + const got=bits.length?"Got it — "+bits.join(", ")+". ":""; + if(next) return {draft,pending:next[0],ready:false,assistantSay:got+next[1]}; + return {draft,pending:null,ready:true, + assistantSay:got+"Ready to create — review and hit Create, or keep talking to adjust."}; +} + +/* ---- editable creation card (middle panel) mirrors the draft; edits write to CAP.draft ---- */ +function renderCapCard(r){ + const d=CAP.draft; d.tasks=d.tasks||[]; const miss=r.pending; + const card=$id("capCard"), title=$id("buildTitle"); + // the creation form stays hidden until the assistant knows what you want to build + const build=document.querySelector(".cappanel.build"); + if(build) build.style.display=d.intent?"flex":"none"; + if(!d.intent) return; + const ownerChips=`
${Object.entries(PEOPLE).map(([k,p])=> + ``).join("")}
`; + const projSel=``; + const rows=[]; + const row=(key,lbl,html)=>rows.push(`
${lbl}${html}
`); + if(d.intent==="create_project"){ + title.textContent="New project"; + row("projectName","Project",``); + row("owner","Owner",ownerChips); + row("due","Due",``); + // tasks (and their subtasks) default to the project owner when none is set + if(d.owner) d.tasks.forEach(t=>{ if(!t.owner) t.owner=d.owner; + (t.subs||[]).forEach(s=>{ if(!s.owner) s.owner=t.owner||d.owner; }); }); + const tl=d.tasks.length?d.tasks.map((tk,i)=>taskCardHTML(tk,i)).join("") + :`
No tasks yet — say “add a task called …” or type one below.
`; + rows.push(`
Tasks
${tl} +
+
`); + } else { + title.textContent=d.intent==="create_subtask"?"New subtask":"New task"; + row("taskTitle","Task",``); + row("parent","Project",projSel); + row("owner","Owner",ownerChips); + row("due","Due",``); + } + card.innerHTML=rows.join(""); card.className="capcard show"; + const ready=d.intent==="create_project"?(!!d.project):(!!d.task&&!!d.parentId); + $id("capActions").innerHTML=``; +} +function refreshCard(){ renderCapCard({pending:CAP.pending}); } +function addCapTask(){ const el=$id("capTaskNew"); if(!el) return; const v=el.value.trim(); if(!v) return; + CAP.draft.tasks=CAP.draft.tasks||[]; CAP.draft.tasks.push({title:v[0].toUpperCase()+v.slice(1),owner:null,due:null,size:null,subs:[]}); + refreshCard(); setTimeout(()=>$id("capTaskNew")&&$id("capTaskNew").focus(),0); } +function delCapTask(i){ CAP.draft.tasks.splice(i,1); refreshCard(); } + +/* a task = title + owner + due + size + its own subtasks, editably & tidily */ +const escq=s=>(s||"").replace(/"/g,"""); +const ownerOpts=v=>``+Object.entries(PEOPLE).map(([k,p])=> + ``).join(""); +function ownerPill(v,onch){ const col=v?PEOPLE[v].color:"#c2c8d2"; + return ` + `; } +function duePill(v,onch,sm){ return ``; } +function szSeg(v,onch){ return `${["s","m","l","xl"].map(z=> + ``).join("")}`; } +function taskCardHTML(tk,i){ tk.subs=tk.subs||[]; + return `
+
+ +
+
+ ${ownerPill(tk.owner,`setTaskOwner(${i},this.value)`)} + ${duePill(tk.due,`setTask(${i},'due',this.value)`)} + ${szSeg(tk.size,`setTaskSize(${i},Z)`)}
+ ${tk.subs.map((s,j)=>`
+ + ${ownerPill(s.owner,`setSubOwner(${i},${j},this.value)`)} + ${duePill(s.due,`setSub(${i},${j},'due',this.value)`,true)} +
`).join("")} +
+
+
`; +} +function setTask(i,f,v){ CAP.draft.tasks[i][f]=(f==="due"||f==="size"||f==="owner")?(v||null):v; } +function setTaskOwner(i,v){ CAP.draft.tasks[i].owner=v||null; refreshCard(); } +function setTaskSize(i,z){ const t=CAP.draft.tasks[i]; t.size=(t.size===z?null:z); refreshCard(); } +function setSub(i,j,f,v){ CAP.draft.tasks[i].subs[j][f]=(f==="due"||f==="size"||f==="owner")?(v||null):v; } +function setSubOwner(i,j,v){ CAP.draft.tasks[i].subs[j].owner=v||null; refreshCard(); } +function addSub(i){ const el=$id("subNew"+i); if(!el) return; const v=el.value.trim(); if(!v) return; + CAP.draft.tasks[i].subs=CAP.draft.tasks[i].subs||[]; + CAP.draft.tasks[i].subs.push({title:v[0].toUpperCase()+v.slice(1),owner:null,due:null,size:null}); + refreshCard(); setTimeout(()=>$id("subNew"+i)&&$id("subNew"+i).focus(),0); } +function delSub(i,j){ CAP.draft.tasks[i].subs.splice(j,1); refreshCard(); } + +function commitCapture(){ + const d=CAP.draft, owner=d.owner||"fd"; + snap(); + let focusId; + if(d.intent==="create_project"){ + /* by default a project, its tasks and their subtasks share the same due date — + any level left blank inherits its parent's date */ + const projDue=d.due||null; + const proj=T(cap1(d.project||"New project"),owner,{d:projDue,open:true}); + (d.tasks||[]).forEach(tk=>{ + const tDue=tk.due||projDue||null; + const task=T(cap1(tk.title),tk.owner||owner,{d:tDue,s:tk.size||null,open:(tk.subs&&tk.subs.length>0)}); + (tk.subs||[]).forEach(s=>task.children.push(T(cap1(s.title),s.owner||tk.owner||owner,{d:s.due||tDue||null,s:s.size||null}))); + proj.children.push(task); }); + DATA.push(proj); focusId=proj.id; + } else { + const parent=d.parentId?findPath(d.parentId).pop():null; + if(!parent){ UNDO.pop(); botSay("Which project should it go in?"); CAP.pending="parent"; renderCapCard({pending:"parent"}); return; } + const node=T(cap1(d.task||"New task"),owner,{d:d.due||parent.due||null,s:d.size||null}); + if(!fitsDepth(node,parent.id)){ UNDO.pop(); + botSay("That would nest too deep — the hierarchy stops at project › task › subtask."); return; } + parent.children.push(node); parent.open=true; focusId=node.id; + } + ding(3); closeCapture(); renderAll(); // submitted — no extra screen +} + +/* ================= full-transcript processing → Review & Approve ================= */ +/* Paste a whole conversation; the LLM (or the offline mock) proposes MULTIPLE projects and + tasks at once. Nothing is created until the user accepts items and hits "Push approved". */ +const CLIENT_NAMES=[...CLIENTS.map(c=>c.name),null]; +const OAI_PROPOSAL_SCHEMA={type:"object",additionalProperties:false, + required:["assistantSay","projects"], + properties:{ + assistantSay:{type:"string"}, + projects:{type:"array",items:{type:"object",additionalProperties:false, + required:["name","client","owner","due","tasks"], + properties:{ + name:{type:"string"}, + client:{type:["string","null"],enum:CLIENT_NAMES}, + owner:{type:["string","null"],enum:OWNER_IDS}, + due:{type:["string","null"]}, + tasks:{type:"array",items:{type:"object",additionalProperties:false, + required:["title","owner","due","size","client","subs"], + properties:{ + title:{type:"string"}, + owner:{type:["string","null"],enum:OWNER_IDS}, + due:{type:["string","null"]}, + size:{type:["string","null"],enum:["s","m","l","xl",null]}, + client:{type:["string","null"],enum:CLIENT_NAMES}, + subs:{type:"array",items:{type:"object",additionalProperties:false, + required:["title","owner","due","size"], + properties:{title:{type:"string"},owner:{type:["string","null"],enum:OWNER_IDS}, + due:{type:["string","null"]},size:{type:["string","null"],enum:["s","m","l","xl",null]}}}} + }}} + }}}}}; +async function openaiTranscript(text,key){ + const sys=`You read a raw client/team conversation transcript and extract the NEW engineering projects and tasks it implies, for a 3-level planner (project > task > subtask). +LANGUAGE: the transcript may be English or French — ALL OUTPUT MUST BE IN ENGLISH. +- Group work into projects. A customer pilot becomes a project; set its "client" to the matching known client. Pure internal work has client=null. +- Each task: a concise imperative title (no leading article), owner, due (YYYY-MM-DD resolved from context.today, else null), size (s/m/l/xl or null), client (if the task is for a known client else null), and a subs array (usually empty). +- ASSIGNEE: infer each owner from this RESPONSIBILITY MAP using the task's content; only null if genuinely unclear: +${buildRespMapText()} +${buildVocabText()} + Use the exact hardware/client spellings above; map mis-hearings to the canonical form. +- Do NOT re-create work that already exists in context.projects — only return genuinely new items. +- assistantSay: one short sentence, e.g. "I identified 2 projects and 7 tasks from this conversation." +Return ONLY the JSON object.`; + const body={model:"gpt-4o-mini",temperature:0, + messages:[{role:"system",content:sys}, + {role:"user",content:JSON.stringify({transcript:text,context:captureContext()})}], + response_format:{type:"json_schema",json_schema:{name:"proposal",strict:true,schema:OAI_PROPOSAL_SCHEMA}}}; + const res=await fetch("https://api.openai.com/v1/chat/completions",{method:"POST", + headers:{"Content-Type":"application/json","Authorization":"Bearer "+key},body:JSON.stringify(body)}); + if(!res.ok) throw new Error(res.status+" "+(await res.text()).slice(0,140)); + return JSON.parse((await res.json()).choices[0].message.content); +} +/* offline stand-in: split the transcript into action clauses, route them to per-client projects */ +let PROP = null; +function normalizeProposalWrapped(raw) { + return normalizeProposal(raw); +} +async function extractTranscript(text){ + const key=getKey(); + if(key){ try{ return normalizeProposalWrapped(await openaiTranscript(text,key)); }catch(e){ /* fall back to mock */ } } + await new Promise(r=>setTimeout(r,400)); + return normalizeProposalWrapped(mockTranscript(text)); +} +function openTranscript(){ $id("trInput").value=""; $id("transcriptModal").classList.add("show"); setTimeout(()=>$id("trInput").focus(),50); } +function closeTranscript(){ $id("transcriptModal").classList.remove("show"); } +async function runTranscript(){ const text=$id("trInput").value.trim(); if(text.length<8) return; + const btn=$id("trGo"); btn.disabled=true; btn.textContent="Processing…"; + try{ PROP=await extractTranscript(text); } finally{ btn.disabled=false; btn.textContent="Process"; } + closeTranscript(); openReview(); } +/* paperclip in the chat: attach a transcript file and run it straight into Review & Approve */ +async function attachTranscript(input){ + const f=input.files&&input.files[0]; input.value=""; if(!f) return; + let text=""; try{ text=await f.text(); }catch(e){} + text=stripCaptions(text); + if(!text||text.trim().length<8){ bubble("That file looks empty — try a transcript with some text in it.","bot think"); return; } + bubble("📎 "+f.name,"me"); + const think=bubble("Reading the transcript…","bot think"); + try{ PROP=await extractTranscript(text); } catch(e){ PROP=null; } + think.remove(); + if(!PROP){ bubble("I couldn't read that file.","bot think"); return; } + openReview(); +} +/* drop WebVTT/SRT timestamp + index lines so only the spoken text reaches the model */ +function openReview(){ renderReview(); $id("reviewModal").classList.add("show"); } +function closeReview(){ $id("reviewModal").classList.remove("show"); PROP=null; } +function rvObj(uid){ if(!PROP) return null; + for(const p of PROP.projects){ if(p.uid===uid) return p; + for(const t of p.tasks){ if(t.uid===uid) return t; for(const s of t.subs){ if(s.uid===uid) return s; } } } return null; } +function rvToggle(uid){ const o=rvObj(uid); if(o){ o.accepted=!o.accepted; renderReview(); } } +function rvText(uid,f,v){ const o=rvObj(uid); if(o) o[f]=v; } // no re-render: keep input focus +function rvOwner(uid,v){ const o=rvObj(uid); if(o){ o.owner=v||null; renderReview(); } } +function rvDue(uid,v){ const o=rvObj(uid); if(o) o.due=v||null; } +function rvSize(uid,z){ const o=rvObj(uid); if(o){ o.size=(o.size===z?null:z); renderReview(); } } +function rvTaskHTML(p,t){ + return `
+ + + + + + ${szSeg(t.size,`rvSize(${t.uid},Z)`)} + ${t.client?`${t.client}`:''} + + ${(t.subs||[]).map(s=>`
+ + + +
`).join("")} +
`; +} +function renderReview(){ if(!PROP) return; + let na=0,nt=0; + PROP.projects.forEach(p=>{ if(p.accepted){ na++; p.tasks.forEach(t=>{ if(t.accepted) nt++; }); } }); + $id("rvBanner").textContent=PROP.assistantSay||`I identified ${PROP.projects.length} project(s).`; + $id("rvBody").innerHTML=PROP.projects.map(p=>`
+
+ + + ${p.client?'Pilot · '+p.client:'Engineering'} +
+
+ + +
+
${p.tasks.map(t=>rvTaskHTML(p,t)).join("")||'
No tasks proposed for this project.
'}
+
`).join("")||'
Nothing detected. Try a more detailed transcript.
'; + $id("rvActions").innerHTML=` + `; +} +function pushApproved(){ if(!PROP) return; snap(); let np=0; + PROP.projects.forEach(p=>{ if(!p.accepted) return; + const proj=T(cap1(p.name||"New project"),p.owner||"fd",{d:p.due||null,open:true}); + if(p.client) proj.client=p.client; // light CRM link tag + (p.tasks||[]).forEach(t=>{ if(!t.accepted) return; + const subsOK=(t.subs||[]).filter(s=>s.accepted); + const task=T(cap1(t.title||"New task"),t.owner||p.owner||"fd",{d:t.due||p.due||null,s:t.size||null,open:subsOK.length>0}); + if(t.client) task.client=t.client; + subsOK.forEach(s=>task.children.push(T(cap1(s.title||"Subtask"),s.owner||t.owner||p.owner||"fd",{d:s.due||t.due||p.due||null,s:s.size||null}))); + proj.children.push(task); }); + DATA.push(proj); np++; }); + closeReview(); ding(3); renderAll(); +} + +/* ---- continuous mic: stays live, auto-sends each finished sentence as a turn until you + stop it. Finished sentences are queued so a slow extract call never drops one. ---- */ +let capQueue=[], speaking=false, micPaused=false; +function setMic(on){ listening=on; const f=$id("micFab"); if(f) f.classList.toggle("live",on); } +/* the bottom-right mic is the only mic: tap to open the chat (if needed) and talk; tap again to stop */ +function micFabTap(){ const m=$id("vmodal"); + if(CAP&&m&&m.classList.contains("min")){ restoreCapture(); return; } // expand instead of toggling mic + if(!CAP){ openCapture(); setTimeout(toggleListen,150); } else toggleListen(); } +function pushTurn(text){ capQueue.push(text); drainQueue(); } +async function drainQueue(){ if(!CAP||CAP.busy) return; + while(capQueue.length){ $id("capInput").value=capQueue.shift(); await sendTurn(); } } +function isBotEcho(text){ const n=normCap(text), b=normCap(lastBotText); + return n.length>5 && b && (b.includes(n)||n.includes(b)); } +function toggleListen(){ if(listening) return stopListen(); + const SR=window.SpeechRecognition||window.webkitSpeechRecognition; + if(!SR){ botSay("Speech recognition isn’t available in this browser — type your answer instead. (In production this streams to Whisper.)"); return; } + rec=new SR(); rec.lang=capLang==="fr"?"fr-FR":"en-US"; rec.interimResults=true; rec.continuous=true; + rec.onresult=e=>{ if(speaking||micPaused) return; // never while the bot is talking + let fin="",intr=""; + for(let i=e.resultIndex;i{ if(listening&&!micPaused){ try{rec.start();}catch(e){} } else if(!listening) setMic(false); }; + rec.onerror=()=>{}; + try{ rec.start(); setMic(true); }catch(e){ setMic(false); } +} +function stopListen(){ listening=false; micPaused=false; if(rec){ try{rec.stop();}catch(e){} } setMic(false); } + +/* ================= team photos ================= */ +function openTeam(){ renderTeam(); $id("teamModal").classList.add("show"); } +function closeTeam(){ $id("teamModal").classList.remove("show"); } +function renderTeam(){ + $id("teamList").innerHTML=Object.entries(PEOPLE).map(([k,p])=>`
+ ${av(k,"lg")}${p.name} + + ${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(); requestSave(); }; + r.readAsDataURL(f); } +function removePhoto(k){ delete PEOPLE[k].photo; renderTeam(); renderAll(); requestSave(); } + +/* ================= search ================= */ +function doSearch(){ + const q=document.getElementById("searchbox").value.trim().toLowerCase(); + const box=document.getElementById("searchres"); + if(q.length<2){ box.innerHTML=""; box.style.display="none"; return; } + const hits=[]; + flat(DATA,(n,d,path)=>{ if(hits.length>=8) return; + if(n.title.toLowerCase().includes(q)||PEOPLE[n.owner].name.toLowerCase().includes(q)) + hits.push({n,proj:path[0]?path[0].title:n.title}); }); + box.innerHTML=hits.map(h=>``).join("") + ||'
No matches
'; + box.style.display="block"; +} +function pickSearch(id){ const sb=document.getElementById("searchbox"); + sb.value=""; document.getElementById("searchres").style.display="none"; openDetail(id); } + +/* ================= hover tooltip — full task names ================= */ +/* IMPORTANT: which bar the cursor is over is read from each event's REAL target + (e.target.closest("[data-full]")), never from document.elementFromPoint. That was the bug: + after the chart's innerHTML is swapped under a stationary cursor, Chromium's elementFromPoint + hit-test cache goes stale and keeps returning the old/wrong node until the tab is re-focused — + so every filter change killed the tooltip and even moving the mouse didn't help, because every + detection path read from that one poisoned source. Event targets are always live, so this + survives re-renders with no tab switch and no synthetic-event trickery. */ +const TIP=document.createElement("div"); TIP.id="gtip"; document.body.appendChild(TIP); +let MX=-1,MY=-1,tipKey=null,tipTimer=null,tipEl=null; +let PTRDOWN=false; // physical button held (i.e. a drag in progress) — suppress the tip +function hideTip(){ clearTimeout(tipTimer); tipTimer=null; tipKey=null; tipEl=null; TIP.style.display="none"; } +function placeTip(){ + TIP.style.left=Math.max(8,Math.min(MX+14,window.innerWidth-TIP.offsetWidth-10))+"px"; + TIP.style.top =Math.max(6,MY-TIP.offsetHeight-16)+"px"; +} +function revealTip(){ tipTimer=null; + if(!tipEl||!tipEl.isConnected){ hideTip(); return; } // node vanished during the delay + TIP.textContent=tipEl.dataset.full; TIP.style.display="block"; placeTip(); +} +/* feed me the element under the pointer, taken from a live event target (or a fresh hit-test) */ +function hoverOn(target){ + if(PTRDOWN){ hideTip(); return; } + const host=(target&&target.closest)?target.closest("[data-full]"):null; + if(!host){ hideTip(); return; } + const key=host.dataset.tid||host.dataset.full; + if(key!==tipKey){ // moved onto a new bar → arm the reveal delay + clearTimeout(tipTimer); tipKey=key; tipEl=host; TIP.style.display="none"; + tipTimer=setTimeout(revealTip,600); return; + } + tipEl=host; // same bar (may be a fresh node after a render) + if(TIP.style.display==="block") placeTip(); // already shown → follow the cursor +} +document.addEventListener("pointerdown",()=>{PTRDOWN=true;},true); +document.addEventListener("pointerup",()=>{PTRDOWN=false;},true); +document.addEventListener("pointercancel",()=>{PTRDOWN=false;},true); +window.addEventListener("blur",()=>{PTRDOWN=false;}); +const onMove=e=>{ MX=e.clientX; MY=e.clientY; if(!e.buttons) PTRDOWN=false; hoverOn(e.target); }; +document.addEventListener("pointermove",onMove,{passive:true}); +document.addEventListener("pointerover",e=>{ MX=e.clientX; MY=e.clientY; hoverOn(e.target); },{passive:true}); +document.addEventListener("pointerout", e=>{ if(!e.relatedTarget) hideTip(); },{passive:true}); +document.addEventListener("mousemove",onMove,{passive:true}); // fallbacks if +document.addEventListener("mouseover",e=>{ MX=e.clientX; MY=e.clientY; hoverOn(e.target); },{passive:true}); // pointer events glitch +// scrolling hides the tip; the next real hover re-arms it +document.addEventListener("scroll",()=>hideTip(),true); +// (no idle watchdog: the tooltip appears ONLY on a real hover, never synthesized from a click/render) + +function renderAll(){ renderFilter(); renderDash(); + if(typeof requestAnimationFrame!=="undefined") + requestAnimationFrame(kickHover); // re-evaluate hover after every re-render +} +Object.defineProperty(window, "CAP", { get: () => CAP, configurable: true }); + +requestSave = await initApp({ + applyBoard: (b) => applyBoard(b, setUid), + boardPayload: () => boardPayload(getUid), + renderAll, +}); + +const _globals = { + toggleSearch, openTeam, micFabTap, openTranscript, toggleSettings, toggleSidebar, closeSettings, + toggleFlyout, toggleFocus, toggleShowDone, toggleSubs, closeCapture, toggleCapLang, minimizeCapture, + sendTurn, restoreCapture, skipKey, saveKey, clearKey, closeTranscript, runTranscript, closeReview, + closeTeam, closeSheet, setFilter, setScaleView, ding, toggleDone, openDetail, setZoom, setGView, + toggleExp, updTask, refreshBarMenu, addChild, deleteTask, addCapTask, barDown, barContext, pickSearch, + uploadPhoto, removePhoto, rvToggle, rvText, rvOwner, rvDue, rvSize, pushApproved, attachTranscript, + doSearch, refreshCard, delCapTask, setTask, setTaskOwner, setTaskSize, setSub, setSubOwner, addSub, + delSub, commitCapture, toggleListen, stopListen, renderAll, moveTask, setKeyVal, +}; +Object.assign(window, _globals); diff --git a/src/data/board-store.js b/_site/src/data/board-store.js similarity index 100% rename from src/data/board-store.js rename to _site/src/data/board-store.js diff --git a/_site/src/data/constants.js b/_site/src/data/constants.js new file mode 100644 index 0000000..cc88532 --- /dev/null +++ b/_site/src/data/constants.js @@ -0,0 +1,31 @@ +export { + PEOPLE, + TODAY, + CLIENTS, + HARDWARE_VOCAB, + DOMAIN_RULES, + DATA, +} from "./board-store.js"; + +export const SIZE_PTS = { s: 1, m: 2, l: 4, xl: 8 }; +export const SIZE_NAMES = { s: "S", m: "M", l: "L", xl: "XL" }; +export const LEAD = { s: 1, m: 3, l: 7, xl: 14 }; + +export const ZOOMS = [ + { l: "Day", h: 0, v: 3 }, + { l: "Week", h: 7, v: 7 }, + { l: "3 weeks", h: 21, v: 21 }, + { l: "6 weeks", h: 42, v: 42 }, +]; + +export const GBAR_H = { s: 26, m: 34, l: 44, xl: 56 }; +export const R0G = 0; +export const R1G = 90; +export const SPAN_G = R1G - R0G; +export const TODAY_PX = 240; + +export const C_LATE = "#ff5d5d"; +export const C_TODAY = "#2f80ff"; +export const C_RADAR = "#16c79a"; +export const C_LATER = "#9b8cff"; +export const C_DONE = "#c8cdd6"; diff --git a/src/data/defaults.js b/_site/src/data/defaults.js similarity index 100% rename from src/data/defaults.js rename to _site/src/data/defaults.js diff --git a/src/data/sample-tasks.js b/_site/src/data/sample-tasks.js similarity index 100% rename from src/data/sample-tasks.js rename to _site/src/data/sample-tasks.js diff --git a/_site/src/lib/capture.js b/_site/src/lib/capture.js new file mode 100644 index 0000000..f17f240 --- /dev/null +++ b/_site/src/lib/capture.js @@ -0,0 +1,193 @@ +import { CLIENTS, PEOPLE, TODAY } from "../data/constants.js"; +import { canonHardware, findClient, inferOwnerByDomain, norm } from "./domain.js"; + +export const cap1 = (s) => (s ? s.replace(/^[a-z]/, (c) => c.toUpperCase()) : s); + +export function isoCap(d) { + return d.toISOString().slice(0, 10); +} + +export function stripCaptions(t) { + if (!t) return t; + return t + .replace(/^WEBVTT.*$/im, "") + .split(/\r?\n/) + .filter((l) => !/^\s*\d+\s*$/.test(l) && !/^\s*[\d:.,]+\s*-->\s*[\d:.,]+/.test(l)) + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +export function findOwnerId(t) { + let best = null; + let pos = -1; + for (const [k, p] of Object.entries(PEOPLE)) { + for (const a of p.al) { + const i = t.indexOf(a); + if (i > pos) { + pos = i; + best = k; + } + } + } + return best; +} + +export function findDue(t, today = TODAY) { + const d = new Date(today); + const wd = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]; + const inDays = t.match(/in (\d+) days?/); + if (/\btoday\b/.test(t)) return isoCap(d); + if (/\btomorrow\b/.test(t)) { + d.setDate(d.getDate() + 1); + return isoCap(d); + } + if (/\bnext week\b/.test(t)) { + d.setDate(d.getDate() + 7); + return isoCap(d); + } + if (inDays) { + d.setDate(d.getDate() + +inDays[1]); + return isoCap(d); + } + const wi = wd.findIndex((w) => new RegExp("\\b" + w + "\\b").test(t)); + if (wi > -1) { + const delta = (wi - d.getDay() + 7) % 7 || 7; + d.setDate(d.getDate() + delta); + return isoCap(d); + } + return null; +} + +export function findSize(t) { + const m = t.match(/\b(extra large|x-?large|xl|small|medium|large)\b/); + if (!m) return null; + return ( + { + small: "s", + medium: "m", + large: "l", + xl: "xl", + "x-large": "xl", + xlarge: "xl", + "extra large": "xl", + }[m[1]] || null + ); +} + +export function normalizeProposal(raw) { + let ruid = 0; + const projects = (raw.projects || []) + .map((p) => ({ + uid: ++ruid, + accepted: true, + name: cap1(p.name || "New project"), + owner: p.owner || null, + due: p.due || null, + client: p.client || findClient(" " + (p.name || "") + " ") || null, + tasks: (p.tasks || []).map((t) => { + const ti = canonHardware(cap1(t.title || "")); + return { + uid: ++ruid, + accepted: true, + title: ti, + owner: t.owner || inferOwnerByDomain(ti) || null, + due: t.due || null, + size: t.size || null, + client: t.client || null, + subs: (t.subs || []).map((s) => { + const si = canonHardware(cap1(s.title || "")); + return { + uid: ++ruid, + accepted: true, + title: si, + owner: s.owner || inferOwnerByDomain(si) || null, + due: s.due || null, + size: s.size || null, + }; + }), + }; + }), + })) + .filter((p) => p.name); + return { assistantSay: raw.assistantSay || "", projects }; +} + +export function mockTranscript(text) { + const lc = norm(text); + const mentioned = CLIENTS.filter( + (c) => lc.includes(norm(c.name)) || c.al.some((a) => lc.includes(norm(a))) + ); + const VSRC = + "install|build|fix|implement|test|deploy|order|write|design|integrate|ship|prepare|configure|run|benchmark|schedule|review|deliver|set ?up|mount|wire|calibrate|debug|develop|create|add|update|replace|repair|assemble|program|tune|investigate|source|procure"; + const VERBS = new RegExp("\\b(" + VSRC + ")\\b", "i"); + const splitAnd = new RegExp( + "(?:,\\s*)?\\b(?:and|then|also)\\b\\s+(?=(?:we\\s+|i\\s+|they\\s+|the client\\s+|please\\s+|to\\s+)?(?:" + + VSRC + + ")\\b)", + "i" + ); + const clauses = text + .split(/[.;\n]+/) + .flatMap((c) => c.split(splitAnd)) + .map((s) => (s || "").trim()) + .filter(Boolean); + const tasks = []; + clauses.forEach((cl) => { + if (!VERBS.test(cl)) return; + const FILLER = + /^(?:the client wants us to|the client wants|they want us to|they want to|they want|we need to|we should|we'?ll|we|i need to|i'?ll|i|and|then|also|so|separately|additionally|internally|meanwhile|next|first|second|third|finally|please|can you|make sure to|let'?s|for)[,]?\s+/i; + let frag = cl.trim(); + let prev; + do { + prev = frag; + frag = frag.replace(FILLER, "").trim(); + } while (frag !== prev); + CLIENTS.forEach((c) => { + frag = frag.replace(new RegExp("^" + c.name + "\\s*,?\\s*", "i"), "").trim(); + }); + frag = frag.replace(/^to\s+/i, "").trim(); + if (frag.length < 4) return; + frag = canonHardware(frag); + const client = findClient(" " + cl.toLowerCase() + " "); + tasks.push({ + title: cap1(frag).slice(0, 90), + owner: inferOwnerByDomain(frag), + due: findDue(" " + cl.toLowerCase() + " "), + size: findSize(cl.toLowerCase()), + client, + subs: [], + }); + }); + const projects = []; + const getProj = (name, client) => { + let p = projects.find((x) => x.name === name); + if (!p) { + p = { name, client: client || null, owner: null, due: null, tasks: [] }; + projects.push(p); + } + return p; + }; + if (mentioned.length) { + tasks.forEach((tk) => { + const cl = tk.client || mentioned[0].name; + getProj("Pilot - " + cl, cl).tasks.push(tk); + }); + mentioned.forEach((c) => getProj("Pilot - " + c.name, c.name)); + } else { + const p = getProj("New engineering work", null); + tasks.forEach((tk) => p.tasks.push(tk)); + } + projects.forEach((p) => { + const cnt = {}; + p.tasks.forEach((t) => { + if (t.owner) cnt[t.owner] = (cnt[t.owner] || 0) + 1; + }); + p.owner = Object.keys(cnt).sort((a, b) => cnt[b] - cnt[a])[0] || null; + }); + const nT = projects.reduce((a, p) => a + p.tasks.length, 0); + return { + assistantSay: `I identified ${projects.length} project${projects.length !== 1 ? "s" : ""} and ${nT} task${nT !== 1 ? "s" : ""} from this conversation.`, + projects, + }; +} diff --git a/_site/src/lib/date-core.js b/_site/src/lib/date-core.js new file mode 100644 index 0000000..30bc5a0 --- /dev/null +++ b/_site/src/lib/date-core.js @@ -0,0 +1,14 @@ +export function dayN(iso, today) { + return Math.round((new Date(iso) - today) / 864e5); +} + +export function dayIso(d, today) { + const x = new Date(today); + x.setDate(x.getDate() + d); + return x.toISOString().slice(0, 10); +} + +export function barSpan(n, today, lead) { + const e = dayN(n.due, today); + return { s: n.start ? dayN(n.start, today) : e - (lead[n.size || "m"] - 1), e }; +} diff --git a/_site/src/lib/dates.js b/_site/src/lib/dates.js new file mode 100644 index 0000000..44c801f --- /dev/null +++ b/_site/src/lib/dates.js @@ -0,0 +1,106 @@ +import { LEAD } from "../data/constants.js"; +import { C_DONE, C_LATE, C_LATER, C_RADAR, C_TODAY } from "../data/constants.js"; +import { barSpan as _barSpan, dayIso, dayN } from "./date-core.js"; +import { flat } from "./tree.js"; +import { taskDone } from "./tree.js"; + +export { dayN, dayIso } from "./date-core.js"; + +export function createDateHelpers(today) { + const dayNLocal = (iso) => dayN(iso, today); + const dayIsoLocal = (d) => dayIso(d, today); + + const barSpan = (n) => _barSpan(n, today, LEAD); + + function workDays(s, e) { + if (isNaN(s) || isNaN(e) || e < s) return 0; + let c = 0; + for (let d = s; d <= e; d++) { + const wd = new Date(dayIsoLocal(d)).getDay(); + if (wd !== 0 && wd !== 6) c++; + } + return c; + } + + function barColor(e, s, done) { + if (done) return C_DONE; + if (e < 0) return C_LATE; + if (e === 0) return C_TODAY; + if (s <= 0) return C_RADAR; + return C_LATER; + } + + function barGeom(s, e, done, r0g = 0, r1g = 90) { + let rs; + let re; + if (done) { + rs = s; + re = e + 1; + } else if (e <= 0) { + rs = 0; + re = 1; + } else { + rs = Math.max(s, 0); + re = e + 1; + } + const cs = Math.max(rs, r0g); + return [cs, Math.min(Math.max(re, cs + 0.5), r1g)]; + } + + function rollupSpan(n) { + let s = Infinity; + let e = -Infinity; + flat([n], (x) => { + if (x.children.length || !x.due) return; + const sp = barSpan(x); + if (sp.s < s) s = sp.s; + if (sp.e > e) e = sp.e; + }); + return e === -Infinity ? barSpan(n) : { s, e }; + } + + const spanFor = (n) => (n.children.length ? rollupSpan(n) : barSpan(n)); + + function leafWeight(n) { + const { s, e } = barSpan(n); + const w = workDays(s, e); + return w > 0 ? w : 1; + } + + function progWD(n) { + let done = 0; + let tot = 0; + flat([n], (x) => { + if (x.children.length) return; + const w = leafWeight(x); + tot += w; + if (x.done) done += w; + }); + return tot ? done / tot : 0; + } + + function isUrgent(n) { + const done = n.children.length ? taskDone(n) : n.done; + if (done) return false; + const { s } = spanFor(n); + return !isNaN(s) && s <= 0; + } + + const fmtD = (iso) => + new Date(iso).toLocaleDateString("en-GB", { day: "numeric", month: "short" }); + + return { + dayN: dayNLocal, + dayIso: dayIsoLocal, + barSpan, + workDays, + barColor, + barGeom, + rollupSpan, + spanFor, + leafWeight, + progWD, + isUrgent, + fmtD, + }; +} diff --git a/_site/src/lib/domain.js b/_site/src/lib/domain.js new file mode 100644 index 0000000..1e42bd8 --- /dev/null +++ b/_site/src/lib/domain.js @@ -0,0 +1,49 @@ +import { CLIENTS, DOMAIN_RULES, HARDWARE_VOCAB, PEOPLE } from "../data/constants.js"; + +export function norm(s) { + return " " + (s || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim() + " "; +} + +export function inferOwnerByDomain(t) { + const s = " " + (t || "").toLowerCase() + " "; + for (const r of DOMAIN_RULES) { + for (const k of r.kw) { + if (s.includes(k)) return r.o; + } + } + return null; +} + +export function canonHardware(s) { + if (!s) return s; + return s + .replace(/\bel[\s-]?0?5\b/ig, "EL05") + .replace(/\brs[\s-]?0*(\d{1,2})\b/ig, (m, n) => "RS" + String(n).padStart(2, "0")) + .replace(/\bd[\s-]?wave\b/ig, "D-Wave") + .replace(/\bfeetech\b/ig, "Feetech") + .replace(/\brobstride\b/ig, "Robstride"); +} + +export function findClient(t) { + const s = norm(t); + for (const c of CLIENTS) { + if (s.includes(norm(c.name))) return c.name; + for (const a of c.al) { + if (s.includes(norm(a))) return c.name; + } + } + return null; +} + +export function buildRespMapText() { + return Object.entries(PEOPLE) + .map(([id, p]) => `- ${p.name} (id "${id}"; voice transcription often mis-hears this name as: ${p.al.join(", ")}): ${p.role}`) + .join("\n"); +} + +export function buildVocabText() { + return ( + `HARDWARE (use these exact spellings):\n- ${HARDWARE_VOCAB.join("\n- ")}\n` + + `KNOWN CLIENTS (use these exact spellings):\n- ${CLIENTS.map((c) => c.name).join("\n- ")}` + ); +} diff --git a/src/lib/persistence.js b/_site/src/lib/persistence.js similarity index 100% rename from src/lib/persistence.js rename to _site/src/lib/persistence.js diff --git a/_site/src/lib/tree.js b/_site/src/lib/tree.js new file mode 100644 index 0000000..e3d41cc --- /dev/null +++ b/_site/src/lib/tree.js @@ -0,0 +1,86 @@ +export function createTaskFactory() { + let uid = 0; + const T = (title, o, opts = {}) => ({ + id: ++uid, + title, + owner: o, + priority: opts.p || "med", + due: opts.d || null, + start: opts.st || null, + size: opts.s || null, + done: opts.done || false, + doneAt: opts.doneAt || null, + children: opts.c || [], + open: opts.open || false, + }); + return { + T, + resetUid: () => { uid = 0; }, + getUid: () => uid, + setUid: (v) => { uid = v; }, + }; +} + +export const flat = (nodes, fn, depth = 0, path = []) => + nodes.forEach((n) => { + fn(n, depth, path); + flat(n.children, fn, depth + 1, [...path, n]); + }); + +export function findPath(id, nodes, path = []) { + for (const n of nodes) { + if (n.id === id) return [...path, n]; + const r = findPath(id, n.children, [...path, n]); + if (r) return r; + } + return null; +} + +export function counts(n) { + if (!n.children.length) return { done: n.done ? 1 : 0, total: 1 }; + let d = 0; + let t = 0; + n.children.forEach((c) => { + const r = counts(c); + d += r.done; + t += r.total; + }); + return { done: d, total: t }; +} + +export const pct = (n) => { + const c = counts(n); + return c.total ? Math.round((100 * c.done) / c.total) : 0; +}; + +export function progFrac(n, sizePts) { + let done = 0; + let tot = 0; + flat([n], (x) => { + if (x.children.length) return; + const w = sizePts[x.size || "m"]; + tot += w; + if (x.done) done += w; + }); + return tot ? done / tot : 0; +} + +export const taskDone = (n) => (!n.children.length ? n.done : pct(n) === 100); + +export function taskDoneAt(n) { + let m = null; + flat([n], (x) => { + if (!x.children.length && x.doneAt && (!m || x.doneAt > m)) m = x.doneAt; + }); + return m || n.doneAt; +} + +export const contains = (n, id) => n.id === id || n.children.some((c) => contains(c, id)); + +export const depthOf = (id, nodes) => findPath(id, nodes).length - 1; + +export const heightOf = (n) => + n.children.length ? 1 + Math.max(...n.children.map(heightOf)) : 0; + +export const fitsDepth = (node, destId, nodes) => + depthOf(destId, nodes) + 1 + heightOf(node) <= 2; diff --git a/db.py b/db.py index 600275d..bfeec38 100644 --- a/db.py +++ b/db.py @@ -29,6 +29,10 @@ def init_db(): 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): @@ -41,7 +45,11 @@ def get_board(conn=None): board = default_board() save_board(conn, board) return board, datetime.now(timezone.utc).isoformat() - return json.loads(row["data"]), row["updated_at"] + 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() diff --git a/src/app/main.js b/src/app/main.js index c79f80e..e1f4f53 100644 --- a/src/app/main.js +++ b/src/app/main.js @@ -1,11 +1,9 @@ import { - PEOPLE, TODAY, HARDWARE_VOCAB, CLIENTS, DATA, + PEOPLE, TODAY, HARDWARE_VOCAB, CLIENTS, SIZE_PTS, SIZE_NAMES, LEAD, ZOOMS, GBAR_H, R0G, R1G, SPAN_G, TODAY_PX, C_LATE, C_TODAY, C_RADAR, C_LATER, C_DONE, } from "../data/constants.js"; -import { applyBoard, boardPayload, initBoardDefaults } from "../data/board-store.js"; -import { buildSampleTasks } from "../data/sample-tasks.js"; import { inferOwnerByDomain, canonHardware, findClient, buildRespMapText, buildVocabText, norm as _norm } from "../lib/domain.js"; import { createTaskFactory, flat, findPath as findPathIn, counts, pct, taskDone, @@ -16,15 +14,93 @@ import { cap1, stripCaptions, findOwnerId, findDue, findSize, normalizeProposal, mockTranscript, isoCap, } from "../lib/capture.js"; -import { initApp, scheduleSave } from "../lib/persistence.js"; +import { startBoardSync } from "../lib/board-sync.js"; /* ================= sample data ================= */ /* al = ASR aliases: common Whisper mishearings of each name. In production this mapping is done by the extraction LLM given the roster, plus Whisper initial_prompt biasing ("Team: Jean, Florian, Iannis, …"). */ +const RESP_MAP_TEXT = buildRespMapText(); +const VOCAB_TEXT = buildVocabText(); const { T, setUid, getUid } = createTaskFactory(); -initBoardDefaults(setUid, () => buildSampleTasks(T)); + +const DATA = [ + /* ---- Client pilot: Derichebourg (waste-sorting robot) ---- */ + T("Derichebourg pilot — sorting robot","ia",{p:"high",d:"2026-07-10",open:true,c:[ + T("Integrate RS03 drive motors","sk",{p:"high",d:"2026-06-16",s:"l",open:true,c:[ + T("Mount RS03 motors and couplers","sk",{done:true,d:"2026-06-08",s:"m"}), + T("Wire motor CAN bus to controller","sk",{d:"2026-06-14",s:"m"}), + T("Calibrate RS03 torque limits","sk",{d:"2026-06-17",s:"s"}), + ]}), + T("Tune obstacle avoidance for the sorting line","ak",{p:"high",d:"2026-06-19",s:"l",open:true,c:[ + T("Collect depth data along the conveyor","ak",{done:true,d:"2026-06-10",s:"m"}), + T("Train avoidance model","ak",{d:"2026-06-18",s:"l"}), + T("Field test near the conveyor","ak",{d:"2026-06-22",s:"m"}), + ]}), + T("Fix D-Wave board brownout under load","ly",{p:"high",d:"2026-06-12",s:"m",open:true,c:[ + T("Diagnose the power regulator","ly",{done:true,d:"2026-06-11",s:"s"}), + T("Replace regulator and retest","ly",{d:"2026-06-13",s:"m"}), + ]}), + T("Approve motor procurement budget","jn",{d:"2026-06-15",s:"s"}), + T("Coordinate on-site pilot install","fd",{d:"2026-06-30",s:"l"}), + ]}), + + /* ---- Client pilot: JCDecaux (billboard-servicing robot) ---- */ + T("JCDecaux pilot — billboard servicing","ak",{p:"high",d:"2026-07-20",open:true,c:[ + T("Design board-mount manipulator arm","ia",{d:"2026-06-23",s:"l",open:true,c:[ + T("CAD the arm linkage","ia",{d:"2026-06-18",s:"m"}), + T("Source Feetech servos for the arm","lm",{d:"2026-06-20",s:"s"}), + ]}), + T("Autonomous navigation between billboards","ak",{p:"high",d:"2026-06-26",s:"xl",open:true,c:[ + T("Build city route planner","ak",{d:"2026-06-24",s:"l"}), + T("GPS waypoint following","ak",{d:"2026-06-25",s:"m"}), + ]}), + T("Hub-motor sizing for outdoor terrain","sk",{d:"2026-06-20",s:"m"}), + T("Demo prep for JCDecaux","fd",{d:"2026-06-27",s:"s"}), + ]}), + + /* ---- Client pilot: Onet (floor-cleaning autonomy) ---- */ + T("Onet pilot — floor-cleaning autonomy","ak",{d:"2026-08-01",open:true,c:[ + T("Map the Onet facility floorplan","ak",{d:"2026-06-21",s:"m"}), + T("Integrate Feetech servos for the brush arm","sk",{d:"2026-06-24",s:"m",open:true,c:[ + T("Mount the brush assembly","lm",{d:"2026-06-22",s:"s"}), + T("Tune servo sweep pattern","sk",{d:"2026-06-25",s:"s"}), + ]}), + T("Safety e-stop wiring","ly",{p:"high",d:"2026-06-16",s:"s"}), + ]}), + + /* ---- Internal R&D: core platform ---- */ + T("RoboOS v2 — core platform","ia",{p:"high",d:"2026-07-31",open:true,c:[ + T("Migrate OS to RS04 motor drivers","sk",{p:"high",d:"2026-06-24",s:"l",open:true,c:[ + T("Port CAN driver to RS04","sk",{d:"2026-06-22",s:"m"}), + T("Bench-test RS04 closed loop","sk",{d:"2026-06-23",s:"m"}), + ]}), + T("Real-time locomotion controller","ak",{d:"2026-06-29",s:"l"}), + T("Evaluate EL05 actuators","sk",{d:"2026-06-17",s:"m",open:true,c:[ + T("Run EL05 load tests","sk",{done:true,d:"2026-06-09",s:"s"}), + T("Compare EL05 vs RS02 efficiency","sk",{d:"2026-06-18",s:"s"}), + ]}), + T("Nightly build + hardware-in-the-loop rig","ia",{d:"2026-06-21",s:"m"}), + T("Assemble robot chassis v2","ia",{d:"2026-07-06",s:"l"}), + ]}), + + /* ---- Client pilot: NSI (inventory-scanning robot) ---- */ + T("NSI pilot — inventory scanning","ia",{d:"2026-07-15",open:true,c:[ + T("Scoping follow-up with NSI","fd",{d:"2026-06-19",s:"s"}), + T("Barcode scanner integration","sk",{d:"2026-06-28",s:"m"}), + T("Aisle navigation tuning","ak",{d:"2026-07-02",s:"m"}), + ]}), +]; + +/* subtasks inherit their parent task's owner by default — with an occasional + (seeded-random) different owner sprinkled in, like a real team would have */ +{ let seed=7; const rnd=()=>(seed=(seed*1103515245+12345)%2147483648)/2147483648; + const keys=Object.keys(PEOPLE); + const walk=(nodes,parent,depth)=>nodes.forEach(n=>{ + if(depth>=2&&parent) n.owner=rnd()<0.7?parent.owner:keys[Math.floor(rnd()*keys.length)]; + walk(n.children,n,depth+1); }); + walk(DATA,null,0); } const findPath = (id, nodes = DATA, path = []) => findPathIn(id, nodes, path); const depthOf = (id) => depthOfIn(id, DATA); @@ -1009,10 +1085,10 @@ Rules: - Task titles are concise imperative phrases with NO leading article — "Clean the bathroom", not "a clean the bathroom". Each item has title, plus owner/due/size if stated (else null), plus a "subs" array (empty if none). - SUBTASKS: the word "subtask" ALWAYS means an item inside some existing task's "subs" array — NEVER a new top-level task, no matter how many are added. When the user says "add subtask(s)" / "add N subtasks": if they name or imply a parent ("for the first task", "under clean the bathroom"), use it; if they DON'T name one, attach the subtasks to the LAST task currently in the tasks array. Return that task's complete subs list. Subtasks are leaves (no further subs). Never increase the number of top-level tasks when the user said "subtask". - ASSIGNEE INFERENCE: when no owner is explicitly named for a task/subtask, look at the task's CONTENT and assign the teammate whose responsibility best matches it, using this RESPONSIBILITY MAP: -${buildRespMapText()} +${RESP_MAP_TEXT} Examples: "Install RS03 motor on prototype" → Iannis or Sanket; "Implement obstacle avoidance for the Derichebourg pilot" → Akshat; "Fix D-Wave board power issue" → Leynaïck. Only when nothing in the content maps to a responsibility, fall back to the PROJECT's owner. If the user says "owners same as the project", set every task's and subtask's owner to the project owner (this overrides inference). - DOMAIN VOCABULARY — use these EXACT spellings; never invent or mis-spell hardware or client names: -${buildVocabText()} +${VOCAB_TEXT} Map mis-heard variants to the canonical form (e.g. "RS zero three"/"RS-3" → "RS03", "dwave" → "D-Wave", "jaycee decaux" → "JCDecaux"). - When the user gives an ordered list of due dates/owners "in that order" for the tasks, apply them positionally to the tasks in their current order. - DELETING: you CAN delete. When the user asks to remove/delete a task or subtask (e.g. "delete the two tests you just added", "remove clean the toilet"), put each item's exact current title into the "remove" array. Otherwise "remove" is []. Never say you can't delete. @@ -1292,8 +1368,8 @@ LANGUAGE: the transcript may be English or French — ALL OUTPUT MUST BE IN ENGL - Group work into projects. A customer pilot becomes a project; set its "client" to the matching known client. Pure internal work has client=null. - Each task: a concise imperative title (no leading article), owner, due (YYYY-MM-DD resolved from context.today, else null), size (s/m/l/xl or null), client (if the task is for a known client else null), and a subs array (usually empty). - ASSIGNEE: infer each owner from this RESPONSIBILITY MAP using the task's content; only null if genuinely unclear: -${buildRespMapText()} -${buildVocabText()} +${RESP_MAP_TEXT} +${VOCAB_TEXT} Use the exact hardware/client spellings above; map mis-hearings to the canonical form. - Do NOT re-create work that already exists in context.projects — only return genuinely new items. - assistantSay: one short sentence, e.g. "I identified 2 projects and 7 tasks from this conversation." @@ -1511,11 +1587,7 @@ function renderAll(){ renderFilter(); renderDash(); } Object.defineProperty(window, "CAP", { get: () => CAP, configurable: true }); -requestSave = await initApp({ - applyBoard: (b) => applyBoard(b, setUid), - boardPayload: () => boardPayload(getUid), - renderAll, -}); +renderAll(); const _globals = { toggleSearch, openTeam, micFabTap, openTranscript, toggleSettings, toggleSidebar, closeSettings, @@ -1528,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/data/constants.js b/src/data/constants.js index cc88532..1e6cd9d 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -1,11 +1,38 @@ -export { - PEOPLE, - TODAY, - CLIENTS, - HARDWARE_VOCAB, - DOMAIN_RULES, - DATA, -} from "./board-store.js"; +export const 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"] }, +}; + +export const TODAY = new Date("2026-06-12"); + +export const 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)", +]; + +export const 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"] }, +]; + +export const 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"] }, +]; export const SIZE_PTS = { s: 1, m: 2, l: 4, xl: 8 }; export const SIZE_NAMES = { s: "S", m: "M", l: "L", xl: "XL" }; diff --git a/src/lib/board-sync.js b/src/lib/board-sync.js new file mode 100644 index 0000000..1495773 --- /dev/null +++ b/src/lib/board-sync.js @@ -0,0 +1,100 @@ +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; + + 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); + })(); +} From bf14691ac7ee5acbad12b7434e2d8094ce31cc45 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 16 Jun 2026 10:00:37 +0000 Subject: [PATCH 5/7] Remove accidental _site build artifacts from repo Co-authored-by: Sanket Sharma --- .gitignore | 1 + _site/index.html | 835 ----------------- _site/src/app/main.js | 1530 -------------------------------- _site/src/data/board-store.js | 68 -- _site/src/data/constants.js | 31 - _site/src/data/defaults.js | 35 - _site/src/data/sample-tasks.js | 82 -- _site/src/lib/capture.js | 193 ---- _site/src/lib/date-core.js | 14 - _site/src/lib/dates.js | 106 --- _site/src/lib/domain.js | 49 - _site/src/lib/persistence.js | 55 -- _site/src/lib/tree.js | 86 -- 13 files changed, 1 insertion(+), 3084 deletions(-) delete mode 100644 _site/index.html delete mode 100644 _site/src/app/main.js delete mode 100644 _site/src/data/board-store.js delete mode 100644 _site/src/data/constants.js delete mode 100644 _site/src/data/defaults.js delete mode 100644 _site/src/data/sample-tasks.js delete mode 100644 _site/src/lib/capture.js delete mode 100644 _site/src/lib/date-core.js delete mode 100644 _site/src/lib/dates.js delete mode 100644 _site/src/lib/domain.js delete mode 100644 _site/src/lib/persistence.js delete mode 100644 _site/src/lib/tree.js diff --git a/.gitignore b/.gitignore index bd77215..0d0bbcd 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__/ *.pyc .venv/ venv/ +_site/ diff --git a/_site/index.html b/_site/index.html deleted file mode 100644 index a881025..0000000 --- a/_site/index.html +++ /dev/null @@ -1,835 +0,0 @@ - - - - - - - -TaskBoard — Prototype - - - - - - - -
-
-
-
- -
-
-
- -
-
-
- -
-
-
- -
Today's priorities
-
-
- -
Show done tasks
-
-
- -
Show subtasks
-
-
- -
- Late - Due today - Work on today, due later - Due later -
-
-
-
-
-
-
-
-
- - - -
-
-
- -

New project

-
-
-
-
-
-

Assistant

-
- - -
-
-
-
- - - -
-
-
- -
- -
-
-

Connect ChatGPT

-

Paste your OpenAI API key to power capture with GPT. It’s kept only in this browser session — never written into the file — and is sent only to OpenAI. Leave blank to use the basic offline parser.

- -
- - -
- -
-
- -
-
- -

Process a conversation

-

Paste a client or team conversation. I'll propose projects and tasks for you to review — nothing is created until you approve it.

- -
- - -
-
-
- -
-
- -

Review & approve

-
-
-
-
-
- -
-
- -

Team photos

-

Upload a picture for each teammate. With no photo, their coloured initials are used.

-
-
-
- -
-
-
- -
- -
-
-
- - - - diff --git a/_site/src/app/main.js b/_site/src/app/main.js deleted file mode 100644 index c79f80e..0000000 --- a/_site/src/app/main.js +++ /dev/null @@ -1,1530 +0,0 @@ -import { - PEOPLE, TODAY, HARDWARE_VOCAB, CLIENTS, DATA, - SIZE_PTS, SIZE_NAMES, LEAD, ZOOMS, GBAR_H, - R0G, R1G, SPAN_G, TODAY_PX, - C_LATE, C_TODAY, C_RADAR, C_LATER, C_DONE, -} from "../data/constants.js"; -import { applyBoard, boardPayload, initBoardDefaults } from "../data/board-store.js"; -import { buildSampleTasks } from "../data/sample-tasks.js"; -import { inferOwnerByDomain, canonHardware, findClient, buildRespMapText, buildVocabText, norm as _norm } from "../lib/domain.js"; -import { - createTaskFactory, flat, findPath as findPathIn, counts, pct, taskDone, - taskDoneAt as taskDoneAtIn, contains, depthOf as depthOfIn, heightOf, fitsDepth as fitsDepthIn, -} from "../lib/tree.js"; -import { createDateHelpers } from "../lib/dates.js"; -import { - cap1, stripCaptions, findOwnerId, findDue, findSize, - normalizeProposal, mockTranscript, isoCap, -} from "../lib/capture.js"; -import { initApp, scheduleSave } from "../lib/persistence.js"; - -/* ================= sample data ================= */ -/* al = ASR aliases: common Whisper mishearings of each name. - In production this mapping is done by the extraction LLM given the roster, - plus Whisper initial_prompt biasing ("Team: Jean, Florian, Iannis, …"). */ - -const { T, setUid, getUid } = createTaskFactory(); -initBoardDefaults(setUid, () => buildSampleTasks(T)); - -const findPath = (id, nodes = DATA, path = []) => findPathIn(id, nodes, path); -const depthOf = (id) => depthOfIn(id, DATA); -const fitsDepth = (node, destId) => fitsDepthIn(node, destId, DATA); - -/* ================= helpers ================= */ -/* size-weighted progress (0..1): done size-points / total size-points across the leaves */ -const progFrac = (n) => { let done = 0, tot = 0; flat([n], (x) => { if (x.children.length) return; const w = SIZE_PTS[x.size || "m"]; tot += w; if (x.done) done += w; }); return tot ? done / tot : 0; }; -function dueChip(due,done){ if(!due||done) return ""; - const dd=Math.round((new Date(due)-TODAY)/864e5); - const cls=dd<0?"overdue":dd<=3?"soon":""; - const lbl=dd<0?`${-dd}d overdue`:dd===0?"Due today":dd===1?"Due tomorrow":"Due "+new Date(due).toLocaleDateString("en-GB",{day:"numeric",month:"short"}); - return `${lbl}`; } -const prChip=p=>({high:'High',med:'Medium',low:'Low'})[p]; -const av=(pid,cls="sm")=>{const p=PEOPLE[pid];return p.photo - ? `` - : `${p.initials}`;}; -const GRIP_SVG=''; - -/* ================= undo (ctrl/cmd+Z) ================= */ -const UNDO=[]; -let requestSave = () => {}; -function snap(){ UNDO.push(JSON.stringify(DATA)); if(UNDO.length>60) UNDO.shift(); requestSave(); } -function undo(){ if(!UNDO.length) return; - DATA.splice(0,DATA.length,...JSON.parse(UNDO.pop())); - closeSheet(); ding(0); renderAll(); requestSave(); } -document.addEventListener("keydown",e=>{ - if(e.key==="Escape"){ closeSheet(); closeCapture(); closeTeam(); closeBarMenu(); closeTranscript(); closeReview(); hideTip(); return; } - if((e.ctrlKey||e.metaKey)&&!e.shiftKey&&e.key.toLowerCase()==="z"){ - if(/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return; // let fields keep their own undo - e.preventDefault(); undo(); }}); - -/* ================= dashboard ================= */ -let lastTilt=0, ownerFilter="all"; -const want=o=>ownerFilter==="all"||o===ownerFilter; -function setFilter(k){ ownerFilter=k; closeFlyouts(); setTimeout(renderAll,0); } -/* one roll-out open at a time; expandable icons stay lit while their options are out */ -function closeFlyouts(exceptId){ - document.querySelectorAll(".gfctl.open").forEach(c=>{ if(c.id!==exceptId){ c.classList.remove("open"); - if(c.classList.contains("gfexp")){ const b=c.querySelector(".gficon"); if(b)b.classList.remove("on"); } } }); -} -function toggleFlyout(name){ - const c=document.getElementById("ctl-"+name); if(!c) return; - const willOpen=!c.classList.contains("open"); - closeFlyouts(willOpen?c.id:null); - c.classList.toggle("open",willOpen); - const b=c.querySelector(".gficon"); if(b) b.classList.toggle("on",willOpen); -} -let _descTimer=null; -/* toggles flash a one-line description, then it rolls back in */ -function flashDesc(name,text){ - const c=document.getElementById("ctl-"+name); if(!c) return; - closeFlyouts(c.id); - const dd=c.querySelector(".gfdesc"); if(dd&&text) dd.textContent=text; - c.classList.add("open"); clearTimeout(_descTimer); - _descTimer=setTimeout(()=>c.classList.remove("open"),1800); -} -/* click anywhere outside a control closes any open roll-out */ -document.addEventListener("pointerdown",e=>{ if(!e.target.closest(".gfctl")) closeFlyouts(); },true); -/* owners are an always-visible segmented row of avatars (built once, re-rendered on change) */ -function renderFilter(){ - const pop=document.getElementById("gfilterPop"); - if(pop) pop.innerHTML= - ``+ - Object.entries(PEOPLE).map(([k,p])=> - ``).join(""); - // light the collapsed owner icon when a specific person is filtered (so it's clear a filter is on) - const pb=document.getElementById("gpeoplebtn"); if(pb) pb.classList.toggle("gactive",ownerFilter!=="all"); -} -let SCALE_SUB=false; // false = task view (top-level tasks), true = subtask view (leaves) -function setScaleView(sub){ SCALE_SUB=sub; renderDash(); } -const taskDoneAt = (n) => taskDoneAtIn(n); -function renderDash(){ - renderGantt(); - if(!document.getElementById("myday")) return; // this version runs without the scale pane - const sv=document.getElementById("scview"); - if(sv) sv.innerHTML= - ``+ - ``; - const inView=(n,depth)=>SCALE_SUB?!n.children.length:depth===1; - const mine=[]; - flat(DATA,(n,depth,path)=>{ if(!inView(n,depth)||!want(n.owner)||taskDone(n)) return; - const dd=n.due?Math.round((new Date(n.due)-TODAY)/864e5):null; - mine.push({n,proj:(path[0]||n).title,dd,dn:false}); - }); - // balance scale over the selected time window: outstanding work weighs the left arm, - // work finished within the window counterweights on the right - const HZ=ZOOMS[ZOOM].h; - const myLate=mine.filter(x=>x.dd!==null&&x.dd<0).sort((a,b)=>a.dd-b.dd); - const myToday=mine.filter(x=>x.dd===0); - const upcoming=HZ>0?mine.filter(x=>x.dd!==null&&x.dd>0&&x.dd<=HZ).sort((a,b)=>a.dd-b.dd):[]; - const banked=[]; - flat(DATA,(n,depth,path)=>{ if(!inView(n,depth)||!want(n.owner)||!taskDone(n)) return; - const da=taskDoneAt(n); if(!da) return; - const ago=Math.round((TODAY-new Date(da))/864e5); - if(ago>=0&&ago<=Math.max(HZ,0)) banked.push({n,proj:(path[0]||n).title,dn:true}); }); - const PT=x=>SIZE_PTS[x.n.size||"m"]; - const pill=(x,cls,side)=>`
- - - ${av(x.n.owner,"xs")} -
`; - let lt=0,rt=0; - // angry-birds physics: late = heaviest arm (3×), start-today middle (2×), due-today lightest (1×) - const leftHtml=[ - ...myLate.map(x=>{lt+=PT(x)*3;return pill(x,"late","l");}), - ...myToday.map(x=>{lt+=PT(x)*2;return pill(x,"today","l");}), - ...upcoming.map(x=>{lt+=PT(x)*1;return pill(x,"soon","l");})].join(""); - const rightHtml=banked.map(x=>{rt+=PT(x)*2;return pill(x,"done","r");}).join(""); - let tilt=Math.max(-9,Math.min(9,(lt-rt)*0.8)); - document.getElementById("myday").innerHTML=` -
-
-
${leftHtml}${rightHtml}
-
-
-
-
-
late · due today${HZ>0?` · ${({7:"due this week",21:"due in 3 weeks",42:"due in 6 weeks"})[HZ]}`:""} · ${HZ>0?"done this period":"done today"} ✓
`; - sizeScale(); - settleScale(lastTilt); // position pills synchronously, then animate to the new tilt - if(typeof requestAnimationFrame!=="undefined") - requestAnimationFrame(()=>requestAnimationFrame(()=>applyTilt(tilt))); - lastTilt=tilt; -} -const SPLIT_MQ="(min-width:1100px) and (orientation:landscape)"; -function applyTilt(tilt){ - const b=document.getElementById("beamEl"); if(!b) return; - b.style.transform=`translateX(-50%) rotate(${-tilt}deg)`; - settleScale(tilt); -} -/* physics layout, in plank coordinates: u = distance along the beam, v = height above it. - Pills lie parallel to the plank and slide along it; on the dipping side they jam - against the card wall (which, seen from the plank, is a slanted line — so the wall - column staircases: each box rests a little further up-plank than the one below it). - On the raised side they slide down against the fulcrum. */ -function layoutScale(tilt){ - const wrap=document.getElementById("scaleEl"), beam=document.getElementById("beamEl"), - pile=document.getElementById("pileEl"); - if(!wrap||!beam||!pile) return 999; - const W=wrap.clientWidth||600, H=wrap.clientHeight||235; - const B=beam.offsetWidth||W*0.96, C=B/2; - const tan=Math.tan(Math.abs(tilt)*Math.PI/180); - const els=s=>[...pile.querySelectorAll(".pill.side-"+s)]; - const boxes=[]; - const place=(list,wall,dir,limit,stairs)=>{ - const base=84+(Math.min(Math.abs(tilt),9)/9)*Math.max(H-230,0); // steeper tilt → taller jam - let i=0,col=0,u=wall; - while(i0?u>limit-170:u0&&v+h>cap) break; // column full → next column up-plank - let u0=dir>0?u:u-w; - if(stairs) u0=dir>0?Math.max(u0,wall+v*tan):Math.min(u0,wall-w-v*tan); // wall staircase - u0=dir>0?Math.min(Math.max(u0,0),limit-w):Math.max(Math.min(u0,B-w),limit); - el.style.left=u0+"px"; el.style.bottom=(7+v)+"px"; - boxes.push({u:u0,v,w,h}); - v+=h+5; colW=Math.max(colW,w); i++; - } - u+=dir*(colW+8); col++; - } - }; - const FW=36; // fulcrum keep-out - if(tilt>=-0.5) place(els("l"),0,1,C-FW,tilt>0.5); else place(els("l"),C-FW,-1,0,false); - if(tilt<= 0.5) place(els("r"),B,-1,C+FW,tilt<-0.5); else place(els("r"),C+FW,1,B,false); - // highest pile point in screen space (used to grow the stacked card) - const phi=-tilt*Math.PI/180, cy=H-70; - let minTop=cy; - boxes.forEach(b=>{const yl=-(b.v+b.h+7); - [b.u-C,b.u+b.w-C].forEach(xl=>{ - const y=cy+xl*Math.sin(phi)+yl*Math.cos(phi); - if(yt<0?t:(t<1?t*TW:TW+(t-1)); // day → stretched-day units -const gx=t=>(uDay(t)-R0G)/SPAN_EFFV*100; // day → % position on the track -/* a due date means END of that day, and open work never lives in the past: - — done tasks keep their historical span - — late and due-today tasks ALL span exactly the today box, ending ON the today line - — future tasks start today at the earliest and end at the end of their due day */ -/* re-renders triggered by clicks are deferred out of the input event: mutating the DOM - while Chrome is still dispatching the click can wedge its hover/input pipeline - (frozen cursor + dead hover until a tab switch) */ -const defer=fn=>setTimeout(fn,0); -function toggleShowDone(){ showDone=!showDone; const b=$id("gdonebtn"); if(b)b.classList.toggle("on",showDone); - defer(renderGantt); } -/* the control cluster is FIXED to the viewport so it stays put while the chart scrolls under it. - At the top it sits just under the date ribbon; as the ribbon scrolls away it rises to the top. */ -function placeFloat(){ const g=document.querySelector(".gantt"), fl=$id("gfloat"); - if(!g||!fl) return; const gr=g.getBoundingClientRect(), ax=document.querySelector(".gaxis"); - let top=gr.top+10; - if(ax){ const ar=ax.getBoundingClientRect(); top=Math.max(top,ar.bottom+8); } - fl.style.position="fixed"; - fl.style.right=Math.max(14,(window.innerWidth-gr.right)+14)+"px"; - fl.style.top=top+"px"; } -window.addEventListener("resize",()=>placeFloat()); -const EXP=new Set(); // individually EXPANDED tasks (when the global toggle is off) -const COL=new Set(); // individually COLLAPSED tasks (when the global toggle is on) -/* a task's subtasks are open if: global toggle on AND not individually collapsed, OR - global toggle off AND individually expanded — so the chevron always works either way */ -const subOpen=id=>subsAll?!COL.has(id):EXP.has(id); -function toggleExp(id){ if(subsAll){ COL.has(id)?COL.delete(id):COL.add(id); } - else { EXP.has(id)?EXP.delete(id):EXP.add(id); } defer(renderGantt); } -let subsAll=false; // global show/hide all subtasks -function toggleSubs(){ subsAll=!subsAll; COL.clear(); // start each "show all" fully expanded - const b=$id("gsubbtn"); if(b)b.classList.toggle("on",subsAll); - defer(renderGantt); } -let focusToday=false; // show only today's priorities (late + due today + started) -function toggleFocus(){ focusToday=!focusToday; const b=$id("gfocusbtn"); if(b)b.classList.toggle("on",focusToday); - defer(renderGantt); } -let GVIEW="proj"; // "proj" | "tasks" | "subs" -function setGView(v){ GVIEW=v; defer(renderGantt); } -function setZoom(i){ ZOOM=i; setTimeout(renderAll,0); } // drives the scale window and the gantt zoom -function renderGantt(){ - const VIS=ZOOMS[ZOOM].v; - // fixed-width today box: convert TODAY_PX into day units for the current zoom + panel width. - // On phones the box is narrower so the rest of the timeline isn't squeezed off-screen. - const TPX=(typeof window!=="undefined"&&window.innerWidth<=740)?150:TODAY_PX; - const host=document.querySelector(".gscroll")||document.getElementById("gantt"); - const PW=Math.max((host&&host.clientWidth)||0,360); - const dayPx=Math.max((PW-TPX)/Math.max(VIS-1,1),3); - TW=TPX/dayPx; SPAN_EFFV=SPAN_G+(TW-1); - // calendar axis: month headers + one weekday-letter + number per day (weekly when too tight) - const showDaily=dayPx>=20; - const months=[]; let lastM=-1; - for(let d=0;d<=R1G;d++){ const dt=new Date(dayIso(d)), m=dt.getFullYear()*12+dt.getMonth(); - if(m!==lastM){ lastM=m; months.push({d,label:dt.toLocaleDateString("en-GB",{month:"short",year:"numeric"})}); } } - const dticks=[]; - for(let d=0;d<=R1G;d++){ const dt=new Date(dayIso(d)), wknd=dt.getDay()%6===0; - if(showDaily||dt.getDay()===1||d===0) dticks.push({d,wd:"SMTWTFS"[dt.getDay()],num:dt.getDate(),wknd,today:d===0}); } - const zoomEl=document.getElementById("gzoom"); - if(zoomEl){ // build once; afterwards only toggle classes (keeps the clicked button alive) - if(!zoomEl.childElementCount) - zoomEl.innerHTML=ZOOMS.map((z,i)=>``).join(""); - [...zoomEl.children].forEach((b,i)=>b.classList.toggle("active",ZOOM===i)); - } - // keep the three toggle pictograms lit in line with their state - [["gdonebtn",showDone],["gsubbtn",subsAll],["gfocusbtn",focusToday]].forEach(([id,on])=>{ - const b=document.getElementById(id); if(b) b.classList.toggle("on",on); }); - const gv=document.getElementById("gview"), GVKEYS=["proj","tasks"]; - if(gv){ - if(!gv.childElementCount) - gv.innerHTML=[["proj","By project"],["tasks","Prioritized tasks"]] - .map(([k,l])=>``).join(""); - [...gv.children].forEach((b,i)=>b.classList.toggle("active",GVIEW===GVKEYS[i])); - } - // calendar grid: faint day lines, firmer Monday lines, alternate weeks washed - const wk=[0]; - for(let d=1;d<=R1G;d++) if(new Date(dayIso(d)).getDay()===1) wk.push(d); - wk.push(R1G); - const deco=[]; - for(let i=0;i`); - for(let d=1;d`); } - months.forEach(m=>{ if(m.d>0) deco.push(`
`); }); - const td=new Date(dayIso(0)); - const ord=n=>{const s=["th","st","nd","rd"],v=n%100;return n+(s[(v-20)%10]||s[v]||s[0]);}; - const todayStr="Today, "+td.toLocaleDateString("en-GB",{weekday:"long"})+" "+ord(td.getDate())+" "+td.toLocaleDateString("en-GB",{month:"long"}); - const todayMid=(gx(0)+gx(1))/2; - const rows=[deco.join("")+ - `
-
-
${months.map((m,i)=>{const end=i+1${m.label}`;}).join("")}
-
${dticks.map(t=> t.today - ? `${todayStr}` - : `${t.wd}${t.num}`).join("")}
`]; - let any=false; - /* ctx (priority views only): {proj, parent, col} — bar takes the project colour and the - tooltip carries the full story: where it lives, how big, how important */ - const barRow=(n,extra,isSub,ctx)=>{ // one chart row for a task or a subtask - if(!n.due) return ""; - const {s,e}=spanFor(n), done=isSub?n.done:taskDone(n); - if(eR1G) return ""; - const late=!done&&e<0, [tcs,tce]=barGeom(s,e,done), sz=n.size||"m"; - const h=isSub?Math.max(15,Math.round(GBAR_H[sz]*0.62)):GBAR_H[sz]; // subtasks shorter than tasks - // a task with subtasks shows its duration-weighted completion as a darker fill inside its - // own bar (same two-tone idea as the project summary bar, applied in place) - const hasKids=!isSub&&n.children.length>0, donePct=hasKids?Math.round(progWD(n)*100):0; - const col=barColor(e,s,done); - const fillBg=(hasKids&&!done&&donePct>0) - ? `background-color:${col};background-image:linear-gradient(90deg,rgba(0,0,0,.26) 0 ${donePct}%,rgba(0,0,0,0) ${donePct}% 100%)` - : `background:${col}`; - const tip=`${n.title} · ${SIZE_NAMES[sz]} · ${fmtD(dayIso(e))}${late?' (late)':''}${hasKids?` · ${donePct}% done`:""}`+(ctx - ?` — ${ctx.proj}${ctx.parent?" › "+ctx.parent:""} · ${({high:"high",med:"medium",low:"low"})[n.priority||"med"]} priority · ${PEOPLE[n.owner].name}` - :""); - return `
-
- - ${av(n.owner,"xs")} - ${n.title} - - -
${extra?`
${extra}
`:""} -
`; - }; - const chevFor=t=>{ const exp=subOpen(t.id); - return t.children.length?``:""; }; - // subtask visibility is controlled only by Show subtasks / per-task expansion — NOT by focus. - // When focus is on AND subtasks are visible, only the urgent subtasks are included. - const subRows=(t,ctx)=>(!subOpen(t.id))?"":t.children - .filter(c=>want(c.owner)&&(showDone||!c.done)&&(!focusToday||isUrgent(c))) - .map(c=>barRow(c,"",true,ctx?{proj:ctx.proj,parent:t.title,col:ctx.col}:null)).join(""); - if(GVIEW==="proj") DATA.forEach(p=>{ - let open=0, maxE=1, anyLeaf=false; - flat([p],n=>{ if(n.children.length||!want(n.owner)) return; - anyLeaf=true; - if(!n.done) open++; - if(n.due) maxE=Math.max(maxE,dayN(n.due)); }); - if(!anyLeaf&&!want(p.owner)) return; - const col=PEOPLE[p.owner].color; - // thin summary bar spanning the project's whole task range (earliest start → latest due) - const sp=rollupSpan(p); - const scs=Math.max(sp.s,R0G), sce=Math.max(Math.min(sp.e+1,R1G),scs+0.5); - const taskRows=[...p.children] - .filter(t=>{ let rel=want(t.owner); - flat([t],x=>{ if(!x.children.length&&want(x.owner)) rel=true; }); return rel; }) - .filter(t=>showDone||!taskDone(t)) - .filter(t=>!focusToday||isUrgent(t)) - /* render in the project's own task order (no date sort) so manual reordering — - from the project window or by dragging a bar — is reflected directly */ - .map(t=>barRow(t,chevFor(t),false,null)+subRows(t,null)).join(""); - if(focusToday&&!taskRows) return; // nothing urgent in this project — hide it - any=true; - const prog=progWD(p), ppc=Math.round(prog*100), spanW=gx(sce)-gx(scs); - // project bar thickness scales with the project's total weight (sum of its leaf size points) - let pPts=0; flat([p],x=>{ if(x.children.length) return; pPts+=SIZE_PTS[x.size||"m"]; }); - const ph=Math.max(7,Math.min(20,Math.round(5+Math.sqrt(pPts)*2.2))); - rows.push(`
-
- -
-
- ${ppc}% -
- ${taskRows}
`); - }); - else{ - // priority views: project boxes drop away; the most urgent & biggest work floats to the top - const wantTask=GVIEW==="tasks", PRW={high:0,med:1,low:2}, cand=[]; - flat(DATA,(n,depth,path)=>{ - if(wantTask?depth!==1:n.children.length>0) return; - let rel=want(n.owner); - if(wantTask&&!rel) flat([n],x=>{ if(!x.children.length&&want(x.owner)) rel=true; }); - if(!rel) return; - if((wantTask?taskDone(n):n.done)&&!showDone) return; - if(!n.due) return; - const {s,e}=barSpan(n); - if(eR1G) return; - const root=path[0]||n, par=path.length>1?path[path.length-2]:null; - cand.push({n,e,root,par}); - }); - cand.sort((a,b)=>a.e-b.e - ||SIZE_PTS[b.n.size||"m"]-SIZE_PTS[a.n.size||"m"] - ||PRW[a.n.priority||"med"]-PRW[b.n.priority||"med"]); - any=cand.length>0; - rows.push('
'); - cand.forEach(({n,root,par})=>{ - const ctx={proj:root.title,col:PEOPLE[root.owner].color, - parent:(!wantTask&&par&&par!==root)?par.title:null}; - rows.push(barRow(n,wantTask?chevFor(n):"",!wantTask,ctx)); - if(wantTask) rows.push(subRows(n,ctx)); - }); - rows.push('
'); - } - document.getElementById("gantt").innerHTML= - `
`+ - rows.join("")+ - (any?"":'
No scheduled tasks for this filter.
')+ - `
`; - const sc=document.querySelector(".gscroll"); - sc.addEventListener("scroll",pinFlags,{passive:true}); - const gpane=document.querySelector(".gantt"); - if(gpane&&!gpane._floatBound){ gpane._floatBound=true; gpane.addEventListener("scroll",placeFloat,{passive:true}); } - pinFlags(); placeFloat(); placeOverflowTitles(); - // unstick Chromium's hover hit-testing after the DOM swap (otherwise tooltips/hover - // stay dead until you move the mouse or switch tabs) - if(typeof requestAnimationFrame!=="undefined") - requestAnimationFrame(kickHover); -} -/* After replacing #gantt's innerHTML, nudge native :hover (the done-dot and resize ears) back - to life with a synchronous reflow. The TOOLTIP is intentionally NOT synthesized here — it is - driven only by real hover events (see the hover module below). Synthesizing it from - elementFromPoint made the tip pop on clicks (e.g. when a filter flyout closed and the - hit-test fell through to a bar behind it). We only clear a now-stale tip. */ -function kickHover(){ - try{ - document.body.style.pointerEvents="none"; - void document.body.offsetHeight; // force synchronous reflow → refresh native :hover - document.body.style.pointerEvents=""; - }catch(e){} - if(typeof tipEl!=="undefined" && tipEl && !tipEl.isConnected) hideTip(); // hovered bar was replaced -} -/* keep project names visible: if a flag's pole is outside the scrolled viewport, - pin the flag to the nearest edge with an arrow; it snaps back when the pole returns */ -/* when a bar is too narrow to show even half its title, hide the inner label and print the - full title as plain text just to the right of the bar (no background) */ -function placeOverflowTitles(){ - document.querySelectorAll("#gantt .gttlout").forEach(x=>x.remove()); - document.querySelectorAll("#gantt .gtrack").forEach(track=>{ - track.querySelectorAll(":scope > .gbar").forEach(bar=>{ - const ttl=bar.querySelector(".ttl"); if(!ttl) return; - const full=ttl.scrollWidth, vis=ttl.clientWidth; - if(full>4 && vis{ - const lbl=row.querySelector(".gsumlbl"), line=row.querySelector(".gsumline"); - if(!lbl||!line) return; - // keep the project name visible: slide the label to the viewport's left edge as its - // range line scrolls past, but never beyond the line's right end - const ll=line.offsetLeft, lr=ll+line.offsetWidth; - let left=Math.max(ll,v0+4); - left=Math.min(left,Math.max(lr-46,ll)); - lbl.style.left=left+"px"; - }); -} -window.addEventListener("resize",()=>{ sizeScale(); applyTilt(lastTilt); defer(renderGantt); }); - -/* --- floating "gripped pill" ghost, shared by bar drags and pop-up task drags --- */ -function makeGhost(text,color){ const g=document.createElement("div"); - g.className="dragghost"; g.textContent=text; - if(color) g.style.background=color; - document.body.appendChild(g); return g; } -function placeGhost(g,e){ g.style.left=(e.clientX+16)+"px"; g.style.top=(e.clientY-34)+"px"; } -const rootOf=id=>findPath(id)[0]; -function projUnder(e,dragId){ - const t=document.elementFromPoint(e.clientX,e.clientY)?.closest?.(".pgroup[data-pid]"); - return t&&+t.dataset.pid!==rootOf(dragId).id?t:null; } -function dropInto(groupEl,id){ const node=detach(id); - findPath(+groupEl.dataset.pid).pop().children.push(node); } - -/* --- bar dragging: move / resize ears / drop on a project pill --- */ -let G=null, suppressCtx=0; -const siblingsOf=id=>{ const p=findPath(id); p.pop(); const par=p.pop(); return par?par.children:DATA; }; -/* right-click (desktop) opens the quick menu — but a touch long-press also fires contextmenu, - and that case already opened the detail popup, so swallow it for ~0.8s after a long-press */ -function barContext(ev,id,rect){ ev.preventDefault(); - if(Date.now()-suppressCtx<800) return; openBarMenu(id,rect); } -function barDown(e,id,mode){ - if(e.button!==undefined && e.button>0) return; // right/middle mouse → let oncontextmenu open the quick menu - e.preventDefault(); e.stopPropagation(); hideTip(); - const el=e.target.closest(".gbar"), track=el.parentElement; - const n=findPath(id).pop(), {s,e:en}=barSpan(n); - const touch=e.pointerType==="touch"; - el.classList.add("dragging"); - G={id,mode,el,n,touch,longTimer:null,ppd:track.getBoundingClientRect().width/SPAN_EFFV, - x0:e.clientX,y0:e.clientY,s0:s,e0:en,s,e:en,moved:false,overProj:null, - axis:mode==="move"?null:"date",reTo:null,reRow:null, // move starts axis-undecided - ghost:mode==="move"?makeGhost(n.title,el.style.background):null}; - if(G.ghost) G.ghost.style.display="none"; // appears once the bar actually moves - // touch: a LONG-PRESS on a bar opens the full detail popup (a short tap opens the quick menu) - if(touch && mode==="move"){ - G.longTimer=setTimeout(()=>{ if(!G||G.moved) return; - document.removeEventListener("pointermove",barMove); - document.removeEventListener("pointerup",barUp); - document.removeEventListener("pointercancel",barUp); - G.el.classList.remove("dragging"); G.el.style.opacity=""; if(G.ghost) G.ghost.remove(); - const nid=G.id; G=null; suppressCtx=Date.now(); // swallow the contextmenu this long-press will also fire - if(navigator.vibrate){ try{navigator.vibrate(8);}catch(_){} } openDetail(nid); - },480); - } - document.addEventListener("pointermove",barMove,{passive:false}); - document.addEventListener("pointerup",barUp); - document.addEventListener("pointercancel",barUp); -} -function clearReMark(){ document.querySelectorAll(".grow.reinsb,.grow.reinsa") - .forEach(x=>x.classList.remove("reinsb","reinsa")); } -/* vertical drag of a bar = reorder among its siblings (or drop into another project) */ -function barReorderMove(ev){ - G.moved=true; G.el.style.opacity=".45"; - if(G.ghost){ G.ghost.style.display=""; G.ghost.textContent="⇅ "+G.n.title; placeGhost(G.ghost,ev); } - clearReMark(); G.reTo=null; G.reRow=null; - if(G.overProj){ G.overProj.classList.remove("gdropover"); G.overProj=null; } - const el=document.elementFromPoint(ev.clientX,ev.clientY); - const bar=el?.closest?.(".gbar[data-tid]"), sibs=siblingsOf(G.id); - if(bar&&+bar.dataset.tid!==G.id&&sibs.some(s=>s.id===+bar.dataset.tid)){ - const row=bar.closest(".grow"), r=bar.getBoundingClientRect(), after=ev.clientY>r.top+r.height/2; - row.classList.add(after?"reinsa":"reinsb"); G.reRow=row; - G.reTo=sibs.findIndex(s=>s.id===+bar.dataset.tid)+(after?1:0); - } else { // not over a sibling → offer to move into another project - const t=projUnder(ev,G.id); G.overProj=t; if(t) t.classList.add("gdropover"); - } -} -function barMove(ev){ - if(!G) return; - ev.preventDefault(); - // any real movement cancels a pending long-press (it's a drag, not a press) - if(G.longTimer&&(Math.abs(ev.clientX-G.x0)>5||Math.abs(ev.clientY-G.y0)>5)){ clearTimeout(G.longTimer); G.longTimer=null; } - if(G.axis===null){ // decide intent on first real movement - const dx=ev.clientX-G.x0, dy=ev.clientY-G.y0; - if(Math.max(Math.abs(dx),Math.abs(dy))<5) return; - G.axis=Math.abs(dy)>Math.abs(dx)*1.25?"reorder":"date"; - } - if(G.axis==="reorder") return barReorderMove(ev); - const dd=Math.round((ev.clientX-G.x0)/G.ppd); - if(dd!==0) G.moved=true; - if(G.mode==="move"){ G.s=G.s0+dd; G.e=G.e0+dd; } - else if(G.mode==="l"){ G.s=Math.min(G.s0+dd,G.e0); } - else { G.e=Math.max(G.e0+dd,G.s0); } - const [cs,ce]=barGeom(G.s,G.e,G.n.done); - G.el.style.left=gx(cs)+"%"; - G.el.style.width=(gx(ce)-gx(cs))+"%"; - if(G.ghost){ G.ghost.style.display=G.moved?"":"none"; - G.ghost.textContent=G.n.title+" · due "+fmtD(dayIso(G.e)); - placeGhost(G.ghost,ev); } - if(G.mode==="move"){ - const t=projUnder(ev,G.id); - if(G.overProj&&G.overProj!==t) G.overProj.classList.remove("gdropover"); - G.overProj=t; if(t) t.classList.add("gdropover"); - } -} -function barUp(e){ - document.removeEventListener("pointermove",barMove); - document.removeEventListener("pointerup",barUp); - document.removeEventListener("pointercancel",barUp); - if(!G) return; // long-press already handled it - if(G.longTimer){ clearTimeout(G.longTimer); G.longTimer=null; } - G.el.classList.remove("dragging"); G.el.style.opacity=""; - if(G.ghost) G.ghost.remove(); - clearReMark(); - if(e&&e.type==="pointercancel"){ if(G.overProj)G.overProj.classList.remove("gdropover"); G=null; return; } - // vertical reorder / cross-project move - if(G.axis==="reorder"){ - const dropped=G.overProj; if(dropped) dropped.classList.remove("gdropover"); - if(dropped&&fitsDepth(G.n,+dropped.dataset.pid)){ snap(); dropInto(dropped,G.id); G=null; ding(2); renderAll(); return; } - const arr=siblingsOf(G.id), from=arr.findIndex(s=>s.id===G.id); let to=G.reTo; - if(to!=null&&from>-1){ if(to>from) to--; if(to!==from){ snap(); const [nd]=arr.splice(from,1); arr.splice(to,0,nd); ding(2); } } - G=null; renderAll(); return; - } - const n=G.n, dropped=G.overProj; - if(dropped) dropped.classList.remove("gdropover"); - if(!G.moved&&!dropped){ // a tap/click that didn't drag - const r=G.el.getBoundingClientRect(), touch=G.touch, mode=G.mode, nid=n.id; G=null; - if(mode!=="move") return; // tap on a resize ear → do nothing - if(touch) openBarMenu(nid,r); // mobile: tap = quick menu (long-press already gives the popup) - else openDetail(nid); // desktop: left-click = full detail popup - return; } - snap(); - if(G.mode==="move"){ n.due=dayIso(G.e); if(n.start) n.start=dayIso(G.s); } - else if(G.mode==="l"){ n.start=dayIso(G.s); } - else { if(!n.start) n.start=dayIso(G.s0); n.due=dayIso(G.e); } - if(dropped) dropInto(dropped,n.id); - G=null; ding(2); renderAll(); -} - -/* --- grip a row in a pop-up. Stay inside the pop-up → reorder the list. - Drag outside it → the pop-up closes and you assign the item to another - project (drop on its box) or task (drop on its bar), depth rules permitting. --- */ -let RD=null; -function rowDown(e,id){ - e.preventDefault(); e.stopPropagation(); hideTip(); - const path=findPath(id), n=path[path.length-1], parent=path[path.length-2]; - RD={id,n,parentId:parent?parent.id:null,box:document.querySelector(".tbox"), - mode:"reorder",over:null,toIdx:null, - ghost:makeGhost(n.title,PEOPLE[n.owner].color)}; - placeGhost(RD.ghost,e); - document.addEventListener("pointermove",rowMove,{passive:false}); - document.addEventListener("pointerup",rowUp); - document.addEventListener("pointercancel",rowUp); -} -function clearRowMark(){ document.querySelectorAll(".ptask.insb,.ptask.insa") - .forEach(x=>x.classList.remove("insb","insa")); } -function rowMove(e){ - e.preventDefault(); placeGhost(RD.ghost,e); - if(RD.mode==="reorder"){ - const r=RD.box&&RD.box.getBoundingClientRect(); - if(!r||e.clientXr.right||e.clientYr.bottom){ - RD.mode="assign"; RD.over=null; RD.toIdx=null; - clearRowMark(); closeSheet(); // left the pop-up → reveal the timeline targets - }else{ - clearRowMark(); RD.over=null; RD.toIdx=null; - const t=document.elementFromPoint(e.clientX,e.clientY)?.closest?.(".ptask[data-cid]"); - if(t&&+t.dataset.cid!==RD.id){ - const tr=t.getBoundingClientRect(), after=e.clientY>tr.top+tr.height/2; - t.classList.add(after?"insa":"insb"); - RD.over=t; - RD.toIdx=[...RD.box.querySelectorAll(".ptask[data-cid]")].indexOf(t)+(after?1:0); - } - return; - } - } - // assign mode: task bars first (finer target), then project boxes - const el=document.elementFromPoint(e.clientX,e.clientY); - let t=el?.closest?.(".gbar[data-tid]")||null; - if(t){ const tid=+t.dataset.tid; - if(tid===RD.id||tid===RD.parentId||contains(RD.n,tid)||!fitsDepth(RD.n,tid)) t=null; } - if(!t){ const g=el?.closest?.(".pgroup[data-pid]"); - if(g){ const pid=+g.dataset.pid; - if(pid!==RD.parentId&&fitsDepth(RD.n,pid)) t=g; } } - if(RD.over&&RD.over!==t) RD.over.classList.remove("gdropover"); - RD.over=t; if(t) t.classList.add("gdropover"); -} -function rowUp(){ - document.removeEventListener("pointermove",rowMove); - document.removeEventListener("pointerup",rowUp); - document.removeEventListener("pointercancel",rowUp); - clearRowMark(); RD.ghost.remove(); - const S=RD; RD=null; - if(S.mode==="reorder"){ - if(S.toIdx!=null&&S.parentId!=null){ - const parent=findPath(S.parentId).pop(); - const from=parent.children.findIndex(c=>c.id===S.id); - let to=S.toIdx; if(to>from) to--; - if(from>-1&&to!==from){ snap(); - const [nd]=parent.children.splice(from,1); - parent.children.splice(to,0,nd); - ding(2); renderAll(); openDetail(S.parentId); } - } - return; // released inside the pop-up: nothing moved, pop-up stays open - } - if(S.over){ S.over.classList.remove("gdropover"); - snap(); const node=detach(S.id); - findPath(+(S.over.dataset.tid||S.over.dataset.pid)).pop().children.push(node); - ding(2); renderAll(); } -} - -/* --- drag a project flag vertically to reorder projects; a plain click opens the popup --- */ -let PD=null; -function projDown(e,pid){ - e.preventDefault(); e.stopPropagation(); hideTip(); - PD={pid,started:false,x0:e.clientX,y0:e.clientY,over:null,pos:null,ghost:null}; - document.addEventListener("pointermove",projMove,{passive:false}); - document.addEventListener("pointerup",projUp); - document.addEventListener("pointercancel",projUp); -} -function clearProjMark(){ document.querySelectorAll(".pgroup.insb,.pgroup.insa") - .forEach(x=>x.classList.remove("insb","insa")); } -function projMove(e){ - e.preventDefault(); - if(!PD.started){ - if(Math.hypot(e.clientX-PD.x0,e.clientY-PD.y0)<6) return; // click tolerance - PD.started=true; - const p=DATA.find(x=>x.id===PD.pid); - PD.ghost=makeGhost("⇅ "+p.title,PEOPLE[p.owner].color); - } - placeGhost(PD.ghost,e); - clearProjMark(); PD.over=null; - const t=document.elementFromPoint(e.clientX,e.clientY)?.closest?.(".pgroup[data-pid]"); - if(t&&+t.dataset.pid!==PD.pid){ - const r=t.getBoundingClientRect(); - PD.pos=e.clientYx.id===pid), [proj]=DATA.splice(from,1); - let to=DATA.findIndex(x=>x.id===+over.dataset.pid); - if(pos==="after") to++; - DATA.splice(to,0,proj); - ding(2); renderAll(); -} - -/* ---- moving tasks between projects/parents (via modal "Move to") ---- */ -/* strict 3-level hierarchy: project (0) -> task (1) -> subtask (2) */ -function detach(id){ const p=findPath(id), node=p.pop(), parent=p.pop(); - const arr=parent?parent.children:DATA; arr.splice(arr.indexOf(node),1); return node; } -function moveInto(id,dest){ if(!dest||id===dest||contains(findPath(id).pop(),dest))return false; - if(!fitsDepth(findPath(id).pop(),dest)) return false; // would exceed 3 levels - const target=findPath(dest).pop(); const node=detach(id); - target.children.push(node); target.open=true; return true; } -function moveTask(id,dest){ snap(); - if(moveInto(id,+dest)){ renderAll(); openDetail(id); } else UNDO.pop(); } - -function toggleDone(id){ snap(); const n=findPath(id).pop(); - const stamp=x=>x.doneAt=x.done?TODAY.toISOString().slice(0,10):null; - if(!n.children.length){ n.done=!n.done; stamp(n); } - else { const target=pct(n)!==100; flat([n],x=>{if(!x.children.length){x.done=target;stamp(x);}}); } - renderAll(); } - -/* ================= task modal — comprehensive & editable ================= */ -/* ===== on-the-go bar menu: quick edits without the full detail sheet ===== */ -let BARMENU=null; -const BM=document.createElement("div"); BM.id="barMenu"; BM.className="barmenu"; document.body.appendChild(BM); -function openBarMenu(id,anchor){ - const path=findPath(id); if(!path) return; const n=path.pop(); - if(anchor&&anchor.getBoundingClientRect) anchor=anchor.getBoundingClientRect(); - if(anchor) BM._anchor={left:anchor.left,right:anchor.right,top:anchor.top,bottom:anchor.bottom}; - const a=BM._anchor||{left:100,right:160,top:100,bottom:130}; - BM.innerHTML=` -
Owner
-
${Object.entries(PEOPLE).map(([k,p])=>``).join("")}
-
Size${["s","m","l","xl"].map(z=>``).join("")}
-
Due
`; - BM.classList.add("show"); BARMENU=id; - const mw=BM.offsetWidth||236, mh=BM.offsetHeight||170, gap=8, vw=window.innerWidth, vh=window.innerHeight; - // sit to the RIGHT of the pill (flip left only if there's no room) - let left=a.right+gap; if(left+mw>vw-8) left=Math.max(8,a.left-mw-gap); - // align with the pill's top and roll down; if that would overflow, align to its bottom and roll up - let top=(a.top+mh<=vh-8)?a.top:Math.max(8,a.bottom-mh); - top=Math.max(8,Math.min(top,vh-mh-8)); - BM.style.left=left+"px"; BM.style.top=top+"px"; -} -function refreshBarMenu(id){ if(BARMENU===id) openBarMenu(id); } -function closeBarMenu(){ BM.classList.remove("show"); BARMENU=null; } -document.addEventListener("pointerdown",e=>{ if(BARMENU&&!e.target.closest("#barMenu")) closeBarMenu(); },true); - -function updTask(id,f,v,quiet){ snap(); const n=findPath(id).pop(); - if(f==="title") n.title=v.trim()||n.title; - else if(f==="owner") n.owner=v; - else if(f==="priority") n.priority=v; - else if(f==="due") n.due=v||null; - else if(f==="start") n.start=v||null; - else if(f==="size") n.size=v||null; - renderAll(); if(!quiet&&f!=="title") openDetail(id); } -function deleteTask(id){ const n=findPath(id).pop(); - if(typeof confirm!=="undefined"&&!confirm('Delete "'+n.title+'"'+(n.children.length?" and its subtasks":"")+"?")) return; - snap(); detach(id); closeSheet(); renderAll(); } -function addChild(id){ const el=document.getElementById("dSubNew"), v=el.value.trim(); if(!v) return; - if(findPath(id).length>=3) return; // subtasks can't have children - snap(); const n=findPath(id).pop(); n.children.push(T(cap1(v),n.owner,{d:n.due||null})); - renderAll(); openDetail(id); } -function openDetail(id){ - const path=findPath(id); if(!path) return; - const n=path[path.length-1], leaf=!n.children.length; - document.getElementById("dCrumb").innerHTML=path.length>1 - ?path.slice(0,-1).map(x=>``).join(" › ")+" ›" - :"Project"; - const ti=document.getElementById("dTitle"); - ti.value=n.title; ti.onchange=e=>updTask(id,"title",e.target.value); - const par=path.length>1?path[path.length-2].id:null; - const mopts=['']; - flat(DATA,(x,depth)=>{ if(contains(n,x.id))return; - if(depth+1+heightOf(n)>2) return; // keep the 3-level hierarchy - mopts.push(``); }); - // Size: leaves carry an editable t-shirt size; projects/parent tasks SHOW the rolled-up - // point total (sum of their leaves' size points) — not a field the user fills in. - let _szPts=0; flat([n],x=>{ if(x.children.length) return; _szPts+=SIZE_PTS[x.size||"m"]; }); - const sizeFld=leaf - ? `
Size
` - : `
Size${_szPts} pts
`; - document.getElementById("dBody").innerHTML=` -
Owner${av(n.owner)}
-
Due${dueChip(n.due,leaf&&n.done)}
- ${sizeFld} - ${leaf?`
Status -
`:""} -
Move to
- ${path.length>=3?"":`
${path.length>1?"Subtasks":"Tasks — grip ⠿ to drag onto another project"}
`} - ${n.children.map(ch=>{ const lp=pct(ch), lleaf=!ch.children.length; - let _cp=0; flat([ch],x=>{ if(x.children.length) return; _cp+=SIZE_PTS[x.size||"m"]; }); - const szCtl=lleaf - ? `` - : `${_cp} pts`; - return `
- - - - ${ownerPill(ch.owner,`updTask(${ch.id},'owner',this.value,true);openDetail(${id})`)} - ${szCtl} - ${dueChip(ch.due,lleaf&&ch.done)}
`;}).join("")} - ${path.length>=3?"":`
`} - `; - document.getElementById("tmodal").classList.add("show"); - document.getElementById("scrim").classList.add("show"); -} -function closeSheet(){ document.getElementById("tmodal").classList.remove("show"); - document.getElementById("scrim").classList.remove("show"); } - -/* ================= conversational capture ================= */ -/* Real product: each turn's transcript + context (roster, project list, today) is POSTed - to /extract, which calls the LLM (ChatGPT) with a JSON schema and returns structured - fields + the next question. Here callExtract() falls back to a local mock so the whole - UX is demoable; swap EXTRACT_URL for the live endpoint and nothing else changes. */ -const $id=x=>document.getElementById(x); -const EXTRACT_URL=null; // e.g. "/extract" once the serverless function is live - -let actx; -function ding(step=0){ try{ - actx=actx||new (window.AudioContext||window.webkitAudioContext)(); - const o=actx.createOscillator(), g=actx.createGain(), t0=actx.currentTime; - o.type="sine"; o.frequency.setValueAtTime(740+step*80,t0); o.frequency.exponentialRampToValueAtTime(1180+step*80,t0+0.08); - g.gain.setValueAtTime(0.0001,t0); g.gain.exponentialRampToValueAtTime(0.09,t0+0.02); - g.gain.exponentialRampToValueAtTime(0.0001,t0+0.35); - o.connect(g); g.connect(actx.destination); o.start(t0); o.stop(t0+0.4); -}catch(e){} } - -let CAP=null, rec=null, listening=false, capTTS=false, capLang="en"; // input language; output is always English -function toggleCapLang(){ capLang=capLang==="en"?"fr":"en"; - $id("langBtn").textContent="🌐 "+capLang.toUpperCase(); - if(listening){ stopListen(); } } - -function openCapture(){ - CAP={turns:[], history:[], draft:{}, pending:null, ready:false, busy:false}; capQueue=[]; - $id("capChat").innerHTML=""; $id("capInput").value=""; - $id("capCard").className="capcard"; $id("capActions").innerHTML=""; - updateKeyBadge(); renderCapCard({pending:null}); // show the (empty) creation card - botSay("Hi! What would you like to do? For example: “new project Roman Pilot, first task get insurance, due Monday.”"); - $id("vmodal").classList.add("show"); $id("vmodal").classList.remove("min"); - if(!getKey()) askKey(); // offer to connect GPT on first use - else setTimeout(()=>$id("capInput").focus(),50); -} -function minimizeCapture(){ stopListen(); $id("vmodal").classList.add("min"); } -function restoreCapture(){ $id("vmodal").classList.remove("min"); setTimeout(()=>{const i=$id("capInput"); i&&i.focus();},50); } -function closeCapture(){ stopListen(); window.speechSynthesis&&speechSynthesis.cancel(); - $id("vmodal").classList.remove("show","min"); CAP=null; } - -function bubble(text,cls){ const b=document.createElement("div"); - b.className="bub "+cls; b.textContent=text; - const c=$id("capChat"); c.appendChild(b); c.scrollTop=c.scrollHeight; return b; } -let lastBotText=""; -const normCap=s=>(s||"").toLowerCase().replace(/[^a-z0-9 ]/g,"").replace(/\s+/g," ").trim(); -function botSay(text){ lastBotText=text; bubble(text,"bot"); if(capTTS) speak(text); } -function speak(text){ try{ if(!window.speechSynthesis) return; - speechSynthesis.cancel(); const u=new SpeechSynthesisUtterance(text); - u.rate=1.05; u.lang="en-US"; - if(listening){ micPaused=true; try{rec.stop();}catch(e){} } // hard-pause the mic so it can't hear itself - speaking=true; - u.onend=u.onerror=()=>{ speaking=false; - if(listening&&micPaused){ micPaused=false; try{rec.start();}catch(e){} } }; - speechSynthesis.speak(u); }catch(e){ speaking=false; micPaused=false; } } -function toggleTTS(){ capTTS=!capTTS; - $id("ttsBtn").textContent=capTTS?"🔊 Voice on":"🔇 Voice off"; - $id("ttsBtn").classList.toggle("on",capTTS); - if(!capTTS&&window.speechSynthesis) speechSynthesis.cancel(); } - -async function sendTurn(){ - if(!CAP||CAP.busy) return; - const el=$id("capInput"), text=el.value.trim(); if(!text) return; - el.value=""; bubble(text,"me"); CAP.turns.push(text); - CAP.busy=true; const think=bubble("…","bot think"); - const r=await callExtract(text,CAP.draft,CAP.pending); - think.remove(); CAP.busy=false; - CAP.draft=r.draft; CAP.pending=r.pending; CAP.ready=r.ready; - botSay(r.assistantSay); - CAP.history.push({role:"user",content:text},{role:"assistant",content:r.assistantSay}); - if(CAP.history.length>16) CAP.history=CAP.history.slice(-16); - renderCapCard(r); -} - -/* ---- sidebar popovers: search (top) and settings (bottom) ---- */ -function closeSidePops(except){ ["sbsearchpop","sbsettingspop"].forEach(id=>{ if(id!==except){ const p=document.getElementById(id); if(p)p.classList.remove("show"); } }); } -function toggleSearch(){ const p=document.getElementById("sbsearchpop"); if(!p)return; closeSidePops("sbsearchpop"); - const open=p.classList.toggle("show"); if(open){ const i=document.getElementById("searchbox"); if(i){i.focus();i.select&&i.select();} } } -function toggleSettings(){ const p=document.getElementById("sbsettingspop"); if(!p)return; closeSidePops("sbsettingspop"); - const open=p.classList.toggle("show"); if(open){ const i=document.getElementById("setKeyInput"); if(i)i.value=getKey(); } } -function closeSettings(){ const p=document.getElementById("sbsettingspop"); if(p)p.classList.remove("show"); } -/* hide / show the whole left rail; the chart reclaims the space and re-lays out once it settles */ -function toggleSidebar(){ const hidden=document.body.classList.toggle("sbhide"); closeSidePops(); - const b=document.querySelector(".sbtoggle"); if(b) b.title=hidden?"Show sidebar":"Hide sidebar"; - placeFloat(); setTimeout(()=>{ if(typeof renderGantt==="function") renderGantt(); placeFloat(); },240); } - -/* ---- OpenAI key, kept in this browser session only (never written to the file) ---- */ -let OAI_KEY=""; -function getKey(){ try{ return sessionStorage.getItem("oai_key")||OAI_KEY; }catch(e){ return OAI_KEY; } } -function setKeyVal(v){ OAI_KEY=v; try{ v?sessionStorage.setItem("oai_key",v):sessionStorage.removeItem("oai_key"); }catch(e){} } -function askKey(){ const has=!!getKey(); - $id("keyInput").value=""; $id("keyInput").placeholder=has?"Key saved — paste a new one to replace":"sk-…"; - $id("clearKey").style.display=has?"inline-flex":"none"; - $id("keyModal").classList.add("show"); setTimeout(()=>$id("keyInput").focus(),50); } -function saveKey(){ const v=$id("keyInput").value.trim(); if(v) setKeyVal(v); - $id("keyInput").value=""; $id("keyModal").classList.remove("show"); updateKeyBadge(); } -function skipKey(){ $id("keyModal").classList.remove("show"); } -function clearKey(){ setKeyVal(""); $id("keyModal").classList.remove("show"); updateKeyBadge(); } -function updateKeyBadge(){ const b=$id("keyBtn"); if(b) b.textContent=getKey()?"🔑 GPT on":"🔑 Key"; } - -/* swappable extraction: a live endpoint, then OpenAI direct (browser key), then the mock */ -const OWNER_IDS=[...Object.keys(PEOPLE),null]; // owner must be a real teammate id, never free text -const OAI_SCHEMA={type:"object",additionalProperties:false, - required:["intent","project","task","tasks","remove","owner","parentId","due","size","pending","ready","assistantSay"], - properties:{ - remove:{type:"array",items:{type:"string"}}, - intent:{type:["string","null"],enum:["create_project","create_task","create_subtask",null]}, - project:{type:["string","null"]}, task:{type:["string","null"]}, - tasks:{type:"array",items:{type:"object",additionalProperties:false, - required:["title","owner","due","size","subs"], - properties:{title:{type:"string"},owner:{type:["string","null"],enum:OWNER_IDS}, - due:{type:["string","null"]},size:{type:["string","null"],enum:["s","m","l","xl",null]}, - subs:{type:"array",items:{type:"object",additionalProperties:false, - required:["title","owner","due","size"], - properties:{title:{type:"string"},owner:{type:["string","null"],enum:OWNER_IDS}, - due:{type:["string","null"]},size:{type:["string","null"],enum:["s","m","l","xl",null]}}}}}}}, - owner:{type:["string","null"],enum:OWNER_IDS}, parentId:{type:["integer","null"]}, - due:{type:["string","null"]}, size:{type:["string","null"],enum:["s","m","l","xl",null]}, - pending:{type:["string","null"]}, ready:{type:"boolean"}, assistantSay:{type:"string"}}}; -function captureContext(){ return {today:isoCap(TODAY), - people:Object.entries(PEOPLE).map(([id,p])=>({id,name:p.name,responsibility:p.role,aka:p.al})), - hardware:HARDWARE_VOCAB, clients:CLIENTS.map(c=>c.name), - projects:DATA.map(p=>({id:p.id,name:p.title,tasks:p.children.map(t=>({id:t.id,name:t.title}))}))}; } -async function callExtract(text,draft,pending){ - if(EXTRACT_URL){ try{ - const res=await fetch(EXTRACT_URL,{method:"POST",headers:{"Content-Type":"application/json"}, - body:JSON.stringify({text,draft,pending,context:captureContext()})}); - return await res.json(); - }catch(e){ /* fall through */ } } - const key=getKey(); - if(key){ try{ return await openaiExtract(text,draft,pending,key); } - catch(e){ bubble("⚠︎ GPT call failed ("+e.message+") — using offline parser.","bot think"); } } - await new Promise(r=>setTimeout(r,300)); // simulate latency - return mockExtract(text,draft,pending); -} -async function openaiExtract(text,draft,pending,key){ - const sys=`You convert a teammate's spoken/typed request into a structured task-capture object for a 3-level planner (project > task > subtask). -LANGUAGE: the user may speak or type in English OR French — understand both perfectly. ALL OUTPUT MUST BE IN ENGLISH: translate every project/task/subtask name to English, and write assistantSay in English, regardless of the input language. -Rules: -- Merge the NEW utterance into the CURRENT draft; KEEP earlier fields unless the user changes them. Never discard the project the user is building. The user may correct any field at any time ("actually call it X", "change owner to Y"). -- intent is one of create_project / create_task / create_subtask, and stays create_project while the user is still building a new project. -- When intent is create_project, collect the project's tasks in the "tasks" array. ALWAYS return the COMPLETE cumulative list — every task added so far in currentDraft.tasks PLUS any new one this turn. If the user says "add a task / first task / another task ...", append a new item; never drop previously added tasks and never switch intent or use parentId. -- Task titles are concise imperative phrases with NO leading article — "Clean the bathroom", not "a clean the bathroom". Each item has title, plus owner/due/size if stated (else null), plus a "subs" array (empty if none). -- SUBTASKS: the word "subtask" ALWAYS means an item inside some existing task's "subs" array — NEVER a new top-level task, no matter how many are added. When the user says "add subtask(s)" / "add N subtasks": if they name or imply a parent ("for the first task", "under clean the bathroom"), use it; if they DON'T name one, attach the subtasks to the LAST task currently in the tasks array. Return that task's complete subs list. Subtasks are leaves (no further subs). Never increase the number of top-level tasks when the user said "subtask". -- ASSIGNEE INFERENCE: when no owner is explicitly named for a task/subtask, look at the task's CONTENT and assign the teammate whose responsibility best matches it, using this RESPONSIBILITY MAP: -${buildRespMapText()} - Examples: "Install RS03 motor on prototype" → Iannis or Sanket; "Implement obstacle avoidance for the Derichebourg pilot" → Akshat; "Fix D-Wave board power issue" → Leynaïck. Only when nothing in the content maps to a responsibility, fall back to the PROJECT's owner. If the user says "owners same as the project", set every task's and subtask's owner to the project owner (this overrides inference). -- DOMAIN VOCABULARY — use these EXACT spellings; never invent or mis-spell hardware or client names: -${buildVocabText()} - Map mis-heard variants to the canonical form (e.g. "RS zero three"/"RS-3" → "RS03", "dwave" → "D-Wave", "jaycee decaux" → "JCDecaux"). -- When the user gives an ordered list of due dates/owners "in that order" for the tasks, apply them positionally to the tasks in their current order. -- DELETING: you CAN delete. When the user asks to remove/delete a task or subtask (e.g. "delete the two tests you just added", "remove clean the toilet"), put each item's exact current title into the "remove" array. Otherwise "remove" is []. Never say you can't delete. -- Use intent create_task / create_subtask ONLY when adding to a project/task that ALREADY EXISTS in context.projects. Then set parentId to that existing id. -- "task" (singular) is only for create_task/create_subtask; for create_project leave "task" null and use "tasks". -- owner MUST be one of the provided people ids, or null. Names are frequently MIS-HEARD by voice transcription — map any spelling variant or mishearing listed in the responsibility map to the correct id (e.g. "Janice"/"Yannis"/"Ioannis" → Iannis "ia"; "Flo"/"Florine" → Florian "fd"; "Sankeet" → Sanket "sk"). Do NOT assign the work to a different real teammate just because the heard name is fuzzy; if you genuinely cannot resolve it, use null rather than guessing the wrong person. -- due: resolve relative dates ("Monday","tomorrow","in 3 days") to absolute YYYY-MM-DD using context.today; else null. size: s/m/l/xl if stated else null. -- pending = the single most useful field still needed ("projectName","taskTitle","parent","owner"), or null if nothing required is missing. Required: create_project needs project(name); create_task/subtask need task(title) and parentId. -- ready = true when required fields are present (a project is ready once it has a name, even with zero tasks). -- assistantSay = one short, natural sentence confirming what you understood and asking the next thing (or noting it's ready). Talk like a helpful colleague, not a form. If you just appended a task, acknowledge it and invite another or Create. -- Use the prior conversation messages to resolve references: "them/those/all of them", "the first one", "same", "same for the subtasks". "Same for X" / "same for the subtasks" means apply the value MOST RECENTLY set or discussed (e.g. the due date you just applied to the tasks) to X — do NOT guess a different attribute. If the last thing set was a due date, "same for the subtasks" sets that same due date on every subtask. -Return ONLY the JSON object.`; - const history=(typeof CAP!=="undefined"&&CAP&&CAP.history)?CAP.history.slice(-12):[]; - const body={model:"gpt-4o-mini",temperature:0, - messages:[{role:"system",content:sys},...history, - {role:"user",content:JSON.stringify({newUtterance:text,currentDraft:draft||{},pendingField:pending,context:captureContext()})}], - response_format:{type:"json_schema",json_schema:{name:"capture",strict:true,schema:OAI_SCHEMA}}}; - const res=await fetch("https://api.openai.com/v1/chat/completions",{method:"POST", - headers:{"Content-Type":"application/json","Authorization":"Bearer "+key}, - body:JSON.stringify(body)}); - if(!res.ok){ throw new Error(res.status+" "+(await res.text()).slice(0,140)); } - const o=JSON.parse((await res.json()).choices[0].message.content); - const clean=s=>s?canonHardware(cap1(s.replace(/^(?:a|an|the)\s+/i,"").trim())):s; - // merge by title: UPDATE fields on existing tasks (so "set size on each task" actually lands), - // APPEND genuinely new ones, and keep tasks GPT didn't mention so nothing is dropped. - // owner falls back to a domain guess from the title when the model left it null. - const cleanSubs=arr=>(arr||[]).map(s=>{const ti=clean(s.title); - return {title:ti,owner:s.owner||inferOwnerByDomain(ti),due:s.due||null,size:s.size||null};}).filter(s=>s.title); - const wantsSub=/\bsub ?-?tasks?\b/i.test(text||""); // user explicitly asked for subtasks - const out=((draft&&draft.tasks)||[]).map(t=>({...t,subs:(t.subs||[]).slice()})); - const idx=new Map(out.map((t,i)=>[(t.title||"").toLowerCase(),i])); - const fresh=[]; - (o.tasks||[]).forEach(t=>{ const ti=clean(t.title); if(!ti) return; const k=ti.toLowerCase(); - if(idx.has(k)){ const e=out[idx.get(k)]; - if(t.owner) e.owner=t.owner; if(t.due) e.due=t.due; if(t.size) e.size=t.size; - if(t.subs&&t.subs.length) e.subs=cleanSubs(t.subs); - } else fresh.push({title:ti,owner:t.owner||inferOwnerByDomain(ti),due:t.due||null,size:t.size||null,subs:cleanSubs(t.subs)}); }); - // safety net: if the user said "subtask", new items belong UNDER the last task, not as top-level tasks - if(wantsSub && out.length){ const last=out[out.length-1]; last.subs=last.subs||[]; - fresh.forEach(f=>last.subs.push({title:f.title,owner:f.owner,due:f.due,size:f.size})); } - else fresh.forEach(f=>{ out.push(f); idx.set(f.title.toLowerCase(),out.length-1); }); - // deletions: drop any task or subtask whose title GPT listed in "remove" - let finalTasks=out; - const rm=new Set((o.remove||[]).map(s=>(s||"").toLowerCase().trim()).filter(Boolean)); - if(rm.size){ finalTasks=out.filter(t=>!rm.has((t.title||"").toLowerCase())); - finalTasks.forEach(t=>{ t.subs=(t.subs||[]).filter(s=>!rm.has((s.title||"").toLowerCase())); }); } - return {draft:{intent:o.intent,project:o.project,task:clean(o.task),tasks:finalTasks,owner:o.owner,parentId:o.parentId,due:o.due,size:o.size}, - pending:o.pending,ready:o.ready,assistantSay:o.assistantSay}; -} - -/* ---- local stand-in for the LLM: understands intent + fills fields across turns ---- */ -function matchProject(t){ let best=null,score=0; - DATA.forEach(p=>{ const n=p.title.toLowerCase(); - if(t.includes(n)){ if(n.length>score){ score=n.length; best=p; } } }); - return best; } - -function mockExtract(text,draft,pending){ - draft=JSON.parse(JSON.stringify(draft||{})); - const raw=text.trim(), t=" "+raw.toLowerCase()+" "; - const newlyAsked=pending; - - // a turn that simply answers the bot's pending question routes wholesale into that field - if(pending==="projectName"){ draft.project=cap1(raw.replace(/[.?!]+$/,"")); } - else if(pending==="taskTitle"){ draft.task=cap1(raw.replace(/^(it'?s|its|to|the task is)\s+/i,"").replace(/[.?!]+$/,"")); } - else if(pending==="owner"){ const o=findOwnerId(t); if(o) draft.owner=o; } - else if(pending==="parent"){ const p=matchProject(t); if(p) draft.parentId=p.id; } - else if(pending==="due"){ const due=findDue(t); if(due) draft.due=due; } - - // mixed-initiative: always scan for explicit signals too (user may over-specify) - if(!draft.intent){ - if(/\bproject\b/.test(t)) draft.intent="create_project"; - else if(/\bsub ?task\b/.test(t)) draft.intent="create_subtask"; - else if(/\btask\b/.test(t)) draft.intent="create_task"; - } - if(!draft.owner){ const o=findOwnerId(t); if(o&&/\b(owner|own|assign|for|by)\b/.test(t)) draft.owner=o; } - const due=findDue(t); if(due) draft.due=due; - const sz=findSize(t); if(sz) draft.size=sz; - // project name from "(project|product) ... (called|named) X" or "project for X" - if(draft.intent==="create_project"&&!draft.project){ - let m=raw.match(/(?:project|product)[^.]*?(?:called|named|name is|is called)\s+(.+?)(?=\s+(?:and|the task|task|due|owner|by|with|subtask)\b|[.,!?]|$)/i) - ||raw.match(/(?:new project)\s+(?:called\s+|named\s+|for\s+)?(.+?)(?=\s+(?:and|the task|task|due|owner|by|with|subtask)\b|[.,!?]|$)/i); - if(m) draft.project=cap1(m[1].trim().replace(/^(?:called|named|is\s+called)\s+/i,"")); } - // explicit (re)naming at any turn — lets the user correct a wrong name ("call it X", "should be called X") - if(draft.intent==="create_project"){ - const rn=raw.match(/(?:call it|name it|rename it to|should be called|it'?s called|the project is(?: called)?|name is(?: called)?)\s+(.+?)(?=\s+(?:and|task|due|owner|by|with|subtask)\b|[.,!?]|$)/i); - if(rn) draft.project=cap1(rn[1].trim().replace(/^(?:called|named)\s+/i,"")); } - draft.tasks=draft.tasks||[]; - // a task mentioned in this turn - const tm=raw.match(/(?:add (?:a |another )?|first |second |third |next )?(?:new )?task\s+(?:called\s+|named\s+|is\s+|to\s+|:\s*)?(.+?)(?=\s+(?:and|due|owner|by|with|subtask|the project)\b|[.,!?]|$)/i); - if(draft.intent==="create_project"){ - // building a project → append the task to its list (don't switch intent) - if(tm){ const v=canonHardware(tm[1].trim().replace(/^(?:called|named)\s+/i,"")); - if(v.length>1) draft.tasks.push({title:cap1(v),owner:findOwnerId(t)||inferOwnerByDomain(v),due:findDue(t),size:findSize(t),subs:[]}); } - } else if(!draft.task){ - if(tm){ const v=tm[1].trim().replace(/^(?:called|named)\s+/i,""); if(v.length>1) draft.task=cap1(v); } - } - // existing-project reference for create_task - if(draft.intent==="create_task"&&!draft.parentId){ const p=matchProject(t); if(p) draft.parentId=p.id; } - // strip a trailing "… to/in/for " the task regex may have swallowed - if(draft.task&&draft.parentId){ const p=findPath(draft.parentId)?.pop(); - if(p){ const esc=p.title.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"); - draft.task=cap1(draft.task.replace(new RegExp("\\s+(?:to|in|for|under)\\s+"+esc+"\\s*$","i"),"").trim()); } } - - return finalize(draft,newlyAsked); -} - -function finalize(draft,justAsked){ - if(!draft.intent) return {draft,pending:null,ready:false, - assistantSay:"Tell me what to create — a project, a task, or a subtask."}; - const have={ projectName:!!draft.project, taskTitle:!!draft.task, - owner:!!draft.owner, parent:!!draft.parentId }; - let order; - if(draft.intent==="create_project") order=[["projectName","What should the project be called?"],["owner","Who owns it?"]]; - else order=[["taskTitle","What’s the task?"],["parent","Which project does it go in?"],["owner","Who owns it?"]]; - const next=order.find(([k])=>!have[k]); - // confirmation fragment for what we understood so far - const bits=[]; - if(draft.project) bits.push("project “"+draft.project+"”"); - if(draft.tasks&&draft.tasks.length) bits.push(draft.tasks.length+(draft.tasks.length>1?" tasks":" task")); - if(draft.task) bits.push("task “"+draft.task+"”"); - if(draft.parentId){ const p=findPath(draft.parentId)?.pop(); if(p) bits.push("in "+p.title); } - if(draft.owner) bits.push("owner "+PEOPLE[draft.owner].name); - if(draft.due) bits.push("due "+new Date(draft.due).toLocaleDateString("en-GB",{day:"numeric",month:"short"})); - const got=bits.length?"Got it — "+bits.join(", ")+". ":""; - if(next) return {draft,pending:next[0],ready:false,assistantSay:got+next[1]}; - return {draft,pending:null,ready:true, - assistantSay:got+"Ready to create — review and hit Create, or keep talking to adjust."}; -} - -/* ---- editable creation card (middle panel) mirrors the draft; edits write to CAP.draft ---- */ -function renderCapCard(r){ - const d=CAP.draft; d.tasks=d.tasks||[]; const miss=r.pending; - const card=$id("capCard"), title=$id("buildTitle"); - // the creation form stays hidden until the assistant knows what you want to build - const build=document.querySelector(".cappanel.build"); - if(build) build.style.display=d.intent?"flex":"none"; - if(!d.intent) return; - const ownerChips=`
${Object.entries(PEOPLE).map(([k,p])=> - ``).join("")}
`; - const projSel=``; - const rows=[]; - const row=(key,lbl,html)=>rows.push(`
${lbl}${html}
`); - if(d.intent==="create_project"){ - title.textContent="New project"; - row("projectName","Project",``); - row("owner","Owner",ownerChips); - row("due","Due",``); - // tasks (and their subtasks) default to the project owner when none is set - if(d.owner) d.tasks.forEach(t=>{ if(!t.owner) t.owner=d.owner; - (t.subs||[]).forEach(s=>{ if(!s.owner) s.owner=t.owner||d.owner; }); }); - const tl=d.tasks.length?d.tasks.map((tk,i)=>taskCardHTML(tk,i)).join("") - :`
No tasks yet — say “add a task called …” or type one below.
`; - rows.push(`
Tasks
${tl} -
-
`); - } else { - title.textContent=d.intent==="create_subtask"?"New subtask":"New task"; - row("taskTitle","Task",``); - row("parent","Project",projSel); - row("owner","Owner",ownerChips); - row("due","Due",``); - } - card.innerHTML=rows.join(""); card.className="capcard show"; - const ready=d.intent==="create_project"?(!!d.project):(!!d.task&&!!d.parentId); - $id("capActions").innerHTML=``; -} -function refreshCard(){ renderCapCard({pending:CAP.pending}); } -function addCapTask(){ const el=$id("capTaskNew"); if(!el) return; const v=el.value.trim(); if(!v) return; - CAP.draft.tasks=CAP.draft.tasks||[]; CAP.draft.tasks.push({title:v[0].toUpperCase()+v.slice(1),owner:null,due:null,size:null,subs:[]}); - refreshCard(); setTimeout(()=>$id("capTaskNew")&&$id("capTaskNew").focus(),0); } -function delCapTask(i){ CAP.draft.tasks.splice(i,1); refreshCard(); } - -/* a task = title + owner + due + size + its own subtasks, editably & tidily */ -const escq=s=>(s||"").replace(/"/g,"""); -const ownerOpts=v=>``+Object.entries(PEOPLE).map(([k,p])=> - ``).join(""); -function ownerPill(v,onch){ const col=v?PEOPLE[v].color:"#c2c8d2"; - return ` - `; } -function duePill(v,onch,sm){ return ``; } -function szSeg(v,onch){ return `${["s","m","l","xl"].map(z=> - ``).join("")}`; } -function taskCardHTML(tk,i){ tk.subs=tk.subs||[]; - return `
-
- -
-
- ${ownerPill(tk.owner,`setTaskOwner(${i},this.value)`)} - ${duePill(tk.due,`setTask(${i},'due',this.value)`)} - ${szSeg(tk.size,`setTaskSize(${i},Z)`)}
- ${tk.subs.map((s,j)=>`
- - ${ownerPill(s.owner,`setSubOwner(${i},${j},this.value)`)} - ${duePill(s.due,`setSub(${i},${j},'due',this.value)`,true)} -
`).join("")} -
-
-
`; -} -function setTask(i,f,v){ CAP.draft.tasks[i][f]=(f==="due"||f==="size"||f==="owner")?(v||null):v; } -function setTaskOwner(i,v){ CAP.draft.tasks[i].owner=v||null; refreshCard(); } -function setTaskSize(i,z){ const t=CAP.draft.tasks[i]; t.size=(t.size===z?null:z); refreshCard(); } -function setSub(i,j,f,v){ CAP.draft.tasks[i].subs[j][f]=(f==="due"||f==="size"||f==="owner")?(v||null):v; } -function setSubOwner(i,j,v){ CAP.draft.tasks[i].subs[j].owner=v||null; refreshCard(); } -function addSub(i){ const el=$id("subNew"+i); if(!el) return; const v=el.value.trim(); if(!v) return; - CAP.draft.tasks[i].subs=CAP.draft.tasks[i].subs||[]; - CAP.draft.tasks[i].subs.push({title:v[0].toUpperCase()+v.slice(1),owner:null,due:null,size:null}); - refreshCard(); setTimeout(()=>$id("subNew"+i)&&$id("subNew"+i).focus(),0); } -function delSub(i,j){ CAP.draft.tasks[i].subs.splice(j,1); refreshCard(); } - -function commitCapture(){ - const d=CAP.draft, owner=d.owner||"fd"; - snap(); - let focusId; - if(d.intent==="create_project"){ - /* by default a project, its tasks and their subtasks share the same due date — - any level left blank inherits its parent's date */ - const projDue=d.due||null; - const proj=T(cap1(d.project||"New project"),owner,{d:projDue,open:true}); - (d.tasks||[]).forEach(tk=>{ - const tDue=tk.due||projDue||null; - const task=T(cap1(tk.title),tk.owner||owner,{d:tDue,s:tk.size||null,open:(tk.subs&&tk.subs.length>0)}); - (tk.subs||[]).forEach(s=>task.children.push(T(cap1(s.title),s.owner||tk.owner||owner,{d:s.due||tDue||null,s:s.size||null}))); - proj.children.push(task); }); - DATA.push(proj); focusId=proj.id; - } else { - const parent=d.parentId?findPath(d.parentId).pop():null; - if(!parent){ UNDO.pop(); botSay("Which project should it go in?"); CAP.pending="parent"; renderCapCard({pending:"parent"}); return; } - const node=T(cap1(d.task||"New task"),owner,{d:d.due||parent.due||null,s:d.size||null}); - if(!fitsDepth(node,parent.id)){ UNDO.pop(); - botSay("That would nest too deep — the hierarchy stops at project › task › subtask."); return; } - parent.children.push(node); parent.open=true; focusId=node.id; - } - ding(3); closeCapture(); renderAll(); // submitted — no extra screen -} - -/* ================= full-transcript processing → Review & Approve ================= */ -/* Paste a whole conversation; the LLM (or the offline mock) proposes MULTIPLE projects and - tasks at once. Nothing is created until the user accepts items and hits "Push approved". */ -const CLIENT_NAMES=[...CLIENTS.map(c=>c.name),null]; -const OAI_PROPOSAL_SCHEMA={type:"object",additionalProperties:false, - required:["assistantSay","projects"], - properties:{ - assistantSay:{type:"string"}, - projects:{type:"array",items:{type:"object",additionalProperties:false, - required:["name","client","owner","due","tasks"], - properties:{ - name:{type:"string"}, - client:{type:["string","null"],enum:CLIENT_NAMES}, - owner:{type:["string","null"],enum:OWNER_IDS}, - due:{type:["string","null"]}, - tasks:{type:"array",items:{type:"object",additionalProperties:false, - required:["title","owner","due","size","client","subs"], - properties:{ - title:{type:"string"}, - owner:{type:["string","null"],enum:OWNER_IDS}, - due:{type:["string","null"]}, - size:{type:["string","null"],enum:["s","m","l","xl",null]}, - client:{type:["string","null"],enum:CLIENT_NAMES}, - subs:{type:"array",items:{type:"object",additionalProperties:false, - required:["title","owner","due","size"], - properties:{title:{type:"string"},owner:{type:["string","null"],enum:OWNER_IDS}, - due:{type:["string","null"]},size:{type:["string","null"],enum:["s","m","l","xl",null]}}}} - }}} - }}}}}; -async function openaiTranscript(text,key){ - const sys=`You read a raw client/team conversation transcript and extract the NEW engineering projects and tasks it implies, for a 3-level planner (project > task > subtask). -LANGUAGE: the transcript may be English or French — ALL OUTPUT MUST BE IN ENGLISH. -- Group work into projects. A customer pilot becomes a project; set its "client" to the matching known client. Pure internal work has client=null. -- Each task: a concise imperative title (no leading article), owner, due (YYYY-MM-DD resolved from context.today, else null), size (s/m/l/xl or null), client (if the task is for a known client else null), and a subs array (usually empty). -- ASSIGNEE: infer each owner from this RESPONSIBILITY MAP using the task's content; only null if genuinely unclear: -${buildRespMapText()} -${buildVocabText()} - Use the exact hardware/client spellings above; map mis-hearings to the canonical form. -- Do NOT re-create work that already exists in context.projects — only return genuinely new items. -- assistantSay: one short sentence, e.g. "I identified 2 projects and 7 tasks from this conversation." -Return ONLY the JSON object.`; - const body={model:"gpt-4o-mini",temperature:0, - messages:[{role:"system",content:sys}, - {role:"user",content:JSON.stringify({transcript:text,context:captureContext()})}], - response_format:{type:"json_schema",json_schema:{name:"proposal",strict:true,schema:OAI_PROPOSAL_SCHEMA}}}; - const res=await fetch("https://api.openai.com/v1/chat/completions",{method:"POST", - headers:{"Content-Type":"application/json","Authorization":"Bearer "+key},body:JSON.stringify(body)}); - if(!res.ok) throw new Error(res.status+" "+(await res.text()).slice(0,140)); - return JSON.parse((await res.json()).choices[0].message.content); -} -/* offline stand-in: split the transcript into action clauses, route them to per-client projects */ -let PROP = null; -function normalizeProposalWrapped(raw) { - return normalizeProposal(raw); -} -async function extractTranscript(text){ - const key=getKey(); - if(key){ try{ return normalizeProposalWrapped(await openaiTranscript(text,key)); }catch(e){ /* fall back to mock */ } } - await new Promise(r=>setTimeout(r,400)); - return normalizeProposalWrapped(mockTranscript(text)); -} -function openTranscript(){ $id("trInput").value=""; $id("transcriptModal").classList.add("show"); setTimeout(()=>$id("trInput").focus(),50); } -function closeTranscript(){ $id("transcriptModal").classList.remove("show"); } -async function runTranscript(){ const text=$id("trInput").value.trim(); if(text.length<8) return; - const btn=$id("trGo"); btn.disabled=true; btn.textContent="Processing…"; - try{ PROP=await extractTranscript(text); } finally{ btn.disabled=false; btn.textContent="Process"; } - closeTranscript(); openReview(); } -/* paperclip in the chat: attach a transcript file and run it straight into Review & Approve */ -async function attachTranscript(input){ - const f=input.files&&input.files[0]; input.value=""; if(!f) return; - let text=""; try{ text=await f.text(); }catch(e){} - text=stripCaptions(text); - if(!text||text.trim().length<8){ bubble("That file looks empty — try a transcript with some text in it.","bot think"); return; } - bubble("📎 "+f.name,"me"); - const think=bubble("Reading the transcript…","bot think"); - try{ PROP=await extractTranscript(text); } catch(e){ PROP=null; } - think.remove(); - if(!PROP){ bubble("I couldn't read that file.","bot think"); return; } - openReview(); -} -/* drop WebVTT/SRT timestamp + index lines so only the spoken text reaches the model */ -function openReview(){ renderReview(); $id("reviewModal").classList.add("show"); } -function closeReview(){ $id("reviewModal").classList.remove("show"); PROP=null; } -function rvObj(uid){ if(!PROP) return null; - for(const p of PROP.projects){ if(p.uid===uid) return p; - for(const t of p.tasks){ if(t.uid===uid) return t; for(const s of t.subs){ if(s.uid===uid) return s; } } } return null; } -function rvToggle(uid){ const o=rvObj(uid); if(o){ o.accepted=!o.accepted; renderReview(); } } -function rvText(uid,f,v){ const o=rvObj(uid); if(o) o[f]=v; } // no re-render: keep input focus -function rvOwner(uid,v){ const o=rvObj(uid); if(o){ o.owner=v||null; renderReview(); } } -function rvDue(uid,v){ const o=rvObj(uid); if(o) o.due=v||null; } -function rvSize(uid,z){ const o=rvObj(uid); if(o){ o.size=(o.size===z?null:z); renderReview(); } } -function rvTaskHTML(p,t){ - return `
- - - - - - ${szSeg(t.size,`rvSize(${t.uid},Z)`)} - ${t.client?`${t.client}`:''} - - ${(t.subs||[]).map(s=>`
- - - -
`).join("")} -
`; -} -function renderReview(){ if(!PROP) return; - let na=0,nt=0; - PROP.projects.forEach(p=>{ if(p.accepted){ na++; p.tasks.forEach(t=>{ if(t.accepted) nt++; }); } }); - $id("rvBanner").textContent=PROP.assistantSay||`I identified ${PROP.projects.length} project(s).`; - $id("rvBody").innerHTML=PROP.projects.map(p=>`
-
- - - ${p.client?'Pilot · '+p.client:'Engineering'} -
-
- - -
-
${p.tasks.map(t=>rvTaskHTML(p,t)).join("")||'
No tasks proposed for this project.
'}
-
`).join("")||'
Nothing detected. Try a more detailed transcript.
'; - $id("rvActions").innerHTML=` - `; -} -function pushApproved(){ if(!PROP) return; snap(); let np=0; - PROP.projects.forEach(p=>{ if(!p.accepted) return; - const proj=T(cap1(p.name||"New project"),p.owner||"fd",{d:p.due||null,open:true}); - if(p.client) proj.client=p.client; // light CRM link tag - (p.tasks||[]).forEach(t=>{ if(!t.accepted) return; - const subsOK=(t.subs||[]).filter(s=>s.accepted); - const task=T(cap1(t.title||"New task"),t.owner||p.owner||"fd",{d:t.due||p.due||null,s:t.size||null,open:subsOK.length>0}); - if(t.client) task.client=t.client; - subsOK.forEach(s=>task.children.push(T(cap1(s.title||"Subtask"),s.owner||t.owner||p.owner||"fd",{d:s.due||t.due||p.due||null,s:s.size||null}))); - proj.children.push(task); }); - DATA.push(proj); np++; }); - closeReview(); ding(3); renderAll(); -} - -/* ---- continuous mic: stays live, auto-sends each finished sentence as a turn until you - stop it. Finished sentences are queued so a slow extract call never drops one. ---- */ -let capQueue=[], speaking=false, micPaused=false; -function setMic(on){ listening=on; const f=$id("micFab"); if(f) f.classList.toggle("live",on); } -/* the bottom-right mic is the only mic: tap to open the chat (if needed) and talk; tap again to stop */ -function micFabTap(){ const m=$id("vmodal"); - if(CAP&&m&&m.classList.contains("min")){ restoreCapture(); return; } // expand instead of toggling mic - if(!CAP){ openCapture(); setTimeout(toggleListen,150); } else toggleListen(); } -function pushTurn(text){ capQueue.push(text); drainQueue(); } -async function drainQueue(){ if(!CAP||CAP.busy) return; - while(capQueue.length){ $id("capInput").value=capQueue.shift(); await sendTurn(); } } -function isBotEcho(text){ const n=normCap(text), b=normCap(lastBotText); - return n.length>5 && b && (b.includes(n)||n.includes(b)); } -function toggleListen(){ if(listening) return stopListen(); - const SR=window.SpeechRecognition||window.webkitSpeechRecognition; - if(!SR){ botSay("Speech recognition isn’t available in this browser — type your answer instead. (In production this streams to Whisper.)"); return; } - rec=new SR(); rec.lang=capLang==="fr"?"fr-FR":"en-US"; rec.interimResults=true; rec.continuous=true; - rec.onresult=e=>{ if(speaking||micPaused) return; // never while the bot is talking - let fin="",intr=""; - for(let i=e.resultIndex;i{ if(listening&&!micPaused){ try{rec.start();}catch(e){} } else if(!listening) setMic(false); }; - rec.onerror=()=>{}; - try{ rec.start(); setMic(true); }catch(e){ setMic(false); } -} -function stopListen(){ listening=false; micPaused=false; if(rec){ try{rec.stop();}catch(e){} } setMic(false); } - -/* ================= team photos ================= */ -function openTeam(){ renderTeam(); $id("teamModal").classList.add("show"); } -function closeTeam(){ $id("teamModal").classList.remove("show"); } -function renderTeam(){ - $id("teamList").innerHTML=Object.entries(PEOPLE).map(([k,p])=>`
- ${av(k,"lg")}${p.name} - - ${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(); requestSave(); }; - r.readAsDataURL(f); } -function removePhoto(k){ delete PEOPLE[k].photo; renderTeam(); renderAll(); requestSave(); } - -/* ================= search ================= */ -function doSearch(){ - const q=document.getElementById("searchbox").value.trim().toLowerCase(); - const box=document.getElementById("searchres"); - if(q.length<2){ box.innerHTML=""; box.style.display="none"; return; } - const hits=[]; - flat(DATA,(n,d,path)=>{ if(hits.length>=8) return; - if(n.title.toLowerCase().includes(q)||PEOPLE[n.owner].name.toLowerCase().includes(q)) - hits.push({n,proj:path[0]?path[0].title:n.title}); }); - box.innerHTML=hits.map(h=>``).join("") - ||'
No matches
'; - box.style.display="block"; -} -function pickSearch(id){ const sb=document.getElementById("searchbox"); - sb.value=""; document.getElementById("searchres").style.display="none"; openDetail(id); } - -/* ================= hover tooltip — full task names ================= */ -/* IMPORTANT: which bar the cursor is over is read from each event's REAL target - (e.target.closest("[data-full]")), never from document.elementFromPoint. That was the bug: - after the chart's innerHTML is swapped under a stationary cursor, Chromium's elementFromPoint - hit-test cache goes stale and keeps returning the old/wrong node until the tab is re-focused — - so every filter change killed the tooltip and even moving the mouse didn't help, because every - detection path read from that one poisoned source. Event targets are always live, so this - survives re-renders with no tab switch and no synthetic-event trickery. */ -const TIP=document.createElement("div"); TIP.id="gtip"; document.body.appendChild(TIP); -let MX=-1,MY=-1,tipKey=null,tipTimer=null,tipEl=null; -let PTRDOWN=false; // physical button held (i.e. a drag in progress) — suppress the tip -function hideTip(){ clearTimeout(tipTimer); tipTimer=null; tipKey=null; tipEl=null; TIP.style.display="none"; } -function placeTip(){ - TIP.style.left=Math.max(8,Math.min(MX+14,window.innerWidth-TIP.offsetWidth-10))+"px"; - TIP.style.top =Math.max(6,MY-TIP.offsetHeight-16)+"px"; -} -function revealTip(){ tipTimer=null; - if(!tipEl||!tipEl.isConnected){ hideTip(); return; } // node vanished during the delay - TIP.textContent=tipEl.dataset.full; TIP.style.display="block"; placeTip(); -} -/* feed me the element under the pointer, taken from a live event target (or a fresh hit-test) */ -function hoverOn(target){ - if(PTRDOWN){ hideTip(); return; } - const host=(target&&target.closest)?target.closest("[data-full]"):null; - if(!host){ hideTip(); return; } - const key=host.dataset.tid||host.dataset.full; - if(key!==tipKey){ // moved onto a new bar → arm the reveal delay - clearTimeout(tipTimer); tipKey=key; tipEl=host; TIP.style.display="none"; - tipTimer=setTimeout(revealTip,600); return; - } - tipEl=host; // same bar (may be a fresh node after a render) - if(TIP.style.display==="block") placeTip(); // already shown → follow the cursor -} -document.addEventListener("pointerdown",()=>{PTRDOWN=true;},true); -document.addEventListener("pointerup",()=>{PTRDOWN=false;},true); -document.addEventListener("pointercancel",()=>{PTRDOWN=false;},true); -window.addEventListener("blur",()=>{PTRDOWN=false;}); -const onMove=e=>{ MX=e.clientX; MY=e.clientY; if(!e.buttons) PTRDOWN=false; hoverOn(e.target); }; -document.addEventListener("pointermove",onMove,{passive:true}); -document.addEventListener("pointerover",e=>{ MX=e.clientX; MY=e.clientY; hoverOn(e.target); },{passive:true}); -document.addEventListener("pointerout", e=>{ if(!e.relatedTarget) hideTip(); },{passive:true}); -document.addEventListener("mousemove",onMove,{passive:true}); // fallbacks if -document.addEventListener("mouseover",e=>{ MX=e.clientX; MY=e.clientY; hoverOn(e.target); },{passive:true}); // pointer events glitch -// scrolling hides the tip; the next real hover re-arms it -document.addEventListener("scroll",()=>hideTip(),true); -// (no idle watchdog: the tooltip appears ONLY on a real hover, never synthesized from a click/render) - -function renderAll(){ renderFilter(); renderDash(); - if(typeof requestAnimationFrame!=="undefined") - requestAnimationFrame(kickHover); // re-evaluate hover after every re-render -} -Object.defineProperty(window, "CAP", { get: () => CAP, configurable: true }); - -requestSave = await initApp({ - applyBoard: (b) => applyBoard(b, setUid), - boardPayload: () => boardPayload(getUid), - renderAll, -}); - -const _globals = { - toggleSearch, openTeam, micFabTap, openTranscript, toggleSettings, toggleSidebar, closeSettings, - toggleFlyout, toggleFocus, toggleShowDone, toggleSubs, closeCapture, toggleCapLang, minimizeCapture, - sendTurn, restoreCapture, skipKey, saveKey, clearKey, closeTranscript, runTranscript, closeReview, - closeTeam, closeSheet, setFilter, setScaleView, ding, toggleDone, openDetail, setZoom, setGView, - toggleExp, updTask, refreshBarMenu, addChild, deleteTask, addCapTask, barDown, barContext, pickSearch, - uploadPhoto, removePhoto, rvToggle, rvText, rvOwner, rvDue, rvSize, pushApproved, attachTranscript, - doSearch, refreshCard, delCapTask, setTask, setTaskOwner, setTaskSize, setSub, setSubOwner, addSub, - delSub, commitCapture, toggleListen, stopListen, renderAll, moveTask, setKeyVal, -}; -Object.assign(window, _globals); diff --git a/_site/src/data/board-store.js b/_site/src/data/board-store.js deleted file mode 100644 index e349a7c..0000000 --- a/_site/src/data/board-store.js +++ /dev/null @@ -1,68 +0,0 @@ -import { - DEFAULT_CLIENTS, - DEFAULT_DOMAIN_RULES, - DEFAULT_HARDWARE_VOCAB, - DEFAULT_PEOPLE, - DEFAULT_TODAY, -} from "./defaults.js"; -import { flat } from "../lib/tree.js"; - -export const PEOPLE = {}; -export const DATA = []; -export const CLIENTS = []; -export const HARDWARE_VOCAB = []; -export const DOMAIN_RULES = []; -export let TODAY = new Date(DEFAULT_TODAY); - -function maxTaskId(nodes) { - let m = 0; - flat(nodes, (n) => { - if (n.id > m) m = n.id; - }); - return m; -} - -export function applyBoard(board, setUid) { - Object.keys(PEOPLE).forEach((k) => delete PEOPLE[k]); - Object.assign(PEOPLE, board.people || {}); - - DATA.splice(0, DATA.length, ...(board.tasks || [])); - - CLIENTS.splice(0, CLIENTS.length, ...(board.clients || [])); - HARDWARE_VOCAB.splice(0, HARDWARE_VOCAB.length, ...(board.hardware_vocab || [])); - DOMAIN_RULES.splice(0, DOMAIN_RULES.length, ...(board.domain_rules || [])); - - const uid = Math.max(board.uid || 0, maxTaskId(DATA)); - if (setUid) setUid(uid); - - if (board.today) TODAY = new Date(board.today); -} - -export function boardPayload(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 initBoardDefaults(setUid, buildTasks) { - Object.keys(PEOPLE).forEach((k) => delete PEOPLE[k]); - Object.assign(PEOPLE, structuredClone(DEFAULT_PEOPLE)); - CLIENTS.splice(0, CLIENTS.length, ...structuredClone(DEFAULT_CLIENTS)); - HARDWARE_VOCAB.splice(0, HARDWARE_VOCAB.length, ...DEFAULT_HARDWARE_VOCAB); - DOMAIN_RULES.splice(0, DOMAIN_RULES.length, ...structuredClone(DEFAULT_DOMAIN_RULES)); - TODAY = new Date(DEFAULT_TODAY); - - const tasks = buildTasks ? buildTasks() : []; - DATA.splice(0, DATA.length, ...tasks); - - const uid = maxTaskId(DATA); - if (setUid) setUid(uid); -} - -initBoardDefaults(); diff --git a/_site/src/data/constants.js b/_site/src/data/constants.js deleted file mode 100644 index cc88532..0000000 --- a/_site/src/data/constants.js +++ /dev/null @@ -1,31 +0,0 @@ -export { - PEOPLE, - TODAY, - CLIENTS, - HARDWARE_VOCAB, - DOMAIN_RULES, - DATA, -} from "./board-store.js"; - -export const SIZE_PTS = { s: 1, m: 2, l: 4, xl: 8 }; -export const SIZE_NAMES = { s: "S", m: "M", l: "L", xl: "XL" }; -export const LEAD = { s: 1, m: 3, l: 7, xl: 14 }; - -export const ZOOMS = [ - { l: "Day", h: 0, v: 3 }, - { l: "Week", h: 7, v: 7 }, - { l: "3 weeks", h: 21, v: 21 }, - { l: "6 weeks", h: 42, v: 42 }, -]; - -export const GBAR_H = { s: 26, m: 34, l: 44, xl: 56 }; -export const R0G = 0; -export const R1G = 90; -export const SPAN_G = R1G - R0G; -export const TODAY_PX = 240; - -export const C_LATE = "#ff5d5d"; -export const C_TODAY = "#2f80ff"; -export const C_RADAR = "#16c79a"; -export const C_LATER = "#9b8cff"; -export const C_DONE = "#c8cdd6"; diff --git a/_site/src/data/defaults.js b/_site/src/data/defaults.js deleted file mode 100644 index 3742983..0000000 --- a/_site/src/data/defaults.js +++ /dev/null @@ -1,35 +0,0 @@ -export const DEFAULT_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"] }, -}; - -export const DEFAULT_TODAY = "2026-06-12"; - -export const DEFAULT_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)", -]; - -export const DEFAULT_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"] }, -]; - -export const DEFAULT_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"] }, -]; diff --git a/_site/src/data/sample-tasks.js b/_site/src/data/sample-tasks.js deleted file mode 100644 index c7285b7..0000000 --- a/_site/src/data/sample-tasks.js +++ /dev/null @@ -1,82 +0,0 @@ -import { PEOPLE } from "./board-store.js"; - -export function buildSampleTasks(T) { - const tasks = [ - T("Derichebourg pilot - sorting robot", "ia", { p: "high", d: "2026-07-10", open: true, c: [ - T("Integrate RS03 drive motors", "sk", { p: "high", d: "2026-06-16", s: "l", open: true, c: [ - T("Mount RS03 motors and couplers", "sk", { done: true, d: "2026-06-08", s: "m" }), - T("Wire motor CAN bus to controller", "sk", { d: "2026-06-14", s: "m" }), - T("Calibrate RS03 torque limits", "sk", { d: "2026-06-17", s: "s" }), - ] }), - T("Tune obstacle avoidance for the sorting line", "ak", { p: "high", d: "2026-06-19", s: "l", open: true, c: [ - T("Collect depth data along the conveyor", "ak", { done: true, d: "2026-06-10", s: "m" }), - T("Train avoidance model", "ak", { d: "2026-06-18", s: "l" }), - T("Field test near the conveyor", "ak", { d: "2026-06-22", s: "m" }), - ] }), - T("Fix D-Wave board brownout under load", "ly", { p: "high", d: "2026-06-12", s: "m", open: true, c: [ - T("Diagnose the power regulator", "ly", { done: true, d: "2026-06-11", s: "s" }), - T("Replace regulator and retest", "ly", { d: "2026-06-13", s: "m" }), - ] }), - T("Approve motor procurement budget", "jn", { d: "2026-06-15", s: "s" }), - T("Coordinate on-site pilot install", "fd", { d: "2026-06-30", s: "l" }), - ] }), - - T("JCDecaux pilot - billboard servicing", "ak", { p: "high", d: "2026-07-20", open: true, c: [ - T("Design board-mount manipulator arm", "ia", { d: "2026-06-23", s: "l", open: true, c: [ - T("CAD the arm linkage", "ia", { d: "2026-06-18", s: "m" }), - T("Source Feetech servos for the arm", "lm", { d: "2026-06-20", s: "s" }), - ] }), - T("Autonomous navigation between billboards", "ak", { p: "high", d: "2026-06-26", s: "xl", open: true, c: [ - T("Build city route planner", "ak", { d: "2026-06-24", s: "l" }), - T("GPS waypoint following", "ak", { d: "2026-06-25", s: "m" }), - ] }), - T("Hub-motor sizing for outdoor terrain", "sk", { d: "2026-06-20", s: "m" }), - T("Demo prep for JCDecaux", "fd", { d: "2026-06-27", s: "s" }), - ] }), - - T("Onet pilot - floor-cleaning autonomy", "ak", { d: "2026-08-01", open: true, c: [ - T("Map the Onet facility floorplan", "ak", { d: "2026-06-21", s: "m" }), - T("Integrate Feetech servos for the brush arm", "sk", { d: "2026-06-24", s: "m", open: true, c: [ - T("Mount the brush assembly", "lm", { d: "2026-06-22", s: "s" }), - T("Tune servo sweep pattern", "sk", { d: "2026-06-25", s: "s" }), - ] }), - T("Safety e-stop wiring", "ly", { p: "high", d: "2026-06-16", s: "s" }), - ] }), - - T("RoboOS v2 - core platform", "ia", { p: "high", d: "2026-07-31", open: true, c: [ - T("Migrate OS to RS04 motor drivers", "sk", { p: "high", d: "2026-06-24", s: "l", open: true, c: [ - T("Port CAN driver to RS04", "sk", { d: "2026-06-22", s: "m" }), - T("Bench-test RS04 closed loop", "sk", { d: "2026-06-23", s: "m" }), - ] }), - T("Real-time locomotion controller", "ak", { d: "2026-06-29", s: "l" }), - T("Evaluate EL05 actuators", "sk", { d: "2026-06-17", s: "m", open: true, c: [ - T("Run EL05 load tests", "sk", { done: true, d: "2026-06-09", s: "s" }), - T("Compare EL05 vs RS02 efficiency", "sk", { d: "2026-06-18", s: "s" }), - ] }), - T("Nightly build + hardware-in-the-loop rig", "ia", { d: "2026-06-21", s: "m" }), - T("Assemble robot chassis v2", "ia", { d: "2026-07-06", s: "l" }), - ] }), - - T("NSI pilot - inventory scanning", "ia", { d: "2026-07-15", open: true, c: [ - T("Scoping follow-up with NSI", "fd", { d: "2026-06-19", s: "s" }), - T("Barcode scanner integration", "sk", { d: "2026-06-28", s: "m" }), - T("Aisle navigation tuning", "ak", { d: "2026-07-02", s: "m" }), - ] }), - ]; - - sprinkleSubtaskOwners(tasks); - return tasks; -} - -function sprinkleSubtaskOwners(nodes) { - let seed = 7; - const rnd = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648; - const keys = Object.keys(PEOPLE); - const walk = (list, parent, depth) => list.forEach((n) => { - if (depth >= 2 && parent) { - n.owner = rnd() < 0.7 ? parent.owner : keys[Math.floor(rnd() * keys.length)]; - } - walk(n.children, n, depth + 1); - }); - walk(nodes, null, 0); -} diff --git a/_site/src/lib/capture.js b/_site/src/lib/capture.js deleted file mode 100644 index f17f240..0000000 --- a/_site/src/lib/capture.js +++ /dev/null @@ -1,193 +0,0 @@ -import { CLIENTS, PEOPLE, TODAY } from "../data/constants.js"; -import { canonHardware, findClient, inferOwnerByDomain, norm } from "./domain.js"; - -export const cap1 = (s) => (s ? s.replace(/^[a-z]/, (c) => c.toUpperCase()) : s); - -export function isoCap(d) { - return d.toISOString().slice(0, 10); -} - -export function stripCaptions(t) { - if (!t) return t; - return t - .replace(/^WEBVTT.*$/im, "") - .split(/\r?\n/) - .filter((l) => !/^\s*\d+\s*$/.test(l) && !/^\s*[\d:.,]+\s*-->\s*[\d:.,]+/.test(l)) - .join("\n") - .replace(/\n{3,}/g, "\n\n") - .trim(); -} - -export function findOwnerId(t) { - let best = null; - let pos = -1; - for (const [k, p] of Object.entries(PEOPLE)) { - for (const a of p.al) { - const i = t.indexOf(a); - if (i > pos) { - pos = i; - best = k; - } - } - } - return best; -} - -export function findDue(t, today = TODAY) { - const d = new Date(today); - const wd = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]; - const inDays = t.match(/in (\d+) days?/); - if (/\btoday\b/.test(t)) return isoCap(d); - if (/\btomorrow\b/.test(t)) { - d.setDate(d.getDate() + 1); - return isoCap(d); - } - if (/\bnext week\b/.test(t)) { - d.setDate(d.getDate() + 7); - return isoCap(d); - } - if (inDays) { - d.setDate(d.getDate() + +inDays[1]); - return isoCap(d); - } - const wi = wd.findIndex((w) => new RegExp("\\b" + w + "\\b").test(t)); - if (wi > -1) { - const delta = (wi - d.getDay() + 7) % 7 || 7; - d.setDate(d.getDate() + delta); - return isoCap(d); - } - return null; -} - -export function findSize(t) { - const m = t.match(/\b(extra large|x-?large|xl|small|medium|large)\b/); - if (!m) return null; - return ( - { - small: "s", - medium: "m", - large: "l", - xl: "xl", - "x-large": "xl", - xlarge: "xl", - "extra large": "xl", - }[m[1]] || null - ); -} - -export function normalizeProposal(raw) { - let ruid = 0; - const projects = (raw.projects || []) - .map((p) => ({ - uid: ++ruid, - accepted: true, - name: cap1(p.name || "New project"), - owner: p.owner || null, - due: p.due || null, - client: p.client || findClient(" " + (p.name || "") + " ") || null, - tasks: (p.tasks || []).map((t) => { - const ti = canonHardware(cap1(t.title || "")); - return { - uid: ++ruid, - accepted: true, - title: ti, - owner: t.owner || inferOwnerByDomain(ti) || null, - due: t.due || null, - size: t.size || null, - client: t.client || null, - subs: (t.subs || []).map((s) => { - const si = canonHardware(cap1(s.title || "")); - return { - uid: ++ruid, - accepted: true, - title: si, - owner: s.owner || inferOwnerByDomain(si) || null, - due: s.due || null, - size: s.size || null, - }; - }), - }; - }), - })) - .filter((p) => p.name); - return { assistantSay: raw.assistantSay || "", projects }; -} - -export function mockTranscript(text) { - const lc = norm(text); - const mentioned = CLIENTS.filter( - (c) => lc.includes(norm(c.name)) || c.al.some((a) => lc.includes(norm(a))) - ); - const VSRC = - "install|build|fix|implement|test|deploy|order|write|design|integrate|ship|prepare|configure|run|benchmark|schedule|review|deliver|set ?up|mount|wire|calibrate|debug|develop|create|add|update|replace|repair|assemble|program|tune|investigate|source|procure"; - const VERBS = new RegExp("\\b(" + VSRC + ")\\b", "i"); - const splitAnd = new RegExp( - "(?:,\\s*)?\\b(?:and|then|also)\\b\\s+(?=(?:we\\s+|i\\s+|they\\s+|the client\\s+|please\\s+|to\\s+)?(?:" + - VSRC + - ")\\b)", - "i" - ); - const clauses = text - .split(/[.;\n]+/) - .flatMap((c) => c.split(splitAnd)) - .map((s) => (s || "").trim()) - .filter(Boolean); - const tasks = []; - clauses.forEach((cl) => { - if (!VERBS.test(cl)) return; - const FILLER = - /^(?:the client wants us to|the client wants|they want us to|they want to|they want|we need to|we should|we'?ll|we|i need to|i'?ll|i|and|then|also|so|separately|additionally|internally|meanwhile|next|first|second|third|finally|please|can you|make sure to|let'?s|for)[,]?\s+/i; - let frag = cl.trim(); - let prev; - do { - prev = frag; - frag = frag.replace(FILLER, "").trim(); - } while (frag !== prev); - CLIENTS.forEach((c) => { - frag = frag.replace(new RegExp("^" + c.name + "\\s*,?\\s*", "i"), "").trim(); - }); - frag = frag.replace(/^to\s+/i, "").trim(); - if (frag.length < 4) return; - frag = canonHardware(frag); - const client = findClient(" " + cl.toLowerCase() + " "); - tasks.push({ - title: cap1(frag).slice(0, 90), - owner: inferOwnerByDomain(frag), - due: findDue(" " + cl.toLowerCase() + " "), - size: findSize(cl.toLowerCase()), - client, - subs: [], - }); - }); - const projects = []; - const getProj = (name, client) => { - let p = projects.find((x) => x.name === name); - if (!p) { - p = { name, client: client || null, owner: null, due: null, tasks: [] }; - projects.push(p); - } - return p; - }; - if (mentioned.length) { - tasks.forEach((tk) => { - const cl = tk.client || mentioned[0].name; - getProj("Pilot - " + cl, cl).tasks.push(tk); - }); - mentioned.forEach((c) => getProj("Pilot - " + c.name, c.name)); - } else { - const p = getProj("New engineering work", null); - tasks.forEach((tk) => p.tasks.push(tk)); - } - projects.forEach((p) => { - const cnt = {}; - p.tasks.forEach((t) => { - if (t.owner) cnt[t.owner] = (cnt[t.owner] || 0) + 1; - }); - p.owner = Object.keys(cnt).sort((a, b) => cnt[b] - cnt[a])[0] || null; - }); - const nT = projects.reduce((a, p) => a + p.tasks.length, 0); - return { - assistantSay: `I identified ${projects.length} project${projects.length !== 1 ? "s" : ""} and ${nT} task${nT !== 1 ? "s" : ""} from this conversation.`, - projects, - }; -} diff --git a/_site/src/lib/date-core.js b/_site/src/lib/date-core.js deleted file mode 100644 index 30bc5a0..0000000 --- a/_site/src/lib/date-core.js +++ /dev/null @@ -1,14 +0,0 @@ -export function dayN(iso, today) { - return Math.round((new Date(iso) - today) / 864e5); -} - -export function dayIso(d, today) { - const x = new Date(today); - x.setDate(x.getDate() + d); - return x.toISOString().slice(0, 10); -} - -export function barSpan(n, today, lead) { - const e = dayN(n.due, today); - return { s: n.start ? dayN(n.start, today) : e - (lead[n.size || "m"] - 1), e }; -} diff --git a/_site/src/lib/dates.js b/_site/src/lib/dates.js deleted file mode 100644 index 44c801f..0000000 --- a/_site/src/lib/dates.js +++ /dev/null @@ -1,106 +0,0 @@ -import { LEAD } from "../data/constants.js"; -import { C_DONE, C_LATE, C_LATER, C_RADAR, C_TODAY } from "../data/constants.js"; -import { barSpan as _barSpan, dayIso, dayN } from "./date-core.js"; -import { flat } from "./tree.js"; -import { taskDone } from "./tree.js"; - -export { dayN, dayIso } from "./date-core.js"; - -export function createDateHelpers(today) { - const dayNLocal = (iso) => dayN(iso, today); - const dayIsoLocal = (d) => dayIso(d, today); - - const barSpan = (n) => _barSpan(n, today, LEAD); - - function workDays(s, e) { - if (isNaN(s) || isNaN(e) || e < s) return 0; - let c = 0; - for (let d = s; d <= e; d++) { - const wd = new Date(dayIsoLocal(d)).getDay(); - if (wd !== 0 && wd !== 6) c++; - } - return c; - } - - function barColor(e, s, done) { - if (done) return C_DONE; - if (e < 0) return C_LATE; - if (e === 0) return C_TODAY; - if (s <= 0) return C_RADAR; - return C_LATER; - } - - function barGeom(s, e, done, r0g = 0, r1g = 90) { - let rs; - let re; - if (done) { - rs = s; - re = e + 1; - } else if (e <= 0) { - rs = 0; - re = 1; - } else { - rs = Math.max(s, 0); - re = e + 1; - } - const cs = Math.max(rs, r0g); - return [cs, Math.min(Math.max(re, cs + 0.5), r1g)]; - } - - function rollupSpan(n) { - let s = Infinity; - let e = -Infinity; - flat([n], (x) => { - if (x.children.length || !x.due) return; - const sp = barSpan(x); - if (sp.s < s) s = sp.s; - if (sp.e > e) e = sp.e; - }); - return e === -Infinity ? barSpan(n) : { s, e }; - } - - const spanFor = (n) => (n.children.length ? rollupSpan(n) : barSpan(n)); - - function leafWeight(n) { - const { s, e } = barSpan(n); - const w = workDays(s, e); - return w > 0 ? w : 1; - } - - function progWD(n) { - let done = 0; - let tot = 0; - flat([n], (x) => { - if (x.children.length) return; - const w = leafWeight(x); - tot += w; - if (x.done) done += w; - }); - return tot ? done / tot : 0; - } - - function isUrgent(n) { - const done = n.children.length ? taskDone(n) : n.done; - if (done) return false; - const { s } = spanFor(n); - return !isNaN(s) && s <= 0; - } - - const fmtD = (iso) => - new Date(iso).toLocaleDateString("en-GB", { day: "numeric", month: "short" }); - - return { - dayN: dayNLocal, - dayIso: dayIsoLocal, - barSpan, - workDays, - barColor, - barGeom, - rollupSpan, - spanFor, - leafWeight, - progWD, - isUrgent, - fmtD, - }; -} diff --git a/_site/src/lib/domain.js b/_site/src/lib/domain.js deleted file mode 100644 index 1e42bd8..0000000 --- a/_site/src/lib/domain.js +++ /dev/null @@ -1,49 +0,0 @@ -import { CLIENTS, DOMAIN_RULES, HARDWARE_VOCAB, PEOPLE } from "../data/constants.js"; - -export function norm(s) { - return " " + (s || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim() + " "; -} - -export function inferOwnerByDomain(t) { - const s = " " + (t || "").toLowerCase() + " "; - for (const r of DOMAIN_RULES) { - for (const k of r.kw) { - if (s.includes(k)) return r.o; - } - } - return null; -} - -export function canonHardware(s) { - if (!s) return s; - return s - .replace(/\bel[\s-]?0?5\b/ig, "EL05") - .replace(/\brs[\s-]?0*(\d{1,2})\b/ig, (m, n) => "RS" + String(n).padStart(2, "0")) - .replace(/\bd[\s-]?wave\b/ig, "D-Wave") - .replace(/\bfeetech\b/ig, "Feetech") - .replace(/\brobstride\b/ig, "Robstride"); -} - -export function findClient(t) { - const s = norm(t); - for (const c of CLIENTS) { - if (s.includes(norm(c.name))) return c.name; - for (const a of c.al) { - if (s.includes(norm(a))) return c.name; - } - } - return null; -} - -export function buildRespMapText() { - return Object.entries(PEOPLE) - .map(([id, p]) => `- ${p.name} (id "${id}"; voice transcription often mis-hears this name as: ${p.al.join(", ")}): ${p.role}`) - .join("\n"); -} - -export function buildVocabText() { - return ( - `HARDWARE (use these exact spellings):\n- ${HARDWARE_VOCAB.join("\n- ")}\n` + - `KNOWN CLIENTS (use these exact spellings):\n- ${CLIENTS.map((c) => c.name).join("\n- ")}` - ); -} diff --git a/_site/src/lib/persistence.js b/_site/src/lib/persistence.js deleted file mode 100644 index 07d5a7a..0000000 --- a/_site/src/lib/persistence.js +++ /dev/null @@ -1,55 +0,0 @@ -let boardReady = false; -let saveTimer = null; -let saveInFlight = false; -let saveQueued = false; - -export function isBoardReady() { - return boardReady; -} - -export function scheduleSave(saveBoard) { - if (!boardReady) return; - clearTimeout(saveTimer); - saveTimer = setTimeout(saveBoard, 500); -} - -export async function saveBoard(boardPayload) { - 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(boardPayload()), - }); - if (!res.ok) throw new Error("save failed"); - } catch (e) { - console.error("Board save failed", e); - } finally { - saveInFlight = false; - if (saveQueued) { - saveQueued = false; - scheduleSave(() => saveBoard(boardPayload)); - } - } -} - -export async function initApp({ applyBoard, boardPayload, renderAll }) { - const doSave = () => saveBoard(boardPayload); - let useServer = false; - try { - const res = await fetch("/api/board"); - if (!res.ok) throw new Error("load failed"); - applyBoard(await res.json()); - useServer = true; - } catch (e) { - console.warn("Board load skipped, using local sample data.", e); - } - boardReady = useServer; - renderAll(); - return () => scheduleSave(doSave); -} diff --git a/_site/src/lib/tree.js b/_site/src/lib/tree.js deleted file mode 100644 index e3d41cc..0000000 --- a/_site/src/lib/tree.js +++ /dev/null @@ -1,86 +0,0 @@ -export function createTaskFactory() { - let uid = 0; - const T = (title, o, opts = {}) => ({ - id: ++uid, - title, - owner: o, - priority: opts.p || "med", - due: opts.d || null, - start: opts.st || null, - size: opts.s || null, - done: opts.done || false, - doneAt: opts.doneAt || null, - children: opts.c || [], - open: opts.open || false, - }); - return { - T, - resetUid: () => { uid = 0; }, - getUid: () => uid, - setUid: (v) => { uid = v; }, - }; -} - -export const flat = (nodes, fn, depth = 0, path = []) => - nodes.forEach((n) => { - fn(n, depth, path); - flat(n.children, fn, depth + 1, [...path, n]); - }); - -export function findPath(id, nodes, path = []) { - for (const n of nodes) { - if (n.id === id) return [...path, n]; - const r = findPath(id, n.children, [...path, n]); - if (r) return r; - } - return null; -} - -export function counts(n) { - if (!n.children.length) return { done: n.done ? 1 : 0, total: 1 }; - let d = 0; - let t = 0; - n.children.forEach((c) => { - const r = counts(c); - d += r.done; - t += r.total; - }); - return { done: d, total: t }; -} - -export const pct = (n) => { - const c = counts(n); - return c.total ? Math.round((100 * c.done) / c.total) : 0; -}; - -export function progFrac(n, sizePts) { - let done = 0; - let tot = 0; - flat([n], (x) => { - if (x.children.length) return; - const w = sizePts[x.size || "m"]; - tot += w; - if (x.done) done += w; - }); - return tot ? done / tot : 0; -} - -export const taskDone = (n) => (!n.children.length ? n.done : pct(n) === 100); - -export function taskDoneAt(n) { - let m = null; - flat([n], (x) => { - if (!x.children.length && x.doneAt && (!m || x.doneAt > m)) m = x.doneAt; - }); - return m || n.doneAt; -} - -export const contains = (n, id) => n.id === id || n.children.some((c) => contains(c, id)); - -export const depthOf = (id, nodes) => findPath(id, nodes).length - 1; - -export const heightOf = (n) => - n.children.length ? 1 + Math.max(...n.children.map(heightOf)) : 0; - -export const fitsDepth = (node, destId, nodes) => - depthOf(destId, nodes) + 1 + heightOf(node) <= 2; From 3e570bfb7aa7e8c7b47153b8a71c8c1a9efb285c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 16 Jun 2026 10:08:19 +0000 Subject: [PATCH 6/7] Update CHANGELOG to reflect board-sync.js (removed stale file refs) Co-authored-by: Sanket Sharma --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9a4ca2..5049508 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,7 @@ - `db.py` - SQLite storage in `data/taskboard.db` - `seed_data.py` - default team/tasks seeded on first run - `requirements.txt` - Flask dependency -- `src/data/board-store.js` and `src/data/defaults.js` - mutable board state loaded from API -- `src/lib/persistence.js` - debounced auto-save (500ms) after edits +- `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 From 2770463defb3f71c4ef2b8bd74c4a2ba37923293 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 16 Jun 2026 12:39:30 +0000 Subject: [PATCH 7/7] Fix broken gantt when loading board from SQLite seed_data.make() was calling make(**child) on already-built nodes, which corrupted the task tree (no due dates, no subtasks, zero gantt bars). Also validate server board quality before replacing inline sample data. Co-authored-by: Sanket Sharma --- CHANGELOG.md | 4 ++++ seed_data.py | 2 +- src/lib/board-sync.js | 10 ++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5049508..6f0d51f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ - 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/seed_data.py b/seed_data.py index 3c367cc..dcbdcaf 100644 --- a/seed_data.py +++ b/seed_data.py @@ -122,7 +122,7 @@ def make(title, owner, **opts): uid += 1 kids = opts.pop("c", []) node = _t(uid, title, owner, **opts) - node["children"] = [make(**child) for child in kids] + node["children"] = kids return node tasks = [ diff --git a/src/lib/board-sync.js b/src/lib/board-sync.js index 1495773..84235d1 100644 --- a/src/lib/board-sync.js +++ b/src/lib/board-sync.js @@ -25,6 +25,16 @@ 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);