app
Adom Gantt
Public Made by Adomby adom
Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, push
← Commit history
Publish 0.1.0
12 files changed
+1842
.gitignoreadded+1@@ -0,0 +1 @@+examples/*-state.json
SKILL.mdadded+165@@ -0,0 +1,165 @@+---+name: adom-gantt+description: >+ Interactive Gantt chart viewer for Adom project timelines. Serves a+ data-driven timeline from a JSON file with phases, tasks, milestones,+ and dependency arrows. Features: click-to-filter legend, drag-to-resize+ bars, inline date editor, dependency management, undo (Ctrl+Z), and+ auto-save to a sidecar state file. Trigger words: gantt, timeline,+ project schedule, gantt chart, project gantt, show timeline, view+ schedule, project plan chart, milestone chart.+---++# adom-gantt++Interactive Gantt chart viewer for Adom project timelines.++## Quick start++```bash+node ~/project/adom-gantt/server.js --data my-project.json --port 8901+```++Then open via Hydrogen webview or pup browser window at the proxy URL.++## Data file schema++Create a JSON file with this structure:++```json+{+ "project": {+ "title": "My Project",+ "subtitle": "Short description",+ "appName": "my-project"+ },+ "phases": [+ {+ "name": "Phase 0 — Setup",+ "color": "#64ABFF",+ "start": "2026-06",+ "end": "2026-08"+ }+ ],+ "items": [+ {+ "type": "group",+ "label": "Phase 0 — Setup (Jun–Aug 2026)",+ "phase": 0+ },+ {+ "type": "task",+ "id": "unique-id",+ "name": "Task Name",+ "start": "2026-06-01",+ "end": "2026-07-15",+ "phase": 0,+ "status": "planned",+ "description": "What this task involves.",+ "deps": ["other-task-id"],+ "striped": false+ },+ {+ "type": "milestone",+ "name": "M1: First milestone",+ "date": "2026-08-31",+ "phase": 0,+ "description": "What this milestone means."+ }+ ]+}+```++### Field reference++**project** (required)++| Field | Type | Required | Description |+|-------|------|----------|-------------|+| title | string | yes | Project name shown in header |+| subtitle | string | no | Short tagline (default: "Gantt chart") |+| appName | string | no | Shown in brand zone (default: "adom-gantt") |++**phases[]** (required, at least 1)++| Field | Type | Required | Description |+|-------|------|----------|-------------|+| name | string | yes | Legend label (e.g., "Phase 1 — Build") |+| color | hex string | yes | Bar color for this phase |+| start | YYYY-MM | yes | Phase start month |+| end | YYYY-MM | yes | Phase end month |++**items[]** (required) — ordered array, rendered top-to-bottom++Each item has a `type` field: `"group"`, `"task"`, or `"milestone"`.++**type: "group"** — section header row++| Field | Type | Required | Description |+|-------|------|----------|-------------|+| label | string | yes | Group header text |+| phase | number | yes | Index into phases[] (controls filter visibility) |++**type: "task"** — bar on the timeline++| Field | Type | Required | Description |+|-------|------|----------|-------------|+| id | string | yes | Unique identifier (used for deps and state) |+| name | string | yes | Task label |+| start | YYYY-MM-DD | yes | Start date |+| end | YYYY-MM-DD | yes | End date |+| phase | number | yes | Index into phases[] |+| status | string | no | One of: "live", "planned", "not-started" |+| description | string | no | Tooltip body text |+| deps | string[] | no | Array of task IDs this depends on |+| striped | boolean | no | Striped bar pattern (for ongoing work) |++**type: "milestone"** — diamond marker++| Field | Type | Required | Description |+|-------|------|----------|-------------|+| name | string | yes | Milestone label |+| date | YYYY-MM-DD | yes | Milestone date |+| phase | number | yes | Index into phases[] |+| description | string | no | Tooltip body text |++### Recommended phase colors++These are from the Adom brand palette and contrast well on dark backgrounds:++| Color | Hex | Good for |+|-------|-----|----------|+| Blue Light | #64ABFF | Foundation / setup phases |+| Green | #3fb950 | Build / integration phases |+| Purple | #8C6BF7 | Testing / validation phases |+| Orange | #f0883e | Optimization / tuning phases |+| Red | #f85149 | Research / experimental phases |+| Teal | #00b8b0 | Delivery / launch phases |++## Features++- **Click legend** to filter phases, milestones, or dependency arrows+- **Click a bar** to open the date editor with start/end pickers and dependency management+- **Drag bar edges** to resize (changes start or end date)+- **Ctrl+Z** to undo (50-level stack)+- **Auto-save** to `<datafile>-state.json` sidecar (800ms debounce)+- **Auto-load** saved state on page refresh+- **Dynamic date range** computed from data (no hardcoded timeline)+- **Dynamic today marker** shows current date++## State file++Edits (date changes, dependency adds/removes) are saved to a sidecar JSON+file next to your data file. For example, `smt-line.json` saves state to+`smt-line-state.json`. This file only contains deltas — it records the+current start, end, and deps for each task. Delete it to reset to the+original data file values.++## Example++```bash+node ~/project/adom-gantt/server.js --data ~/project/adom-gantt/examples/smt-line.json+```++This loads the Adom SMT Supervisory Line project — a 32-month, 5-phase+plan with 30+ tasks, 8 milestones, and dependency arrows.
docs/hero.pngadded⋯ 1 unchanged line ⋯
docs/icon.svgadded+6@@ -0,0 +1,6 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#e6edf3">+ <rect x="3" y="4" width="18" height="2" rx="1" opacity="0.5"/>+ <rect x="5" y="9" width="12" height="2" rx="1"/>+ <rect x="3" y="14" width="16" height="2" rx="1" opacity="0.7"/>+ <rect x="7" y="19" width="10" height="2" rx="1" opacity="0.5"/>+</svg>
examples/sample-product-launch.jsonadded+48@@ -0,0 +1,48 @@+{+ "project": {+ "title": "Nova Smart Speaker",+ "subtitle": "Product launch timeline",+ "appName": "nova-planner"+ },+ "phases": [+ { "name": "Phase 1 — Design", "color": "#64ABFF", "start": "2026-01", "end": "2026-04" },+ { "name": "Phase 2 — Prototype", "color": "#3fb950", "start": "2026-03", "end": "2026-07" },+ { "name": "Phase 3 — Validation", "color": "#8C6BF7", "start": "2026-07", "end": "2026-10" },+ { "name": "Phase 4 — Production", "color": "#f0883e", "start": "2026-10", "end": "2027-02" }+ ],+ "items": [+ { "type": "group", "label": "Phase 1 — Design (Jan–Apr 2026)", "phase": 0 },+ { "type": "task", "id": "reqs", "name": "Requirements gathering", "start": "2026-01-05", "end": "2026-02-01", "phase": 0, "status": "live", "description": "Stakeholder interviews, market analysis, feature prioritization." },+ { "type": "task", "id": "arch", "name": "System architecture", "start": "2026-01-15", "end": "2026-03-01", "phase": 0, "status": "live", "description": "Hardware block diagram, firmware partitioning, cloud API design.", "deps": ["reqs"] },+ { "type": "task", "id": "id", "name": "Industrial design", "start": "2026-01-20", "end": "2026-03-15", "phase": 0, "status": "planned", "description": "Enclosure CAD, acoustic simulation, thermal analysis." },+ { "type": "task", "id": "sch", "name": "Schematic capture", "start": "2026-02-10", "end": "2026-03-20", "phase": 0, "status": "planned", "description": "4-layer PCB, audio codec, WiFi/BT SoC, MEMS mic array.", "deps": ["arch"] },+ { "type": "task", "id": "fw-plan", "name": "Firmware architecture", "start": "2026-02-15", "end": "2026-03-10", "phase": 0, "status": "not-started", "description": "RTOS selection, audio pipeline design, OTA update strategy.", "deps": ["arch"] },+ { "type": "task", "id": "layout", "name": "PCB layout", "start": "2026-03-01", "end": "2026-04-10", "phase": 0, "status": "not-started", "description": "Placement, routing, impedance-controlled traces for audio.", "deps": ["sch"] },+ { "type": "milestone", "name": "M1: Design review complete", "date": "2026-04-10", "phase": 0, "description": "Schematic, layout, and ID signed off." },++ { "type": "group", "label": "Phase 2 — Prototype (Mar–Jul 2026)", "phase": 1 },+ { "type": "task", "id": "pcb-fab", "name": "PCB fabrication (EVT)", "start": "2026-04-11", "end": "2026-05-01", "phase": 1, "status": "not-started", "description": "3 board revisions, 20 units.", "deps": ["layout"] },+ { "type": "task", "id": "smt", "name": "SMT assembly", "start": "2026-05-01", "end": "2026-05-20", "phase": 1, "status": "not-started", "description": "Pick-and-place, reflow, visual inspection.", "deps": ["pcb-fab"] },+ { "type": "task", "id": "fw-core", "name": "Firmware core bring-up", "start": "2026-03-15", "end": "2026-06-01", "phase": 1, "status": "not-started", "description": "Boot, audio I2S, WiFi stack, BLE pairing.", "deps": ["fw-plan"] },+ { "type": "task", "id": "enclosure", "name": "Enclosure prototyping", "start": "2026-04-01", "end": "2026-06-15", "phase": 1, "status": "not-started", "description": "3D prints, soft tooling, acoustic testing.", "deps": ["id"] },+ { "type": "task", "id": "cloud", "name": "Cloud API + app", "start": "2026-04-01", "end": "2026-07-01", "phase": 1, "status": "not-started", "description": "Device provisioning, OTA, voice control integration.", "deps": ["arch"] },+ { "type": "task", "id": "int", "name": "HW/FW integration", "start": "2026-05-20", "end": "2026-07-01", "phase": 1, "status": "not-started", "description": "Board bringup, audio tuning, thermal validation.", "deps": ["smt", "fw-core"] },+ { "type": "milestone", "name": "M2: EVT functional", "date": "2026-07-01", "phase": 1, "description": "20 working units, audio quality validated." },++ { "type": "group", "label": "Phase 3 — Validation (Jul–Oct 2026)", "phase": 2 },+ { "type": "task", "id": "dvt", "name": "DVT build (100 units)", "start": "2026-07-01", "end": "2026-08-01", "phase": 2, "status": "not-started", "description": "Production tooling, injection-molded enclosures.", "deps": ["int", "enclosure"] },+ { "type": "task", "id": "cert", "name": "Regulatory certification", "start": "2026-07-15", "end": "2026-09-15", "phase": 2, "status": "not-started", "description": "FCC, CE, UL, Bluetooth SIG.", "deps": ["dvt"] },+ { "type": "task", "id": "fw-final", "name": "Firmware feature-complete", "start": "2026-07-01", "end": "2026-09-01", "phase": 2, "status": "not-started", "description": "Voice assistant, EQ presets, multi-room, OTA.", "deps": ["fw-core", "cloud"] },+ { "type": "task", "id": "qa", "name": "QA + reliability testing", "start": "2026-08-01", "end": "2026-10-01", "phase": 2, "status": "not-started", "description": "Drop test, thermal cycling, 1000-hour burn-in, audio golden ear.", "deps": ["dvt", "fw-final"] },+ { "type": "task", "id": "beta", "name": "Beta program (50 users)", "start": "2026-08-15", "end": "2026-10-01", "phase": 2, "status": "not-started", "description": "Field testing, NPS survey, bug triage.", "deps": ["dvt", "fw-final"] },+ { "type": "milestone", "name": "M3: DVT sign-off", "date": "2026-10-01", "phase": 2, "description": "Certification passed, QA green, beta feedback incorporated." },++ { "type": "group", "label": "Phase 4 — Production (Oct 2026–Feb 2027)", "phase": 3 },+ { "type": "task", "id": "pvt", "name": "PVT build (1000 units)", "start": "2026-10-01", "end": "2026-11-15", "phase": 3, "status": "not-started", "description": "Production-intent tooling, line qualification.", "deps": ["qa", "cert"] },+ { "type": "task", "id": "pkg", "name": "Packaging + unboxing design", "start": "2026-10-01", "end": "2026-11-01", "phase": 3, "status": "not-started", "description": "Retail box, quick-start guide, accessory kit." },+ { "type": "task", "id": "mfg", "name": "Mass production ramp", "start": "2026-11-15", "end": "2027-01-15", "phase": 3, "status": "not-started", "description": "5,000 units/month target, yield optimization.", "deps": ["pvt", "pkg"] },+ { "type": "task", "id": "launch-prep", "name": "Launch preparation", "start": "2026-12-01", "end": "2027-01-31", "phase": 3, "status": "not-started", "description": "Marketing site, press kits, retail channel setup, support training." },+ { "type": "task", "id": "ship", "name": "First customer shipments", "start": "2027-01-15", "end": "2027-02-15", "phase": 3, "status": "not-started", "description": "Warehouse fulfillment, retail distribution.", "deps": ["mfg", "launch-prep"] },+ { "type": "milestone", "name": "M4: Product launch", "date": "2027-02-01", "phase": 3, "description": "General availability, press embargo lifts." }+ ]+}
examples/smt-line.jsonadded+63@@ -0,0 +1,63 @@+{+ "project": {+ "title": "Adom SMT Supervisory Line",+ "subtitle": "Project timeline",+ "appName": "smt-planner"+ },+ "phases": [+ { "name": "Phase 0 — Foundation", "color": "#64ABFF", "start": "2026-06", "end": "2026-08" },+ { "name": "Phase 1 — SPI + Loop 1", "color": "#3fb950", "start": "2026-09", "end": "2027-02" },+ { "name": "Phase 2 — AOI + Loop 2", "color": "#8C6BF7", "start": "2027-03", "end": "2027-08" },+ { "name": "Phase 3 — Reflow + Predict", "color": "#f0883e", "start": "2027-09", "end": "2028-06" },+ { "name": "Phase 4 — Research", "color": "#f85149", "start": "2028-07", "end": "2028-12" }+ ],+ "items": [+ { "type": "group", "label": "Phase 0 — Foundation (Jun–Aug 2026)", "phase": 0 },+ { "type": "task", "id": "day1", "name": "Essemtec Day-1 Verification", "start": "2026-06-01", "end": "2026-07-15", "phase": 0, "status": "planned", "description": "10 empirical tests on dev ePlace install. Blocks PumaBridge file-write design decisions." },+ { "type": "task", "id": "i2s", "name": "i2s Evaluation", "start": "2026-06-01", "end": "2026-07-31", "phase": 0, "status": "planned", "description": "Evaluate i2s 2D inspection: data access via CFX/eData, measurement quality, licensing ($38K)." },+ { "type": "task", "id": "pg", "name": "PostgreSQL Schema", "start": "2026-06-01", "end": "2026-07-15", "phase": 0, "status": "not-started", "description": "Board-GUID-keyed tables: spi_measurements, aoi_measurements, traceability." },+ { "type": "task", "id": "rmq", "name": "RabbitMQ Broker", "start": "2026-06-01", "end": "2026-06-30", "phase": 0, "status": "not-started", "description": "AMQP broker for CFX ingest and WriteOp routing." },+ { "type": "task", "id": "cfx", "name": "CFX Ingestor (Rust)", "start": "2026-06-15", "end": "2026-08-31", "phase": 0, "status": "not-started", "description": "Subscribes to Puma CFX bus, transforms IPC-2591 JSON to Postgres rows.", "deps": ["rmq", "pg"] },+ { "type": "task", "id": "env", "name": "Environmental Sensors", "start": "2026-06-01", "end": "2026-08-15", "phase": 0, "status": "not-started", "description": "Temperature/humidity sensors for Loop 7 SPC bands." },+ { "type": "task", "id": "pb03", "name": "PumaBridge v0.1–v0.3", "start": "2026-06-15", "end": "2026-08-31", "phase": 0, "status": "planned", "description": "W1: env setup. W2: read-only eData. W3: recipe writes. W4: file-watcher + git.", "deps": ["day1"] },+ { "type": "task", "id": "pfp", "name": "puma-file-prep (ongoing)", "start": "2026-05-01", "end": "2027-02-28", "phase": 0, "status": "live", "striped": true, "description": "279 packages, 61 components, 55/55 E2E tests. Ongoing: AMQP transport, UI polish." },+ { "type": "task", "id": "pc", "name": "puma-converter (ongoing)", "start": "2026-05-01", "end": "2026-12-31", "phase": 0, "status": "live", "striped": true, "description": "CAD/BOM import engine. KiCad to Puma XML. Stable, incremental." },+ { "type": "task", "id": "inv", "name": "pnp-inventory (ongoing)", "start": "2026-05-01", "end": "2026-12-31", "phase": 0, "status": "live", "striped": true, "description": "vendor-cross-ref (port 8893) + job-inventory-check (port 8895)." },+ { "type": "milestone", "name": "M1: Data backbone operational", "date": "2026-08-31", "phase": 0, "description": "Postgres + RabbitMQ + CFX ingestor running. Puma telemetry flowing." },++ { "type": "group", "label": "Phase 1 — SPI + Loop 1 (Sep 2026–Feb 2027)", "phase": 1 },+ { "type": "task", "id": "spi-src", "name": "Source Used 3D SPI", "start": "2026-07-01", "end": "2026-10-31", "phase": 1, "status": "not-started", "description": "Scout used Koh Young KY8030 or similar. $15K–$50K. 3–6 month lead." },+ { "type": "task", "id": "spi-int", "name": "3D SPI Integration", "start": "2026-10-01", "end": "2027-01-31", "phase": 1, "status": "not-started", "description": "Install, integrate with CFX bus, validate per-pad volume data to Postgres.", "deps": ["spi-src", "cfx"] },+ { "type": "task", "id": "spc", "name": "SPC Engine (Rust)", "start": "2026-09-01", "end": "2026-12-31", "phase": 1, "status": "not-started", "description": "Western Electric control-chart rules. Fires alerts on process drift.", "deps": ["pg"] },+ { "type": "task", "id": "pb10", "name": "PumaBridge v0.4–v1.0", "start": "2026-09-01", "end": "2026-11-30", "phase": 1, "status": "planned", "description": "W5: Package file writes. W6: remaining file-writers, observability, pre-flight checks.", "deps": ["pb03"] },+ { "type": "task", "id": "loop1", "name": "Loop 1: Dispense → SPI (advisory)", "start": "2026-12-01", "end": "2027-02-28", "phase": 1, "status": "not-started", "description": "Proportional control on per-pad-class jet valve shot params. Advisory mode.", "deps": ["spi-int", "spc", "pb10"] },+ { "type": "task", "id": "loop7", "name": "Loop 7: Environment → SPC", "start": "2026-10-01", "end": "2027-01-31", "phase": 1, "status": "not-started", "description": "Temperature/humidity-band SPC limit adjustment.", "deps": ["spc", "env"] },+ { "type": "task", "id": "loop4", "name": "Loop 4: Lot Change Screening", "start": "2027-01-01", "end": "2027-02-28", "phase": 1, "status": "not-started", "description": "Tighten SPC limits on first boards after reel change.", "deps": ["spc"] },+ { "type": "task", "id": "mol01", "name": "Molecule-Inventory v0.1", "start": "2026-11-01", "end": "2027-02-28", "phase": 1, "status": "not-started", "description": "Standalone app. Reel tracking, feeder-slot mapping, MSL state, stock levels." },+ { "type": "milestone", "name": "M2: SPI data flowing", "date": "2027-01-15", "phase": 1, "description": "Per-pad paste volume data in Postgres. SPC engine monitoring." },+ { "type": "milestone", "name": "M3: Loop 1 advisory", "date": "2027-02-28", "phase": 1, "description": "Dispense closed-loop running in advisory mode." },++ { "type": "group", "label": "Phase 2 — AOI + Loop 2 (Mar–Aug 2027)", "phase": 2 },+ { "type": "task", "id": "i2s-int", "name": "i2s Integration (if viable)", "start": "2027-01-01", "end": "2027-04-30", "phase": 2, "status": "planned", "description": "Integrate Puma i2s 2D pre-reflow inspection into supervisor. Depends on Q-i2s eval.", "deps": ["i2s"] },+ { "type": "task", "id": "aoi-src", "name": "Source Used Post-Reflow AOI", "start": "2027-01-01", "end": "2027-05-31", "phase": 2, "status": "not-started", "description": "Used Koh Young Zenith or similar. $25K–$80K. Required for Loop 3 and Loop 6." },+ { "type": "task", "id": "aoi-int", "name": "Post-Reflow AOI Integration", "start": "2027-04-01", "end": "2027-07-31", "phase": 2, "status": "not-started", "description": "Install, integrate CFX, validate per-joint solder data.", "deps": ["aoi-src", "cfx"] },+ { "type": "task", "id": "loop2", "name": "Loop 2: Placement → AOI (Kalman)", "start": "2027-03-01", "end": "2027-07-31", "phase": 2, "status": "not-started", "description": "Kalman filter corrects feeder/nozzle offsets from i2s or external pre-reflow AOI.", "deps": ["i2s-int", "pb10"] },+ { "type": "task", "id": "loop6", "name": "Loop 6: Test → Root Cause", "start": "2027-05-01", "end": "2027-08-31", "phase": 2, "status": "not-started", "description": "Correlates test failures to upstream anomalies. Mahalanobis scoring. RCA dossier.", "deps": ["aoi-int", "spc"] },+ { "type": "task", "id": "dash", "name": "Operator Dashboard v1", "start": "2027-04-01", "end": "2027-08-31", "phase": 2, "status": "not-started", "description": "Web app: live SPC charts, alerts, traceability, RCA dossiers." },+ { "type": "milestone", "name": "M4: AOI/i2s data flowing", "date": "2027-05-31", "phase": 2, "description": "Pre-reflow inspection data in Postgres." },+ { "type": "milestone", "name": "M5: Loop 2 closed-loop", "date": "2027-08-31", "phase": 2, "description": "Placement correction running with automatic writebacks." },++ { "type": "group", "label": "Phase 3 — Reflow + Predictive (Sep 2027–Jun 2028)", "phase": 3 },+ { "type": "task", "id": "loop3", "name": "Loop 3: Reflow Bayesian Opt.", "start": "2027-09-01", "end": "2028-03-31", "phase": 3, "status": "not-started", "description": "Bayesian optimization (BoTorch/Ax) of reflow profile. Human-in-the-loop.", "deps": ["aoi-int"] },+ { "type": "task", "id": "loop5", "name": "Loop 5: Feeder Health", "start": "2027-09-01", "end": "2028-02-28", "phase": 3, "status": "not-started", "description": "Predictive maintenance. Supervisor module. Pickup retry anomaly detection." },+ { "type": "task", "id": "loop1cl", "name": "Loop 1 → Closed-Loop", "start": "2027-09-01", "end": "2027-12-31", "phase": 3, "status": "not-started", "description": "Promote Loop 1 from advisory to automatic writebacks.", "deps": ["loop1"] },+ { "type": "task", "id": "mol10", "name": "Molecule-Inventory v1.0", "start": "2027-09-01", "end": "2028-01-31", "phase": 3, "status": "not-started", "description": "Full stock integration, MSL compliance, BOM cross-check.", "deps": ["mol01"] },+ { "type": "milestone", "name": "M6: Loop 3 BO running", "date": "2028-03-31", "phase": 3, "description": "Reflow Bayesian optimization operational." },+ { "type": "milestone", "name": "M7: Feeder predictive maint.", "date": "2028-02-28", "phase": 3, "description": "Feeder health monitoring and alerting live." },++ { "type": "group", "label": "Phase 4 — Research (Jul 2028+)", "phase": 4 },+ { "type": "task", "id": "classif", "name": "Custom Defect Classifiers", "start": "2028-07-01", "end": "2028-12-31", "phase": 4, "status": "not-started", "description": "Train custom vision models on accumulated production data." },+ { "type": "task", "id": "loop8", "name": "Loop 8: Transfer Learning", "start": "2028-07-01", "end": "2028-12-31", "phase": 4, "status": "not-started", "description": "Seed new product recipes from similar existing products." },+ { "type": "task", "id": "loop9", "name": "Loop 9: LLM Operator Assist", "start": "2028-09-01", "end": "2028-12-31", "phase": 4, "status": "not-started", "description": "Plain-English diagnostics and recommendations." }+ ]+}
favicon.svgadded+6@@ -0,0 +1,6 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#e6edf3">+ <rect x="3" y="4" width="18" height="2" rx="1" opacity="0.5"/>+ <rect x="5" y="9" width="12" height="2" rx="1"/>+ <rect x="3" y="14" width="16" height="2" rx="1" opacity="0.7"/>+ <rect x="7" y="19" width="10" height="2" rx="1" opacity="0.5"/>+</svg>
gantt.htmladded+692@@ -0,0 +1,692 @@+<!DOCTYPE html>+<html lang="en">+<head>+<meta charset="UTF-8">+<meta name="viewport" content="width=device-width, initial-scale=1.0">+<title>adom-gantt</title>+<link rel="icon" href="favicon.svg">+<style>+@font-face { font-family: 'Familjen Grotesk'; src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2') format('woff2'); font-weight: 700; font-display: swap; }+@font-face { font-family: 'Familjen Grotesk'; src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-600-normal.woff2') format('woff2'); font-weight: 600; font-display: swap; }+@font-face { font-family: 'Familjen Grotesk'; src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2') format('woff2'); font-weight: 500; font-display: swap; }+@font-face { font-family: 'Satoshi'; src: url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }+@font-face { font-family: 'Satoshi'; src: url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2'); font-weight: 500; font-display: swap; }+@font-face { font-family: 'JetBrains Mono'; src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }++:root {+ --bg: #0d1117; --surface: #161b22; --elevated: #1c2128; --overlay: #21262d;+ --border: #30363d; --border-muted: #21262d;+ --text: #e6edf3; --text2: #8b949e; --text3: #484f58;+ --accent: #00b8b0; --accent-bright: #00e6dc; --today: #00b8b0;+}+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }+html, body { font: 13px/1.4 'Satoshi', -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; background: var(--bg); color: var(--text); overflow: hidden; height: 100%; }++/* ── Header ── */+header { display: flex; align-items: center; gap: 12px; padding: 0 16px; height: 44px; border-bottom: 1px solid var(--border-muted); background: var(--surface); font-family: 'Familjen Grotesk', sans-serif; flex-shrink: 0; }+header .brand { display: flex; align-items: center; gap: 8px; color: var(--accent); font-size: 13px; font-weight: 600; cursor: default; flex-shrink: 0; }+header .brand svg.logo { width: 16px; height: 16px; flex-shrink: 0; }+header .brand .app-name { color: var(--accent); }+header .brand .app-subtitle { color: var(--text2); font-weight: 500; }+header .brand .sep { color: var(--text3); font-weight: 400; }+header .vsep { width: 1px; height: 18px; background: var(--border); flex-shrink: 0; }+header .subject { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; cursor: default; }+header .subject-name { color: var(--text); font-family: 'Familjen Grotesk', sans-serif; font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }+header .subject-meta { display: flex; align-items: center; gap: 6px; color: var(--text2); font-family: 'JetBrains Mono', monospace; font-size: 11px; white-space: nowrap; }+header .subject-meta .sep { color: var(--text3); }+header .actions { display: flex; align-items: center; gap: 4px; margin-left: auto; flex-shrink: 0; }+header .act-btn { background: none; border: 1px solid var(--border); border-radius: 6px; color: var(--text2); font-family: 'Satoshi', sans-serif; font-size: 11px; padding: 4px 10px; cursor: pointer; transition: background 0.15s, color 0.15s; display: flex; align-items: center; gap: 4px; }+header .act-btn:hover { background: rgba(255,255,255,0.04); color: var(--text); }+header .act-btn svg { width: 14px; height: 14px; }++/* ── Legend ── */+.legend { display: flex; gap: 4px; padding: 8px 16px; border-bottom: 1px solid var(--border-muted); background: var(--surface); align-items: center; flex-wrap: wrap; flex-shrink: 0; }+.legend-item { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text2); font-family: 'Satoshi', sans-serif; padding: 3px 10px; border-radius: 6px; cursor: pointer; transition: opacity 0.15s, background 0.15s; user-select: none; }+.legend-item:hover { background: rgba(255,255,255,0.04); }+.legend-item.off { opacity: 0.3; }+.legend-item.static { cursor: default; }+.legend-item.static:hover { background: none; }+.legend-dot { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; }+.legend-diamond { width: 7px; height: 7px; transform: rotate(45deg); background: var(--text); border: 1px solid var(--text2); flex-shrink: 0; }+.legend-line { width: 12px; height: 2px; background: var(--today); border-radius: 1px; flex-shrink: 0; }++/* ── Chart ── */+.chart-wrap { display: flex; flex: 1; overflow: hidden; min-height: 0; }+.labels-col { width: 272px; flex-shrink: 0; border-right: 1px solid var(--border); overflow: hidden; background: var(--bg); }+.timeline-col { flex: 1; overflow: auto; scrollbar-width: none; }+.timeline-col::-webkit-scrollbar { height: 0; width: 0; }+.q-row, .m-row { display: flex; }+.q-row { height: 22px; } .m-row { height: 24px; }+.q-cell { display: flex; align-items: center; justify-content: center; height: 100%; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text3); border-bottom: 1px solid var(--border-muted); border-right: 1px solid var(--border-muted); }+.m-cell { display: flex; align-items: center; justify-content: center; height: 100%; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text3); border-bottom: 1px solid var(--border); border-right: 1px solid rgba(48,54,61,0.3); }+.label-h-q { height: 22px; border-bottom: 1px solid var(--border-muted); }+.label-h-m { height: 24px; border-bottom: 1px solid var(--border); }++/* ── Rows ── */+.label-row { height: 34px; display: flex; align-items: center; padding: 0 12px; border-bottom: 1px solid rgba(48,54,61,0.15); font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: default; transition: background 0.15s, opacity 0.15s; }+.label-row:hover { background: rgba(255,255,255,0.02); }+.label-row.group { font-family: 'Familjen Grotesk', sans-serif; font-size: 12px; font-weight: 600; color: var(--text2); background: var(--surface); height: 28px; letter-spacing: 0.02em; }+.label-row.ms { color: var(--text2); font-size: 11px; font-style: italic; }+.label-row.dim { opacity: 0.15; }+.label-row.multi-sel { background: rgba(0,184,176,0.06); }+.tag { display: inline-block; font-family: 'JetBrains Mono', monospace; font-size: 9px; padding: 1px 5px; border-radius: 4px; margin-left: 6px; vertical-align: middle; }+.tag-live { background: rgba(63,185,80,0.12); color: #3fb950; }+.tag-planned { background: rgba(100,171,255,0.12); color: #64ABFF; }+.tag-not-started { background: rgba(139,148,158,0.10); color: var(--text3); }++.bar-row { height: 34px; position: relative; border-bottom: 1px solid rgba(48,54,61,0.15); transition: opacity 0.15s; }+.bar-row.group { height: 28px; background: var(--surface); }+.bar-row.dim { opacity: 0.15; }+.bar { position: absolute; top: 7px; height: 20px; border-radius: 6px; opacity: 0.82; cursor: pointer; transition: opacity 0.15s; }+.bar:hover { opacity: 1; }+.bar.selected { opacity: 1; outline: 2px solid var(--accent); outline-offset: 1px; }+.bar-striped { background-image: repeating-linear-gradient(-45deg, transparent, transparent 4px, rgba(0,0,0,0.12) 4px, rgba(0,0,0,0.12) 8px); }+.bar .drag-handle { position: absolute; top: 0; width: 8px; height: 100%; cursor: ew-resize; z-index: 2; }+.bar .drag-handle.left { left: -2px; border-radius: 6px 0 0 6px; }+.bar .drag-handle.right { right: -2px; border-radius: 0 6px 6px 0; }+.bar .drag-handle:hover { background: rgba(255,255,255,0.15); }+.ms-diamond { position: absolute; top: 11px; width: 10px; height: 10px; transform: rotate(45deg) translateX(-50%); background: var(--text); border: 2px solid var(--bg); z-index: 5; cursor: pointer; transition: box-shadow 0.15s; }+.ms-diamond:hover { box-shadow: 0 0 10px rgba(230,237,243,0.3); }+.today-line { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--today); z-index: 8; pointer-events: none; box-shadow: 0 0 6px rgba(0,184,176,0.3); }+.today-tag { position: absolute; top: 4px; transform: translateX(-50%); font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--today); background: var(--bg); padding: 1px 4px; border-radius: 3px; border: 1px solid rgba(0,184,176,0.25); pointer-events: none; z-index: 9; white-space: nowrap; }+.phase-bg { position: absolute; top: 0; bottom: 0; opacity: 0.025; pointer-events: none; }+.dep-svg { position: absolute; top: 0; left: 0; pointer-events: none; z-index: 4; overflow: visible; }+.dep-svg path { fill: none; stroke: var(--accent); stroke-width: 1.5; opacity: 0.5; transition: opacity 0.15s; }+.dep-svg polygon { fill: var(--accent); opacity: 0.5; transition: opacity 0.15s; }+.dep-svg.hidden path, .dep-svg.hidden polygon { opacity: 0; }++/* ── Tooltip ── */+.tip { display: none; position: fixed; z-index: 99999; background: rgba(22,27,34,0.92); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border: 1px solid rgba(0,184,177,0.2); border-radius: 10px; padding: 10px 14px; max-width: 340px; box-shadow: 0 12px 40px rgba(0,0,0,0.55); pointer-events: none; font-family: 'Satoshi', sans-serif; font-size: 12px; line-height: 1.5; color: var(--text); }+.tip.show { display: block; }+.tip .tip-title { font-family: 'Familjen Grotesk', sans-serif; font-weight: 600; font-size: 13px; color: var(--accent); margin-bottom: 4px; }+.tip .tip-dates { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text3); margin-bottom: 6px; }+.tip .tip-body { color: var(--text2); }++/* ── Editor panel (single + batch) ── */+.editor { position: fixed; z-index: 99998; background: var(--elevated); border: 1px solid var(--border); border-radius: 8px; padding: 0 14px 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); font-family: 'Satoshi', sans-serif; display: none; min-width: 280px; }+.editor .drag-bar { height: 28px; cursor: grab; display: flex; align-items: center; justify-content: center; margin: 0 -14px 8px; padding: 0 14px; border-radius: 8px 8px 0 0; }+.editor .drag-bar:active { cursor: grabbing; }+.editor .drag-bar::after { content: ''; width: 32px; height: 3px; border-radius: 2px; background: var(--border); }+.editor.show { display: block; }+.editor label { display: block; font-size: 11px; color: var(--text3); margin-bottom: 4px; font-family: 'JetBrains Mono', monospace; }+.editor input[type="date"], .editor input[type="number"] { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 12px; padding: 5px 8px; width: 130px; outline: none; }+.editor input:focus { border-color: var(--accent); }+.editor .de-row { display: flex; gap: 10px; margin-bottom: 8px; }+.editor .de-actions { display: flex; gap: 8px; margin-top: 10px; }+.editor .de-dep-section { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-muted); }+.editor select { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-family: 'Satoshi', sans-serif; font-size: 12px; padding: 5px 8px; width: 100%; outline: none; }+.de-btn { border: none; border-radius: 6px; padding: 6px 14px; font-family: 'Satoshi', sans-serif; font-size: 12px; font-weight: 500; cursor: pointer; transition: background 0.15s; }+.de-btn.primary { background: var(--accent); color: var(--bg); }+.de-btn.primary:hover { background: var(--accent-bright); }+.de-btn.secondary { background: transparent; color: var(--text2); border: 1px solid var(--border); }+.de-btn.secondary:hover { background: rgba(255,255,255,0.04); }+.dep-list { list-style: none; margin: 4px 0 0; }+.dep-list li { display: flex; align-items: center; justify-content: space-between; font-size: 11px; color: var(--text2); padding: 3px 0; }+.dep-list .dep-rm { color: var(--text3); cursor: pointer; font-size: 13px; padding: 0 4px; border-radius: 4px; transition: color 0.15s; }+.dep-list .dep-rm:hover { color: #f85149; }+.batch-title { font-family: 'Familjen Grotesk', sans-serif; font-size: 13px; font-weight: 600; color: var(--accent); margin-bottom: 10px; }+.batch-hint { font-size: 11px; color: var(--text3); margin-bottom: 8px; }+.batch-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }+.batch-row label { margin: 0; min-width: 60px; }+.batch-row input[type="number"] { width: 70px; }+.batch-row span { font-size: 11px; color: var(--text3); }++/* ── Dropdown menu ── */+.dropdown { position: fixed; z-index: 99997; background: var(--elevated); border: 1px solid var(--border); border-radius: 8px; padding: 4px 0; box-shadow: 0 8px 24px rgba(0,0,0,0.5); display: none; min-width: 180px; }+.dropdown.show { display: block; }+.dropdown .dd-item { padding: 6px 14px; font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text2); cursor: pointer; transition: background 0.15s; display: flex; align-items: center; gap: 8px; }+.dropdown .dd-item:hover { background: rgba(255,255,255,0.04); color: var(--text); }+.dropdown .dd-sep { height: 1px; background: var(--border-muted); margin: 4px 0; }+.dropdown .dd-label { padding: 4px 14px; font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--text3); }++/* ── Toasts ── */+.undo-toast { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); background: var(--elevated); border: 1px solid var(--border); border-radius: 8px; padding: 8px 16px; font-size: 12px; color: var(--text2); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 99998; opacity: 0; transition: opacity 0.15s; pointer-events: none; }+.undo-toast.show { opacity: 1; }+.undo-toast kbd { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text3); }+.save-indicator { position: fixed; bottom: 16px; right: 16px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text3); z-index: 99998; opacity: 0; transition: opacity 0.3s; }+.save-indicator.show { opacity: 1; }+.sel-count { position: fixed; bottom: 16px; left: 16px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--accent); z-index: 99998; opacity: 0; transition: opacity 0.15s; }+.sel-count.show { opacity: 1; }++.drop-indicator { position: absolute; left: 0; right: 0; height: 2px; background: var(--accent); z-index: 20; pointer-events: none; border-radius: 1px; box-shadow: 0 0 6px rgba(0,184,176,0.4); display: none; }+.drop-indicator.show { display: block; }+.label-row.dragging, .bar-row.dragging { opacity: 0.4; }+.label-row[data-id] { cursor: default; }+.labels-col, .timeline-col { -webkit-user-select: none; user-select: none; }+.app { display: flex; flex-direction: column; height: 100vh; }+input[type="file"] { display: none; }+</style>+</head>+<body>+<div class="app">+ <header id="hdr"></header>+ <div class="legend" id="legend"></div>+ <div class="chart-wrap">+ <div class="labels-col" id="labels"></div>+ <div class="timeline-col" id="timeline"></div>+ </div>+</div>+<div class="tip" id="tip"></div>+<div class="editor" id="editor"></div>+<div class="dropdown" id="exportMenu"></div>+<div class="dropdown" id="importMenu"></div>+<div class="undo-toast" id="undoToast">Undid change · <kbd>Ctrl+Z</kbd></div>+<div class="save-indicator" id="saveInd">saved</div>+<div class="sel-count" id="selCount"></div>+<input type="file" id="importFile" accept=".json,.csv,.xml">++<script>+(async function() {++const COL = 66;+const $ = id => document.getElementById(id);+const labelsEl = $('labels'), timelineEl = $('timeline'), tipEl = $('tip');+const editorEl = $('editor'), undoToast = $('undoToast'), saveInd = $('saveInd');+const selCountEl = $('selCount'), exportMenu = $('exportMenu'), importMenu = $('importMenu');+const importFileEl = $('importFile');++// ── Load data ──+const projData = await fetch('data').then(r => r.json());+const { project, phases, items } = projData;+document.title = `${project.title} — adom-gantt`;++// ── Compute date range ──+let minD = '9999-12-31', maxD = '0000-01-01';+items.forEach(r => { if (r.start && r.start < minD) minD = r.start; if (r.end && r.end > maxD) maxD = r.end; if (r.date && r.date < minD) minD = r.date; if (r.date && r.date > maxD) maxD = r.date; });+phases.forEach(p => { const ps = p.start+'-01'; const [ey,em]=p.end.split('-').map(Number); const pe=`${ey}-${String(em).padStart(2,'0')}-28`; if (ps<minD) minD=ps; if (pe>maxD) maxD=pe; });+const [minY,minM] = minD.split('-').map(Number), [maxY,maxM] = maxD.split('-').map(Number);+const START = new Date(minY, minM-1, 1), MONTHS = (maxY-minY)*12+(maxM-minM)+2, W = MONTHS*COL;++function mDiff(a,b){return(b.getFullYear()-a.getFullYear())*12+b.getMonth()-a.getMonth();}+function toX(ds){const[y,mo,d]=ds.split('-').map(Number);return(mDiff(START,new Date(y,mo-1,d||1))+((d?(d-1)/30:0)))*COL;}+function xToDate(x){const tm=x/COL,mo=Math.floor(tm),df=tm-mo,d=new Date(START.getFullYear(),START.getMonth()+mo,1),day=Math.max(1,Math.min(28,Math.round(df*30)+1));return`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(day).padStart(2,'0')}`;}+function fmt(ds){const[y,mo,d]=ds.split('-').map(Number),mn=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];return d?`${mn[mo-1]} ${d}, ${y}`:`${mn[mo-1]} ${y}`;}+function addDays(ds, n){const[y,mo,d]=ds.split('-').map(Number);const dt=new Date(y,mo-1,d);dt.setDate(dt.getDate()+n);return`${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}`;}++// ── Header with import/export buttons ──+$('hdr').innerHTML = `+ <div class="brand"><svg class="logo" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="3" y="4" width="18" height="2" rx="1" opacity="0.5"/><rect x="5" y="9" width="12" height="2" rx="1"/><rect x="3" y="14" width="16" height="2" rx="1" opacity="0.7"/><rect x="7" y="19" width="10" height="2" rx="1" opacity="0.5"/></svg>+ <span class="app-name">${project.appName||'adom-gantt'}</span><span class="sep">·</span><span class="app-subtitle">${project.subtitle||'Gantt chart'}</span></div>+ <div class="vsep"></div>+ <div class="subject"><span class="subject-name">${project.title}</span><span class="subject-meta"><span class="sep">·</span><span>${MONTHS} months</span></span></div>+ <div class="actions">+ <button class="act-btn" id="btnImport"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2"/></svg>Import</button>+ <button class="act-btn" id="btnExport"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 21V9m0 0l4 4m-4-4l-4 4M4 7V5a2 2 0 012-2h12a2 2 0 012 2v2"/></svg>Export</button>+ <button class="act-btn" id="btnHelp" style="font-family:'Familjen Grotesk',sans-serif;font-size:14px;font-weight:600;padding:4px 9px">?</button>+ </div>`;++// ── State ──+const phaseVis = phases.map(()=>true);+let msVis=true, depsVis=true;+const selected = new Set();+const barEls = {};+let depSvg;++// ── Undo ──+const undoStack = [];+let undoTimer = null;+function snapshotMany(ids){ids.forEach(id=>{const r=items.find(d=>d.id===id);if(r)undoStack.push({type:'edit',id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[]});});if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);}+function snapshot(id){snapshotMany([id]);}+function snapshotOrder(){undoStack.push({type:'order',order:items.map(r=>r.id||r.label||r.name)});if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);}+function applyOrder(order){+ const byKey=new Map();items.forEach(r=>byKey.set(r.id||r.label||r.name, r));+ items.length=0;order.forEach(k=>{const r=byKey.get(k);if(r)items.push(r);});+ items.forEach((r,i)=>{r._itemIdx=i;labelsEl.appendChild(r._lDiv);bars.appendChild(r._bDiv);if(r.id&&barEls[r.id])barEls[r.id].idx=i;});+ drawDeps();+}+function undo(){+ if(!undoStack.length)return;+ const sn=undoStack.pop();+ if(sn.type==='order'){applyOrder(sn.order);}+ else{const r=items.find(d=>d.id===sn.id);if(!r)return;r.start=sn.s;r.end=sn.e;r.deps=sn.deps;updateBar(sn.id);}+ clearTimeout(undoTimer);undoToast.classList.add('show');undoTimer=setTimeout(()=>undoToast.classList.remove('show'),1800);scheduleSave();+}+document.addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key==='z'){e.preventDefault();undo();}if(e.key==='Escape'){clearSelection();closeEditor();closeMenus();}});++// ── Persistence ──+let saveTimer=null;+function getState(){return items.filter(r=>r.id).map(r=>({id:r.id,s:r.start,e:r.end,deps:r.deps||[]}));}+function applyState(state){if(!Array.isArray(state))return;state.forEach(sv=>{const r=items.find(d=>d.id===sv.id);if(!r)return;if(sv.s)r.start=sv.s;if(sv.e)r.end=sv.e;if(sv.deps)r.deps=sv.deps;});}+function scheduleSave(){clearTimeout(saveTimer);saveTimer=setTimeout(saveToFile,800);}+function saveToFile(){fetch('save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(getState())}).then(r=>r.json()).then(()=>{flash(saveInd,'saved');}).catch(()=>{flash(saveInd,'save failed',2500);});}+function flash(el,text,ms){el.textContent=text;el.classList.add('show');setTimeout(()=>el.classList.remove('show'),ms||1500);}++// ── Legend ──+const legendEl = $('legend');+phases.forEach((ph,i)=>{const el=document.createElement('div');el.className='legend-item';el.innerHTML=`<div class="legend-dot" style="background:${ph.color}"></div>${ph.name}`;el.addEventListener('click',()=>{phaseVis[i]=!phaseVis[i];el.classList.toggle('off',!phaseVis[i]);applyFilter();});legendEl.appendChild(el);});+const msL=document.createElement('div');msL.className='legend-item';msL.innerHTML='<div class="legend-diamond"></div>Milestone';msL.addEventListener('click',()=>{msVis=!msVis;msL.classList.toggle('off',!msVis);applyFilter();});legendEl.appendChild(msL);+const depL=document.createElement('div');depL.className='legend-item';depL.innerHTML='<svg width="14" height="10" style="flex-shrink:0"><path d="M0,5C4,5 10,5 14,5" stroke="var(--accent)" stroke-width="1.5" fill="none" opacity="0.6"/><polygon points="14,5 10,3 10,7" fill="var(--accent)" opacity="0.6"/></svg>Dependencies';+depL.addEventListener('click',()=>{depsVis=!depsVis;depL.classList.toggle('off',!depsVis);depSvg.classList.toggle('hidden',!depsVis);});legendEl.appendChild(depL);+legendEl.appendChild(Object.assign(document.createElement('div'),{className:'legend-item static',innerHTML:'<div class="legend-line"></div>Today'}));++// ── Build chart ──+labelsEl.appendChild(Object.assign(document.createElement('div'),{className:'label-h-q'}));+const qRow=document.createElement('div');qRow.className='q-row';qRow.style.width=W+'px';+let cm=new Date(START);const qs=[];let cq=null;+for(let i=0;i<MONTHS;i++){const ql=`Q${Math.floor(cm.getMonth()/3)+1} ${cm.getFullYear()}`;if(!cq||cq.l!==ql){if(cq)qs.push(cq);cq={l:ql,n:1};}else cq.n++;cm=new Date(cm.getFullYear(),cm.getMonth()+1,1);}+if(cq)qs.push(cq);+qs.forEach(q=>{const c=document.createElement('div');c.className='q-cell';c.style.width=(q.n*COL)+'px';c.textContent=q.l;qRow.appendChild(c);});+labelsEl.appendChild(Object.assign(document.createElement('div'),{className:'label-h-m'}));+const mRow=document.createElement('div');mRow.className='m-row';mRow.style.width=W+'px';+const mNames=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];+cm=new Date(START);for(let i=0;i<MONTHS;i++){const c=document.createElement('div');c.className='m-cell';c.style.width=COL+'px';c.textContent=mNames[cm.getMonth()];mRow.appendChild(c);cm=new Date(cm.getFullYear(),cm.getMonth()+1,1);}++const bars=document.createElement('div');bars.style.cssText=`position:relative;width:${W}px;`;+phases.forEach((p,i)=>{const bg=document.createElement('div');bg.className='phase-bg';const[sy,sm]=p.start.split('-').map(Number),[ey,em]=p.end.split('-').map(Number);bg.style.left=(mDiff(START,new Date(sy,sm-1,1))*COL)+'px';bg.style.width=((mDiff(START,new Date(ey,em-1,1))+1)*COL-mDiff(START,new Date(sy,sm-1,1))*COL)+'px';bg.style.background=p.color;bars.appendChild(bg);});+for(let i=0;i<=MONTHS;i++){const l=document.createElement('div');l.style.cssText=`position:absolute;top:0;bottom:0;left:${i*COL}px;width:1px;background:rgba(48,54,61,0.18);pointer-events:none;`;bars.appendChild(l);}+depSvg=document.createElementNS('http://www.w3.org/2000/svg','svg');depSvg.classList.add('dep-svg');depSvg.setAttribute('width',W);depSvg.setAttribute('height','1');bars.appendChild(depSvg);++const statusTag={live:'tag-live',planned:'tag-planned','not-started':'tag-not-started'};+const statusLabel={live:'live',planned:'planned','not-started':'not started'};++items.forEach((r,idx)=>{+ const lDiv=document.createElement('div'),bDiv=document.createElement('div');+ if(r.type==='group'){lDiv.className='label-row group';lDiv.textContent=r.label;bDiv.className='bar-row group';}+ else if(r.type==='milestone'){lDiv.className='label-row ms';lDiv.innerHTML='◆ '+r.name;bDiv.className='bar-row';const x=toX(r.date),d=document.createElement('div');d.className='ms-diamond';d.style.left=x+'px';d.dataset.title=r.name;d.dataset.dates=fmt(r.date);d.dataset.body=r.description||'';bDiv.appendChild(d);}+ else{lDiv.className='label-row';lDiv.dataset.id=r.id;const tg=r.status?`<span class="tag ${statusTag[r.status]||''}">${statusLabel[r.status]||r.status}</span>`:'';lDiv.innerHTML=r.name+tg;bDiv.className='bar-row';const x1=toX(r.start),x2=toX(r.end),b=document.createElement('div');b.className='bar'+(r.striped?' bar-striped':'');b.style.left=x1+'px';b.style.width=Math.max(x2-x1,6)+'px';b.style.background=phases[r.phase].color;b.dataset.title=r.name;b.dataset.dates=fmt(r.start)+' → '+fmt(r.end);b.dataset.body=r.description||'';b.dataset.id=r.id;b.dataset.idx=idx;b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle left'}));b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle right'}));bDiv.appendChild(b);barEls[r.id]={bar:b,idx,row:bDiv};}+ r._lDiv=lDiv;r._bDiv=bDiv;r._itemIdx=idx;labelsEl.appendChild(lDiv);bars.appendChild(bDiv);+});++const today=new Date(),tx=mDiff(START,today)*COL+(today.getDate()/30)*COL;+if(tx>0&&tx<W){const tl=document.createElement('div');tl.className='today-line';tl.style.left=tx+'px';bars.appendChild(tl);const tt=document.createElement('div');tt.className='today-tag';tt.style.left=tx+'px';tt.textContent='today';bars.appendChild(tt);}+timelineEl.appendChild(qRow);timelineEl.appendChild(mRow);timelineEl.appendChild(bars);+timelineEl.addEventListener('scroll',()=>{labelsEl.scrollTop=timelineEl.scrollTop;});++// ── Filter ──+function applyFilter(){items.forEach(r=>{let vis=true;if(r.phase!==undefined&&!phaseVis[r.phase])vis=false;if(r.type==='milestone'&&!msVis)vis=false;r._lDiv.classList.toggle('dim',!vis);r._bDiv.classList.toggle('dim',!vis);});drawDeps();}++// ── Dependencies ──+function drawDeps(){+ depSvg.innerHTML='';let maxY=0;+ items.forEach(r=>{if(!r.deps||!r.id)return;const tgt=barEls[r.id];if(!tgt)return;+ r.deps.forEach(depId=>{const src=barEls[depId];if(!src)return;if(r._bDiv.classList.contains('dim')||items[src.idx]._bDiv.classList.contains('dim'))return;+ const sb=src.bar,tb=tgt.bar,x1=sb.offsetLeft+sb.offsetWidth,y1=sb.closest('.bar-row').offsetTop+17,x2=tb.offsetLeft,y2=tb.closest('.bar-row').offsetTop+17,mx=x1+(x2-x1)*0.5;+ const p=document.createElementNS('http://www.w3.org/2000/svg','path');p.setAttribute('d',`M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`);depSvg.appendChild(p);+ const a=document.createElementNS('http://www.w3.org/2000/svg','polygon');a.setAttribute('points',`${x2},${y2} ${x2-6},${y2-3} ${x2-6},${y2+3}`);depSvg.appendChild(a);+ maxY=Math.max(maxY,y1,y2);});});+ depSvg.setAttribute('height',Math.max(maxY+40,bars.scrollHeight));+}+setTimeout(drawDeps,100);new ResizeObserver(drawDeps).observe(bars);++// ── Selection (Ctrl+click toggle, Shift+click range, click single) ──+let selAnchor = null;+function taskIds(){return items.filter(r=>r.id).map(r=>r.id);}+function clearSelection(){selected.clear();refreshSelVisuals();updateSelCount();}+function refreshSelVisuals(){+ document.querySelectorAll('.bar.selected').forEach(b=>b.classList.remove('selected'));+ document.querySelectorAll('.label-row.multi-sel').forEach(l=>l.classList.remove('multi-sel'));+ selected.forEach(sid=>{if(barEls[sid]){barEls[sid].bar.classList.add('selected');const r=items.find(d=>d.id===sid);if(r)r._lDiv.classList.add('multi-sel');}});+}+function doSelect(id, e){+ const ctrl=e.ctrlKey||e.metaKey, shift=e.shiftKey;+ if(shift&&selAnchor){+ const ids=taskIds(), ai=ids.indexOf(selAnchor), ci=ids.indexOf(id);+ if(ai>=0&&ci>=0){+ const lo=Math.min(ai,ci), hi=Math.max(ai,ci);+ if(!ctrl)selected.clear();+ for(let i=lo;i<=hi;i++)selected.add(ids[i]);+ }+ } else if(ctrl){+ if(selected.has(id))selected.delete(id); else selected.add(id);+ selAnchor=id;+ } else {+ const was=selected.has(id)&&selected.size===1;+ selected.clear();+ if(!was){selected.add(id);selAnchor=id;}else{selAnchor=null;}+ }+ refreshSelVisuals();updateSelCount();+}+function updateSelCount(){if(selected.size>1){selCountEl.textContent=`${selected.size} selected`;selCountEl.classList.add('show');}else{selCountEl.classList.remove('show');}}++// ── Label click → select bar ──+labelsEl.addEventListener('click',e=>{+ if(reorderState)return;+ const row=e.target.closest('.label-row[data-id]');+ if(!row)return;+ const id=row.dataset.id;+ doSelect(id, e);+ if(selected.size===1)openSingleEditor(id);+ else if(selected.size>1)openBatchEditor();+ else closeEditor();+});++// ── Unified pending gesture (vertical=reorder, horizontal=move) ──+let reorderState=null, gesture=null;+const labelDropInd=document.createElement('div');labelDropInd.className='drop-indicator';labelsEl.appendChild(labelDropInd);+const barDropInd=document.createElement('div');barDropInd.className='drop-indicator';bars.appendChild(barDropInd);+function rowYCenter(el){return el.offsetTop+el.offsetHeight/2;}++labelsEl.addEventListener('mousedown',e=>{+ const row=e.target.closest('.label-row[data-id]');+ if(!row)return;+ gesture={id:row.dataset.id,startX:e.clientX,startY:e.clientY,src:'label'};+});++// (bar body mousedown is handled below in the drag section)++let didDrag = false;+bars.addEventListener('click',e=>{+ if(didDrag){didDrag=false;return;}+ const bar=e.target.closest('.bar[data-id]');+ if(bar&&!e.target.classList.contains('drag-handle')){+ const id=bar.dataset.id;+ doSelect(id, e);+ if(selected.size===1)openSingleEditor(id);+ else if(selected.size>1)openBatchEditor();+ else closeEditor();+ e.stopPropagation();+ }else if(!e.target.closest('.editor')&&!e.target.closest('.dropdown')){clearSelection();closeEditor();}+});+document.addEventListener('click',e=>{if(!e.target.closest('.editor')&&!e.target.closest('.bar[data-id]')&&!e.target.closest('.dropdown')&&!e.target.closest('.act-btn'))closeEditor();});++// ── Single editor ──+function openSingleEditor(id){+ const r=items.find(d=>d.id===id);if(!r)return;+ const allTasks=items.filter(d=>d.id&&d.id!==id),deps=r.deps||[];+ editorEl.innerHTML=`<div class="drag-bar"></div><div class="de-row"><div><label>start</label><input type="date" id="deStart" value="${r.start}"></div><div><label>end</label><input type="date" id="deEnd" value="${r.end}"></div></div><div class="de-dep-section"><label>dependencies</label><ul class="dep-list" id="deDepList"></ul><select id="deAddDep" style="margin-top:6px;"><option value="">+ add dependency...</option>${allTasks.filter(t=>!deps.includes(t.id)).map(t=>`<option value="${t.id}">${t.name}</option>`).join('')}</select></div><div class="de-actions"><button class="de-btn primary" id="deApply">Apply</button><button class="de-btn secondary" id="deClose">Cancel</button></div>`;+ renderDepList(id);+ const rect=barEls[id].bar.getBoundingClientRect();+ const edW=300, edH=300;+ let edX=rect.right+8, edY=rect.top;+ if(edX+edW>window.innerWidth) edX=rect.left-edW-8;+ if(edX<0) edX=8;+ if(edY+edH>window.innerHeight) edY=window.innerHeight-edH-8;+ if(edY<0) edY=8;+ editorEl.style.left=edX+'px';editorEl.style.top=edY+'px';editorEl.classList.add('show');+ $('deAddDep').addEventListener('change',function(){if(this.value){snapshot(id);if(!r.deps)r.deps=[];r.deps.push(this.value);renderDepList(id);drawDeps();scheduleSave();const o=this.querySelector(`option[value="${r.deps[r.deps.length-1]}"]`);if(o)o.remove();this.value='';}});+ $('deApply').addEventListener('click',()=>{snapshot(id);const ns=$('deStart').value,ne=$('deEnd').value;if(ns)r.start=ns;if(ne)r.end=ne;updateBar(id);closeEditor();scheduleSave();});+ $('deClose').addEventListener('click',closeEditor);+}++function renderDepList(id){+ const r=items.find(d=>d.id===id),list=$('deDepList');if(!list)return;+ const incoming=(r.deps||[]).map(depId=>{const dep=items.find(d=>d.id===depId);return{depId,name:dep?dep.name:depId,dir:'in'};});+ const outgoing=items.filter(d=>d.deps&&d.deps.includes(id)).map(d=>({depId:d.id,name:d.name,dir:'out'}));+ const all=[...incoming,...outgoing];+ list.innerHTML=all.map(d=>`<li><span style="font-family:'JetBrains Mono',monospace;font-size:9px;color:${d.dir==='in'?'var(--accent)':'var(--text3)'};margin-right:4px;min-width:48px">${d.dir==='in'?'← needs':'→ blocks'}</span><span style="flex:1;color:${d.dir==='in'?'var(--text)':'var(--text2)'}">${d.name}</span><span class="dep-rm" data-dep="${d.depId}" data-dir="${d.dir}">×</span></li>`).join('')||'<li style="color:var(--text3)">none</li>';+ list.querySelectorAll('.dep-rm').forEach(btn=>{btn.addEventListener('click',()=>{+ const depId=btn.dataset.dep, dir=btn.dataset.dir;+ if(dir==='in'){snapshot(id);r.deps=r.deps.filter(d=>d!==depId);}+ else{const blocker=items.find(d=>d.id===depId);if(blocker){snapshot(depId);blocker.deps=blocker.deps.filter(d=>d!==id);}}+ renderDepList(id);drawDeps();scheduleSave();+ });});+}++// ── Batch editor (relative adjustments) ──+function daysBetween(a,b){const[ay,am,ad]=a.split('-').map(Number),[by,bm,bd]=b.split('-').map(Number);return Math.round((new Date(by,bm-1,bd)-new Date(ay,am-1,ad))/86400000);}++function openBatchEditor(){+ const ids=[...selected];+ const tasks=ids.map(id=>items.find(d=>d.id===id)).filter(r=>r&&r.start);+ if(!tasks.length){closeEditor();return;}+ const earliest=tasks.reduce((a,r)=>r.start<a?r.start:a,tasks[0].start);+ const latest=tasks.reduce((a,r)=>r.end>a?r.end:a,tasks[0].end);++ editorEl.innerHTML=`<div class="drag-bar"></div>+ <div class="batch-title">${ids.length} tasks selected</div>+ <div class="batch-hint">All changes are relative — each task shifts from its own dates</div>+ <div style="margin-bottom:10px">+ <div class="de-row">+ <div><label>earliest start</label><input type="date" id="bStart" value="${earliest}"></div>+ <div><label>latest end</label><input type="date" id="bEnd" value="${latest}"></div>+ </div>+ <div style="font-size:10px;color:var(--text3);margin-top:2px">Changing a date shifts all selected by the same offset</div>+ </div>+ <div style="border-top:1px solid var(--border-muted);padding-top:8px;margin-bottom:4px">+ <div class="batch-row"><label>shift all</label><input type="number" id="bShiftAll" value="0" step="1"> <span>days</span></div>+ <div class="batch-row"><label>shift start</label><input type="number" id="bShiftS" value="0" step="1"> <span>days</span></div>+ <div class="batch-row"><label>shift end</label><input type="number" id="bShiftE" value="0" step="1"> <span>days</span></div>+ </div>+ <div class="de-actions"><button class="de-btn primary" id="bApply">Apply</button><button class="de-btn secondary" id="bClose">Cancel</button></div>`;+ editorEl.style.left=(window.innerWidth/2-155)+'px';editorEl.style.top=(window.innerHeight/2-130)+'px';editorEl.classList.add('show');++ $('bApply').addEventListener('click',()=>{+ const newStart=$('bStart').value, newEnd=$('bEnd').value;+ const startDelta=daysBetween(earliest,newStart);+ const endDelta=daysBetween(latest,newEnd);+ const all=parseInt($('bShiftAll').value)||0, ds=parseInt($('bShiftS').value)||0, de=parseInt($('bShiftE').value)||0;+ const totalStartShift=startDelta+all+ds, totalEndShift=endDelta+all+de;+ if(totalStartShift===0&&totalEndShift===0){closeEditor();return;}+ snapshotMany(ids);+ ids.forEach(id=>{const r=items.find(d=>d.id===id);if(!r||!r.start)return;r.start=addDays(r.start,totalStartShift);r.end=addDays(r.end,totalEndShift);updateBar(id);});+ scheduleSave();closeEditor();+ });+ $('bClose').addEventListener('click',closeEditor);+}++function updateBar(id){const r=items.find(d=>d.id===id),be=barEls[id];if(!r||!be)return;const x1=toX(r.start),x2=toX(r.end);be.bar.style.left=x1+'px';be.bar.style.width=Math.max(x2-x1,6)+'px';be.bar.dataset.dates=fmt(r.start)+' → '+fmt(r.end);drawDeps();}+function closeEditor(){editorEl.classList.remove('show');editorEl.innerHTML='';}+function closeMenus(){exportMenu.classList.remove('show');importMenu.classList.remove('show');}++// ── Drag (edge-resize, body-move horizontal, reorder vertical) ──+let dragState=null;++bars.addEventListener('mousedown',e=>{+ const handle=e.target.closest('.drag-handle');+ const bar=handle?handle.closest('.bar[data-id]'):e.target.closest('.bar[data-id]');+ if(!bar)return;++ if(handle){+ e.preventDefault();hideTip();+ const id=bar.dataset.id, r=items.find(d=>d.id===id);+ const side=handle.classList.contains('left')?'left':'right';+ snapshot(id);+ if(!selected.has(id)){selected.clear();selected.add(id);selAnchor=id;refreshSelVisuals();updateSelCount();}+ dragState={id,side,startX:e.clientX,origLeft:parseFloat(bar.style.left),origWidth:parseFloat(bar.style.width),bar,r,moveIds:[id],originals:{[id]:{left:parseFloat(bar.style.left),width:parseFloat(bar.style.width)}}};+ document.body.style.cursor='ew-resize';+ return;+ }++ gesture={id:bar.dataset.id,startX:e.clientX,startY:e.clientY,src:'bar',bar};+});++document.addEventListener('mousemove',e=>{+ // Resolve gesture → horizontal drag or vertical reorder+ if(gesture&&!dragState&&!reorderState){+ const dx=e.clientX-gesture.startX, dy=e.clientY-gesture.startY;+ if(Math.abs(dx)<5&&Math.abs(dy)<5)return;+ hideTip();didDrag=true;+ const id=gesture.id;++ if(Math.abs(dx)>=Math.abs(dy)){+ // Horizontal → move bar dates+ const bar=gesture.bar||barEls[id]?.bar;+ if(!bar){gesture=null;return;}+ const r=items.find(d=>d.id===id);+ const moveIds=(selected.has(id)&&selected.size>1)?[...selected]:[id];+ snapshotMany(moveIds);+ const originals={};+ moveIds.forEach(mid=>{const be=barEls[mid];if(be)originals[mid]={left:parseFloat(be.bar.style.left),width:parseFloat(be.bar.style.width)};});+ dragState={id,side:'move',startX:gesture.startX,origLeft:parseFloat(bar.style.left),origWidth:parseFloat(bar.style.width),bar,r,moveIds,originals};+ if(!selected.has(id)){selected.clear();selected.add(id);selAnchor=id;refreshSelVisuals();updateSelCount();}+ document.body.style.cursor='grabbing';+ } else {+ // Vertical → reorder+ reorderState={active:true,id};+ closeEditor();+ const moveIds=(selected.has(id)&&selected.size>1)?[...selected]:[id];+ if(!selected.has(id)){selected.clear();selected.add(id);selAnchor=id;refreshSelVisuals();updateSelCount();}+ reorderState.moveIds=moveIds;+ moveIds.forEach(mid=>{const r=items.find(d=>d.id===mid);if(r){r._lDiv.classList.add('dragging');r._bDiv.classList.add('dragging');}});+ document.body.style.cursor='grabbing';+ }+ gesture=null;+ }++ // Horizontal drag+ if(dragState){+ didDrag=true;+ const dx=e.clientX-dragState.startX;+ const{side,origLeft,origWidth,bar,r,moveIds,originals}=dragState;+ if(side==='move'){+ moveIds.forEach(mid=>{+ const be=barEls[mid],orig=originals[mid],ri=items.find(d=>d.id===mid);+ if(!be||!orig||!ri)return;+ const nl=Math.max(0,orig.left+dx);+ be.bar.style.left=nl+'px';ri.start=xToDate(nl);ri.end=xToDate(nl+orig.width);+ be.bar.dataset.dates=fmt(ri.start)+' → '+fmt(ri.end);+ });+ } else if(side==='left'){+ const nl=Math.max(0,origLeft+dx),nw=origWidth-(nl-origLeft);+ if(nw>=6){bar.style.left=nl+'px';bar.style.width=nw+'px';r.start=xToDate(nl);bar.dataset.dates=fmt(r.start)+' → '+fmt(r.end);}+ } else {+ const nw=Math.max(6,origWidth+dx);bar.style.width=nw+'px';r.end=xToDate(origLeft+nw);bar.dataset.dates=fmt(r.start)+' → '+fmt(r.end);+ }+ drawDeps();+ }++ // Vertical reorder+ if(reorderState&&reorderState.active){+ const labelsRect=labelsEl.getBoundingClientRect();+ const mouseY=e.clientY-labelsRect.top+labelsEl.scrollTop;+ let targetIdx=-1, insertBefore=true;+ for(let i=0;i<items.length;i++){+ const r=items[i];if(reorderState.moveIds.includes(r.id))continue;+ const cy=rowYCenter(r._lDiv);+ if(mouseY<cy){targetIdx=i;insertBefore=true;break;}+ targetIdx=i;insertBefore=false;+ }+ if(targetIdx>=0){+ const ref=items[targetIdx];+ labelDropInd.style.top=(insertBefore?ref._lDiv.offsetTop:ref._lDiv.offsetTop+ref._lDiv.offsetHeight)+'px';labelDropInd.classList.add('show');+ barDropInd.style.top=(insertBefore?ref._bDiv.offsetTop:ref._bDiv.offsetTop+ref._bDiv.offsetHeight)+'px';barDropInd.classList.add('show');+ reorderState.targetIdx=targetIdx;reorderState.insertBefore=insertBefore;+ }+ }+});++document.addEventListener('mouseup',()=>{+ gesture=null;++ if(dragState){scheduleSave();dragState=null;document.body.style.cursor='';}++ if(reorderState&&reorderState.active){+ labelDropInd.classList.remove('show');barDropInd.classList.remove('show');+ reorderState.moveIds.forEach(mid=>{const r=items.find(d=>d.id===mid);if(r){r._lDiv.classList.remove('dragging');r._bDiv.classList.remove('dragging');}});+ document.body.style.cursor='';+ if(reorderState.targetIdx!=null){+ snapshotOrder();+ const moveItems=reorderState.moveIds.map(mid=>items.find(d=>d.id===mid)).filter(Boolean);+ const remainingItems=items.filter(r=>!reorderState.moveIds.includes(r.id));+ const actualInsert=remainingItems.indexOf(items[reorderState.targetIdx]);+ const finalInsert=reorderState.insertBefore?actualInsert:actualInsert+1;+ remainingItems.splice(finalInsert,0,...moveItems);+ items.length=0;items.push(...remainingItems);+ items.forEach((r,i)=>{r._itemIdx=i;labelsEl.appendChild(r._lDiv);bars.appendChild(r._bDiv);if(r.id&&barEls[r.id])barEls[r.id].idx=i;});+ drawDeps();scheduleSave();didDrag=true;+ }+ reorderState=null;+ }+});++// ── Tooltip ──+let tipTimer=null;+function showTip(el,e){if(dragState)return;clearTimeout(tipTimer);tipTimer=setTimeout(()=>{const t=el.dataset.title,d=el.dataset.dates,b=el.dataset.body;if(!t)return;tipEl.innerHTML=`<div class="tip-title">${t}</div><div class="tip-dates">${d}</div>${b?'<div class="tip-body">'+b+'</div>':''}`;tipEl.classList.add('show');posTip(e);},500);}+function posTip(e){tipEl.style.left=Math.min(e.clientX+14,window.innerWidth-360)+'px';tipEl.style.top=Math.min(e.clientY+18,window.innerHeight-120)+'px';}+function hideTip(){clearTimeout(tipTimer);tipEl.classList.remove('show');}+document.addEventListener('mouseover',e=>{const el=e.target.closest('[data-title]');if(el)showTip(el,e);});+document.addEventListener('mouseout',e=>{if(e.target.closest('[data-title]'))hideTip();});+document.addEventListener('mousemove',e=>{if(tipEl.classList.contains('show'))posTip(e);});++// ── Export menu ──+$('btnExport').addEventListener('click',e=>{+ closeMenus();const rect=e.currentTarget.getBoundingClientRect();+ exportMenu.innerHTML=`+ <div class="dd-label">export as</div>+ <div class="dd-item" data-fmt="json">JSON (native)</div>+ <div class="dd-item" data-fmt="csv">CSV</div>+ <div class="dd-item" data-fmt="svg">SVG</div>+ <div class="dd-item" data-fmt="html">HTML (self-contained)</div>`;+ exportMenu.style.left=Math.min(rect.left,window.innerWidth-200)+'px';exportMenu.style.top=(rect.bottom+4)+'px';exportMenu.classList.add('show');+ exportMenu.querySelectorAll('.dd-item').forEach(item=>{item.addEventListener('click',()=>{doExport(item.dataset.fmt);closeMenus();});});+ e.stopPropagation();+});++function doExport(fmt){+ const url = `export/${fmt}`;+ const a = document.createElement('a');+ a.href = url;+ a.download = `${project.title.replace(/[^a-zA-Z0-9]/g,'-').toLowerCase()}.${fmt==='html'?'html':fmt}`;+ document.body.appendChild(a);a.click();a.remove();+}++// ── Import menu ──+let importFormat = null;+$('btnImport').addEventListener('click',e=>{+ closeMenus();const rect=e.currentTarget.getBoundingClientRect();+ importMenu.innerHTML=`+ <div class="dd-label">import from</div>+ <div class="dd-item" data-fmt="json">JSON (adom-gantt)</div>+ <div class="dd-item" data-fmt="csv">CSV</div>+ <div class="dd-item" data-fmt="msproject-xml">MS Project XML</div>+ <div class="dd-item" data-fmt="jira-csv">Jira CSV</div>`;+ importMenu.style.left=Math.min(rect.left,window.innerWidth-200)+'px';importMenu.style.top=(rect.bottom+4)+'px';importMenu.classList.add('show');+ importMenu.querySelectorAll('.dd-item').forEach(item=>{item.addEventListener('click',()=>{importFormat=item.dataset.fmt;importFileEl.accept=importFormat.includes('xml')?'.xml':importFormat.includes('csv')?'.csv':'.json,.csv,.xml';importFileEl.click();closeMenus();});});+ e.stopPropagation();+});++importFileEl.addEventListener('change',async e=>{+ const file=e.target.files[0];if(!file)return;+ const text=await file.text();+ try{+ const r=await fetch(`import/${importFormat}`,{method:'POST',headers:{'Content-Type':'text/plain'},body:text});+ const res=await r.json();+ if(res.ok){flash(saveInd,`imported ${res.items} items`);setTimeout(()=>location.reload(),500);}+ else{alert('Import failed: '+(res.error||'unknown error'));}+ }catch(err){alert('Import error: '+err.message);}+ importFileEl.value='';+});++// ── Help menu ──+$('btnHelp').addEventListener('click',e=>{+ closeMenus();const rect=e.currentTarget.getBoundingClientRect();+ exportMenu.innerHTML=`+ <div class="dd-label">interactions</div>+ <div class="dd-item" style="cursor:default;opacity:0.8"><span style="color:var(--text3);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click bar</span>Edit dates + deps</div>+ <div class="dd-item" style="cursor:default;opacity:0.8"><span style="color:var(--text3);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Ctrl+click</span>Toggle in selection</div>+ <div class="dd-item" style="cursor:default;opacity:0.8"><span style="color:var(--text3);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Shift+click</span>Range select</div>+ <div class="dd-item" style="cursor:default;opacity:0.8"><span style="color:var(--text3);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click label</span>Select (same as bar)</div>+ <div class="dd-item" style="cursor:default;opacity:0.8"><span style="color:var(--text3);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">drag edge</span>Resize start or end</div>+ <div class="dd-item" style="cursor:default;opacity:0.8"><span style="color:var(--text3);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">drag horiz.</span>Move bar dates</div>+ <div class="dd-item" style="cursor:default;opacity:0.8"><span style="color:var(--text3);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">drag vert.</span>Reorder rows</div>+ <div class="dd-sep"></div>+ <div class="dd-label">keyboard</div>+ <div class="dd-item" style="cursor:default;opacity:0.8"><span style="color:var(--text3);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Ctrl+Z</span>Undo</div>+ <div class="dd-item" style="cursor:default;opacity:0.8"><span style="color:var(--text3);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Esc</span>Clear selection</div>+ <div class="dd-sep"></div>+ <div class="dd-label">legend</div>+ <div class="dd-item" style="cursor:default;opacity:0.8"><span style="color:var(--text3);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click phase</span>Toggle phase visibility</div>+ <div class="dd-item" style="cursor:default;opacity:0.8"><span style="color:var(--text3);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click deps</span>Toggle dependency arrows</div>+ <div class="dd-sep"></div>+ <div class="dd-label">data</div>+ <div class="dd-item" style="cursor:default;opacity:0.8"><span style="color:var(--text3);font-size:10px">Edits auto-save to sidecar JSON. Ctrl+Z undoes up to 100 steps.</span></div>`;+ exportMenu.style.left=Math.min(rect.right-220,window.innerWidth-240)+'px';exportMenu.style.top=(rect.bottom+4)+'px';exportMenu.classList.add('show');+ e.stopPropagation();+});++// Close menus on outside click+document.addEventListener('click',e=>{if(!e.target.closest('.dropdown')&&!e.target.closest('.act-btn'))closeMenus();});++// ── Draggable editor ──+let edDrag=null;+editorEl.addEventListener('mousedown',e=>{+ if(!e.target.closest('.drag-bar'))return;+ e.preventDefault();+ const rect=editorEl.getBoundingClientRect();+ edDrag={sx:e.clientX-rect.left,sy:e.clientY-rect.top};+});+document.addEventListener('mousemove',e=>{+ if(!edDrag)return;+ editorEl.style.left=Math.max(0,e.clientX-edDrag.sx)+'px';+ editorEl.style.top=Math.max(0,e.clientY-edDrag.sy)+'px';+});+document.addEventListener('mouseup',()=>{edDrag=null;});++// ── Init ──+try{const r=await fetch('state');if(r.ok){const st=await r.json();if(!st.empty){applyState(st);Object.keys(barEls).forEach(id=>updateBar(id));}}}catch(e){}+drawDeps();++})();+</script>+</body>+</html>
install.shadded+12@@ -0,0 +1,12 @@+#!/bin/sh+set -e++# Symlink the server back into the module dir so edits propagate and+# reinstalls do not clobber.+mkdir -p "$HOME/.local/bin"+chmod +x "$PWD/server.js"+ln -sfn "$PWD/server.js" "$HOME/.local/bin/adom-gantt"++echo "installed: adom-gantt"+echo "run 'adom-gantt --data <project.json> [--port 8901]', then open /proxy/8901/ in a webview"+echo "example data lives in $PWD/examples/"
package.jsonadded+50@@ -0,0 +1,50 @@+{+ "slug": "adom-gantt",+ "title": "Adom Gantt",+ "type": "app",+ "version": "0.1.0",+ "description": "Interactive Gantt chart viewer for project timelines. Data-driven, dark-themed, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.",+ "main": "server.js",+ "bin": { "adom-gantt": "server.js" },+ "tags": [+ "gantt",+ "timeline",+ "project-management",+ "scheduling",+ "visualization",+ "cli"+ ],+ "dependencies": {},+ "discovery_triggers": [+ "show me a gantt chart",+ "open the project timeline",+ "make a gantt chart for this project",+ "view the project schedule",+ "plot our milestones on a timeline",+ "import this MS Project file",+ "export the schedule as SVG"+ ],+ "discovery_pitch": "Reach for adom-gantt when a project's phases, tasks, milestones and dependencies need to be seen and edited on a timeline rather than read as a list.",+ "sample_prompts": [+ {+ "label": "View a plan",+ "prompt": "open the SMT line plan as a gantt chart"+ },+ {+ "label": "Shift a phase",+ "prompt": "push every validation task out by two weeks on the timeline"+ },+ {+ "label": "Import",+ "prompt": "import this Jira CSV and show it as a gantt chart"+ }+ ],+ "scripts": {+ "install": "./install.sh",+ "uninstall": "./uninstall.sh",+ "start": "node server.js"+ },+ "hero": {+ "path": "docs/hero.png"+ }+}
server.jsadded+793@@ -0,0 +1,793 @@+#!/usr/bin/env node+const http = require('http');+const fs = require('fs');+const path = require('path');+const url_ = require('url');++const args = process.argv.slice(2);+const dataIdx = args.indexOf('--data');+const portIdx = args.indexOf('--port');+const DATA_FILE = dataIdx >= 0 ? path.resolve(args[dataIdx + 1]) : null;+const PORT = portIdx >= 0 ? parseInt(args[portIdx + 1], 10) : 8901;++if (!DATA_FILE) {+ console.error('Usage: node server.js --data <project.json> [--port 8901]');+ process.exit(1);+}+if (!fs.existsSync(DATA_FILE)) {+ console.error(`Data file not found: ${DATA_FILE}`);+ process.exit(1);+}++const STATE_FILE = DATA_FILE.replace(/\.json$/, '-state.json');+const APP_DIR = __dirname;+const DATA_DIR = path.dirname(DATA_FILE);++const MIME = {+ '.html': 'text/html', '.js': 'application/javascript',+ '.css': 'text/css', '.json': 'application/json',+ '.svg': 'image/svg+xml', '.png': 'image/png',+};++// ── Helpers ──++function readBody(req) {+ return new Promise((resolve, reject) => {+ let body = '';+ req.on('data', c => body += c);+ req.on('end', () => resolve(body));+ req.on('error', reject);+ });+}++function jsonResp(res, code, obj) {+ res.writeHead(code, { 'Content-Type': 'application/json' });+ res.end(JSON.stringify(obj));+}++function loadData() {+ return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));+}++function loadState() {+ if (fs.existsSync(STATE_FILE)) {+ return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));+ }+ return null;+}++function mergedData() {+ const data = loadData();+ const state = loadState();+ if (state && Array.isArray(state)) {+ state.forEach(sv => {+ const r = data.items.find(d => d.id === sv.id);+ if (!r) return;+ if (sv.s) r.start = sv.s;+ if (sv.e) r.end = sv.e;+ if (sv.deps) r.deps = sv.deps;+ });+ }+ return data;+}++// ── CSV Parsing (RFC 4180 compliant) ──++function parseCSVRow(line) {+ const fields = [];+ let i = 0, field = '', inQuotes = false;+ while (i < line.length) {+ const ch = line[i];+ if (inQuotes) {+ if (ch === '"') {+ if (i + 1 < line.length && line[i + 1] === '"') {+ field += '"';+ i += 2;+ } else {+ inQuotes = false;+ i++;+ }+ } else {+ field += ch;+ i++;+ }+ } else {+ if (ch === '"') {+ inQuotes = true;+ i++;+ } else if (ch === ',') {+ fields.push(field.trim());+ field = '';+ i++;+ } else {+ field += ch;+ i++;+ }+ }+ }+ fields.push(field.trim());+ return fields;+}++function parseCSV(text) {+ const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n').filter(l => l.trim());+ if (lines.length < 2) return [];+ const headers = parseCSVRow(lines[0]).map(h => h.toLowerCase().trim());+ const rows = [];+ for (let i = 1; i < lines.length; i++) {+ const vals = parseCSVRow(lines[i]);+ const obj = {};+ headers.forEach((h, j) => { obj[h] = vals[j] || ''; });+ rows.push(obj);+ }+ return rows;+}++function itemsToCSV(items) {+ const headers = ['id', 'name', 'start', 'end', 'phase', 'status', 'description', 'deps', 'type', 'label', 'date', 'striped'];+ const escape = v => {+ if (v == null) return '';+ const s = String(v);+ if (s.includes(',') || s.includes('"') || s.includes('\n')) {+ return '"' + s.replace(/"/g, '""') + '"';+ }+ return s;+ };+ const lines = [headers.join(',')];+ items.forEach(item => {+ const row = headers.map(h => {+ if (h === 'deps') return escape((item.deps || []).join(';'));+ return escape(item[h]);+ });+ lines.push(row.join(','));+ });+ return lines.join('\n');+}++// ── Import: JSON ──++function importJSON(body) {+ const data = JSON.parse(body);+ if (!data.project || !data.phases || !data.items) {+ throw new Error('JSON must contain project, phases, and items fields');+ }+ if (!Array.isArray(data.phases) || !Array.isArray(data.items)) {+ throw new Error('phases and items must be arrays');+ }+ fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8');+ // Clear state file since data structure changed+ if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);+ return { ok: true, items: data.items.length, phases: data.phases.length };+}++// ── Import: CSV ──++function importCSV(body, queryString) {+ const parsed = url_.parse('?' + (queryString || ''), true);+ const phasesParam = parsed.query.phases;++ const rows = parseCSV(body);+ if (rows.length === 0) throw new Error('CSV has no data rows');++ // Build items from CSV rows+ const items = [];+ const phaseNames = new Set();++ rows.forEach(row => {+ const phase = row.phase || 'Default';+ phaseNames.add(phase);+ });++ // Determine phases: from query param or auto-detect+ let phaseList;+ if (phasesParam) {+ phaseList = phasesParam.split(',').map(p => p.trim());+ } else {+ phaseList = Array.from(phaseNames);+ }++ const phaseColors = ['#64ABFF', '#3fb950', '#8C6BF7', '#f0883e', '#f85149', '#00b8b0', '#e6edf3', '#da3633'];+ const phases = phaseList.map((name, i) => ({+ name,+ color: phaseColors[i % phaseColors.length],+ start: '', end: ''+ }));++ // Build phase index+ const phaseIndex = {};+ phaseList.forEach((name, i) => { phaseIndex[name] = i; });++ // Track phase date ranges+ const phaseRanges = phaseList.map(() => ({ min: '9999-12-31', max: '0000-01-01' }));++ rows.forEach(row => {+ const phaseName = row.phase || 'Default';+ const pi = phaseIndex[phaseName] != null ? phaseIndex[phaseName] : 0;+ const deps = row.deps ? row.deps.split(';').map(d => d.trim()).filter(Boolean) : [];++ const item = {+ type: row.type || 'task',+ id: row.id || '',+ name: row.name || row.label || '',+ phase: pi,+ };++ if (row.start) { item.start = row.start; }+ if (row.end) { item.end = row.end; }+ if (row.date) { item.date = row.date; }+ if (row.status) { item.status = row.status; }+ if (row.description) { item.description = row.description; }+ if (deps.length) { item.deps = deps; }+ if (row.label) { item.label = row.label; }+ if (row.striped === 'true') { item.striped = true; }++ // Update phase date ranges+ const s = item.start || item.date;+ const e = item.end || item.date;+ if (s && s < phaseRanges[pi].min) phaseRanges[pi].min = s;+ if (e && e > phaseRanges[pi].max) phaseRanges[pi].max = e;++ items.push(item);+ });++ // Set phase start/end from discovered ranges+ phases.forEach((p, i) => {+ const r = phaseRanges[i];+ if (r.min !== '9999-12-31') {+ const [y, m] = r.min.split('-');+ p.start = `${y}-${m}`;+ }+ if (r.max !== '0000-01-01') {+ const [y, m] = r.max.split('-');+ p.end = `${y}-${m}`;+ }+ });++ const data = {+ project: { title: 'Imported Project', subtitle: 'CSV import', appName: 'adom-gantt' },+ phases,+ items+ };++ fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8');+ if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);+ return { ok: true, items: items.length, phases: phases.length };+}++// ── Import: MS Project XML ──++function importMSProjectXML(body) {+ // Parse <Task> elements with regex+ const taskRegex = /<Task>([\s\S]*?)<\/Task>/g;+ const tasks = [];+ let match;++ while ((match = taskRegex.exec(body)) !== null) {+ const block = match[1];+ const get = tag => {+ const m = block.match(new RegExp(`<${tag}>(.*?)<\/${tag}>`));+ return m ? m[1].trim() : '';+ };++ const uid = get('UID');+ const name = get('Name');+ const start = get('Start');+ const finish = get('Finish');+ const pctComplete = parseInt(get('PercentComplete') || '0', 10);+ const summary = get('Summary');++ // Skip summary tasks (they are groups) unless they have no sub-tasks+ if (!name || summary === '1') continue;++ // Parse predecessors+ const predRegex = /<PredecessorLink>([\s\S]*?)<\/PredecessorLink>/g;+ const deps = [];+ let predMatch;+ while ((predMatch = predRegex.exec(block)) !== null) {+ const predUID = predMatch[1].match(/<PredecessorUID>(.*?)<\/PredecessorUID>/);+ if (predUID) deps.push('task-' + predUID[1]);+ }++ // Parse dates (MS Project uses ISO datetime, extract date portion)+ const parseDate = ds => {+ if (!ds) return '';+ const m = ds.match(/(\d{4}-\d{2}-\d{2})/);+ return m ? m[1] : '';+ };++ let status = 'not-started';+ if (pctComplete >= 100) status = 'live';+ else if (pctComplete > 0) status = 'planned';++ tasks.push({+ type: 'task',+ id: 'task-' + uid,+ name,+ start: parseDate(start),+ end: parseDate(finish),+ phase: 0,+ status,+ description: pctComplete > 0 ? `${pctComplete}% complete` : '',+ deps: deps.length ? deps : undefined+ });+ }++ if (tasks.length === 0) throw new Error('No tasks found in MS Project XML');++ // Compute date range for single phase+ let minDate = '9999-12-31', maxDate = '0000-01-01';+ tasks.forEach(t => {+ if (t.start && t.start < minDate) minDate = t.start;+ if (t.end && t.end > maxDate) maxDate = t.end;+ });++ const [minY, minM] = minDate.split('-');+ const [maxY, maxM] = maxDate.split('-');++ const data = {+ project: { title: 'MS Project Import', subtitle: 'Imported from MS Project XML', appName: 'adom-gantt' },+ phases: [{+ name: 'Imported',+ color: '#64ABFF',+ start: `${minY}-${minM}`,+ end: `${maxY}-${maxM}`+ }],+ items: tasks+ };++ fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8');+ if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);+ return { ok: true, items: tasks.length };+}++// ── Import: JIRA CSV ──++function importJiraCSV(body) {+ const rows = parseCSV(body);+ if (rows.length === 0) throw new Error('JIRA CSV has no data rows');++ const statusMap = {+ 'done': 'live',+ 'closed': 'live',+ 'resolved': 'live',+ 'in progress': 'planned',+ 'in review': 'planned',+ 'to do': 'not-started',+ 'open': 'not-started',+ 'backlog': 'not-started',+ };++ const items = [];+ let minDate = '9999-12-31', maxDate = '0000-01-01';++ rows.forEach((row, i) => {+ // JIRA CSV columns: Summary, Issue key, Issue id, Issue Type, Status, Created, Updated, Due Date, etc.+ const name = row.summary || row.title || row.name || '';+ const key = row['issue key'] || row.key || row.id || `jira-${i}`;+ const status = (row.status || '').toLowerCase();+ const created = row.created || row['created date'] || '';+ const dueDate = row['due date'] || row.due || row.duedate || '';++ // Parse dates — JIRA uses various formats+ const parseJiraDate = ds => {+ if (!ds) return '';+ // Try ISO format first+ const isoMatch = ds.match(/(\d{4}-\d{2}-\d{2})/);+ if (isoMatch) return isoMatch[1];+ // Try dd/MMM/yy or dd/MMM/yyyy+ const slashMatch = ds.match(/(\d{1,2})\/([A-Za-z]{3})\/(\d{2,4})/);+ if (slashMatch) {+ const months = { jan: '01', feb: '02', mar: '03', apr: '04', may: '05', jun: '06', jul: '07', aug: '08', sep: '09', oct: '10', nov: '11', dec: '12' };+ const d = slashMatch[1].padStart(2, '0');+ const m = months[slashMatch[2].toLowerCase()] || '01';+ let y = slashMatch[3];+ if (y.length === 2) y = (parseInt(y) > 50 ? '19' : '20') + y;+ return `${y}-${m}-${d}`;+ }+ return '';+ };++ const startDate = parseJiraDate(created);+ const endDate = parseJiraDate(dueDate) || startDate;++ const mappedStatus = statusMap[status] || 'not-started';+ const description = row.description || '';++ if (startDate && startDate < minDate) minDate = startDate;+ if (endDate && endDate > maxDate) maxDate = endDate;+ if (startDate && startDate > maxDate) maxDate = startDate;++ items.push({+ type: 'task',+ id: key.replace(/\s+/g, '-').toLowerCase(),+ name,+ start: startDate || endDate,+ end: endDate || startDate,+ phase: 0,+ status: mappedStatus,+ description: description.length > 200 ? description.substring(0, 200) + '...' : description+ });+ });++ // Filter out items without any dates+ const validItems = items.filter(it => it.start && it.end);+ if (validItems.length === 0) throw new Error('No items with valid dates found in JIRA CSV');++ if (minDate === '9999-12-31') minDate = new Date().toISOString().slice(0, 10);+ if (maxDate === '0000-01-01') maxDate = new Date().toISOString().slice(0, 10);++ const [minY, minM] = minDate.split('-');+ const [maxY, maxM] = maxDate.split('-');++ const data = {+ project: { title: 'JIRA Import', subtitle: 'Imported from JIRA CSV', appName: 'adom-gantt' },+ phases: [{+ name: 'JIRA Issues',+ color: '#64ABFF',+ start: `${minY}-${minM}`,+ end: `${maxY}-${maxM}`+ }],+ items: validItems+ };++ fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8');+ if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);+ return { ok: true, items: validItems.length };+}++// ── Export: SVG ──++function generateSVG() {+ const data = mergedData();+ const { project, phases, items } = data;++ const COL = 66;+ const ROW_H = 34;+ const GROUP_H = 28;+ const HEADER_H = 60;+ const LABEL_W = 260;+ const PAD = 16;++ // Compute date range+ let minDate = '9999-12-31', maxDate = '0000-01-01';+ items.forEach(r => {+ if (r.start && r.start < minDate) minDate = r.start;+ if (r.end && r.end > maxDate) maxDate = r.end;+ if (r.date && r.date < minDate) minDate = r.date;+ if (r.date && r.date > maxDate) maxDate = r.date;+ });+ phases.forEach(p => {+ const ps = p.start + '-01';+ const [ey, em] = p.end.split('-').map(Number);+ const pe = `${ey}-${String(em).padStart(2, '0')}-28`;+ if (ps < minDate) minDate = ps;+ if (pe > maxDate) maxDate = pe;+ });++ const [minY, minM] = minDate.split('-').map(Number);+ const [maxY, maxM] = maxDate.split('-').map(Number);+ const START = new Date(minY, minM - 1, 1);+ const MONTHS = (maxY - minY) * 12 + (maxM - minM) + 2;+ const timelineW = MONTHS * COL;+ const totalW = LABEL_W + timelineW + PAD * 2;++ function mDiff(a, b) { return (b.getFullYear() - a.getFullYear()) * 12 + b.getMonth() - a.getMonth(); }+ function toX(ds) {+ const [y, mo, d] = ds.split('-').map(Number);+ return LABEL_W + PAD + (mDiff(START, new Date(y, mo - 1, d || 1)) + ((d ? (d - 1) / 30 : 0))) * COL;+ }++ // Calculate total height+ let totalH = HEADER_H;+ items.forEach(r => { totalH += r.type === 'group' ? GROUP_H : ROW_H; });+ totalH += PAD;++ const esc = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');++ let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${totalW}" height="${totalH}" viewBox="0 0 ${totalW} ${totalH}">+<defs>+ <style>+ .bg { fill: #0d1117; }+ .surface { fill: #161b22; }+ .bar-label { fill: #e6edf3; font-family: sans-serif; font-size: 12px; }+ .group-label { fill: #8b949e; font-family: sans-serif; font-size: 12px; font-weight: 600; }+ .header-text { fill: #e6edf3; font-family: sans-serif; font-size: 16px; font-weight: 600; }+ .month-text { fill: #484f58; font-family: monospace; font-size: 10px; text-anchor: middle; }+ .status-text { font-family: monospace; font-size: 9px; }+ .ms-label { fill: #8b949e; font-family: sans-serif; font-size: 11px; font-style: italic; }+ .gridline { stroke: rgba(48,54,61,0.18); stroke-width: 1; }+ .border { stroke: #30363d; stroke-width: 1; fill: none; }+ </style>+</defs>+<rect class="bg" width="${totalW}" height="${totalH}"/>`;++ // Title+ svg += `\n<text class="header-text" x="${PAD}" y="28">${esc(project.title)}</text>`;+ svg += `\n<text class="month-text" x="${PAD}" y="42" style="text-anchor:start;fill:#8b949e;">${esc(project.subtitle || '')}</text>`;++ // Month headers+ let cm = new Date(START);+ const mNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];+ for (let i = 0; i < MONTHS; i++) {+ const x = LABEL_W + PAD + i * COL + COL / 2;+ svg += `\n<text class="month-text" x="${x}" y="${HEADER_H - 8}">${mNames[cm.getMonth()]} ${cm.getFullYear()}</text>`;+ cm = new Date(cm.getFullYear(), cm.getMonth() + 1, 1);+ }++ // Grid lines+ for (let i = 0; i <= MONTHS; i++) {+ const x = LABEL_W + PAD + i * COL;+ svg += `\n<line class="gridline" x1="${x}" y1="${HEADER_H}" x2="${x}" y2="${totalH}"/>`;+ }++ // Phase backgrounds+ phases.forEach(p => {+ const [sy, sm] = p.start.split('-').map(Number);+ const [ey, em] = p.end.split('-').map(Number);+ const x1 = LABEL_W + PAD + mDiff(START, new Date(sy, sm - 1, 1)) * COL;+ const x2 = LABEL_W + PAD + (mDiff(START, new Date(ey, em - 1, 1)) + 1) * COL;+ svg += `\n<rect x="${x1}" y="${HEADER_H}" width="${x2 - x1}" height="${totalH - HEADER_H}" fill="${p.color}" opacity="0.04"/>`;+ });++ // Items+ let y = HEADER_H;+ items.forEach(r => {+ const h = r.type === 'group' ? GROUP_H : ROW_H;++ if (r.type === 'group') {+ svg += `\n<rect class="surface" x="0" y="${y}" width="${totalW}" height="${h}"/>`;+ svg += `\n<text class="group-label" x="${PAD}" y="${y + h / 2 + 4}">${esc(r.label)}</text>`;+ } else if (r.type === 'milestone') {+ const mx = toX(r.date);+ svg += `\n<text class="ms-label" x="${PAD}" y="${y + h / 2 + 4}">◆ ${esc(r.name)}</text>`;+ svg += `\n<rect x="${mx - 5}" y="${y + 12}" width="10" height="10" transform="rotate(45 ${mx} ${y + 17})" fill="#e6edf3" stroke="#0d1117" stroke-width="2"/>`;+ } else {+ const x1 = toX(r.start);+ const x2 = toX(r.end);+ const w = Math.max(x2 - x1, 6);+ const color = phases[r.phase] ? phases[r.phase].color : '#64ABFF';++ // Status tag+ const statusColors = { live: '#3fb950', planned: '#64ABFF', 'not-started': '#484f58' };+ const statusLabels = { live: 'live', planned: 'planned', 'not-started': 'not started' };+ const stag = r.status ? ` [${statusLabels[r.status] || r.status}]` : '';++ svg += `\n<text class="bar-label" x="${PAD}" y="${y + h / 2 + 4}">${esc(r.name)}</text>`;+ if (r.status) {+ const tagColor = statusColors[r.status] || '#484f58';+ const textLen = r.name.length * 7 + PAD;+ svg += `\n<text class="status-text" x="${textLen + 8}" y="${y + h / 2 + 4}" fill="${tagColor}">${esc(statusLabels[r.status] || r.status)}</text>`;+ }++ svg += `\n<rect x="${x1}" y="${y + 7}" width="${w}" height="20" rx="6" fill="${color}" opacity="0.82"/>`;++ if (r.striped) {+ svg += `\n<rect x="${x1}" y="${y + 7}" width="${w}" height="20" rx="6" fill="url(#stripes)" opacity="0.12"/>`;+ }+ }++ // Row separator+ svg += `\n<line x1="0" y1="${y + h}" x2="${totalW}" y2="${y + h}" stroke="rgba(48,54,61,0.15)" stroke-width="1"/>`;++ y += h;+ });++ // Today line+ const today = new Date();+ const tx = LABEL_W + PAD + mDiff(START, today) * COL + (today.getDate() / 30) * COL;+ if (tx > LABEL_W + PAD && tx < LABEL_W + PAD + timelineW) {+ svg += `\n<line x1="${tx}" y1="${HEADER_H}" x2="${tx}" y2="${totalH}" stroke="#00b8b0" stroke-width="2" opacity="0.8"/>`;+ svg += `\n<text x="${tx}" y="${HEADER_H + 14}" fill="#00b8b0" font-family="monospace" font-size="9" text-anchor="middle">today</text>`;+ }++ svg += '\n</svg>';+ return svg;+}++// ── Export: HTML snapshot ──++function generateHTMLSnapshot() {+ const ganttHtml = fs.readFileSync(path.join(APP_DIR, 'gantt.html'), 'utf8');+ const data = mergedData();++ // Replace the fetch('data') call with inline data+ // The original code: const projData = await fetch('data').then(r => r.json());+ const inlineDataScript = `const projData = ${JSON.stringify(data)};`;++ // Also replace the state fetch with a no-op+ // The original: const r=await fetch('state');if(r.ok){const st=await r.json();...+ // We set state to already-applied (merged data already has state applied)+ const inlineStateScript = `const r={ok:true,json:async()=>({empty:true})};`;++ // Replace the save function to be a no-op in snapshot mode+ const noopSave = `function saveToFile() { saveInd.textContent='snapshot mode';saveInd.classList.add('show');setTimeout(()=>saveInd.classList.remove('show'),1500); }`;++ let html = ganttHtml;++ // Replace the data fetch line+ html = html.replace(+ /const projData = await fetch\('data'\)\.then\(r => r\.json\(\)\);/,+ inlineDataScript+ );++ // Replace the state fetch block+ html = html.replace(+ /try\{const r=await fetch\('state'\);if\(r\.ok\)\{const st=await r\.json\(\);if\(!st\.empty\)\{applyState\(st\);Object\.keys\(barEls\)\.forEach\(id=>updateBar\(id\)\);\}\}\}catch\(e\)\{\}/,+ '/* state already merged into inline data */'+ );++ // Replace saveToFile function+ html = html.replace(+ /function saveToFile\(\) \{[^}]+\}/,+ noopSave+ );++ // Read and inline favicon+ const faviconPath = path.join(APP_DIR, 'favicon.svg');+ if (fs.existsSync(faviconPath)) {+ const faviconData = fs.readFileSync(faviconPath, 'utf8');+ const b64 = Buffer.from(faviconData).toString('base64');+ html = html.replace(+ '<link rel="icon" href="favicon.svg">',+ `<link rel="icon" href="data:image/svg+xml;base64,${b64}">`+ );+ }++ // Add a comment at the top indicating this is a snapshot+ html = html.replace('<!DOCTYPE html>', `<!DOCTYPE html>\n<!-- adom-gantt snapshot — generated ${new Date().toISOString()} -->`);++ return html;+}++// ── Server ──++const server = http.createServer(async (req, res) => {+ res.setHeader('Access-Control-Allow-Origin', '*');+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');+ if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }++ const parsed = url_.parse(req.url, true);+ const urlPath = parsed.pathname;+ const query = parsed.search ? parsed.search.slice(1) : '';++ try {++ // ── Existing endpoints ──++ if (urlPath === '/data') {+ res.writeHead(200, { 'Content-Type': 'application/json' });+ res.end(fs.readFileSync(DATA_FILE, 'utf8'));+ return;+ }++ if (req.method === 'POST' && urlPath === '/save') {+ const body = await readBody(req);+ try {+ JSON.parse(body);+ fs.writeFileSync(STATE_FILE, body, 'utf8');+ jsonResp(res, 200, { ok: true, file: path.basename(STATE_FILE), bytes: body.length });+ } catch (e) {+ jsonResp(res, 400, { ok: false, error: e.message });+ }+ return;+ }++ if (urlPath === '/state') {+ if (fs.existsSync(STATE_FILE)) {+ res.writeHead(200, { 'Content-Type': 'application/json' });+ res.end(fs.readFileSync(STATE_FILE, 'utf8'));+ } else {+ jsonResp(res, 200, { empty: true });+ }+ return;+ }++ // ── Import endpoints ──++ if (req.method === 'POST' && urlPath === '/import/json') {+ const body = await readBody(req);+ try {+ const result = importJSON(body);+ jsonResp(res, 200, result);+ } catch (e) {+ jsonResp(res, 400, { ok: false, error: e.message });+ }+ return;+ }++ if (req.method === 'POST' && urlPath === '/import/csv') {+ const body = await readBody(req);+ try {+ const result = importCSV(body, query);+ jsonResp(res, 200, result);+ } catch (e) {+ jsonResp(res, 400, { ok: false, error: e.message });+ }+ return;+ }++ if (req.method === 'POST' && urlPath === '/import/msproject-xml') {+ const body = await readBody(req);+ try {+ const result = importMSProjectXML(body);+ jsonResp(res, 200, result);+ } catch (e) {+ jsonResp(res, 400, { ok: false, error: e.message });+ }+ return;+ }++ if (req.method === 'POST' && urlPath === '/import/jira-csv') {+ const body = await readBody(req);+ try {+ const result = importJiraCSV(body);+ jsonResp(res, 200, result);+ } catch (e) {+ jsonResp(res, 400, { ok: false, error: e.message });+ }+ return;+ }++ // ── Export endpoints ──++ if (urlPath === '/export/json') {+ const data = mergedData();+ res.writeHead(200, {+ 'Content-Type': 'application/json',+ 'Content-Disposition': 'attachment; filename="adom-gantt-export.json"'+ });+ res.end(JSON.stringify(data, null, 2));+ return;+ }++ if (urlPath === '/export/csv') {+ const data = mergedData();+ const csv = itemsToCSV(data.items);+ res.writeHead(200, {+ 'Content-Type': 'text/csv',+ 'Content-Disposition': 'attachment; filename="adom-gantt-export.csv"'+ });+ res.end(csv);+ return;+ }++ if (urlPath === '/export/svg') {+ const svg = generateSVG();+ res.writeHead(200, {+ 'Content-Type': 'image/svg+xml',+ 'Content-Disposition': 'attachment; filename="adom-gantt-timeline.svg"'+ });+ res.end(svg);+ return;+ }++ if (urlPath === '/export/html') {+ const html = generateHTMLSnapshot();+ res.writeHead(200, {+ 'Content-Type': 'text/html',+ 'Content-Disposition': 'attachment; filename="adom-gantt-snapshot.html"'+ });+ res.end(html);+ return;+ }++ // ── Static file serving ──++ let filePath = path.join(APP_DIR, urlPath === '/' ? 'gantt.html' : urlPath);+ const ext = path.extname(filePath);+ if (!fs.existsSync(filePath)) { res.writeHead(404); res.end('Not found'); return; }+ res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });+ fs.createReadStream(filePath).pipe(res);++ } catch (e) {+ jsonResp(res, 500, { ok: false, error: e.message });+ }+});++server.listen(PORT, '127.0.0.1', () => {+ console.log(`adom-gantt serving ${path.basename(DATA_FILE)} on http://127.0.0.1:${PORT}`);+ console.log(`State file: ${STATE_FILE}`);+ console.log(`Data dir: ${DATA_DIR}`);+ console.log(`Endpoints: /data /state /save /import/{json,csv,msproject-xml,jira-csv} /export/{json,csv,svg,html}`);+});
uninstall.shadded+6@@ -0,0 +1,6 @@+#!/bin/sh+set -e++rm -f "$HOME/.local/bin/adom-gantt"++echo "uninstalled: adom-gantt"