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:
commit
471cf6fb3c
30 changed files with 5878 additions and 0 deletions
11
.dockerignore
Normal file
11
.dockerignore
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
node_modules
|
||||
dist
|
||||
tsconfig.tsbuildinfo
|
||||
.git
|
||||
.gitignore
|
||||
.vscode
|
||||
.idea
|
||||
*.md
|
||||
.DS_Store
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
dist
|
||||
tsconfig.tsbuildinfo
|
||||
.DS_Store
|
||||
*.local
|
||||
28
Dockerfile
Normal file
28
Dockerfile
Normal 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
136
README.md
Normal 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
30
index.html
Normal 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
59
nginx.conf
Normal 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
1750
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
23
package.json
Normal file
23
package.json
Normal 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
6
public/favicon.svg
Normal 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
147
scripts/gen_baseline.py
Normal 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
160
scripts/parse_pdf.py
Normal 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
77
src/App.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
173
src/components/ActivityPanel.tsx
Normal file
173
src/components/ActivityPanel.tsx
Normal 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>
|
||||
}
|
||||
162
src/components/Dashboard.tsx
Normal file
162
src/components/Dashboard.tsx
Normal 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
237
src/components/DataView.tsx
Normal 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 & 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
220
src/components/Gantt.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
src/components/ThemeControls.tsx
Normal file
51
src/components/ThemeControls.tsx
Normal 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
214
src/components/Todos.tsx
Normal 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
97
src/components/Wbs.tsx
Normal 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
1230
src/data/baseline.ts
Normal file
File diff suppressed because it is too large
Load diff
76
src/lib/appearance.ts
Normal file
76
src/lib/appearance.ts
Normal 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
82
src/lib/dates.ts
Normal 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
113
src/lib/storage.ts
Normal 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
148
src/lib/store.tsx
Normal 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
161
src/lib/tree.ts
Normal 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
9
src/main.tsx
Normal 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
371
src/styles.css
Normal 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
72
src/types.ts
Normal 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
20
tsconfig.json
Normal 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
10
vite.config.ts
Normal 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 },
|
||||
})
|
||||
Loading…
Reference in a new issue