Initial commit
This commit is contained in:
commit
6bcb87febe
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
17
index.html
Normal file
17
index.html
Normal 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
2208
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal 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
1
public/_redirects
Normal file
@ -0,0 +1 @@
|
|||||||
|
/* /index.html 200
|
||||||
6
public/icon.svg
Normal file
6
public/icon.svg
Normal 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 |
20
public/manifest.webmanifest
Normal file
20
public/manifest.webmanifest
Normal 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
33
public/service-worker.js
Normal 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
2422
src/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
114
src/evaluatorStorage.test.ts
Normal file
114
src/evaluatorStorage.test.ts
Normal 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
318
src/evaluatorStorage.ts
Normal 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
21
src/graph.test.ts
Normal 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
195
src/integrity.test.ts
Normal 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
289
src/integrity.ts
Normal 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
138
src/labels.ts
Normal 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
16
src/main.tsx
Normal 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
148
src/scoring.test.ts
Normal 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
183
src/scoring.ts
Normal 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
71
src/storage.test.ts
Normal 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
250
src/storage.ts
Normal 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
1726
src/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
215
src/types.ts
Normal file
215
src/types.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal 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
6
vite.config.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()]
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user