Project tracker seeded from the Mega Infrastructure Candy barchart

App for the Mega Infrastructure AI Integration programme (A000) and Mega
Plant (B110). 100 activities across 2 programmes, 06 Oct 2026 - 31 Aug 2027,
parsed from the client PDF and frozen as an immutable baseline.

Views: Dashboard, Timeline (month grid + week grid matching the PDF's
Friday-anchored week numbering), Todo list, WBS, Data.

Notes on the source data:
- Hierarchy is derived from the PDF's indentation, not the activity code.
  Codes do not sort structurally (A490 sits under Workforce, A510 under
  Financials), so a code-ordered import would produce a wrong tree.
- The schedule is built from the dates, not the PDF's "Durn" column, which
  disagrees with its own dates for 13 of the 100 activities.
- Spec issues found in the PDF are surfaced in-app under Data: 2 unresolved
  "?" placeholders, 6 activities sharing one stretched 6-week window while
  declared 15 days, 13 duration mismatches, 17 spelling corrections
  (original wording preserved and viewable).

Progress (status, % complete, note) is stored behind a pluggable adapter so
the site can be static today and point at a shared JSON store later without
touching the UI.
This commit is contained in:
Dave 2026-10-01 08:48:51 +02:00
commit 471cf6fb3c
30 changed files with 5878 additions and 0 deletions

11
.dockerignore Normal file
View file

@ -0,0 +1,11 @@
node_modules
dist
tsconfig.tsbuildinfo
.git
.gitignore
.vscode
.idea
*.md
.DS_Store
Dockerfile
.dockerignore

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules
dist
tsconfig.tsbuildinfo
.DS_Store
*.local

28
Dockerfile Normal file
View file

@ -0,0 +1,28 @@
# syntax=docker/dockerfile:1
# ---------- build ----------
FROM node:22-alpine AS build
WORKDIR /app
# Deps first, so this layer is cached and only re-runs when the lockfile changes.
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---------- serve ----------
FROM nginx:alpine AS runtime
# Replace the packaged default site with ours.
RUN rm -f /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
# Coolify/Traefik route to this port.
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://127.0.0.1/ || exit 1
CMD ["nginx", "-g", "daemon off;"]

136
README.md Normal file
View file

@ -0,0 +1,136 @@
# Mega Infrastructure — Project Tracker
A project tracker seeded from `Mega Infrastructure AI intergration system.pdf` (a Candy 2.05p / P6
barchart printed 30 Sep 2026). **100 activities, 2 programmes, 06 Oct 2026 → 31 Aug 2027.**
```bash
npm install
npm run dev # http://localhost:4004
npm run build # -> dist/ (static, drop on any host)
```
Port **4004** on purpose — 3000 is taken by another local app.
## What's in it
| View | What it does |
|---|---|
| **Dashboard** | Weighted % complete, counts by status, overdue, starting-this-week, workstream health, build-load-by-month chart |
| **Timeline** | Collapsible WBS Gantt. **Month** grid for reading, **Week** grid matches the PDF (Friday-anchored, the SA convention Candy uses). Today-line, status colours, progress fill. |
| **Todo list** | The 78 deliverables as a checklist. Filter by window (open / next 7d / next 30d / in-flight / overdue / unconfirmed), status, workstream, free text. Group by workstream, status or start month. Inline status + % slider. |
| **WBS** | Full 100-row hierarchy matching the PDF, with rollup % and status. Toggle corrected names ↔ original PDF wording. |
| **Data** | Source provenance, sharing config, export/import, and the spec issues found in the PDF. |
Click any activity (bar, row or todo) to open the edit drawer: status, % complete, note, plus the
plan facts and any flags attached to that activity.
## Appearance
Dark/light mode plus four accent colours, set from the topbar.
- **Mode** — sun/moon toggle, **dark by default**. An explicit choice sticks from then on.
- **Accent** — Blue, Violet, Emerald, Amber. Each has a lighter variant tuned for dark mode
(`#2563eb` light / `#60a5fa` dark, and so on) so contrast holds in both.
Both persist to `localStorage`. An inline script in `index.html` resolves the theme *before* first
paint, so there's no flash of the wrong colours on reload — it mirrors `src/lib/appearance.ts`
(`DEFAULT_MODE` / `DEFAULT_ACCENT`), so keep the two in sync if you change the defaults or the key names.
All colour is token-driven from the top of `src/styles.css`. To add a fifth accent, add one entry
to `ACCENTS` in `src/lib/appearance.ts`, a `[data-accent="..."]` rule and its
`[data-theme="dark"][data-accent="..."]` counterpart in `styles.css`, and the value to the inline
script's whitelist.
## Deploying to Coolify
```bash
docker build -t mega-tracker .
```
Multi-stage: `node:22-alpine` builds, `nginx:alpine` serves the static output. Final image is nginx
plus ~215 KB of files — no Node runtime, no `node_modules`.
In Coolify: **New Resource → Dockerfile**, point it at this repo/directory. It finds the Dockerfile
automatically.
- **Port**: `80` (the Dockerfile `EXPOSE`s it; Coolify/Traefik routes to it)
- **Build args**: none needed
- **Domain**: set it on the resource; Coolify generates the Traefik route and TLS for you
- **Health check**: baked into the image (`wget --spider` every 30s)
**Use a subdomain, not a path.** The build emits absolute asset URLs (`/assets/…`). That is correct
for a dedicated hostname like `tracker.mega.co.za`, and wrong for a path mount like
`example.com/mega/`, which would 404 on every asset. If you ever need a subpath, set
`base: '/mega/'` in `vite.config.ts` and rebuild.
**Caching is deliberate.** Hashed assets under `/assets/` get `max-age=31536000, immutable`; the
app shell gets `no-store`. A new deploy emits new asset filenames, so this is always correct, and it
prevents the classic "the update didn't work" browser cache bug.
### If you ever need build-time config
Vite inlines `VITE_*` variables into the bundle at **build** time, so they must be set as build
arguments in Coolify, not runtime environment variables. There is currently nothing to configure —
no API base URL, no feature flags. If you add the shared progress store (below) and need a default
endpoint, add a `--build-arg VITE_PROGRESS_URL=…` and read it via `import.meta.env`.
## Data model
The plan (codes, names, dates, hierarchy) is baked into `src/data/baseline.ts` and is **never
edited by the app**. Only progress is recorded: status, % complete, and a note, keyed by activity
code.
Parent rollups are duration-weighted from the children, using `spanDays` — the working days the
activity's own dates actually span — rather than the PDF's `Durn` column, which disagrees with the
dates for 13 of the 100 activities.
## Storage, and the honest limitation
Progress defaults to `localStorage`. **That means a static deploy shows the project owner the plan
but not your progress** — the plan is in the bundle, the progress is in your browser. The owner
would see every activity at 0%.
To share, open **Data → Share progress with the project owner** and point it at any endpoint that
accepts `GET` and `PUT` of a JSON body. A single-row PocketBase record works and needs no schema
knowledge in the app:
```
GET https://your-host/api/collections/tracker_progress/records/<RECORD_ID>
PATCH https://your-host/api/collections/tracker_progress/records/<RECORD_ID>
```
The adapter is endpoint-agnostic (`src/lib/storage.ts` → `remoteBlobAdapter`), so it also works
against a tiny hand-rolled endpoint or a KV store. The auth token is kept in the browser only and
is never bundled into the deployed files.
Export/import JSON works regardless of adapter, so you always have a portable backup.
## Spec issues found in the PDF
Flagged in-app under **Data → Spec issues**, and derived at runtime from the baseline rather than
hardcoded:
- **2 activities still contain a `?`** — `B220 Payments ?`, `B340 Per Bank?`. Unfinished spec text.
- **6 activities share one identical 6-week window** (`A490, A140, A180, A220, A260, A320`, all
08 Dec 26 → 19 Jan 27) while each is declared 15 working days but occupies 31. Reads as a bulk
edit or a scheduling-constraint artefact in Candy, not a plan.
- **13 activities** have a duration that disagrees with their own dates.
- **17 spelling corrections** applied for display; the original wording is preserved and viewable
in the WBS tab.
- `A000` and `A010` cover the identical window — worth confirming whether "Site Dashboard" is a
module or the whole programme.
- The plan is heavily front-loaded: 33 of 78 deliverables start in the first 8 weeks, peaking at
10 concurrent in the week of 13 Oct 2026.
## Regenerating from a new PDF
```bash
pdftotext -layout "new-plan.pdf" /tmp/mega.txt
python3 scripts/parse_pdf.py # -> /tmp/mega_acts.json
python3 scripts/gen_baseline.py # -> src/data/baseline.ts
```
**Hierarchy is taken from the PDF's indentation, not the activity code.** Codes do not sort
structurally — `A490` sits under Workforce, `A510` under Financials, `A580`/`A590` under Health &
Safety. Re-importing from a spreadsheet that sorts by code will produce a wrong tree.

30
index.html Normal file
View file

@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mega Infrastructure — Project Tracker</title>
<meta name="description" content="Project tracker for the Mega Infrastructure AI Integration programme, seeded from the Candy barchart." />
<script>
// Resolve the theme before first paint so there is no flash of the wrong
// colours. Must mirror src/lib/appearance.ts (DEFAULT_MODE / DEFAULT_ACCENT).
;(function () {
try {
var t = localStorage.getItem('mega-tracker/theme/v1')
if (t !== 'light' && t !== 'dark') t = 'dark'
var a = localStorage.getItem('mega-tracker/accent/v1')
if (['blue', 'violet', 'emerald', 'amber'].indexOf(a) < 0) a = 'blue'
var el = document.documentElement
el.dataset.theme = t
el.dataset.accent = a
el.style.colorScheme = t
} catch (e) {}
})()
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

59
nginx.conf Normal file
View file

@ -0,0 +1,59 @@
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;
server_tokens off;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml
font/woff
font/woff2;
# NOTE: add_header does not inherit into a location that declares its own,
# so the security headers are repeated in each block rather than set once
# at server level (where they would be silently dropped).
# Content-hashed build output. Safe to cache forever: a new deploy emits new
# filenames, so a stale cache can never serve the wrong bundle.
# `expires` is deliberately not used here -- it emits its own Cache-Control
# and the two would conflict.
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header X-Content-Type-Options "nosniff" always;
access_log off;
try_files $uri =404;
}
# The app shell. Must never be cached, or browsers keep booting the old
# bundle after a deploy and users report "the update didn't work".
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
# SPA fallback. index.html carries the theme bootstrap script, so an
# unknown route must still resolve to a normal 200 document.
location / {
add_header Cache-Control "no-cache" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
try_files $uri $uri/ /index.html;
}
}

1750
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

23
package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "mega-project-tracker",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^5.4.11"
}
}

6
public/favicon.svg Normal file
View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Mega Infrastructure project tracker">
<rect width="64" height="64" rx="14" fill="#14171c"/>
<rect x="12" y="17" width="40" height="7" rx="3.5" fill="#60a5fa"/>
<rect x="12" y="28.5" width="27" height="7" rx="3.5" fill="#34d399"/>
<rect x="12" y="40" width="18" height="7" rx="3.5" fill="#fbbf24"/>
</svg>

After

Width:  |  Height:  |  Size: 396 B

147
scripts/gen_baseline.py Normal file
View file

@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Generate src/data/baseline.ts from the parsed Candy barchart dump."""
import json, datetime, re, textwrap
from collections import OrderedDict
acts = json.load(open("/tmp/mega_acts.json"))
by = {a["code"]: a for a in acts}
# ---- ordering: roots in document order, children in document order -------
roots = [a["code"] for a in acts if a["parent"] is None]
def walk(code, depth, path):
a = by[code]
a["level"] = depth
a["path"] = path
for c in a["children"]:
walk(c, depth + 1, path + [code])
for r in roots:
walk(r, 0, [])
# ---- typo corrections (display only; originalName is preserved) ----------
FIX = [
("Mega Infrastructure AI Intergration", "Mega Infrastructure AI Integration"),
("CCS Intergration", "CCS Integration"),
("Training & Cources", "Training & Courses"),
("Drivers license & PDP", "Drivers licence & PDP"),
("All sites income & Expences", "All sites income & Expenses"),
("All sites combined retetion", "All sites combined retention"),
("All sites combined Expences", "All sites combined Expenses"),
("All sites combined material expences", "All sites combined material expenses"),
("Combined supplier expences", "Combined supplier expenses"),
("Head office fixed expences", "Head office fixed expenses"),
("Expences per week", "Expenses per week"),
("Expences per month total to date", "Expenses per month total to date"),
("Opperator certificate", "Operator certificate"),
("Opperator", "Operator"),
("Expences", "Expenses"),
("VaT allocation", "VAT allocation"),
]
for a in acts:
a["originalName"] = a["name"]
n = a["name"]
for bad, good in FIX:
if n == bad:
n = good
break
if bad in n:
n = n.replace(bad, good)
a["displayName"] = n
# ---- quality analysis ----------------------------------------------------
def wd(a, b):
d, n = a, 0
while d <= b:
if d.weekday() < 5:
n += 1
d += datetime.timedelta(days=1)
return n
def dt(s): return datetime.date.fromisoformat(s)
for a in acts:
s, f = dt(a["start"]), dt(a["finish"])
a["spanDays"] = wd(s, f)
a["durMismatch"] = (a["duration"] != a["spanDays"])
# stretched-window cluster: leaves sharing an identical window that is ~2x their declared duration
from collections import defaultdict
win = defaultdict(list)
for a in acts:
win[(a["start"], a["finish"])].append(a["code"])
STRETCHED = set()
for k, v in win.items():
if len(v) < 2:
continue
for c in v:
a = by[c]
if a["durMismatch"] and a["duration"] > 0 and a["spanDays"] > a["duration"] * 1.5:
STRETCHED.add(c)
for a in acts:
a["unconfirmed"] = "?" in a["name"]
a["stretched"] = a["code"] in STRETCHED
a["typoFixed"] = a["displayName"] != a["originalName"]
a["workstream"] = a["path"][1] if len(a["path"]) >= 2 else None
PROG = {
"A000": "Mega Infrastructure AI Integration",
"B110": "Mega Plant",
}
def q(s):
return json.dumps(s).replace("\\", "\\\\") if False else json.dumps(s)
out = []
out.append("// AUTO-GENERATED from 'Mega Infrastructure AI intergration system.pdf'")
out.append("// (Candy 2.05p / P6 barchart export, printed 30 Sep 2026).")
out.append("// 100 activities, 2 programmes, 06 Oct 2026 -> 31 Aug 2027.")
out.append("// Hierarchy comes from PDF indentation -- NOT from the activity code.")
out.append("// Regenerate with scripts/gen_baseline.py if the source PDF changes.")
out.append("")
out.append("import type { BaselineActivity } from '../types'")
out.append("")
out.append("export const SOURCE_PDF = 'Mega Infrastructure AI intergration system.pdf'")
out.append("export const SOURCE_PRINTED = '30 September 2026 13:00'")
out.append("")
out.append("export const PROGRAMMES = [")
for r in roots:
a = by[r]
out.append(" {")
out.append(f" code: {q(a['code'])},")
out.append(f" name: {q(PROG[a['code']])},")
out.append(f" originalName: {q(a['originalName'])},")
out.append(f" start: {q(a['start'])},")
out.append(f" finish: {q(a['finish'])},")
out.append(" },")
out.append("] as const")
out.append("")
out.append("export const BASELINE: BaselineActivity[] = [")
for a in acts:
flags = []
if a["unconfirmed"]: flags.append("unconfirmed")
if a["stretched"]: flags.append("stretched")
if a["durMismatch"]: flags.append("duration-mismatch")
if a["typoFixed"]: flags.append("name-corrected")
out.append(" {")
out.append(f" code: {q(a['code'])},")
out.append(f" name: {q(a['displayName'])},")
out.append(f" originalName: {q(a['originalName'])},")
out.append(f" parent: {q(a['parent'])},")
out.append(f" level: {a['level']},")
out.append(f" plannedDuration: {a['duration']},")
out.append(f" spanDays: {a['spanDays']},")
out.append(f" start: {q(a['start'])},")
out.append(f" finish: {q(a['finish'])},")
out.append(f" flags: {json.dumps(flags)},")
out.append(" },")
out.append("]")
out.append("")
src = "\n".join(out)
open("/tmp/baseline.ts", "w").write(src)
print(f"generated {len(acts)} activities, {len(src)} bytes")
print(f"flags: unconfirmed={sum(1 for a in acts if a['unconfirmed'])} "
f"stretched={sum(1 for a in acts if a['stretched'])} "
f"durMismatch={sum(1 for a in acts if a['durMismatch'])} "
f"nameCorrected={sum(1 for a in acts if a['typoFixed'])}")

160
scripts/parse_pdf.py Normal file
View file

@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Parse the Mega Infrastructure Candy/Primavera barchart text dump into a WBS tree."""
import re, json, datetime
from collections import OrderedDict
SRC = "/tmp/mega.txt"
LINE_RE = re.compile(
r"^(?P<indent>\s*)"
r"(?P<code>[A-Z]\d{3})\s*-\s*"
r"(?P<name>.*?)\s{2,}"
r"(?P<dur>\d+)\s+"
r"(?P<start>\d{2}\s+\w{3}\s+\d{2})\s+"
r"(?P<finish>\d{2}\s+\w{3}\s+\d{2})"
)
MON = {"Oct":10,"Nov":11,"Dec":12,"Jan":1,"Feb":2,"Mar":3,"Apr":4,"May":5,"Jun":6,"Jul":7,"Aug":8,"Sep":9}
def parse_date(s, century):
d, m, y = s.split()
return datetime.date(2000 + int(y), MON[m], int(d))
acts = []
with open(SRC) as f:
for raw in f:
raw = raw.rstrip("\n")
if not raw.strip():
continue
m = LINE_RE.match(raw)
if not m:
continue
g = m.groupdict()
# Candy prints dates as "06 Oct 26" -> 2026, "17 Mar 27" -> 2027
start = parse_date(g["start"], None)
finish = parse_date(g["finish"], None)
acts.append({
"code": g["code"],
"name": re.sub(r"\s+", " ", g["name"]).strip(),
"indent": len(g["indent"]),
"duration": int(g["dur"]),
"start": start.isoformat(),
"finish": finish.isoformat(),
})
# Build tree from leading-space indentation
roots, stack = [], []
for a in acts:
while stack and stack[-1]["indent"] >= a["indent"]:
stack.pop()
a["parent"] = stack[-1]["code"] if stack else None
a["children"] = []
if stack:
stack[-1]["children"].append(a["code"])
else:
roots.append(a["code"])
stack.append(a)
by_code = {a["code"]: a for a in acts}
def span(a):
return (datetime.date.fromisoformat(a["start"]), datetime.date.fromisoformat(a["finish"]))
print(f"TOTAL ACTIVITIES PARSED: {len(acts)}")
print(f"ROOT NODES: {roots}\n")
def wd(a, b):
"""working days inclusive"""
d, n = a, 0
while d <= b:
if d.weekday() < 5:
n += 1
d += datetime.timedelta(days=1)
return n
for r in roots:
a = by_code[r]
s, f = span(a)
print("=" * 78)
print(f"{a['code']} - {a['name']} [{a['duration']}wd] {a['start']} -> {a['finish']}")
print(f" actual calendar span: {(f-s).days+1} days, {wd(s,f)} working days")
tot = 0
for c in a["children"]:
ca = by_code[c]
s2, f2 = span(ca)
tot += ca["duration"]
flag = ""
if ca["duration"] != wd(s2, f2):
flag += f" [!DUR {ca['duration']}d vs {wd(s2,f2)}wd actual]"
if ca["finish"] > a["finish"] or ca["start"] < a["start"]:
flag += " [!OUTSIDE PARENT WINDOW]"
print(f" +- {ca['code']} - {ca['name']} [{ca['duration']}wd] {ca['start']} -> {ca['finish']}{flag}")
for g in ca["children"]:
ga = by_code[g]
s3, f3 = span(ga)
gflag = ""
if ga["duration"] != wd(s3, f3):
gflag += f" [!DUR {ga['duration']}d vs {wd(s3,f3)}wd]"
print(f" | {ga['code']} - {ga['name']} [{ga['duration']}wd] {ga['start']} -> {ga['finish']}{gflag}")
print(f" >> direct children duration sum: {tot} vs parent {a['duration']} (delta {tot - a['duration']:+d})")
# ---- global anomalies ----
print("\n" + "=" * 78)
print("ANOMALIES / DATA QUALITY FINDINGS")
print("=" * 78)
# 1. code order != tree order
order = [a["code"] for a in acts]
outoforder = [(a["code"], a["parent"]) for a in acts
if a["parent"] and order.index(a["parent"]) > order.index(a["code"])]
print(f"\n1. Codes that sort AFTER their parent's children (code != hierarchy):")
for c, p in outoforder:
print(f" {c} (parent {p}) appears before sibling {c} numerically")
# 2. duration vs calendar mismatch
print(f"\n2. Duration field != actual working days between start/finish:")
n = 0
for a in acts:
s, f = span(a)
if a["duration"] != wd(s, f):
n += 1
print(f" {a['code']} - {a['name']}: says {a['duration']}wd, span is {wd(s,f)}wd")
print(f" total: {n}/{len(acts)}")
# 3. unconfirmed "?" text
print(f"\n3. Unresolved placeholders in spec text:")
for a in acts:
if "?" in a["name"]:
print(f" {a['code']} - {a['name']}")
# 4. parents with no children / children with no dates
print(f"\n4. Leaf activities (bottom of WBS): {sum(1 for a in acts if not a['children'])}")
print(f" Parents: {sum(1 for a in acts if a['children'])}")
# 5. typos
TYPOS = {"Intergration":"Integration","Opperator":"Operator","Cources":"Courses",
"Expences":"Expenses","retetion":"retention","license":"licence",
"expences":"expenses","VaT":"VAT"}
print(f"\n5. Likely typos in spec wording:")
for a in acts:
for k, v in TYPOS.items():
if k in a["name"]:
print(f" {a['code']}: {a['name']} -> {a['name'].replace(k, v)}")
# 6. identical windows (possible duplicates / parallel pairs)
print(f"\n6. Activities sharing an identical start/finish window:")
seen = {}
for a in acts:
seen.setdefault((a["start"], a["finish"]), []).append(a["code"])
for k, v in seen.items():
if len(v) > 1:
print(f" {k[0]} -> {k[1]}: {', '.join(v)}")
# 7. programme bounds
allstarts = [span(a)[0] for a in acts]
allfin = [span(a)[1] for a in acts]
print(f"\n7. Programme envelope: {min(allstarts)} -> {max(allfin)}")
print(f" Today: {datetime.date(2026,9,30)} -> everything is FUTURE, nothing overdue at kickoff")
json.dump(acts, open("/tmp/mega_acts.json", "w"), indent=1)
print(f"\nWrote {len(acts)} activities -> /tmp/mega_acts.json")

77
src/App.tsx Normal file
View file

@ -0,0 +1,77 @@
import { useState } from 'react'
import { StoreProvider, useStore } from './lib/store'
import { Dashboard } from './components/Dashboard'
import { Gantt } from './components/Gantt'
import { Todos } from './components/Todos'
import { Wbs } from './components/Wbs'
import { DataView } from './components/DataView'
import { ActivityPanel } from './components/ActivityPanel'
import { ThemeControls } from './components/ThemeControls'
import './styles.css'
type Tab = 'dashboard' | 'timeline' | 'todos' | 'wbs' | 'data'
const TABS: [Tab, string][] = [
['dashboard', 'Dashboard'],
['timeline', 'Timeline'],
['todos', 'Todo list'],
['wbs', 'WBS'],
['data', 'Data'],
]
function Shell() {
const [tab, setTab] = useState<Tab>('dashboard')
const [open, setOpen] = useState<string | null>(null)
const { views, index } = useStore()
const leaves = [...views.values()].filter((v) => v.isLeaf)
const done = leaves.filter((v) => v.status === 'done').length
const overall = Math.round(
leaves.reduce((s, v) => s + v.percent, 0) / (leaves.length || 1),
)
return (
<div className="app">
<header className="topbar">
<div className="brand">
<strong>Mega Infrastructure</strong>
<span>AI Integration — project tracker</span>
</div>
<nav>
{TABS.map(([k, l]) => (
<button key={k} className={tab === k ? 'on' : ''} onClick={() => setTab(k)}>
{l}
{k === 'todos' && done > 0 && <em className="badge">{done}</em>}
</button>
))}
</nav>
<div className="topmeta">
<div className="bar sm">
<i style={{ width: `${overall}%` }} />
</div>
<span>{overall}%</span>
<span className="muted sm">{index.order.length} activities</span>
<ThemeControls />
</div>
</header>
<main>
{tab === 'dashboard' && <Dashboard onOpen={setOpen} />}
{tab === 'timeline' && <Gantt onOpen={setOpen} />}
{tab === 'todos' && <Todos onOpen={setOpen} />}
{tab === 'wbs' && <Wbs onOpen={setOpen} />}
{tab === 'data' && <DataView onOpen={setOpen} />}
</main>
<ActivityPanel code={open} onClose={() => setOpen(null)} />
</div>
)
}
export default function App() {
return (
<StoreProvider>
<Shell />
</StoreProvider>
)
}

View file

@ -0,0 +1,173 @@
import { useState } from 'react'
import { useStore } from '../lib/store'
import { fmtLong, today as todayISO } from '../lib/dates'
import { STATUSES, STATUS_LABEL, type Status } from '../types'
/**
* Mounted with key={code} by the shell, so switching activities remounts the
* form and every useState initialiser runs against the new record. No sync
* effect needed.
*/
function PanelBody({ code, onClose }: { code: string; onClose: () => void }) {
const { views, index, update } = useStore()
const v = views.get(code)
if (!v) return null
const a = v.act
const p = v.progress
const [note, setNote] = useState(p.note)
const [err, setErr] = useState<string | null>(null)
const kids = index.childrenOf.get(a.code) ?? []
const ancestors = v.path.map((c) => index.byCode.get(c)!).filter(Boolean)
return (
<div className="panel-backdrop" onClick={onClose}>
<div className="panel" onClick={(e) => e.stopPropagation()}>
<header>
<div>
<span className="panel-code">{a.code}</span>
<h2>{a.name}</h2>
{ancestors.length > 0 && (
<p className="crumb">
{ancestors.map((x) => `${x.code} ${x.name}`).join(' › ')}
</p>
)}
</div>
<button className="x" onClick={onClose} aria-label="close">
✕
</button>
</header>
<div className="panel-body">
<dl className="facts">
<div>
<dt>Planned start</dt>
<dd>{fmtLong(a.start)}</dd>
</div>
<div>
<dt>Planned finish</dt>
<dd>{fmtLong(a.finish)}</dd>
</div>
<div>
<dt>Duration (PDF)</dt>
<dd>
{a.plannedDuration} working days
{a.plannedDuration !== a.spanDays && (
<em className="mismatch"> · dates span {a.spanDays}</em>
)}
</dd>
</div>
<div>
<dt>Level</dt>
<dd>
{a.level === 0 ? 'Programme' : a.level === 1 ? 'Workstream' : a.level === 2 ? 'Feature' : 'Deliverable'}
</dd>
</div>
</dl>
{kids.length > 0 && (
<div className="panel-kids">
<h4>Rolls up {kids.length} child activities · {v.percent}% complete</h4>
<ul>
{kids.map((c) => {
const kv = views.get(c)!
return (
<li key={c}>
<code>{c}</code> {kv.act.name}
<span className="muted"> {kv.percent}%</span>
</li>
)
})}
</ul>
</div>
)}
<div className="field">
<label>Status</label>
<div className="seg wide">
{STATUSES.map((s) => (
<button
key={s}
className={`${p.status === s ? 'on' : ''} st-${s}`}
onClick={() => update(code, { status: s as Status })}
>
{STATUS_LABEL[s]}
</button>
))}
</div>
</div>
<div className="field">
<label>
Percent complete <strong>{p.percent}%</strong>
</label>
<input
type="range"
min={0}
max={100}
step={5}
value={p.percent}
onChange={(e) => update(code, { percent: Number(e.target.value) })}
/>
</div>
<div className="field">
<label htmlFor="note">Note</label>
<textarea
id="note"
rows={4}
value={note}
placeholder="Blockers, decisions, client feedback…"
onChange={(e) => setNote(e.target.value)}
onBlur={() => {
if (note === p.note) return
if (note.trim() === '' && p.status === 'blocked' && !/block|wait|need/i.test(note)) {
setErr('Marking this blocked without saying why costs the owner a message. Add a short reason, or switch the status back.')
return
}
setErr(null)
update(code, { note })
}}
/>
{err && <p className="error">{err}</p>}
<p className="muted sm">Saved when you click away.</p>
</div>
{(a.flags.includes('unconfirmed') || a.flags.includes('stretched') || a.name !== a.originalName) && (
<div className="panel-flags">
{a.flags.includes('unconfirmed') && (
<p>
<span className="pill warn">?</span> The PDF still has a “?” in this description — needs client
confirmation before you build it.
</p>
)}
{a.flags.includes('stretched') && (
<p>
<span className="pill bad">!</span> Declared {a.plannedDuration} working days but occupies{' '}
{a.spanDays}. Shared an identical window with five other activities.
</p>
)}
{a.name !== a.originalName && (
<p>
<span className="pill">Aa</span> PDF reads “{a.originalName}”. Corrected for display; the original is
preserved.
</p>
)}
</div>
)}
<p className="muted sm">Last updated {p.updatedAt ? new Date(p.updatedAt).toLocaleString() : 'never'}.</p>
</div>
</div>
</div>
)
}
export function ActivityPanel({ code, onClose }: { code: string | null; onClose: () => void }) {
if (!code) return null
return <PanelBody key={code} code={code} onClose={onClose} />
}
export function TodayHint() {
return <span className="muted sm">Today {fmtLong(todayISO())}</span>
}

View file

@ -0,0 +1,162 @@
import { useMemo } from 'react'
import { useStore } from '../lib/store'
import { fmtLong, today as todayISO, addDays } from '../lib/dates'
import { STATUS_LABEL } from '../types'
export function Dashboard({ onOpen }: { onOpen: (code: string) => void }) {
const { views, activities } = useStore()
const all = useMemo(() => [...views.values()], [views])
const leaves = all.filter((v) => v.isLeaf)
const totalW = leaves.reduce((s, v) => s + Math.max(1, v.act.spanDays), 0)
const overall = Math.round(
leaves.reduce((s, v) => s + v.percent * Math.max(1, v.act.spanDays), 0) / (totalW || 1),
)
const count = (s: string) => leaves.filter((v) => v.status === s).length
const overdue = leaves.filter((v) => v.overdue)
const t = todayISO()
const next7 = leaves
.filter((v) => v.status !== 'done' && v.act.start >= t && v.act.start <= addDays(t, 7))
.sort((a, b) => (a.act.start < b.act.start ? -1 : 1))
const workstreams = useMemo(() => {
const codes = new Set<string>()
for (const v of all) if (v.workstream) codes.add(v.workstream)
return [...codes]
.map((c) => views.get(c)!)
.filter(Boolean)
.sort((a, b) => (a.act.code < b.act.code ? -1 : 1))
}, [all, views])
const load = useMemo(() => {
const m = new Map<string, number>()
for (const v of leaves) {
const k = v.act.start.slice(0, 7)
m.set(k, (m.get(k) ?? 0) + 1)
}
return [...m.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))
}, [leaves])
const maxLoad = Math.max(1, ...load.map(([, n]) => n))
const first8Weeks = leaves.filter((v) => v.act.start <= '2026-11-30').length
const kickoff = activities.reduce((m, a) => (a.start < m ? a.start : m), activities[0].start)
const ends = activities.reduce((m, a) => (a.finish > m ? a.finish : m), activities[0].finish)
return (
<div className="dash">
<div className="kpis">
<div className="kpi big">
<span className="kpi-val">{overall}%</span>
<span className="kpi-lbl">Overall complete</span>
<div className="bar">
<i style={{ width: `${overall}%` }} />
</div>
</div>
<div className="kpi">
<span className="kpi-val">{leaves.length}</span>
<span className="kpi-lbl">Deliverables</span>
</div>
<div className="kpi st-done">
<span className="kpi-val">{count('done')}</span>
<span className="kpi-lbl">Done</span>
</div>
<div className="kpi st-in-progress">
<span className="kpi-val">{count('in-progress')}</span>
<span className="kpi-lbl">{STATUS_LABEL['in-progress']}</span>
</div>
<div className="kpi st-blocked">
<span className="kpi-val">{count('blocked')}</span>
<span className="kpi-lbl">Blocked</span>
</div>
<div className={`kpi ${overdue.length ? 'st-blocked' : ''}`}>
<span className="kpi-val">{overdue.length}</span>
<span className="kpi-lbl">Overdue</span>
</div>
</div>
<div className="dash-grid">
<section className="card">
<h3>Programme window</h3>
<p className="muted">
{fmtLong(kickoff)} → {fmtLong(ends)} · {activities.length} activities · source: Candy barchart printed
30 Sep 2026
</p>
<p className="muted sm">
Kickoff is {Math.max(0, Math.round((new Date(kickoff).getTime() - new Date(t).getTime()) / 86_400_000))} days
from today. Everything in the plan is future-dated, so nothing is late until work starts.
</p>
</section>
<section className="card">
<h3>Starting in the next 7 days</h3>
{next7.length === 0 ? (
<p className="muted">Nothing scheduled to start in the next week.</p>
) : (
<ul className="mini">
{next7.slice(0, 6).map((v) => (
<li key={v.act.code} onClick={() => onOpen(v.act.code)}>
<span className="t-code">{v.act.code}</span>
<span className="t-name">{v.act.name}</span>
<span className="muted">{v.act.start.slice(8, 10)}/{v.act.start.slice(5, 7)}</span>
</li>
))}
</ul>
)}
</section>
<section className="card">
<h3>Overdue</h3>
{overdue.length === 0 ? (
<p className="muted">None. Good.</p>
) : (
<ul className="mini">
{overdue.slice(0, 8).map((v) => (
<li key={v.act.code} onClick={() => onOpen(v.act.code)}>
<span className="t-code">{v.act.code}</span>
<span className="t-name">{v.act.name}</span>
<span className="muted">was due {v.act.finish}</span>
</li>
))}
</ul>
)}
</section>
<section className="card wide">
<h3>Workstream health</h3>
<div className="ws">
{workstreams.map((w) => (
<div key={w.act.code} className="ws-row" onClick={() => onOpen(w.act.code)}>
<span className="ws-code">{w.act.code}</span>
<span className="ws-name">{w.act.name}</span>
<div className="bar">
<i style={{ width: `${w.percent}%` }} />
</div>
<span className="ws-pct">{w.percent}%</span>
<span className="muted sm">
{w.act.start.slice(5)} → {w.act.finish.slice(5)}
</span>
</div>
))}
</div>
</section>
<section className="card wide">
<h3>Build load — deliverables starting per month</h3>
<div className="load">
{load.map(([k, n]) => (
<div key={k} className="load-col" title={`${n} deliverables start in ${k}`}>
<div className="load-bar" style={{ height: `${(n / maxLoad) * 100}%` }} />
<span className="load-n">{n}</span>
<span className="load-l">{k.slice(5)}/{k.slice(2, 4)}</span>
</div>
))}
</div>
<p className="muted sm">
Front-loaded by design: {first8Weeks} of {leaves.length} deliverables start in the first 8 weeks. Worth
confirming this is one developer.
</p>
</section>
</div>
</div>
)
}

237
src/components/DataView.tsx Normal file
View file

@ -0,0 +1,237 @@
import { useMemo, useRef, useState } from 'react'
import { useStore } from '../lib/store'
import { downloadJson, readRemoteToken, readRemoteUrl, writeRemoteToken, writeRemoteUrl } from '../lib/storage'
import { SOURCE_PDF, SOURCE_PRINTED } from '../data/baseline'
import { fmtLong } from '../lib/dates'
import type { ProgressMap } from '../types'
export function DataView({ onOpen }: { onOpen: (code: string) => void }) {
const { activities, views, progress, setMany, reset, reload, adapter, ready, error } = useStore()
const fileRef = useRef<HTMLInputElement>(null)
const [url, setUrl] = useState(readRemoteUrl())
const [token, setToken] = useState(readRemoteToken())
const [note, setNote] = useState<string | null>(null)
const issues = useMemo(() => {
const unconfirmed = activities.filter((a) => a.flags.includes('unconfirmed'))
const stretched = activities.filter((a) => a.flags.includes('stretched'))
const mismatch = activities.filter((a) => a.flags.includes('duration-mismatch'))
const renamed = activities.filter((a) => a.flags.includes('name-corrected'))
return { unconfirmed, stretched, mismatch, renamed }
}, [activities])
const withProgress = Object.keys(progress).length
const doImport = (f: File) => {
const r = new FileReader()
r.onload = () => {
try {
const data = JSON.parse(String(r.result)) as ProgressMap
if (typeof data !== 'object' || data === null) throw new Error('not an object')
setMany(data)
setNote(`Imported ${Object.keys(data).length} progress records.`)
} catch (e) {
setNote(`Import failed: ${e instanceof Error ? e.message : e}`)
}
}
r.readAsText(f)
}
const applyRemote = () => {
if (url.trim()) {
writeRemoteUrl(url.trim())
writeRemoteToken(token.trim())
setNote('Shared store configured. Reloading…')
window.dispatchEvent(new Event('mega-tracker:adapter-changed'))
} else {
writeRemoteUrl('')
writeRemoteToken('')
setNote('Reverted to this-browser storage.')
window.dispatchEvent(new Event('mega-tracker:adapter-changed'))
}
}
return (
<div className="dataview">
<section className="card">
<h3>Source</h3>
<p className="muted">
Seeded from <code>{SOURCE_PDF}</code> — a Candy 2.05p (P6) barchart printed {SOURCE_PRINTED}. 100
activities across 2 programmes, 06 Oct 2026 → 31 Aug 2027. Hierarchy is taken from the PDF's indentation,
not the activity code.
</p>
<p className="muted sm">
Storage now: <strong>{adapter.label}</strong> — {adapter.description} ·{' '}
{withProgress ? `${withProgress} activity record(s) saved` : 'no progress recorded yet'}
{ready ? '' : ' · loading…'}
</p>
{error && <p className="error">Last save failed: {error}</p>}
</section>
<section className="card">
<h3>Share progress with the project owner</h3>
<p className="muted">
A purely static site cannot do this on its own — progress lives in the browser that recorded it, so the
owner would see the plan with everything at 0%. Point this at any endpoint that accepts <code>GET</code> and{' '}
<code>PUT</code> of a JSON body and both of you see the same numbers. A single-row PocketBase record works
(you already run one for WFDASH) and needs no schema in this app.
</p>
<div className="row-form">
<input
placeholder="https://your-host/api/collections/tracker_progress/records/RECORD_ID"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<input placeholder="Auth header (optional)" value={token} onChange={(e) => setToken(e.target.value)} />
<button onClick={applyRemote}>{url.trim() ? 'Use shared store' : 'Back to local'}</button>
</div>
<p className="muted sm">
Leave the URL blank to keep everything on this device. The token is stored in this browser only, never in
the deployed bundle.
</p>
</section>
<section className="card">
<h3>Backup &amp; restore</h3>
<div className="row-form">
<button onClick={() => downloadJson(`mega-progress-${new Date().toISOString().slice(0, 10)}.json`, progress)}>
Export progress JSON
</button>
<button onClick={() => downloadJson('mega-baseline-plan.json', activities)}>Export full plan</button>
<button onClick={() => fileRef.current?.click()}>Import progress JSON</button>
<button
className="danger"
onClick={() => {
if (confirm('Reset all progress? The plan itself is never touched — only status, % and notes.')) {
void reset()
setNote('Progress reset.')
}
}}
>
Reset all progress
</button>
<button onClick={() => void reload()}>Reload from storage</button>
</div>
<input
ref={fileRef}
type="file"
accept="application/json"
style={{ display: 'none' }}
onChange={(e) => {
const f = e.target.files?.[0]
if (f) doImport(f)
e.target.value = ''
}}
/>
{note && <p className="muted sm">{note}</p>}
</section>
<section className="card">
<h3>Spec issues to resolve with the client</h3>
<p className="muted">
These came out of parsing the PDF, not out of opinion. Each is a real question worth putting back to Mega
before you build the thing.
</p>
<details open>
<summary>
<strong>2 activities still contain an unresolved “?”</strong> — the spec was never finished
</summary>
<ul className="issue">
{issues.unconfirmed.map((a) => (
<li key={a.code} onClick={() => onOpen(a.code)}>
<code>{a.code}</code> {a.name} — spec text literally reads “{a.originalName}”
</li>
))}
</ul>
</details>
<details open>
<summary>
<strong>6 activities share one identical 6-week window</strong> but are all declared 15 days
</summary>
<p className="muted sm">
{issues.stretched.map((a) => a.code).join(', ')} all sit on 08 Dec 26 → 19 Jan 27 across 4 different
workstreams, each declared 15 working days while actually occupying 31. That reads as a bulk edit or a
scheduling constraint artefact in Candy rather than a plan. Ask whether these really are 6 weeks of work.
</p>
</details>
<details>
<summary>
<strong>{issues.mismatch.length} activities have a duration that disagrees with their own dates</strong>
</summary>
<p className="muted sm">
The app schedules off the <em>dates</em> and treats the PDF’s “Durn” column as a reference value, because
the dates are what the bars actually show. Mostly the summary rows.
</p>
<ul className="issue">
{issues.mismatch.map((a) => (
<li key={a.code} onClick={() => onOpen(a.code)}>
<code>{a.code}</code> {a.name} — PDF says {a.plannedDuration}d, dates span {a.spanDays} working days
</li>
))}
</ul>
</details>
<details>
<summary>
<strong>{issues.renamed.length} spelling corrections applied</strong> — original text kept, switch the WBS
view to “Original PDF wording” to compare
</summary>
<ul className="issue">
{issues.renamed.map((a) => (
<li key={a.code} onClick={() => onOpen(a.code)}>
<code>{a.code}</code> {a.originalName} → <strong>{a.name}</strong>
</li>
))}
</ul>
</details>
<details>
<summary>
<strong>A000 and A010 cover the identical window</strong> — is “Site Dashboard” a module or the whole
programme?
</summary>
<p className="muted sm">
A000 (the programme) and A010 (Site Dashboard) both run 06 Oct 26 → 31 Aug 27, and A000 lists A010 as a
child. Worth confirming whether A010 is meant to be a sibling workstream rather than a container for
everything.
</p>
</details>
<details>
<summary>
<strong>The plan is heavily front-loaded</strong> — is one developer really building all of this?
</summary>
<p className="muted sm">
33 of 78 deliverables start in the first 8 weeks, peaking at 10 concurrent in the week of 13 Oct 2026. If
that is a single developer, the dates need revisiting; if there is a team, the plan should probably say so.
</p>
</details>
</section>
<section className="card">
<h3>Progress recorded so far</h3>
{withProgress === 0 ? (
<p className="muted">Nothing yet. Open an activity and set a status to start tracking.</p>
) : (
<ul className="issue">
{Object.entries(progress)
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
.map(([code, p]) => {
const v = views.get(code)
return (
<li key={code} onClick={() => onOpen(code)}>
<code>{code}</code> {v?.act.name ?? '(unknown)'} — {p.status} · {p.percent}%
{p.note ? ` · “${p.note}”` : ''} · {new Date(p.updatedAt).toLocaleString()}
</li>
)
})}
</ul>
)}
<p className="muted sm">Today is {fmtLong(new Date().toISOString().slice(0, 10))}.</p>
</section>
</div>
)
}

220
src/components/Gantt.tsx Normal file
View file

@ -0,0 +1,220 @@
import { useMemo, useState } from 'react'
import { useStore } from '../lib/store'
import { scheduleSpan, type ScheduleSpan } from '../lib/tree'
import {
addDays,
daysBetween,
fmtShort,
monthLabel,
monthStart,
toDate,
today as todayISO,
weekAnchoredToFriday,
} from '../lib/dates'
import type { Status } from '../types'
const LABEL_W = 330
const MONTH_W = 96
const WEEK_W = 15
const ROW_H = 30
type Mode = 'month' | 'week'
interface Tick {
iso: string
left: number // %
width: number // %
label: string
sub?: string
}
function buildTicks(span: ScheduleSpan, mode: Mode): Tick[] {
const out: Tick[] = []
const pct = (iso: string) => (daysBetween(span.start, iso) / span.totalDays) * 100
const spanDays = (a: string, b: string) => ((daysBetween(a, b) + 1) / span.totalDays) * 100
if (mode === 'month') {
let cur = monthStart(span.start)
while (cur <= span.finish) {
const d = toDate(cur)
const end = addDays(
new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1)).toISOString().slice(0, 10),
-1,
)
const clippedEnd = end > span.finish ? span.finish : end
out.push({ iso: cur, left: pct(cur), width: spanDays(cur, clippedEnd), label: monthLabel(cur) })
cur = addDays(end, 1)
}
return out
}
// Friday-anchored weeks, matching the Candy printout.
let cur = weekAnchoredToFriday(span.start)
while (cur <= span.finish) {
const end = addDays(cur, 6)
const clippedEnd = end > span.finish ? span.finish : end
const d = toDate(cur)
out.push({
iso: cur,
left: pct(cur),
width: spanDays(cur, clippedEnd),
label: String(d.getUTCDate()),
sub: d.getUTCDate() === 1 || cur === weekAnchoredToFriday(span.start) ? monthLabel(cur) : undefined,
})
cur = addDays(cur, 7)
}
return out
}
const BAR_COLOUR: Record<Status, string> = {
'not-started': 'var(--bar-planned)',
'in-progress': 'var(--bar-active)',
blocked: 'var(--bar-blocked)',
done: 'var(--bar-done)',
}
export function Gantt({ onOpen }: { onOpen: (code: string) => void }) {
const { activities, index, views } = useStore()
const [mode, setMode] = useState<Mode>('month')
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
const [onlyLeaves, setOnlyLeaves] = useState(false)
const span = useMemo(() => scheduleSpan(activities), [activities])
const ticks = useMemo(() => buildTicks(span, mode), [span, mode])
const tlWidth = mode === 'month' ? MONTH_W : WEEK_W
const rows = useMemo(() => {
const out: string[] = []
const walk = (code: string) => {
const kids = index.childrenOf.get(code) ?? []
const leaf = kids.length === 0
if (!onlyLeaves || !leaf) out.push(code)
if (collapsed.has(code)) return
kids.forEach(walk)
}
index.roots.forEach(walk)
return out
}, [index, collapsed, onlyLeaves])
const today = todayISO()
const todayLeft = (daysBetween(span.start, today) / span.totalDays) * 100
const toggle = (code: string) =>
setCollapsed((s) => {
const n = new Set(s)
if (n.has(code)) n.delete(code)
else n.add(code)
return n
})
return (
<div className="gantt">
<div className="gantt-toolbar">
<div className="seg">
<button className={mode === 'month' ? 'on' : ''} onClick={() => setMode('month')}>
Month
</button>
<button className={mode === 'week' ? 'on' : ''} onClick={() => setMode('week')}>
Week <span className="hint">— matches PDF</span>
</button>
</div>
<label className="chk">
<input type="checkbox" checked={onlyLeaves} onChange={(e) => setOnlyLeaves(e.target.checked)} />
Deliverables only
</label>
<div className="legend">
{(['not-started', 'in-progress', 'blocked', 'done'] as Status[]).map((s) => (
<span key={s} className="legend-item">
<i style={{ background: BAR_COLOUR[s] }} />
{s === 'not-started' ? 'Planned' : s === 'in-progress' ? 'In progress' : s[0].toUpperCase() + s.slice(1)}
</span>
))}
</div>
</div>
<div className="gantt-scroll">
<div className="gantt-inner" style={{ ['--tl-w' as string]: `${tlWidth}px` }}>
{/* header */}
<div className="g-head" style={{ height: mode === 'month' ? 34 : 42 }}>
<div className="g-head-label" style={{ width: LABEL_W }}>
<span>Activity</span>
<span className="muted">{rows.length} rows</span>
</div>
<div className="g-head-track" style={{ width: `${(span.totalDays / (mode === 'month' ? 30.4 : 7)) * tlWidth}px` }}>
{ticks.map((t) => (
<div
key={t.iso}
className={`g-tick ${t.sub ? 'g-tick-major' : ''}`}
style={{ left: `${t.left}%`, width: `${t.width}%` }}
>
<span>{t.label}</span>
{t.sub && <em>{t.sub}</em>}
</div>
))}
</div>
</div>
{/* rows */}
<div className="g-body">
{rows.map((code) => {
const v = views.get(code)!
const a = v.act
const left = (daysBetween(span.start, a.start) / span.totalDays) * 100
const width = ((daysBetween(a.start, a.finish) + 1) / span.totalDays) * 100
const kids = index.childrenOf.get(code) ?? []
const isSummary = kids.length > 0
return (
<div key={code} className="g-row" style={{ height: ROW_H }}>
<div
className="g-label"
style={{ width: LABEL_W, paddingLeft: 8 + a.level * 14 }}
onClick={() => onOpen(code)}
>
{isSummary ? (
<button className="g-caret" onClick={() => toggle(code)} aria-label="collapse">
{collapsed.has(code) ? '▸' : '▾'}
</button>
) : (
<span className="g-caret-spacer" />
)}
<span className={`g-code lvl${a.level}`}>{a.code}</span>
<span className="g-name" title={`${a.name}${a.name !== a.originalName ? ` (PDF: ${a.originalName})` : ''}`}>
{a.name}
</span>
{a.flags.includes('unconfirmed') && <span className="pill warn" title="Unconfirmed in the PDF spec">?</span>}
{a.flags.includes('stretched') && <span className="pill bad" title="Declared 15d but occupies 31 working days">!</span>}
<span className="g-pct">{v.percent}%</span>
</div>
<div className="g-track" style={{ width: `${(span.totalDays / (mode === 'month' ? 30.4 : 7)) * tlWidth}px` }}>
{ticks.map((t) => (
<div key={t.iso} className="g-line" style={{ left: `${t.left}%` }} />
))}
<div
className={`g-bar ${isSummary ? 'summary' : ''} st-${v.status}`}
style={{
left: `${left}%`,
width: `${Math.max(width, 0.35)}%`,
background: BAR_COLOUR[v.status],
}}
onClick={() => onOpen(code)}
title={`${a.code} ${a.name}\n${fmtShort(a.start)} → ${fmtShort(a.finish)}\n${a.plannedDuration}wd planned · ${v.percent}% complete`}
>
<div className="g-fill" style={{ width: `${v.percent}%` }} />
</div>
</div>
</div>
)
})}
{todayLeft >= 0 && todayLeft <= 100 && (
<div className="g-today" style={{ left: LABEL_W + (todayLeft / 100) * (span.totalDays / (mode === 'month' ? 30.4 : 7)) * tlWidth }}>
<span>Today</span>
</div>
)}
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,51 @@
import { ACCENTS, useAppearance } from '../lib/appearance'
export function ThemeControls() {
const { mode, accent, setAccent, toggle } = useAppearance()
return (
<div className="appearance">
<div className="swatches" role="radiogroup" aria-label="Accent colour">
{ACCENTS.map((a) => (
<button
key={a.id}
type="button"
role="radio"
aria-checked={accent === a.id}
aria-label={a.label}
title={a.label}
className={`swatch${accent === a.id ? ' on' : ''}`}
data-accent={a.id}
onClick={() => setAccent(a.id)}
/>
))}
</div>
<button
type="button"
className="mode-toggle"
onClick={toggle}
title={mode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
aria-label={mode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
>
{mode === 'dark' ? <SunIcon /> : <MoonIcon />}
</button>
</div>
)
}
function SunIcon() {
return (
<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<circle cx="10" cy="10" r="3.6" />
<path d="M10 1.6v2.1M10 16.3v2.1M18.4 10h-2.1M3.7 10H1.6M15.9 4.1l-1.5 1.5M5.6 14.4l-1.5 1.5M15.9 15.9l-1.5-1.5M5.6 5.6L4.1 4.1" />
</svg>
)
}
function MoonIcon() {
return (
<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.5 12.4A7.9 7.9 0 0 1 7.6 2.5a7.9 7.9 0 1 0 9.9 9.9z" />
</svg>
)
}

214
src/components/Todos.tsx Normal file
View file

@ -0,0 +1,214 @@
import { useMemo, useState } from 'react'
import { useStore } from '../lib/store'
import { fmtShort } from '../lib/dates'
import { STATUS_LABEL, STATUSES, type Status } from '../types'
import type { ActivityView } from '../types'
type Window = 'all' | 'next7' | 'next30' | 'overdue' | 'in-flight' | 'unconfirmed' | 'open'
type GroupBy = 'workstream' | 'status' | 'month' | 'none'
export function Todos({ onOpen }: { onOpen: (code: string) => void }) {
const { views, index, update } = useStore()
const [q, setQ] = useState('')
const [win, setWin] = useState<Window>('open')
const [status, setStatus] = useState<Status | 'all'>('all')
const [ws, setWs] = useState<string>('all')
const [group, setGroup] = useState<GroupBy>('workstream')
const [onlyLeaves, setOnlyLeaves] = useState(true)
const workstreams = useMemo(() => {
const codes = new Set<string>()
for (const v of views.values()) if (v.workstream) codes.add(v.workstream)
return [...codes].sort()
}, [views])
const all = useMemo(() => [...views.values()], [views])
const filtered = useMemo(() => {
const needle = q.trim().toLowerCase()
return all
.filter((v) => (onlyLeaves ? v.isLeaf : true))
.filter((v) => (ws === 'all' ? true : v.workstream === ws))
.filter((v) => (status === 'all' ? true : v.status === status))
.filter((v) => {
if (!needle) return true
return (
v.act.name.toLowerCase().includes(needle) ||
v.act.code.toLowerCase().includes(needle) ||
v.progress.note.toLowerCase().includes(needle)
)
})
.filter((v) => {
switch (win) {
case 'next7':
return !v.isLeaf && v.status !== 'done' && v.act.start <= addDaysIso(7) && v.act.finish >= todayIsoLocal()
case 'next30':
return !v.isLeaf && v.status !== 'done' && v.act.start <= addDaysIso(30)
case 'overdue':
return v.overdue
case 'in-flight':
return v.inFlight && v.status !== 'done'
case 'unconfirmed':
return v.act.flags.includes('unconfirmed')
case 'open':
return v.status !== 'done'
default:
return true
}
})
.sort((a, b) => (a.act.start < b.act.start ? -1 : a.act.start > b.act.start ? 1 : a.act.code < b.act.code ? -1 : 1))
}, [all, q, ws, status, win, onlyLeaves])
const groups = useMemo(() => {
if (group === 'none') return [{ key: 'All', items: filtered }]
const m = new Map<string, ActivityView[]>()
for (const v of filtered) {
let key: string
if (group === 'workstream') key = v.workstream ? `${v.workstream} · ${index.byCode.get(v.workstream)?.name ?? ''}` : v.act.code
else if (group === 'status') key = STATUS_LABEL[v.status]
else key = v.act.start.slice(0, 7)
const arr = m.get(key)
if (arr) arr.push(v)
else m.set(key, [v])
}
return [...m.entries()].map(([key, items]) => ({ key, items })).sort((a, b) => (a.key < b.key ? -1 : 1))
}, [filtered, group, index])
const done = all.filter((v) => v.status === 'done').length
return (
<div className="todos">
<div className="filters">
<input
className="search"
placeholder="Search code, name or note…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<div className="seg">
{(
[
['open', 'Open'],
['next7', 'Next 7d'],
['next30', 'Next 30d'],
['in-flight', 'In flight'],
['overdue', 'Overdue'],
['unconfirmed', 'Unconfirmed'],
['all', 'All'],
] as [Window, string][]
).map(([k, l]) => (
<button key={k} className={win === k ? 'on' : ''} onClick={() => setWin(k)}>
{l}
</button>
))}
</div>
<select value={status} onChange={(e) => setStatus(e.target.value as Status | 'all')}>
<option value="all">Any status</option>
{STATUSES.map((s) => (
<option key={s} value={s}>
{STATUS_LABEL[s]}
</option>
))}
</select>
<select value={ws} onChange={(e) => setWs(e.target.value)}>
<option value="all">All workstreams</option>
{workstreams.map((w) => (
<option key={w} value={w}>
{w} · {index.byCode.get(w)?.name}
</option>
))}
</select>
<select value={group} onChange={(e) => setGroup(e.target.value as GroupBy)}>
<option value="workstream">Group by workstream</option>
<option value="status">Group by status</option>
<option value="month">Group by start month</option>
<option value="none">No grouping</option>
</select>
<label className="chk">
<input type="checkbox" checked={onlyLeaves} onChange={(e) => setOnlyLeaves(e.target.checked)} />
Deliverables only
</label>
<span className="count">
{filtered.length} shown · {done} of {all.length} done
</span>
</div>
{groups.map((g) => (
<div key={g.key} className="tgroup">
{group !== 'none' && (
<div className="tgroup-head">
<span>{g.key}</span>
<span className="muted">
{g.items.length} · {Math.round(g.items.reduce((s, v) => s + v.percent, 0) / (g.items.length || 1))}%
</span>
</div>
)}
{g.items.map((v) => (
<div key={v.act.code} className={`trow st-${v.status}`} onClick={() => onOpen(v.act.code)}>
<button
className="tcheck"
aria-label="toggle done"
onClick={(e) => {
e.stopPropagation()
update(v.act.code, { status: v.status === 'done' ? 'in-progress' : 'done' })
}}
>
{v.status === 'done' ? '✓' : ''}
</button>
<span className="t-code">{v.act.code}</span>
<span className="t-name">{v.act.name}</span>
{v.act.flags.includes('unconfirmed') && <span className="pill warn">?</span>}
{v.overdue && <span className="pill bad">late</span>}
{v.dueSoon && <span className="pill soon">due soon</span>}
<span className="t-dates">
{fmtShort(v.act.start)} → {fmtShort(v.act.finish)}
</span>
<span className="t-status">
<select
value={v.status}
onClick={(e) => e.stopPropagation()}
onChange={(e) => update(v.act.code, { status: e.target.value as Status })}
>
{STATUSES.map((s) => (
<option key={s} value={s}>
{STATUS_LABEL[s]}
</option>
))}
</select>
</span>
<span className="t-pct">
<input
type="range"
min={0}
max={100}
step={5}
value={v.progress.percent}
onClick={(e) => e.stopPropagation()}
onChange={(e) => update(v.act.code, { percent: Number(e.target.value) })}
/>
<em>{v.progress.percent}%</em>
</span>
<span className="t-note" title={v.progress.note}>
{v.progress.note ? '✎' : ''}
</span>
</div>
))}
</div>
))}
{filtered.length === 0 && <div className="empty">Nothing matches these filters.</div>}
</div>
)
}
// local helpers to avoid pulling date utils into the render path twice
function todayIsoLocal(): string {
const n = new Date()
const p = (x: number) => String(x).padStart(2, '0')
return `${n.getFullYear()}-${p(n.getMonth() + 1)}-${p(n.getDate())}`
}
function addDaysIso(n: number): string {
const d = new Date()
d.setDate(d.getDate() + n)
const p = (x: number) => String(x).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
}

97
src/components/Wbs.tsx Normal file
View file

@ -0,0 +1,97 @@
import { useState } from 'react'
import { useStore } from '../lib/store'
import { fmtShort } from '../lib/dates'
export function Wbs({ onOpen }: { onOpen: (code: string) => void }) {
const { index, views } = useStore()
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
const [showPdf, setShowPdf] = useState(false)
const toggle = (c: string) =>
setCollapsed((s) => {
const n = new Set(s)
if (n.has(c)) n.delete(c)
else n.add(c)
return n
})
const rows: string[] = []
const walk = (code: string) => {
rows.push(code)
if (collapsed.has(code)) return
for (const c of index.childrenOf.get(code) ?? []) walk(c)
}
index.roots.forEach(walk)
return (
<div className="wbs">
<div className="filters">
<div className="seg">
<button className={!showPdf ? 'on' : ''} onClick={() => setShowPdf(false)}>
Corrected names
</button>
<button className={showPdf ? 'on' : ''} onClick={() => setShowPdf(true)}>
Original PDF wording
</button>
</div>
<span className="count">
{index.order.length} activities · {index.childrenOf.get('A000')?.length ?? 0} A-series workstreams
</span>
</div>
<div className="wbs-head">
<span>Code</span>
<span>Activity</span>
<span>Start</span>
<span>Finish</span>
<span>Dur</span>
<span>%</span>
<span>Status</span>
</div>
{rows.map((code) => {
const v = views.get(code)!
const a = v.act
const kids = index.childrenOf.get(code) ?? []
const name = showPdf ? a.originalName : a.name
return (
<div
key={code}
className={`wbs-row lvl${a.level} st-${v.status} ${kids.length ? 'summary' : ''}`}
onClick={() => onOpen(code)}
>
<span className="wbs-code" style={{ paddingLeft: 8 + a.level * 16 }}>
{kids.length ? (
<button
className="g-caret"
onClick={(e) => {
e.stopPropagation()
toggle(code)
}}
>
{collapsed.has(code) ? '▸' : '▾'}
</button>
) : (
<span className="g-caret-spacer" />
)}
{a.code}
</span>
<span className="wbs-name">
{name}
{a.flags.includes('unconfirmed') && <span className="pill warn">?</span>}
{a.flags.includes('stretched') && <span className="pill bad">!</span>}
</span>
<span className="muted">{fmtShort(a.start)}</span>
<span className="muted">{fmtShort(a.finish)}</span>
<span className="muted">
{a.plannedDuration}d
{a.plannedDuration !== a.spanDays && <em className="mismatch" title={`Actual span is ${a.spanDays} working days`}> ({a.spanDays})</em>}
</span>
<span className="wbs-pct">{v.percent}%</span>
<span className="wbs-st">{v.status}</span>
</div>
)
})}
</div>
)
}

1230
src/data/baseline.ts Normal file

File diff suppressed because it is too large Load diff

76
src/lib/appearance.ts Normal file
View file

@ -0,0 +1,76 @@
import { useCallback, useEffect, useState } from 'react'
export type ThemeMode = 'light' | 'dark'
export type Accent = 'blue' | 'violet' | 'emerald' | 'amber'
export const THEME_KEY = 'mega-tracker/theme/v1'
export const ACCENT_KEY = 'mega-tracker/accent/v1'
export const ACCENTS: { id: Accent; label: string }[] = [
{ id: 'blue', label: 'Blue' },
{ id: 'violet', label: 'Violet' },
{ id: 'emerald', label: 'Emerald' },
{ id: 'amber', label: 'Amber' },
]
function isMode(v: string | null): v is ThemeMode {
return v === 'light' || v === 'dark'
}
function isAccent(v: string | null): v is Accent {
return v === 'blue' || v === 'violet' || v === 'emerald' || v === 'amber'
}
/** Used when nothing is stored yet. Set before first paint by the inline script in index.html. */
export const DEFAULT_MODE: ThemeMode = 'dark'
export const DEFAULT_ACCENT: Accent = 'blue'
export function readMode(): ThemeMode {
let v: string | null = null
try {
v = localStorage.getItem(THEME_KEY)
} catch {
/* private mode */
}
return isMode(v) ? v : DEFAULT_MODE
}
export function readAccent(): Accent {
let v: string | null = null
try {
v = localStorage.getItem(ACCENT_KEY)
} catch {
/* private mode */
}
return isAccent(v) ? v : DEFAULT_ACCENT
}
export function applyAppearance(mode: ThemeMode, accent: Accent): void {
const el = document.documentElement
el.dataset.theme = mode
el.dataset.accent = accent
el.style.colorScheme = mode
}
export function useAppearance() {
const [mode, setModeState] = useState<ThemeMode>(() => readMode())
const [accent, setAccentState] = useState<Accent>(() => readAccent())
useEffect(() => {
applyAppearance(mode, accent)
try {
localStorage.setItem(THEME_KEY, mode)
localStorage.setItem(ACCENT_KEY, accent)
} catch {
/* private mode */
}
}, [mode, accent])
const setMode = useCallback((m: ThemeMode) => setModeState(m), [])
const setAccent = useCallback((a: Accent) => setAccentState(a), [])
const toggle = useCallback(
() => setModeState((m) => (m === 'dark' ? 'light' : 'dark')),
[],
)
return { mode, accent, setMode, setAccent, toggle }
}

82
src/lib/dates.ts Normal file
View file

@ -0,0 +1,82 @@
const MS_DAY = 86_400_000
/** Parse YYYY-MM-DD as a UTC midnight Date so day arithmetic never drifts on tz boundaries. */
export function toDate(iso: string): Date {
const [y, m, d] = iso.split('-').map(Number)
return new Date(Date.UTC(y, m - 1, d))
}
export function toISO(d: Date): string {
return d.toISOString().slice(0, 10)
}
export function today(): string {
const n = new Date()
return toISO(new Date(Date.UTC(n.getFullYear(), n.getMonth(), n.getDate())))
}
export function addDays(iso: string, n: number): string {
return toISO(new Date(toDate(iso).getTime() + n * MS_DAY))
}
export function daysBetween(a: string, b: string): number {
return Math.round((toDate(b).getTime() - toDate(a).getTime()) / MS_DAY)
}
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
export function fmtShort(iso: string): string {
const d = toDate(iso)
return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`
}
export function fmtLong(iso: string): string {
const d = toDate(iso)
return `${String(d.getUTCDate()).padStart(2, '0')} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`
}
export function monthLabel(iso: string): string {
const d = toDate(iso)
return `${MONTHS[d.getUTCMonth()]} ${String(d.getUTCFullYear()).slice(2)}`
}
export function monthStart(iso: string): string {
const d = toDate(iso)
return toISO(new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1)))
}
export function monthEnd(iso: string): string {
const d = toDate(iso)
return toISO(new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)))
}
export function addMonths(iso: string, n: number): string {
const d = toDate(iso)
return toISO(new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + n, 1)))
}
export function weekdayShort(iso: string): string {
return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][toDate(iso).getUTCDay()]
}
export function isWeekend(iso: string): boolean {
const g = toDate(iso).getUTCDay()
return g === 0 || g === 6
}
/**
* The Candy barchart anchors its weekly grid to FRIDAY (South African week
* start), so the tick labels read 9, 16, 23, 30 for Oct 2026 rather than
* 5, 12, 19, 26. Preserved so "match PDF" mode lines up with the printout.
*/
export function weekAnchoredToFriday(iso: string): string {
const d = toDate(iso)
const back = (d.getUTCDay() + 2) % 7 // Fri -> 0, Sat -> 1, ... Thu -> 6
return addDays(iso, -back)
}
export function workingDays(a: string, b: string): number {
let n = 0
for (let d = a; d <= b; d = addDays(d, 1)) if (!isWeekend(d)) n++
return n
}

113
src/lib/storage.ts Normal file
View file

@ -0,0 +1,113 @@
import type { ProgressMap, StorageAdapter } from '../types'
const KEY = 'mega-tracker/progress/v1'
/**
* Default. Progress lives in this browser only.
*
* Consequence worth stating plainly: a static deploy means the project owner
* opening the same URL sees the *plan* (baked into the bundle) but NOT your
* progress, because progress never leaves this device. Switch to
* `remoteBlobAdapter` to share.
*/
export const localAdapter: StorageAdapter = {
id: 'local',
label: 'This browser',
description: 'localStorage. Progress stays on this device and is not shared.',
async load() {
try {
const raw = localStorage.getItem(KEY)
return raw ? (JSON.parse(raw) as ProgressMap) : {}
} catch {
return {}
}
},
async save(map) {
localStorage.setItem(KEY, JSON.stringify(map, null, 2))
},
}
export interface RemoteConfig {
/** GET/PUT endpoint holding the whole progress blob as JSON. */
url: string
/** Optional bearer token or API key. Stored in localStorage, never in the bundle. */
token?: string
label?: string
}
const TOKEN_KEY = 'mega-tracker/remote-token/v1'
export function readRemoteToken(): string {
return localStorage.getItem(TOKEN_KEY) ?? ''
}
export function writeRemoteToken(t: string): void {
if (t) localStorage.setItem(TOKEN_KEY, t)
else localStorage.removeItem(TOKEN_KEY)
}
export function hasRemoteConfig(): boolean {
const cfg = localStorage.getItem('mega-tracker/remote-url/v1')
return Boolean(cfg)
}
export function writeRemoteUrl(u: string): void {
if (u) localStorage.setItem('mega-tracker/remote-url/v1', u)
else localStorage.removeItem('mega-tracker/remote-url/v1')
}
export function readRemoteUrl(): string {
return localStorage.getItem('mega-tracker/remote-url/v1') ?? ''
}
/**
* Reads and writes the whole progress map as one JSON document.
*
* Deliberately endpoint-agnostic -- any service that accepts GET + PUT of a
* JSON body works, including a single-row PocketBase record:
*
* GET /api/collections/tracker_progress/records/<id>
* PATCH /api/collections/tracker_progress/records/<id>
*
* so this needs no PocketBase SDK and no schema knowledge baked in here.
*/
export function remoteBlobAdapter(cfg: RemoteConfig): StorageAdapter {
const headers = (): Record<string, string> => {
const h: Record<string, string> = { 'Content-Type': 'application/json' }
if (cfg.token) h.Authorization = cfg.token
return h
}
return {
id: 'remote',
label: cfg.label ?? 'Shared store',
description: `Shared JSON at ${cfg.url}`,
async load() {
const r = await fetch(cfg.url, { headers: headers() })
if (r.status === 404) return {}
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`)
const body = (await r.json()) as unknown
if (body && typeof body === 'object' && 'data' in body) {
return (body as { data: ProgressMap }).data
}
return body as ProgressMap
},
async save(map) {
const r = await fetch(cfg.url, {
method: 'PUT',
headers: headers(),
body: JSON.stringify(map),
})
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`)
},
}
}
export function downloadJson(filename: string, data: unknown): void {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}

148
src/lib/store.tsx Normal file
View file

@ -0,0 +1,148 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
import { BASELINE } from '../data/baseline'
import {
hasRemoteConfig,
localAdapter,
readRemoteToken,
readRemoteUrl,
remoteBlobAdapter,
} from './storage'
import { buildIndex, buildViews } from './tree'
import type { ActivityView, BaselineActivity, ProgressMap, ProgressRecord, StorageAdapter } from '../types'
const ACTIVITIES = BASELINE as BaselineActivity[]
interface Store {
activities: BaselineActivity[]
index: ReturnType<typeof buildIndex>
views: Map<string, ActivityView>
progress: ProgressMap
adapter: StorageAdapter
ready: boolean
error: string | null
update: (code: string, patch: Partial<ProgressRecord>) => void
setMany: (map: ProgressMap) => void
reset: () => Promise<void>
reload: () => Promise<void>
}
const Ctx = createContext<Store | null>(null)
function pickAdapter(): StorageAdapter {
if (hasRemoteConfig()) {
return remoteBlobAdapter({ url: readRemoteUrl(), token: readRemoteToken() })
}
return localAdapter
}
export function StoreProvider({ children }: { children: ReactNode }) {
const [progress, setProgress] = useState<ProgressMap>({})
const [ready, setReady] = useState(false)
const [error, setError] = useState<string | null>(null)
const adapterRef = useRef<StorageAdapter>(pickAdapter())
const timer = useRef<number | null>(null)
const index = useMemo(() => buildIndex(ACTIVITIES), [])
const views = useMemo(() => buildViews(ACTIVITIES, progress, index), [progress, index])
const flush = useCallback(async (map: ProgressMap) => {
try {
await adapterRef.current.save(map)
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}, [])
const reload = useCallback(async () => {
try {
const m = await adapterRef.current.load()
setProgress(m)
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setReady(true)
}
}, [])
useEffect(() => {
void reload()
}, [reload])
const update = useCallback(
(code: string, patch: Partial<ProgressRecord>) => {
setProgress((prev) => {
const cur = prev[code] ?? { status: 'not-started' as const, percent: 0, note: '' }
const next: ProgressRecord = { ...cur, ...patch, updatedAt: new Date().toISOString() }
// Keep status and percent consistent so the rollups never lie.
if (next.status === 'done') next.percent = 100
if (next.status === 'not-started' && patch.status === 'not-started') next.percent = 0
if (next.status === 'in-progress' && !patch.percent && next.percent === 0) next.percent = 10
const map = { ...prev, [code]: next }
if (timer.current) window.clearTimeout(timer.current)
timer.current = window.setTimeout(() => void flush(map), 500)
return map
})
},
[flush],
)
const setMany = useCallback(
(map: ProgressMap) => {
setProgress(map)
void flush(map)
},
[flush],
)
const reset = useCallback(async () => {
setProgress({})
await flush({})
}, [flush])
// Any manual change to the remote config must re-point the adapter.
useEffect(() => {
const h = () => {
adapterRef.current = pickAdapter()
void reload()
}
window.addEventListener('mega-tracker:adapter-changed', h)
return () => window.removeEventListener('mega-tracker:adapter-changed', h)
}, [reload])
const value = useMemo<Store>(
() => ({
activities: ACTIVITIES,
index,
views,
progress,
adapter: adapterRef.current,
ready,
error,
update,
setMany,
reset,
reload,
}),
[index, views, progress, ready, error, update, setMany, reset, reload],
)
return <Ctx.Provider value={value}>{children}</Ctx.Provider>
}
export function useStore(): Store {
const s = useContext(Ctx)
if (!s) throw new Error('useStore must be used inside <StoreProvider>')
return s
}

161
src/lib/tree.ts Normal file
View file

@ -0,0 +1,161 @@
import type { ActivityView, BaselineActivity, ProgressMap, ProgressRecord, Status } from '../types'
import { addDays, daysBetween, today as todayISO } from './dates'
export const EMPTY_PROGRESS: ProgressRecord = {
status: 'not-started',
percent: 0,
note: '',
updatedAt: '',
}
export interface TreeIndex {
byCode: Map<string, BaselineActivity>
childrenOf: Map<string, string[]>
roots: string[]
/** Document order across the whole tree. */
order: string[]
pathOf: Map<string, string[]>
workstreamOf: Map<string, string | null>
}
export function buildIndex(acts: BaselineActivity[]): TreeIndex {
const byCode = new Map(acts.map((a) => [a.code, a]))
const childrenOf = new Map<string, string[]>()
const roots: string[] = []
const order: string[] = []
for (const a of acts) {
order.push(a.code)
if (a.parent === null) {
roots.push(a.code)
childrenOf.set(a.code, [])
} else {
const list = childrenOf.get(a.parent)
if (list) list.push(a.code)
else childrenOf.set(a.parent, [a.code])
}
}
const pathOf = new Map<string, string[]>()
const workstreamOf = new Map<string, string | null>()
const walk = (code: string, path: string[]) => {
pathOf.set(code, path)
// "Workstream" = the deepest meaningful module, capped at level 2.
// Path index maps to level, so index 2 is a level-2 node.
// A110 (lvl3) -> A100 Workforce A260 (lvl2) -> A250 H&S
// B210 (lvl3) -> B200 Running cost B290 (lvl4) -> B280 Overheads
workstreamOf.set(code, path[2] ?? path[1] ?? code)
for (const c of childrenOf.get(code) ?? []) walk(c, [...path, code])
}
for (const r of roots) walk(r, [])
return { byCode, childrenOf, roots, order, pathOf, workstreamOf }
}
function rollupStatus(childStatuses: Status[], ownStatus: Status): Status {
if (childStatuses.length === 0) return ownStatus
if (childStatuses.every((s) => s === 'done')) return 'done'
if (childStatuses.includes('blocked')) return 'blocked'
if (childStatuses.includes('in-progress') || childStatuses.includes('done')) return 'in-progress'
return 'not-started'
}
/**
* Joins baseline + progress and derives rollups.
*
* Percent rolls up duration-weighted using spanDays (derived from the real
* dates) rather than the PDF's "Durn" column, which is wrong for 13 of the
* 100 activities.
*/
export function buildViews(
acts: BaselineActivity[],
progress: ProgressMap,
idx: TreeIndex,
soonDays = 14,
): Map<string, ActivityView> {
const t = todayISO()
const raw = new Map<string, ActivityView>()
for (const act of acts) {
const p = progress[act.code] ?? EMPTY_PROGRESS
raw.set(act.code, {
act,
progress: p,
percent: p.percent,
status: p.status,
isLeaf: (idx.childrenOf.get(act.code) ?? []).length === 0,
children: idx.childrenOf.get(act.code) ?? [],
path: idx.pathOf.get(act.code) ?? [],
workstream: idx.workstreamOf.get(act.code) ?? null,
overdue: false,
dueSoon: false,
inFlight: false,
})
}
// Post-order rollup.
const resolve = (code: string): ActivityView => {
const v = raw.get(code)!
if (v.isLeaf) return v
const kids = v.children.map(resolve)
const totalW = kids.reduce((s, k) => s + Math.max(1, k.act.spanDays), 0)
v.percent = Math.round(
kids.reduce((s, k) => s + k.percent * Math.max(1, k.act.spanDays), 0) / (totalW || 1),
)
v.status = rollupStatus(
kids.map((k) => k.status),
v.progress.status,
)
return v
}
for (const r of idx.roots) resolve(r)
// Date-derived flags, using each activity's own window.
for (const v of raw.values()) {
const done = v.status === 'done'
v.overdue = !done && v.act.finish < t
v.dueSoon = !done && v.act.finish >= t && v.act.finish <= addDays(t, soonDays)
v.inFlight = !done && v.act.start <= addDays(t, soonDays)
}
return raw
}
/** Depth-first flatten, honouring a set of collapsed codes. */
export function flatten(
idx: TreeIndex,
collapsed: Set<string>,
includeParents: boolean,
): string[] {
const out: string[] = []
const walk = (code: string) => {
const kids = idx.childrenOf.get(code) ?? []
const isLeaf = kids.length === 0
if (includeParents || !isLeaf) out.push(code)
if (collapsed.has(code)) return
for (const c of kids) walk(c)
}
for (const r of idx.roots) walk(r)
return out
}
export interface ScheduleSpan {
start: string
finish: string
totalDays: number
}
/** Axis bounds for the Gantt, always wide enough to show the today marker. */
export function scheduleSpan(acts: BaselineActivity[]): ScheduleSpan {
const t = todayISO()
let start = acts[0].start
let finish = acts[0].finish
for (const a of acts) {
if (a.start < start) start = a.start
if (a.finish > finish) finish = a.finish
}
// Pad so the today line is visible even before kickoff.
const lo = t < start ? addDays(t, -10) : addDays(start, -7)
const hi = finish > t ? addDays(finish, 14) : addDays(t, 30)
return { start: lo, finish: hi, totalDays: daysBetween(lo, hi) + 1 }
}

9
src/main.tsx Normal file
View file

@ -0,0 +1,9 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

371
src/styles.css Normal file
View file

@ -0,0 +1,371 @@
/* ============================================================
Mega Infrastructure — Project Tracker
Colour is entirely token-driven. Themes are selected by
[data-theme] on <html>, accents by [data-accent].
============================================================ */
:root {
/* --- surfaces & text --- */
--bg: #f5f6f8;
--surface: #ffffff;
--surface-2: #fafbfc;
--text: #14171c;
--muted: #6a7280;
/* --- lines --- */
--border: #e3e6ea;
--border-2: #eef0f3;
--grid-major: #d3d8de;
/* --- status --- */
--ok: #15803d;
--ok-bg: #dcfce7;
--ok-fg: #166534;
--bad: #dc2626;
--bad-bg: #fee2e2;
--bad-fg: #991b1b;
--warn-bg: #fef3c7;
--warn-fg: #92400e;
--soon-bg: #ede9fe;
--soon-fg: #5b21b6;
--note-bg: #fffbeb;
--note-border: #fde68a;
/* --- gantt bars --- */
--bar-planned: #9aa4b2;
--bar-active: var(--accent);
--bar-blocked: var(--bad);
--bar-done: var(--ok);
--bar-fill: rgba(15, 23, 42, 0.3);
--today: #ef4444;
--on-accent: #ffffff;
--overlay: rgba(16, 24, 40, 0.38);
--shadow: 0 1px 2px rgba(16, 24, 40, 0.06), 0 1px 3px rgba(16, 24, 40, 0.04);
--radius: 8px;
}
/* ---------------- accents (light) ---------------- */
[data-accent="blue"] { --accent: #2563eb; --accent-soft: #e8f0fe; --accent-ring: rgba(37, 99, 235, 0.3); }
[data-accent="violet"] { --accent: #7c3aed; --accent-soft: #f1eafe; --accent-ring: rgba(124, 58, 237, 0.3); }
[data-accent="emerald"] { --accent: #047857; --accent-soft: #dcfce7; --accent-ring: rgba(4, 120, 87, 0.3); }
[data-accent="amber"] { --accent: #b45309; --accent-soft: #fef3c7; --accent-ring: rgba(180, 83, 9, 0.3); }
/* ---------------- accents (dark) ---------------- */
[data-theme="dark"][data-accent="blue"] { --accent: #60a5fa; --accent-soft: rgba(96, 165, 250, 0.16); --accent-ring: rgba(96, 165, 250, 0.4); }
[data-theme="dark"][data-accent="violet"] { --accent: #a78bfa; --accent-soft: rgba(167, 139, 250, 0.16); --accent-ring: rgba(167, 139, 250, 0.4); }
[data-theme="dark"][data-accent="emerald"] { --accent: #34d399; --accent-soft: rgba(52, 211, 153, 0.16); --accent-ring: rgba(52, 211, 153, 0.4); }
[data-theme="dark"][data-accent="amber"] { --accent: #fbbf24; --accent-soft: rgba(251, 191, 36, 0.16); --accent-ring: rgba(251, 191, 36, 0.4); }
/* ---------------- dark theme ---------------- */
[data-theme="dark"] {
--bg: #0d1017;
--surface: #151922;
--surface-2: #1b202b;
--text: #e7eaf0;
--muted: #949db0;
--border: #262c39;
--border-2: #1e2430;
--grid-major: #333b49;
--ok: #4ade80;
--ok-bg: rgba(74, 222, 128, 0.15);
--ok-fg: #86efac;
--bad: #f87171;
--bad-bg: rgba(248, 113, 113, 0.15);
--bad-fg: #fca5a5;
--warn-bg: rgba(251, 191, 36, 0.15);
--warn-fg: #fcd34d;
--soon-bg: rgba(167, 139, 250, 0.18);
--soon-fg: #c4b5fd;
--note-bg: rgba(251, 191, 36, 0.09);
--note-border: rgba(251, 191, 36, 0.3);
--bar-planned: #59626f;
--bar-fill: rgba(0, 0, 0, 0.45);
--today: #f87171;
--on-accent: #0d1017;
--overlay: rgba(0, 0, 0, 0.58);
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
}
/* ============================================================ */
* { box-sizing: border-box; }
html, body, #root { height: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.45 ui-sans-serif, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
button, input, select, textarea { font: inherit; color: inherit; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.92em; }
.muted { color: var(--muted); }
.sm { font-size: 12px; }
.error { color: var(--bad); font-size: 12px; margin: 6px 0 0; }
.mismatch { color: var(--warn-fg); font-style: normal; }
.empty { padding: 30px 20px; text-align: center; color: var(--muted); }
::-webkit-scrollbar { width: 11px; height: 11px; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 99px; border: 3px solid var(--bg); }
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
::-webkit-scrollbar-track { background: transparent; }
/* ---------- shell ---------- */
.app { display: flex; flex-direction: column; height: 100%; }
.topbar {
display: flex; align-items: center; gap: 20px;
padding: 10px 20px; background: var(--surface);
border-bottom: 1px solid var(--border); flex: none;
}
.brand { display: flex; flex-direction: column; line-height: 1.25; }
.brand strong { font-size: 15px; letter-spacing: -0.01em; }
.brand span { font-size: 12px; color: var(--muted); }
.topbar nav { display: flex; gap: 2px; }
.topbar nav button {
position: relative; border: 0; background: transparent; cursor: pointer;
padding: 7px 13px; border-radius: 7px; color: var(--muted); font-weight: 500;
}
.topbar nav button:hover { background: var(--surface-2); color: var(--text); }
.topbar nav button.on { background: var(--accent-soft); color: var(--accent); }
.badge {
position: absolute; top: 2px; right: 2px; background: var(--ok); color: var(--on-accent);
font-style: normal; font-size: 10px; line-height: 1; padding: 2px 4px; border-radius: 6px;
}
.topmeta { margin-left: auto; display: flex; align-items: center; gap: 12px; }
.bar { height: 8px; background: var(--border-2); border-radius: 99px; overflow: hidden; flex: 1; min-width: 90px; }
.bar.sm { height: 6px; width: 110px; min-width: 110px; }
.bar > i { display: block; height: 100%; background: var(--accent); border-radius: 99px; transition: width .25s; }
main { flex: 1; min-height: 0; display: flex; flex-direction: column; }
/* ---------- appearance controls ---------- */
.appearance { display: flex; align-items: center; gap: 8px; }
.swatches { display: flex; gap: 5px; }
.swatch {
width: 16px; height: 16px; padding: 0; border-radius: 99px;
background: var(--accent); border: 0; cursor: pointer;
box-shadow: 0 0 0 1px var(--border-2);
transition: box-shadow .12s, transform .12s;
}
.swatch:hover { transform: scale(1.12); }
.swatch.on { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--accent); }
.mode-toggle {
display: grid; place-items: center; width: 30px; height: 30px; padding: 0;
border-radius: 7px; color: var(--muted); background: transparent; border: 1px solid transparent;
}
.mode-toggle:hover { background: var(--surface-2); color: var(--text); border-color: var(--border); }
/* ---------- controls ---------- */
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--surface); }
.seg button { border: 0; background: transparent; padding: 6px 11px; cursor: pointer; color: var(--muted); }
.seg button + button { border-left: 1px solid var(--border); }
.seg button.on { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
.seg button.st-done.on { background: var(--ok-bg); color: var(--ok-fg); }
.seg button.st-blocked.on { background: var(--bad-bg); color: var(--bad-fg); }
.seg button.st-in-progress.on { background: var(--accent-soft); color: var(--accent); }
.seg.wide { display: flex; }
.seg.wide button { flex: 1; }
.hint { font-size: 11px; opacity: .7; }
select, .search, .row-form input, textarea {
border: 1px solid var(--border); border-radius: 7px; padding: 6px 9px;
background: var(--surface); color: var(--text);
}
select { cursor: pointer; }
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent-soft); border-color: var(--accent); }
.chk { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); cursor: pointer; white-space: nowrap; }
button {
background: var(--surface); border: 1px solid var(--border); border-radius: 7px;
padding: 6px 11px; cursor: pointer; color: var(--text);
}
button:hover { border-color: var(--muted); }
button.danger { color: var(--bad); border-color: var(--bad-bg); }
button.danger:hover { background: var(--bad-bg); }
.filters { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 20px; background: var(--surface); border-bottom: 1px solid var(--border); flex: none; }
.search { min-width: 220px; flex: 1 1 220px; max-width: 340px; }
.count { margin-left: auto; color: var(--muted); font-size: 12px; }
.pill { display: inline-block; padding: 1px 6px; border-radius: 99px; font-size: 10px; font-weight: 700; background: var(--border-2); color: var(--muted); margin-left: 6px; }
.pill.warn { background: var(--warn-bg); color: var(--warn-fg); }
.pill.bad { background: var(--bad-bg); color: var(--bad-fg); }
.pill.soon { background: var(--soon-bg); color: var(--soon-fg); }
/* ---------- dashboard ---------- */
.dash { padding: 18px 20px 40px; overflow: auto; }
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-bottom: 18px; }
.kpi { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--shadow); display: flex; flex-direction: column; gap: 2px; }
.kpi.big { grid-column: span 2; }
.kpi-val { font-size: 26px; font-weight: 650; letter-spacing: -0.02em; }
.kpi.big .kpi-val { font-size: 34px; }
.kpi-lbl { font-size: 12px; color: var(--muted); }
.kpi .bar { margin-top: 8px; }
.kpi.st-done .kpi-val { color: var(--ok); }
.kpi.st-in-progress .kpi-val { color: var(--accent); }
.kpi.st-blocked .kpi-val { color: var(--bad); }
.dash-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(330px, 1fr)); gap: 12px; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px 16px; box-shadow: var(--shadow); }
.card.wide { grid-column: 1 / -1; }
.card h3 { margin: 0 0 8px; font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); font-weight: 650; }
.card p { margin: 0 0 8px; }
.mini { list-style: none; margin: 0; padding: 0; }
.mini li { display: flex; align-items: center; gap: 8px; padding: 5px 0; border-top: 1px solid var(--border-2); cursor: pointer; }
.mini li:first-child { border-top: 0; }
.mini li:hover { background: var(--surface-2); }
.mini .t-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ws { display: flex; flex-direction: column; gap: 6px; }
.ws-row { display: grid; grid-template-columns: 54px 190px 1fr 42px 110px; align-items: center; gap: 10px; cursor: pointer; padding: 3px 0; }
.ws-row:hover { background: var(--surface-2); }
.ws-code { font-family: ui-monospace, monospace; font-size: 12px; color: var(--muted); }
.ws-name { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ws-pct { text-align: right; font-variant-numeric: tabular-nums; }
.load { display: flex; align-items: flex-end; gap: 6px; height: 130px; padding-top: 8px; }
.load-col { flex: 1; display: flex; flex-direction: column; justify-content: flex-end; align-items: center; height: 100%; gap: 3px; }
.load-bar { width: 100%; background: var(--accent); border-radius: 4px 4px 0 0; min-height: 3px; opacity: .85; }
.load-n { font-size: 11px; color: var(--muted); }
.load-l { font-size: 10px; color: var(--muted); }
/* ---------- gantt ---------- */
.gantt { display: flex; flex-direction: column; min-height: 0; flex: 1; }
.gantt-toolbar { display: flex; align-items: center; gap: 14px; padding: 10px 20px; background: var(--surface); border-bottom: 1px solid var(--border); flex: none; }
.legend { margin-left: auto; display: flex; gap: 12px; }
.legend-item { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--muted); }
.legend-item i { width: 10px; height: 10px; border-radius: 3px; display: inline-block; }
.gantt-scroll { overflow: auto; flex: 1; min-height: 0; background: var(--surface); }
.gantt-inner { width: max-content; position: relative; }
.g-head { display: flex; position: sticky; top: 0; z-index: 5; background: var(--surface); border-bottom: 1px solid var(--border); }
.g-head-label { position: sticky; left: 0; z-index: 6; background: var(--surface); border-right: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; padding: 0 10px; font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 650; flex: none; }
.g-head-track { position: relative; flex: none; }
.g-tick { position: absolute; top: 0; bottom: 0; border-left: 1px solid var(--border-2); display: flex; flex-direction: column; align-items: center; justify-content: center; font-size: 10px; color: var(--muted); overflow: hidden; }
.g-tick-major { border-left-color: var(--grid-major); }
.g-tick em { font-style: normal; font-size: 9px; color: var(--accent); font-weight: 700; }
.g-body { position: relative; }
.g-row { display: flex; border-bottom: 1px solid var(--border-2); }
.g-row:hover .g-label { background: var(--surface-2); }
.g-label { position: sticky; left: 0; z-index: 3; background: var(--surface); border-right: 1px solid var(--border); display: flex; align-items: center; gap: 6px; flex: none; cursor: pointer; overflow: hidden; }
.g-caret { border: 0; background: transparent; padding: 0 2px; color: var(--muted); cursor: pointer; font-size: 10px; line-height: 1; flex: none; }
.g-caret-spacer { display: inline-block; width: 14px; flex: none; }
.g-code { font-family: ui-monospace, monospace; font-size: 11px; color: var(--muted); flex: none; }
.g-code.lvl0 { font-weight: 700; color: var(--text); }
.g-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
.g-pct { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; width: 34px; text-align: right; flex: none; }
.g-track { position: relative; flex: none; }
.g-line { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--border-2); }
.g-bar { position: absolute; top: 7px; height: 16px; border-radius: 4px; cursor: pointer; overflow: hidden; min-width: 3px; opacity: .9; }
.g-bar:hover { opacity: 1; box-shadow: 0 0 0 2px var(--accent-ring); }
.g-bar.summary { top: 10px; height: 10px; border-radius: 2px; opacity: .55; }
.g-bar.st-done { opacity: .55; }
.g-fill { height: 100%; background: var(--bar-fill); pointer-events: none; }
.g-today { position: absolute; top: 0; bottom: 0; width: 0; border-left: 2px solid var(--today); z-index: 4; pointer-events: none; }
.g-today span { position: absolute; top: 2px; left: 3px; font-size: 10px; color: var(--today); font-weight: 700; background: var(--surface); padding: 0 3px; }
/* ---------- todos ---------- */
.todos { flex: 1; min-height: 0; overflow: auto; }
.tgroup { margin: 0 0 6px; }
.tgroup-head { display: flex; justify-content: space-between; padding: 8px 20px 5px; font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 650; position: sticky; top: 0; background: var(--bg); z-index: 2; }
.trow { display: flex; align-items: center; gap: 10px; padding: 6px 20px; border-top: 1px solid var(--border-2); cursor: pointer; background: var(--surface); }
.trow:hover { background: var(--surface-2); }
.tcheck { width: 18px; height: 18px; border-radius: 5px; border: 1.5px solid var(--border); padding: 0; line-height: 1; font-size: 11px; color: var(--on-accent); background: var(--surface); flex: none; }
.st-done .tcheck { background: var(--ok); border-color: var(--ok); }
.st-blocked .tcheck { border-color: var(--bad); }
.t-code { font-family: ui-monospace, monospace; font-size: 11px; color: var(--muted); width: 46px; flex: none; }
.t-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.st-done .t-name { text-decoration: line-through; color: var(--muted); }
.t-dates { font-size: 12px; color: var(--muted); width: 128px; flex: none; font-variant-numeric: tabular-nums; }
.t-status select { padding: 3px 6px; font-size: 12px; }
.t-pct { display: flex; align-items: center; gap: 7px; width: 148px; flex: none; }
.t-pct input[type="range"] { flex: 1; accent-color: var(--accent); }
.t-pct em { font-style: normal; font-size: 11px; color: var(--muted); width: 34px; text-align: right; font-variant-numeric: tabular-nums; }
.t-note { width: 18px; color: var(--muted); flex: none; }
/* ---------- wbs ---------- */
.wbs { flex: 1; min-height: 0; overflow: auto; }
.wbs-head, .wbs-row { display: grid; grid-template-columns: 130px 1fr 74px 74px 92px 46px 92px; gap: 10px; padding: 0 20px; align-items: center; }
.wbs-head { position: sticky; top: 0; background: var(--surface); border-bottom: 1px solid var(--border); font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 650; height: 30px; z-index: 2; }
.wbs-row { border-bottom: 1px solid var(--border-2); height: 28px; cursor: pointer; background: var(--surface); font-size: 13px; }
.wbs-row:hover { background: var(--surface-2); }
.wbs-row.summary { font-weight: 600; }
.wbs-code { display: flex; align-items: center; gap: 4px; font-family: ui-monospace, monospace; font-size: 11px; color: var(--muted); }
.wbs-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center; }
.wbs-pct { text-align: right; font-variant-numeric: tabular-nums; }
.wbs-st { font-size: 11px; color: var(--muted); }
.wbs-row.st-done .wbs-st { color: var(--ok); }
.wbs-row.st-in-progress .wbs-st { color: var(--accent); }
.wbs-row.st-blocked .wbs-st { color: var(--bad); }
/* ---------- data ---------- */
.dataview { padding: 18px 20px 40px; overflow: auto; display: flex; flex-direction: column; gap: 12px; }
.row-form { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; }
.row-form input { flex: 1 1 260px; min-width: 200px; }
details { border-top: 1px solid var(--border-2); padding: 9px 0; }
details:first-of-type { border-top: 0; }
summary { cursor: pointer; padding: 2px 0; }
summary:hover { color: var(--accent); }
.issue { list-style: none; margin: 8px 0 0; padding: 0; }
.issue li { padding: 4px 0 4px 10px; border-left: 2px solid var(--border); cursor: pointer; }
.issue li:hover { border-left-color: var(--accent); background: var(--surface-2); }
.issue code { color: var(--muted); margin-right: 4px; }
/* ---------- panel ---------- */
.panel-backdrop { position: fixed; inset: 0; background: var(--overlay); display: flex; justify-content: flex-end; z-index: 40; }
.panel { width: min(520px, 100%); background: var(--surface); height: 100%; overflow: auto; box-shadow: -8px 0 30px rgba(0, 0, 0, 0.25); display: flex; flex-direction: column; }
.panel header { display: flex; gap: 12px; padding: 18px 20px 12px; border-bottom: 1px solid var(--border); }
.panel header h2 { margin: 2px 0 0; font-size: 17px; letter-spacing: -0.01em; }
.panel-code { font-family: ui-monospace, monospace; font-size: 11px; color: var(--muted); }
.crumb { margin: 6px 0 0; font-size: 11px; color: var(--muted); }
.panel .x { margin-left: auto; align-self: flex-start; border: 0; background: transparent; font-size: 15px; color: var(--muted); }
.panel-body { padding: 16px 20px 40px; display: flex; flex-direction: column; gap: 16px; }
.facts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 0; }
.facts div { background: var(--surface-2); border: 1px solid var(--border-2); border-radius: var(--radius); padding: 9px 11px; }
.facts dt { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.facts dd { margin: 3px 0 0; font-size: 13px; }
.panel-kids { border: 1px solid var(--border); border-radius: var(--radius); padding: 11px 13px; }
.panel-kids h4 { margin: 0 0 6px; font-size: 12px; color: var(--muted); font-weight: 600; }
.panel-kids ul { list-style: none; margin: 0; padding: 0; }
.panel-kids li { padding: 3px 0; font-size: 13px; }
.panel-kids code { color: var(--muted); margin-right: 5px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field label { font-size: 12px; color: var(--muted); font-weight: 600; }
.field input[type="range"] { accent-color: var(--accent); }
textarea { resize: vertical; font-family: inherit; }
.panel-flags { background: var(--note-bg); border: 1px solid var(--note-border); border-radius: var(--radius); padding: 10px 12px; font-size: 12.5px; }
.panel-flags p { margin: 0 0 6px; }
.panel-flags p:last-child { margin-bottom: 0; }
@media (max-width: 760px) {
.topbar { flex-wrap: wrap; gap: 10px; }
.t-dates, .t-pct { display: none; }
.kpi.big { grid-column: span 2; }
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; }
}

72
src/types.ts Normal file
View file

@ -0,0 +1,72 @@
export type Status = 'not-started' | 'in-progress' | 'blocked' | 'done'
export const STATUSES: Status[] = ['not-started', 'in-progress', 'blocked', 'done']
export const STATUS_LABEL: Record<Status, string> = {
'not-started': 'Not started',
'in-progress': 'In progress',
blocked: 'Blocked',
done: 'Done',
}
export type Flag =
| 'unconfirmed'
| 'stretched'
| 'duration-mismatch'
| 'name-corrected'
export interface BaselineActivity {
code: string
name: string
originalName: string
parent: string | null
level: number
/** Working-day count exactly as printed in the PDF's "Durn" column. */
plannedDuration: number
/** Working days actually spanned by start -> finish. Differs from the PDF for 13 activities. */
spanDays: number
start: string
finish: string
flags: Flag[]
}
export interface ProgressRecord {
status: Status
/** 0-100 */
percent: number
note: string
actualStart?: string
actualFinish?: string
updatedAt: string
}
export type ProgressMap = Record<string, ProgressRecord>
/** An activity joined with its rollup and its progress record. */
export interface ActivityView {
act: BaselineActivity
progress: ProgressRecord
/** Duration-weighted across children, or the leaf's own percent. */
percent: number
status: Status
isLeaf: boolean
children: string[]
/** Ancestor codes, root first. */
path: string[]
/** Activity code at level 1 (the workstream), if any. */
workstream: string | null
/** Plan finish already passed and not done. */
overdue: boolean
/** Finishes within the next `n` days and not done. */
dueSoon: boolean
/** Started or finishes before today. */
inFlight: boolean
}
export interface StorageAdapter {
id: string
label: string
description: string
load: () => Promise<ProgressMap>
save: (map: ProgressMap) => Promise<void>
}

20
tsconfig.json Normal file
View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

10
vite.config.ts Normal file
View file

@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// Port 4004 deliberately -- 3000 is taken by another local app.
export default defineConfig({
plugins: [react()],
server: { port: 4004, host: true },
preview: { port: 4004, host: true },
build: { outDir: 'dist', sourcemap: false },
})