Compare commits
10 Commits
2c3dc66878
...
47855c0332
| Author | SHA1 | Date | |
|---|---|---|---|
| 47855c0332 | |||
| 0854bbc3c5 | |||
| f4dc01916e | |||
| 88f2bf3782 | |||
| 18da00addf | |||
| f68b897562 | |||
| 3df15b3520 | |||
| e30ef72769 | |||
| fac6e7f356 | |||
| f1200b7d79 |
24
.env.example
24
.env.example
@@ -8,12 +8,28 @@ MOCK_AI=false
|
||||
# Required for the AI draft button, unless MOCK_AI=true.
|
||||
ANTHROPIC_API_KEY=your-anthropic-api-key-here
|
||||
|
||||
# Required for company deployment. Leave both blank for solo local use with
|
||||
# no login prompt. Set both to require a login for anyone reaching this tool.
|
||||
# Named users. Comma-separated user:password pairs. Each name doubles as the
|
||||
# identity used for the per-user token quota below.
|
||||
# Example: APP_USERS=alice:pass1,bob:pass2,carol:pass3
|
||||
# Leave blank for solo local use with no login prompt (usage is then tracked
|
||||
# under the identity "local").
|
||||
APP_USERS=
|
||||
|
||||
# Legacy single shared login. Only used if APP_USERS is blank. Everyone who
|
||||
# logs in with this pair shares one identity and one token quota.
|
||||
APP_USERNAME=
|
||||
APP_PASSWORD=
|
||||
|
||||
# Optional. Caps AI draft calls per source IP, to limit cost from the shared
|
||||
# ANTHROPIC_API_KEY. Defaults: 20 requests per 5 minutes.
|
||||
# Optional. Caps AI draft calls per source IP, to catch a runaway script
|
||||
# fast. Defaults: 20 requests per 5 minutes.
|
||||
RATE_LIMIT_MAX=20
|
||||
RATE_LIMIT_WINDOW_MS=300000
|
||||
|
||||
# On the back burner: off by default. Caps combined input+output tokens per
|
||||
# named user, per UTC calendar day. Leave at 0 (or unset) for unlimited,
|
||||
# which also skips writing TOKEN_USAGE_FILE. Set a positive number (for
|
||||
# example 50000, roughly 100-200 AI drafts with this tool's prompt size) to
|
||||
# turn it back on. Usage then persists across restarts if TOKEN_USAGE_FILE's
|
||||
# folder is a mounted volume.
|
||||
TOKEN_LIMIT_PER_USER=0
|
||||
TOKEN_USAGE_FILE=./data/token-usage.json
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ node_modules/
|
||||
Thumbs.db
|
||||
*.log
|
||||
.env
|
||||
/data/
|
||||
|
||||
27
CHANGELOG.md
Normal file
27
CHANGELOG.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are logged here, newest first. This file starts from the point the toolkit was handed off and set up in this repo.
|
||||
|
||||
## 2026-08-24
|
||||
|
||||
- Changed: enlarged the Breadcrumbs working area. The selected breadcrumb's editor card now has more padding, a taller minimum height, and taller text fields, so it uses more of the page instead of sitting as a small box.
|
||||
- Changed: the breadcrumb delete button now reads "Delete" instead of a bare "✕".
|
||||
- Fixed: every delete action now shows a confirmation before it deletes. Replaced the browser's plain `confirm()` popup with a custom on-brand modal (a card with a red left accent, matching the app's existing "attention" styling, on a dimmed backdrop). Covers cluster delete, issue delete, breadcrumb delete, action delete, scope item delete, priority-rank item delete, decision delete, and both Reset buttons. Several of these had no confirmation at all before this change.
|
||||
- Fixed: the new breadcrumb list was rendering as single-character vertical columns instead of a normal list. Cause: the list used a `<nav>` tag, which collided with the app's existing `nav{display:flex}` rule written for the top tab bar. Changed the element to a `<div>`.
|
||||
- Fixed: the app was capped at `max-width:1500px` and centered, wasting space on wide screens. Removed the cap so the app fills the browser width.
|
||||
- Changed: redesigned the Breadcrumbs panel in the Field Problem Workshop tool. Previously every breadcrumb card showed all 7 fields fully expanded at once, for every breadcrumb, all the time. Replaced with a list-plus-detail split view: a compact status-dot list on the left, the full field editor for one selected breadcrumb on the right.
|
||||
|
||||
## 2026-08-21
|
||||
|
||||
- Added: `UNIFIED_TOOL_ROADMAP.md`, a design and phased-rollout plan for eventually merging the Field Problem Workshop and the Scope Lock Meeting Suite onto one backend with a shared database. Planning only; not built.
|
||||
- Changed: turned the per-user daily token quota off by default (`TOKEN_LIMIT_PER_USER=0`). A cost analysis against the tool's own baseline usage showed real spend at stake was small enough that the quota wasn't worth the added complexity yet. The code stays in place and can be re-enabled with one setting.
|
||||
- Added: a per-user daily token quota (`TOKEN_LIMIT_PER_USER`), tracked per named user in `APP_USERS` and persisted to `data/token-usage.json`, mounted as a Docker volume so usage survives a container restart.
|
||||
- Added: `MOCK_AI` mode. Set `MOCK_AI=true` to test the AI draft button, the login gate, and the token quota with a canned response, no API key and no cost.
|
||||
|
||||
## 2026-08-20
|
||||
|
||||
- Added: `DOCKER_TESTING.md`, a step-by-step guide for standing up and testing the Docker environment.
|
||||
- Added: a shared login gate (`APP_USERS`, or legacy `APP_USERNAME`/`APP_PASSWORD`) and per-IP rate limiting (`RATE_LIMIT_MAX` / `RATE_LIMIT_WINDOW_MS`) ahead of running this for the whole company on the internal network.
|
||||
- Added: `Dockerfile`, `.dockerignore`, `docker-compose.yml` to containerize the toolkit, so it can run standalone or alongside the Work Package Suite container.
|
||||
- Fixed: the AI draft button returned "Failed to fetch." Cause: it called the Anthropic API directly from the browser with no key, which the browser's CORS policy blocks. Added `server.js`, a local Node server that serves the two tools and proxies `/api/claude` to Anthropic server-side, so the API key never reaches the browser. Updated the button in `field-problem-workshop.html` to call `/api/claude` instead of `api.anthropic.com` directly.
|
||||
- Added: initial import of the toolkit into this Git repository — `README.md`, `QUICKSTART.md`, `package.json`, `.gitignore`, the two tool files under `tools/`, and the breadcrumb-methodology skill under `skills/`.
|
||||
@@ -26,14 +26,15 @@ If either command fails, install Docker before you continue.
|
||||
3. Set a test value for each line:
|
||||
```
|
||||
ANTHROPIC_API_KEY=<a personal or trial key, for testing only>
|
||||
APP_USERNAME=tester
|
||||
APP_PASSWORD=test-password-123
|
||||
APP_USERS=tester:test-password-123,tester2:test-password-456
|
||||
RATE_LIMIT_MAX=5
|
||||
RATE_LIMIT_WINDOW_MS=60000
|
||||
```
|
||||
4. Save the file.
|
||||
|
||||
Use a personal or trial API key here, not the company-billed key. Keep the company key for the real deployment. Set `APP_USERNAME` and `APP_PASSWORD` so you can confirm the login gate works. `RATE_LIMIT_MAX=5` with a 60 second window makes the rate limit easy to trigger on purpose, for testing.
|
||||
Use a personal or trial API key here, not the company-billed key. Keep the company key for the real deployment. `APP_USERS` sets up two logins so you can confirm each person gets their own. `RATE_LIMIT_MAX=5` with a 60 second window makes the rate limit easy to trigger on purpose.
|
||||
|
||||
`TOKEN_LIMIT_PER_USER` is left out of this file on purpose: the per-user daily token quota ships off by default (see Step 9, optional). Leave it out unless you specifically want to test that feature.
|
||||
|
||||
`.env` is not tracked by Git. Docker Compose reads it automatically because `docker-compose.yml` lists it under `env_file`.
|
||||
|
||||
@@ -59,14 +60,14 @@ Use a personal or trial API key here, not the company-billed key. Keep the compa
|
||||
```
|
||||
docker compose logs
|
||||
```
|
||||
4. Confirm the log reports whether it found `ANTHROPIC_API_KEY` and whether a login is required.
|
||||
4. Confirm the log reports whether it found `ANTHROPIC_API_KEY`, how many named users are configured, and the per-user token quota.
|
||||
|
||||
## Step 5: Test in a browser
|
||||
|
||||
1. Open a browser.
|
||||
2. Go to `http://localhost:5173`.
|
||||
3. Confirm the browser asks for a username and password.
|
||||
4. Enter the `APP_USERNAME` and `APP_PASSWORD` values from your `.env` file.
|
||||
4. Enter one of the `APP_USERS` pairs from your `.env` file, for example `tester` / `test-password-123`.
|
||||
5. Confirm the toolkit's landing page loads, with links to both tools.
|
||||
6. Open each tool link and confirm it loads.
|
||||
|
||||
@@ -91,13 +92,30 @@ Use a personal or trial API key here, not the company-billed key. Keep the compa
|
||||
2. Confirm the next click returns a rate-limit message instead of a normal draft or a silent failure.
|
||||
3. Wait for the time window to pass, then confirm the button works again.
|
||||
|
||||
## Step 9: Stop the container
|
||||
## Step 9 (optional): Test the per-user token quota
|
||||
|
||||
Skip this step for a normal test run. The quota ships off by default; this is only for confirming the feature still works if you turn it back on.
|
||||
|
||||
1. Add `TOKEN_LIMIT_PER_USER=2000` to `.env` and restart: `docker compose up -d --build`.
|
||||
2. Log in as `tester` and click the AI draft button once or twice, until the response reports a quota error instead of a draft.
|
||||
3. Confirm the error names `tester`, the tokens used, the limit, and a countdown to the reset.
|
||||
4. Open a new private or incognito browser window and log in as `tester2` instead.
|
||||
5. Confirm `tester2` can still click the AI draft button. Each named user has a separate quota.
|
||||
6. Run this command to view the usage file directly:
|
||||
```
|
||||
cat data/token-usage.json
|
||||
```
|
||||
7. Confirm it lists a separate entry for each user who made a call, with today's date and a token count.
|
||||
8. Run `docker compose restart`, then confirm `tester` is still blocked. The quota survives a restart because `data` is a mounted volume.
|
||||
9. Remove `TOKEN_LIMIT_PER_USER` from `.env` and restart again to turn it back off.
|
||||
|
||||
## Step 10: Stop the container
|
||||
|
||||
1. Run this command:
|
||||
```
|
||||
docker compose down
|
||||
```
|
||||
2. This stops and removes the container. It does not delete your `.env` file or the project folder.
|
||||
2. This stops and removes the container. It does not delete your `.env` file, your `data` folder, or the project folder.
|
||||
|
||||
## Rebuild after a code change
|
||||
|
||||
@@ -114,7 +132,10 @@ Use a personal or trial API key here, not the company-billed key. Keep the compa
|
||||
Stop whatever else is using that port, or change the port mapping in `docker-compose.yml` from `"5173:5173"` to, for example, `"5180:5173"`. Then open `http://localhost:5180` instead.
|
||||
|
||||
**The browser does not ask for a login.**
|
||||
Check that both `APP_USERNAME` and `APP_PASSWORD` are set in `.env`, with no typos in the variable names. Restart the container after any `.env` change: `docker compose up -d --build`.
|
||||
Check that `APP_USERS` is set in `.env` (or the legacy `APP_USERNAME`/`APP_PASSWORD` pair), with no typos in the variable names. Restart the container after any `.env` change: `docker compose up -d --build`.
|
||||
|
||||
**Everyone seems to share one quota, or a user's quota did not reset the next day.**
|
||||
Check that each person has their own entry in `APP_USERS`, not one shared `APP_USERNAME`/`APP_PASSWORD`. Quota resets happen on UTC calendar days, which may be a few hours off from your local midnight.
|
||||
|
||||
**The AI draft button reports a missing key.**
|
||||
Check that `ANTHROPIC_API_KEY` is set in `.env` and is a real key. Restart the container after the change.
|
||||
|
||||
26
README.md
26
README.md
@@ -128,32 +128,40 @@ The `.gitignore` file excludes `node_modules` and common system files from commi
|
||||
|
||||
## Deploy for the whole company
|
||||
|
||||
Use this when the tool needs to be reachable by anyone on the internal network or VPN, not just on one person's machine. This uses a company-owned Anthropic API key shared by everyone who reaches the tool, so it adds a login gate and a request cap that a solo local setup does not need.
|
||||
Use this when the tool needs to be reachable by anyone on the internal network or VPN, not just on one person's machine. This uses a company-owned Anthropic API key shared by everyone who reaches the tool, so it adds a per-person login and a request cap that a solo local setup does not need.
|
||||
|
||||
1. Get an Anthropic API key billed to a company account, not a personal one. IT or finance should provision this, since it is billed like any other company vendor cost.
|
||||
2. Pick a shared username and password for this tool. This is a simple login gate, not a full identity system. It exists so the tool is not reachable by anyone who is merely on the same network segment, but treat the VPN and internal network as the primary control, not this login.
|
||||
2. Decide who needs their own login. Each name gets its own password.
|
||||
3. On the host or container platform, set these values as environment variables, or in a `.env` file next to `docker-compose.yml`:
|
||||
```
|
||||
ANTHROPIC_API_KEY=<company key>
|
||||
APP_USERNAME=<shared username>
|
||||
APP_PASSWORD=<shared password>
|
||||
APP_USERS=alice:pass1,bob:pass2,carol:pass3
|
||||
```
|
||||
4. Build and run the container:
|
||||
```
|
||||
docker compose up -d --build
|
||||
```
|
||||
5. To run this alongside the existing Work Package Suite container instead of on its own, copy the `sde-meeting-toolkit` service block from `docker-compose.yml` into that stack's compose file, and apply the same environment variables there.
|
||||
6. Confirm the login prompt appears when you open the tool's URL from another machine on the network.
|
||||
5. To run this alongside the existing Work Package Suite container instead of on its own, copy the `sde-meeting-toolkit` service block from `docker-compose.yml` into that stack's compose file, including its `volumes` entry, and apply the same environment variables there.
|
||||
6. Confirm the login prompt appears when you open the tool's URL from another machine on the network, and that it accepts one of the named user/password pairs.
|
||||
|
||||
### What the login gate does and does not do
|
||||
|
||||
- It requires a username and password before any page or API call on this tool succeeds.
|
||||
- It requires a username and password before any page or API call on this tool succeeds, and identifies which named user made each AI draft call.
|
||||
- It does not encrypt traffic on its own. Run this behind the same network and VPN protections used for the Work Package Suite, and add TLS at the reverse proxy or load balancer if one is already in place for that stack.
|
||||
- It does not track who made which AI draft request. Every user shares one login and one API key. If per-person attribution matters later, that needs a real identity integration, which is a larger change than this tool currently supports.
|
||||
- It is a shared-credential list, not a real identity system. Anyone who has a name's password can use that name's quota. If real single-sign-on attribution matters later, that needs a larger integration than this tool currently supports.
|
||||
- Old single-shared-login setups still work: set `APP_USERNAME` and `APP_PASSWORD` instead of `APP_USERS` if you want everyone to share one login and one quota, unchanged from before this feature existed.
|
||||
|
||||
### Per-user token quota (built, currently off)
|
||||
|
||||
This tool can cap combined input and output tokens per named user, per UTC calendar day, but it ships disabled. A rough cost analysis against this tool's own baseline usage (a workshop session drafts around 6 to 10 breadcrumbs, using roughly 15 to 25 percent of a 50000-token daily allowance) showed the spend at stake is small enough, and the number of people and sessions low enough, that the quota was not worth the added complexity for now. The code stays in place in case usage grows.
|
||||
|
||||
To turn it on: set `TOKEN_LIMIT_PER_USER` to a positive number of tokens (for example 50000) in `.env`. Leave it unset or `0` for unlimited, which is the default, and which also skips writing a usage file at all.
|
||||
|
||||
Once enabled, usage is written to `TOKEN_USAGE_FILE` (default `./data/token-usage.json`) after every AI call. In Docker, `docker-compose.yml` already mounts `./data` as a volume, so usage survives a restart or redeploy once you turn this on. A user who hits the cap gets a clear error naming their usage and the time until reset, instead of a silent failure or an unexplained cost. To reset one person's quota early, stop the container, edit their entry out of the usage file (or set its `date` to any past date), and restart. To raise or lower the cap for everyone, change `TOKEN_LIMIT_PER_USER` and restart; the change applies from that point on, not retroactively.
|
||||
|
||||
### Rate limit
|
||||
|
||||
`RATE_LIMIT_MAX` and `RATE_LIMIT_WINDOW_MS` cap AI draft requests per source IP address, to prevent a leaked link or a stuck script from running up cost on the shared key. Defaults: 20 requests per 5 minutes. Raise these in `.env` if real usage hits the limit; the tool returns a clear rate-limit error rather than failing silently.
|
||||
`RATE_LIMIT_MAX` and `RATE_LIMIT_WINDOW_MS` cap AI draft requests per source IP address, independent of the token quota. This catches a runaway script in the first few seconds, before it could burn through a whole day's token quota. Defaults: 20 requests per 5 minutes.
|
||||
|
||||
## Relation to the Work Package Suite
|
||||
|
||||
|
||||
102
UNIFIED_TOOL_ROADMAP.md
Normal file
102
UNIFIED_TOOL_ROADMAP.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Roadmap: One Unified Tool with a Backing Database
|
||||
|
||||
This is a plan, not code yet. It sets direction for merging the Field Problem Workshop and the Scope Lock Meeting Suite into one tool, backed by a database. Decisions already made: a separate, lightweight database for this toolkit (not shared with the Work Package Suite), and a single-facilitator-at-a-time model (no real-time multi-user sync).
|
||||
|
||||
## Why integrate at all
|
||||
|
||||
Today the two tools do not connect. Each holds its own state in the browser, and a person carries results from one into the other by hand. The one piece of real evidence that they are meant to connect: four of the seven baseline clusters in the Field Problem Workshop already carry a candidate-solution tag, for example "Tracking MVP (pilot)" on the Progress Visibility cluster, and "AWP Process RFP" on two others. That tag is exactly the kind of item the Scope Lock Meeting Suite sorts into In MVP, Later Phase, Out, or Parking Lot. A database turns that tag from a note into a real link.
|
||||
|
||||
A database also gives you two things the current JSON-file model cannot: a history of every session ever run, not just the one currently loaded, and the ability to query and report across sessions instead of opening files one at a time.
|
||||
|
||||
## Target shape
|
||||
|
||||
One backend, one database, two frontends that call it instead of holding state locally. A "pilot" or "project" record ties a Workshop session to its paired Scope Lock session, so the link that exists today only as a text tag becomes a real relationship.
|
||||
|
||||
## Data model
|
||||
|
||||
SQLite is enough for this scale (a handful of sessions a month, one facilitator at a time). No separate database server to run or patch.
|
||||
|
||||
```
|
||||
projects
|
||||
id, name, created_at
|
||||
-- e.g. "Micron EUV"
|
||||
|
||||
workshop_sessions
|
||||
id, project_id -> projects.id, name, created_at, facilitator_user
|
||||
|
||||
clusters
|
||||
id, workshop_session_id -> workshop_sessions.id, name, description, candidate_solution
|
||||
-- candidate_solution is today's free-text tag ("Tracking MVP (pilot)")
|
||||
|
||||
problems
|
||||
id, cluster_id -> clusters.id, text, votes
|
||||
|
||||
breadcrumbs
|
||||
id, cluster_id -> clusters.id, type, driver, cap, metric, method, baseline, ai_drafted (bool)
|
||||
|
||||
actions
|
||||
id, workshop_session_id -> workshop_sessions.id, text, owner, due_date, done
|
||||
|
||||
scope_sessions
|
||||
id, project_id -> projects.id, name, created_at, facilitator_user
|
||||
|
||||
scope_items
|
||||
id, scope_session_id -> scope_sessions.id, name, column
|
||||
-- column is one of: unsorted, in_mvp, later_phase, out, parking_lot
|
||||
source_cluster_id -> clusters.id, nullable
|
||||
-- set when an item was promoted from a Workshop cluster's candidate_solution tag,
|
||||
-- instead of typed fresh into the Scope Lock board
|
||||
|
||||
priority_rank
|
||||
scope_item_id -> scope_items.id, rank
|
||||
|
||||
decisions
|
||||
id, scope_session_id -> scope_sessions.id, text, decided_at, decided_by
|
||||
|
||||
readiness_checks
|
||||
id, scope_session_id -> scope_sessions.id, item, status, notes
|
||||
```
|
||||
|
||||
`source_cluster_id` is the whole point of unifying these tools. Today, someone reads "Tracking MVP (pilot)" off the Workshop screen and retypes it into the Scope Lock board. With this link, the Scope Lock session can offer a "Promote from Workshop" action that lists every cluster carrying a candidate-solution tag from the paired Workshop session, and creates a `scope_item` with `source_cluster_id` set. From then on, the scope item and the breadcrumb evidence that justified it are one clickable hop apart, not two separate exports.
|
||||
|
||||
## API surface
|
||||
|
||||
A REST endpoint per resource above: list, get, create, update, delete. A few extras beyond plain CRUD:
|
||||
|
||||
- `POST /api/scope-items/promote` — body: `{cluster_id}`. Creates a scope item pre-filled from that cluster's name and candidate-solution tag.
|
||||
- `GET /api/projects/:id/summary` — pulls both sessions' data together for a single export, replacing the two separate "Export summary" buttons with one that shows the full picture: problems, breadcrumbs, and the scope decisions made from them.
|
||||
- The existing `/api/claude` proxy carries over unchanged. It has nothing to do with persistence.
|
||||
|
||||
Reuse the login already built (`APP_USERS`) to identify `facilitator_user` and `decided_by`. No new auth work needed.
|
||||
|
||||
## Frontend approach
|
||||
|
||||
Both tools are single HTML files that hold state in a JS object and re-render on every change. The lowest-risk migration keeps that shape and swaps what backs it:
|
||||
|
||||
1. On page load, fetch the current session's data from the API instead of reading `DEFAULT_STATE` or `localStorage`.
|
||||
2. Replace the existing `save()` function's body. Today it writes to memory or `localStorage`. It should instead call the matching API endpoint (`PATCH /api/breadcrumbs/:id`, and so on) and update local state from the response.
|
||||
3. Keep Save JSON / Load JSON / Export summary as import-export conveniences around the database, not as the primary way data survives. They are useful for taking a copy of a session offline, or seeding a new session from an old export.
|
||||
|
||||
This avoids a full rewrite of the rendering code, which is most of both files. It touches the data-access edges, not the UI.
|
||||
|
||||
## Migration path for existing data
|
||||
|
||||
1. Write a one-time import script that reads an exported Workshop JSON file (the July 21 baseline, for example) and inserts it as a `workshop_session` with its clusters, problems, breadcrumbs, and actions.
|
||||
2. Do the same for any Scope Lock exports.
|
||||
3. From then on, new sessions are created through the app, not by hand-editing JSON.
|
||||
|
||||
## Phased rollout
|
||||
|
||||
1. **Schema and API, no UI change.** Stand up SQLite, the tables above, and the CRUD endpoints. Test with curl or a REST client. Nothing user-facing changes yet.
|
||||
2. **Migrate the Field Problem Workshop's data access** to the API, per the frontend approach above. Verify Save JSON / Load JSON / Export summary still work, now reading from and writing to the database.
|
||||
3. **Migrate the Scope Lock Meeting Suite** the same way.
|
||||
4. **Build the actual link**: the `source_cluster_id` field, the "Promote from Workshop" action, and the combined project summary export. This is the step that turns two tools into one.
|
||||
5. **Add a project/session list view**: a simple page listing every project and its paired sessions, since the database now holds more than the one session someone currently has open. This is the first piece of value a database gives you that a JSON file never could.
|
||||
6. **Later, if it comes up**: multi-user real-time editing (websockets), re-enabling the per-user token quota now scoped to real sessions instead of just a daily counter, or a tighter identity integration than the shared-credential login.
|
||||
|
||||
## Open decisions for you
|
||||
|
||||
- **Naming**: is "project" the right top-level container, or should it be "pilot," matching the language already in the Scope Lock export ("Pilot: Micron")? Pick the word the team already uses out loud.
|
||||
- **Multiple concurrent projects**: the schema above supports more than one project at a time (Micron plus whatever comes after it). Confirm that is wanted, versus a model that assumes only one active pilot at a time.
|
||||
- **Where the SQLite file lives in Docker**: the same volume-mount pattern already used for `data/token-usage.json` in `docker-compose.yml` works for a database file. No new infrastructure decision needed there.
|
||||
- **Who builds this**: this is a multi-week effort, not an afternoon change, given two 800-plus line HTML files need their data-access layer reworked. Decide whether that is you, a broader team effort, or something to scope out to whoever ends up owning the Work Package Suite integration work too.
|
||||
@@ -14,3 +14,7 @@ services:
|
||||
- "5173:5173"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
# Persists the per-user token quota file across restarts and rebuilds.
|
||||
# Without this, everyone's daily usage silently resets on every deploy.
|
||||
- ./data:/app/data
|
||||
|
||||
167
server.js
167
server.js
@@ -3,6 +3,7 @@
|
||||
* Serves the static tool files.
|
||||
* Proxies AI draft requests to the Anthropic API, so the API key
|
||||
* stays on the server and never appears in the browser.
|
||||
* Identifies each caller by login and enforces a daily token quota per person.
|
||||
*/
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
@@ -49,17 +50,52 @@ function sendJson(res, status, obj) {
|
||||
}
|
||||
|
||||
/* ===== Access control =====
|
||||
* Set APP_USERNAME and APP_PASSWORD (in .env or the container environment)
|
||||
* to require a login for the whole app. Leave both unset for solo local use
|
||||
* with no login prompt. This is a simple shared-credential gate, meant to sit
|
||||
* behind the company VPN and internal network, not to replace them. */
|
||||
* Named users. Set APP_USERS as a comma-separated list of user:password
|
||||
* pairs, for example: APP_USERS=alice:pass1,bob:pass2
|
||||
* Each name is also the identity used for the daily token quota below.
|
||||
*
|
||||
* Legacy single-user mode: set APP_USERNAME and APP_PASSWORD instead. That
|
||||
* name becomes the one identity everyone shares (no per-person quota).
|
||||
*
|
||||
* Leave all of the above unset for solo local use: no login prompt, and
|
||||
* usage is tracked under the identity "local".
|
||||
*
|
||||
* This is a simple shared-credential gate, meant to sit behind the company
|
||||
* VPN and internal network, not to replace them. */
|
||||
function loadUserMap() {
|
||||
const map = {};
|
||||
if (process.env.APP_USERS) {
|
||||
process.env.APP_USERS.split(',').forEach((pair) => {
|
||||
const idx = pair.indexOf(':');
|
||||
if (idx === -1) return;
|
||||
const name = pair.slice(0, idx).trim();
|
||||
const pass = pair.slice(idx + 1).trim();
|
||||
if (name && pass) map[name] = pass;
|
||||
});
|
||||
} else if (process.env.APP_USERNAME && process.env.APP_PASSWORD) {
|
||||
map[process.env.APP_USERNAME] = process.env.APP_PASSWORD;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
const USER_MAP = loadUserMap();
|
||||
const AUTH_REQUIRED = Object.keys(USER_MAP).length > 0;
|
||||
|
||||
// Returns the identified username on success, or null on failure.
|
||||
function checkBasicAuth(req) {
|
||||
const user = process.env.APP_USERNAME;
|
||||
const pass = process.env.APP_PASSWORD;
|
||||
if (!user || !pass) return true;
|
||||
if (!AUTH_REQUIRED) return 'local';
|
||||
const header = req.headers['authorization'] || '';
|
||||
const expected = 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
|
||||
return header === expected;
|
||||
if (!header.startsWith('Basic ')) return null;
|
||||
let decoded;
|
||||
try {
|
||||
decoded = Buffer.from(header.slice(6), 'base64').toString('utf8');
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
const idx = decoded.indexOf(':');
|
||||
if (idx === -1) return null;
|
||||
const name = decoded.slice(0, idx);
|
||||
const pass = decoded.slice(idx + 1);
|
||||
return USER_MAP[name] === pass ? name : null;
|
||||
}
|
||||
function requireAuth(res) {
|
||||
res.writeHead(401, {
|
||||
@@ -70,9 +106,9 @@ function requireAuth(res) {
|
||||
}
|
||||
|
||||
/* ===== Rate limiting for the AI proxy =====
|
||||
* A shared company API key means one leaked link or one runaway script can
|
||||
* generate real cost. This caps AI draft calls per source IP. It resets if
|
||||
* the process restarts; that is an accepted tradeoff for a small internal tool. */
|
||||
* Caps AI draft calls per source IP, independent of the token quota below.
|
||||
* This catches a runaway script quickly, before it burns through a whole
|
||||
* day's token quota in a few seconds. Resets if the process restarts. */
|
||||
const RATE_LIMIT_MAX = parseInt(process.env.RATE_LIMIT_MAX || '20', 10);
|
||||
const RATE_LIMIT_WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS || String(5 * 60 * 1000), 10);
|
||||
const rateLimitHits = new Map();
|
||||
@@ -84,13 +120,72 @@ function isRateLimited(ip) {
|
||||
return hits.length > RATE_LIMIT_MAX;
|
||||
}
|
||||
|
||||
/* ===== Per-user daily token quota (off by default) =====
|
||||
* On the back burner: real usage does not look large enough to justify
|
||||
* running this. Left in place, disabled, in case that changes. Set
|
||||
* TOKEN_LIMIT_PER_USER to a positive number to turn it back on; leave it
|
||||
* unset or 0 for unlimited, which skips the check and the usage file.
|
||||
*
|
||||
* TOKEN_LIMIT_PER_USER caps combined input+output tokens per identified user,
|
||||
* per UTC calendar day. Usage is written to TOKEN_USAGE_FILE after every AI
|
||||
* call, so it survives a container restart. Mount that file's folder as a
|
||||
* volume in Docker or it resets on every redeploy.
|
||||
*
|
||||
* The check runs before the API call using the day's tally so far: if the
|
||||
* user is already at or over the limit, the call is refused with no cost.
|
||||
* A single call in progress when the limit is reached can still push the
|
||||
* tally past the cap by that one call's tokens; the next call is blocked.
|
||||
* That is an accepted tradeoff, since real token cost of a call is only
|
||||
* known once its response returns. */
|
||||
const TOKEN_LIMIT_PER_USER = parseInt(process.env.TOKEN_LIMIT_PER_USER || '0', 10);
|
||||
const TOKEN_LIMIT_ENABLED = TOKEN_LIMIT_PER_USER > 0;
|
||||
const TOKEN_USAGE_FILE = process.env.TOKEN_USAGE_FILE || path.join(ROOT, 'data', 'token-usage.json');
|
||||
|
||||
function todayUTC() {
|
||||
return new Date().toISOString().slice(0, 10); // "YYYY-MM-DD"
|
||||
}
|
||||
function loadUsage() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(TOKEN_USAGE_FILE, 'utf8'));
|
||||
} catch (err) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
function saveUsage(usage) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(TOKEN_USAGE_FILE), { recursive: true });
|
||||
fs.writeFileSync(TOKEN_USAGE_FILE, JSON.stringify(usage));
|
||||
} catch (err) {
|
||||
console.error('Could not write token usage file:', err.message);
|
||||
}
|
||||
}
|
||||
function usedTokensToday(usage, user) {
|
||||
const rec = usage[user];
|
||||
if (!rec || rec.date !== todayUTC()) return 0;
|
||||
return rec.tokens;
|
||||
}
|
||||
function addTokens(user, tokens) {
|
||||
const usage = loadUsage();
|
||||
const today = todayUTC();
|
||||
const rec = usage[user] && usage[user].date === today ? usage[user] : { date: today, tokens: 0 };
|
||||
rec.tokens += tokens;
|
||||
usage[user] = rec;
|
||||
saveUsage(usage);
|
||||
return rec.tokens;
|
||||
}
|
||||
function secondsUntilUTCMidnight() {
|
||||
const now = new Date();
|
||||
const midnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
|
||||
return Math.round((midnight - now) / 1000);
|
||||
}
|
||||
|
||||
/* ===== Mock mode =====
|
||||
* Set MOCK_AI=true to test the whole app, including the AI draft button,
|
||||
* with no API key and no real API call. Returns a canned breadcrumb draft
|
||||
* shaped like a real Anthropic response, so the frontend parsing and gate
|
||||
* checks run exactly as they would against the live API. Use this for
|
||||
* testing the login gate, rate limit, and Docker setup without cost. */
|
||||
function mockClaudeResponse(res) {
|
||||
* Set MOCK_AI=true to test the whole app, including the AI draft button and
|
||||
* the token quota above, with no API key and no real API call. Returns a
|
||||
* canned breadcrumb draft with a synthetic usage count, shaped like a real
|
||||
* Anthropic response, so the frontend parsing, gate checks, and quota
|
||||
* accounting all run exactly as they would against the live API. */
|
||||
function mockClaudeResponse(res, user) {
|
||||
const draft = {
|
||||
domain: 'MOCK: Progress Visibility',
|
||||
driver: 'MOCK driver: schedule forecasts drift from field reality.',
|
||||
@@ -99,16 +194,19 @@ function mockClaudeResponse(res) {
|
||||
method: 'MOCK method: monthly spot audit of 30+ sampled assets.',
|
||||
baseline: 'MOCK baseline: first audit result.'
|
||||
};
|
||||
const usage = { input_tokens: 950, output_tokens: 280 };
|
||||
const body = {
|
||||
content: [{ type: 'text', text: JSON.stringify(draft) }]
|
||||
content: [{ type: 'text', text: JSON.stringify(draft) }],
|
||||
usage
|
||||
};
|
||||
if (TOKEN_LIMIT_ENABLED) addTokens(user, usage.input_tokens + usage.output_tokens);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function proxyToClaude(req, res) {
|
||||
function proxyToClaude(req, res, user) {
|
||||
if (String(process.env.MOCK_AI).toLowerCase() === 'true') {
|
||||
mockClaudeResponse(res);
|
||||
mockClaudeResponse(res, user);
|
||||
return;
|
||||
}
|
||||
if (!process.env.ANTHROPIC_API_KEY) {
|
||||
@@ -133,6 +231,16 @@ function proxyToClaude(req, res) {
|
||||
const text = await upstream.text();
|
||||
res.writeHead(upstream.status, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(text);
|
||||
if (TOKEN_LIMIT_ENABLED) {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed.usage) {
|
||||
addTokens(user, (parsed.usage.input_tokens || 0) + (parsed.usage.output_tokens || 0));
|
||||
}
|
||||
} catch (err) {
|
||||
// Response was not JSON, or had no usage field. Nothing to record.
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
sendJson(res, 502, { error: 'Could not reach the Anthropic API: ' + err.message });
|
||||
}
|
||||
@@ -172,7 +280,8 @@ function serveStatic(req, res) {
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (!checkBasicAuth(req)) {
|
||||
const user = checkBasicAuth(req);
|
||||
if (!user) {
|
||||
requireAuth(res);
|
||||
return;
|
||||
}
|
||||
@@ -182,7 +291,16 @@ const server = http.createServer((req, res) => {
|
||||
sendJson(res, 429, { error: `Rate limit reached (${RATE_LIMIT_MAX} AI drafts per ${Math.round(RATE_LIMIT_WINDOW_MS / 60000)} min). Wait a bit and try again.` });
|
||||
return;
|
||||
}
|
||||
proxyToClaude(req, res);
|
||||
if (TOKEN_LIMIT_ENABLED) {
|
||||
const usedToday = usedTokensToday(loadUsage(), user);
|
||||
if (usedToday >= TOKEN_LIMIT_PER_USER) {
|
||||
sendJson(res, 429, {
|
||||
error: `Daily token quota reached (${usedToday}/${TOKEN_LIMIT_PER_USER} tokens for "${user}"). Resets in ${secondsUntilUTCMidnight()}s, at UTC midnight.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
proxyToClaude(req, res, user);
|
||||
return;
|
||||
}
|
||||
if (req.method === 'GET') {
|
||||
@@ -200,5 +318,6 @@ server.listen(PORT, () => {
|
||||
} else {
|
||||
console.log(process.env.ANTHROPIC_API_KEY ? 'ANTHROPIC_API_KEY loaded: AI draft button is live.' : 'No ANTHROPIC_API_KEY found: AI draft button will return an error until you add one to .env.');
|
||||
}
|
||||
console.log((process.env.APP_USERNAME && process.env.APP_PASSWORD) ? 'Login required: APP_USERNAME/APP_PASSWORD are set.' : 'No login required: APP_USERNAME/APP_PASSWORD are not set.');
|
||||
console.log(AUTH_REQUIRED ? `Login required: ${Object.keys(USER_MAP).length} named user(s) configured.` : 'No login required: no APP_USERS or APP_USERNAME/APP_PASSWORD are set.');
|
||||
console.log(TOKEN_LIMIT_ENABLED ? `Per-user daily token quota: ${TOKEN_LIMIT_PER_USER} tokens. Usage file: ${TOKEN_USAGE_FILE}` : 'No per-user token quota: TOKEN_LIMIT_PER_USER is unset or 0 (unlimited, feature on the back burner).');
|
||||
});
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
nav button .count{display:inline-block; margin-left:7px; background:var(--border); border-radius:12px; padding:1px 8px; font-size:12px; color:var(--text);}
|
||||
nav .flow{align-self:center; color:var(--border-strong); font-size:13px; padding:0 2px;}
|
||||
|
||||
main{padding:24px; max-width:1500px; margin:0 auto;}
|
||||
main{padding:24px;}
|
||||
.panel{display:none;}
|
||||
.panel.active{display:block;}
|
||||
.panel-head{margin-bottom:18px;}
|
||||
@@ -111,19 +111,32 @@
|
||||
.medit{width:100%; border:none; background:var(--bg); padding:7px 9px; font-size:13.5px; font-family:inherit; resize:vertical;}
|
||||
|
||||
/* Breadcrumbs */
|
||||
.bc{background:var(--layer); border:1px solid var(--border); margin-bottom:16px; padding:16px;}
|
||||
.bc{background:var(--layer); border:1px solid var(--border); margin-bottom:16px; padding:26px 30px; min-height:70vh;}
|
||||
.bc.example{border-left:4px solid var(--teal);}
|
||||
.bc-top{display:grid; grid-template-columns:repeat(3, 1fr); gap:14px; margin-bottom:12px;}
|
||||
.bc-out{display:grid; grid-template-columns:repeat(4, 1fr); gap:14px; background:var(--bg); padding:12px; border:1px solid var(--border);}
|
||||
.bc-top{display:grid; grid-template-columns:repeat(3, 1fr); gap:18px; margin-bottom:16px;}
|
||||
.bc-out{display:grid; grid-template-columns:repeat(4, 1fr); gap:18px; background:var(--bg); padding:16px; border:1px solid var(--border);}
|
||||
.bc-field h3{font-size:10.5px; letter-spacing:.5px; text-transform:uppercase; color:var(--text-helper); margin-bottom:5px; display:flex; align-items:center; gap:6px;}
|
||||
.stepnum{width:17px; height:17px; border-radius:50%; background:var(--text); color:#fff; font-size:10.5px; display:inline-flex; align-items:center; justify-content:center; flex-shrink:0;}
|
||||
.bc-field textarea{width:100%; border:none; background:var(--bg); padding:8px 10px; font-size:13.5px; font-family:inherit; resize:vertical; min-height:56px; color:var(--text);}
|
||||
.bc-out .bc-field textarea{background:var(--layer);}
|
||||
.bc-field textarea{width:100%; border:none; background:var(--bg); padding:10px 12px; font-size:14px; font-family:inherit; resize:vertical; min-height:130px; color:var(--text);}
|
||||
.bc-out .bc-field textarea{background:var(--layer); min-height:90px;}
|
||||
.bc-out-head{grid-column:1/-1; font-size:10.5px; letter-spacing:.5px; text-transform:uppercase; color:var(--text-helper); display:flex; align-items:center; gap:6px;}
|
||||
.bc-related{margin:0 0 12px; font-size:13px;}
|
||||
.bc-related summary{cursor:pointer; color:var(--interactive);}
|
||||
.bc-related li{margin-left:22px; padding:2px 0; color:var(--text-secondary);}
|
||||
.bc-foot{display:flex; align-items:center; gap:14px; margin-top:12px; flex-wrap:wrap;}
|
||||
.bc-layout{display:grid; grid-template-columns:250px 1fr; gap:16px; align-items:start;}
|
||||
.bc-nav{display:block; background:var(--layer); border:1px solid var(--border); position:sticky; top:12px; max-height:80vh; overflow-y:auto; overflow-x:hidden;}
|
||||
.bc-nav-item{display:flex; align-items:flex-start; gap:9px; padding:10px 12px; border-bottom:1px solid var(--border); cursor:pointer; min-width:0;}
|
||||
.bc-nav-item:last-child{border-bottom:none;}
|
||||
.bc-nav-item:hover{background:var(--layer-hover);}
|
||||
.bc-nav-item.active{background:var(--layer-hover); border-left:3px solid var(--interactive); padding-left:9px;}
|
||||
.bc-nav-text{flex:1; min-width:0; font-size:12.5px; line-height:1.35; color:var(--text-secondary); overflow-wrap:break-word;}
|
||||
.bc-nav-item.active .bc-nav-text{color:var(--text); font-weight:600;}
|
||||
.bc-dot{width:8px; height:8px; border-radius:50%; flex-shrink:0; background:var(--border-strong);}
|
||||
.bc-dot.complete{background:var(--success);}
|
||||
.bc-dot.partial{background:var(--warning);}
|
||||
.bc-detail{min-width:0;}
|
||||
@media (max-width:900px){ .bc-layout{grid-template-columns:1fr;} .bc-nav{position:static; max-height:none;} }
|
||||
.gate{display:flex; gap:12px; font-size:12.5px; flex-wrap:wrap;}
|
||||
.gate span{color:var(--text-helper);}
|
||||
.gate span.ok{color:var(--success); font-weight:600;}
|
||||
@@ -151,6 +164,13 @@
|
||||
.toast{position:fixed; bottom:24px; left:50%; transform:translateX(-50%); background:var(--text); color:#fff; padding:12px 22px; font-size:14px; opacity:0; pointer-events:none; transition:opacity .2s; z-index:50;}
|
||||
.toast.show{opacity:1;}
|
||||
#jsonFile{display:none;}
|
||||
.btn.danger{background:var(--danger); color:#fff; border:none;}
|
||||
.btn.danger:hover{background:#b3121d;}
|
||||
.confirm-backdrop{position:fixed; inset:0; background:rgba(22,22,22,.5); display:flex; align-items:center; justify-content:center; z-index:70; padding:20px;}
|
||||
.confirm-card{background:var(--layer); border:1px solid var(--border); border-left:4px solid var(--danger); padding:22px 26px; max-width:420px; width:100%;}
|
||||
.confirm-card h3{font-size:16px; font-weight:600; margin-bottom:8px;}
|
||||
.confirm-card p{font-size:14px; color:var(--text-secondary); line-height:1.5; margin-bottom:20px;}
|
||||
.confirm-actions{display:flex; justify-content:flex-end; gap:10px;}
|
||||
@media (max-width:1200px){ .bc-top{grid-template-columns:1fr;} .bc-out{grid-template-columns:1fr 1fr;} }
|
||||
@media (prefers-reduced-motion: reduce){ *{transition:none !important;} }
|
||||
</style>
|
||||
@@ -205,7 +225,10 @@
|
||||
<p>Root problem to value driver to capability to measurable outcome. A completed breadcrumb IS a pilot success criterion; the export builds the criteria table from every complete card. AI draft fills the fields from construction execution methodology (EVM, CII AWP, Last Planner, first-time quality); the room reviews and edits.</p>
|
||||
</div>
|
||||
<div class="summary-bar" id="bcSummary"></div>
|
||||
<div id="bcList"></div>
|
||||
<div class="bc-layout">
|
||||
<div class="bc-nav" id="bcNav"></div>
|
||||
<div class="bc-detail" id="bcDetail"></div>
|
||||
</div>
|
||||
<div class="add-row"><button class="btn ghost" id="btnAddBc">Add a blank breadcrumb</button></div>
|
||||
</section>
|
||||
|
||||
@@ -478,17 +501,25 @@ document.getElementById('panel-map').addEventListener('click', e=>{
|
||||
const c = clusterById(t.dataset.cprom);
|
||||
if(c){
|
||||
c.promoted = true;
|
||||
S.breadcrumbs.push(B({prob:(c.root||c.name), related:activeMembers(c.id).map(p=>p.t)}));
|
||||
const nb = B({prob:(c.root||c.name), related:activeMembers(c.id).map(p=>p.t)});
|
||||
S.breadcrumbs.push(nb);
|
||||
bcSelectedId = nb.id;
|
||||
save(); renderAll(); toast('Promoted with ' + activeMembers(c.id).length + ' related issues');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if(t.dataset.cdel){
|
||||
const c = clusterById(t.dataset.cdel);
|
||||
if(c && confirm(`Remove cluster "${c.name}"? Its issues return to Unclustered.`)){
|
||||
S.problems.forEach(p=>{ if(p.cluster===c.id) p.cluster=''; });
|
||||
S.clusters = S.clusters.filter(x=>x.id!==c.id);
|
||||
save(); renderMap();
|
||||
if(c){
|
||||
confirmModal({
|
||||
title: 'Remove cluster?',
|
||||
message: `Remove cluster "${c.name}"? Its issues return to Unclustered.`,
|
||||
confirmLabel: 'Remove cluster'
|
||||
}, ()=>{
|
||||
S.problems.forEach(p=>{ if(p.cluster===c.id) p.cluster=''; });
|
||||
S.clusters = S.clusters.filter(x=>x.id!==c.id);
|
||||
save(); renderMap();
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -512,9 +543,16 @@ document.getElementById('panel-map').addEventListener('click', e=>{
|
||||
return;
|
||||
}
|
||||
if(t.dataset.pdel){
|
||||
S.problems = S.problems.filter(p=>p.id !== t.dataset.pdel);
|
||||
editingItems.delete(t.dataset.pdel);
|
||||
save(); renderMap();
|
||||
const pid = t.dataset.pdel;
|
||||
confirmModal({
|
||||
title: 'Remove issue?',
|
||||
message: 'Remove this issue from the problem map? This cannot be undone.',
|
||||
confirmLabel: 'Remove issue'
|
||||
}, ()=>{
|
||||
S.problems = S.problems.filter(p=>p.id !== pid);
|
||||
editingItems.delete(pid);
|
||||
save(); renderMap();
|
||||
});
|
||||
return;
|
||||
}
|
||||
const head = t.closest('[data-toggle]');
|
||||
@@ -567,6 +605,8 @@ document.getElementById('newCluster').addEventListener('keydown', e=>{ if(e.key=
|
||||
const ADOPTION_WORDS = /implement|adopt|roll ?out|have a tool|tool exists|usage|active users?|users? active/i;
|
||||
const REQ = ['driver','cap','metric','method','baseline'];
|
||||
function bcComplete(b){ return REQ.every(f=>String(b[f]||'').trim().length>0); }
|
||||
function bcStatus(b){ if(bcComplete(b)) return 'complete'; if(REQ.some(f=>String(b[f]||'').trim())) return 'partial'; return 'blank'; }
|
||||
let bcSelectedId = null;
|
||||
function footHTML(b){
|
||||
const adoptionFlag = b.type==='Outcome' && ADOPTION_WORDS.test(b.metric||'');
|
||||
const gate = REQ.map(f=>{
|
||||
@@ -577,7 +617,7 @@ function footHTML(b){
|
||||
<div class="gate">${gate}</div>
|
||||
${adoptionFlag ? '<span class="gate-hint">Metric reads as adoption. Name the outcome the tool should move, or tag this card Adoption.</span>' : ''}
|
||||
<button class="btn small ai" data-aibc="${b.id}">✨ AI draft fields</button>
|
||||
<button class="row-del" data-bdel="${b.id}" title="Remove" style="margin-left:auto;">✕</button>`;
|
||||
<button class="row-del" data-bdel="${b.id}" title="Delete this breadcrumb" style="margin-left:auto; padding:4px 8px; font-size:13px;">Delete</button>`;
|
||||
}
|
||||
function bcSummaryHTML(){
|
||||
const nOut = S.breadcrumbs.filter(b=>b.type==='Outcome').length;
|
||||
@@ -587,15 +627,10 @@ function bcSummaryHTML(){
|
||||
<div class="${nInc?'warn':''}"><b>${nInc}</b>Incomplete</div>
|
||||
<div><b>${nOut}</b>Outcome</div><div><b>${S.breadcrumbs.length-nOut}</b>Adoption</div>`;
|
||||
}
|
||||
function renderBc(){
|
||||
const wrap = document.getElementById('bcList');
|
||||
document.getElementById('cntBc').textContent = S.breadcrumbs.length;
|
||||
document.getElementById('bcSummary').innerHTML = bcSummaryHTML();
|
||||
if(!S.breadcrumbs.length){ wrap.innerHTML = '<div class="empty">Promote root problems from the map, or add a blank breadcrumb.</div>'; return; }
|
||||
wrap.innerHTML = S.breadcrumbs.map(b=>{
|
||||
const rel = (b.related && b.related.length)
|
||||
? `<details class="bc-related"><summary>Related issues carried from the cluster (${b.related.length})</summary><ul>${b.related.map(r=>`<li>${esc(r)}</li>`).join('')}</ul></details>` : '';
|
||||
return `<div class="bc${b.example?' example':''}">
|
||||
function bcCardHTML(b){
|
||||
const rel = (b.related && b.related.length)
|
||||
? `<details class="bc-related"><summary>Related issues carried from the cluster (${b.related.length})</summary><ul>${b.related.map(r=>`<li>${esc(r)}</li>`).join('')}</ul></details>` : '';
|
||||
return `<div class="bc${b.example?' example':''}">
|
||||
<div class="bc-top">
|
||||
<div class="bc-field">
|
||||
<h3><span class="stepnum">1</span>Root problem statement ${b.example?'<span class="example-tag">Worked example</span>':''}</h3>
|
||||
@@ -620,20 +655,48 @@ function renderBc(){
|
||||
</div>
|
||||
<div class="bc-foot" data-foot="${b.id}">${footHTML(b)}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
document.getElementById('bcList').addEventListener('input', e=>{
|
||||
function bcNavRowHTML(b){
|
||||
const label = shortT(b.prob, 60) || '(blank breadcrumb)';
|
||||
return `<div class="bc-nav-item${b.id===bcSelectedId?' active':''}" data-bcnav="${b.id}">
|
||||
<span class="bc-dot ${bcStatus(b)}"></span>
|
||||
<span class="bc-nav-text">${esc(label)}</span>
|
||||
</div>`;
|
||||
}
|
||||
function renderBc(){
|
||||
document.getElementById('cntBc').textContent = S.breadcrumbs.length;
|
||||
document.getElementById('bcSummary').innerHTML = bcSummaryHTML();
|
||||
const nav = document.getElementById('bcNav');
|
||||
const detail = document.getElementById('bcDetail');
|
||||
if(!S.breadcrumbs.length){
|
||||
nav.innerHTML = '';
|
||||
detail.innerHTML = '<div class="empty">Promote root problems from the map, or add a blank breadcrumb.</div>';
|
||||
bcSelectedId = null;
|
||||
return;
|
||||
}
|
||||
if(!S.breadcrumbs.some(b=>b.id===bcSelectedId)) bcSelectedId = S.breadcrumbs[0].id;
|
||||
nav.innerHTML = S.breadcrumbs.map(bcNavRowHTML).join('');
|
||||
const selected = S.breadcrumbs.find(b=>b.id===bcSelectedId);
|
||||
detail.innerHTML = bcCardHTML(selected);
|
||||
}
|
||||
document.getElementById('bcNav').addEventListener('click', e=>{
|
||||
const row = e.target.closest('[data-bcnav]');
|
||||
if(row){ bcSelectedId = row.dataset.bcnav; renderBc(); }
|
||||
});
|
||||
document.getElementById('bcDetail').addEventListener('input', e=>{
|
||||
if(e.target.dataset.bid){
|
||||
const b = S.breadcrumbs.find(x=>x.id===e.target.dataset.bid);
|
||||
if(b){
|
||||
b[e.target.dataset.f] = e.target.value; save();
|
||||
const foot = document.querySelector(`[data-foot="${b.id}"]`);
|
||||
if(foot) foot.innerHTML = footHTML(b);
|
||||
const navRow = document.querySelector(`[data-bcnav="${b.id}"]`);
|
||||
if(navRow) navRow.outerHTML = bcNavRowHTML(b);
|
||||
document.getElementById('bcSummary').innerHTML = bcSummaryHTML();
|
||||
}
|
||||
}
|
||||
});
|
||||
document.getElementById('bcList').addEventListener('click', e=>{
|
||||
document.getElementById('bcDetail').addEventListener('click', e=>{
|
||||
const tt = e.target.closest('[data-tt]');
|
||||
if(tt){
|
||||
const b = S.breadcrumbs.find(x=>x.id===tt.dataset.tt);
|
||||
@@ -642,12 +705,21 @@ document.getElementById('bcList').addEventListener('click', e=>{
|
||||
}
|
||||
if(e.target.dataset.aibc){ aiDraftBreadcrumb(e.target.dataset.aibc, e.target); return; }
|
||||
if(e.target.dataset.bdel){
|
||||
S.breadcrumbs = S.breadcrumbs.filter(b=>b.id!==e.target.dataset.bdel);
|
||||
save(); renderBc();
|
||||
const bid = e.target.dataset.bdel;
|
||||
confirmModal({
|
||||
title: 'Remove breadcrumb?',
|
||||
message: 'Remove this breadcrumb? Its fields will be lost unless you have already saved or exported this session.',
|
||||
confirmLabel: 'Remove breadcrumb'
|
||||
}, ()=>{
|
||||
S.breadcrumbs = S.breadcrumbs.filter(b=>b.id!==bid);
|
||||
save(); renderBc();
|
||||
});
|
||||
}
|
||||
});
|
||||
document.getElementById('btnAddBc').addEventListener('click', ()=>{
|
||||
S.breadcrumbs.push(B({}));
|
||||
const nb = B({});
|
||||
S.breadcrumbs.push(nb);
|
||||
bcSelectedId = nb.id;
|
||||
save(); renderBc();
|
||||
});
|
||||
|
||||
@@ -717,7 +789,14 @@ function renderAct(){
|
||||
}
|
||||
document.getElementById('actList').addEventListener('click', e=>{
|
||||
if(e.target.dataset.done){ const a=S.actions.find(x=>x.id===e.target.dataset.done); if(a){a.done=!a.done; save(); renderAct();} }
|
||||
if(e.target.dataset.adel){ S.actions=S.actions.filter(a=>a.id!==e.target.dataset.adel); save(); renderAct(); }
|
||||
if(e.target.dataset.adel){
|
||||
const aid = e.target.dataset.adel;
|
||||
confirmModal({
|
||||
title: 'Remove action?',
|
||||
message: 'Remove this action item? This cannot be undone.',
|
||||
confirmLabel: 'Remove action'
|
||||
}, ()=>{ S.actions=S.actions.filter(a=>a.id!==aid); save(); renderAct(); });
|
||||
}
|
||||
});
|
||||
document.getElementById('actList').addEventListener('change', e=>{
|
||||
const t = e.target;
|
||||
@@ -819,10 +898,14 @@ document.getElementById('btnExport').addEventListener('click', ()=>{
|
||||
toast('Summary downloaded');
|
||||
});
|
||||
document.getElementById('btnReset').addEventListener('click', ()=>{
|
||||
if(confirm('Reset to the clustered July 21 baseline? Anything entered since will be lost unless saved to JSON.')){
|
||||
confirmModal({
|
||||
title: 'Reset to baseline?',
|
||||
message: 'Reset to the clustered July 21 baseline? Anything entered since will be lost unless saved to JSON.',
|
||||
confirmLabel: 'Reset'
|
||||
}, ()=>{
|
||||
S = JSON.parse(JSON.stringify(DEFAULT_STATE));
|
||||
save(); renderAll(); toast('Reset to baseline');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let toastTimer;
|
||||
@@ -834,6 +917,27 @@ function toast(msg){
|
||||
toastTimer = setTimeout(()=>t.classList.remove('show'), 2200);
|
||||
}
|
||||
|
||||
function confirmModal(opts, onConfirm){
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'confirm-backdrop';
|
||||
backdrop.innerHTML = `<div class="confirm-card" role="alertdialog" aria-modal="true" aria-labelledby="confirmTitle">
|
||||
<h3 id="confirmTitle">${esc(opts.title || 'Remove this?')}</h3>
|
||||
<p>${esc(opts.message || 'This cannot be undone.')}</p>
|
||||
<div class="confirm-actions">
|
||||
<button class="btn ghost" data-cc="cancel">Cancel</button>
|
||||
<button class="btn danger" data-cc="ok">${esc(opts.confirmLabel || 'Delete')}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(backdrop);
|
||||
function close(){ document.removeEventListener('keydown', onKey); backdrop.remove(); }
|
||||
function onKey(e){ if(e.key==='Escape') close(); }
|
||||
document.addEventListener('keydown', onKey);
|
||||
backdrop.addEventListener('click', e=>{ if(e.target===backdrop) close(); });
|
||||
backdrop.querySelector('[data-cc="cancel"]').addEventListener('click', close);
|
||||
backdrop.querySelector('[data-cc="ok"]').addEventListener('click', ()=>{ close(); onConfirm(); });
|
||||
backdrop.querySelector('[data-cc="ok"]').focus();
|
||||
}
|
||||
|
||||
function renderAll(){ renderMap(); renderBc(); renderAct(); }
|
||||
renderAll();
|
||||
</script>
|
||||
|
||||
@@ -214,6 +214,13 @@
|
||||
opacity:0; pointer-events:none; transition:opacity .2s;
|
||||
}
|
||||
.toast.show{opacity:1;}
|
||||
.btn.danger{background:var(--danger); color:#fff; border:none;}
|
||||
.btn.danger:hover{background:#b3121d;}
|
||||
.confirm-backdrop{position:fixed; inset:0; background:rgba(22,22,22,.5); display:flex; align-items:center; justify-content:center; z-index:70; padding:20px;}
|
||||
.confirm-card{background:var(--layer); border:1px solid var(--border); border-left:4px solid var(--danger); padding:22px 26px; max-width:420px; width:100%;}
|
||||
.confirm-card h3{font-size:16px; font-weight:600; margin-bottom:8px;}
|
||||
.confirm-card p{font-size:14px; color:var(--text-secondary); line-height:1.5; margin-bottom:20px;}
|
||||
.confirm-actions{display:flex; justify-content:flex-end; gap:10px;}
|
||||
@media (max-width:1100px){ .board{grid-template-columns:repeat(2,1fr);} .reg-form{grid-template-columns:1fr 1fr;} }
|
||||
@media (prefers-reduced-motion: reduce){ *{transition:none !important;} }
|
||||
</style>
|
||||
@@ -502,8 +509,12 @@ document.getElementById('panel-board').addEventListener('click', e=>{
|
||||
if(item){ item.b = (item.b === t.dataset.b) ? '' : t.dataset.b; save(); renderScope(); }
|
||||
}
|
||||
if(t.dataset && t.dataset.del){
|
||||
S.scope = S.scope.filter(i=>i.id!==t.dataset.del);
|
||||
save(); renderScope();
|
||||
const iid = t.dataset.del;
|
||||
confirmModal({
|
||||
title: 'Remove item?',
|
||||
message: 'Remove this item from the Scope Boundary Board? This cannot be undone.',
|
||||
confirmLabel: 'Remove item'
|
||||
}, ()=>{ S.scope = S.scope.filter(i=>i.id!==iid); save(); renderScope(); });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -553,9 +564,16 @@ document.getElementById('rankList').addEventListener('click', e=>{
|
||||
[S.rank[i], S.rank[i+1]] = [S.rank[i+1], S.rank[i]];
|
||||
save(); renderRank();
|
||||
} else if(t.dataset.rdel !== undefined){
|
||||
S.rank.splice(+t.dataset.rdel, 1);
|
||||
if(S.cutAfter > S.rank.length) S.cutAfter = S.rank.length;
|
||||
save(); renderRank();
|
||||
const idx = +t.dataset.rdel;
|
||||
confirmModal({
|
||||
title: 'Remove item?',
|
||||
message: 'Remove this item from the priority ranking? This cannot be undone.',
|
||||
confirmLabel: 'Remove item'
|
||||
}, ()=>{
|
||||
S.rank.splice(idx, 1);
|
||||
if(S.cutAfter > S.rank.length) S.cutAfter = S.rank.length;
|
||||
save(); renderRank();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -609,8 +627,12 @@ document.getElementById('btnAddDecision').addEventListener('click', ()=>{
|
||||
|
||||
document.getElementById('regTableWrap').addEventListener('click', e=>{
|
||||
if(e.target.dataset.ddel !== undefined){
|
||||
S.decisions.splice(+e.target.dataset.ddel, 1);
|
||||
save(); renderRegistry();
|
||||
const idx = +e.target.dataset.ddel;
|
||||
confirmModal({
|
||||
title: 'Remove decision?',
|
||||
message: 'Remove this decision from the registry? This cannot be undone.',
|
||||
confirmLabel: 'Remove decision'
|
||||
}, ()=>{ S.decisions.splice(idx, 1); save(); renderRegistry(); });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -719,10 +741,14 @@ document.getElementById('btnExport').addEventListener('click', ()=>{
|
||||
|
||||
/* ================= RESET ================= */
|
||||
document.getElementById('btnReset').addEventListener('click', ()=>{
|
||||
if(confirm('Reset all four tools to their starting state? This clears everything entered in this meeting.')){
|
||||
confirmModal({
|
||||
title: 'Reset everything?',
|
||||
message: 'Reset all four tools to their starting state? This clears everything entered in this meeting.',
|
||||
confirmLabel: 'Reset'
|
||||
}, ()=>{
|
||||
S = JSON.parse(JSON.stringify(DEFAULT_STATE));
|
||||
save(); renderAll(); toast('Reset complete');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/* ================= TOAST ================= */
|
||||
@@ -735,6 +761,27 @@ function toast(msg){
|
||||
toastTimer = setTimeout(()=>t.classList.remove('show'), 2000);
|
||||
}
|
||||
|
||||
function confirmModal(opts, onConfirm){
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'confirm-backdrop';
|
||||
backdrop.innerHTML = `<div class="confirm-card" role="alertdialog" aria-modal="true" aria-labelledby="confirmTitle">
|
||||
<h3 id="confirmTitle">${esc(opts.title || 'Remove this?')}</h3>
|
||||
<p>${esc(opts.message || 'This cannot be undone.')}</p>
|
||||
<div class="confirm-actions">
|
||||
<button class="btn ghost" data-cc="cancel">Cancel</button>
|
||||
<button class="btn danger" data-cc="ok">${esc(opts.confirmLabel || 'Delete')}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(backdrop);
|
||||
function close(){ document.removeEventListener('keydown', onKey); backdrop.remove(); }
|
||||
function onKey(e){ if(e.key==='Escape') close(); }
|
||||
document.addEventListener('keydown', onKey);
|
||||
backdrop.addEventListener('click', e=>{ if(e.target===backdrop) close(); });
|
||||
backdrop.querySelector('[data-cc="cancel"]').addEventListener('click', close);
|
||||
backdrop.querySelector('[data-cc="ok"]').addEventListener('click', ()=>{ close(); onConfirm(); });
|
||||
backdrop.querySelector('[data-cc="ok"]').focus();
|
||||
}
|
||||
|
||||
function renderAll(){ renderScope(); renderRank(); renderRegistry(); renderReady(); }
|
||||
renderAll();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user