Release v1.1.1 demo advisor mode

This commit is contained in:
Franz Rolfsvaag 2026-05-28 11:13:35 +02:00
parent 82da79cc4a
commit 76ec48bfef
9 changed files with 314 additions and 139 deletions

4
package-lock.json generated
View File

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

View File

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

View File

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

View File

@ -37,6 +37,7 @@ import {
import { import {
deleteEvaluatorNote, deleteEvaluatorNote,
deleteEvaluatorProfile, deleteEvaluatorProfile,
ensureDemoEvaluatorProfiles,
exportEvaluatorBackup, exportEvaluatorBackup,
importEvaluatorBackup, importEvaluatorBackup,
importExportPayload, importExportPayload,
@ -153,20 +154,14 @@ function AppRoutes() {
return ( return (
<div className={demoMode ? "app-shell demo-active" : "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>
<div className="topbar-actions"> <a className="text-link" href="/veileder">
<button className={demoMode ? "text-link active" : "text-link"} onClick={() => changeDemoMode(!demoMode)} type="button"> {t("app.evaluator")}
{demoMode ? t("demo.exit") : t("demo.enter")} </a>
</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")}>
@ -206,7 +201,7 @@ function AppRoutes() {
)} )}
{view === "personvern" && <PrivacyPanel demoMode={demoMode} entries={entries} onDeleted={refresh} />} {view === "personvern" && <PrivacyPanel demoMode={demoMode} entries={entries} onDeleted={refresh} />}
</main> </main>
<AppFooter /> <AppFooter demoMode={demoMode} onDemoModeChange={changeDemoMode} />
<ConfirmDeleteModal <ConfirmDeleteModal
itemName={deleteRequest?.name ?? ""} itemName={deleteRequest?.name ?? ""}
open={Boolean(deleteRequest)} open={Boolean(deleteRequest)}
@ -242,21 +237,7 @@ function NavButton({
); );
} }
function DemoBanner({ onExit }: { onExit: () => void }) { function AppFooter({ demoMode, onDemoModeChange }: { demoMode: boolean; onDemoModeChange: (enabled: boolean) => 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 { language, setLanguage, t } = useI18n();
const baseYear = 2026; const baseYear = 2026;
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
@ -292,6 +273,9 @@ function AppFooter() {
<Github aria-hidden="true" /> <Github aria-hidden="true" />
<span>{t("footer.sourceCode", { version: APP_VERSION })}</span> <span>{t("footer.sourceCode", { version: APP_VERSION })}</span>
</a> </a>
<button className={demoMode ? "footer-link active" : "footer-link"} onClick={() => onDemoModeChange(!demoMode)} type="button">
{demoMode ? t("demo.exit") : t("demo.enter")}
</button>
</div> </div>
</footer> </footer>
); );
@ -1687,8 +1671,9 @@ function PrivacyPanel({ demoMode, entries, onDeleted }: { demoMode: boolean; ent
function EvaluatorApp() { function EvaluatorApp() {
const { labels, t } = useI18n(); const { labels, t } = useI18n();
const [profiles, setProfiles] = useState<EvaluatorProfile[]>(() => listEvaluatorProfiles()); const [demoMode, setDemoMode] = useState(() => isDemoModeEnabled());
const [selectedId, setSelectedId] = useState<string | null>(() => listEvaluatorProfiles()[0]?.id ?? null); const [profiles, setProfiles] = useState<EvaluatorProfile[]>(() => listEvaluatorProfiles(isDemoModeEnabled()));
const [selectedId, setSelectedId] = useState<string | null>(() => listEvaluatorProfiles(isDemoModeEnabled())[0]?.id ?? null);
const [displayName, setDisplayName] = useState(""); const [displayName, setDisplayName] = useState("");
const [targetId, setTargetId] = useState(""); const [targetId, setTargetId] = useState("");
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
@ -1705,10 +1690,27 @@ function EvaluatorApp() {
if (selected) setPersonNote(getPersonNote(selected)?.text ?? ""); if (selected) setPersonNote(getPersonNote(selected)?.text ?? "");
}, [selected?.id]); }, [selected?.id]);
const refresh = (nextSelectedId?: string) => { useEffect(() => {
const next = listEvaluatorProfiles(); const next = listEvaluatorProfiles(demoMode);
setProfiles(next); setProfiles(next);
if (nextSelectedId) setSelectedId(nextSelectedId); setSelectedId((current) => (current && next.some((profile) => profile.id === current) ? current : next[0]?.id ?? null));
setSelectedEntryId(null);
}, [demoMode]);
const refresh = (nextSelectedId?: string) => {
const next = listEvaluatorProfiles(demoMode);
setProfiles(next);
setSelectedId((current) => nextSelectedId ?? (current && next.some((profile) => profile.id === current) ? current : next[0]?.id ?? null));
};
const changeDemoMode = (enabled: boolean) => {
setDemoModeEnabled(enabled);
if (enabled) {
ensureDemoData();
ensureDemoEvaluatorProfiles();
}
setDemoMode(enabled);
setMessage(enabled ? t("demo.notice") : "");
}; };
const importFile = async (event: React.ChangeEvent<HTMLInputElement>) => { const importFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
@ -1716,7 +1718,7 @@ function EvaluatorApp() {
if (!file) return; if (!file) return;
try { try {
const result = importExportPayload(await file.text(), displayName, targetId || undefined); const result = importExportPayload(await file.text(), displayName, targetId || undefined, demoMode);
const flagged = result.verification.modified + result.verification.missing + result.verification.unsupported + result.verification.error; const flagged = result.verification.modified + result.verification.missing + result.verification.unsupported + result.verification.error;
const actionText = const actionText =
result.target_resolution === "matched_existing_person" result.target_resolution === "matched_existing_person"
@ -1739,8 +1741,10 @@ function EvaluatorApp() {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (!file) return; if (!file) return;
try { try {
importEvaluatorBackup(await file.text()); const text = await file.text();
setMessage(t("evaluator.importedBackup")); const demoEntries = countEvaluatorBackupDemoEntries(text);
importEvaluatorBackup(text, demoMode);
setMessage(`${t("evaluator.importedBackup")}${demoEntries > 0 ? ` ADVARSEL: Backupen inneholder ${demoEntries} demodata-oppføringer.` : ""}`);
refresh(); refresh();
} catch (error) { } catch (error) {
setMessage(error instanceof Error ? error.message : "Backup kunne ikke importeres."); setMessage(error instanceof Error ? error.message : "Backup kunne ikke importeres.");
@ -1750,32 +1754,32 @@ function EvaluatorApp() {
}; };
const downloadBackup = () => { const downloadBackup = () => {
const blob = new Blob([exportEvaluatorBackup()], { type: "application/json" }); const blob = new Blob([exportEvaluatorBackup(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-veilederdata-${new Date().toISOString().slice(0, 10)}.json`; link.download = `${demoMode ? "arbeidspuls-demo" : "arbeidspuls"}-veilederdata-${new Date().toISOString().slice(0, 10)}.json`;
link.click(); link.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
const saveName = () => { const saveName = () => {
if (!selected) return; if (!selected) return;
renameEvaluatorProfile(selected.id, selected.display_name); renameEvaluatorProfile(selected.id, selected.display_name, demoMode);
refresh(selected.id); refresh(selected.id);
}; };
const savePersonNote = () => { const savePersonNote = () => {
if (!selected) return; if (!selected) return;
upsertEvaluatorNote(selected.id, personNote, { note_type: "person" }); upsertEvaluatorNote(selected.id, personNote, { note_type: "person" }, demoMode);
setPersonNote(""); setPersonNote("");
refresh(selected.id); refresh(selected.id);
}; };
const deleteSelectedPerson = () => { const deleteSelectedPerson = () => {
if (!deletePersonRequest) return; if (!deletePersonRequest) return;
deleteEvaluatorProfile(deletePersonRequest.id); deleteEvaluatorProfile(deletePersonRequest.id, demoMode);
const remaining = listEvaluatorProfiles(); const remaining = listEvaluatorProfiles(demoMode);
setProfiles(remaining); setProfiles(remaining);
setSelectedId(remaining[0]?.id ?? null); setSelectedId(remaining[0]?.id ?? null);
setSelectedEntryId(null); setSelectedEntryId(null);
@ -1801,7 +1805,7 @@ function EvaluatorApp() {
const followupsByParent = useMemo(() => (selected ? groupFollowups(selected.reports) : new Map<string, DelayedFollowup[]>()), [selected]); const followupsByParent = useMemo(() => (selected ? groupFollowups(selected.reports) : new Map<string, DelayedFollowup[]>()), [selected]);
return ( return (
<div className="app-shell evaluator-shell"> <div className={demoMode ? "app-shell evaluator-shell demo-active" : "app-shell evaluator-shell"}>
<header className="topbar"> <header className="topbar">
<div> <div>
<p className="eyebrow">{t("evaluator.eyebrow")}</p> <p className="eyebrow">{t("evaluator.eyebrow")}</p>
@ -1812,6 +1816,13 @@ function EvaluatorApp() {
</a> </a>
</header> </header>
{demoMode && (
<div className="notice warning">
<ShieldAlert aria-hidden="true" />
<span>{t("demo.notice")}</span>
</div>
)}
<main className="evaluator-layout"> <main className="evaluator-layout">
<section className="evaluator-sidebar"> <section className="evaluator-sidebar">
<div className="field-card"> <div className="field-card">
@ -1968,6 +1979,7 @@ function EvaluatorApp() {
followups={selectedEntry.type === "work_report" ? followupsByParent.get(selectedEntry.id) ?? [] : []} followups={selectedEntry.type === "work_report" ? followupsByParent.get(selectedEntry.id) ?? [] : []}
notes={selected.evaluator_notes} notes={selected.evaluator_notes}
profileId={selected.id} profileId={selected.id}
demoMode={demoMode}
onSaved={() => refresh(selected.id)} onSaved={() => refresh(selected.id)}
onClose={() => setSelectedEntryId(null)} onClose={() => setSelectedEntryId(null)}
/> />
@ -1982,7 +1994,7 @@ function EvaluatorApp() {
)} )}
</section> </section>
</main> </main>
<AppFooter /> <AppFooter demoMode={demoMode} onDemoModeChange={changeDemoMode} />
<ConfirmDeleteModal <ConfirmDeleteModal
open={Boolean(deletePersonRequest)} open={Boolean(deletePersonRequest)}
itemName={ itemName={
@ -1997,6 +2009,20 @@ function EvaluatorApp() {
); );
} }
function countEvaluatorBackupDemoEntries(jsonText: string) {
try {
const parsed = JSON.parse(jsonText) as { data_mode?: string; profiles?: EvaluatorProfile[] };
if (!Array.isArray(parsed.profiles)) return 0;
const count = parsed.profiles.reduce(
(total, profile) => total + (profile.contains_demo_data ? 1 : 0) + profile.reports.filter((entry) => entry.demo || entry.id.startsWith("demo-arbeidspuls")).length,
0
);
return parsed.data_mode === "demo" && count === 0 ? 1 : count;
} catch {
return 0;
}
}
function ReviewReportCard({ function ReviewReportCard({
report, report,
followups, followups,
@ -2130,6 +2156,7 @@ function ReviewDetailPane({
followups, followups,
notes, notes,
profileId, profileId,
demoMode,
onSaved, onSaved,
onClose onClose
}: { }: {
@ -2137,6 +2164,7 @@ function ReviewDetailPane({
followups: DelayedFollowup[]; followups: DelayedFollowup[];
notes: EvaluatorProfile["evaluator_notes"]; notes: EvaluatorProfile["evaluator_notes"];
profileId: string; profileId: string;
demoMode: boolean;
onSaved: () => void; onSaved: () => void;
onClose: () => void; onClose: () => void;
}) { }) {
@ -2151,22 +2179,22 @@ function ReviewDetailPane({
const save = () => { const save = () => {
if (entry.type === "work_report") { if (entry.type === "work_report") {
upsertEvaluatorNote(profileId, noteText, { note_type: "report", related_report_id: entry.id }); upsertEvaluatorNote(profileId, noteText, { note_type: "report", related_report_id: entry.id }, demoMode);
} else { } else {
upsertEvaluatorNote(profileId, noteText, { upsertEvaluatorNote(profileId, noteText, {
note_type: "followup", note_type: "followup",
related_followup_id: entry.id, related_followup_id: entry.id,
parent_report_id: findParentId(entry) parent_report_id: findParentId(entry)
}); }, demoMode);
} }
onSaved(); onSaved();
}; };
const remove = () => { const remove = () => {
if (entry.type === "work_report") { if (entry.type === "work_report") {
deleteEvaluatorNote(profileId, { note_type: "report", related_report_id: entry.id }); deleteEvaluatorNote(profileId, { note_type: "report", related_report_id: entry.id }, demoMode);
} else { } else {
deleteEvaluatorNote(profileId, { note_type: "followup", related_followup_id: entry.id }); deleteEvaluatorNote(profileId, { note_type: "followup", related_followup_id: entry.id }, demoMode);
} }
setNoteText(""); setNoteText("");
onSaved(); onSaved();

View File

@ -1,4 +1,4 @@
import type { StoredEntry } from "./types"; import type { EvaluatorProfile, StoredEntry } from "./types";
export const demoEntries: StoredEntry[] = [ export const demoEntries: StoredEntry[] = [
{ {
@ -182,3 +182,154 @@ export const demoEntries: StoredEntry[] = [
} }
} }
]; ];
const sharedDemoProfiles = {
created_at: "2026-05-28T08:00:00.000Z",
updated_at: "2026-05-28T08:00:00.000Z",
contains_demo_data: true,
evaluator_notes: []
};
export const demoEvaluatorProfiles: EvaluatorProfile[] = [
{
...sharedDemoProfiles,
id: "demo-advisor-profile-01",
display_name: "Demo: Nora Hjemmekontor",
source_fingerprint: "demo-nora",
reports: demoEntries
},
{
...sharedDemoProfiles,
id: "demo-advisor-profile-02",
display_name: "Demo: Amir Kontor",
source_fingerprint: "demo-amir",
reports: [
{
id: "demo-arbeidspuls-amir-001",
created_at: "2026-05-18T07:20:00.000Z",
report_date: "2026-05-18",
report_time: "09:20:00",
type: "work_report",
workplace: "Kontor",
work_start_time: "08:30",
work_end_time: "11:30",
work_ability: 3,
energy_level: 3,
mental_clarity: 4,
symptom_burden: 2,
effort_strain: 3,
status: "kan_fortsette",
physical_energy: 3,
mental_energy: 4,
perceived_productivity: 3,
task_completion: "delvis",
task_types: ["sitting", "computer_work", "time_pressure"],
main_limitations: ["fatigue"],
helpful_accommodations: ["extra_breaks"],
note: "Testdata: moderat dag med noen pauser.",
total_score_percent: 68,
demo: true
},
{
id: "demo-arbeidspuls-amir-002",
created_at: "2026-05-23T10:45:00.000Z",
report_date: "2026-05-23",
report_time: "12:45:00",
type: "work_report",
workplace: "Kontor",
work_start_time: "10:00",
work_end_time: "12:30",
work_ability: 2,
energy_level: 2,
mental_clarity: 2,
symptom_burden: 4,
effort_strain: 5,
status: "trenger_pause",
physical_energy: 2,
mental_energy: 2,
perceived_productivity: 2,
task_completion: "litt",
task_types: ["sitting", "talking_social", "noise"],
main_limitations: ["fatigue", "sensory_overload", "brain_fog"],
helpful_accommodations: ["quiet_room", "shorter_task"],
note: "Testdata: støy og sosial belastning ga tydelig fall.",
total_score_percent: 24,
demo: true
},
{
id: "demo-arbeidspuls-amir-followup-002",
created_at: "2026-05-24T08:30:00.000Z",
report_date: "2026-05-24",
report_time: "10:30:00",
type: "delayed_followup",
parent_report_id: "demo-arbeidspuls-amir-002",
related_report_id: "demo-arbeidspuls-amir-002",
worse_than_before: "moderat",
delayed_symptoms: ["fatigue", "brain_fog", "headache"],
recovery_status: "to_tre_dager",
note: "Testdata: oppfølging etter krevende kontordag.",
demo: true
}
]
},
{
...sharedDemoProfiles,
id: "demo-advisor-profile-03",
display_name: "Demo: Liv Butikk",
source_fingerprint: "demo-liv",
reports: [
{
id: "demo-arbeidspuls-liv-001",
created_at: "2026-05-19T12:15:00.000Z",
report_date: "2026-05-19",
report_time: "14:15:00",
type: "work_report",
workplace: "Butikk",
work_start_time: "12:00",
work_end_time: "14:00",
work_ability: 3,
energy_level: 3,
mental_clarity: 3,
symptom_burden: 3,
effort_strain: 3,
status: "trenger_enklere_oppgave",
physical_energy: 3,
mental_energy: 3,
perceived_productivity: 3,
task_completion: "delvis",
task_types: ["standing", "walking", "talking_social"],
main_limitations: ["pain", "fatigue"],
helpful_accommodations: ["sitting_down", "help_from_others"],
note: "Testdata: fysisk aktivitet krevde tilrettelegging.",
total_score_percent: 50,
demo: true
},
{
id: "demo-arbeidspuls-liv-002",
created_at: "2026-05-26T07:50:00.000Z",
report_date: "2026-05-26",
report_time: "09:50:00",
type: "work_report",
workplace: "Butikk",
work_start_time: "09:00",
work_end_time: "09:45",
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: ["standing", "walking"],
main_limitations: [],
helpful_accommodations: ["shorter_task", "sitting_down"],
note: "Testdata: kortere oppgave fungerte bedre.",
total_score_percent: 78,
demo: true
}
]
}
];

View File

@ -123,4 +123,12 @@ describe("evaluator-import", () => {
expect(result.profile.contains_demo_data).toBe(true); expect(result.profile.contains_demo_data).toBe(true);
expect(listEvaluatorProfiles()[0].contains_demo_data).toBe(true); expect(listEvaluatorProfiles()[0].contains_demo_data).toBe(true);
}); });
it("holder veilederens demobrukere separat fra normale brukere", () => {
const demoProfiles = listEvaluatorProfiles(true);
expect(demoProfiles.length).toBeGreaterThanOrEqual(3);
expect(demoProfiles.every((profile) => profile.contains_demo_data)).toBe(true);
expect(listEvaluatorProfiles()).toHaveLength(0);
});
}); });

View File

@ -1,8 +1,11 @@
import { calculateReportScore, getEntryTime } from "./scoring"; import { calculateReportScore, getEntryTime } from "./scoring";
import { summarizeVerification, verifyEntryIntegrity } from "./integrity"; import { summarizeVerification, verifyEntryIntegrity } from "./integrity";
import { demoEvaluatorProfiles } from "./demoData";
import type { EvaluatorNote, EvaluatorProfile, ExportPayload, ImportTargetResolution, Report, StoredEntry } from "./types"; import type { EvaluatorNote, EvaluatorProfile, ExportPayload, ImportTargetResolution, Report, StoredEntry } from "./types";
const EVALUATOR_KEY = "arbeidsevne-egenvurdering:evaluator-profiles"; const EVALUATOR_KEY = "arbeidsevne-egenvurdering:evaluator-profiles";
const DEMO_EVALUATOR_KEY = "arbeidspuls:demo-evaluator-profiles";
const DEMO_EVALUATOR_SEEDED_KEY = "arbeidspuls:demo-evaluator-seeded";
export type ImportResult = { export type ImportResult = {
profile: EvaluatorProfile; profile: EvaluatorProfile;
@ -15,8 +18,22 @@ export type ImportResult = {
matched_report_ids: string[]; matched_report_ids: string[];
}; };
function readProfiles(): EvaluatorProfile[] { function evaluatorKey(demoMode = false) {
const raw = localStorage.getItem(EVALUATOR_KEY); return demoMode ? DEMO_EVALUATOR_KEY : EVALUATOR_KEY;
}
export function ensureDemoEvaluatorProfiles() {
const existing = readProfiles(true);
if (existing.length > 0) return existing;
if (localStorage.getItem(DEMO_EVALUATOR_SEEDED_KEY) === "true") return [];
const seeded = demoEvaluatorProfiles.map(normalizeProfile);
writeProfiles(seeded, true);
localStorage.setItem(DEMO_EVALUATOR_SEEDED_KEY, "true");
return seeded;
}
function readProfiles(demoMode = false): EvaluatorProfile[] {
const raw = localStorage.getItem(evaluatorKey(demoMode));
if (!raw) return []; if (!raw) return [];
try { try {
@ -27,32 +44,33 @@ function readProfiles(): EvaluatorProfile[] {
} }
} }
function writeProfiles(profiles: EvaluatorProfile[]) { function writeProfiles(profiles: EvaluatorProfile[], demoMode = false) {
localStorage.setItem(EVALUATOR_KEY, JSON.stringify(profiles)); localStorage.setItem(evaluatorKey(demoMode), JSON.stringify(profiles));
} }
export function listEvaluatorProfiles() { export function listEvaluatorProfiles(demoMode = false) {
return readProfiles().map(normalizeProfile).sort((a, b) => b.updated_at.localeCompare(a.updated_at)); if (demoMode) ensureDemoEvaluatorProfiles();
return readProfiles(demoMode).map(normalizeProfile).sort((a, b) => b.updated_at.localeCompare(a.updated_at));
} }
export function updateEvaluatorProfile(profile: EvaluatorProfile) { export function updateEvaluatorProfile(profile: EvaluatorProfile, demoMode = false) {
const profiles = readProfiles(); const profiles = readProfiles(demoMode);
const next = profiles.some((item) => item.id === profile.id) const next = profiles.some((item) => item.id === profile.id)
? profiles.map((item) => (item.id === profile.id ? profile : item)) ? profiles.map((item) => (item.id === profile.id ? profile : item))
: [profile, ...profiles]; : [profile, ...profiles];
writeProfiles(next); writeProfiles(next, demoMode);
} }
export function renameEvaluatorProfile(profileId: string, displayName: string) { export function renameEvaluatorProfile(profileId: string, displayName: string, demoMode = false) {
const now = new Date().toISOString(); const now = new Date().toISOString();
const profiles = readProfiles().map((profile) => const profiles = readProfiles(demoMode).map((profile) =>
profile.id === profileId ? { ...profile, display_name: displayName.trim() || profile.display_name, updated_at: now } : profile profile.id === profileId ? { ...profile, display_name: displayName.trim() || profile.display_name, updated_at: now } : profile
); );
writeProfiles(profiles); writeProfiles(profiles, demoMode);
} }
export function deleteEvaluatorProfile(profileId: string) { export function deleteEvaluatorProfile(profileId: string, demoMode = false) {
writeProfiles(readProfiles().filter((profile) => profile.id !== profileId)); writeProfiles(readProfiles(demoMode).filter((profile) => profile.id !== profileId), demoMode);
} }
export function addEvaluatorNote(profileId: string, text: string, relatedReportId?: string) { export function addEvaluatorNote(profileId: string, text: string, relatedReportId?: string) {
@ -62,22 +80,24 @@ export function addEvaluatorNote(profileId: string, text: string, relatedReportI
export function upsertEvaluatorNote( export function upsertEvaluatorNote(
profileId: string, profileId: string,
text: string, text: string,
noteTarget: Pick<EvaluatorNote, "note_type" | "related_report_id" | "related_followup_id" | "parent_report_id"> noteTarget: Pick<EvaluatorNote, "note_type" | "related_report_id" | "related_followup_id" | "parent_report_id">,
demoMode = false
) { ) {
const now = new Date().toISOString(); const now = new Date().toISOString();
const trimmed = text.trim(); const trimmed = text.trim();
const profiles = readProfiles().map((profile) => const profiles = readProfiles(demoMode).map((profile) =>
profile.id === profileId ? upsertNoteInProfile(normalizeProfile(profile), trimmed, noteTarget, now) : profile profile.id === profileId ? upsertNoteInProfile(normalizeProfile(profile), trimmed, noteTarget, now) : profile
); );
writeProfiles(profiles); writeProfiles(profiles, demoMode);
} }
export function deleteEvaluatorNote( export function deleteEvaluatorNote(
profileId: string, profileId: string,
noteTarget: Pick<EvaluatorNote, "note_type" | "related_report_id" | "related_followup_id"> noteTarget: Pick<EvaluatorNote, "note_type" | "related_report_id" | "related_followup_id">,
demoMode = false
) { ) {
const now = new Date().toISOString(); const now = new Date().toISOString();
const profiles = readProfiles().map((profile) => const profiles = readProfiles(demoMode).map((profile) =>
profile.id === profileId profile.id === profileId
? { ? {
...normalizeProfile(profile), ...normalizeProfile(profile),
@ -86,14 +106,14 @@ export function deleteEvaluatorNote(
} }
: profile : profile
); );
writeProfiles(profiles); writeProfiles(profiles, demoMode);
} }
export function importExportPayload(jsonText: string, displayName: string, targetProfileId?: string): ImportResult { export function importExportPayload(jsonText: string, displayName: string, targetProfileId?: string, demoMode = false): ImportResult {
const parsed = JSON.parse(jsonText); const parsed = JSON.parse(jsonText);
const reports = extractReports(parsed).map(verifyEntryIntegrity); const reports = extractReports(parsed).map(verifyEntryIntegrity);
const demoEntriesDetected = countDemoEntries(reports, parsed); const demoEntriesDetected = countDemoEntries(reports, parsed);
const profiles = readProfiles().map(normalizeProfile); const profiles = readProfiles(demoMode).map(normalizeProfile);
const fingerprint = makeFingerprint(reports); const fingerprint = makeFingerprint(reports);
const target = resolveImportTarget(reports, profiles, targetProfileId); const target = resolveImportTarget(reports, profiles, targetProfileId);
if (target.action === "conflict_requires_manual_resolution") { if (target.action === "conflict_requires_manual_resolution") {
@ -141,7 +161,7 @@ export function importExportPayload(jsonText: string, displayName: string, targe
evaluator_notes: [] evaluator_notes: []
}; };
updateEvaluatorProfile(profile); updateEvaluatorProfile(profile, demoMode);
return { return {
profile, profile,
added, added,
@ -154,25 +174,26 @@ export function importExportPayload(jsonText: string, displayName: string, targe
}; };
} }
export function exportEvaluatorBackup() { export function exportEvaluatorBackup(demoMode = false) {
return JSON.stringify( return JSON.stringify(
{ {
backup_type: "arbeidsevne-veilederdata", backup_type: "arbeidsevne-veilederdata",
schema_version: 1, schema_version: 1,
exported_at: new Date().toISOString(), exported_at: new Date().toISOString(),
profiles: listEvaluatorProfiles() data_mode: demoMode ? "demo" : "normal",
profiles: listEvaluatorProfiles(demoMode)
}, },
null, null,
2 2
); );
} }
export function importEvaluatorBackup(jsonText: string) { export function importEvaluatorBackup(jsonText: string, demoMode = false) {
const parsed = JSON.parse(jsonText); const parsed = JSON.parse(jsonText);
if (parsed?.backup_type !== "arbeidsevne-veilederdata" || !Array.isArray(parsed.profiles)) { if (parsed?.backup_type !== "arbeidsevne-veilederdata" || !Array.isArray(parsed.profiles)) {
throw new Error("Filen er ikke en gyldig veilederbackup."); throw new Error("Filen er ikke en gyldig veilederbackup.");
} }
const existing = readProfiles(); const existing = readProfiles(demoMode);
const merged = [...existing]; const merged = [...existing];
for (const profile of parsed.profiles as EvaluatorProfile[]) { for (const profile of parsed.profiles as EvaluatorProfile[]) {
const index = merged.findIndex((item) => item.id === profile.id); const index = merged.findIndex((item) => item.id === profile.id);
@ -186,7 +207,7 @@ export function importEvaluatorBackup(jsonText: string) {
merged.push(normalizeProfile(profile)); merged.push(normalizeProfile(profile));
} }
} }
writeProfiles(merged); writeProfiles(merged, demoMode);
} }
export function extractReports(payload: ExportPayload | StoredEntry[] | { reports?: StoredEntry[] }) { export function extractReports(payload: ExportPayload | StoredEntry[] | { reports?: StoredEntry[] }) {

View File

@ -334,7 +334,7 @@ const en: TranslationTree = {
notCalculated: "Not calculated", notCalculated: "Not calculated",
unknownWorkplace: "Unknown workplace" unknownWorkplace: "Unknown workplace"
}, },
app: { eyebrow: "Local self-assessment", title: "Arbeidspuls", evaluator: "Guide", navLabel: "Main navigation" }, app: { eyebrow: "Local self-assessment", title: "Arbeidspuls", evaluator: "Advisor", navLabel: "Main navigation" },
nav: { register: "Register", followup: "Follow-up", overview: "Overview", privacy: "Privacy" }, nav: { register: "Register", followup: "Follow-up", overview: "Overview", privacy: "Privacy" },
demo: { demo: {
enter: "Demo", enter: "Demo",
@ -442,9 +442,9 @@ const en: TranslationTree = {
backupTitle: "Backup and device changes", backupTitle: "Backup and device changes",
backupBody: backupBody:
"Because the data is stored locally, it may be lost if you change device, delete browser data, use another browser or reset the device. Export a JSON file regularly if you want to keep the reports.", "Because the data is stored locally, it may be lost if you change device, delete browser data, use another browser or reset the device. Export a JSON file regularly if you want to keep the reports.",
sharingTitle: "Sharing with a guide", sharingTitle: "Sharing with an advisor",
sharingBody: sharingBody:
"If a guide or another person will review the reports, export a JSON file and share it with them, for example by email. The recipient can import the JSON file in the guide view.", "If an advisor or another person will review the reports, export a JSON file and share it with them, for example by email. The recipient can import the JSON file in the advisor view.",
sensitiveTitle: "Sensitive information", sensitiveTitle: "Sensitive information",
sensitiveBody: sensitiveBody:
"Reports may contain health and function-related information. Share the JSON file in a way suitable for sensitive information, and only with people who should have access.", "Reports may contain health and function-related information. Share the JSON file in a way suitable for sensitive information, and only with people who should have access.",
@ -452,8 +452,8 @@ const en: TranslationTree = {
deleteAllName: "all local reports" deleteAllName: "all local reports"
}, },
evaluator: { evaluator: {
eyebrow: "Guide", eyebrow: "Advisor",
title: "Guide review", title: "Advisor review",
backToApp: "Back to app", backToApp: "Back to app",
importJson: "Import JSON", importJson: "Import JSON",
importHelp: "Choose an export from the app. Data is stored locally in this browser.", importHelp: "Choose an export from the app. Data is stored locally in this browser.",
@ -462,14 +462,14 @@ const en: TranslationTree = {
connectExisting: "Connect to existing person", connectExisting: "Connect to existing person",
autoMatch: "Create new or use automatic match", autoMatch: "Create new or use automatic match",
chooseJson: "Choose JSON file", chooseJson: "Choose JSON file",
exportBackup: "Export guide data", exportBackup: "Export advisor data",
importBackup: "Import guide backup", importBackup: "Import advisor backup",
importedBackup: "Guide backup has been imported.", importedBackup: "Advisor backup has been imported.",
demoDataWarning: "This import contains demo data. Do not use it as real user or health information.", 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",
deletePersonTitle: "Deletes this user, imported reports, follow-ups and guide notes after confirmation.", deletePersonTitle: "Deletes this user, imported reports, follow-ups and advisor notes after confirmation.",
reportCount: "{count} reports", reportCount: "{count} reports",
personNote: "General note for user", personNote: "General note for user",
personNoteHelp: "This note applies to the person as a whole, not one specific report.", personNoteHelp: "This note applies to the person as a whole, not one specific report.",
@ -486,15 +486,15 @@ const en: TranslationTree = {
closeDetails: "Close details", closeDetails: "Close details",
reportNote: "Report note", reportNote: "Report note",
followupNote: "Follow-up note", followupNote: "Follow-up note",
noteHelp: "The guide note is stored locally and does not change imported report data.", noteHelp: "The advisor note is stored locally and does not change imported report data.",
deleteNote: "Delete note", deleteNote: "Delete note",
validationError: "Validation error", validationError: "Validation error",
comment: "Comment", comment: "Comment",
evaluatorNote: "Guide note", evaluatorNote: "Advisor note",
followupNoteBadge: "Follow-up note", followupNoteBadge: "Follow-up note",
hasFollowup: "Has follow-up", hasFollowup: "Has follow-up",
hasComment: "Has user comment", hasComment: "Has user comment",
hasEvaluatorNote: "Has guide note", hasEvaluatorNote: "Has advisor note",
scoreValues: "Score values" scoreValues: "Score values"
}, },
details: { details: {

View File

@ -51,14 +51,6 @@ 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,
@ -688,47 +680,6 @@ 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 { .demo-meta-pill {
background: #fbecd7; background: #fbecd7;
color: #7d4118; color: #7d4118;
@ -1633,7 +1584,10 @@ textarea {
border: 1px solid #ccd7d4; border: 1px solid #ccd7d4;
border-radius: 999px; border-radius: 999px;
color: #235b5e; color: #235b5e;
cursor: pointer;
display: inline-flex; display: inline-flex;
font: inherit;
font-weight: 850;
gap: 0.4rem; gap: 0.4rem;
justify-content: center; justify-content: center;
padding: 0.44rem 0.7rem; padding: 0.44rem 0.7rem;
@ -1653,6 +1607,19 @@ textarea {
transform: translateY(-1px); transform: translateY(-1px);
} }
.footer-link.active {
background: #235b5e;
border-color: #235b5e;
color: #fff;
}
.footer-link.active:hover,
.footer-link.active:focus-visible {
background: #1f4f51;
border-color: #1f4f51;
color: #fff;
}
.app-footer svg { .app-footer svg {
width: 14px; width: 14px;
height: 14px; height: 14px;