From 3a63693432a38a887d74e84091ae6f8618a80a8f Mon Sep 17 00:00:00 2001 From: Ishavn Date: Sun, 2 Aug 2026 19:45:41 +0300 Subject: [PATCH 1/3] Add local workspace persistence and exports --- docs/implementation-notes.md | 8 ++-- src/main.tsx | 55 +++++++++++++++++++++++---- src/report.ts | 73 ++++++++++++++++++++++++++++++++++++ src/storage.ts | 49 ++++++++++++++++++++++++ src/styles.css | 4 +- tests/report.test.ts | 51 +++++++++++++++++++++++++ 6 files changed, 228 insertions(+), 12 deletions(-) create mode 100644 src/report.ts create mode 100644 src/storage.ts create mode 100644 tests/report.test.ts diff --git a/docs/implementation-notes.md b/docs/implementation-notes.md index 44f343b..b666fd5 100644 --- a/docs/implementation-notes.md +++ b/docs/implementation-notes.md @@ -16,11 +16,13 @@ The current slice is a responsive React/Vite local-first review workspace, a det - Findings are explicitly evidence-backed and use “potential” language. - A reviewer can add a note, mark a finding in scope, create a change request decision, reopen it, and use the unreviewed filter as a queue. - A first-run onboarding tour explains the scope, source upload, analysis, evidence review, decisions and local-first privacy model. The tour can be skipped, replayed from the help button and navigated with the keyboard. +- The current local workspace is persisted in browser storage under a versioned key, including sources, findings, analysis metrics, review notes and decisions. This is intentionally device-local and does not replace a shared backend. +- Review reports and approved change requests can be exported as Markdown files without sending project data to an external service. ## Next technical slice 1. Add PDF/DOCX extraction adapters. 2. Add rule-level tests against a labelled fixture set. -3. Persist projects, sources, review decisions and findings in local SQLite. -4. Turn the change-request decision into an editable/exportable draft. -5. Build and test the open-source Slack, Gmail and WhatsApp pilot connectors. +3. Add a shared server-side workspace and explicit authentication for team use. +4. Turn the change-request export into an editable, provider-specific draft. +5. Build and test live Slack, Gmail and WhatsApp connectors behind the approved privacy boundary. diff --git a/src/main.tsx b/src/main.tsx index b1811d4..287bf76 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,27 +3,38 @@ import type { ChangeEvent } from 'react' import { createRoot } from 'react-dom/client' import { analyzeSources, demoSources, parseSourceFile } from './analysis' import type { Finding, SourceDocument } from './analysis' +import { buildChangeRequest, buildReviewReport } from './report' +import { loadWorkspace, saveWorkspace } from './storage' import './styles.css' const projectNames = ['Acme launch site', 'Northstar rebrand', 'Wavelength app'] const initialAnalysis = analyzeSources(demoSources) const tourStorageKey = 'scopeguard-onboarding-complete' +const persistedWorkspace = loadWorkspace() function App() { - const [activeProject, setActiveProject] = useState(projectNames[0]) + const [activeProject, setActiveProject] = useState(persistedWorkspace?.projectName ?? projectNames[0]) const [filter, setFilter] = useState<'all' | 'high' | 'unreviewed'>('all') - const [findings, setFindings] = useState(initialAnalysis.findings) - const [sources, setSources] = useState(demoSources) - const [analysis, setAnalysis] = useState(initialAnalysis) + const [findings, setFindings] = useState(persistedWorkspace?.findings ?? initialAnalysis.findings) + const [sources, setSources] = useState(persistedWorkspace?.sources ?? demoSources) + const [analysis, setAnalysis] = useState(persistedWorkspace?.analysis ?? initialAnalysis) const [isAnalysing, setIsAnalysing] = useState(false) const [analysisReady, setAnalysisReady] = useState(true) const [sourceError, setSourceError] = useState(null) const [reviewingId, setReviewingId] = useState(null) - const [reviewNotes, setReviewNotes] = useState>({}) + const [reviewNotes, setReviewNotes] = useState>(persistedWorkspace?.reviewNotes ?? {}) const [isDragging, setIsDragging] = useState(false) const [isTourOpen, setIsTourOpen] = useState(() => !hasCompletedTour()) const [tourStep, setTourStep] = useState(0) + const [savedAt, setSavedAt] = useState(persistedWorkspace?.savedAt ?? null) + const [exportNotice, setExportNotice] = useState(null) const fileInput = useRef(null) + const changeRequestFindings = useMemo(() => findings.filter((finding) => finding.decision === 'change_request'), [findings]) + + useEffect(() => { + setSavedAt(saveWorkspace({ projectName: activeProject, sources, findings, analysis, reviewNotes })) + }, [activeProject, analysis, findings, reviewNotes, sources]) + const visibleFindings = useMemo(() => { if (filter === 'high') return findings.filter((finding) => finding.severity === 'high') if (filter === 'unreviewed') return findings.filter((finding) => !finding.reviewed) @@ -89,6 +100,22 @@ function App() { setReviewingId(id) } + const exportReport = () => { + downloadText(`${toFileSlug(activeProject)}-scopeguard-report.md`, buildReviewReport(activeProject, sources, analysis, findings)) + showExportNotice('Report downloaded') + } + + const exportChangeRequests = () => { + const content = changeRequestFindings.map((finding) => buildChangeRequest(finding, activeProject, reviewNotes[finding.id])).join('\n\n---\n\n') + downloadText(`${toFileSlug(activeProject)}-change-requests.md`, content) + showExportNotice(`${changeRequestFindings.length} change request${changeRequestFindings.length === 1 ? '' : 's'} downloaded`) + } + + const showExportNotice = (message: string) => { + setExportNotice(message) + window.setTimeout(() => setExportNotice(null), 2400) + } + return (
Projects/{activeProject}
-
Saved locally{exportNotice && {exportNotice}}
+
{savedAt ? 'Saved on this device' : 'Session only'}{exportNotice && {exportNotice}}
-

PROJECT REVIEW · 14 MAY 2026

+

PROJECT REVIEW · LOCAL PILOT

Keep the work
inside the lines.

ScopeGuard compares what was agreed with what is now being asked — before the margin disappears.

-
{changeRequestFindings.length > 0 && }
+
{changeRequestFindings.length > 0 && }
+ {isDemoWorkspace &&
Demo data

The workspace starts with example files. Upload real exports to replace them, or start an empty local project.

} +
-
{analysisReady ? '✓' : '…'}
{analysisReady ? 'Analysis complete' : 'Ready to analyse'}{analysisReady ? `Compared ${analysis.messagesCompared} messages against ${analysis.scopeItemsCount} scope items` : `${sources.length} source${sources.length === 1 ? '' : 's'} loaded · Run analysis to compare them`}
-
Scope coverage{analysis.scopeCoverage}%
Hours at risk{analysis.hoursAtRisk}
Unreviewed{findings.filter((finding) => !finding.reviewed).length}
+
{analysisReady ? '✓' : '…'}
{analysisReady ? 'Analysis complete' : sources.length ? 'Ready to analyse' : 'Add project sources'}{analysisReady ? `Compared ${analysis.messagesCompared} messages against ${analysis.scopeItemsCount} scope items` : `${sources.length} source${sources.length === 1 ? '' : 's'} loaded · Run analysis after adding both source types`}
+
With scope basis{analysisReady ? `${analysis.scopeCoverage}%` : '—'}
Preliminary exposure{analysisReady ? analysis.hoursAtRisk : '—'}
Unreviewed{analysisReady ? findings.filter((finding) => !finding.reviewed).length : '—'}
-

EVIDENCE REVIEW

Potential scope drift

{(['all', 'high', 'unreviewed'] as const).map((item) => )}
+

EVIDENCE REVIEW

Potential scope drift

{(['all', 'high', 'unreviewed'] as const).map((item) => )}
{visibleFindings.map((finding) => )} - {!visibleFindings.length &&
Nothing waiting here.Every finding in this view has been reviewed.
} + {!visibleFindings.length &&
{!analysisReady ? 'Sources changed — run analysis.' : findings.length ? 'Nothing waiting here.' : 'No potential scope drift found.'}{!analysisReady ? 'Previous findings are hidden until the current source set is analysed.' : findings.length ? 'Every finding in this view has been reviewed.' : 'Add another communication export if you want to check more conversations.'}
}