Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/pwa/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Free PWA. Session-only by default; opt-in local persistence; education + trainin
## Invariants

- **Architecture aligned with Azure per ADR-078** (same product, gated tiers): shared domain Zustand stores from `@variscout/stores` (`useProjectStore`, `useInvestigationStore`, `useImprovementStore`, `useSessionStore`, `useWallLayoutStore`, `useCanvasStore`); state shapes tier-agnostic, persistence tier-gated (Q8-revised: IndexedDB Hub-of-one + `.vrs`); tier-gated features check `isPaidTier()` at mount; shared orchestration components live in `@variscout/ui` with ~40 LOC route-shell per app. The "DataContext only, no Zustand" rule was retired by ADR-078.
- **Persistence boundary** (F1+F2 PR2): hub-blob writes flow through `pwaHubRepository` (`apps/pwa/src/persistence/`, module-scoped singleton implementing `@variscout/core/persistence#HubRepository`). Direct `hubRepository.{saveHub,loadHub}` outside `apps/pwa/src/persistence/` is the boundary contract (F2 PR3 will enforce with an ESLint rule); `getOptInFlag`/`setOptInFlag` are documented exceptions. `HUB_PERSIST_SNAPSHOT` is the bootstrap action — bypasses the "no active hub" guard.
- Embedded mode supported for iframes (see flows in `docs/02-journeys/flows/pwa-education.md`).
- Entry: `src/components/Dashboard.tsx`. Hosts the timeline-window picker (investigation-time, default `open-ended`; session-local in V1).

Expand Down
1 change: 1 addition & 0 deletions apps/pwa/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"d3-array": "^3.2.4",
"dexie": "^4.4.2",
"html-to-image": "^1.11.13",
"immer": "^11.1.4",
"lucide-react": "^1.14.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
Expand Down
6 changes: 5 additions & 1 deletion apps/pwa/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { SaveToBrowserButton } from './components/SaveToBrowserButton';
import { VrsExportButton } from './components/VrsExportButton';
import { SessionProvider, useSession } from './store/sessionStore';
import { hubRepository } from './db/hubRepository';
import { pwaHubRepository } from './persistence';
import { Beaker, Settings, Download, Table2, RotateCcw, FileText } from 'lucide-react';
import {
useFindings,
Expand Down Expand Up @@ -161,7 +162,10 @@ function AppMain() {
let cancelled = false;
void hubRepository.getOptInFlag().then(async opted => {
if (!opted || cancelled) return;
const loaded = await hubRepository.loadHub();
// Load via repository pattern (P4.2). pwaHubRepository.hubs.list() returns
// [] or [hub]; no literal ID needed. hubRepository.getOptInFlag stays
// direct-call — no HubAction equivalent until F3 adds HubMetaAction.
const [loaded] = await pwaHubRepository.hubs.list();
if (loaded && !cancelled) setSessionHub(loaded);
});
return () => {
Expand Down
7 changes: 4 additions & 3 deletions apps/pwa/src/components/SaveToBrowserButton.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// apps/pwa/src/components/SaveToBrowserButton.tsx
import { useEffect, useState } from 'react';
import type { ProcessHub } from '@variscout/core/processHub';
import { hubRepository } from '../db/hubRepository';
import { hubRepository } from '../db/hubRepository'; // getOptInFlag / setOptInFlag only
import { pwaHubRepository } from '../persistence';

export interface SaveToBrowserButtonProps {
currentHub: ProcessHub;
Expand All @@ -18,7 +19,7 @@ export function SaveToBrowserButton({ currentHub }: SaveToBrowserButtonProps) {
// Auto-save on Hub change once opted in
useEffect(() => {
if (optedIn) {
void hubRepository.saveHub(currentHub);
void pwaHubRepository.dispatch({ kind: 'HUB_PERSIST_SNAPSHOT', hub: currentHub });
}
}, [optedIn, currentHub]);

Expand All @@ -33,7 +34,7 @@ export function SaveToBrowserButton({ currentHub }: SaveToBrowserButtonProps) {
onClick={async () => {
setBusy(true);
await hubRepository.setOptInFlag(true);
await hubRepository.saveHub(currentHub);
await pwaHubRepository.dispatch({ kind: 'HUB_PERSIST_SNAPSHOT', hub: currentHub });
setOptedIn(true);
setBusy(false);
}}
Expand Down
17 changes: 17 additions & 0 deletions apps/pwa/src/components/__tests__/SaveToBrowserButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { SaveToBrowserButton } from '../SaveToBrowserButton';
import { hubRepository } from '../../db/hubRepository';
import { pwaHubRepository } from '../../persistence';
import { DEFAULT_PROCESS_HUB } from '@variscout/core/processHub';

const hub = { ...DEFAULT_PROCESS_HUB, processGoal: 'Test goal.' };
Expand Down Expand Up @@ -43,4 +44,20 @@ describe('SaveToBrowserButton', () => {
await waitFor(async () => expect(await hubRepository.getOptInFlag()).toBe(false));
expect(await hubRepository.loadHub()).toBeNull();
});

it('clicking save routes through pwaHubRepository.dispatch with HUB_PERSIST_SNAPSHOT', async () => {
// Verifies the dispatch path is exercised — the write goes through
// pwaHubRepository.dispatch rather than hubRepository.saveHub directly.
const dispatchSpy = vi.spyOn(pwaHubRepository, 'dispatch');
render(<SaveToBrowserButton currentHub={hub} />);
fireEvent.click(await screen.findByRole('button', { name: /save to this browser/i }));
await waitFor(() => expect(dispatchSpy).toHaveBeenCalled());
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'HUB_PERSIST_SNAPSHOT',
hub: expect.objectContaining({ processGoal: 'Test goal.' }),
})
);
dispatchSpy.mockRestore();
});
});
175 changes: 175 additions & 0 deletions apps/pwa/src/persistence/PwaHubRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// apps/pwa/src/persistence/PwaHubRepository.ts
//
// PWA persistence model: Hub-of-one blob only.
// The PWA stores exactly one row in IndexedDB — `{ id: 'hub-of-one', hub: ProcessHub }`.
// There are no per-entity tables. The grouped read APIs below serve data from
// that single hub blob; F3 will normalize into dedicated tables.

import type {
HubRepository,
HubReadAPI,
OutcomeReadAPI,
EvidenceSnapshotReadAPI,
EvidenceSourceReadAPI,
InvestigationReadAPI,
FindingReadAPI,
QuestionReadAPI,
CausalLinkReadAPI,
SuspectedCauseReadAPI,
CanvasStateReadAPI,
} from '@variscout/core/persistence';
import type { HubAction } from '@variscout/core/actions';
import { hubRepository } from '../db/hubRepository';
import { applyAction } from './applyAction';

export class PwaHubRepository implements HubRepository {
// ---------------------------------------------------------------------------
// Single write path
// ---------------------------------------------------------------------------

async dispatch(action: HubAction): Promise<void> {
// HUB_PERSIST_SNAPSHOT is the bootstrap/save path — the action carries the
// full hub blob, so no existing hub needs to be loaded first. This is the
// only action that can execute before a hub has been persisted (e.g. the
// first "Save to this browser" click). applyAction still handles this kind
// for purity over HubAction, but dispatch short-circuits to avoid the
// unnecessary load round-trip and to support the null-hub bootstrap case.
if (action.kind === 'HUB_PERSIST_SNAPSHOT') {
await hubRepository.saveHub(action.hub);
return;
}
const hub = await hubRepository.loadHub();
if (!hub) {
throw new Error('No active hub to dispatch action against');
}
const next = applyAction(hub, action);
await hubRepository.saveHub(next);
}

// ---------------------------------------------------------------------------
// Read APIs — hubs
// ---------------------------------------------------------------------------

hubs: HubReadAPI = {
async get(id) {
const hub = await hubRepository.loadHub();
return hub?.id === id ? hub : undefined;
},
async list() {
const hub = await hubRepository.loadHub();
return hub ? [hub] : [];
},
};

// ---------------------------------------------------------------------------
// Read APIs — outcomes
// Outcomes are hub-resident arrays; filter for live entries (deletedAt === null).
// ---------------------------------------------------------------------------

outcomes: OutcomeReadAPI = {
async get(id) {
const hub = await hubRepository.loadHub();
return hub?.outcomes?.find(o => o.id === id && o.deletedAt === null);
},
async listByHub(hubId) {
const hub = await hubRepository.loadHub();
if (!hub || hub.id !== hubId) return [];
return (hub.outcomes ?? []).filter(o => o.deletedAt === null);
},
};

// ---------------------------------------------------------------------------
// Read APIs — canvas state
// canonicalProcessMap is the hub's canvas snapshot.
// ---------------------------------------------------------------------------

canvasState: CanvasStateReadAPI = {
async getByHub(hubId) {
const hub = await hubRepository.loadHub();
if (!hub || hub.id !== hubId) return undefined;
return hub.canonicalProcessMap;
},
};

// ---------------------------------------------------------------------------
// Stub read APIs — entities not yet stored in PWA hub blob.
// PWA persists hub blob only; F3 normalizes these into dedicated tables.
// ---------------------------------------------------------------------------

evidenceSnapshots: EvidenceSnapshotReadAPI = {
// PWA persists hub blob only; F3 normalizes evidenceSnapshots into a dedicated table.
async get(_id) {
return undefined;
},
async listByHub(_hubId) {
return [];
},
};

evidenceSources: EvidenceSourceReadAPI = {
// PWA persists hub blob only; F3 normalizes evidenceSources into a dedicated table.
async get(_id) {
return undefined;
},
async listByHub(_hubId) {
return [];
},
async getCursor(_hubId, _sourceId) {
return undefined;
},
};

investigations: InvestigationReadAPI = {
// PWA persists hub blob only; investigations live in session-only Zustand store today.
async get(_id) {
return undefined;
},
async listByHub(_hubId) {
return [];
},
};

findings: FindingReadAPI = {
// PWA persists hub blob only; findings live in session-only Zustand store today.
async get(_id) {
return undefined;
},
async listByInvestigation(_investigationId) {
return [];
},
};

questions: QuestionReadAPI = {
// PWA persists hub blob only; questions live in session-only Zustand store today.
async get(_id) {
return undefined;
},
async listByInvestigation(_investigationId) {
return [];
},
};

causalLinks: CausalLinkReadAPI = {
// PWA persists hub blob only; causalLinks live in session-only Zustand store today.
async get(_id) {
return undefined;
},
async listByInvestigation(_investigationId) {
return [];
},
};

suspectedCauses: SuspectedCauseReadAPI = {
// PWA persists hub blob only; suspectedCauses live in session-only Zustand store today.
async get(_id) {
return undefined;
},
async listByInvestigation(_investigationId) {
return [];
},
};
}

// Module-scoped singleton. Composition root + dispatch boundary documented in apps/pwa/CLAUDE.md.
// Vitest module-mocking handles test override.
export const pwaHubRepository = new PwaHubRepository();
Loading