Add demo mode and seeded demo data

This commit is contained in:
Franz Rolfsvaag 2026-05-28 10:58:54 +02:00
parent 80c7de3c1f
commit 82da79cc4a
14 changed files with 558 additions and 75 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "arbeidspuls",
"version": "1.0.0",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "arbeidspuls",
"version": "1.0.0",
"version": "1.1.0",
"dependencies": {
"lucide-react": "^0.475.0",
"react": "^19.0.0",

View File

@ -1,6 +1,6 @@
{
"name": "arbeidspuls",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"type": "module",
"scripts": {

View File

@ -1,4 +1,4 @@
const CACHE_NAME = "arbeidspuls-v4";
const CACHE_NAME = "arbeidspuls-v5";
const ASSETS = ["/", "/index.html", "/manifest.webmanifest", "/icon.svg"];
self.addEventListener("install", (event) => {

View File

@ -66,11 +66,14 @@ import {
createReport,
deleteAllReports,
deleteReport,
ensureDemoData,
exportReports,
getLastWorkReport,
importReportsFromJson,
isDemoModeEnabled,
listReports,
previewImportReports
previewImportReports,
setDemoModeEnabled
} from "./storage";
import type {
Accommodation,
@ -97,7 +100,7 @@ type LineVisibility = "normal" | "dim" | "hidden";
type TrendMetric = { key: keyof Report | "total_score_percent"; label: string; short: string; color: string };
const PERIOD_KEY = "arbeidsevne-egenvurdering:trend-period";
const APP_VERSION = "1.0";
const APP_VERSION = "1.1";
const taskTypeOptions = Object.keys(taskTypeLabels) as TaskType[];
const limitationOptions = Object.keys(limitationLabels) as Limitation[];
@ -127,24 +130,43 @@ function AppRoutes() {
return <EvaluatorApp />;
}
const [demoMode, setDemoMode] = useState(() => isDemoModeEnabled());
const [view, setView] = useState<View>("registrer");
const [entries, setEntries] = useState<StoredEntry[]>(() => listReports());
const [entries, setEntries] = useState<StoredEntry[]>(() => listReports(isDemoModeEnabled()));
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
const [deleteRequest, setDeleteRequest] = useState<{ id: string; name: string } | null>(null);
const refresh = () => setEntries(listReports());
const refresh = () => setEntries(listReports(demoMode));
const selectedEntry = selectedEntryId ? entries.find((entry) => entry.id === selectedEntryId) : undefined;
const changeDemoMode = (enabled: boolean) => {
setDemoModeEnabled(enabled);
if (enabled) ensureDemoData();
setDemoMode(enabled);
setSelectedEntryId(null);
setDeleteRequest(null);
setEntries(listReports(enabled));
};
useEffect(() => {
setEntries(listReports(demoMode));
}, [demoMode]);
return (
<div className="app-shell">
<div className={demoMode ? "app-shell demo-active" : "app-shell"}>
{demoMode && <DemoBanner onExit={() => changeDemoMode(false)} />}
<header className="topbar">
<div>
<p className="eyebrow">{t("app.eyebrow")}</p>
<h1>{t("app.title")}</h1>
</div>
<a className="text-link" href="/veileder">
{t("app.evaluator")}
</a>
<div className="topbar-actions">
<button className={demoMode ? "text-link active" : "text-link"} onClick={() => changeDemoMode(!demoMode)} type="button">
{demoMode ? t("demo.exit") : t("demo.enter")}
</button>
<a className="text-link" href="/veileder">
{t("app.evaluator")}
</a>
</div>
</header>
<nav className="main-nav" aria-label={t("app.navLabel")}>
@ -165,8 +187,14 @@ function AppRoutes() {
</nav>
<main>
{view === "registrer" && <ReportForm onSaved={refresh} />}
{view === "oppfolging" && <FollowupForm reports={entries} onSaved={refresh} />}
{demoMode && (
<div className="notice warning">
<ShieldAlert aria-hidden="true" />
<span>{t("demo.notice")}</span>
</div>
)}
{view === "registrer" && <ReportForm demoMode={demoMode} onSaved={refresh} />}
{view === "oppfolging" && <FollowupForm demoMode={demoMode} reports={entries} onSaved={refresh} />}
{view === "oversikt" && (
<Overview
entries={entries}
@ -176,7 +204,7 @@ function AppRoutes() {
onRequestDelete={(entry) => setDeleteRequest({ id: entry.id, name: entry.type === "work_report" ? entry.workplace || t("danger.reportName") : t("danger.followupName") })}
/>
)}
{view === "personvern" && <PrivacyPanel entries={entries} onDeleted={refresh} />}
{view === "personvern" && <PrivacyPanel demoMode={demoMode} entries={entries} onDeleted={refresh} />}
</main>
<AppFooter />
<ConfirmDeleteModal
@ -185,7 +213,7 @@ function AppRoutes() {
onCancel={() => setDeleteRequest(null)}
onConfirm={() => {
if (!deleteRequest) return;
deleteReport(deleteRequest.id);
deleteReport(deleteRequest.id, demoMode);
if (selectedEntryId === deleteRequest.id) setSelectedEntryId(null);
setDeleteRequest(null);
refresh();
@ -214,6 +242,20 @@ function NavButton({
);
}
function DemoBanner({ onExit }: { onExit: () => void }) {
const { t } = useI18n();
return (
<div className="demo-banner" role="status">
<span>DEMO</span>
<span aria-hidden="true">[</span>
<button aria-label={t("demo.exit")} onClick={onExit} type="button">
x
</button>
<span aria-hidden="true">]</span>
</div>
);
}
function AppFooter() {
const { language, setLanguage, t } = useI18n();
const baseYear = 2026;
@ -306,8 +348,8 @@ function ConfirmDeleteModal({
);
}
function createInitialReportDraft(): ReportDraft {
const last = getLastWorkReport();
function createInitialReportDraft(demoMode = false): ReportDraft {
const last = getLastWorkReport(demoMode);
return {
workplace: last?.workplace ?? "",
work_start_time: last?.work_start_time ?? "",
@ -319,9 +361,9 @@ function createInitialReportDraft(): ReportDraft {
};
}
function ReportForm({ onSaved }: { onSaved: () => void }) {
function ReportForm({ demoMode, onSaved }: { demoMode: boolean; onSaved: () => void }) {
const { labels, t } = useI18n();
const [draft, setDraft] = useState<ReportDraft>(() => createInitialReportDraft());
const [draft, setDraft] = useState<ReportDraft>(() => createInitialReportDraft(demoMode));
const [attempted, setAttempted] = useState(false);
const [savedText, setSavedText] = useState("");
@ -329,6 +371,12 @@ function ReportForm({ onSaved }: { onSaved: () => void }) {
const missing = reportFields.filter((field) => !draft[field.key]);
const canSubmit = missing.length === 0;
useEffect(() => {
setDraft(createInitialReportDraft(demoMode));
setAttempted(false);
setSavedText("");
}, [demoMode]);
const setScale = (key: keyof ReportDraft, value: ScaleValue) => {
setDraft((current) => ({ ...current, [key]: value }));
};
@ -347,8 +395,8 @@ function ReportForm({ onSaved }: { onSaved: () => void }) {
setAttempted(true);
if (!canSubmit) return;
createReport(draft);
setDraft(createInitialReportDraft());
createReport(draft, demoMode);
setDraft(createInitialReportDraft(demoMode));
setAttempted(false);
setSavedText(t("register.saved"));
onSaved();
@ -666,7 +714,7 @@ function ChipGroup<T extends string>({
);
}
function FollowupForm({ reports, onSaved }: { reports: StoredEntry[]; onSaved: () => void }) {
function FollowupForm({ demoMode, reports, onSaved }: { demoMode: boolean; reports: StoredEntry[]; onSaved: () => void }) {
const { labels, t } = useI18n();
const [draft, setDraft] = useState<FollowupDraft>({ delayed_symptoms: [], note: "" });
const [attempted, setAttempted] = useState(false);
@ -679,7 +727,7 @@ function FollowupForm({ reports, onSaved }: { reports: StoredEntry[]; onSaved: (
setAttempted(true);
if (!draft.worse_than_before) return;
createFollowup(draft);
createFollowup(draft, demoMode);
setDraft({ delayed_symptoms: [], note: "" });
setAttempted(false);
setSavedText(t("followup.saved"));
@ -1502,24 +1550,24 @@ function Detail({ label, value }: { label: string; value: React.ReactNode }) {
);
}
function PrivacyPanel({ entries, onDeleted }: { entries: StoredEntry[]; onDeleted: () => void }) {
function PrivacyPanel({ demoMode, entries, onDeleted }: { demoMode: boolean; entries: StoredEntry[]; onDeleted: () => void }) {
const { t } = useI18n();
const [confirmDeleteAll, setConfirmDeleteAll] = useState(false);
const [pendingImport, setPendingImport] = useState<{ text: string; summary: ImportSummary; fileName: string } | null>(null);
const [importMessage, setImportMessage] = useState("");
const download = () => {
const blob = new Blob([exportReports()], { type: "application/json" });
const blob = new Blob([exportReports(demoMode)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `arbeidsevne-rapporter-${new Date().toISOString().slice(0, 10)}.json`;
link.download = `${demoMode ? "arbeidspuls-demo" : "arbeidspuls"}-rapporter-${new Date().toISOString().slice(0, 10)}.json`;
link.click();
URL.revokeObjectURL(url);
};
const clear = () => {
deleteAllReports();
deleteAllReports(demoMode);
setConfirmDeleteAll(false);
onDeleted();
};
@ -1529,7 +1577,7 @@ function PrivacyPanel({ entries, onDeleted }: { entries: StoredEntry[]; onDelete
if (!file) return;
try {
const text = await file.text();
const summary = previewImportReports(text);
const summary = previewImportReports(text, demoMode);
setPendingImport({ text, summary, fileName: file.name });
setImportMessage("");
} catch (error) {
@ -1542,13 +1590,13 @@ function PrivacyPanel({ entries, onDeleted }: { entries: StoredEntry[]; onDelete
const confirmImport = () => {
if (!pendingImport) return;
const summary = importReportsFromJson(pendingImport.text);
const summary = importReportsFromJson(pendingImport.text, demoMode);
const flagged = summary.verification
? summary.verification.modified + summary.verification.missing + summary.verification.unsupported + summary.verification.error
: 0;
setPendingImport(null);
setImportMessage(
`Import fullført: ${summary.reports_added} nye rapporter, ${summary.followups_added} nye oppfølginger, ${summary.duplicates_skipped} duplikater hoppet over.${flagged > 0 ? " Noen rapporter kunne ikke verifiseres." : ""}`
`Import fullført: ${summary.reports_added} nye rapporter, ${summary.followups_added} nye oppfølginger, ${summary.duplicates_skipped} duplikater hoppet over.${summary.demo_entries_detected ? " Demodata ble importert i demo-modus." : ""}${flagged > 0 ? " Noen rapporter kunne ikke verifiseres." : ""}`
);
onDeleted();
};
@ -1557,7 +1605,7 @@ function PrivacyPanel({ entries, onDeleted }: { entries: StoredEntry[]; onDelete
<section className="privacy-panel">
<div className="notice warning">
<ShieldAlert aria-hidden="true" />
<span>{t("privacy.warning")}</span>
<span>{demoMode ? t("privacy.demoWarning") : t("privacy.warning")}</span>
</div>
<div className="action-grid">
@ -1594,6 +1642,9 @@ function PrivacyPanel({ entries, onDeleted }: { entries: StoredEntry[]; onDelete
conflicts: pendingImport.summary.conflicts_detected
})}
</p>
{Boolean(pendingImport.summary.demo_entries_detected) && (
<p className="error-text">{t("privacy.demoImportWarning", { count: pendingImport.summary.demo_entries_detected ?? 0 })}</p>
)}
<div className="action-grid">
<button className="secondary-action" onClick={() => setPendingImport(null)} type="button">
{t("privacy.cancelImport")}
@ -1672,9 +1723,7 @@ function EvaluatorApp() {
? "Importen ble knyttet til eksisterende bruker."
: "Opprettet ny bruker.";
setMessage(
flagged > 0
? `${actionText} ${result.added} nye rapporter, ${result.skipped} duplikater hoppet over. Noen rapporter kunne ikke verifiseres.`
: `${actionText} ${result.added} nye rapporter, ${result.skipped} duplikater hoppet over.`
`${actionText} ${result.added} nye rapporter, ${result.skipped} duplikater hoppet over.${result.demo_entries_detected > 0 ? ` ADVARSEL: Importen inneholder ${result.demo_entries_detected} demodata-oppføringer.` : ""}${flagged > 0 ? " Noen rapporter kunne ikke verifiseres." : ""}`
);
setDisplayName("");
setTargetId("");
@ -1833,6 +1882,12 @@ function EvaluatorApp() {
{selected && (
<div className="evaluator-main-grid">
<div className="evaluator-left-workspace">
{selected.contains_demo_data && (
<div className="notice warning">
<ShieldAlert aria-hidden="true" />
<span>{t("evaluator.demoDataWarning")}</span>
</div>
)}
<section className="field-card evaluator-name-card">
<label className="text-field">
<span>{t("evaluator.displayName")}</span>
@ -2013,6 +2068,7 @@ function ReviewReportCard({
))}
</div>
<div className="report-meta-pill-row">
{report.demo && <span className="demo-meta-pill" title={t("evaluator.demoDataWarning")}>{t("demo.label")}</span>}
{report.integrity?.verification_status && report.integrity.verification_status !== "valid" && (
<span className="verification-warning meta-pill" title={verificationTooltip(report.integrity.verification_status)}>
! <span>{t("evaluator.validationError")}</span>
@ -2167,6 +2223,7 @@ function ReportDetailSections({ report, followups }: { report: Report; followups
return (
<div className="detail-sections">
<DetailSection title={t("details.summary")}>
{report.demo && <Detail label={t("details.dataMode")} value={t("demo.label")} />}
{hasVerificationWarning && (
<Detail
label={t("details.integrity")}
@ -2246,6 +2303,7 @@ function FollowupDetailSections({ followup }: { followup: DelayedFollowup }) {
return (
<div className="detail-sections">
<DetailSection title={t("details.summary")}>
{followup.demo && <Detail label={t("details.dataMode")} value={t("demo.label")} />}
{hasVerificationWarning && (
<Detail
label={t("details.integrity")}

184
src/demoData.ts Normal file
View File

@ -0,0 +1,184 @@
import type { StoredEntry } from "./types";
export const demoEntries: StoredEntry[] = [
{
id: "demo-arbeidspuls-001",
created_at: "2026-05-20T06:15:00.000Z",
report_date: "2026-05-20",
report_time: "08:15:00",
type: "work_report",
workplace: "Hjemmekontor",
work_start_time: "08:00",
work_end_time: "10:00",
work_ability: 4,
energy_level: 4,
mental_clarity: 4,
symptom_burden: 2,
effort_strain: 2,
status: "kan_fortsette",
physical_energy: 4,
mental_energy: 4,
perceived_productivity: 4,
task_completion: "som_forventet",
task_types: ["sitting", "computer_work", "concentration"],
main_limitations: [],
helpful_accommodations: ["reduced_pace"],
note: "Testdata: rolig arbeidsøkt med god funksjon.",
total_score_percent: 78,
demo: true,
integrity: {
report_id: "demo-arbeidspuls-001",
canonicalization: "stable-json-v2",
algorithm: "sha256",
payload_hash_sha256: "c8dec91321626eaa8be100c2d6e8e0fc2b30eef5bfd6dda55a27e150dad186c7",
signed_at: "2026-05-20T06:15:00.000Z",
verification_status: "valid"
}
},
{
id: "demo-arbeidspuls-002",
created_at: "2026-05-21T09:40:00.000Z",
report_date: "2026-05-21",
report_time: "11:40:00",
type: "work_report",
workplace: "Kontor",
work_start_time: "09:00",
work_end_time: "12:00",
work_ability: 3,
energy_level: 3,
mental_clarity: 3,
symptom_burden: 3,
effort_strain: 4,
status: "trenger_pause",
physical_energy: 3,
mental_energy: 3,
perceived_productivity: 3,
task_completion: "delvis",
task_types: ["sitting", "talking_social", "noise", "time_pressure"],
main_limitations: ["fatigue", "sensory_overload"],
helpful_accommodations: ["extra_breaks", "quiet_room"],
note: "Testdata: tydelig behov for pauser og roligere miljø.",
total_score_percent: 51,
demo: true,
integrity: {
report_id: "demo-arbeidspuls-002",
canonicalization: "stable-json-v2",
algorithm: "sha256",
payload_hash_sha256: "0e6c518e835f401ee925f5b4f2db1892493a033321bd587ebecb96cf990fcea7",
signed_at: "2026-05-21T09:40:00.000Z",
verification_status: "valid"
}
},
{
id: "demo-arbeidspuls-003",
created_at: "2026-05-24T12:05:00.000Z",
report_date: "2026-05-24",
report_time: "14:05:00",
type: "work_report",
workplace: "Butikk/verksted",
work_start_time: "11:00",
work_end_time: "14:00",
work_ability: 2,
energy_level: 2,
mental_clarity: 2,
symptom_burden: 4,
effort_strain: 4,
status: "trenger_enklere_oppgave",
physical_energy: 2,
mental_energy: 2,
perceived_productivity: 2,
task_completion: "litt",
task_types: ["standing", "walking", "talking_social", "bright_light"],
main_limitations: ["fatigue", "pain", "brain_fog"],
helpful_accommodations: ["sitting_down", "shorter_task"],
note: "Testdata: lavere funksjon med tydelig symptomøkning.",
total_score_percent: 31,
demo: true,
integrity: {
report_id: "demo-arbeidspuls-003",
canonicalization: "stable-json-v2",
algorithm: "sha256",
payload_hash_sha256: "cde1f65dc4607e61a6bb9ad2e157a07ffda33ca89a756f1b8915ba4a97bd36da",
signed_at: "2026-05-24T12:05:00.000Z",
verification_status: "valid"
}
},
{
id: "demo-arbeidspuls-004",
created_at: "2026-05-27T07:30:00.000Z",
report_date: "2026-05-27",
report_time: "09:30:00",
type: "work_report",
workplace: "Hjemmekontor",
work_start_time: "08:30",
work_end_time: "09:30",
work_ability: 5,
energy_level: 4,
mental_clarity: 5,
symptom_burden: 1,
effort_strain: 1,
status: "kan_fortsette",
physical_energy: 4,
mental_energy: 5,
perceived_productivity: 5,
task_completion: "mer_enn_forventet",
task_types: ["sitting", "computer_work", "concentration"],
main_limitations: [],
helpful_accommodations: ["reduced_pace"],
note: "Testdata: kort og svært god økt.",
total_score_percent: 98,
demo: true,
integrity: {
report_id: "demo-arbeidspuls-004",
canonicalization: "stable-json-v2",
algorithm: "sha256",
payload_hash_sha256: "5fd70378270c9ab2760c21e285b5ff8661c3b1ae034d12ae7073f93962b667bb",
signed_at: "2026-05-27T07:30:00.000Z",
verification_status: "valid"
}
},
{
id: "demo-arbeidspuls-followup-002",
created_at: "2026-05-22T08:00:00.000Z",
report_date: "2026-05-22",
report_time: "10:00:00",
type: "delayed_followup",
parent_report_id: "demo-arbeidspuls-002",
related_report_id: "demo-arbeidspuls-002",
worse_than_before: "moderat",
delayed_symptoms: ["fatigue", "brain_fog", "sensory_sensitivity"],
recovery_status: "to_tre_dager",
note: "Testdata: forsinket forverring etter kontorøkt.",
demo: true,
integrity: {
report_id: "demo-arbeidspuls-followup-002",
canonicalization: "stable-json-v2",
algorithm: "sha256",
payload_hash_sha256: "9ddb85b0a00f638748099092fd798ef0c49de24358a570e911d9225f444aa56b",
signed_at: "2026-05-22T08:00:00.000Z",
verification_status: "valid"
}
},
{
id: "demo-arbeidspuls-followup-003",
created_at: "2026-05-25T09:10:00.000Z",
report_date: "2026-05-25",
report_time: "11:10:00",
type: "delayed_followup",
parent_report_id: "demo-arbeidspuls-003",
related_report_id: "demo-arbeidspuls-003",
worse_than_before: "mye",
delayed_symptoms: ["fatigue", "pain", "headache", "sleep_disruption"],
recovery_status: "fire_pluss_dager",
note: "Testdata: kraftig forsinket reaksjon etter fysisk/sosial arbeidsøkt.",
demo: true,
integrity: {
report_id: "demo-arbeidspuls-followup-003",
canonicalization: "stable-json-v2",
algorithm: "sha256",
payload_hash_sha256: "021422fac293255f00dd41f0583e8055a7ef552c1db2cd20c8c9163cf9817fc8",
signed_at: "2026-05-25T09:10:00.000Z",
verification_status: "valid"
}
}
];

View File

@ -111,4 +111,16 @@ describe("evaluator-import", () => {
expect(profiles[0].reports).toHaveLength(1);
expect(profiles[0].evaluator_notes).toHaveLength(0);
});
it("varsler og lagrer markering når import inneholder demodata", () => {
const demoReport = { ...report, id: "demo-arbeidspuls-evaluator", demo: true };
const result = importExportPayload(
JSON.stringify({ ...payload([demoReport]), data_mode: "demo", contains_demo_data: true }),
"Demo"
);
expect(result.demo_entries_detected).toBe(1);
expect(result.profile.contains_demo_data).toBe(true);
expect(listEvaluatorProfiles()[0].contains_demo_data).toBe(true);
});
});

View File

@ -9,6 +9,7 @@ export type ImportResult = {
added: number;
skipped: number;
verification: ReturnType<typeof summarizeVerification>;
demo_entries_detected: number;
target_resolution: ImportTargetResolution;
matched_person_id: string | null;
matched_report_ids: string[];
@ -89,7 +90,9 @@ export function deleteEvaluatorNote(
}
export function importExportPayload(jsonText: string, displayName: string, targetProfileId?: string): ImportResult {
const reports = extractReports(JSON.parse(jsonText)).map(verifyEntryIntegrity);
const parsed = JSON.parse(jsonText);
const reports = extractReports(parsed).map(verifyEntryIntegrity);
const demoEntriesDetected = countDemoEntries(reports, parsed);
const profiles = readProfiles().map(normalizeProfile);
const fingerprint = makeFingerprint(reports);
const target = resolveImportTarget(reports, profiles, targetProfileId);
@ -124,6 +127,7 @@ export function importExportPayload(jsonText: string, displayName: string, targe
display_name: targetProfile.display_name,
updated_at: now,
source_fingerprint: targetProfile.source_fingerprint ?? fingerprint,
contains_demo_data: targetProfile.contains_demo_data || demoEntriesDetected > 0 || nextReports.some(isDemoEntry),
reports: nextReports.sort((a, b) => getEntryTime(b) - getEntryTime(a))
}
: {
@ -132,6 +136,7 @@ export function importExportPayload(jsonText: string, displayName: string, targe
created_at: now,
updated_at: now,
source_fingerprint: fingerprint,
contains_demo_data: demoEntriesDetected > 0 || nextReports.some(isDemoEntry),
reports: nextReports.sort((a, b) => getEntryTime(b) - getEntryTime(a)),
evaluator_notes: []
};
@ -142,6 +147,7 @@ export function importExportPayload(jsonText: string, displayName: string, targe
added,
skipped,
verification: summarizeVerification(reports),
demo_entries_detected: demoEntriesDetected,
target_resolution: targetProfile ? "matched_existing_person" : "created_new_person",
matched_person_id: targetProfile?.id ?? null,
matched_report_ids: target.matchedReportIds
@ -228,9 +234,11 @@ function normalizeImportedEntry(entry: StoredEntry): StoredEntry {
}
function normalizeProfile(profile: EvaluatorProfile): EvaluatorProfile {
const reports = (profile.reports ?? []).map(normalizeImportedEntry).sort((a, b) => getEntryTime(b) - getEntryTime(a));
return {
...profile,
reports: (profile.reports ?? []).map(normalizeImportedEntry).sort((a, b) => getEntryTime(b) - getEntryTime(a)),
reports,
contains_demo_data: profile.contains_demo_data || reports.some(isDemoEntry),
evaluator_notes: (profile.evaluator_notes ?? []).map((note) => ({
...note,
note_type: note.note_type ?? (note.related_report_id ? "report" : note.related_followup_id ? "followup" : "person")
@ -238,6 +246,19 @@ function normalizeProfile(profile: EvaluatorProfile): EvaluatorProfile {
};
}
function isDemoEntry(entry: Partial<StoredEntry>) {
return entry.demo === true || typeof entry.id === "string" && entry.id.startsWith("demo-arbeidspuls");
}
function countDemoEntries(entries: StoredEntry[], payload: unknown) {
const payloadHasDemoFlag =
!Array.isArray(payload) &&
typeof payload === "object" &&
payload !== null &&
((payload as ExportPayload).data_mode === "demo" || (payload as ExportPayload).contains_demo_data === true);
return payloadHasDemoFlag && entries.length > 0 ? Math.max(1, entries.filter(isDemoEntry).length) : entries.filter(isDemoEntry).length;
}
function upsertNoteInProfile(
profile: EvaluatorProfile,
text: string,

View File

@ -57,6 +57,12 @@ const nb = {
overview: "Oversikt",
privacy: "Personvern"
},
demo: {
enter: "Demo",
exit: "Forlat demo",
label: "Demodata",
notice: "Du er i demo-modus. Data her er adskilt fra dine faktiske rapporter, og eksport/import gjelder bare demodata."
},
footer: {
norwegian: "Norsk",
english: "English",
@ -139,6 +145,7 @@ const nb = {
},
privacy: {
warning: "Rapportene kan inneholde sensitive helseopplysninger og lagres bare lokalt i denne nettleseren.",
demoWarning: "Demo-modus bruker egen lokal lagring. Disse rapportene er testdata og blandes ikke med faktiske rapporter.",
exportJson: "Eksporter JSON",
importBackup: "Importer sikkerhetskopi",
deleteAll: "Slett alle lokale data",
@ -146,6 +153,7 @@ const nb = {
importFrom: "Importer data fra {fileName}",
importStats:
"Nye rapporter: {reports}. Nye oppfølginger: {followups}. Duplikater hoppet over: {duplicates}. Konflikter: {conflicts}.",
demoImportWarning: "Importen inneholder {count} demodata-oppføringer og kan bare importeres i demo-modus.",
cancelImport: "Avbryt import",
importData: "Importer data",
baseCopy:
@ -179,6 +187,7 @@ const nb = {
exportBackup: "Eksporter veilederdata",
importBackup: "Importer veilederbackup",
importedBackup: "Veilederbackup er importert.",
demoDataWarning: "Denne importen inneholder demodata. Ikke bruk den som faktisk bruker-/helsegrunnlag.",
importStart: "Importer en JSON-fil for å starte gjennomgang.",
saveName: "Lagre navn",
deletePerson: "Slett bruker og rapporter",
@ -219,6 +228,7 @@ const nb = {
technical: "Teknisk informasjon",
rawData: "Rådata",
integrity: "Integritetskontroll",
dataMode: "Datamodus",
time: "Tidspunkt",
workTime: "Arbeidstid",
status: "Status",
@ -326,6 +336,12 @@ const en: TranslationTree = {
},
app: { eyebrow: "Local self-assessment", title: "Arbeidspuls", evaluator: "Guide", navLabel: "Main navigation" },
nav: { register: "Register", followup: "Follow-up", overview: "Overview", privacy: "Privacy" },
demo: {
enter: "Demo",
exit: "Exit demo",
label: "Demo data",
notice: "You are in demo mode. This data is separate from real reports, and export/import only applies to demo data."
},
footer: {
norwegian: "Norsk",
english: "English",
@ -408,12 +424,14 @@ const en: TranslationTree = {
},
privacy: {
warning: "Reports may contain sensitive health information and are stored only locally in this browser.",
demoWarning: "Demo mode uses separate local storage. These reports are test data and are not mixed with real reports.",
exportJson: "Export JSON",
importBackup: "Import backup",
deleteAll: "Delete all local data",
importHelp: "Import previously exported JSON if you changed device or lost local data. Imported data is stored locally in this browser.",
importFrom: "Import data from {fileName}",
importStats: "New reports: {reports}. New follow-ups: {followups}. Duplicates skipped: {duplicates}. Conflicts: {conflicts}.",
demoImportWarning: "The import contains {count} demo data entries and can only be imported in demo mode.",
cancelImport: "Cancel import",
importData: "Import data",
baseCopy:
@ -447,6 +465,7 @@ const en: TranslationTree = {
exportBackup: "Export guide data",
importBackup: "Import guide backup",
importedBackup: "Guide backup has been imported.",
demoDataWarning: "This import contains demo data. Do not use it as real user or health information.",
importStart: "Import a JSON file to start reviewing.",
saveName: "Save name",
deletePerson: "Delete user and reports",
@ -487,6 +506,7 @@ const en: TranslationTree = {
technical: "Technical information",
rawData: "Raw data",
integrity: "Integrity check",
dataMode: "Data mode",
time: "Time",
workTime: "Work time",
status: "Status",

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { attachIntegrity, buildCanonicalReportPayload, canonicalizeReportForIntegrity, createExportIntegrity, verifyEntryIntegrity } from "./integrity";
import { demoEntries } from "./demoData";
import type { Report } from "./types";
const report: Report = {
@ -104,6 +105,18 @@ describe("integritetskontroll", () => {
expect(verifyEntryIntegrity(withLocalFields).integrity?.verification_status).toBe("valid");
});
it("lar demodataflagg være metadata uten å endre rapportintegritet", () => {
const signed = attachIntegrity(report);
expect(verifyEntryIntegrity({ ...signed, demo: true }).integrity?.verification_status).toBe("valid");
});
it("validerer innebygde demorapporter mot medfølgende integritetsdata", () => {
expect(demoEntries.map((entry) => verifyEntryIntegrity(entry).integrity?.verification_status)).toEqual(
demoEntries.map(() => "valid")
);
});
it("validerer rapport med arbeidssted og legacy energidetaljer uten false modified", () => {
const raw = {
id: "aac87f66-2d39-4a6f-8169-7d39aa5fc0e6",

View File

@ -20,7 +20,10 @@ const EXCLUDED_KEYS = new Set([
"ui_state",
"total_score_percent",
"score_band",
"normalized_period_score"
"normalized_period_score",
"demo",
"data_mode",
"contains_demo_data"
]);
const ALIAS_FIELDS: Record<string, string> = {

View File

@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { importReportsFromJson, listReports, previewImportReports } from "./storage";
import { exportReports, importReportsFromJson, listReports, previewImportReports, setDemoModeEnabled } from "./storage";
import type { ExportPayload, Report } from "./types";
const store = new Map<string, string>();
@ -68,4 +68,24 @@ describe("ordinær brukerimport", () => {
expect(summary.conflicts_detected).toBe(1);
expect(summary.reports_added).toBe(0);
});
it("holder demodata adskilt fra normale rapporter og blokkerer normal import", () => {
setDemoModeEnabled(true);
expect(listReports(true).length).toBeGreaterThan(0);
expect(listReports()).toHaveLength(0);
const demoExport = exportReports(true);
expect(JSON.parse(demoExport).data_mode).toBe("demo");
expect(() => previewImportReports(demoExport)).toThrow("demodata");
});
it("tillater import av demodata i demo-modus", () => {
const demoReport = { ...report, id: "demo-arbeidspuls-test", demo: true };
const summary = importReportsFromJson(JSON.stringify({ ...payload([demoReport]), data_mode: "demo", contains_demo_data: true }), true);
expect(summary.demo_entries_detected).toBe(1);
expect(listReports(true).some((entry) => entry.id === "demo-arbeidspuls-test" && entry.demo)).toBe(true);
expect(listReports()).toHaveLength(0);
});
});

View File

@ -1,24 +1,59 @@
import { calculateReportScore, getEntryTime } from "./scoring";
import { attachIntegrity, createExportIntegrity, summarizeVerification, verifyEntryIntegrity } from "./integrity";
import { demoEntries } from "./demoData";
import type { DelayedFollowup, ExportPayload, FollowupDraft, ImportSummary, Report, ReportDraft, StoredEntry } from "./types";
const STORAGE_KEY = "arbeidsevne-egenvurdering:entries";
const DEMO_STORAGE_KEY = "arbeidspuls:demo-entries";
const DEMO_MODE_KEY = "arbeidspuls:demo-mode";
const DEMO_SEEDED_KEY = "arbeidspuls:demo-seeded";
function readEntries(): StoredEntry[] {
const raw = localStorage.getItem(STORAGE_KEY);
export type DataMode = "normal" | "demo";
function storageKey(mode: DataMode) {
return mode === "demo" ? DEMO_STORAGE_KEY : STORAGE_KEY;
}
function modeFromDemoFlag(demoMode = false): DataMode {
return demoMode ? "demo" : "normal";
}
export function isDemoModeEnabled() {
return localStorage.getItem(DEMO_MODE_KEY) === "true";
}
export function setDemoModeEnabled(enabled: boolean) {
localStorage.setItem(DEMO_MODE_KEY, enabled ? "true" : "false");
if (enabled) ensureDemoData();
}
export function ensureDemoData() {
const existing = readEntries("demo");
if (existing.length > 0) return existing;
if (localStorage.getItem(DEMO_SEEDED_KEY) === "true") return [];
const seeded = demoEntries.map((entry) => markDemoEntry(normalizeStoredEntry(entry)));
writeEntries(seeded, "demo");
localStorage.setItem(DEMO_SEEDED_KEY, "true");
return seeded;
}
function readEntries(mode: DataMode = "normal"): StoredEntry[] {
const raw = localStorage.getItem(storageKey(mode));
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
const entries = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.reports) ? parsed.reports : [];
return entries.map(normalizeStoredEntry);
const candidate = parsed && typeof parsed === "object" ? (parsed as { reports?: unknown }).reports : undefined;
const entries: StoredEntry[] = Array.isArray(parsed) ? parsed : Array.isArray(candidate) ? candidate as StoredEntry[] : [];
return entries.map(normalizeStoredEntry).map((entry) => (mode === "demo" ? markDemoEntry(entry) : stripDemoMarker(entry)));
} catch {
return [];
}
}
function writeEntries(entries: StoredEntry[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
function writeEntries(entries: StoredEntry[], mode: DataMode = "normal") {
const normalized = entries.map((entry) => (mode === "demo" ? markDemoEntry(entry) : stripDemoMarker(entry)));
localStorage.setItem(storageKey(mode), JSON.stringify(normalized));
}
function dateParts(now = new Date()) {
@ -47,7 +82,7 @@ function baseFields() {
};
}
export function createReport(draft: ReportDraft): Report {
export function createReport(draft: ReportDraft, demoMode = false): Report {
if (
!draft.work_ability ||
!draft.energy_level ||
@ -82,12 +117,13 @@ export function createReport(draft: ReportDraft): Report {
};
report.total_score_percent = calculateReportScore(report) ?? undefined;
const signedReport = attachIntegrity(report);
writeEntries([signedReport, ...readEntries()]);
const mode = modeFromDemoFlag(demoMode);
const signedReport = demoMode ? markDemoEntry(attachIntegrity(report)) as Report : attachIntegrity(report);
writeEntries([signedReport, ...readEntries(mode)], mode);
return signedReport;
}
export function createFollowup(draft: FollowupDraft): DelayedFollowup {
export function createFollowup(draft: FollowupDraft, demoMode = false): DelayedFollowup {
if (!draft.worse_than_before) {
throw new Error("Oppfølgingen mangler svar på om du er verre enn før økten.");
}
@ -103,57 +139,84 @@ export function createFollowup(draft: FollowupDraft): DelayedFollowup {
note: draft.note.trim() || undefined
};
const signedFollowup = attachIntegrity(followup);
writeEntries([signedFollowup, ...readEntries()]);
const mode = modeFromDemoFlag(demoMode);
const signedFollowup = demoMode ? markDemoEntry(attachIntegrity(followup)) as DelayedFollowup : attachIntegrity(followup);
writeEntries([signedFollowup, ...readEntries(mode)], mode);
return signedFollowup;
}
export function listReports(): StoredEntry[] {
return readEntries().sort((a, b) => getEntryTime(b) - getEntryTime(a));
export function listReports(demoMode = false): StoredEntry[] {
if (demoMode) ensureDemoData();
return readEntries(modeFromDemoFlag(demoMode)).sort((a, b) => getEntryTime(b) - getEntryTime(a));
}
export function getLastWorkReport(): Report | undefined {
return listReports().find((entry): entry is Report => entry.type === "work_report");
export function getLastWorkReport(demoMode = false): Report | undefined {
return listReports(demoMode).find((entry): entry is Report => entry.type === "work_report");
}
export function getReport(id: string): StoredEntry | undefined {
return readEntries().find((entry) => entry.id === id);
export function getReport(id: string, demoMode = false): StoredEntry | undefined {
return readEntries(modeFromDemoFlag(demoMode)).find((entry) => entry.id === id);
}
export function deleteReport(id: string) {
writeEntries(readEntries().filter((entry) => entry.id !== id));
export function deleteReport(id: string, demoMode = false) {
const mode = modeFromDemoFlag(demoMode);
writeEntries(readEntries(mode).filter((entry) => entry.id !== id), mode);
}
export function deleteAllReports() {
writeEntries([]);
export function deleteAllReports(demoMode = false) {
writeEntries([], modeFromDemoFlag(demoMode));
}
export function exportReports() {
const reports = listReports();
export function exportReports(demoMode = false) {
const reports = listReports(demoMode);
const payload: ExportPayload = {
schema_version: 3,
export_version: 3,
exported_at: new Date().toISOString(),
data_mode: demoMode ? "demo" : "normal",
contains_demo_data: demoMode || reports.some(isDemoEntry),
export_integrity: createExportIntegrity(reports),
reports
};
return JSON.stringify(payload, null, 2);
}
export function previewImportReports(jsonText: string) {
const imported = parseImportEntries(jsonText).map(normalizeStoredEntry).map(verifyEntryIntegrity);
return { ...summarizeImport(imported, readEntries()), verification: summarizeVerification(imported) };
export function previewImportReports(jsonText: string, demoMode = false) {
const parsed = JSON.parse(jsonText);
const imported = parseImportEntriesFromParsed(parsed).map(normalizeStoredEntry).map(verifyEntryIntegrity);
const demoEntriesDetected = countDemoEntries(imported, parsed);
if (!demoMode && demoEntriesDetected > 0) {
throw new Error("Importen inneholder demodata. Bytt til demo-modus for å importere den.");
}
const mode = modeFromDemoFlag(demoMode);
const prepared = demoMode ? imported.map(markDemoEntry) : imported.map(stripDemoMarker);
return {
...summarizeImport(prepared, readEntries(mode)),
demo_entries_detected: demoEntriesDetected,
verification: summarizeVerification(prepared)
};
}
export function importReportsFromJson(jsonText: string): ImportSummary {
const imported = parseImportEntries(jsonText).map(normalizeStoredEntry).map(verifyEntryIntegrity);
const existing = readEntries();
const summary = { ...summarizeImport(imported, existing), verification: summarizeVerification(imported) };
export function importReportsFromJson(jsonText: string, demoMode = false): ImportSummary {
const parsed = JSON.parse(jsonText);
const imported = parseImportEntriesFromParsed(parsed).map(normalizeStoredEntry).map(verifyEntryIntegrity);
const demoEntriesDetected = countDemoEntries(imported, parsed);
if (!demoMode && demoEntriesDetected > 0) {
throw new Error("Importen inneholder demodata. Bytt til demo-modus for å importere den.");
}
const mode = modeFromDemoFlag(demoMode);
const prepared = demoMode ? imported.map(markDemoEntry) : imported.map(stripDemoMarker);
const existing = readEntries(mode);
const summary = {
...summarizeImport(prepared, existing),
demo_entries_detected: demoEntriesDetected,
verification: summarizeVerification(prepared)
};
const existingSignatures = new Set(existing.map(entrySignature));
const existingById = new Map(existing.map((entry) => [entry.id, entry]));
const additions: StoredEntry[] = [];
for (const entry of imported) {
for (const entry of prepared) {
const existingWithId = existingById.get(entry.id);
if (existingWithId && entryContentSignature(existingWithId) !== entryContentSignature(entry)) continue;
if (existingSignatures.has(entrySignature(entry))) continue;
@ -161,13 +224,13 @@ export function importReportsFromJson(jsonText: string): ImportSummary {
additions.push(entry);
}
writeEntries([...additions, ...existing].sort((a, b) => getEntryTime(b) - getEntryTime(a)));
writeEntries([...additions, ...existing].sort((a, b) => getEntryTime(b) - getEntryTime(a)), mode);
return summary;
}
function parseImportEntries(jsonText: string): StoredEntry[] {
const parsed = JSON.parse(jsonText);
const entries = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.reports) ? parsed.reports : null;
function parseImportEntriesFromParsed(parsed: unknown): StoredEntry[] {
const candidate = parsed && typeof parsed === "object" ? (parsed as { reports?: unknown }).reports : undefined;
const entries = Array.isArray(parsed) ? parsed : Array.isArray(candidate) ? candidate : null;
if (!entries) throw new Error("Filen inneholder ikke en gjenkjennbar rapporteksport.");
return entries;
}
@ -248,3 +311,25 @@ function normalizeStoredEntry(entry: StoredEntry): StoredEntry {
delayed_symptoms: entry.delayed_symptoms ?? []
};
}
export function isDemoEntry(entry: Partial<StoredEntry>) {
return entry.demo === true || typeof entry.id === "string" && entry.id.startsWith("demo-arbeidspuls");
}
function countDemoEntries(entries: StoredEntry[], payload: unknown) {
const payloadHasDemoFlag =
!Array.isArray(payload) &&
typeof payload === "object" &&
payload !== null &&
((payload as ExportPayload).data_mode === "demo" || (payload as ExportPayload).contains_demo_data === true);
return payloadHasDemoFlag && entries.length > 0 ? Math.max(1, entries.filter(isDemoEntry).length) : entries.filter(isDemoEntry).length;
}
function markDemoEntry<T extends StoredEntry>(entry: T): T {
return { ...entry, demo: true };
}
function stripDemoMarker<T extends StoredEntry>(entry: T): T {
const { demo, ...rest } = entry;
return rest as T;
}

View File

@ -38,6 +38,11 @@ button {
padding: max(16px, env(safe-area-inset-top)) 14px max(24px, env(safe-area-inset-bottom));
}
.app-shell.demo-active {
outline: 3px solid rgba(178, 100, 42, 0.16);
outline-offset: -3px;
}
.topbar {
display: flex;
align-items: center;
@ -46,6 +51,14 @@ button {
padding: 10px 2px 14px;
}
.topbar-actions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: flex-end;
}
.topbar h1,
.field-head h2,
.filters h2,
@ -663,9 +676,11 @@ textarea {
}
.text-link {
background: transparent;
border: 1px solid #ccd7d4;
border-radius: 999px;
color: #235b5e;
cursor: pointer;
font-size: 0.82rem;
font-weight: 850;
padding: 9px 12px;
@ -673,6 +688,52 @@ textarea {
white-space: nowrap;
}
.text-link.active {
background: #235b5e;
border-color: #235b5e;
color: #fff;
}
.demo-banner {
align-items: center;
background: #1f4f51;
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 999px;
box-shadow: 0 12px 30px rgba(31, 79, 81, 0.22);
color: #fff;
display: inline-flex;
font-size: 0.78rem;
font-weight: 950;
gap: 0.5rem;
letter-spacing: 0;
padding: 0.42rem 0.46rem 0.42rem 0.74rem;
position: fixed;
right: max(12px, env(safe-area-inset-right));
top: max(12px, env(safe-area-inset-top));
z-index: 40;
}
.demo-banner button {
align-items: center;
background: rgba(255, 255, 255, 0.16);
border: 1px solid rgba(255, 255, 255, 0.42);
border-radius: 999px;
color: #fff;
cursor: pointer;
display: inline-flex;
font: inherit;
height: 1.35rem;
justify-content: center;
line-height: 1;
padding: 0;
width: 1.35rem;
}
.demo-meta-pill {
background: #fbecd7;
color: #7d4118;
}
.inline-head {
display: flex;
align-items: center;

View File

@ -99,6 +99,7 @@ export interface Report {
total_score_percent?: number;
integrity?: ReportIntegrity;
note?: string;
demo?: boolean;
}
export interface ReportDraft {
@ -134,6 +135,7 @@ export interface DelayedFollowup {
recovery_status?: RecoveryStatus;
integrity?: ReportIntegrity;
note?: string;
demo?: boolean;
}
export interface FollowupDraft {
@ -164,6 +166,7 @@ export interface EvaluatorProfile {
created_at: string;
updated_at: string;
source_fingerprint?: string;
contains_demo_data?: boolean;
reports: StoredEntry[];
evaluator_notes: EvaluatorNote[];
}
@ -172,6 +175,8 @@ export interface ExportPayload {
schema_version: number;
export_version: number;
exported_at: string;
data_mode?: "normal" | "demo";
contains_demo_data?: boolean;
export_integrity?: ExportIntegrity;
reports: StoredEntry[];
}
@ -181,6 +186,7 @@ export interface ImportSummary {
followups_added: number;
duplicates_skipped: number;
conflicts_detected: number;
demo_entries_detected?: number;
verification?: VerificationSummary;
}