Initial commit

This commit is contained in:
Franz Rolfsvaag 2026-05-27 22:51:28 +02:00
commit 6bcb87febe
25 changed files with 8449 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
dist/
*.log
.env
.env.local

17
index.html Normal file
View File

@ -0,0 +1,17 @@
<!doctype html>
<html lang="nb">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#235b5e" />
<meta name="description" content="Rask egenvurdering av arbeidsevne under og etter arbeidsutprøving." />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/icon.svg" />
<title>Arbeidsevne</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2208
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "arbeidsevne-egenvurdering",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc && vite build",
"preview": "vite preview --host 0.0.0.0",
"test": "vitest run"
},
"dependencies": {
"lucide-react": "^0.475.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "^5.8.0",
"vite": "^7.0.0",
"vitest": "^4.1.7"
}
}

1
public/_redirects Normal file
View File

@ -0,0 +1 @@
/* /index.html 200

6
public/icon.svg Normal file
View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#235b5e"/>
<path d="M137 286c24-94 69-141 119-141s95 47 119 141" fill="none" stroke="#f7d06c" stroke-width="42" stroke-linecap="round"/>
<path d="M154 329h204" stroke="#f9f6ee" stroke-width="42" stroke-linecap="round"/>
<circle cx="256" cy="256" r="42" fill="#f9f6ee"/>
</svg>

After

Width:  |  Height:  |  Size: 393 B

View File

@ -0,0 +1,20 @@
{
"name": "Arbeidsevne-egenvurdering",
"short_name": "Arbeidsevne",
"description": "Rask lokal egenvurdering av arbeidsevne under og etter arbeidsutprøving.",
"lang": "nb",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#f6f2ea",
"theme_color": "#235b5e",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}

33
public/service-worker.js Normal file
View File

@ -0,0 +1,33 @@
const CACHE_NAME = "arbeidsevne-egenvurdering-v4";
const ASSETS = ["/", "/index.html", "/manifest.webmanifest", "/icon.svg"];
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS)));
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
);
self.clients.claim();
});
self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return;
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) return cached;
return fetch(event.request)
.then((response) => {
const copy = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy));
return response;
})
.catch(() => caches.match("/index.html"));
})
);
});

2422
src/App.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { deleteEvaluatorProfile, importExportPayload, listEvaluatorProfiles, upsertEvaluatorNote } from "./evaluatorStorage";
import type { ExportPayload, Report } from "./types";
const store = new Map<string, string>();
beforeEach(() => {
store.clear();
vi.stubGlobal("localStorage", {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, value),
removeItem: (key: string) => store.delete(key),
clear: () => store.clear()
});
});
const report: Report = {
id: "rapport-1",
report_time: "08:00:00",
report_date: "2026-05-27",
created_at: "2026-05-27T06:00:00.000Z",
type: "work_report",
workplace: "Teststed",
work_ability: 4,
energy_level: 3,
mental_clarity: 4,
symptom_burden: 2,
effort_strain: 3,
status: "kan_fortsette",
physical_energy: 3,
mental_energy: 3,
task_types: [],
main_limitations: [],
helpful_accommodations: []
};
function payload(reports: Report[]): ExportPayload {
return {
schema_version: 2,
export_version: 2,
exported_at: "2026-05-27T10:00:00.000Z",
reports
};
}
describe("evaluator-import", () => {
it("importerer samme rapport-ID bare én gang", () => {
const first = importExportPayload(JSON.stringify(payload([report])), "Person A");
const second = importExportPayload(JSON.stringify(payload([report])), "Person A", first.profile.id);
expect(first.added).toBe(1);
expect(first.target_resolution).toBe("created_new_person");
expect(second.added).toBe(0);
expect(second.skipped).toBe(1);
expect(second.target_resolution).toBe("matched_existing_person");
expect(second.matched_person_id).toBe(first.profile.id);
expect(second.matched_report_ids).toEqual(["rapport-1"]);
expect(listEvaluatorProfiles()[0].reports).toHaveLength(1);
});
it("legger nye rapporter til eksisterende person ved UUID-treff", () => {
const first = importExportPayload(JSON.stringify(payload([report])), "Person A");
const nextReport = { ...report, id: "rapport-2", created_at: "2026-05-28T06:00:00.000Z", report_date: "2026-05-28" };
const second = importExportPayload(JSON.stringify(payload([report, nextReport])), "Nytt navn som ikke skal overskrive");
expect(second.added).toBe(1);
expect(second.skipped).toBe(1);
expect(second.target_resolution).toBe("matched_existing_person");
expect(listEvaluatorProfiles()[0].reports).toHaveLength(2);
expect(listEvaluatorProfiles()[0].display_name).toBe(first.profile.display_name);
});
it("oppretter ny bruker når ingen rapport-UUIDer matcher", () => {
importExportPayload(JSON.stringify(payload([report])), "Person A");
const unrelatedReport = { ...report, id: "rapport-ny", created_at: "2026-05-28T06:00:00.000Z", report_date: "2026-05-28" };
const imported = importExportPayload(JSON.stringify(payload([unrelatedReport])), "Person B");
expect(imported.target_resolution).toBe("created_new_person");
expect(listEvaluatorProfiles()).toHaveLength(2);
});
it("stopper import når rapport-UUIDer matcher flere brukere", () => {
const otherReport = { ...report, id: "rapport-2", created_at: "2026-05-28T06:00:00.000Z", report_date: "2026-05-28" };
importExportPayload(JSON.stringify(payload([report])), "Person A");
importExportPayload(JSON.stringify(payload([otherReport])), "Person B");
expect(() => importExportPayload(JSON.stringify(payload([report, otherReport])), "Konflikt")).toThrow("matcher flere brukere");
});
it("beholder veiledernotater ved gjentatt import", () => {
const first = importExportPayload(JSON.stringify(payload([report])), "Person A");
upsertEvaluatorNote(first.profile.id, "Viktig rapportnotat", { note_type: "report", related_report_id: report.id });
importExportPayload(JSON.stringify(payload([report])), "Person A", first.profile.id);
const profile = listEvaluatorProfiles()[0];
expect(profile.evaluator_notes).toHaveLength(1);
expect(profile.evaluator_notes[0].text).toBe("Viktig rapportnotat");
});
it("sletter valgt veilederbruker uten å påvirke andre brukere", () => {
const first = importExportPayload(JSON.stringify(payload([report])), "Person A");
const otherReport = { ...report, id: "rapport-2", created_at: "2026-05-28T06:00:00.000Z", report_date: "2026-05-28" };
const second = importExportPayload(JSON.stringify(payload([otherReport])), "Person B");
upsertEvaluatorNote(first.profile.id, "Rapportnotat", { note_type: "report", related_report_id: report.id });
deleteEvaluatorProfile(first.profile.id);
const profiles = listEvaluatorProfiles();
expect(profiles).toHaveLength(1);
expect(profiles[0].id).toBe(second.profile.id);
expect(profiles[0].reports).toHaveLength(1);
expect(profiles[0].evaluator_notes).toHaveLength(0);
});
});

318
src/evaluatorStorage.ts Normal file
View File

@ -0,0 +1,318 @@
import { calculateReportScore, getEntryTime } from "./scoring";
import { summarizeVerification, verifyEntryIntegrity } from "./integrity";
import type { EvaluatorNote, EvaluatorProfile, ExportPayload, ImportTargetResolution, Report, StoredEntry } from "./types";
const EVALUATOR_KEY = "arbeidsevne-egenvurdering:evaluator-profiles";
export type ImportResult = {
profile: EvaluatorProfile;
added: number;
skipped: number;
verification: ReturnType<typeof summarizeVerification>;
target_resolution: ImportTargetResolution;
matched_person_id: string | null;
matched_report_ids: string[];
};
function readProfiles(): EvaluatorProfile[] {
const raw = localStorage.getItem(EVALUATOR_KEY);
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function writeProfiles(profiles: EvaluatorProfile[]) {
localStorage.setItem(EVALUATOR_KEY, JSON.stringify(profiles));
}
export function listEvaluatorProfiles() {
return readProfiles().map(normalizeProfile).sort((a, b) => b.updated_at.localeCompare(a.updated_at));
}
export function updateEvaluatorProfile(profile: EvaluatorProfile) {
const profiles = readProfiles();
const next = profiles.some((item) => item.id === profile.id)
? profiles.map((item) => (item.id === profile.id ? profile : item))
: [profile, ...profiles];
writeProfiles(next);
}
export function renameEvaluatorProfile(profileId: string, displayName: string) {
const now = new Date().toISOString();
const profiles = readProfiles().map((profile) =>
profile.id === profileId ? { ...profile, display_name: displayName.trim() || profile.display_name, updated_at: now } : profile
);
writeProfiles(profiles);
}
export function deleteEvaluatorProfile(profileId: string) {
writeProfiles(readProfiles().filter((profile) => profile.id !== profileId));
}
export function addEvaluatorNote(profileId: string, text: string, relatedReportId?: string) {
return upsertEvaluatorNote(profileId, text, { note_type: relatedReportId ? "report" : "person", related_report_id: relatedReportId });
}
export function upsertEvaluatorNote(
profileId: string,
text: string,
noteTarget: Pick<EvaluatorNote, "note_type" | "related_report_id" | "related_followup_id" | "parent_report_id">
) {
const now = new Date().toISOString();
const trimmed = text.trim();
const profiles = readProfiles().map((profile) =>
profile.id === profileId ? upsertNoteInProfile(normalizeProfile(profile), trimmed, noteTarget, now) : profile
);
writeProfiles(profiles);
}
export function deleteEvaluatorNote(
profileId: string,
noteTarget: Pick<EvaluatorNote, "note_type" | "related_report_id" | "related_followup_id">
) {
const now = new Date().toISOString();
const profiles = readProfiles().map((profile) =>
profile.id === profileId
? {
...normalizeProfile(profile),
evaluator_notes: normalizeProfile(profile).evaluator_notes.filter((note) => !sameNoteTarget(note, noteTarget)),
updated_at: now
}
: profile
);
writeProfiles(profiles);
}
export function importExportPayload(jsonText: string, displayName: string, targetProfileId?: string): ImportResult {
const reports = extractReports(JSON.parse(jsonText)).map(verifyEntryIntegrity);
const profiles = readProfiles().map(normalizeProfile);
const fingerprint = makeFingerprint(reports);
const target = resolveImportTarget(reports, profiles, targetProfileId);
if (target.action === "conflict_requires_manual_resolution") {
const names = target.matches.map((match) => `${match.person.display_name} (${match.matchCount})`).join(", ");
throw new Error(`Importen matcher flere brukere og må avklares manuelt: ${names}.`);
}
const targetProfile = target.person;
const now = new Date().toISOString();
const existingReports = targetProfile?.reports ?? [];
const existingKeys = new Set(existingReports.map(reportSignature));
const nextReports: StoredEntry[] = [...existingReports];
let added = 0;
let skipped = 0;
for (const report of reports) {
const normalized = normalizeImportedEntry(report);
const signature = reportSignature(normalized);
if (existingKeys.has(signature)) {
skipped += 1;
continue;
}
existingKeys.add(signature);
nextReports.push(normalized);
added += 1;
}
const profile: EvaluatorProfile = targetProfile
? {
...targetProfile,
display_name: targetProfile.display_name,
updated_at: now,
source_fingerprint: targetProfile.source_fingerprint ?? fingerprint,
reports: nextReports.sort((a, b) => getEntryTime(b) - getEntryTime(a))
}
: {
id: crypto.randomUUID(),
display_name: displayName.trim() || `Import ${new Date().toLocaleDateString("nb-NO")}`,
created_at: now,
updated_at: now,
source_fingerprint: fingerprint,
reports: nextReports.sort((a, b) => getEntryTime(b) - getEntryTime(a)),
evaluator_notes: []
};
updateEvaluatorProfile(profile);
return {
profile,
added,
skipped,
verification: summarizeVerification(reports),
target_resolution: targetProfile ? "matched_existing_person" : "created_new_person",
matched_person_id: targetProfile?.id ?? null,
matched_report_ids: target.matchedReportIds
};
}
export function exportEvaluatorBackup() {
return JSON.stringify(
{
backup_type: "arbeidsevne-veilederdata",
schema_version: 1,
exported_at: new Date().toISOString(),
profiles: listEvaluatorProfiles()
},
null,
2
);
}
export function importEvaluatorBackup(jsonText: string) {
const parsed = JSON.parse(jsonText);
if (parsed?.backup_type !== "arbeidsevne-veilederdata" || !Array.isArray(parsed.profiles)) {
throw new Error("Filen er ikke en gyldig veilederbackup.");
}
const existing = readProfiles();
const merged = [...existing];
for (const profile of parsed.profiles as EvaluatorProfile[]) {
const index = merged.findIndex((item) => item.id === profile.id);
if (index >= 0) {
merged[index] = {
...normalizeProfile(merged[index]),
...normalizeProfile(profile),
display_name: normalizeProfile(merged[index]).display_name || normalizeProfile(profile).display_name
};
} else {
merged.push(normalizeProfile(profile));
}
}
writeProfiles(merged);
}
export function extractReports(payload: ExportPayload | StoredEntry[] | { reports?: StoredEntry[] }) {
if (Array.isArray(payload)) return payload;
if (payload && Array.isArray(payload.reports)) return payload.reports;
throw new Error("Filen inneholder ikke en gjenkjennbar rapporteksport.");
}
export function reportSignature(entry: Partial<StoredEntry>) {
if (entry.id) return `id:${entry.id}`;
const values =
entry.type === "work_report"
? [
entry.created_at,
entry.report_date,
entry.report_time,
(entry as Partial<Report>).work_ability,
(entry as Partial<Report>).energy_level,
(entry as Partial<Report>).status
]
: [entry.created_at, entry.report_date, entry.report_time, entry.type];
return `fallback:${values.join("|")}`;
}
function normalizeImportedEntry(entry: StoredEntry): StoredEntry {
if (entry.type === "work_report") {
const { physical_energy_detail, mental_energy_detail, ...rest } = entry;
return {
...rest,
task_types: rest.task_types ?? [],
main_limitations: rest.main_limitations ?? [],
helpful_accommodations: rest.helpful_accommodations ?? [],
physical_energy: rest.physical_energy ?? physical_energy_detail ?? rest.energy_level,
mental_energy: rest.mental_energy ?? mental_energy_detail ?? rest.energy_level,
total_score_percent: rest.total_score_percent ?? calculateReportScore({ ...rest, physical_energy: rest.physical_energy ?? physical_energy_detail ?? rest.energy_level, mental_energy: rest.mental_energy ?? mental_energy_detail ?? rest.energy_level }) ?? undefined
};
}
return {
...entry,
parent_report_id: entry.parent_report_id ?? entry.related_report_id,
related_report_id: entry.related_report_id ?? entry.parent_report_id,
delayed_symptoms: entry.delayed_symptoms ?? []
};
}
function normalizeProfile(profile: EvaluatorProfile): EvaluatorProfile {
return {
...profile,
reports: (profile.reports ?? []).map(normalizeImportedEntry).sort((a, b) => getEntryTime(b) - getEntryTime(a)),
evaluator_notes: (profile.evaluator_notes ?? []).map((note) => ({
...note,
note_type: note.note_type ?? (note.related_report_id ? "report" : note.related_followup_id ? "followup" : "person")
}))
};
}
function upsertNoteInProfile(
profile: EvaluatorProfile,
text: string,
noteTarget: Pick<EvaluatorNote, "note_type" | "related_report_id" | "related_followup_id" | "parent_report_id">,
now: string
): EvaluatorProfile {
const notes = profile.evaluator_notes.filter((note) => !sameNoteTarget(note, noteTarget));
if (!text) {
return { ...profile, evaluator_notes: notes, updated_at: now };
}
const existing = profile.evaluator_notes.find((note) => sameNoteTarget(note, noteTarget));
const note: EvaluatorNote = {
id: existing?.id ?? crypto.randomUUID(),
created_at: existing?.created_at ?? now,
updated_at: now,
text,
note_type: noteTarget.note_type,
related_report_id: noteTarget.related_report_id,
related_followup_id: noteTarget.related_followup_id,
parent_report_id: noteTarget.parent_report_id
};
return { ...profile, evaluator_notes: [note, ...notes], updated_at: now };
}
function sameNoteTarget(note: Pick<EvaluatorNote, "note_type" | "related_report_id" | "related_followup_id">, target: Pick<EvaluatorNote, "note_type" | "related_report_id" | "related_followup_id">) {
return (
(note.note_type ?? "person") === (target.note_type ?? "person") &&
(note.related_report_id ?? "") === (target.related_report_id ?? "") &&
(note.related_followup_id ?? "") === (target.related_followup_id ?? "")
);
}
function makeFingerprint(reports: StoredEntry[]) {
const base = reports
.slice()
.sort((a, b) => reportSignature(a).localeCompare(reportSignature(b)))
.map(reportSignature)
.join(";");
let hash = 0;
for (let index = 0; index < base.length; index += 1) {
hash = (hash * 31 + base.charCodeAt(index)) >>> 0;
}
return hash.toString(16);
}
function resolveImportTarget(reports: StoredEntry[], profiles: EvaluatorProfile[], targetProfileId?: string) {
const importedIds = new Set(reports.map((report) => report.id).filter(Boolean));
if (importedIds.size === 0 && targetProfileId) {
return {
action: "matched_existing_person" as const,
person: profiles.find((profile) => profile.id === targetProfileId),
matchedReportIds: [] as string[]
};
}
const matches = profiles
.map((person) => {
const personIds = new Set(person.reports.map((report) => report.id).filter(Boolean));
const matchedReportIds = [...importedIds].filter((id) => personIds.has(id));
return { person, matchCount: matchedReportIds.length, matchedReportIds };
})
.filter((match) => match.matchCount > 0)
.sort((a, b) => b.matchCount - a.matchCount);
if (matches.length === 0) return { action: "created_new_person" as const, person: undefined, matchedReportIds: [] as string[] };
if (matches.length === 1) {
return {
action: "matched_existing_person" as const,
person: matches[0].person,
matchedReportIds: matches[0].matchedReportIds
};
}
return {
action: "conflict_requires_manual_resolution" as const,
matches,
matchedReportIds: matches.flatMap((match) => match.matchedReportIds)
};
}

21
src/graph.test.ts Normal file
View File

@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { createLineSegments } from "./App";
describe("grafsegmenter", () => {
it("lager striplet horisontal startlinje og stripler hull på minst tre dager", () => {
const day = 24 * 60 * 60 * 1000;
const segments = createLineSegments(
[
{ x: 20, y: 50, time: 0 },
{ x: 40, y: 40, time: day },
{ x: 100, y: 20, time: 4 * day }
],
0
);
expect(segments[0]).toMatchObject({ x1: 0, x2: 20, y1: 50, y2: 50, dashed: true });
expect(segments[1].dashed).toBe(false);
expect(segments[2].dashed).toBe(true);
expect(segments[2]).toMatchObject({ x1: 40, y1: 40, x2: 100, y2: 20 });
});
});

195
src/integrity.test.ts Normal file
View File

@ -0,0 +1,195 @@
import { describe, expect, it } from "vitest";
import { attachIntegrity, buildCanonicalReportPayload, canonicalizeReportForIntegrity, createExportIntegrity, verifyEntryIntegrity } from "./integrity";
import type { Report } from "./types";
const report: Report = {
id: "rapport-1",
report_time: "08:00:00",
report_date: "2026-05-27",
created_at: "2026-05-27T06:00:00.000Z",
type: "work_report",
work_ability: 4,
energy_level: 3,
mental_clarity: 4,
symptom_burden: 2,
effort_strain: 3,
status: "kan_fortsette",
physical_energy: 3,
mental_energy: 3,
task_types: [],
main_limitations: [],
helpful_accommodations: []
};
describe("integritetskontroll", () => {
it("legger til og validerer integritetsmetadata", () => {
const signed = attachIntegrity(report);
const verified = verifyEntryIntegrity(signed);
expect(signed.integrity?.payload_hash_sha256).toBeTruthy();
expect(signed.integrity?.payload_hash_sha256).toHaveLength(64);
expect(signed.integrity?.algorithm).toBe("sha256");
expect(verified.integrity?.verification_status).toBe("valid");
});
it("markerer endret rapport som mulig endret", () => {
const signed = attachIntegrity(report);
const tampered = { ...signed, energy_level: 1 as const };
const verified = verifyEntryIntegrity(tampered);
expect(verified.integrity?.verification_status).toBe("modified");
});
it("validerer uendret eksport og import uten falsk modified", () => {
const signed = attachIntegrity({ ...report, total_score_percent: 72 });
const exported = JSON.stringify({ reports: [signed] });
const imported = JSON.parse(exported).reports[0] as Report;
const verified = verifyEntryIntegrity(imported);
expect(verified.integrity?.verification_status).toBe("valid");
});
it("påvirkes ikke av nøkkelrekkefølge eller appgenerert verifiseringsstatus", () => {
const signed = attachIntegrity(report);
const reordered = {
type: signed.type,
created_at: signed.created_at,
id: signed.id,
report_date: signed.report_date,
report_time: signed.report_time,
status: signed.status,
effort_strain: signed.effort_strain,
symptom_burden: signed.symptom_burden,
mental_clarity: signed.mental_clarity,
energy_level: signed.energy_level,
work_ability: signed.work_ability,
physical_energy: signed.physical_energy,
mental_energy: signed.mental_energy,
helpful_accommodations: signed.helpful_accommodations,
main_limitations: signed.main_limitations,
task_types: signed.task_types,
integrity: { ...signed.integrity!, verification_status: "modified" as const }
};
expect(canonicalizeReportForIntegrity(reordered)).toBe(canonicalizeReportForIntegrity(signed));
expect(verifyEntryIntegrity(reordered).integrity?.verification_status).toBe("valid");
});
it("fjerner hele integrity-objektet fra canonical payload", () => {
const signed = attachIntegrity(report);
const withChangedIntegrity = {
...signed,
integrity: {
...signed.integrity!,
verification_status: "modified" as const,
verification_reason: "Endret lokal forklaring",
signed_at: "2099-01-01T00:00:00.000Z"
}
};
expect(buildCanonicalReportPayload(withChangedIntegrity)).toBe(buildCanonicalReportPayload(report));
expect(verifyEntryIntegrity(withChangedIntegrity).integrity?.verification_status).toBe("valid");
});
it("ignorerer rekalkulert totalscore og veiledernotater i integritetskontrollen", () => {
const signed = attachIntegrity(report);
const withLocalFields = {
...signed,
total_score_percent: 12,
evaluator_notes: [{ id: "note-1", text: "Lokalt notat" }],
report_review_note: "Lokalt notat",
normalized_period_score: 55
} as Report;
expect(verifyEntryIntegrity(withLocalFields).integrity?.verification_status).toBe("valid");
});
it("validerer rapport med arbeidssted og legacy energidetaljer uten false modified", () => {
const raw = {
id: "aac87f66-2d39-4a6f-8169-7d39aa5fc0e6",
created_at: "2026-05-27T18:36:17.482Z",
report_date: "2026-05-27",
report_time: "20:36:17",
type: "work_report",
workplace: "Hjemme",
work_start_time: "12:30",
work_end_time: "20:00",
work_ability: 4,
energy_level: 3,
mental_clarity: 4,
symptom_burden: 1,
effort_strain: 2,
status: "kan_fortsette",
physical_energy: 3,
mental_energy: 4,
physical_energy_detail: 3,
mental_energy_detail: 4,
perceived_productivity: 4,
task_completion: "som_forventet",
task_types: ["sitting", "computer_work", "concentration"],
main_limitations: ["stress_anxiety"],
helpful_accommodations: [],
total_score_percent: 81
} as Report;
const signed = attachIntegrity(raw);
const imported = JSON.parse(JSON.stringify(signed)) as Report;
expect(buildCanonicalReportPayload(imported)).not.toContain("physical_energy_detail");
expect(buildCanonicalReportPayload(imported)).not.toContain("mental_energy_detail");
expect(verifyEntryIntegrity(imported).integrity?.verification_status).toBe("valid");
});
it("bruker legacy energidetaljer som fallback når foretrukket felt mangler", () => {
const preferred = attachIntegrity({ ...report, physical_energy: 2, mental_energy: 4 });
const aliasOnly = {
...report,
physical_energy: undefined,
mental_energy: undefined,
physical_energy_detail: 2,
mental_energy_detail: 4
} as unknown as Report;
expect(buildCanonicalReportPayload(aliasOnly)).toBe(buildCanonicalReportPayload(preferred));
expect(verifyEntryIntegrity({ ...aliasOnly, integrity: preferred.integrity }).integrity?.verification_status).toBe("valid");
});
it("oppdager faktisk endring av rapporterte felt", () => {
const signed = attachIntegrity({ ...report, note: "Opprinnelig kommentar" });
expect(verifyEntryIntegrity({ ...signed, work_ability: 1 }).integrity?.verification_status).toBe("modified");
expect(verifyEntryIntegrity({ ...signed, energy_level: 1 }).integrity?.verification_status).toBe("modified");
expect(verifyEntryIntegrity({ ...signed, symptom_burden: 5 }).integrity?.verification_status).toBe("modified");
expect(verifyEntryIntegrity({ ...signed, workplace: "Annet sted" }).integrity?.verification_status).toBe("modified");
expect(verifyEntryIntegrity({ ...signed, note: "Endret kommentar" }).integrity?.verification_status).toBe("modified");
});
it("markerer eldre local-fnv1a-64 metadata som unsupported, ikke modified", () => {
const legacy = {
...report,
integrity: {
report_id: report.id,
canonicalization: "stable-json-v2" as const,
algorithm: "local-fnv1a-64" as const,
payload_hash_sha256: "1234",
signed_at: "2026-05-27T06:00:00.000Z"
}
} as Report;
expect(verifyEntryIntegrity(legacy).integrity?.verification_status).toBe("unsupported");
});
it("markerer rapport uten metadata som ikke verifisert", () => {
const verified = verifyEntryIntegrity(report);
expect(verified.integrity?.verification_status).toBe("missing");
});
it("lager eksportintegritet for manifest", () => {
const signed = attachIntegrity(report);
const integrity = createExportIntegrity([signed]);
expect(integrity.schema).toBe("arbeidsevne-export-integrity-v1");
expect(integrity.manifest_hash_sha256).toBeTruthy();
});
});

289
src/integrity.ts Normal file
View File

@ -0,0 +1,289 @@
import type { ExportIntegrity, ReportIntegrity, StoredEntry, VerificationStatus, VerificationSummary } from "./types";
const EXCLUDED_KEYS = new Set([
"integrity",
"report_integrity",
"payload_hash_sha256",
"signature",
"signed_at",
"verification_status",
"verification_reason",
"verification_error",
"imported_at",
"import_source",
"import_metadata",
"evaluator_notes",
"report_review_note",
"followup_review_notes",
"person_display_name",
"local_display_name",
"ui_state",
"total_score_percent",
"score_band",
"normalized_period_score"
]);
const ALIAS_FIELDS: Record<string, string> = {
physical_energy_detail: "physical_energy",
mental_energy_detail: "mental_energy"
};
export function attachIntegrity<T extends StoredEntry>(entry: T): T {
const integrity: ReportIntegrity = {
report_id: entry.id,
canonicalization: "stable-json-v2",
algorithm: "sha256",
payload_hash_sha256: hashEntryPayload(entry),
signed_at: entry.created_at || new Date().toISOString(),
verification_status: "valid"
};
return { ...entry, integrity };
}
export function verifyEntryIntegrity<T extends StoredEntry>(entry: T): T {
if (!entry.integrity) {
return withVerification(entry, "missing", "Rapporten mangler verifiseringsdata. Dette kan være en eldre eksport.");
}
if (!["stable-json-v1", "stable-json-v2"].includes(entry.integrity.canonicalization)) {
return withVerification(entry, "unsupported", "Verifiseringsmetoden støttes ikke av denne appen.");
}
if (entry.integrity.algorithm !== "sha256" && entry.integrity.algorithm !== "SHA-256") {
return withVerification(
entry,
"unsupported",
"Rapporten bruker en eldre verifiseringsmetode som ikke kan kontrolleres sikkert i denne versjonen."
);
}
try {
const expected = entry.integrity.payload_hash_sha256;
const actual = hashEntryPayload(entry);
return expected === actual
? withVerification(entry, "valid", "Rapporten matcher lagret integritetsdata.")
: withVerification(entry, "modified", "Rapporten stemmer ikke med verifiseringsdata. Innholdet kan være endret.");
} catch {
return withVerification(entry, "error", "Det oppstod en feil under verifisering.");
}
}
export function createExportIntegrity(entries: StoredEntry[]): ExportIntegrity {
const manifest = entries.map((entry) => ({
id: entry.id,
type: entry.type,
payload_hash_sha256: entry.integrity?.payload_hash_sha256 ?? hashEntryPayload(entry)
}));
return {
schema: "arbeidsevne-export-integrity-v1",
created_at: new Date().toISOString(),
algorithm: "sha256",
manifest_hash_sha256: stableHash(manifest),
note:
"Lokal best-effort integritetskontroll. Dette kan avdekke endringer i eksporterte data, men er ikke et juridisk eller medisinsk bevis."
};
}
export function summarizeVerification(entries: StoredEntry[]): VerificationSummary {
const summary: VerificationSummary = { valid: 0, modified: 0, missing: 0, unsupported: 0, error: 0 };
for (const entry of entries) {
const status = entry.integrity?.verification_status ?? "missing";
summary[status] += 1;
}
return summary;
}
export function verificationLabel(status: VerificationStatus | undefined) {
switch (status) {
case "valid":
return "Verifisert";
case "modified":
return "Mulig endret";
case "unsupported":
return "Verifisering støttes ikke";
case "error":
return "Feil ved verifisering";
case "missing":
default:
return "Ikke verifisert";
}
}
export function verificationTooltip(status: VerificationStatus | undefined) {
switch (status) {
case "modified":
return "Rapporten stemmer ikke med verifiseringsdata. Innholdet kan være endret.";
case "missing":
case undefined:
return "Rapporten mangler verifiseringsdata. Dette kan være en eldre eksport eller data uten signering.";
case "unsupported":
return "Verifisering støttes ikke for denne rapporten.";
case "error":
return "Det oppstod en feil ved verifisering.";
case "valid":
return "Rapporten matcher lagret integritetsdata.";
}
}
export function hashEntryPayload(entry: StoredEntry) {
return sha256(buildCanonicalReportPayload(entry));
}
export function canonicalizeReportForIntegrity(entry: StoredEntry) {
return buildCanonicalReportPayload(entry);
}
export function buildCanonicalReportPayload(entry: StoredEntry) {
const clone = clonePlain(entry);
const normalized = normalizeLegacyAndAliasFields(stripExcluded(clone));
return stableStringify(stripUndefined(normalized));
}
function withVerification<T extends StoredEntry>(entry: T, status: VerificationStatus, reason: string): T {
const integrity: ReportIntegrity = entry.integrity
? { ...entry.integrity, verification_status: status, verification_reason: reason }
: {
report_id: entry.id,
canonicalization: "stable-json-v2",
algorithm: "sha256",
payload_hash_sha256: "",
signed_at: "",
verification_status: status,
verification_reason: reason
};
return { ...entry, integrity };
}
function stableHash(value: unknown) {
return sha256(stableStringify(value));
}
function stableStringify(value: unknown): string {
if (value === undefined) return "";
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.filter((key) => !EXCLUDED_KEYS.has(key))
.sort()
.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
.join(",")}}`;
}
function stripExcluded(value: unknown): unknown {
if (value === null || typeof value !== "object") return value;
if (Array.isArray(value)) return value.map(stripExcluded);
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.filter(([key]) => !EXCLUDED_KEYS.has(key))
.filter(([, child]) => child !== undefined)
.map(([key, child]) => [key, stripExcluded(child)])
);
}
function normalizeLegacyAndAliasFields(value: unknown): unknown {
if (value === null || typeof value !== "object") return value;
if (Array.isArray(value)) return value.map(normalizeLegacyAndAliasFields);
const record = { ...(value as Record<string, unknown>) };
for (const [alias, preferred] of Object.entries(ALIAS_FIELDS)) {
if (record[preferred] === undefined && record[alias] !== undefined) {
record[preferred] = record[alias];
}
delete record[alias];
}
return Object.fromEntries(Object.entries(record).map(([key, child]) => [key, normalizeLegacyAndAliasFields(child)]));
}
function stripUndefined(value: unknown): unknown {
if (value === null || typeof value !== "object") return value;
if (Array.isArray(value)) return value.map(stripUndefined);
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.filter(([, child]) => child !== undefined)
.map(([key, child]) => [key, stripUndefined(child)])
);
}
function clonePlain<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function sha256(input: string) {
const bytes = new TextEncoder().encode(input);
const words: number[] = [];
for (let index = 0; index < bytes.length; index += 1) {
words[index >> 2] = (words[index >> 2] ?? 0) | (bytes[index] << (24 - (index % 4) * 8));
}
const bitLength = bytes.length * 8;
words[bitLength >> 5] = (words[bitLength >> 5] ?? 0) | (0x80 << (24 - (bitLength % 32)));
words[(((bitLength + 64) >> 9) << 4) + 15] = bitLength;
const constants = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
];
let h0 = 0x6a09e667;
let h1 = 0xbb67ae85;
let h2 = 0x3c6ef372;
let h3 = 0xa54ff53a;
let h4 = 0x510e527f;
let h5 = 0x9b05688c;
let h6 = 0x1f83d9ab;
let h7 = 0x5be0cd19;
for (let block = 0; block < words.length; block += 16) {
const schedule = new Array<number>(64);
for (let index = 0; index < 16; index += 1) schedule[index] = words[block + index] ?? 0;
for (let index = 16; index < 64; index += 1) {
const s0 = rotateRight(schedule[index - 15], 7) ^ rotateRight(schedule[index - 15], 18) ^ (schedule[index - 15] >>> 3);
const s1 = rotateRight(schedule[index - 2], 17) ^ rotateRight(schedule[index - 2], 19) ^ (schedule[index - 2] >>> 10);
schedule[index] = add32(schedule[index - 16], s0, schedule[index - 7], s1);
}
let a = h0;
let b = h1;
let c = h2;
let d = h3;
let e = h4;
let f = h5;
let g = h6;
let h = h7;
for (let index = 0; index < 64; index += 1) {
const s1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25);
const ch = (e & f) ^ (~e & g);
const temp1 = add32(h, s1, ch, constants[index], schedule[index]);
const s0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22);
const maj = (a & b) ^ (a & c) ^ (b & c);
const temp2 = add32(s0, maj);
h = g;
g = f;
f = e;
e = add32(d, temp1);
d = c;
c = b;
b = a;
a = add32(temp1, temp2);
}
h0 = add32(h0, a);
h1 = add32(h1, b);
h2 = add32(h2, c);
h3 = add32(h3, d);
h4 = add32(h4, e);
h5 = add32(h5, f);
h6 = add32(h6, g);
h7 = add32(h7, h);
}
return [h0, h1, h2, h3, h4, h5, h6, h7].map((value) => value.toString(16).padStart(8, "0")).join("");
}
function rotateRight(value: number, amount: number) {
return (value >>> amount) | (value << (32 - amount));
}
function add32(...values: number[]) {
return values.reduce((sum, value) => (sum + value) >>> 0, 0);
}

138
src/labels.ts Normal file
View File

@ -0,0 +1,138 @@
import type {
Accommodation,
DelayedSymptom,
Limitation,
RecoveryStatus,
ReportStatus,
TaskCompletion,
TaskType,
WorseThanBefore
} from "./types";
export const statusLabels: Record<ReportStatus, string> = {
kan_fortsette: "Kan fortsette",
trenger_pause: "Trenger pause",
trenger_enklere_oppgave: "Trenger enklere oppgave",
bør_stoppe: "Ønsker å stoppe",
onsker_a_stoppe: "Ønsker å stoppe",
ønsker_å_stoppe: "Ønsker å stoppe"
};
export const taskTypeLabels: Record<TaskType, string> = {
sitting: "Sitte",
standing: "Stå",
walking: "Gå",
lifting: "Løft",
computer_work: "PC-arbeid",
concentration: "Konsentrasjon",
multitasking: "Flere oppgaver",
talking_social: "Prat/sosialt",
noise: "Støy",
bright_light: "Sterkt lys",
time_pressure: "Tidspress"
};
export const limitationLabels: Record<Limitation, string> = {
fatigue: "Utmattelse",
brain_fog: "Hjernetåke",
pain: "Smerter",
headache_migraine: "Hodepine/migrene",
dizziness: "Svimmelhet",
nausea: "Kvalme",
sensory_overload: "Sansebelastning",
stress_anxiety: "Stress/uro",
weakness: "Svakhet",
sleepiness: "Søvnighet",
other: "Annet"
};
export const accommodationLabels: Record<Accommodation, string> = {
extra_breaks: "Ekstra pauser",
sitting_down: "Sitte ned",
quiet_room: "Stille rom",
reduced_pace: "Lavere tempo",
shorter_task: "Kortere oppgave",
help_from_others: "Hjelp fra andre",
remote_work: "Jobbe hjemmefra",
task_switching: "Bytte oppgave",
nothing_helped: "Ingenting hjalp"
};
export const taskCompletionLabels: Record<TaskCompletion, string> = {
nesten_ingenting: "Nesten ingenting",
litt: "Litt",
delvis: "Delvis",
som_forventet: "Som forventet",
mer_enn_forventet: "Mer enn forventet"
};
export const worseLabels: Record<WorseThanBefore, string> = {
nei: "Nei",
litt: "Litt",
moderat: "Moderat",
mye: "Mye"
};
export const delayedSymptomLabels: Record<DelayedSymptom, string> = {
fatigue: "Utmattelse",
pain: "Smerter",
brain_fog: "Hjernetåke",
flu_like: "Influensafølelse",
headache: "Hodepine",
dizziness: "Svimmelhet",
sleep_disruption: "Søvnforstyrrelse",
sensory_sensitivity: "Lys/lydfølsomhet"
};
export const recoveryLabels: Record<RecoveryStatus, string> = {
tilbake_til_baseline: "Tilbake til vanlig nivå",
samme_dag: "Samme dag",
en_dag: "En dag",
to_tre_dager: "To-tre dager",
fire_pluss_dager: "Fire+ dager",
ikke_tilbake_til_baseline: "Ikke tilbake ennå"
};
export const positiveScale = {
1: "Svært lavt",
2: "Lavt",
3: "Middels",
4: "Godt",
5: "Svært godt"
} as const;
export const negativeScale = {
1: "Lite/ingen",
2: "Mildt",
3: "Moderat",
4: "Høyt",
5: "Svært høyt"
} as const;
export const tooltipTexts = {
work_ability:
"Hvor godt du opplever at du kan fungere i arbeidssituasjonen akkurat nå, uavhengig av om du faktisk får gjort alt.",
energy_level: "Samlet opplevd energi. Hvis du ikke skiller mellom fysisk og mental energi, brukes denne for begge.",
physical_energy: "Kroppslig energi, utholdenhet og fysisk kapasitet.",
mental_energy: "Mental utholdenhet, evne til å holde fokus over tid og tåle kognitiv belastning.",
mental_clarity: "Hvor klart du tenker, hvor lett det er å konsentrere seg, huske, forstå og følge med.",
symptom_burden:
"Hvor mye symptomer plager eller begrenser deg, for eksempel smerte, hodepine, svimmelhet, kvalme eller sanseoverbelastning.",
effort_strain:
"Hvor mye det koster å gjennomføre. Dette handler om belastning/anstrengelse, ikke bare hvor sterke symptomene er.",
symptom_vs_effort:
"Symptombelastning handler om hvor mye symptomer du har. Anstrengelse/belastning handler om hvor hardt du må presse deg for å gjennomføre.",
total_score_percent:
"En beregnet funksjonsindikator fra 0 til 100 basert på rapporterte svar. Dette er ikke en medisinsk validert testscore.",
workplace: "Arbeidssted eller kontekst for økten. Feltet forhåndsfylles fra forrige rapport når det finnes.",
work_time: "Start- og sluttid for arbeidsøkten. Feltene kan endres før rapporten sendes."
} as const;
export const categoryTooltipTexts = {
A: "Arbeidsevne",
E: "Energi",
K: "Mental klarhet/konsentrasjon",
S: "Symptombelastning",
B: "Belastning/anstrengelse",
T: "Totalscore"
} as const;

16
src/main.tsx Normal file
View File

@ -0,0 +1,16 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/service-worker.js").catch(() => undefined);
});
}

148
src/scoring.test.ts Normal file
View File

@ -0,0 +1,148 @@
import { describe, expect, it } from "vitest";
import {
calculatePeriodWorkAbilityAssessment,
calculateReportScore,
getEntryTime,
getScoreBand,
normalizeNegativeScale,
normalizePositiveScale
} from "./scoring";
import type { Report } from "./types";
const baseReport: Report = {
id: "rapport-1",
report_time: "08:00:00",
report_date: "2026-05-27",
created_at: "2026-05-27T06:00:00.000Z",
type: "work_report",
work_ability: 3,
energy_level: 3,
mental_clarity: 3,
symptom_burden: 3,
effort_strain: 3,
status: "kan_fortsette",
physical_energy: 3,
mental_energy: 3,
task_types: [],
main_limitations: [],
helpful_accommodations: []
};
describe("scoring", () => {
it("normaliserer positive og negative 1-5-skalaer til 0-100", () => {
expect(normalizePositiveScale(1)).toBe(0);
expect(normalizePositiveScale(4)).toBe(75);
expect(normalizePositiveScale(5)).toBe(100);
expect(normalizeNegativeScale(1)).toBe(100);
expect(normalizeNegativeScale(2)).toBe(75);
expect(normalizeNegativeScale(3)).toBe(50);
expect(normalizeNegativeScale(4)).toBe(25);
expect(normalizeNegativeScale(5)).toBe(0);
});
it("ignorerer manglende valgfrie felt i totalscore", () => {
const withoutOptional = calculateReportScore(baseReport);
const explicitEmpty = calculateReportScore({
...baseReport,
perceived_productivity: undefined,
task_completion: undefined,
main_limitations: [],
helpful_accommodations: []
});
expect(explicitEmpty).toBe(withoutOptional);
});
it("ignorerer fritekst og arbeidstype i totalscore", () => {
const baseline = calculateReportScore(baseReport);
const withExcludedFields = calculateReportScore({
...baseReport,
note: "Dette skal ikke påvirke score.",
task_types: ["lifting", "noise", "time_pressure"]
});
expect(withExcludedFields).toBe(baseline);
});
it("lar ikke bare Annet under begrensninger telle positivt", () => {
expect(calculateReportScore({ ...baseReport, main_limitations: ["other"] })).toBe(calculateReportScore(baseReport));
});
it("bruker energidetaljer når de finnes, og samlet energi som fallback", () => {
const fallback = calculateReportScore({ ...baseReport, energy_level: 5, physical_energy: 5, mental_energy: 5 });
const details = calculateReportScore({
...baseReport,
energy_level: 5,
physical_energy_detail: 1,
mental_energy_detail: 1,
physical_energy: 1,
mental_energy: 1
});
const oneMissing = calculateReportScore({
...baseReport,
energy_level: 5,
physical_energy_detail: 1,
physical_energy: 1,
mental_energy: 5
});
expect(details).toBeLessThan(fallback ?? 0);
expect(oneMissing).toBeGreaterThan(details ?? 0);
expect(oneMissing).toBeLessThan(fallback ?? 100);
});
it("bruker faktisk timestamp for tidsverdier", () => {
const early = getEntryTime({ ...baseReport, id: "a", created_at: "2026-05-27T08:00:00.000Z" });
const late = getEntryTime({ ...baseReport, id: "b", created_at: "2026-05-29T08:00:00.000Z" });
expect(late - early).toBe(48 * 60 * 60 * 1000);
});
it("velger riktig scoreband ved grenseverdier", () => {
expect(getScoreBand(100)?.label).toBe("Særdeles høyt funksjonsnivå");
expect(getScoreBand(90)?.label).toBe("Særdeles høyt funksjonsnivå");
expect(getScoreBand(89)?.label).toBe("Forventet funksjonsnivå");
expect(getScoreBand(75)?.label).toBe("Forventet funksjonsnivå");
expect(getScoreBand(70)?.label).toBe("Forventet funksjonsnivå");
expect(getScoreBand(69)?.label).toBe("Moderat redusert funksjon");
expect(getScoreBand(50)?.label).toBe("Moderat redusert funksjon");
expect(getScoreBand(49)?.label).toBe("Betydelig redusert funksjon");
expect(getScoreBand(30)?.label).toBe("Betydelig redusert funksjon");
expect(getScoreBand(29)?.label).toBe("Svært lav funksjon");
expect(getScoreBand(0)?.label).toBe("Svært lav funksjon");
});
it("tolker 4 av 5 og invertert 2 av 5 som forventet funksjonsnivå", () => {
expect(getScoreBand(normalizePositiveScale(4))?.label).toBe("Forventet funksjonsnivå");
expect(getScoreBand(normalizeNegativeScale(2))?.label).toBe("Forventet funksjonsnivå");
});
it("beregner periodens arbeidsevnevurdering fra rapportenes totalscore", () => {
const assessment = calculatePeriodWorkAbilityAssessment([
{ ...baseReport, id: "a", total_score_percent: 40 },
{ ...baseReport, id: "b", total_score_percent: 68 }
]);
expect(assessment.score_percent).toBe(54);
expect(assessment.score_band?.label).toBe("Moderat redusert funksjon");
expect(assessment.report_count).toBe(2);
});
it("håndterer tom periode uten score", () => {
const assessment = calculatePeriodWorkAbilityAssessment([]);
expect(assessment.score_percent).toBeNull();
expect(assessment.score_band).toBeNull();
expect(assessment.report_count).toBe(0);
});
it("bruker nye scoreband for periodens arbeidsevnevurdering", () => {
const assessment = calculatePeriodWorkAbilityAssessment([
{ ...baseReport, id: "a", total_score_percent: 75 },
{ ...baseReport, id: "b", total_score_percent: 75 }
]);
expect(assessment.score_percent).toBe(75);
expect(assessment.score_band?.label).toBe("Forventet funksjonsnivå");
});
});

183
src/scoring.ts Normal file
View File

@ -0,0 +1,183 @@
import type { Accommodation, Limitation, Report, ReportStatus, ScaleValue, StoredEntry, TaskCompletion } from "./types";
type Component = {
score: number;
weight: number;
};
const STATUS_SCORE_MAP: Partial<Record<ReportStatus, number>> = {
kan_fortsette: 100,
trenger_pause: 65,
trenger_enklere_oppgave: 50,
bør_stoppe: 20,
onsker_a_stoppe: 20,
ønsker_å_stoppe: 20
};
const TASK_COMPLETION_SCORE_MAP: Record<TaskCompletion, number> = {
nesten_ingenting: 0,
litt: 25,
delvis: 50,
som_forventet: 80,
mer_enn_forventet: 100
};
export type ScoreBand = {
min: number;
max: number;
label: string;
short_label: string;
description: string;
};
export const scoreBands: ScoreBand[] = [
{
min: 90,
max: 100,
label: "Særdeles høyt funksjonsnivå",
short_label: "Særdeles høyt",
description: "Rapporten tyder på svært god funksjon i den aktuelle arbeidssituasjonen, over det som normalt kan forventes."
},
{
min: 70,
max: 89,
label: "Forventet funksjonsnivå",
short_label: "Forventet",
description: "Rapporten ligger innenfor et forventet/godt funksjonsnivå for den aktuelle arbeidssituasjonen."
},
{
min: 50,
max: 69,
label: "Moderat redusert funksjon",
short_label: "Moderat redusert",
description: "Rapporten tyder på merkbar reduksjon i arbeidsevne, energi, symptombelastning eller bærekraft."
},
{
min: 30,
max: 49,
label: "Betydelig redusert funksjon",
short_label: "Betydelig redusert",
description: "Rapporten tyder på klart begrenset funksjon og/eller høy belastning ved gjennomføring."
},
{
min: 0,
max: 29,
label: "Svært lav funksjon",
short_label: "Svært lav",
description: "Rapporten tyder på svært lav aktuell funksjon, høy belastning eller at aktiviteten ikke fremstår bærekraftig."
}
];
export const scoreBandDisclaimer =
"Totalscoren er normalisert slik at 75 % omtrent tilsvarer et forventet/godt rapportert funksjonsnivå, mens 100 % tilsvarer særdeles høyt funksjonsnivå. Scoren er en intern funksjonsindikator for oversikt og trend, ikke en medisinsk fasit.";
export function getScoreBand(score: number | null | undefined) {
if (score === null || score === undefined || Number.isNaN(score)) return null;
const clamped = clamp(Math.round(score), 0, 100);
return scoreBands.find((band) => clamped >= band.min && clamped <= band.max) ?? null;
}
export function normalizePositiveScale(value: ScaleValue | undefined) {
if (!value) return null;
return ((value - 1) / 4) * 100;
}
export function normalizeNegativeScale(value: ScaleValue | undefined) {
if (!value) return null;
return ((5 - value) / 4) * 100;
}
function addScore(components: Component[], score: number | null | undefined, weight: number) {
if (score === null || score === undefined || Number.isNaN(score)) return;
components.push({ score: clamp(score, 0, 100), weight });
}
function energyScore(report: Partial<Report>) {
const energyLevel = report.energy_level;
const physical = report.physical_energy_detail ?? report.physical_energy;
const mental = report.mental_energy_detail ?? report.mental_energy;
if (physical || mental) {
const values = [physical ?? energyLevel, mental ?? energyLevel].filter(Boolean) as ScaleValue[];
if (values.length === 0) return null;
return values.reduce((sum, value) => sum + (normalizePositiveScale(value) ?? 0), 0) / values.length;
}
return normalizePositiveScale(energyLevel);
}
function limitationsScore(values: Limitation[] | undefined) {
const selected = values?.filter((value) => value !== "other") ?? [];
if (!values || values.length === 0) return null;
if (selected.length === 0) return null;
return Math.max(30, 100 - selected.length * 10);
}
function accommodationsScore(values: Accommodation[] | undefined) {
if (!values || values.length === 0) return null;
return values.includes("nothing_helped") ? 40 : 75;
}
export function calculateReportScore(report: Partial<Report>) {
const components: Component[] = [];
addScore(components, normalizePositiveScale(report.work_ability), 1.5);
addScore(components, energyScore(report), 1.25);
addScore(components, normalizePositiveScale(report.mental_clarity), 1);
addScore(components, normalizeNegativeScale(report.symptom_burden), 1.25);
addScore(components, normalizeNegativeScale(report.effort_strain), 1.25);
addScore(components, report.status ? STATUS_SCORE_MAP[report.status] : null, 1);
addScore(components, normalizePositiveScale(report.perceived_productivity), 0.75);
addScore(components, report.task_completion ? TASK_COMPLETION_SCORE_MAP[report.task_completion] : null, 0.75);
addScore(components, limitationsScore(report.main_limitations), 0.5);
addScore(components, accommodationsScore(report.helpful_accommodations), 0.25);
if (components.length === 0) return null;
const weighted = components.reduce((sum, component) => sum + component.score * component.weight, 0);
const weights = components.reduce((sum, component) => sum + component.weight, 0);
return clamp(Math.round(weighted / weights), 0, 100);
}
export function getReportScore(report: Report) {
return report.total_score_percent ?? calculateReportScore(report);
}
export function getEntryTime(entry: StoredEntry) {
const created = Date.parse(entry.created_at);
if (!Number.isNaN(created)) return created;
const fallback = Date.parse(`${entry.report_date}T${entry.report_time || "00:00:00"}`);
return Number.isNaN(fallback) ? 0 : fallback;
}
export function normalizeReportMetric(report: Report, metric: keyof Report | "total_score_percent") {
if (metric === "total_score_percent") return getReportScore(report);
if (metric === "symptom_burden" || metric === "effort_strain") {
return normalizeNegativeScale(report[metric] as ScaleValue | undefined);
}
return normalizePositiveScale(report[metric] as ScaleValue | undefined);
}
export function calculatePeriodWorkAbilityAssessment(reports: Report[]) {
const scores = reports.map(getReportScore).filter((score): score is number => typeof score === "number" && Number.isFinite(score));
if (scores.length === 0) {
return {
score_percent: null,
score_band: null,
report_count: 0,
method: "arithmetic_mean_of_report_total_scores" as const
};
}
const score_percent = Math.round(scores.reduce((sum, score) => sum + score, 0) / scores.length);
return {
score_percent,
score_band: getScoreBand(score_percent),
report_count: scores.length,
method: "arithmetic_mean_of_report_total_scores" as const
};
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}

71
src/storage.test.ts Normal file
View File

@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { importReportsFromJson, listReports, previewImportReports } from "./storage";
import type { ExportPayload, Report } from "./types";
const store = new Map<string, string>();
beforeEach(() => {
store.clear();
vi.stubGlobal("localStorage", {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, value),
removeItem: (key: string) => store.delete(key),
clear: () => store.clear()
});
});
const report: Report = {
id: "rapport-1",
report_time: "08:00:00",
report_date: "2026-05-27",
created_at: "2026-05-27T06:00:00.000Z",
type: "work_report",
work_ability: 4,
energy_level: 3,
mental_clarity: 4,
symptom_burden: 2,
effort_strain: 3,
status: "kan_fortsette",
physical_energy: 3,
mental_energy: 3,
task_types: [],
main_limitations: [],
helpful_accommodations: []
};
function payload(reports: Report[]): ExportPayload {
return {
schema_version: 2,
export_version: 2,
exported_at: "2026-05-27T10:00:00.000Z",
reports
};
}
describe("ordinær brukerimport", () => {
it("forhåndsviser import uten å lagre data", () => {
const summary = previewImportReports(JSON.stringify(payload([report])));
expect(summary.reports_added).toBe(1);
expect(listReports()).toHaveLength(0);
});
it("importerer nye rapporter og hopper over duplikater", () => {
const first = importReportsFromJson(JSON.stringify(payload([report])));
const second = importReportsFromJson(JSON.stringify(payload([report])));
expect(first.reports_added).toBe(1);
expect(second.reports_added).toBe(0);
expect(second.duplicates_skipped).toBe(1);
expect(listReports()).toHaveLength(1);
});
it("oppdager konflikt når samme ID har annet innhold", () => {
importReportsFromJson(JSON.stringify(payload([report])));
const changed = { ...report, energy_level: 1 as const };
const summary = previewImportReports(JSON.stringify(payload([changed])));
expect(summary.conflicts_detected).toBe(1);
expect(summary.reports_added).toBe(0);
});
});

250
src/storage.ts Normal file
View File

@ -0,0 +1,250 @@
import { calculateReportScore, getEntryTime } from "./scoring";
import { attachIntegrity, createExportIntegrity, summarizeVerification, verifyEntryIntegrity } from "./integrity";
import type { DelayedFollowup, ExportPayload, FollowupDraft, ImportSummary, Report, ReportDraft, StoredEntry } from "./types";
const STORAGE_KEY = "arbeidsevne-egenvurdering:entries";
function readEntries(): StoredEntry[] {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
const entries = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.reports) ? parsed.reports : [];
return entries.map(normalizeStoredEntry);
} catch {
return [];
}
}
function writeEntries(entries: StoredEntry[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
}
function dateParts(now = new Date()) {
const localDate = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, "0"),
String(now.getDate()).padStart(2, "0")
].join("-");
return {
report_date: localDate,
report_time: [
String(now.getHours()).padStart(2, "0"),
String(now.getMinutes()).padStart(2, "0"),
String(now.getSeconds()).padStart(2, "0")
].join(":")
};
}
function baseFields() {
const now = new Date();
return {
id: crypto.randomUUID(),
created_at: now.toISOString(),
...dateParts(now)
};
}
export function createReport(draft: ReportDraft): Report {
if (
!draft.work_ability ||
!draft.energy_level ||
!draft.mental_clarity ||
!draft.symptom_burden ||
!draft.effort_strain ||
!draft.status
) {
throw new Error("Rapporten mangler påkrevde felt.");
}
const report: Report = {
...baseFields(),
type: "work_report",
workplace: draft.workplace?.trim() || undefined,
work_start_time: draft.work_start_time || undefined,
work_end_time: draft.work_end_time || undefined,
work_ability: draft.work_ability,
energy_level: draft.energy_level,
mental_clarity: draft.mental_clarity,
symptom_burden: draft.symptom_burden,
effort_strain: draft.effort_strain,
status: draft.status,
physical_energy: draft.physical_energy_detail ?? draft.energy_level,
mental_energy: draft.mental_energy_detail ?? draft.energy_level,
perceived_productivity: draft.perceived_productivity,
task_completion: draft.task_completion,
task_types: draft.task_types,
main_limitations: draft.main_limitations,
helpful_accommodations: draft.helpful_accommodations,
note: draft.note.trim() || undefined
};
report.total_score_percent = calculateReportScore(report) ?? undefined;
const signedReport = attachIntegrity(report);
writeEntries([signedReport, ...readEntries()]);
return signedReport;
}
export function createFollowup(draft: FollowupDraft): DelayedFollowup {
if (!draft.worse_than_before) {
throw new Error("Oppfølgingen mangler svar på om du er verre enn før økten.");
}
const followup: DelayedFollowup = {
...baseFields(),
type: "delayed_followup",
parent_report_id: draft.parent_report_id || draft.related_report_id || undefined,
related_report_id: draft.related_report_id || draft.parent_report_id || undefined,
worse_than_before: draft.worse_than_before,
delayed_symptoms: draft.delayed_symptoms,
recovery_status: draft.recovery_status,
note: draft.note.trim() || undefined
};
const signedFollowup = attachIntegrity(followup);
writeEntries([signedFollowup, ...readEntries()]);
return signedFollowup;
}
export function listReports(): StoredEntry[] {
return readEntries().sort((a, b) => getEntryTime(b) - getEntryTime(a));
}
export function getLastWorkReport(): Report | undefined {
return listReports().find((entry): entry is Report => entry.type === "work_report");
}
export function getReport(id: string): StoredEntry | undefined {
return readEntries().find((entry) => entry.id === id);
}
export function deleteReport(id: string) {
writeEntries(readEntries().filter((entry) => entry.id !== id));
}
export function deleteAllReports() {
writeEntries([]);
}
export function exportReports() {
const reports = listReports();
const payload: ExportPayload = {
schema_version: 3,
export_version: 3,
exported_at: new Date().toISOString(),
export_integrity: createExportIntegrity(reports),
reports
};
return JSON.stringify(payload, null, 2);
}
export function previewImportReports(jsonText: string) {
const imported = parseImportEntries(jsonText).map(normalizeStoredEntry).map(verifyEntryIntegrity);
return { ...summarizeImport(imported, readEntries()), verification: summarizeVerification(imported) };
}
export function importReportsFromJson(jsonText: string): ImportSummary {
const imported = parseImportEntries(jsonText).map(normalizeStoredEntry).map(verifyEntryIntegrity);
const existing = readEntries();
const summary = { ...summarizeImport(imported, existing), verification: summarizeVerification(imported) };
const existingSignatures = new Set(existing.map(entrySignature));
const existingById = new Map(existing.map((entry) => [entry.id, entry]));
const additions: StoredEntry[] = [];
for (const entry of imported) {
const existingWithId = existingById.get(entry.id);
if (existingWithId && entryContentSignature(existingWithId) !== entryContentSignature(entry)) continue;
if (existingSignatures.has(entrySignature(entry))) continue;
existingSignatures.add(entrySignature(entry));
additions.push(entry);
}
writeEntries([...additions, ...existing].sort((a, b) => getEntryTime(b) - getEntryTime(a)));
return summary;
}
function parseImportEntries(jsonText: string): StoredEntry[] {
const parsed = JSON.parse(jsonText);
const entries = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.reports) ? parsed.reports : null;
if (!entries) throw new Error("Filen inneholder ikke en gjenkjennbar rapporteksport.");
return entries;
}
function summarizeImport(imported: StoredEntry[], existing: StoredEntry[]): ImportSummary {
const existingSignatures = new Set(existing.map(entrySignature));
const existingById = new Map(existing.map((entry) => [entry.id, entry]));
const seenImported = new Set<string>();
const summary: ImportSummary = {
reports_added: 0,
followups_added: 0,
duplicates_skipped: 0,
conflicts_detected: 0
};
for (const entry of imported) {
const signature = entrySignature(entry);
const existingWithId = existingById.get(entry.id);
if (existingWithId && entryContentSignature(existingWithId) !== entryContentSignature(entry)) {
summary.conflicts_detected += 1;
continue;
}
if (existingSignatures.has(signature) || seenImported.has(signature)) {
summary.duplicates_skipped += 1;
continue;
}
seenImported.add(signature);
if (entry.type === "work_report") summary.reports_added += 1;
if (entry.type === "delayed_followup") summary.followups_added += 1;
}
return summary;
}
function entrySignature(entry: Partial<StoredEntry>) {
if (entry.id) return `id:${entry.id}`;
return entryContentSignature(entry);
}
function entryContentSignature(entry: Partial<StoredEntry>) {
if (entry.type === "work_report") {
const report = entry as Partial<Report>;
return [
"report",
report.created_at,
report.report_date,
report.report_time,
report.work_ability,
report.energy_level,
report.status
].join("|");
}
const followup = entry as Partial<DelayedFollowup>;
return ["followup", followup.created_at, followup.report_date, followup.report_time, followup.worse_than_before].join("|");
}
function normalizeStoredEntry(entry: StoredEntry): StoredEntry {
if (entry.type === "work_report") {
const { physical_energy_detail, mental_energy_detail, ...rest } = entry;
const report: Report = {
...rest,
task_types: rest.task_types ?? [],
main_limitations: rest.main_limitations ?? [],
helpful_accommodations: rest.helpful_accommodations ?? [],
physical_energy: rest.physical_energy ?? physical_energy_detail ?? rest.energy_level,
mental_energy: rest.mental_energy ?? mental_energy_detail ?? rest.energy_level
};
return {
...report,
total_score_percent: report.total_score_percent ?? calculateReportScore(report) ?? undefined
};
}
return {
...entry,
parent_report_id: entry.parent_report_id ?? entry.related_report_id,
related_report_id: entry.related_report_id ?? entry.parent_report_id,
delayed_symptoms: entry.delayed_symptoms ?? []
};
}

1726
src/styles.css Normal file

File diff suppressed because it is too large Load Diff

215
src/types.ts Normal file
View File

@ -0,0 +1,215 @@
export type ScaleValue = 1 | 2 | 3 | 4 | 5;
export type ReportStatus =
| "kan_fortsette"
| "trenger_pause"
| "trenger_enklere_oppgave"
| "bør_stoppe"
| "onsker_a_stoppe"
| "ønsker_å_stoppe";
export type TaskType =
| "sitting"
| "standing"
| "walking"
| "lifting"
| "computer_work"
| "concentration"
| "multitasking"
| "talking_social"
| "noise"
| "bright_light"
| "time_pressure";
export type Limitation =
| "fatigue"
| "brain_fog"
| "pain"
| "headache_migraine"
| "dizziness"
| "nausea"
| "sensory_overload"
| "stress_anxiety"
| "weakness"
| "sleepiness"
| "other";
export type Accommodation =
| "extra_breaks"
| "sitting_down"
| "quiet_room"
| "reduced_pace"
| "shorter_task"
| "help_from_others"
| "remote_work"
| "task_switching"
| "nothing_helped";
export type TaskCompletion =
| "nesten_ingenting"
| "litt"
| "delvis"
| "som_forventet"
| "mer_enn_forventet";
export type WorseThanBefore = "nei" | "litt" | "moderat" | "mye";
export type DelayedSymptom =
| "fatigue"
| "pain"
| "brain_fog"
| "flu_like"
| "headache"
| "dizziness"
| "sleep_disruption"
| "sensory_sensitivity";
export type RecoveryStatus =
| "tilbake_til_baseline"
| "samme_dag"
| "en_dag"
| "to_tre_dager"
| "fire_pluss_dager"
| "ikke_tilbake_til_baseline";
export interface Report {
id: string;
report_time: string;
report_date: string;
created_at: string;
type: "work_report";
workplace?: string;
work_start_time?: string;
work_end_time?: string;
work_ability: ScaleValue;
energy_level: ScaleValue;
mental_clarity: ScaleValue;
symptom_burden: ScaleValue;
effort_strain: ScaleValue;
status: ReportStatus;
physical_energy: ScaleValue;
mental_energy: ScaleValue;
physical_energy_detail?: ScaleValue;
mental_energy_detail?: ScaleValue;
perceived_productivity?: ScaleValue;
task_completion?: TaskCompletion;
task_types: TaskType[];
main_limitations: Limitation[];
helpful_accommodations: Accommodation[];
total_score_percent?: number;
integrity?: ReportIntegrity;
note?: string;
}
export interface ReportDraft {
workplace?: string;
work_start_time?: string;
work_end_time?: string;
work_ability?: ScaleValue;
energy_level?: ScaleValue;
mental_clarity?: ScaleValue;
symptom_burden?: ScaleValue;
effort_strain?: ScaleValue;
status?: ReportStatus;
physical_energy_detail?: ScaleValue;
mental_energy_detail?: ScaleValue;
perceived_productivity?: ScaleValue;
task_completion?: TaskCompletion;
task_types: TaskType[];
main_limitations: Limitation[];
helpful_accommodations: Accommodation[];
note: string;
}
export interface DelayedFollowup {
id: string;
report_time: string;
report_date: string;
created_at: string;
type: "delayed_followup";
parent_report_id?: string;
related_report_id?: string;
worse_than_before: WorseThanBefore;
delayed_symptoms: DelayedSymptom[];
recovery_status?: RecoveryStatus;
integrity?: ReportIntegrity;
note?: string;
}
export interface FollowupDraft {
parent_report_id?: string;
related_report_id?: string;
worse_than_before?: WorseThanBefore;
delayed_symptoms: DelayedSymptom[];
recovery_status?: RecoveryStatus;
note: string;
}
export type StoredEntry = Report | DelayedFollowup;
export interface EvaluatorNote {
id: string;
created_at: string;
updated_at: string;
text: string;
note_type?: "person" | "report" | "followup";
related_report_id?: string;
related_followup_id?: string;
parent_report_id?: string;
}
export interface EvaluatorProfile {
id: string;
display_name: string;
created_at: string;
updated_at: string;
source_fingerprint?: string;
reports: StoredEntry[];
evaluator_notes: EvaluatorNote[];
}
export interface ExportPayload {
schema_version: number;
export_version: number;
exported_at: string;
export_integrity?: ExportIntegrity;
reports: StoredEntry[];
}
export interface ImportSummary {
reports_added: number;
followups_added: number;
duplicates_skipped: number;
conflicts_detected: number;
verification?: VerificationSummary;
}
export type ImportTargetResolution = "created_new_person" | "matched_existing_person" | "conflict_requires_manual_resolution";
export type VerificationStatus = "valid" | "modified" | "missing" | "unsupported" | "error";
export interface ReportIntegrity {
report_id: string;
canonicalization: "stable-json-v1" | "stable-json-v2";
algorithm: "sha256" | "local-fnv1a-64" | "SHA-256";
payload_hash_sha256: string;
signed_at: string;
verification_status?: VerificationStatus;
verification_reason?: string;
}
export interface ExportIntegrity {
schema: "arbeidsevne-export-integrity-v1";
created_at: string;
algorithm: string;
manifest_hash_sha256: string;
note: string;
}
export interface VerificationSummary {
valid: number;
modified: number;
missing: number;
unsupported: number;
error: number;
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

21
tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": []
}

6
vite.config.ts Normal file
View File

@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()]
});