Add demo mode and seeded demo data
This commit is contained in:
parent
80c7de3c1f
commit
82da79cc4a
4
package-lock.json
generated
4
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "arbeidspuls",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
114
src/App.tsx
114
src/App.tsx
@ -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>
|
||||
<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
184
src/demoData.ts
Normal 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"
|
||||
}
|
||||
}
|
||||
];
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
20
src/i18n.tsx
20
src/i18n.tsx
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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> = {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
157
src/storage.ts
157
src/storage.ts
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user