converted to proper npm sveltekit project
This commit is contained in:
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
.svelte-kit
|
||||
build
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
README.md
|
||||
legacy
|
||||
17
.env.example
Normal file
17
.env.example
Normal 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
11
.gitignore
vendored
Normal 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
26
Dockerfile
Normal 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
111
README.md
@@ -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
16
db/init.sql
Normal 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
38
docker-compose.yml
Normal 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
2396
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal 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
59
src/app.css
Normal 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
12
src/app.d.ts
vendored
Normal 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
12
src/app.html
Normal 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>
|
||||
49
src/lib/components/SyncBadge.svelte
Normal file
49
src/lib/components/SyncBadge.svelte
Normal 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>
|
||||
25
src/lib/components/Toast.svelte
Normal file
25
src/lib/components/Toast.svelte
Normal 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
33
src/lib/defaults/index.ts
Normal 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';
|
||||
}
|
||||
90
src/lib/defaults/scope-lock.ts
Normal file
90
src/lib/defaults/scope-lock.ts
Normal 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 }
|
||||
]
|
||||
};
|
||||
}
|
||||
189
src/lib/defaults/workshop.ts
Normal file
189
src/lib/defaults/workshop.ts
Normal 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
83
src/lib/server/ai.ts
Normal 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)
|
||||
};
|
||||
}
|
||||
27
src/lib/server/db/index.ts
Normal file
27
src/lib/server/db/index.ts
Normal 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);
|
||||
}
|
||||
107
src/lib/server/db/workspaces.ts
Normal file
107
src/lib/server/db/workspaces.ts
Normal 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
15
src/lib/toast.svelte.ts
Normal 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
115
src/lib/types.ts
Normal 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
138
src/lib/workspace.svelte.ts
Normal 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);
|
||||
}
|
||||
6
src/routes/+layout.svelte
Normal file
6
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,6 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
108
src/routes/+page.svelte
Normal file
108
src/routes/+page.svelte
Normal 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>
|
||||
28
src/routes/api/ai/draft/+server.ts
Normal file
28
src/routes/api/ai/draft/+server.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
42
src/routes/api/workspaces/[tool]/[name]/+server.ts
Normal file
42
src/routes/api/workspaces/[tool]/[name]/+server.ts
Normal 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 });
|
||||
};
|
||||
11
src/routes/api/workspaces/[tool]/[name]/reset/+server.ts
Normal file
11
src/routes/api/workspaces/[tool]/[name]/reset/+server.ts
Normal 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);
|
||||
};
|
||||
952
src/routes/scope-lock/+page.svelte
Normal file
952
src/routes/scope-lock/+page.svelte
Normal 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>
|
||||
1236
src/routes/workshop/+page.svelte
Normal file
1236
src/routes/workshop/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
7
static/favicon.svg
Normal file
7
static/favicon.svg
Normal 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
13
svelte.config.js
Normal 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
14
tsconfig.json
Normal 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
6
vite.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
});
|
||||
Reference in New Issue
Block a user