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