converted to proper npm sveltekit project

This commit is contained in:
C-West8
2026-07-22 14:52:35 -05:00
parent ba0a445aaf
commit 086542169c
37 changed files with 6018 additions and 14 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
node_modules
.svelte-kit
build
.git
.env
.env.*
!.env.example
*.log
README.md
legacy

17
.env.example Normal file
View File

@@ -0,0 +1,17 @@
# Copy this file to ".env" and fill in real values. Never commit the real .env.
# --- Database ---
# When running the app OUTSIDE Docker (npm run dev), point this at your Postgres.
# When running via docker-compose, this is set for you in docker-compose.yml.
DATABASE_URL=postgres://sde:sde_password@localhost:5432/sde_toolkit
# --- Anthropic API (for the "AI draft" feature) ---
# Get a key from https://console.anthropic.com/ . Without it, the AI button
# fails politely and everything else keeps working.
ANTHROPIC_API_KEY=
# Model used for AI drafting.
ANTHROPIC_MODEL=claude-sonnet-5
# --- Server ---
PORT=3000

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
node_modules/
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
*.log
.DS_Store

26
Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
# --- Build stage: install deps and compile the SvelteKit app ---
FROM node:22-alpine AS build
WORKDIR /app
# Install dependencies first (better layer caching)
COPY package.json package-lock.json* ./
RUN npm ci
# Build the app
COPY . .
RUN npm run build
# Drop dev dependencies so the runtime image stays small
RUN npm prune --omit=dev
# --- Runtime stage: just Node + the built server ---
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/build ./build
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./package.json
EXPOSE 3000
CMD ["node", "build"]

111
README.md
View File

@@ -1,23 +1,106 @@
# Project SDE Meeting Toolkit
## Contents
A shared, multi-user web app with two meeting tools:
| File | Purpose |
- **Field Problem Workshop** — capture field problems, cluster them into root problems, dot-vote, build "breadcrumb" trails to measurable outcomes, and assign actions. Includes an optional AI draft feature.
- **Scope Lock Meeting Suite** — Scope Boundary Board, MVP Priority Ranker, Decision Registry, and Micron Pilot Readiness.
Unlike the original single-file HTML versions (kept in [`legacy/`](./legacy) for reference), everyone who opens the same link works from the **same data**, stored on a shared server. Edits autosave; a version check prevents two people from silently overwriting each other.
## Tech stack
| Piece | Choice | Why |
|---|---|---|
| Framework | SvelteKit (Svelte 5) + TypeScript | Frontend and server API in one project |
| Server | Node (`adapter-node`) | Runs as a plain server, easy to containerize |
| Database | PostgreSQL | Shared storage for all users |
| Packaging | Docker + docker-compose | One command to run the whole thing internally |
## Project layout
```
src/
app.css shared design tokens + base styles
lib/
types.ts data shapes for both tools
workspace.svelte.ts client sync: load / autosave / poll / conflict handling
toast.svelte.ts shared toast controller
components/ Toast, SyncBadge
defaults/ seeded baseline state for each tool
server/
db/ Postgres pool + workspace repository
ai.ts Anthropic proxy (holds the API key server-side)
routes/
+page.svelte home / tool picker
workshop/ Field Problem Workshop
scope-lock/ Scope Lock Meeting Suite
api/
workspaces/[tool]/[name]/ GET (load) + PUT (save)
workspaces/[tool]/[name]/reset/ POST (reset to baseline)
ai/draft/ POST (AI breadcrumb draft)
db/init.sql database schema (runs once on first DB startup)
Dockerfile, docker-compose.yml
legacy/ the original single-file HTML tools
```
## Run it with Docker (recommended)
Requires Docker Desktop / Docker Engine.
```bash
# optional: enable the AI feature by putting a key in your shell/.env first
# ANTHROPIC_API_KEY=sk-ant-...
docker compose up -d --build
```
Then open **http://localhost:3000**. To share on your network, others use `http://<your-machine-ip>:3000`.
```bash
docker compose down # stop
docker compose down -v # stop AND erase all saved data (reset the database)
docker compose logs -f app # watch app logs
```
The database persists in a Docker volume (`db_data`), so your data survives restarts.
## Run it for development (without Docker)
You need Node 20+ and a PostgreSQL you can connect to.
```bash
cp .env.example .env # then edit DATABASE_URL (and ANTHROPIC_API_KEY if wanted)
npm install
# create the schema in your database:
psql "$DATABASE_URL" -f db/init.sql
npm run dev # http://localhost:5173
```
Other scripts: `npm run build` (production build), `npm run preview` (serve the build), `npm run check` (type-check).
## Configuration
All config is via environment variables (see `.env.example`):
| Variable | Purpose |
|---|---|
| SDE_Field_Problem_Workshop_v5.html | Field problem workshop: Problem Map (capture, clusters, dot voting) → Breadcrumbs (with AI draft) → Actions. Pre-loaded with the July 21 session baseline. |
| SDE_Scope_Lock_Meeting_Suite.html | Scope lock meeting suite: Scope Boundary Board, MVP Priority Ranker, Decision Registry, Micron Pilot Readiness. |
| construction-breadcrumbs.skill | Claude skill encoding the breadcrumb methodology (EVM, CII AWP, Last Planner, first-time quality). Install via Save skill in Claude to use it in any chat. |
| `DATABASE_URL` | Postgres connection string. Set automatically by docker-compose. |
| `ANTHROPIC_API_KEY` | Enables the "AI draft" button. Leave blank to disable it (the button then fails politely). |
| `ANTHROPIC_MODEL` | Model for AI drafting (default `claude-sonnet-5`). |
| `PORT` | Server port (default 3000). |
| `ORIGIN` | Public URL of the app, e.g. `http://server:3000`. Needed by the Node server for form/security handling. |
## Running the workshop tool
## How the shared-workspace model works
- **With AI features:** open the HTML file as an artifact inside Claude (upload it to a chat and ask Claude to render it, or keep it in a Project). The AI draft fields button on Breadcrumbs requires the Claude environment and network connectivity.
- **Without AI:** open the file directly in any browser. Everything works except the AI button, which fails politely.
- Each tool has a named workspace (default: `default`) stored as one JSON document with a `version` number.
- When you edit, the browser autosaves after a short pause, sending your changes **plus the version you started from**.
- If a teammate saved first, the server rejects your save (HTTP 409) and hands back their current copy, which your page loads — so nobody's work is silently overwritten. The status badge in the header shows this.
- Open pages poll every few seconds, so a teammate's saved changes show up without a manual refresh.
## Persistence
This fits turn-taking meeting use. It is **not** live character-by-character co-editing (like Google Docs). If that's needed later, the version/polling foundation here can be upgraded to websockets.
- The workshop tool holds state in the session only. **Save JSON before closing** and Load JSON to resume. The exported JSON is the working record; the Export summary markdown is the meeting artifact.
- The Scope Lock suite autosaves to the browser (localStorage) on the machine where it is opened; its Export button produces the durable record.
## Notes / possible next steps
## Reset behavior
The workshop tool's Reset returns to the clustered July 21 baseline (43 problems in 7 root clusters, 6 breadcrumbs, 5 actions). The Scope Lock suite's Reset returns to its pre-seeded starting state.
- **Authentication**: there is none yet — anyone with the link can view and edit. The data model already has an `updated_by` column ready for it.
- **Multiple named workspaces**: the API supports any name (`/api/workspaces/workshop/<name>`); the UI currently uses `default`. Adding a workspace picker is straightforward.
- **The `.skill` file** in `legacy/` is a separate Claude skill, unrelated to running the website.

16
db/init.sql Normal file
View File

@@ -0,0 +1,16 @@
-- Schema for the SDE meeting toolkit.
--
-- Design note: each tool (workshop, scope-lock) stores its data as a single
-- named "workspace" whose state is a JSON document. A version counter gives us
-- optimistic concurrency: a save only succeeds if nobody else saved since you
-- loaded, so two people in the same workspace can't silently clobber each other.
CREATE TABLE IF NOT EXISTS workspaces (
tool TEXT NOT NULL, -- 'workshop' | 'scope-lock'
name TEXT NOT NULL, -- workspace name, e.g. 'default'
state JSONB NOT NULL, -- the whole tool state
version INTEGER NOT NULL DEFAULT 1,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by TEXT, -- optional display name of last editor
PRIMARY KEY (tool, name)
);

38
docker-compose.yml Normal file
View File

@@ -0,0 +1,38 @@
# One command to run the whole thing internally: docker compose up -d
# The app comes up on http://<host>:3000
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: sde
POSTGRES_PASSWORD: sde_password
POSTGRES_DB: sde_toolkit
volumes:
- db_data:/var/lib/postgresql/data
# Runs once on first startup to create the schema.
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U sde -d sde_toolkit"]
interval: 5s
timeout: 5s
retries: 10
app:
build: .
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgres://sde:sde_password@db:5432/sde_toolkit
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
ANTHROPIC_MODEL: ${ANTHROPIC_MODEL:-claude-sonnet-5}
PORT: 3000
ORIGIN: ${ORIGIN:-http://localhost:3000}
ports:
- "3000:3000"
volumes:
db_data:

2396
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "sde-meeting-toolkit",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Project SDE meeting toolkit: Field Problem Workshop and Scope Lock Suite as a shared multi-user web app.",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"start": "node build",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"db:init": "node scripts/db-init.js"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.9",
"@sveltejs/kit": "^2.8.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@types/pg": "^8.20.0",
"svelte": "^5.1.0",
"svelte-check": "^4.0.0",
"typescript": "^5.6.0",
"vite": "^5.4.0"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.32.0",
"pg": "^8.13.0"
}
}

59
src/app.css Normal file
View File

@@ -0,0 +1,59 @@
/* Shared design tokens and base styles, carried over from the original tools
so the look and feel stays consistent across both apps. */
:root {
--bg: #f4f4f4;
--layer: #ffffff;
--layer-hover: #e8e8e8;
--border: #e0e0e0;
--border-strong: #8d8d8d;
--text: #161616;
--text-secondary: #525252;
--text-helper: #6f6f6f;
--interactive: #0f62fe;
--interactive-hover: #0353e9;
--danger: #da1e28;
--success: #24a148;
--warning-bg: #fdf6dd;
--warning: #8e6a00;
--purple: #8a3ffc;
--teal: #007d79;
--gray-tag: #e0e0e0;
--focus: #0f62fe;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'IBM Plex Sans', 'Segoe UI', system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
font-size: 16px;
line-height: 1.4;
}
button {
font-family: inherit;
cursor: pointer;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
a {
color: var(--interactive);
}
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
}
}

12
src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

12
src/app.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,49 @@
<script lang="ts">
import type { SyncStatus } from '$lib/workspace.svelte';
let { status, updatedAt }: { status: SyncStatus; updatedAt: string | null } = $props();
const labels: Record<SyncStatus, string> = {
loading: 'Loading…',
idle: 'Saved',
saving: 'Saving…',
saved: 'Saved',
conflict: 'Reloaded (teammate edit)',
error: 'Save error'
};
let time = $derived(
updatedAt ? new Date(updatedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
);
</script>
<span class="sync sync-{status}" title={time ? `Last saved ${time}` : ''}>
<span class="dot"></span>{labels[status]}
</span>
<style>
.sync {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #c6c6c6;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #6f6f6f;
}
.sync-saved .dot,
.sync-idle .dot {
background: var(--success);
}
.sync-saving .dot {
background: var(--warning);
}
.sync-error .dot,
.sync-conflict .dot {
background: var(--danger);
}
</style>

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import { toast } from '$lib/toast.svelte';
</script>
<div class="toast" class:show={toast.visible}>{toast.message}</div>
<style>
.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 0.2s;
z-index: 50;
}
.toast.show {
opacity: 1;
}
</style>

33
src/lib/defaults/index.ts Normal file
View File

@@ -0,0 +1,33 @@
import type { ToolId } from '$lib/types';
import { workshopDefaultState } from './workshop';
import { scopeLockDefaultState } from './scope-lock';
/** The tools known to the app, with display metadata for the home page. */
export const TOOLS: { id: ToolId; title: string; blurb: string }[] = [
{
id: 'workshop',
title: 'Field Problem Workshop',
blurb: 'Capture field problems, cluster them into root problems, vote, build breadcrumb trails to measurable outcomes, and assign actions.'
},
{
id: 'scope-lock',
title: 'Scope Lock Meeting Suite',
blurb: 'Scope Boundary Board, MVP Priority Ranker, Decision Registry, and Micron Pilot Readiness in one place.'
}
];
/** Return a fresh copy of the baseline state for a tool. */
export function defaultStateFor(tool: ToolId): unknown {
switch (tool) {
case 'workshop':
return workshopDefaultState();
case 'scope-lock':
return scopeLockDefaultState();
default:
throw new Error(`Unknown tool: ${tool satisfies never}`);
}
}
export function isToolId(value: string): value is ToolId {
return value === 'workshop' || value === 'scope-lock';
}

View File

@@ -0,0 +1,90 @@
// Baseline state for the Scope Lock Meeting Suite (Micron pilot starting point).
import type { ScopeLockState } from '$lib/types';
export const DECISION_TYPES = ['Scope', 'Technical', 'Process', 'Data', 'Pilot', 'Other'];
export const DECISION_OWNERS = [
'Nick',
'Tim',
'Cameron',
'Clinton',
'Jason',
'Wyman',
'Ryan',
'Brendan',
'Lee',
'Team',
'Other'
];
export const DECISION_STATUSES = ['Locked', 'Provisional', 'Revisit'] as const;
// Readiness status labels, indexed by ScopeLock ready item `s`.
export const READY_STATES = ['Not started', 'In progress', 'Ready', 'Blocked'];
export function scopeLockDefaultState(): ScopeLockState {
return {
scope: [
// Locked pre-decisions surfaced so the room sees them, not to relitigate them
{ id: 's1', t: 'IO list (types + instances, COIN schema)', b: 'mvp', lock: true },
{ id: 's2', t: 'Cable list', b: 'mvp', lock: true },
{ id: 's3', t: 'Wire list', b: 'mvp', lock: true },
{ id: 's4', t: 'Construction status tracking (asset level)', b: 'mvp', lock: true },
{ id: 's5', t: 'Commissioning / Cx workflows and signoffs', b: 'out', lock: true },
{ id: 's6', t: 'Blender / 3D visualization app', b: 'later', lock: true },
// Open items, unsorted
{ id: 's7', t: 'Mobile field entry (phone / tablet)', b: '', lock: false },
{ id: 's8', t: 'Offline mode with sync', b: '', lock: false },
{ id: 's9', t: 'Photo attachments on status updates', b: '', lock: false },
{ id: 's10', t: 'Progress rollups by system / area', b: '', lock: false },
{ id: 's11', t: 'Weighted progress rules (per asset class / stage)', b: '', lock: false },
{ id: 's12', t: 'Plan vs actual variance reporting', b: '', lock: false },
{ id: 's13', t: 'Standup / weekly progress report export', b: '', lock: false },
{ id: 's14', t: 'Role based access (PM / Field / QC / Admin)', b: '', lock: false },
{ id: 's15', t: 'QC hold points inside construction tracking', b: '', lock: false },
{ id: 's16', t: 'IWP / work package linkage (AWP alignment)', b: '', lock: false },
{ id: 's17', t: 'controls.dev API read/write for field updates', b: '', lock: false },
{ id: 's18', t: 'PrimeFlow import (legacy IO data)', b: '', lock: false },
{ id: 's19', t: 'Acumatica integration (cost codes / hours)', b: '', lock: false },
{ id: 's20', t: 'Parts / device library management', b: '', lock: false },
{ id: 's21', t: 'Panel / termination level granularity', b: '', lock: false },
{ id: 's22', t: 'Dashboards with R/Y/G indicators', b: '', lock: false },
{ id: 's23', t: 'Labor / crew hours tracking', b: '', lock: false },
{ id: 's24', t: 'GC / owner tool data exchange (Micron side)', b: '', lock: false },
{ id: 's25', t: 'Project templates and cloning', b: '', lock: false },
{ id: 's26', t: 'Audit trail (user / timestamp on every update)', b: '', lock: false },
{ id: 's27', t: 'Change / revision handling for engineering data', b: '', lock: false },
{ id: 's28', t: 'Excel import/export round trip', b: '', lock: false },
{ id: 's29', t: 'Training materials and quick start guides', b: '', lock: false },
{ id: 's30', t: 'In app notifications / @mentions', b: '', lock: false }
],
rank: [
{ id: 'r1', t: 'Mobile field entry', n: 'Named top priority in prior sessions' },
{ id: 'r2', t: 'Reporting (standup + rollups)', n: '' },
{ id: 'r3', t: 'Integration and phasing (controls.dev sync)', n: '' },
{ id: 'r4', t: 'Role based access', n: '' },
{ id: 'r5', t: 'Ease of training and adoption', n: '' },
{ id: 'r6', t: 'Plan vs actual variance', n: '' },
{ id: 'r7', t: 'Weighted progress rules', n: '' }
],
cutAfter: 5,
decisions: [],
ready: [
{ id: 'd1', g: 'Data', t: 'Micron IO list source identified and format confirmed', w: 'Which system of record, which export format, who provides it', o: '', s: 0 },
{ id: 'd2', g: 'Data', t: 'Cable and wire list data availability confirmed', w: '', o: '', s: 0 },
{ id: 'd3', g: 'Data', t: 'Asset hierarchy agreed (Building > System > Panel > Device > Termination)', w: 'Depth of tracking granularity for the pilot', o: '', s: 0 },
{ id: 'd4', g: 'Data', t: 'Import format and refresh cadence agreed with engineering', w: 'One time load vs live sync for the pilot', o: '', s: 0 },
{ id: 'd5', g: 'Platform', t: 'controls.dev instance provisioned for Micron project', w: '', o: 'Cameron', s: 0 },
{ id: 'd6', g: 'Platform', t: 'API endpoints ready for field status writes', w: '', o: 'Cameron', s: 0 },
{ id: 'd7', g: 'Platform', t: 'Role definitions configured (PM / Field / QC / Admin)', w: '', o: '', s: 0 },
{ id: 'd8', g: 'Field', t: 'Field devices confirmed (tablets / phones, count and type)', w: '', o: 'Wyman', s: 0 },
{ id: 'd9', g: 'Field', t: 'Fab site network / device security constraints understood', w: 'Semiconductor site restrictions on devices, cameras, connectivity', o: 'Wyman', s: 0 },
{ id: 'd10', g: 'Field', t: 'Field team availability windows confirmed with Micron schedule', w: '', o: 'Wyman', s: 0 },
{ id: 'd11', g: 'People', t: 'Pilot cohort named, including at least one named skeptic', w: 'Change management framework requirement', o: 'Nick', s: 0 },
{ id: 'd12', g: 'People', t: 'Friction routing loop defined (who owns fixing what the field reports)', w: '', o: '', s: 0 },
{ id: 'd13', g: 'People', t: 'Training approach and materials scoped', w: '', o: '', s: 0 },
{ id: 'd14', g: 'Governance', t: 'Pilot success metrics defined (behavioral, not sentiment)', w: 'Update compliance, entry latency, duplicate entry eliminated; not satisfaction scores', o: 'Nick', s: 0 },
{ id: 'd15', g: 'Governance', t: 'Explicit stop path defined (what triggers pausing the pilot)', w: '', o: '', s: 0 },
{ id: 'd16', g: 'Governance', t: 'Baseline current state process documented before go live', w: 'Avoids reconciliation gap with change management framework', o: '', s: 0 }
]
};
}

View File

@@ -0,0 +1,189 @@
// Baseline state and static reference data for the Field Problem Workshop.
// This is the "July 21 session" starting point the tool resets to.
import type {
Breadcrumb,
Cluster,
Problem,
ProblemType,
WorkshopState
} from '$lib/types';
export const PEOPLE = [
'Nick',
'Tim',
'Jason',
'Brendan',
'Lee',
'Cody',
'Clinton',
'Wyman',
'Cameron',
'Ryan',
'Team'
];
export const TYPES: ProblemType[] = ['Problem', 'Question', 'Idea'];
// Theme-analysis suggestions shown as "ghost" rows inside each cluster.
export const SUGGESTIONS: { cluster: string; t: string }[] = [
{ cluster: 'c1', t: 'There is no shared definition of what counts as complete for each asset stage, so reported percent complete means different things by team' },
{ cluster: 'c1', t: 'Percent complete is count based, not effort based: a 2 hour device and a 2 day panel move the number the same amount' },
{ cluster: 'c1', t: 'Turnover readiness by system is not visible, so incomplete predecessor work is discovered at commissioning' },
{ cluster: 'c2', t: 'The field gets no feedback that their updates were used, so the discipline to enter data decays' },
{ cluster: 'c2', t: 'As-built and redline data is not captured at completion, so closeout becomes a reconstruction project' },
{ cluster: 'c2', t: 'Each project invents its own tracker (Excel, paper, whiteboard), so nothing rolls up across projects' },
{ cluster: 'c3', t: 'Permits, LOTO, and energization requirements are not confirmed before work is released, causing same day stand downs' },
{ cluster: 'c3', t: 'Work areas are not confirmed released by the GC or other trades before crews are sent' },
{ cluster: 'c3', t: 'There is no defined readiness check before work is released; each foreman decides on their own what to verify' },
{ cluster: 'c4', t: 'Crews do not see a lookahead: the plan lives with the PM or GF, so the next assignment travels verbally' },
{ cluster: 'c4', t: 'Commitments made at standup are not tracked, so we never know what percent of promised work actually completed' },
{ cluster: 'c4', t: 'There is no visibility to which crews or skills free up when, so reassignment is reactive' },
{ cluster: 'c5', t: 'Extra work and T&M are not captured at the time they are performed, so recoverable cost is lost' },
{ cluster: 'c5', t: 'There is no single owner of the change log; pending changes live in email threads' },
{ cluster: 'c5', t: 'Scope splits at contract boundaries are not documented anywhere the field can check' },
{ cluster: 'c6', t: 'There is no traceable record of who installed or verified a termination, so failure investigations start from zero' },
{ cluster: 'c6', t: 'The field cannot tell whether the drawing in hand is the current revision without calling the office' },
{ cluster: 'c6', t: 'QA hold points are not enforced by the process; skipping a gate has no visible consequence until rework' },
{ cluster: 'c7', t: 'Estimating does not receive field actuals in a usable form, so estimates cannot learn from performance' }
];
function cluster(id: string, name: string, root: string, ws = ''): Cluster {
return { id, name, root, ws, open: false, dots: 0, promoted: false };
}
function problem(
id: string,
t: string,
who: string,
cluster: string,
type: ProblemType = 'Problem'
): Problem {
return { id, t, who, cluster, type, sugg: false };
}
function breadcrumb(o: Partial<Breadcrumb> & { id: string }): Breadcrumb {
return {
example: false,
type: 'Outcome',
prob: '',
related: [],
driver: '',
cap: '',
metric: '',
method: '',
baseline: '',
target: '',
...o
};
}
export function workshopDefaultState(): WorkshopState {
return {
clusters: [
cluster('c1', 'Progress Visibility', 'We cannot answer where a project stands: no trustworthy, phase level percent complete exists', 'Tracking MVP (pilot)'),
cluster('c2', 'Data Freshness & Entry Burden', 'Field data is captured late, on paper, or more than once, so the record is stale and expensive to maintain'),
cluster('c3', 'Constraint Readiness', 'Work is released before it is ready: material, tools, and information are not confirmed available before crews start', 'IWP Tool Suite'),
cluster('c4', 'Planning & Resources', 'Crews and planners lack visibility to backlog, priorities, and resource availability, producing idle time and churn'),
cluster('c5', 'Scope Control', 'Scope changes and gaps move faster than our change control: work happens without approval or falls out entirely', 'AWP Process RFP'),
cluster('c6', 'Quality & Spec Conformance', 'The field cannot reliably build to the approved design: specs, revisions, and QA gates do not reach the point of work', 'AWP Process RFP'),
cluster('c7', 'Knowledge & Integration', 'What we learn does not travel: knowledge stays with individuals and external platforms stay closed to us')
],
problems: [
problem('p01', 'No one can reliably answer "where are we at / what percent complete" on a project', 'Jason', 'c1'),
problem('p05', 'Percent complete is needed by phase (engineering, BIM, tray/conduit), not just one overall number', 'Jason', 'c1'),
problem('p27', 'Work started but not finished. (ie field hits constraint and forgets to return to the scope)', 'Cody', 'c1'),
problem('p17', 'Constraints (ie trade stacking) not being captured in real time. No visibility to GF / Super loosing the ability to better communicate at stand up meetings', 'Nick', 'c1'),
problem('p34', 'Install at risk - BIM has no visibility to what has been installed without being captured in the model.', 'Team', 'c1'),
problem('p02', 'Field data goes stale because updates are not entered when the work completes', 'Tim', 'c2'),
problem('p37', 'Data is being captured in the field on paper and brought back to the office to be converted to digital (ie QA check lists, Time sheets, daily log)', 'Team', 'c2'),
problem('p08', 'Crews walk back to the trailer to turn in daily logs and forms', 'Tim', 'c2'),
problem('p09', 'Getting assets, IO, and cable lists into a workable relational database takes heavy manual entry', 'Jason', 'c2'),
problem('p10', 'Double entry between our tools and the GC platform on every project', 'Jason', 'c2'),
problem('p36', 'cable pull list has duplicate or missing cable', 'Team', 'c2'),
problem('p28', 'material is not available when the scope is executed.', 'Nick', 'c3'),
problem('p29', 'Equipment / tools are not available when the scope is being executed (ladders, lift)', 'Team', 'c3'),
problem('p06', 'We overspend on material because nobody knows where material is', 'Nick', 'c3'),
problem('p18', 'working with incomplete data sets (ie installing with no BIM)', 'Cody', 'c3'),
problem('p22', 'poor visibility to predecessors / requirements for the work being done. (ie if a peice of equipment needs to be commissioned but it requires PTP backbone to be operational on the other side of the building))', 'Tim', 'c3'),
problem('p04', 'Idle time: people stand around waiting for their next assignment', 'Tim', 'c4'),
problem('p14', 'Foreman dont have good visibility to work backlog causing Idle time.', 'Nick', 'c4'),
problem('p16', 'Bim starts work on priority 1, then told to stop and work on priority 3, this repeats', 'Cody', 'c4'),
problem('p19', 'Resource availability is not availible to project teams (ie if a PM needs a tech, where can they get one?)', 'Nick', 'c4'),
problem('p42', 'Progress halts due to loss of resource (ie pto, terminated)', 'Clinton', 'c4'),
problem('p30', 'Scope takes longer than expected (could this be mitigated by including the field team in estimates)', 'Team', 'c4'),
problem('p03', 'scope gap - scope performed without approved change order.', 'Clinton', 'c5'),
problem('p40', 'scope gap - in scope not performed as it was lost in pre-contruction activities (ie dropped from the io list)', 'Clinton', 'c5'),
problem('p41', 'Scope Gap - who terminates the back bone fiber?', 'Clinton', 'c5'),
problem('p35', 'Field team taking direction from external sources causing rework as it was not approved through the proper channels.', 'Team', 'c5'),
problem('p33', 'When field team deviates from plan failure to communicate that back to design (ie redlines during the course of work rather than at the end)', 'Team', 'c5'),
problem('p07', 'Field rework happens because crews do not have the latest drawings', 'Nick', 'c6'),
problem('p15', 'Rework caused by construction tracking not having the correct data', 'Cody', 'c6'),
problem('p23', 'Failure in sequencing, missing the QA gate (ie terminating a cable that is TSP when it should have been a 4 Conductor)', 'Cody', 'c6'),
problem('p24', 'wrong type of conduit installed due to environmental requirements ( ie Class 1 div 2 area had EMT installed) failure mode: out of spec', 'Clinton', 'c6'),
problem('p20', 'installed equipment doesnt match submittals', 'Brendan', 'c6'),
problem('p21', 'install material used was does not match whats approved in the submittal', 'Nick', 'c6'),
problem('p25', 'Specs not accessible to field installation teams', 'Clinton', 'c6'),
problem('p31', 'visability to chain of custody of project scope, drawings, design intent (who do I ask when constructability issue arises)', 'Team', 'c6'),
problem('p32', 'field teams fail to follow the designed workflow/ install.', 'Team', 'c6'),
problem('p13', 'Punch list / issue follow-through is not measured; time to close is unknown', 'Nick', 'c7'),
problem('p43', 'Tribal knowledge is lost after someone leaves or is on PTO', 'Clinton', 'c7'),
problem('p38', 'Lessons learned - Construction methods could be more efficient with one team and this is not shared with the other.', 'Team', 'c7'),
problem('p39', 'What are all of the ways we are reinventing the wheel?', 'Team', 'c7', 'Question'),
problem('p26', 'Suggestion improvement: create info graphics and other simplified ways of communicating project specs', 'Clinton', 'c7', 'Idea'),
problem('p11', 'GC platform sync/access requests get denied; we have never reached the people who actually own the decision', 'Tim', 'c7'),
problem('p12', 'Platform changes GC to GC, so crews face retraining on a new tool nearly every project', 'Brendan', 'c7')
],
breadcrumbs: [
breadcrumb({
id: 'b1',
example: true,
type: 'Outcome',
prob: 'Crews walk back to the trailer to turn in daily logs and forms',
driver: 'Minimize non productive field time; keep craft hours on the work face',
cap: 'Mobile field entry from the work area (phone / tablet, offline capable)',
metric: '% of daily logs submitted from the work area on the same day',
method: 'System timestamps on log submission vs work date',
baseline: 'Field poll of minutes per day spent on paperwork and walking, taken before pilot'
}),
breadcrumb({
id: 'b2',
type: 'Outcome',
prob: 'No one can reliably answer "where are we at / what percent complete" on a project',
driver: 'man power / work planning',
cap: 'construction tracking / resource planning',
metric: 'do we have a tool, tool has been implemented and adopted, ~>80% visibility to the project progress'
}),
breadcrumb({ id: 'b3', type: 'Outcome', prob: 'Field data goes stale because updates are not entered when the work completes' }),
breadcrumb({ id: 'b4', type: 'Outcome', prob: 'scope gap - scope performed without approved change order.' }),
breadcrumb({
id: 'b5',
type: 'Outcome',
prob: 'Double entry between our tools and the GC platform on every project',
driver: 'Reduced duplicate entry',
cap: 'Single system of entry with automated propagation',
metric: 'Number of systems requiring manual entry of the same record',
method: 'Trace 10 representative records end to end',
baseline: 'Count on a current project before pilot',
target: '1 internal system of entry'
}),
breadcrumb({
id: 'b6',
type: 'Adoption',
prob: 'Supporting adoption measure for the pilot',
driver: 'Field usage',
cap: 'Pilot tool in daily field use',
metric: '% of assigned field users active weekly',
method: 'Platform usage analytics',
baseline: 'n/a, starts at zero'
})
],
actions: [
{ id: 'a1', t: 'Review cluster assignments and root problem statements as a team; move issues that landed in the wrong cluster', owner: '', due: '', done: false },
{ id: 'a2', t: 'Complete breadcrumb: Field data goes stale because updates are not entered when the work completes', owner: '', due: '', done: false },
{ id: 'a3', t: 'Complete breadcrumb: scope gap - scope performed without approved change order.', owner: '', due: '', done: false },
{ id: 'a4', t: 'Rework the percent complete breadcrumb: current metric is an adoption statement, needs a real outcome metric with method and baseline', owner: '', due: '', done: false },
{ id: 'a5', t: 'Assign workstreams to the three unmapped clusters (Data Freshness, Planning & Resources, Knowledge & Integration)', owner: '', due: '', done: false }
]
};
}

83
src/lib/server/ai.ts Normal file
View File

@@ -0,0 +1,83 @@
// Server-side "AI draft" for breadcrumb fields. The Anthropic API key lives here
// (from an env var) and never reaches the browser.
import Anthropic from '@anthropic-ai/sdk';
import { env } from '$env/dynamic/private';
export const REQUIRED_FIELDS = ['driver', 'cap', 'metric', 'method', 'baseline'] as const;
export const ADOPTION_WORDS =
/implement|adopt|roll ?out|have a tool|tool exists|usage|active users?|users? active/i;
export interface DraftResult {
domain: string;
driver: string;
cap: string;
metric: string;
method: string;
baseline: string;
}
const METHOD_KB = `Domain playbook (anchor methodology -> standard metrics/methods/baselines):
1 PROGRESS VISIBILITY - Earned Value Mgmt, rules of credit. Metrics: claiming accuracy (reported % vs field-verified % within tolerance, by phase; method: monthly spot audit of 30+ sampled assets; baseline: first audit); forecast reliability (forecast vs actual completion; baseline: last project's reports). Never use "% of assets with a status" (adoption in disguise).
2 DATA FRESHNESS / ENTRY BURDEN - lean data flow, single source of truth. Metrics: entry latency (% of updates within 24h of completion; method: system timestamp vs completion date, weekly; baseline: audit paper-to-digital lag on current project); touch count (systems the same record is entered into; method: trace 10 records end to end; baseline: pre-pilot trace); non-productive data minutes/person/day (method+baseline: structured field poll pre-pilot, repeat midpoint).
3 CONSTRAINT READINESS / WORK RELEASE - CII AWP IR 272-2, constraint-free IWP release (drawings, material, tools, labor, access, predecessors, permits, quality docs, scaffolding). Metrics: % releases issued constraint-free (method: release-gate checklist audit weekly, later constraint stop = miss; baseline: audit last month of work starts from daily logs); stand-down count/week (method: foreman log with fixed reason list; baseline: 2-week logging exercise); tool time % (method: activity sampling study; baseline: pre-pilot study; propose only when stakes justify cost).
4 PLANNING / RESOURCES / IDLE - Last Planner System. Metrics: PPC (% weekly commitments completed as promised; method: log at weekly planning, score next week, publish trend; baseline: first 4 weeks of logging); reasons-for-variance distribution (fixed-list coding of misses); idle hours/crew-week (timecard or foreman reason codes; baseline: 2-week coding).
5 SCOPE CONTROL / CHANGE - PMBOK change control, CII change research. Metrics: unauthorized-work hours/dollars at time of performance (method: monthly reconciliation of field records vs CO log; baseline: run reconciliation on current project); change capture latency days (field record date vs log date); change cycle time (log timestamps; baseline: last project's log).
6 QUALITY / REWORK - first-time quality, CII rework research (2-6% direct cost where measured). Metrics: rework % of craft hours (method: dedicated rework cost code + reason coding, monthly; baseline: create the code, first month is baseline); first-pass yield at QC gates; out-of-rev incidents (QC walkdown coding).
7 KNOWLEDGE / FEEDBACK - CII lessons-learned, estimate feedback. Metrics: closeout compliance % within 60 days (method: closeout checklist audit; baseline: audit last 4-6 projects); % estimates referencing prior actuals (estimating file audit); punch closure median days (issue log timestamps; if untimestamped, that gap is the finding).
GATES: driver = business end in cost/schedule/quality/safety/predictability terms, never a tool. capability = function not product, no vendor names. metric = outcome ratio/rate, NEVER "implemented/adopted/rolled out/active users/have a tool". method = data source + comparison + cadence. baseline = a number or the named pre-pilot study that produces one (cheapest defensible: poll < record trace < log audit < 2-week coding < sampling study). Set targets after baseline, not before. Each field under ~30 words except method/baseline.`;
function parseJson(text: string): Record<string, unknown> {
const clean = String(text).replace(/```json|```/g, '').trim();
const candidates = ['[', '{'].map((ch) => {
const i = clean.indexOf(ch);
return i === -1 ? Infinity : i;
});
const start = Math.min(...candidates);
return JSON.parse(start === Infinity ? clean : clean.slice(start));
}
/** True when the AI feature is configured. Used to disable the button gracefully. */
export function aiConfigured(): boolean {
return Boolean(env.ANTHROPIC_API_KEY);
}
export async function draftBreadcrumb(prob: string, related: string[]): Promise<DraftResult> {
const apiKey = env.ANTHROPIC_API_KEY;
if (!apiKey) throw new Error('AI is not configured on this server (missing ANTHROPIC_API_KEY).');
if (!prob.trim()) throw new Error('Write the problem statement first.');
const client = new Anthropic({ apiKey });
const rel = related.length
? `\nRelated issues in this cluster:\n${related.map((r) => '- ' + r).join('\n')}`
: '';
const prompt = `You populate breadcrumb trails for construction field problems at an industrial I&C contractor, using best-in-class execution methodology.\n\n${METHOD_KB}\n\nProblem statement:\n${prob}${rel}\n\nClassify the problem into one domain above, then draft the five fields per that domain's standard metrics, methods, and baselines, obeying every GATE. Respond ONLY with a JSON object, no prose, no markdown fences:\n{"domain":"...","driver":"...","cap":"...","metric":"...","method":"...","baseline":"..."}`;
const message = await client.messages.create({
model: env.ANTHROPIC_MODEL || 'claude-sonnet-5',
max_tokens: 1000,
messages: [{ role: 'user', content: prompt }]
});
const text = message.content
.filter((block): block is Anthropic.TextBlock => block.type === 'text')
.map((block) => block.text)
.join('\n');
const obj = parseJson(text);
for (const f of REQUIRED_FIELDS) {
if (!obj[f]) throw new Error('AI returned an incomplete draft; try again.');
}
if (ADOPTION_WORDS.test(String(obj.metric))) {
throw new Error('Draft failed the adoption gate; try again.');
}
return {
domain: String(obj.domain ?? 'methodology'),
driver: String(obj.driver),
cap: String(obj.cap),
metric: String(obj.metric),
method: String(obj.method),
baseline: String(obj.baseline)
};
}

View File

@@ -0,0 +1,27 @@
// Postgres connection pool. Created lazily on first use so the module can be
// imported during build/analysis without DATABASE_URL being present.
import pg from 'pg';
import { env } from '$env/dynamic/private';
let pool: pg.Pool | null = null;
function getPool(): pg.Pool {
if (pool) return pool;
const connectionString = env.DATABASE_URL;
if (!connectionString) {
// Fail loudly on first query rather than mysteriously later.
throw new Error(
'DATABASE_URL is not set. Copy .env.example to .env (or set it in docker-compose) and try again.'
);
}
pool = new pg.Pool({ connectionString });
return pool;
}
/** Small helper so call sites read as `query(sql, params)`. */
export function query<T extends pg.QueryResultRow = pg.QueryResultRow>(
text: string,
params?: unknown[]
): Promise<pg.QueryResult<T>> {
return getPool().query<T>(text, params);
}

View File

@@ -0,0 +1,107 @@
// Repository for reading and writing tool workspaces.
//
// Concurrency model: optimistic locking on an integer `version`. A client loads
// a workspace (getting its version), edits locally, then saves with that same
// version as `baseVersion`. If nobody else saved in between, the row's version
// still matches and the write wins (bumping version). If someone else saved
// first, the versions differ, no row updates, and we report a conflict so the
// client can reload instead of clobbering their teammate.
import { query } from './index';
import { defaultStateFor } from '$lib/defaults';
import type { ToolId, WorkspaceEnvelope } from '$lib/types';
interface Row {
tool: ToolId;
name: string;
state: unknown;
version: number;
updated_at: Date;
updated_by: string | null;
}
function toEnvelope<T>(row: Row): WorkspaceEnvelope<T> {
return {
tool: row.tool,
name: row.name,
state: row.state as T,
version: row.version,
updatedAt: row.updated_at.toISOString(),
updatedBy: row.updated_by
};
}
/**
* Load a workspace. If it does not exist yet, seed it from the tool's default
* baseline state so the first visitor sees a populated, useful tool.
*/
export async function getOrCreateWorkspace<T>(
tool: ToolId,
name: string
): Promise<WorkspaceEnvelope<T>> {
const existing = await query<Row>(
'SELECT tool, name, state, version, updated_at, updated_by FROM workspaces WHERE tool = $1 AND name = $2',
[tool, name]
);
if (existing.rows.length > 0) {
return toEnvelope<T>(existing.rows[0]);
}
const seed = defaultStateFor(tool);
// ON CONFLICT guards against a race where two first-visitors insert at once.
const inserted = await query<Row>(
`INSERT INTO workspaces (tool, name, state)
VALUES ($1, $2, $3)
ON CONFLICT (tool, name) DO UPDATE SET tool = EXCLUDED.tool
RETURNING tool, name, state, version, updated_at, updated_by`,
[tool, name, seed]
);
return toEnvelope<T>(inserted.rows[0]);
}
export type SaveResult<T> =
| { ok: true; workspace: WorkspaceEnvelope<T> }
| { ok: false; conflict: WorkspaceEnvelope<T> };
/** Save state with optimistic concurrency. */
export async function saveWorkspace<T>(
tool: ToolId,
name: string,
state: T,
baseVersion: number,
updatedBy: string | null
): Promise<SaveResult<T>> {
const updated = await query<Row>(
`UPDATE workspaces
SET state = $3, version = version + 1, updated_at = now(), updated_by = $5
WHERE tool = $1 AND name = $2 AND version = $4
RETURNING tool, name, state, version, updated_at, updated_by`,
[tool, name, state, baseVersion, updatedBy]
);
if (updated.rows.length === 1) {
return { ok: true, workspace: toEnvelope<T>(updated.rows[0]) };
}
// No row updated: either the workspace does not exist, or the version moved.
// Return the current server copy so the client can reconcile.
const current = await getOrCreateWorkspace<T>(tool, name);
return { ok: false, conflict: current };
}
/** Reset a workspace back to the tool's baseline. */
export async function resetWorkspace<T>(
tool: ToolId,
name: string
): Promise<WorkspaceEnvelope<T>> {
const seed = defaultStateFor(tool);
const reset = await query<Row>(
`INSERT INTO workspaces (tool, name, state)
VALUES ($1, $2, $3)
ON CONFLICT (tool, name)
DO UPDATE SET state = EXCLUDED.state, version = workspaces.version + 1, updated_at = now(), updated_by = NULL
RETURNING tool, name, state, version, updated_at, updated_by`,
[tool, name, seed]
);
return toEnvelope<T>(reset.rows[0]);
}

15
src/lib/toast.svelte.ts Normal file
View File

@@ -0,0 +1,15 @@
// Tiny shared toast controller. Import `toast` anywhere and call toast.show('...').
class ToastController {
message = $state('');
visible = $state(false);
#timer: ReturnType<typeof setTimeout> | null = null;
show(msg: string, ms = 2200): void {
this.message = msg;
this.visible = true;
if (this.#timer) clearTimeout(this.#timer);
this.#timer = setTimeout(() => (this.visible = false), ms);
}
}
export const toast = new ToastController();

115
src/lib/types.ts Normal file
View File

@@ -0,0 +1,115 @@
// Shared data types for both meeting tools. These describe exactly what gets
// stored as the JSON "state" of a workspace in the database.
export type ToolId = 'workshop' | 'scope-lock';
// ---------- Field Problem Workshop ----------
export type ProblemType = 'Problem' | 'Question' | 'Idea';
export interface Problem {
id: string;
t: string; // the problem text
who: string; // who raised it
cluster: string; // cluster id, or '' if unclustered
type: ProblemType;
sugg: boolean; // came from theme-analysis suggestions
}
export interface Cluster {
id: string;
name: string;
root: string; // root problem statement
ws: string; // workstream label
open: boolean;
dots: number; // dot-voting count
promoted: boolean;
}
export type BreadcrumbType = 'Outcome' | 'Adoption';
export interface Breadcrumb {
id: string;
example: boolean;
type: BreadcrumbType;
prob: string;
related: string[];
driver: string;
cap: string;
metric: string;
method: string;
baseline: string;
target: string;
}
export interface WorkshopAction {
id: string;
t: string;
owner: string;
due: string;
done: boolean;
}
export interface WorkshopState {
clusters: Cluster[];
problems: Problem[];
breadcrumbs: Breadcrumb[];
actions: WorkshopAction[];
}
// ---------- Scope Lock Suite ----------
export type ScopeBucket = '' | 'mvp' | 'later' | 'out' | 'park';
export interface ScopeItem {
id: string;
t: string;
b: ScopeBucket; // bucket
lock: boolean; // locked pre-decision
}
export interface RankItem {
id: string;
t: string;
n: string; // note
}
export type DecisionStatus = 'Locked' | 'Provisional' | 'Revisit';
export interface Decision {
text: string;
type: string; // Scope / Priority / Technical / Process / ...
owner: string;
status: DecisionStatus;
why: string; // rationale
trade: string; // trade-off accepted
at: string; // time logged, e.g. "02:15 PM"
}
export interface ReadyItem {
id: string;
g: string; // group (Data / Platform / Field / People / Governance)
t: string; // item text
w: string; // "what it means" note
o: string; // owner
s: number; // status (0 = not started, etc.)
}
export interface ScopeLockState {
scope: ScopeItem[];
rank: RankItem[];
cutAfter: number;
decisions: Decision[];
ready: ReadyItem[];
}
// ---------- Workspace envelope (what the API returns) ----------
export interface WorkspaceEnvelope<T> {
tool: ToolId;
name: string;
state: T;
version: number;
updatedAt: string;
updatedBy: string | null;
}

138
src/lib/workspace.svelte.ts Normal file
View File

@@ -0,0 +1,138 @@
// Reactive client-side workspace synchronizer.
//
// Usage in a component:
// const ws = new Workspace<WorkshopState>('workshop', 'default');
// onMount(() => ws.init());
// ... read/write ws.state ...; call ws.touch() after any mutation.
//
// It debounces saves, uses the version returned by the server for optimistic
// concurrency, and polls so another person's saves appear here too.
import type { ToolId, WorkspaceEnvelope } from './types';
export type SyncStatus = 'loading' | 'idle' | 'saving' | 'saved' | 'conflict' | 'error';
const SAVE_DEBOUNCE_MS = 700;
const POLL_INTERVAL_MS = 6000;
export class Workspace<T> {
tool: ToolId;
name: string;
state = $state<T | null>(null);
version = $state(0);
status = $state<SyncStatus>('loading');
updatedAt = $state<string | null>(null);
message = $state('');
#saveTimer: ReturnType<typeof setTimeout> | null = null;
#pollTimer: ReturnType<typeof setInterval> | null = null;
#dirty = false; // local edits not yet saved
#saving = false;
constructor(tool: ToolId, name = 'default') {
this.tool = tool;
this.name = name;
}
get base(): string {
return `/api/workspaces/${this.tool}/${encodeURIComponent(this.name)}`;
}
async init(): Promise<void> {
await this.load();
this.#pollTimer = setInterval(() => this.#poll(), POLL_INTERVAL_MS);
}
destroy(): void {
if (this.#saveTimer) clearTimeout(this.#saveTimer);
if (this.#pollTimer) clearInterval(this.#pollTimer);
}
async load(): Promise<void> {
this.status = 'loading';
try {
const res = await fetch(this.base);
if (!res.ok) throw new Error(`load failed (${res.status})`);
const env = (await res.json()) as WorkspaceEnvelope<T>;
this.#adopt(env);
this.status = 'idle';
} catch (e) {
this.status = 'error';
this.message = e instanceof Error ? e.message : 'Could not load';
}
}
#adopt(env: WorkspaceEnvelope<T>): void {
this.state = env.state;
this.version = env.version;
this.updatedAt = env.updatedAt;
this.#dirty = false;
}
/** Call after mutating `state`. Schedules a debounced save. */
touch(): void {
this.#dirty = true;
this.status = 'saving';
if (this.#saveTimer) clearTimeout(this.#saveTimer);
this.#saveTimer = setTimeout(() => this.save(), SAVE_DEBOUNCE_MS);
}
async save(): Promise<void> {
if (this.#saving || this.state === null) return;
this.#saving = true;
try {
const res = await fetch(this.base, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ state: $state.snapshot(this.state), baseVersion: this.version })
});
if (res.status === 409) {
// Someone else saved first. Adopt their copy so we don't clobber it.
const { current } = (await res.json()) as { current: WorkspaceEnvelope<T> };
this.#adopt(current);
this.status = 'conflict';
this.message = 'A teammate updated this workspace; their changes were loaded.';
return;
}
if (!res.ok) throw new Error(`save failed (${res.status})`);
const env = (await res.json()) as WorkspaceEnvelope<T>;
this.version = env.version;
this.updatedAt = env.updatedAt;
this.#dirty = false;
this.status = 'saved';
} catch (e) {
this.status = 'error';
this.message = e instanceof Error ? e.message : 'Could not save';
} finally {
this.#saving = false;
}
}
async reset(): Promise<void> {
const res = await fetch(`${this.base}/reset`, { method: 'POST' });
if (res.ok) {
this.#adopt((await res.json()) as WorkspaceEnvelope<T>);
this.status = 'idle';
}
}
async #poll(): Promise<void> {
// Don't stomp on unsaved local edits or an in-flight save.
if (this.#dirty || this.#saving) return;
try {
const res = await fetch(this.base);
if (!res.ok) return;
const env = (await res.json()) as WorkspaceEnvelope<T>;
if (env.version > this.version) this.#adopt(env);
} catch {
// Ignore transient poll failures.
}
}
}
export function uid(): string {
return 'x' + Math.random().toString(36).slice(2, 9);
}

View File

@@ -0,0 +1,6 @@
<script lang="ts">
import '../app.css';
let { children } = $props();
</script>
{@render children()}

108
src/routes/+page.svelte Normal file
View File

@@ -0,0 +1,108 @@
<script lang="ts">
import { TOOLS } from '$lib/defaults';
</script>
<svelte:head><title>Project SDE | Meeting Toolkit</title></svelte:head>
<header>
<div class="brand"><strong>Project SDE</strong> <span>| Meeting Toolkit</span></div>
<div class="spacer"></div>
<div class="meta">Shared workspace · everyone sees the same data</div>
</header>
<main>
<div class="intro">
<h1>Meeting Toolkit</h1>
<p>
Pick a tool. Everything you enter is saved to the shared server automatically, so anyone
who opens the same link works from the same data.
</p>
</div>
<div class="grid">
{#each TOOLS as tool (tool.id)}
<a class="card" href="/{tool.id}">
<h2>{tool.title}</h2>
<p>{tool.blurb}</p>
<span class="go">Open →</span>
</a>
{/each}
</div>
</main>
<style>
header {
background: var(--text);
color: #fff;
padding: 0 24px;
display: flex;
align-items: center;
gap: 12px;
height: 56px;
}
.brand {
font-size: 15px;
}
.brand strong {
font-weight: 600;
}
.brand span {
color: #c6c6c6;
font-weight: 400;
}
.spacer {
flex: 1;
}
.meta {
font-size: 13px;
color: #c6c6c6;
}
main {
padding: 40px 24px;
max-width: 1000px;
margin: 0 auto;
}
.intro h1 {
font-size: 30px;
font-weight: 400;
}
.intro p {
color: var(--text-secondary);
font-size: 16px;
margin-top: 8px;
max-width: 640px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-top: 32px;
}
.card {
display: block;
background: var(--layer);
border: 1px solid var(--border);
border-top: 4px solid var(--interactive);
padding: 24px;
text-decoration: none;
color: var(--text);
transition: box-shadow 0.15s;
}
.card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
.card h2 {
font-size: 20px;
font-weight: 600;
}
.card p {
color: var(--text-secondary);
font-size: 14.5px;
margin: 10px 0 16px;
}
.card .go {
color: var(--interactive);
font-weight: 600;
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,28 @@
import { error, json } from '@sveltejs/kit';
import { draftBreadcrumb } from '$lib/server/ai';
import type { RequestHandler } from './$types';
/**
* POST /api/ai/draft — draft breadcrumb fields from a problem statement.
* Body: { prob: string, related?: string[] }
*/
export const POST: RequestHandler = async ({ request }) => {
let body: { prob?: string; related?: string[] };
try {
body = await request.json();
} catch {
throw error(400, 'Body must be JSON');
}
const prob = String(body.prob ?? '').trim();
const related = Array.isArray(body.related) ? body.related.map(String) : [];
try {
const draft = await draftBreadcrumb(prob, related);
return json(draft);
} catch (e) {
const message = e instanceof Error ? e.message : 'AI draft failed';
// 422: the request was well-formed but the AI step could not complete.
throw error(422, message);
}
};

View File

@@ -0,0 +1,42 @@
import { error, json } from '@sveltejs/kit';
import { isToolId } from '$lib/defaults';
import { getOrCreateWorkspace, saveWorkspace } from '$lib/server/db/workspaces';
import type { RequestHandler } from './$types';
/** GET /api/workspaces/:tool/:name — load (creating from baseline if new). */
export const GET: RequestHandler = async ({ params }) => {
if (!isToolId(params.tool)) throw error(404, 'Unknown tool');
const ws = await getOrCreateWorkspace(params.tool, params.name);
return json(ws);
};
/**
* PUT /api/workspaces/:tool/:name — save with optimistic concurrency.
* Body: { state, baseVersion, updatedBy? }
* 200 with the saved workspace, or 409 with the current server copy on conflict.
*/
export const PUT: RequestHandler = async ({ params, request }) => {
if (!isToolId(params.tool)) throw error(404, 'Unknown tool');
let body: { state?: unknown; baseVersion?: number; updatedBy?: string | null };
try {
body = await request.json();
} catch {
throw error(400, 'Body must be JSON');
}
if (body.state === undefined || body.state === null) throw error(400, 'Missing "state"');
if (typeof body.baseVersion !== 'number') throw error(400, 'Missing numeric "baseVersion"');
const result = await saveWorkspace(
params.tool,
params.name,
body.state,
body.baseVersion,
body.updatedBy ?? null
);
if (result.ok) return json(result.workspace);
// Someone else saved first. Hand back the current copy so the client reconciles.
return json({ conflict: true, current: result.conflict }, { status: 409 });
};

View File

@@ -0,0 +1,11 @@
import { error, json } from '@sveltejs/kit';
import { isToolId } from '$lib/defaults';
import { resetWorkspace } from '$lib/server/db/workspaces';
import type { RequestHandler } from './$types';
/** POST /api/workspaces/:tool/:name/reset — reset to the tool's baseline. */
export const POST: RequestHandler = async ({ params }) => {
if (!isToolId(params.tool)) throw error(404, 'Unknown tool');
const ws = await resetWorkspace(params.tool, params.name);
return json(ws);
};

View File

@@ -0,0 +1,952 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { Workspace, uid } from '$lib/workspace.svelte';
import { toast } from '$lib/toast.svelte';
import Toast from '$lib/components/Toast.svelte';
import SyncBadge from '$lib/components/SyncBadge.svelte';
import {
DECISION_OWNERS,
DECISION_STATUSES,
DECISION_TYPES,
READY_STATES
} from '$lib/defaults/scope-lock';
import type { Decision, ScopeBucket, ScopeLockState } from '$lib/types';
const ws = new Workspace<ScopeLockState>('scope-lock');
onMount(() => ws.init());
onDestroy(() => ws.destroy());
// `s` is a convenience alias; it is null until loaded.
let s = $derived(ws.state);
type Tab = 'board' | 'rank' | 'registry' | 'ready';
let tab = $state<Tab>('board');
const BUCKETS: { k: Exclude<ScopeBucket, ''>; label: string; cls: string; sub: string }[] = [
{ k: 'mvp', label: 'In MVP', cls: 'mvp', sub: 'Ships in the 3 month build' },
{ k: 'later', label: 'Later Phase', cls: 'later', sub: 'Committed, sequenced after MVP' },
{ k: 'out', label: 'Out', cls: 'out', sub: 'Not part of Project SDE' },
{ k: 'park', label: 'Parking Lot', cls: 'park', sub: 'Needs owner + date in Registry' }
];
// ---- Board ----
let newScopeItem = $state('');
function toggleBucket(id: string, b: ScopeBucket) {
if (!s) return;
const item = s.scope.find((i) => i.id === id);
if (item) {
item.b = item.b === b ? '' : b;
ws.touch();
}
}
function delScope(id: string) {
if (!s) return;
s.scope = s.scope.filter((i) => i.id !== id);
ws.touch();
}
function addScope() {
if (!s) return;
const v = newScopeItem.trim();
if (!v) return;
s.scope.push({ id: uid(), t: v, b: '', lock: false });
newScopeItem = '';
ws.touch();
}
// ---- Ranker ----
let newRankItem = $state('');
function moveRank(i: number, dir: -1 | 1) {
if (!s) return;
const j = i + dir;
if (j < 0 || j >= s.rank.length) return;
[s.rank[i], s.rank[j]] = [s.rank[j], s.rank[i]];
ws.touch();
}
function delRank(i: number) {
if (!s) return;
s.rank.splice(i, 1);
if (s.cutAfter > s.rank.length) s.cutAfter = s.rank.length;
ws.touch();
}
function addRank() {
if (!s) return;
const v = newRankItem.trim();
if (!v) return;
s.rank.push({ id: uid(), t: v, n: '' });
newRankItem = '';
ws.touch();
}
function moveCut(dir: -1 | 1) {
if (!s) return;
const next = s.cutAfter + dir;
if (next >= 1 && next <= s.rank.length) {
s.cutAfter = next;
ws.touch();
}
}
// ---- Registry ----
let dText = $state('');
let dType = $state(DECISION_TYPES[0]);
let dOwner = $state(DECISION_OWNERS[0]);
let dStatus = $state<Decision['status']>(DECISION_STATUSES[0]);
let dWhy = $state('');
let dTrade = $state('');
function addDecision() {
if (!s) return;
const text = dText.trim();
if (!text) return;
s.decisions.push({
text,
type: dType,
owner: dOwner,
status: dStatus,
why: dWhy.trim(),
trade: dTrade.trim(),
at: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
});
dText = '';
dWhy = '';
dTrade = '';
ws.touch();
toast.show('Decision logged');
}
function delDecision(i: number) {
if (!s) return;
s.decisions.splice(i, 1);
ws.touch();
}
// ---- Readiness ----
let newReadyItem = $state('');
let readyGroups = $derived(s ? [...new Set(s.ready.map((i) => i.g))] : []);
let readyCounts = $derived.by(() => {
const c = [0, 0, 0, 0];
s?.ready.forEach((i) => c[i.s]++);
return c;
});
function cycleReady(id: string) {
if (!s) return;
const item = s.ready.find((i) => i.id === id);
if (item) {
item.s = (item.s + 1) % 4;
ws.touch();
}
}
function addReady() {
if (!s) return;
const v = newReadyItem.trim();
if (!v) return;
s.ready.push({ id: uid(), g: 'Added in meeting', t: v, w: '', o: '', s: 0 });
newReadyItem = '';
ws.touch();
}
async function reset() {
if (confirm('Reset all four tools to their starting state? This clears everything entered.')) {
await ws.reset();
toast.show('Reset complete');
}
}
// ---- Export ----
function exportSummary() {
if (!s) return;
const d = new Date();
const stamp = d.toISOString().slice(0, 10);
const clean = (v: unknown) => String(v ?? '').replace(/\|/g, '/');
let md = `# Project SDE Scope Lock Meeting Summary\n\nDate: ${d.toLocaleDateString()} \nPilot: Micron \nGenerated from the Scope Lock Meeting Suite\n\n`;
md += `## 1. Scope Boundary Decisions\n\n`;
const bl: Record<string, string> = {
mvp: 'In MVP (3 month construction tracking build)',
later: 'Later Phase',
out: 'Out of Project SDE',
park: 'Parking Lot (needs owner + date)'
};
for (const k of ['mvp', 'later', 'out', 'park']) {
const items = s.scope.filter((i) => i.b === k);
md += `### ${bl[k]}\n`;
md += items.length
? items.map((i) => `- ${i.t}${i.lock ? ' (pre locked)' : ''}`).join('\n') + '\n\n'
: '- None\n\n';
}
const uns = s.scope.filter((i) => !i.b);
if (uns.length) md += `### UNRESOLVED (never sorted)\n` + uns.map((i) => `- ${i.t}`).join('\n') + '\n\n';
md += `## 2. MVP Priority Ranking\n\nDescope line after position ${s.cutAfter}. Items below the line are descoped first if the timeline slips.\n\n`;
s.rank.forEach((r, i) => {
md += `${i + 1}. ${r.t}${i + 1 === s.cutAfter ? '\n---- DESCOPE LINE ----' : ''}\n`;
});
md += '\n';
md += `## 3. Decision Registry\n\n`;
if (s.decisions.length) {
md += `| # | Decision | Type | Owner | Rationale | Trade off | Status | Time |\n|---|---|---|---|---|---|---|---|\n`;
s.decisions.forEach((x, i) => {
md += `| ${i + 1} | ${clean(x.text)} | ${x.type} | ${x.owner} | ${clean(x.why)} | ${clean(x.trade)} | ${x.status} | ${x.at || ''} |\n`;
});
md += '\n';
} else md += 'No decisions were logged.\n\n';
md += `## 4. Micron Pilot Readiness\n\n`;
[...new Set(s.ready.map((i) => i.g))].forEach((g) => {
md += `### ${g}\n`;
s.ready
.filter((i) => i.g === g)
.forEach((i) => {
md += `- [${READY_STATES[i.s]}] ${i.t}${i.o ? ` (Owner: ${i.o})` : ''}\n`;
});
md += '\n';
});
const blocked = s.ready.filter((i) => i.s === 3);
if (blocked.length)
md += `### Blocked items requiring decisions\n` + blocked.map((i) => `- ${i.t}${i.o ? ` (Owner: ${i.o})` : ''}`).join('\n') + '\n';
const blob = new Blob([md], { type: 'text/markdown' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `SDE_Scope_Lock_Summary_${stamp}.md`;
a.click();
URL.revokeObjectURL(a.href);
toast.show('Summary downloaded');
}
let unsortedCount = $derived(s ? s.scope.filter((i) => !i.b).length : 0);
</script>
<svelte:head><title>Project SDE | Scope Lock Meeting Suite</title></svelte:head>
<header>
<div class="brand"><a href="/">Project SDE</a> <span>| Scope Lock Meeting Suite</span></div>
<div class="spacer"></div>
<SyncBadge status={ws.status} updatedAt={ws.updatedAt} />
<button class="hdr-btn" onclick={reset}>Reset all</button>
<button class="hdr-btn primary" onclick={exportSummary}>Export meeting summary</button>
</header>
<nav>
<button class:active={tab === 'board'} onclick={() => (tab = 'board')}>
1. Scope Boundary Board <span class="count">{unsortedCount}</span>
</button>
<button class:active={tab === 'rank'} onclick={() => (tab = 'rank')}>2. MVP Priority Ranker</button>
<button class:active={tab === 'registry'} onclick={() => (tab = 'registry')}>
3. Decision Registry <span class="count">{s?.decisions.length ?? 0}</span>
</button>
<button class:active={tab === 'ready'} onclick={() => (tab = 'ready')}>4. Micron Pilot Readiness</button>
</nav>
{#if !s}
<main><p class="loading">Loading workspace…</p></main>
{:else}
<main>
<!-- PANEL 1: SCOPE BOARD -->
{#if tab === 'board'}
<div class="panel-head">
<h1>Scope Boundary Board</h1>
<p>
Every item gets a home before the meeting ends. In MVP means the 3 month construction
tracking build. Later Phase means committed but sequenced after MVP. Out means not part of
Project SDE. Parking Lot means undecided, and each parked item needs an owner and a date in
the Decision Registry.
</p>
</div>
<div class="tray">
<h2>Unsorted</h2>
<div class="hint">Work top to bottom. Assign each item with the buttons on its card.</div>
<div class="tray-cards">
{#each s.scope.filter((i) => !i.b) as item (item.id)}
{@render card(item)}
{:else}
<span style="color:var(--text-helper);font-size:14px;">All items sorted. Nice work.</span>
{/each}
</div>
<div class="add-row">
<input placeholder="Add a scope item the list is missing" bind:value={newScopeItem} onkeydown={(e) => e.key === 'Enter' && addScope()} />
<button class="btn ghost" onclick={addScope}>Add item</button>
</div>
</div>
<div class="board">
{#each BUCKETS as b (b.k)}
<div class="col {b.cls}">
<div class="col-head"><h2>{b.label}</h2><span class="n">{s.scope.filter((i) => i.b === b.k).length}</span></div>
<div class="col-sub">{b.sub}</div>
<div class="col-body">
{#each s.scope.filter((i) => i.b === b.k) as item (item.id)}
{@render card(item)}
{/each}
</div>
</div>
{/each}
</div>
{/if}
<!-- PANEL 2: RANKER -->
{#if tab === 'rank'}
<div class="panel-head">
<h1>MVP Priority Ranker</h1>
<p>Forced ranking. If the 3 month timeline slips, items below the cut line are the first to be descoped. Move items with the arrows until the room agrees on the order.</p>
</div>
<div class="rank-wrap">
{#each s.rank as item, i (item.id)}
{#if i === s.cutAfter}
<hr class="cutline" />
<div style="position:relative;"><span class="cutline-label" style="position:absolute; right:0; top:-25px;">DESCOPE LINE: below here goes first if the timeline slips</span></div>
{/if}
<div class="rank-item" class:top3={i < 3}>
<div class="rank-badge">{i + 1}</div>
<div class="rank-label">{item.t}{#if item.n}<div class="rank-note">{item.n}</div>{/if}</div>
<button class="arrow" disabled={i === 0} title="Move up" onclick={() => moveRank(i, -1)}>▲</button>
<button class="arrow" disabled={i === s.rank.length - 1} title="Move down" onclick={() => moveRank(i, 1)}>▼</button>
<button class="chip del" title="Remove" onclick={() => delRank(i)}>✕</button>
</div>
{/each}
<div style="margin-top:16px; font-size:13px; color:var(--text-helper);">
Descope line sits after position {s.cutAfter}.
<button class="btn subtle" style="margin-left:10px;" onclick={() => moveCut(-1)}>Move line up</button>
<button class="btn subtle" onclick={() => moveCut(1)}>Move line down</button>
</div>
<div class="add-row">
<input placeholder="Add a priority" bind:value={newRankItem} onkeydown={(e) => e.key === 'Enter' && addRank()} />
<button class="btn ghost" onclick={addRank}>Add</button>
</div>
</div>
{/if}
<!-- PANEL 3: REGISTRY -->
{#if tab === 'registry'}
<div class="panel-head">
<h1>Decision Registry</h1>
<p>Log every scope decision as it is made, with the trade off the team accepted. Nothing stays verbal only. Locked means it does not get relitigated without a formal change.</p>
</div>
<div class="reg-form">
<div class="field full"><label for="dText">DECISION</label><input id="dText" placeholder="e.g. Commissioning workflows are out of MVP scope; MVP is construction tracking only" bind:value={dText} /></div>
<div class="field"><label for="dType">TYPE</label><select id="dType" bind:value={dType}>{#each DECISION_TYPES as t}<option>{t}</option>{/each}</select></div>
<div class="field"><label for="dOwner">OWNER</label><select id="dOwner" bind:value={dOwner}>{#each DECISION_OWNERS as o}<option>{o}</option>{/each}</select></div>
<div class="field"><label for="dStatus">STATUS</label><select id="dStatus" bind:value={dStatus}>{#each DECISION_STATUSES as st}<option>{st}</option>{/each}</select></div>
<div class="field full"><label for="dWhy">RATIONALE</label><input id="dWhy" placeholder="Why this call was made" bind:value={dWhy} /></div>
<div class="field full"><label for="dTrade">TRADE OFF ACCEPTED</label><input id="dTrade" placeholder="What we are giving up or risking by deciding this way" bind:value={dTrade} /></div>
<div class="form-actions"><button class="btn" onclick={addDecision}>Log decision</button></div>
</div>
{#if s.decisions.length}
<table>
<thead><tr><th>#</th><th>Decision</th><th>Type</th><th>Owner</th><th>Rationale</th><th>Trade off</th><th>Status</th><th></th></tr></thead>
<tbody>
{#each s.decisions as d, i (i)}
<tr>
<td style="color:var(--text-helper);">{i + 1}</td>
<td style="font-weight:500;">{d.text}</td>
<td>{d.type}</td>
<td>{d.owner}</td>
<td>{d.why}</td>
<td>{d.trade}</td>
<td><span class="status-tag st-{d.status.toLowerCase()}">{d.status}</span></td>
<td><button class="row-del" title="Remove" onclick={() => delDecision(i)}>✕</button></td>
</tr>
{/each}
</tbody>
</table>
{:else}
<div class="empty">No decisions logged yet. The first one usually takes two minutes; the rest take thirty seconds.</div>
{/if}
{/if}
<!-- PANEL 4: READINESS -->
{#if tab === 'ready'}
<div class="panel-head">
<h1>Micron Pilot Readiness</h1>
<p>What must be true at Micron before the pilot can start. Tap the status to cycle it. Anything Blocked at the end of the meeting needs a decision in the Registry.</p>
</div>
<div class="ready-wrap">
<div class="ready-summary">
<div><b>{readyCounts[2]}</b>Ready</div>
<div><b>{readyCounts[1]}</b>In progress</div>
<div><b>{readyCounts[3]}</b>Blocked</div>
<div><b>{readyCounts[0]}</b>Not started</div>
</div>
{#each readyGroups as g (g)}
<div class="ready-group">
<h2>{g}</h2>
{#each s.ready.filter((i) => i.g === g) as item (item.id)}
<div class="ready-item">
<div class="ready-label">{item.t}{#if item.w}<span class="why">{item.w}</span>{/if}</div>
<input class="ready-owner" placeholder="Owner" bind:value={item.o} onchange={() => ws.touch()} />
<button class="st-btn s{item.s}" onclick={() => cycleReady(item.id)}>{READY_STATES[item.s]}</button>
</div>
{/each}
</div>
{/each}
<div class="add-row">
<input placeholder="Add a readiness item" bind:value={newReadyItem} onkeydown={(e) => e.key === 'Enter' && addReady()} />
<button class="btn ghost" onclick={addReady}>Add</button>
</div>
</div>
{/if}
</main>
{/if}
{#snippet card(item: ScopeLockState['scope'][number])}
<div class="card" class:locked={item.lock}>
<div class="card-title"><span>{item.t}</span>{#if item.lock}<span class="lock-tag">Pre locked</span>{/if}</div>
<div class="card-actions">
{#each BUCKETS as b (b.k)}
<button class="chip" class:on-mvp={item.b === 'mvp' && b.k === 'mvp'} class:on-later={item.b === 'later' && b.k === 'later'} class:on-out={item.b === 'out' && b.k === 'out'} class:on-park={item.b === 'park' && b.k === 'park'} onclick={() => toggleBucket(item.id, b.k)}>{b.label.replace(' Phase', '').replace('In ', '').replace(' Lot', '')}</button>
{/each}
{#if !item.lock}<button class="chip del" title="Remove item" onclick={() => delScope(item.id)}>✕</button>{/if}
</div>
</div>
{/snippet}
<Toast />
<style>
header {
background: var(--text);
color: #fff;
padding: 0 24px;
display: flex;
align-items: center;
gap: 16px;
height: 56px;
}
.brand {
font-size: 15px;
letter-spacing: 0.16px;
}
.brand a {
font-weight: 600;
color: #fff;
text-decoration: none;
}
.brand span {
color: #c6c6c6;
font-weight: 400;
}
.spacer {
flex: 1;
}
.hdr-btn {
background: transparent;
color: #fff;
border: 1px solid #6f6f6f;
padding: 7px 14px;
font-size: 14px;
}
.hdr-btn:hover {
background: #353535;
}
.hdr-btn.primary {
background: var(--interactive);
border-color: var(--interactive);
}
.hdr-btn.primary:hover {
background: var(--interactive-hover);
}
nav {
background: var(--layer);
border-bottom: 1px solid var(--border);
padding: 0 24px;
display: flex;
}
nav button {
background: none;
border: none;
border-bottom: 3px solid transparent;
padding: 14px 20px;
font-size: 15px;
color: var(--text-secondary);
font-weight: 400;
}
nav button:hover {
color: var(--text);
background: var(--layer-hover);
}
nav button.active {
border-bottom-color: var(--interactive);
color: var(--text);
font-weight: 600;
}
nav button .count {
display: inline-block;
margin-left: 8px;
background: var(--gray-tag);
border-radius: 12px;
padding: 1px 8px;
font-size: 12px;
color: var(--text);
}
main {
padding: 24px;
max-width: 1500px;
margin: 0 auto;
}
.loading {
color: var(--text-helper);
}
.panel-head {
margin-bottom: 20px;
}
.panel-head h1 {
font-size: 26px;
font-weight: 400;
}
.panel-head p {
color: var(--text-secondary);
font-size: 15px;
margin-top: 4px;
max-width: 900px;
}
.tray {
background: var(--layer);
border: 1px solid var(--border);
border-left: 4px solid var(--border-strong);
padding: 16px;
margin-bottom: 20px;
}
.tray h2 {
font-size: 15px;
font-weight: 600;
margin-bottom: 4px;
}
.tray .hint {
font-size: 13px;
color: var(--text-helper);
margin-bottom: 12px;
}
.tray-cards {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.add-row {
display: flex;
gap: 8px;
margin-top: 14px;
}
.add-row input {
flex: 1;
max-width: 480px;
border: none;
border-bottom: 1px solid var(--border-strong);
background: var(--bg);
padding: 10px 12px;
font-size: 15px;
font-family: inherit;
}
.btn {
background: var(--interactive);
color: #fff;
border: none;
padding: 10px 18px;
font-size: 14px;
}
.btn:hover {
background: var(--interactive-hover);
}
.btn.ghost {
background: transparent;
color: var(--interactive);
border: 1px solid var(--interactive);
}
.btn.ghost:hover {
background: #edf5ff;
}
.btn.subtle {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border-strong);
padding: 6px 12px;
font-size: 13px;
}
.btn.subtle:hover {
background: var(--layer-hover);
color: var(--text);
}
.board {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.col {
background: var(--layer);
border: 1px solid var(--border);
border-top: 4px solid;
min-height: 220px;
}
.col.mvp {
border-top-color: var(--interactive);
}
.col.later {
border-top-color: var(--teal);
}
.col.out {
border-top-color: var(--danger);
}
.col.park {
border-top-color: var(--purple);
}
.col-head {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: baseline;
justify-content: space-between;
}
.col-head h2 {
font-size: 16px;
font-weight: 600;
}
.col-head .n {
font-size: 14px;
color: var(--text-secondary);
}
.col-body {
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.col-sub {
font-size: 12px;
color: var(--text-helper);
padding: 0 16px 8px;
}
.card {
background: var(--bg);
border: 1px solid var(--border);
border-left: 3px solid var(--border-strong);
padding: 10px 12px;
font-size: 14px;
}
.card.locked {
border-left-color: var(--warning);
background: var(--warning-bg);
}
.card .card-title {
font-weight: 500;
margin-bottom: 8px;
display: flex;
align-items: flex-start;
gap: 6px;
}
.card .lock-tag {
font-size: 10px;
letter-spacing: 0.4px;
text-transform: uppercase;
color: var(--warning);
border: 1px solid var(--warning);
padding: 0 5px;
border-radius: 9px;
white-space: nowrap;
margin-top: 2px;
}
.card-actions {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.chip {
border: 1px solid var(--border-strong);
background: var(--layer);
color: var(--text-secondary);
font-size: 11.5px;
padding: 3px 8px;
letter-spacing: 0.2px;
}
.chip:hover {
border-color: var(--text);
color: var(--text);
}
.chip.on-mvp {
background: var(--interactive);
border-color: var(--interactive);
color: #fff;
}
.chip.on-later {
background: var(--teal);
border-color: var(--teal);
color: #fff;
}
.chip.on-out {
background: var(--danger);
border-color: var(--danger);
color: #fff;
}
.chip.on-park {
background: var(--purple);
border-color: var(--purple);
color: #fff;
}
.chip.del {
border-color: transparent;
color: var(--text-helper);
}
.chip.del:hover {
color: var(--danger);
}
.rank-wrap {
max-width: 900px;
}
.rank-item {
background: var(--layer);
border: 1px solid var(--border);
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 16px;
padding: 14px 16px;
}
.rank-badge {
width: 40px;
height: 40px;
background: var(--text);
color: #fff;
font-size: 19px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.rank-item.top3 .rank-badge {
background: var(--interactive);
}
.rank-label {
flex: 1;
font-size: 17px;
}
.rank-note {
font-size: 13px;
color: var(--text-helper);
}
.arrow {
width: 40px;
height: 40px;
border: 1px solid var(--border-strong);
background: var(--layer);
font-size: 17px;
color: var(--text-secondary);
}
.arrow:hover:not(:disabled) {
background: var(--layer-hover);
color: var(--text);
}
.arrow:disabled {
opacity: 0.25;
cursor: default;
}
.cutline {
border: none;
border-top: 2px dashed var(--danger);
margin: 14px 0;
position: relative;
}
.cutline-label {
background: var(--bg);
color: var(--danger);
font-size: 12px;
font-weight: 600;
padding: 0 8px;
letter-spacing: 0.3px;
}
.reg-form {
background: var(--layer);
border: 1px solid var(--border);
padding: 20px;
margin-bottom: 24px;
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr;
gap: 16px;
}
.reg-form .full {
grid-column: 1 / -1;
}
.field label {
display: block;
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 6px;
letter-spacing: 0.3px;
}
.field input,
.field select {
width: 100%;
border: none;
border-bottom: 1px solid var(--border-strong);
background: var(--bg);
padding: 10px 12px;
font-size: 15px;
font-family: inherit;
color: var(--text);
}
.reg-form .form-actions {
grid-column: 1 / -1;
display: flex;
gap: 12px;
}
table {
width: 100%;
border-collapse: collapse;
background: var(--layer);
border: 1px solid var(--border);
}
th {
text-align: left;
font-size: 13px;
font-weight: 600;
padding: 12px 14px;
background: var(--layer-hover);
border-bottom: 1px solid var(--border-strong);
}
td {
padding: 12px 14px;
border-bottom: 1px solid var(--border);
font-size: 14px;
vertical-align: top;
}
tr:last-child td {
border-bottom: none;
}
.status-tag {
display: inline-block;
font-size: 12px;
font-weight: 600;
padding: 3px 10px;
border-radius: 12px;
white-space: nowrap;
}
.st-locked {
background: #defbe6;
color: #0e6027;
}
.st-provisional {
background: var(--warning-bg);
color: var(--warning);
}
.st-revisit {
background: #fff1f1;
color: var(--danger);
}
.row-del {
background: none;
border: none;
color: var(--text-helper);
font-size: 16px;
}
.row-del:hover {
color: var(--danger);
}
.empty {
background: var(--layer);
border: 1px dashed var(--border-strong);
padding: 32px;
text-align: center;
color: var(--text-helper);
font-size: 15px;
}
.ready-wrap {
max-width: 1100px;
}
.ready-group {
margin-bottom: 24px;
}
.ready-group h2 {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
letter-spacing: 0.4px;
text-transform: uppercase;
margin-bottom: 8px;
}
.ready-item {
background: var(--layer);
border: 1px solid var(--border);
display: flex;
align-items: center;
gap: 16px;
padding: 12px 16px;
margin-bottom: 6px;
}
.ready-label {
flex: 1;
font-size: 15px;
}
.ready-label .why {
display: block;
font-size: 12.5px;
color: var(--text-helper);
margin-top: 2px;
}
.ready-owner {
width: 170px;
border: none;
border-bottom: 1px solid var(--border-strong);
background: var(--bg);
padding: 8px 10px;
font-size: 14px;
font-family: inherit;
}
.st-btn {
width: 130px;
border: 1px solid;
background: var(--layer);
font-size: 13px;
font-weight: 600;
padding: 9px 0;
text-align: center;
}
.st-btn.s0 {
border-color: var(--border-strong);
color: var(--text-secondary);
}
.st-btn.s1 {
border-color: var(--interactive);
color: var(--interactive);
background: #edf5ff;
}
.st-btn.s2 {
border-color: var(--success);
color: #0e6027;
background: #defbe6;
}
.st-btn.s3 {
border-color: var(--danger);
color: var(--danger);
background: #fff1f1;
}
.ready-summary {
background: var(--layer);
border: 1px solid var(--border);
padding: 14px 18px;
margin-bottom: 20px;
display: flex;
gap: 28px;
font-size: 14px;
}
.ready-summary b {
font-size: 20px;
font-weight: 600;
display: block;
}
@media (max-width: 1100px) {
.board {
grid-template-columns: repeat(2, 1fr);
}
.reg-form {
grid-template-columns: 1fr 1fr;
}
}
</style>

File diff suppressed because it is too large Load Diff

7
static/favicon.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="4" fill="#161616" />
<rect x="7" y="7" width="8" height="8" fill="#0f62fe" />
<rect x="17" y="7" width="8" height="8" fill="#007d79" />
<rect x="7" y="17" width="8" height="8" fill="#8a3ffc" />
<rect x="17" y="17" width="8" height="8" fill="#24a148" />
</svg>

After

Width:  |  Height:  |  Size: 359 B

13
svelte.config.js Normal file
View File

@@ -0,0 +1,13 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
// Runs as a standalone Node server (build/index.js) — ideal for Docker.
adapter: adapter()
}
};
export default config;

14
tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

6
vite.config.ts Normal file
View File

@@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});