converted to proper npm sveltekit project
This commit is contained in:
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);
|
||||
}
|
||||
Reference in New Issue
Block a user