← Commit history
.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 &middot; <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">&middot;</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">&middot;</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');++  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}">&#9670; ${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"