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",
|
"name": "arbeidspuls",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "arbeidspuls",
|
"name": "arbeidspuls",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lucide-react": "^0.475.0",
|
"lucide-react": "^0.475.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "arbeidspuls",
|
"name": "arbeidspuls",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
const CACHE_NAME = "arbeidspuls-v4";
|
const CACHE_NAME = "arbeidspuls-v5";
|
||||||
const ASSETS = ["/", "/index.html", "/manifest.webmanifest", "/icon.svg"];
|
const ASSETS = ["/", "/index.html", "/manifest.webmanifest", "/icon.svg"];
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
|
|||||||
120
src/App.tsx
120
src/App.tsx
@ -66,11 +66,14 @@ import {
|
|||||||
createReport,
|
createReport,
|
||||||
deleteAllReports,
|
deleteAllReports,
|
||||||
deleteReport,
|
deleteReport,
|
||||||
|
ensureDemoData,
|
||||||
exportReports,
|
exportReports,
|
||||||
getLastWorkReport,
|
getLastWorkReport,
|
||||||
importReportsFromJson,
|
importReportsFromJson,
|
||||||
|
isDemoModeEnabled,
|
||||||
listReports,
|
listReports,
|
||||||
previewImportReports
|
previewImportReports,
|
||||||
|
setDemoModeEnabled
|
||||||
} from "./storage";
|
} from "./storage";
|
||||||
import type {
|
import type {
|
||||||
Accommodation,
|
Accommodation,
|
||||||
@ -97,7 +100,7 @@ type LineVisibility = "normal" | "dim" | "hidden";
|
|||||||
type TrendMetric = { key: keyof Report | "total_score_percent"; label: string; short: string; color: string };
|
type TrendMetric = { key: keyof Report | "total_score_percent"; label: string; short: string; color: string };
|
||||||
|
|
||||||
const PERIOD_KEY = "arbeidsevne-egenvurdering:trend-period";
|
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 taskTypeOptions = Object.keys(taskTypeLabels) as TaskType[];
|
||||||
const limitationOptions = Object.keys(limitationLabels) as Limitation[];
|
const limitationOptions = Object.keys(limitationLabels) as Limitation[];
|
||||||
@ -127,24 +130,43 @@ function AppRoutes() {
|
|||||||
return <EvaluatorApp />;
|
return <EvaluatorApp />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [demoMode, setDemoMode] = useState(() => isDemoModeEnabled());
|
||||||
const [view, setView] = useState<View>("registrer");
|
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 [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
|
||||||
const [deleteRequest, setDeleteRequest] = useState<{ id: string; name: 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 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 (
|
return (
|
||||||
<div className="app-shell">
|
<div className={demoMode ? "app-shell demo-active" : "app-shell"}>
|
||||||
|
{demoMode && <DemoBanner onExit={() => changeDemoMode(false)} />}
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">{t("app.eyebrow")}</p>
|
<p className="eyebrow">{t("app.eyebrow")}</p>
|
||||||
<h1>{t("app.title")}</h1>
|
<h1>{t("app.title")}</h1>
|
||||||
</div>
|
</div>
|
||||||
<a className="text-link" href="/veileder">
|
<div className="topbar-actions">
|
||||||
{t("app.evaluator")}
|
<button className={demoMode ? "text-link active" : "text-link"} onClick={() => changeDemoMode(!demoMode)} type="button">
|
||||||
</a>
|
{demoMode ? t("demo.exit") : t("demo.enter")}
|
||||||
|
</button>
|
||||||
|
<a className="text-link" href="/veileder">
|
||||||
|
{t("app.evaluator")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav className="main-nav" aria-label={t("app.navLabel")}>
|
<nav className="main-nav" aria-label={t("app.navLabel")}>
|
||||||
@ -165,8 +187,14 @@ function AppRoutes() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
{view === "registrer" && <ReportForm onSaved={refresh} />}
|
{demoMode && (
|
||||||
{view === "oppfolging" && <FollowupForm reports={entries} onSaved={refresh} />}
|
<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" && (
|
{view === "oversikt" && (
|
||||||
<Overview
|
<Overview
|
||||||
entries={entries}
|
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") })}
|
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>
|
</main>
|
||||||
<AppFooter />
|
<AppFooter />
|
||||||
<ConfirmDeleteModal
|
<ConfirmDeleteModal
|
||||||
@ -185,7 +213,7 @@ function AppRoutes() {
|
|||||||
onCancel={() => setDeleteRequest(null)}
|
onCancel={() => setDeleteRequest(null)}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
if (!deleteRequest) return;
|
if (!deleteRequest) return;
|
||||||
deleteReport(deleteRequest.id);
|
deleteReport(deleteRequest.id, demoMode);
|
||||||
if (selectedEntryId === deleteRequest.id) setSelectedEntryId(null);
|
if (selectedEntryId === deleteRequest.id) setSelectedEntryId(null);
|
||||||
setDeleteRequest(null);
|
setDeleteRequest(null);
|
||||||
refresh();
|
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() {
|
function AppFooter() {
|
||||||
const { language, setLanguage, t } = useI18n();
|
const { language, setLanguage, t } = useI18n();
|
||||||
const baseYear = 2026;
|
const baseYear = 2026;
|
||||||
@ -306,8 +348,8 @@ function ConfirmDeleteModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createInitialReportDraft(): ReportDraft {
|
function createInitialReportDraft(demoMode = false): ReportDraft {
|
||||||
const last = getLastWorkReport();
|
const last = getLastWorkReport(demoMode);
|
||||||
return {
|
return {
|
||||||
workplace: last?.workplace ?? "",
|
workplace: last?.workplace ?? "",
|
||||||
work_start_time: last?.work_start_time ?? "",
|
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 { labels, t } = useI18n();
|
||||||
const [draft, setDraft] = useState<ReportDraft>(() => createInitialReportDraft());
|
const [draft, setDraft] = useState<ReportDraft>(() => createInitialReportDraft(demoMode));
|
||||||
const [attempted, setAttempted] = useState(false);
|
const [attempted, setAttempted] = useState(false);
|
||||||
const [savedText, setSavedText] = useState("");
|
const [savedText, setSavedText] = useState("");
|
||||||
|
|
||||||
@ -329,6 +371,12 @@ function ReportForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
const missing = reportFields.filter((field) => !draft[field.key]);
|
const missing = reportFields.filter((field) => !draft[field.key]);
|
||||||
const canSubmit = missing.length === 0;
|
const canSubmit = missing.length === 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(createInitialReportDraft(demoMode));
|
||||||
|
setAttempted(false);
|
||||||
|
setSavedText("");
|
||||||
|
}, [demoMode]);
|
||||||
|
|
||||||
const setScale = (key: keyof ReportDraft, value: ScaleValue) => {
|
const setScale = (key: keyof ReportDraft, value: ScaleValue) => {
|
||||||
setDraft((current) => ({ ...current, [key]: value }));
|
setDraft((current) => ({ ...current, [key]: value }));
|
||||||
};
|
};
|
||||||
@ -347,8 +395,8 @@ function ReportForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
setAttempted(true);
|
setAttempted(true);
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
|
|
||||||
createReport(draft);
|
createReport(draft, demoMode);
|
||||||
setDraft(createInitialReportDraft());
|
setDraft(createInitialReportDraft(demoMode));
|
||||||
setAttempted(false);
|
setAttempted(false);
|
||||||
setSavedText(t("register.saved"));
|
setSavedText(t("register.saved"));
|
||||||
onSaved();
|
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 { labels, t } = useI18n();
|
||||||
const [draft, setDraft] = useState<FollowupDraft>({ delayed_symptoms: [], note: "" });
|
const [draft, setDraft] = useState<FollowupDraft>({ delayed_symptoms: [], note: "" });
|
||||||
const [attempted, setAttempted] = useState(false);
|
const [attempted, setAttempted] = useState(false);
|
||||||
@ -679,7 +727,7 @@ function FollowupForm({ reports, onSaved }: { reports: StoredEntry[]; onSaved: (
|
|||||||
setAttempted(true);
|
setAttempted(true);
|
||||||
if (!draft.worse_than_before) return;
|
if (!draft.worse_than_before) return;
|
||||||
|
|
||||||
createFollowup(draft);
|
createFollowup(draft, demoMode);
|
||||||
setDraft({ delayed_symptoms: [], note: "" });
|
setDraft({ delayed_symptoms: [], note: "" });
|
||||||
setAttempted(false);
|
setAttempted(false);
|
||||||
setSavedText(t("followup.saved"));
|
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 { t } = useI18n();
|
||||||
const [confirmDeleteAll, setConfirmDeleteAll] = useState(false);
|
const [confirmDeleteAll, setConfirmDeleteAll] = useState(false);
|
||||||
const [pendingImport, setPendingImport] = useState<{ text: string; summary: ImportSummary; fileName: string } | null>(null);
|
const [pendingImport, setPendingImport] = useState<{ text: string; summary: ImportSummary; fileName: string } | null>(null);
|
||||||
const [importMessage, setImportMessage] = useState("");
|
const [importMessage, setImportMessage] = useState("");
|
||||||
|
|
||||||
const download = () => {
|
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 url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
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();
|
link.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
const clear = () => {
|
const clear = () => {
|
||||||
deleteAllReports();
|
deleteAllReports(demoMode);
|
||||||
setConfirmDeleteAll(false);
|
setConfirmDeleteAll(false);
|
||||||
onDeleted();
|
onDeleted();
|
||||||
};
|
};
|
||||||
@ -1529,7 +1577,7 @@ function PrivacyPanel({ entries, onDeleted }: { entries: StoredEntry[]; onDelete
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
try {
|
try {
|
||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
const summary = previewImportReports(text);
|
const summary = previewImportReports(text, demoMode);
|
||||||
setPendingImport({ text, summary, fileName: file.name });
|
setPendingImport({ text, summary, fileName: file.name });
|
||||||
setImportMessage("");
|
setImportMessage("");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -1542,13 +1590,13 @@ function PrivacyPanel({ entries, onDeleted }: { entries: StoredEntry[]; onDelete
|
|||||||
|
|
||||||
const confirmImport = () => {
|
const confirmImport = () => {
|
||||||
if (!pendingImport) return;
|
if (!pendingImport) return;
|
||||||
const summary = importReportsFromJson(pendingImport.text);
|
const summary = importReportsFromJson(pendingImport.text, demoMode);
|
||||||
const flagged = summary.verification
|
const flagged = summary.verification
|
||||||
? summary.verification.modified + summary.verification.missing + summary.verification.unsupported + summary.verification.error
|
? summary.verification.modified + summary.verification.missing + summary.verification.unsupported + summary.verification.error
|
||||||
: 0;
|
: 0;
|
||||||
setPendingImport(null);
|
setPendingImport(null);
|
||||||
setImportMessage(
|
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();
|
onDeleted();
|
||||||
};
|
};
|
||||||
@ -1557,7 +1605,7 @@ function PrivacyPanel({ entries, onDeleted }: { entries: StoredEntry[]; onDelete
|
|||||||
<section className="privacy-panel">
|
<section className="privacy-panel">
|
||||||
<div className="notice warning">
|
<div className="notice warning">
|
||||||
<ShieldAlert aria-hidden="true" />
|
<ShieldAlert aria-hidden="true" />
|
||||||
<span>{t("privacy.warning")}</span>
|
<span>{demoMode ? t("privacy.demoWarning") : t("privacy.warning")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="action-grid">
|
<div className="action-grid">
|
||||||
@ -1594,6 +1642,9 @@ function PrivacyPanel({ entries, onDeleted }: { entries: StoredEntry[]; onDelete
|
|||||||
conflicts: pendingImport.summary.conflicts_detected
|
conflicts: pendingImport.summary.conflicts_detected
|
||||||
})}
|
})}
|
||||||
</p>
|
</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">
|
<div className="action-grid">
|
||||||
<button className="secondary-action" onClick={() => setPendingImport(null)} type="button">
|
<button className="secondary-action" onClick={() => setPendingImport(null)} type="button">
|
||||||
{t("privacy.cancelImport")}
|
{t("privacy.cancelImport")}
|
||||||
@ -1672,9 +1723,7 @@ function EvaluatorApp() {
|
|||||||
? "Importen ble knyttet til eksisterende bruker."
|
? "Importen ble knyttet til eksisterende bruker."
|
||||||
: "Opprettet ny bruker.";
|
: "Opprettet ny bruker.";
|
||||||
setMessage(
|
setMessage(
|
||||||
flagged > 0
|
`${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." : ""}`
|
||||||
? `${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.`
|
|
||||||
);
|
);
|
||||||
setDisplayName("");
|
setDisplayName("");
|
||||||
setTargetId("");
|
setTargetId("");
|
||||||
@ -1833,6 +1882,12 @@ function EvaluatorApp() {
|
|||||||
{selected && (
|
{selected && (
|
||||||
<div className="evaluator-main-grid">
|
<div className="evaluator-main-grid">
|
||||||
<div className="evaluator-left-workspace">
|
<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">
|
<section className="field-card evaluator-name-card">
|
||||||
<label className="text-field">
|
<label className="text-field">
|
||||||
<span>{t("evaluator.displayName")}</span>
|
<span>{t("evaluator.displayName")}</span>
|
||||||
@ -2013,6 +2068,7 @@ function ReviewReportCard({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="report-meta-pill-row">
|
<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" && (
|
{report.integrity?.verification_status && report.integrity.verification_status !== "valid" && (
|
||||||
<span className="verification-warning meta-pill" title={verificationTooltip(report.integrity.verification_status)}>
|
<span className="verification-warning meta-pill" title={verificationTooltip(report.integrity.verification_status)}>
|
||||||
! <span>{t("evaluator.validationError")}</span>
|
! <span>{t("evaluator.validationError")}</span>
|
||||||
@ -2167,6 +2223,7 @@ function ReportDetailSections({ report, followups }: { report: Report; followups
|
|||||||
return (
|
return (
|
||||||
<div className="detail-sections">
|
<div className="detail-sections">
|
||||||
<DetailSection title={t("details.summary")}>
|
<DetailSection title={t("details.summary")}>
|
||||||
|
{report.demo && <Detail label={t("details.dataMode")} value={t("demo.label")} />}
|
||||||
{hasVerificationWarning && (
|
{hasVerificationWarning && (
|
||||||
<Detail
|
<Detail
|
||||||
label={t("details.integrity")}
|
label={t("details.integrity")}
|
||||||
@ -2246,6 +2303,7 @@ function FollowupDetailSections({ followup }: { followup: DelayedFollowup }) {
|
|||||||
return (
|
return (
|
||||||
<div className="detail-sections">
|
<div className="detail-sections">
|
||||||
<DetailSection title={t("details.summary")}>
|
<DetailSection title={t("details.summary")}>
|
||||||
|
{followup.demo && <Detail label={t("details.dataMode")} value={t("demo.label")} />}
|
||||||
{hasVerificationWarning && (
|
{hasVerificationWarning && (
|
||||||
<Detail
|
<Detail
|
||||||
label={t("details.integrity")}
|
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].reports).toHaveLength(1);
|
||||||
expect(profiles[0].evaluator_notes).toHaveLength(0);
|
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;
|
added: number;
|
||||||
skipped: number;
|
skipped: number;
|
||||||
verification: ReturnType<typeof summarizeVerification>;
|
verification: ReturnType<typeof summarizeVerification>;
|
||||||
|
demo_entries_detected: number;
|
||||||
target_resolution: ImportTargetResolution;
|
target_resolution: ImportTargetResolution;
|
||||||
matched_person_id: string | null;
|
matched_person_id: string | null;
|
||||||
matched_report_ids: string[];
|
matched_report_ids: string[];
|
||||||
@ -89,7 +90,9 @@ export function deleteEvaluatorNote(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function importExportPayload(jsonText: string, displayName: string, targetProfileId?: string): ImportResult {
|
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 profiles = readProfiles().map(normalizeProfile);
|
||||||
const fingerprint = makeFingerprint(reports);
|
const fingerprint = makeFingerprint(reports);
|
||||||
const target = resolveImportTarget(reports, profiles, targetProfileId);
|
const target = resolveImportTarget(reports, profiles, targetProfileId);
|
||||||
@ -124,6 +127,7 @@ export function importExportPayload(jsonText: string, displayName: string, targe
|
|||||||
display_name: targetProfile.display_name,
|
display_name: targetProfile.display_name,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
source_fingerprint: targetProfile.source_fingerprint ?? fingerprint,
|
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))
|
reports: nextReports.sort((a, b) => getEntryTime(b) - getEntryTime(a))
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
@ -132,6 +136,7 @@ export function importExportPayload(jsonText: string, displayName: string, targe
|
|||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
source_fingerprint: fingerprint,
|
source_fingerprint: fingerprint,
|
||||||
|
contains_demo_data: demoEntriesDetected > 0 || nextReports.some(isDemoEntry),
|
||||||
reports: nextReports.sort((a, b) => getEntryTime(b) - getEntryTime(a)),
|
reports: nextReports.sort((a, b) => getEntryTime(b) - getEntryTime(a)),
|
||||||
evaluator_notes: []
|
evaluator_notes: []
|
||||||
};
|
};
|
||||||
@ -142,6 +147,7 @@ export function importExportPayload(jsonText: string, displayName: string, targe
|
|||||||
added,
|
added,
|
||||||
skipped,
|
skipped,
|
||||||
verification: summarizeVerification(reports),
|
verification: summarizeVerification(reports),
|
||||||
|
demo_entries_detected: demoEntriesDetected,
|
||||||
target_resolution: targetProfile ? "matched_existing_person" : "created_new_person",
|
target_resolution: targetProfile ? "matched_existing_person" : "created_new_person",
|
||||||
matched_person_id: targetProfile?.id ?? null,
|
matched_person_id: targetProfile?.id ?? null,
|
||||||
matched_report_ids: target.matchedReportIds
|
matched_report_ids: target.matchedReportIds
|
||||||
@ -228,9 +234,11 @@ function normalizeImportedEntry(entry: StoredEntry): StoredEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeProfile(profile: EvaluatorProfile): EvaluatorProfile {
|
function normalizeProfile(profile: EvaluatorProfile): EvaluatorProfile {
|
||||||
|
const reports = (profile.reports ?? []).map(normalizeImportedEntry).sort((a, b) => getEntryTime(b) - getEntryTime(a));
|
||||||
return {
|
return {
|
||||||
...profile,
|
...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) => ({
|
evaluator_notes: (profile.evaluator_notes ?? []).map((note) => ({
|
||||||
...note,
|
...note,
|
||||||
note_type: note.note_type ?? (note.related_report_id ? "report" : note.related_followup_id ? "followup" : "person")
|
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(
|
function upsertNoteInProfile(
|
||||||
profile: EvaluatorProfile,
|
profile: EvaluatorProfile,
|
||||||
text: string,
|
text: string,
|
||||||
|
|||||||
20
src/i18n.tsx
20
src/i18n.tsx
@ -57,6 +57,12 @@ const nb = {
|
|||||||
overview: "Oversikt",
|
overview: "Oversikt",
|
||||||
privacy: "Personvern"
|
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: {
|
footer: {
|
||||||
norwegian: "Norsk",
|
norwegian: "Norsk",
|
||||||
english: "English",
|
english: "English",
|
||||||
@ -139,6 +145,7 @@ const nb = {
|
|||||||
},
|
},
|
||||||
privacy: {
|
privacy: {
|
||||||
warning: "Rapportene kan inneholde sensitive helseopplysninger og lagres bare lokalt i denne nettleseren.",
|
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",
|
exportJson: "Eksporter JSON",
|
||||||
importBackup: "Importer sikkerhetskopi",
|
importBackup: "Importer sikkerhetskopi",
|
||||||
deleteAll: "Slett alle lokale data",
|
deleteAll: "Slett alle lokale data",
|
||||||
@ -146,6 +153,7 @@ const nb = {
|
|||||||
importFrom: "Importer data fra {fileName}",
|
importFrom: "Importer data fra {fileName}",
|
||||||
importStats:
|
importStats:
|
||||||
"Nye rapporter: {reports}. Nye oppfølginger: {followups}. Duplikater hoppet over: {duplicates}. Konflikter: {conflicts}.",
|
"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",
|
cancelImport: "Avbryt import",
|
||||||
importData: "Importer data",
|
importData: "Importer data",
|
||||||
baseCopy:
|
baseCopy:
|
||||||
@ -179,6 +187,7 @@ const nb = {
|
|||||||
exportBackup: "Eksporter veilederdata",
|
exportBackup: "Eksporter veilederdata",
|
||||||
importBackup: "Importer veilederbackup",
|
importBackup: "Importer veilederbackup",
|
||||||
importedBackup: "Veilederbackup er importert.",
|
importedBackup: "Veilederbackup er importert.",
|
||||||
|
demoDataWarning: "Denne importen inneholder demodata. Ikke bruk den som faktisk bruker-/helsegrunnlag.",
|
||||||
importStart: "Importer en JSON-fil for å starte gjennomgang.",
|
importStart: "Importer en JSON-fil for å starte gjennomgang.",
|
||||||
saveName: "Lagre navn",
|
saveName: "Lagre navn",
|
||||||
deletePerson: "Slett bruker og rapporter",
|
deletePerson: "Slett bruker og rapporter",
|
||||||
@ -219,6 +228,7 @@ const nb = {
|
|||||||
technical: "Teknisk informasjon",
|
technical: "Teknisk informasjon",
|
||||||
rawData: "Rådata",
|
rawData: "Rådata",
|
||||||
integrity: "Integritetskontroll",
|
integrity: "Integritetskontroll",
|
||||||
|
dataMode: "Datamodus",
|
||||||
time: "Tidspunkt",
|
time: "Tidspunkt",
|
||||||
workTime: "Arbeidstid",
|
workTime: "Arbeidstid",
|
||||||
status: "Status",
|
status: "Status",
|
||||||
@ -326,6 +336,12 @@ const en: TranslationTree = {
|
|||||||
},
|
},
|
||||||
app: { eyebrow: "Local self-assessment", title: "Arbeidspuls", evaluator: "Guide", navLabel: "Main navigation" },
|
app: { eyebrow: "Local self-assessment", title: "Arbeidspuls", evaluator: "Guide", navLabel: "Main navigation" },
|
||||||
nav: { register: "Register", followup: "Follow-up", overview: "Overview", privacy: "Privacy" },
|
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: {
|
footer: {
|
||||||
norwegian: "Norsk",
|
norwegian: "Norsk",
|
||||||
english: "English",
|
english: "English",
|
||||||
@ -408,12 +424,14 @@ const en: TranslationTree = {
|
|||||||
},
|
},
|
||||||
privacy: {
|
privacy: {
|
||||||
warning: "Reports may contain sensitive health information and are stored only locally in this browser.",
|
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",
|
exportJson: "Export JSON",
|
||||||
importBackup: "Import backup",
|
importBackup: "Import backup",
|
||||||
deleteAll: "Delete all local data",
|
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.",
|
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}",
|
importFrom: "Import data from {fileName}",
|
||||||
importStats: "New reports: {reports}. New follow-ups: {followups}. Duplicates skipped: {duplicates}. Conflicts: {conflicts}.",
|
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",
|
cancelImport: "Cancel import",
|
||||||
importData: "Import data",
|
importData: "Import data",
|
||||||
baseCopy:
|
baseCopy:
|
||||||
@ -447,6 +465,7 @@ const en: TranslationTree = {
|
|||||||
exportBackup: "Export guide data",
|
exportBackup: "Export guide data",
|
||||||
importBackup: "Import guide backup",
|
importBackup: "Import guide backup",
|
||||||
importedBackup: "Guide backup has been imported.",
|
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.",
|
importStart: "Import a JSON file to start reviewing.",
|
||||||
saveName: "Save name",
|
saveName: "Save name",
|
||||||
deletePerson: "Delete user and reports",
|
deletePerson: "Delete user and reports",
|
||||||
@ -487,6 +506,7 @@ const en: TranslationTree = {
|
|||||||
technical: "Technical information",
|
technical: "Technical information",
|
||||||
rawData: "Raw data",
|
rawData: "Raw data",
|
||||||
integrity: "Integrity check",
|
integrity: "Integrity check",
|
||||||
|
dataMode: "Data mode",
|
||||||
time: "Time",
|
time: "Time",
|
||||||
workTime: "Work time",
|
workTime: "Work time",
|
||||||
status: "Status",
|
status: "Status",
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { attachIntegrity, buildCanonicalReportPayload, canonicalizeReportForIntegrity, createExportIntegrity, verifyEntryIntegrity } from "./integrity";
|
import { attachIntegrity, buildCanonicalReportPayload, canonicalizeReportForIntegrity, createExportIntegrity, verifyEntryIntegrity } from "./integrity";
|
||||||
|
import { demoEntries } from "./demoData";
|
||||||
import type { Report } from "./types";
|
import type { Report } from "./types";
|
||||||
|
|
||||||
const report: Report = {
|
const report: Report = {
|
||||||
@ -104,6 +105,18 @@ describe("integritetskontroll", () => {
|
|||||||
expect(verifyEntryIntegrity(withLocalFields).integrity?.verification_status).toBe("valid");
|
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", () => {
|
it("validerer rapport med arbeidssted og legacy energidetaljer uten false modified", () => {
|
||||||
const raw = {
|
const raw = {
|
||||||
id: "aac87f66-2d39-4a6f-8169-7d39aa5fc0e6",
|
id: "aac87f66-2d39-4a6f-8169-7d39aa5fc0e6",
|
||||||
|
|||||||
@ -20,7 +20,10 @@ const EXCLUDED_KEYS = new Set([
|
|||||||
"ui_state",
|
"ui_state",
|
||||||
"total_score_percent",
|
"total_score_percent",
|
||||||
"score_band",
|
"score_band",
|
||||||
"normalized_period_score"
|
"normalized_period_score",
|
||||||
|
"demo",
|
||||||
|
"data_mode",
|
||||||
|
"contains_demo_data"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ALIAS_FIELDS: Record<string, string> = {
|
const ALIAS_FIELDS: Record<string, string> = {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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";
|
import type { ExportPayload, Report } from "./types";
|
||||||
|
|
||||||
const store = new Map<string, string>();
|
const store = new Map<string, string>();
|
||||||
@ -68,4 +68,24 @@ describe("ordinær brukerimport", () => {
|
|||||||
expect(summary.conflicts_detected).toBe(1);
|
expect(summary.conflicts_detected).toBe(1);
|
||||||
expect(summary.reports_added).toBe(0);
|
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 { calculateReportScore, getEntryTime } from "./scoring";
|
||||||
import { attachIntegrity, createExportIntegrity, summarizeVerification, verifyEntryIntegrity } from "./integrity";
|
import { attachIntegrity, createExportIntegrity, summarizeVerification, verifyEntryIntegrity } from "./integrity";
|
||||||
|
import { demoEntries } from "./demoData";
|
||||||
import type { DelayedFollowup, ExportPayload, FollowupDraft, ImportSummary, Report, ReportDraft, StoredEntry } from "./types";
|
import type { DelayedFollowup, ExportPayload, FollowupDraft, ImportSummary, Report, ReportDraft, StoredEntry } from "./types";
|
||||||
|
|
||||||
const STORAGE_KEY = "arbeidsevne-egenvurdering:entries";
|
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[] {
|
export type DataMode = "normal" | "demo";
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
|
||||||
|
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 [];
|
if (!raw) return [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
const entries = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.reports) ? parsed.reports : [];
|
const candidate = parsed && typeof parsed === "object" ? (parsed as { reports?: unknown }).reports : undefined;
|
||||||
return entries.map(normalizeStoredEntry);
|
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 {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeEntries(entries: StoredEntry[]) {
|
function writeEntries(entries: StoredEntry[], mode: DataMode = "normal") {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
|
const normalized = entries.map((entry) => (mode === "demo" ? markDemoEntry(entry) : stripDemoMarker(entry)));
|
||||||
|
localStorage.setItem(storageKey(mode), JSON.stringify(normalized));
|
||||||
}
|
}
|
||||||
|
|
||||||
function dateParts(now = new Date()) {
|
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 (
|
if (
|
||||||
!draft.work_ability ||
|
!draft.work_ability ||
|
||||||
!draft.energy_level ||
|
!draft.energy_level ||
|
||||||
@ -82,12 +117,13 @@ export function createReport(draft: ReportDraft): Report {
|
|||||||
};
|
};
|
||||||
report.total_score_percent = calculateReportScore(report) ?? undefined;
|
report.total_score_percent = calculateReportScore(report) ?? undefined;
|
||||||
|
|
||||||
const signedReport = attachIntegrity(report);
|
const mode = modeFromDemoFlag(demoMode);
|
||||||
writeEntries([signedReport, ...readEntries()]);
|
const signedReport = demoMode ? markDemoEntry(attachIntegrity(report)) as Report : attachIntegrity(report);
|
||||||
|
writeEntries([signedReport, ...readEntries(mode)], mode);
|
||||||
return signedReport;
|
return signedReport;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFollowup(draft: FollowupDraft): DelayedFollowup {
|
export function createFollowup(draft: FollowupDraft, demoMode = false): DelayedFollowup {
|
||||||
if (!draft.worse_than_before) {
|
if (!draft.worse_than_before) {
|
||||||
throw new Error("Oppfølgingen mangler svar på om du er verre enn før økten.");
|
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
|
note: draft.note.trim() || undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
const signedFollowup = attachIntegrity(followup);
|
const mode = modeFromDemoFlag(demoMode);
|
||||||
writeEntries([signedFollowup, ...readEntries()]);
|
const signedFollowup = demoMode ? markDemoEntry(attachIntegrity(followup)) as DelayedFollowup : attachIntegrity(followup);
|
||||||
|
writeEntries([signedFollowup, ...readEntries(mode)], mode);
|
||||||
return signedFollowup;
|
return signedFollowup;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listReports(): StoredEntry[] {
|
export function listReports(demoMode = false): StoredEntry[] {
|
||||||
return readEntries().sort((a, b) => getEntryTime(b) - getEntryTime(a));
|
if (demoMode) ensureDemoData();
|
||||||
|
return readEntries(modeFromDemoFlag(demoMode)).sort((a, b) => getEntryTime(b) - getEntryTime(a));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLastWorkReport(): Report | undefined {
|
export function getLastWorkReport(demoMode = false): Report | undefined {
|
||||||
return listReports().find((entry): entry is Report => entry.type === "work_report");
|
return listReports(demoMode).find((entry): entry is Report => entry.type === "work_report");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getReport(id: string): StoredEntry | undefined {
|
export function getReport(id: string, demoMode = false): StoredEntry | undefined {
|
||||||
return readEntries().find((entry) => entry.id === id);
|
return readEntries(modeFromDemoFlag(demoMode)).find((entry) => entry.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteReport(id: string) {
|
export function deleteReport(id: string, demoMode = false) {
|
||||||
writeEntries(readEntries().filter((entry) => entry.id !== id));
|
const mode = modeFromDemoFlag(demoMode);
|
||||||
|
writeEntries(readEntries(mode).filter((entry) => entry.id !== id), mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteAllReports() {
|
export function deleteAllReports(demoMode = false) {
|
||||||
writeEntries([]);
|
writeEntries([], modeFromDemoFlag(demoMode));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exportReports() {
|
export function exportReports(demoMode = false) {
|
||||||
const reports = listReports();
|
const reports = listReports(demoMode);
|
||||||
const payload: ExportPayload = {
|
const payload: ExportPayload = {
|
||||||
schema_version: 3,
|
schema_version: 3,
|
||||||
export_version: 3,
|
export_version: 3,
|
||||||
exported_at: new Date().toISOString(),
|
exported_at: new Date().toISOString(),
|
||||||
|
data_mode: demoMode ? "demo" : "normal",
|
||||||
|
contains_demo_data: demoMode || reports.some(isDemoEntry),
|
||||||
export_integrity: createExportIntegrity(reports),
|
export_integrity: createExportIntegrity(reports),
|
||||||
reports
|
reports
|
||||||
};
|
};
|
||||||
return JSON.stringify(payload, null, 2);
|
return JSON.stringify(payload, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function previewImportReports(jsonText: string) {
|
export function previewImportReports(jsonText: string, demoMode = false) {
|
||||||
const imported = parseImportEntries(jsonText).map(normalizeStoredEntry).map(verifyEntryIntegrity);
|
const parsed = JSON.parse(jsonText);
|
||||||
return { ...summarizeImport(imported, readEntries()), verification: summarizeVerification(imported) };
|
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 {
|
export function importReportsFromJson(jsonText: string, demoMode = false): ImportSummary {
|
||||||
const imported = parseImportEntries(jsonText).map(normalizeStoredEntry).map(verifyEntryIntegrity);
|
const parsed = JSON.parse(jsonText);
|
||||||
const existing = readEntries();
|
const imported = parseImportEntriesFromParsed(parsed).map(normalizeStoredEntry).map(verifyEntryIntegrity);
|
||||||
const summary = { ...summarizeImport(imported, existing), verification: summarizeVerification(imported) };
|
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 existingSignatures = new Set(existing.map(entrySignature));
|
||||||
const existingById = new Map(existing.map((entry) => [entry.id, entry]));
|
const existingById = new Map(existing.map((entry) => [entry.id, entry]));
|
||||||
const additions: StoredEntry[] = [];
|
const additions: StoredEntry[] = [];
|
||||||
|
|
||||||
for (const entry of imported) {
|
for (const entry of prepared) {
|
||||||
const existingWithId = existingById.get(entry.id);
|
const existingWithId = existingById.get(entry.id);
|
||||||
if (existingWithId && entryContentSignature(existingWithId) !== entryContentSignature(entry)) continue;
|
if (existingWithId && entryContentSignature(existingWithId) !== entryContentSignature(entry)) continue;
|
||||||
if (existingSignatures.has(entrySignature(entry))) continue;
|
if (existingSignatures.has(entrySignature(entry))) continue;
|
||||||
@ -161,13 +224,13 @@ export function importReportsFromJson(jsonText: string): ImportSummary {
|
|||||||
additions.push(entry);
|
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;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseImportEntries(jsonText: string): StoredEntry[] {
|
function parseImportEntriesFromParsed(parsed: unknown): StoredEntry[] {
|
||||||
const parsed = JSON.parse(jsonText);
|
const candidate = parsed && typeof parsed === "object" ? (parsed as { reports?: unknown }).reports : undefined;
|
||||||
const entries = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.reports) ? parsed.reports : null;
|
const entries = Array.isArray(parsed) ? parsed : Array.isArray(candidate) ? candidate : null;
|
||||||
if (!entries) throw new Error("Filen inneholder ikke en gjenkjennbar rapporteksport.");
|
if (!entries) throw new Error("Filen inneholder ikke en gjenkjennbar rapporteksport.");
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
@ -248,3 +311,25 @@ function normalizeStoredEntry(entry: StoredEntry): StoredEntry {
|
|||||||
delayed_symptoms: entry.delayed_symptoms ?? []
|
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));
|
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 {
|
.topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -46,6 +51,14 @@ button {
|
|||||||
padding: 10px 2px 14px;
|
padding: 10px 2px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-actions {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.topbar h1,
|
.topbar h1,
|
||||||
.field-head h2,
|
.field-head h2,
|
||||||
.filters h2,
|
.filters h2,
|
||||||
@ -663,9 +676,11 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.text-link {
|
.text-link {
|
||||||
|
background: transparent;
|
||||||
border: 1px solid #ccd7d4;
|
border: 1px solid #ccd7d4;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: #235b5e;
|
color: #235b5e;
|
||||||
|
cursor: pointer;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
padding: 9px 12px;
|
padding: 9px 12px;
|
||||||
@ -673,6 +688,52 @@ textarea {
|
|||||||
white-space: nowrap;
|
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 {
|
.inline-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -99,6 +99,7 @@ export interface Report {
|
|||||||
total_score_percent?: number;
|
total_score_percent?: number;
|
||||||
integrity?: ReportIntegrity;
|
integrity?: ReportIntegrity;
|
||||||
note?: string;
|
note?: string;
|
||||||
|
demo?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportDraft {
|
export interface ReportDraft {
|
||||||
@ -134,6 +135,7 @@ export interface DelayedFollowup {
|
|||||||
recovery_status?: RecoveryStatus;
|
recovery_status?: RecoveryStatus;
|
||||||
integrity?: ReportIntegrity;
|
integrity?: ReportIntegrity;
|
||||||
note?: string;
|
note?: string;
|
||||||
|
demo?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FollowupDraft {
|
export interface FollowupDraft {
|
||||||
@ -164,6 +166,7 @@ export interface EvaluatorProfile {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
source_fingerprint?: string;
|
source_fingerprint?: string;
|
||||||
|
contains_demo_data?: boolean;
|
||||||
reports: StoredEntry[];
|
reports: StoredEntry[];
|
||||||
evaluator_notes: EvaluatorNote[];
|
evaluator_notes: EvaluatorNote[];
|
||||||
}
|
}
|
||||||
@ -172,6 +175,8 @@ export interface ExportPayload {
|
|||||||
schema_version: number;
|
schema_version: number;
|
||||||
export_version: number;
|
export_version: number;
|
||||||
exported_at: string;
|
exported_at: string;
|
||||||
|
data_mode?: "normal" | "demo";
|
||||||
|
contains_demo_data?: boolean;
|
||||||
export_integrity?: ExportIntegrity;
|
export_integrity?: ExportIntegrity;
|
||||||
reports: StoredEntry[];
|
reports: StoredEntry[];
|
||||||
}
|
}
|
||||||
@ -181,6 +186,7 @@ export interface ImportSummary {
|
|||||||
followups_added: number;
|
followups_added: number;
|
||||||
duplicates_skipped: number;
|
duplicates_skipped: number;
|
||||||
conflicts_detected: number;
|
conflicts_detected: number;
|
||||||
|
demo_entries_detected?: number;
|
||||||
verification?: VerificationSummary;
|
verification?: VerificationSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user