Arbeidspuls/src/App.tsx
2026-05-29 17:50:22 +02:00

3389 lines
131 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import type React from "react";
import {
Activity,
BarChart3,
Download,
FileClock,
FileJson,
Github,
HelpCircle,
Heart,
Search,
Plus,
RotateCcw,
Save,
Send,
ShieldAlert,
Trash2,
Upload,
X,
UserRound
} from "lucide-react";
import {
accommodationLabels,
categoryTooltipTexts,
delayedSymptomLabels,
limitationLabels,
negativeScale,
positiveScale,
recoveryLabels,
statusLabels,
taskCompletionLabels,
taskTypeLabels,
tooltipTexts,
worseLabels
} from "./labels";
import {
deleteEvaluatorNote,
deleteEvaluatorProfile,
ensureDemoEvaluatorProfiles,
exportEvaluatorBackup,
importEvaluatorBackup,
importExportPayload,
listEvaluatorProfiles,
renameEvaluatorProfile,
upsertEvaluatorNote
} from "./evaluatorStorage";
import {
calculatePeriodWorkAbilityAssessment,
calculateReportScore,
getEntryTime,
getReportScore,
getScoreBand,
normalizeReportMetric,
scoreBandDisclaimer
} from "./scoring";
import { verificationLabel, verificationTooltip } from "./integrity";
import {
I18nProvider,
translateScoreBand,
translateScoreBandDescription,
translateVerificationStatus,
useI18n
} from "./i18n";
import {
confirmSecureShareImport,
createSecureShare,
decryptSecureSharePayload,
fetchSecureShare,
parseShareRequestFromLocation
} from "./secureShare";
import {
createFollowup,
createReport,
deleteAllReports,
deleteReport,
assertValidExportRange,
buildExportFilename,
ensureDemoData,
exportReports,
getLatestWorkReport,
getLastWorkReport,
importReportsFromJson,
isEditableWithin24Hours,
isDemoModeEnabled,
listReports,
previewImportReports,
setDemoModeEnabled,
updateFollowup,
updateReport
} from "./storage";
import type { ExportRange } from "./storage";
import type {
Accommodation,
DelayedFollowup,
DelayedSymptom,
EvaluatorProfile,
FollowupDraft,
ImportSummary,
Limitation,
RecoveryStatus,
Report,
ReportDraft,
ReportStatus,
ScaleValue,
StoredEntry,
TaskCompletion,
TaskType,
WorseThanBefore
} from "./types";
type View = "registrer" | "oppfolging" | "oversikt" | "personvern";
type PresetRange = "7d" | "1m" | "3m" | "6m" | "1y" | "3y" | "5y" | "all" | "custom";
type LineVisibility = "normal" | "dim" | "hidden";
type TrendMetric = { key: keyof Report | "total_score_percent"; label: string; short: string; color: string };
const PERIOD_KEY = "arbeidsevne-egenvurdering:trend-period";
const APP_VERSION = "1.2";
const taskTypeOptions = Object.keys(taskTypeLabels) as TaskType[];
const limitationOptions = Object.keys(limitationLabels) as Limitation[];
const accommodationOptions = Object.keys(accommodationLabels) as Accommodation[];
const delayedSymptomOptions = Object.keys(delayedSymptomLabels) as DelayedSymptom[];
const visibleStatusOptions: ReportStatus[] = ["kan_fortsette", "trenger_pause", "trenger_enklere_oppgave", "bør_stoppe"];
const trendMetrics: TrendMetric[] = [
{ key: "total_score_percent", label: "Totalscore", short: "T", color: "#235b5e" },
{ key: "work_ability", label: "Arbeidsevne", short: "A", color: "#307f78" },
{ key: "energy_level", label: "Energi", short: "E", color: "#b2642a" },
{ key: "mental_clarity", label: "Klarhet", short: "K", color: "#4f5c9c" },
{ key: "symptom_burden", label: "Symptomer", short: "S", color: "#b23b4a" },
{ key: "effort_strain", label: "Belastning", short: "B", color: "#6b5a3a" }
];
function App() {
return (
<I18nProvider>
<AppRoutes />
</I18nProvider>
);
}
function AppRoutes() {
const { t } = useI18n();
if (window.location.pathname.startsWith("/s")) {
return <SecureSharePage />;
}
if (window.location.pathname.startsWith("/evaluator") || window.location.pathname.startsWith("/veileder")) {
return <EvaluatorApp />;
}
const [demoMode, setDemoMode] = useState(() => isDemoModeEnabled());
const [view, setView] = useState<View>("registrer");
const [entries, setEntries] = useState<StoredEntry[]>(() => listReports(isDemoModeEnabled()));
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
const [deleteRequest, setDeleteRequest] = useState<{ id: string; name: string } | null>(null);
const [editEntry, setEditEntry] = useState<StoredEntry | null>(null);
const refresh = () => {
const next = listReports(demoMode);
setEntries(next);
};
const selectedEntry = selectedEntryId ? entries.find((entry) => entry.id === selectedEntryId) : undefined;
const changeDemoMode = (enabled: boolean) => {
setDemoModeEnabled(enabled);
if (enabled) ensureDemoData();
setDemoMode(enabled);
setSelectedEntryId(null);
setDeleteRequest(null);
setEditEntry(null);
setEntries(listReports(enabled));
};
useEffect(() => {
setEntries(listReports(demoMode));
}, [demoMode]);
return (
<div className={demoMode ? "app-shell demo-active" : "app-shell"}>
<header className="topbar">
<div>
<p className="eyebrow">{t("app.eyebrow")}</p>
<h1>{t("app.title")}</h1>
</div>
<a className="text-link" href="/veileder">
{t("app.evaluator")}
</a>
</header>
<nav className="main-nav" aria-label={t("app.navLabel")}>
<NavButton icon={<Plus />} label={t("nav.register")} active={view === "registrer"} onClick={() => setView("registrer")} />
<NavButton
icon={<FileClock />}
label={t("nav.followup")}
active={view === "oppfolging"}
onClick={() => setView("oppfolging")}
/>
<NavButton icon={<BarChart3 />} label={t("nav.overview")} active={view === "oversikt"} onClick={() => setView("oversikt")} />
<NavButton
icon={<ShieldAlert />}
label={t("nav.privacy")}
active={view === "personvern"}
onClick={() => setView("personvern")}
/>
</nav>
<main>
{demoMode && (
<div className="notice warning">
<ShieldAlert aria-hidden="true" />
<span>{t("demo.notice")}</span>
</div>
)}
{view === "registrer" && (
<ReportForm
demoMode={demoMode}
editReport={editEntry?.type === "work_report" ? editEntry : null}
onCancelEdit={() => setEditEntry(null)}
onSaved={() => {
setEditEntry(null);
refresh();
}}
/>
)}
{view === "oppfolging" && (
<FollowupForm
demoMode={demoMode}
editFollowup={editEntry?.type === "delayed_followup" ? editEntry : null}
reports={entries}
onCancelEdit={() => setEditEntry(null)}
onSaved={() => {
setEditEntry(null);
refresh();
}}
/>
)}
{view === "oversikt" && (
<Overview
entries={entries}
selectedEntry={selectedEntry}
onSelect={setSelectedEntryId}
onClose={() => setSelectedEntryId(null)}
onRequestEdit={(entry) => {
setEditEntry(entry);
setView(entry.type === "work_report" ? "registrer" : "oppfolging");
}}
onRequestDelete={(entry) => setDeleteRequest({ id: entry.id, name: entry.type === "work_report" ? entry.workplace || t("danger.reportName") : t("danger.followupName") })}
/>
)}
{view === "personvern" && <PrivacyPanel demoMode={demoMode} entries={entries} onDeleted={refresh} />}
</main>
<AppFooter demoMode={demoMode} onDemoModeChange={changeDemoMode} />
<ConfirmDeleteModal
itemName={deleteRequest?.name ?? ""}
open={Boolean(deleteRequest)}
onCancel={() => setDeleteRequest(null)}
onConfirm={() => {
if (!deleteRequest) return;
deleteReport(deleteRequest.id, demoMode);
if (selectedEntryId === deleteRequest.id) setSelectedEntryId(null);
setDeleteRequest(null);
refresh();
}}
/>
</div>
);
}
function NavButton({
icon,
label,
active,
onClick
}: {
icon: React.ReactNode;
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button className={active ? "nav-button active" : "nav-button"} onClick={onClick} type="button">
{icon}
<span>{label}</span>
</button>
);
}
function AppFooter({
demoMode,
onDemoModeChange,
showDemoToggle = true
}: {
demoMode: boolean;
onDemoModeChange: (enabled: boolean) => void;
showDemoToggle?: boolean;
}) {
const { language, setLanguage, t } = useI18n();
const baseYear = 2026;
const currentYear = new Date().getFullYear();
const yearLabel = currentYear > baseYear ? `${baseYear}-${currentYear}` : `${baseYear}`;
return (
<footer className="app-footer">
<div className="footer-language-switcher" aria-label={t("footer.languageChoice")}>
<button
aria-current={language === "nb-NO"}
aria-label={t("footer.switchToNorwegian")}
className="footer-language-button"
onClick={() => setLanguage("nb-NO")}
type="button"
>
{t("footer.norwegian")} 🇳🇴
</button>
<span className="footer-language-separator">|</span>
<button
aria-current={language === "en"}
aria-label={t("footer.switchToEnglish")}
className="footer-language-button"
onClick={() => setLanguage("en")}
type="button"
>
{t("footer.english")} 🇬🇧
</button>
</div>
<div className="footer-credit">
© {yearLabel} {t("footer.developedBy")} <Heart aria-label={t("footer.heart")} className="footer-heart" />
</div>
<div className="footer-links">
<a className="footer-link" href="https://git.rolfsvaag.no/frarol96/Arbeidspuls" rel="noreferrer" target="_blank">
<Github aria-hidden="true" />
<span>{t("footer.sourceCode", { version: APP_VERSION })}</span>
</a>
{showDemoToggle && (
<button className={demoMode ? "footer-link active" : "footer-link"} onClick={() => onDemoModeChange(!demoMode)} type="button">
{demoMode ? t("demo.exit") : t("demo.enter")}
</button>
)}
</div>
</footer>
);
}
function ConfirmDeleteModal({
open,
itemName,
onCancel,
onConfirm
}: {
open: boolean;
itemName: string;
onCancel: () => void;
onConfirm: () => void;
}) {
const { t } = useI18n();
const [secondsLeft, setSecondsLeft] = useState(1);
useEffect(() => {
if (!open) return;
setSecondsLeft(1);
const timer = window.setTimeout(() => setSecondsLeft(0), 1000);
return () => window.clearTimeout(timer);
}, [open, itemName]);
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onCancel();
if (event.key === "Enter" && secondsLeft > 0) event.preventDefault();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onCancel, open, secondsLeft]);
if (!open) return null;
return (
<div className="modal-overlay" role="presentation" onMouseDown={onCancel}>
<section className="confirm-modal" role="dialog" aria-modal="true" aria-labelledby="confirm-title" onMouseDown={(event) => event.stopPropagation()}>
<h2 id="confirm-title">{t("deleteModal.title")}</h2>
<p>{t("deleteModal.body", { itemName })}</p>
<div className="action-grid">
<button className="secondary-action" onClick={onCancel} type="button">
{t("common.cancel")}
</button>
<button className="secondary-action danger" disabled={secondsLeft > 0} onClick={onConfirm} type="button">
{secondsLeft > 0 ? t("deleteModal.waiting", { secondsLeft }) : t("deleteModal.ready")}
</button>
</div>
</section>
</div>
);
}
function createInitialReportDraft(demoMode = false): ReportDraft {
const last = getLastWorkReport(demoMode);
return {
workplace: last?.workplace ?? "",
work_start_time: last?.work_start_time ?? "",
work_end_time: last?.work_end_time ?? "",
task_types: [],
main_limitations: [],
helpful_accommodations: [],
note: ""
};
}
function createReportDraftFromReport(report: Report): ReportDraft {
return {
workplace: report.workplace ?? "",
work_start_time: report.work_start_time ?? "",
work_end_time: report.work_end_time ?? "",
work_ability: report.work_ability,
energy_level: report.energy_level,
mental_clarity: report.mental_clarity,
symptom_burden: report.symptom_burden,
effort_strain: report.effort_strain,
status: report.status,
physical_energy_detail: report.physical_energy,
mental_energy_detail: report.mental_energy,
perceived_productivity: report.perceived_productivity,
task_completion: report.task_completion,
task_types: report.task_types,
main_limitations: report.main_limitations,
helpful_accommodations: report.helpful_accommodations,
note: report.note ?? ""
};
}
function ReportForm({
demoMode,
editReport,
onCancelEdit,
onSaved
}: {
demoMode: boolean;
editReport?: Report | null;
onCancelEdit?: () => void;
onSaved: () => void;
}) {
const { labels, t } = useI18n();
const editing = Boolean(editReport);
const editAllowed = !editReport || isEditableWithin24Hours(editReport);
const [draft, setDraft] = useState<ReportDraft>(() => editReport ? createReportDraftFromReport(editReport) : createInitialReportDraft(demoMode));
const [attempted, setAttempted] = useState(false);
const [savedText, setSavedText] = useState("");
const reportFields = requiredReportFields.map((field) => ({ ...field, label: reportFieldLabel(field.key, t) }));
const missing = reportFields.filter((field) => !draft[field.key]);
const canSubmit = missing.length === 0;
useEffect(() => {
setDraft(editReport ? createReportDraftFromReport(editReport) : createInitialReportDraft(demoMode));
setAttempted(false);
setSavedText("");
}, [demoMode, editReport?.id]);
const setScale = (key: keyof ReportDraft, value: ScaleValue) => {
setDraft((current) => ({ ...current, [key]: value }));
};
const toggle = <T extends string>(key: keyof ReportDraft, value: T) => {
setDraft((current) => {
const values = current[key] as T[];
return {
...current,
[key]: values.includes(value) ? values.filter((item) => item !== value) : [...values, value]
};
});
};
const submit = () => {
setAttempted(true);
if (!canSubmit || !editAllowed) return;
if (editReport) updateReport(editReport.id, draft, demoMode);
else createReport(draft, demoMode);
setDraft(createInitialReportDraft(demoMode));
setAttempted(false);
setSavedText(editReport ? t("edit.saved") : t("register.saved"));
onSaved();
window.setTimeout(() => setSavedText(""), 2400);
};
return (
<section className="flow">
<div className="notice">
<Activity aria-hidden="true" />
<span>{editing ? t("edit.reportIntro") : t("register.intro")}</span>
</div>
{editing && !editAllowed && <p className="error-text">{t("edit.expired")}</p>}
<section className="field-card compact-session">
<div className="field-head inline-head">
<h2>{t("register.workSession")}</h2>
<Tooltip text={`${labels.tooltipTexts.workplace} ${labels.tooltipTexts.work_time}`} />
</div>
<div className="session-grid">
<label className="text-field">
<span>{t("register.workplace")}</span>
<input
value={draft.workplace ?? ""}
onChange={(event) => setDraft((current) => ({ ...current, workplace: event.target.value }))}
placeholder={t("register.optional")}
/>
</label>
<label className="text-field">
<span>{t("register.startTime")}</span>
<input
type="time"
value={draft.work_start_time ?? ""}
onChange={(event) => setDraft((current) => ({ ...current, work_start_time: event.target.value }))}
/>
</label>
<label className="text-field">
<span>{t("register.endTime")}</span>
<input
type="time"
value={draft.work_end_time ?? ""}
onChange={(event) => setDraft((current) => ({ ...current, work_end_time: event.target.value }))}
/>
</label>
</div>
</section>
<div className="form-stack">
{reportFields.map((field) => (
<ScaleField
key={field.key}
label={field.label}
tooltip={labels.tooltipTexts[field.key]}
value={draft[field.key] as ScaleValue | undefined}
guidance={field.negative ? labels.negativeScale : labels.positiveScale}
invalid={attempted && !draft[field.key]}
onChange={(value) => setScale(field.key, value)}
/>
))}
<section className={attempted && !draft.status ? "field-card invalid" : "field-card"}>
<div className="field-head">
<h2>{t("register.continueQuestion")}</h2>
<p>{t("register.chooseBestNow")}</p>
</div>
<div className="status-grid">
{visibleStatusOptions.map((status) => (
<button
className={draft.status === status ? "status-button selected" : "status-button"}
key={status}
onClick={() => setDraft((current) => ({ ...current, status }))}
type="button"
>
{labels.statusLabels[status]}
</button>
))}
</div>
</section>
<details className="details-panel">
<summary>{t("register.optionalDetails")}</summary>
<div className="details-content">
<ScaleField
label={t("register.physicalEnergy")}
tooltip={labels.tooltipTexts.physical_energy}
value={draft.physical_energy_detail}
guidance={labels.positiveScale}
compact
onChange={(value) => setScale("physical_energy_detail", value)}
/>
<ScaleField
label={t("register.mentalEnergy")}
tooltip={labels.tooltipTexts.mental_energy}
value={draft.mental_energy_detail}
guidance={labels.positiveScale}
compact
onChange={(value) => setScale("mental_energy_detail", value)}
/>
<ScaleField
label={t("register.productivity")}
value={draft.perceived_productivity}
guidance={labels.positiveScale}
compact
onChange={(value) => setScale("perceived_productivity", value)}
/>
<SelectField
label={t("register.taskCompletion")}
value={draft.task_completion}
options={labels.taskCompletionLabels}
onChange={(value) => setDraft((current) => ({ ...current, task_completion: value }))}
/>
<ChipGroup
label={t("register.taskTypes")}
options={taskTypeOptions}
labels={labels.taskTypeLabels}
values={draft.task_types}
onToggle={(value) => toggle("task_types", value)}
/>
<ChipGroup
label={t("register.limitations")}
options={limitationOptions}
labels={labels.limitationLabels}
values={draft.main_limitations}
onToggle={(value) => toggle("main_limitations", value)}
/>
<ChipGroup
label={t("register.accommodations")}
options={accommodationOptions}
labels={labels.accommodationLabels}
values={draft.helpful_accommodations}
onToggle={(value) => toggle("helpful_accommodations", value)}
/>
<label className="text-field">
<span>{t("register.note")}</span>
<textarea
maxLength={1000}
value={draft.note}
onChange={(event) => setDraft((current) => ({ ...current, note: event.target.value }))}
rows={4}
/>
</label>
</div>
</details>
</div>
<div className="sticky-actions">
{attempted && !canSubmit && <p className="error-text">{t("register.missing", { fields: missing.map((field) => field.label).join(", ") })}</p>}
{savedText && <p className="success-text">{savedText}</p>}
<button className="primary-action" disabled={!editAllowed} onClick={submit} type="button">
<Save aria-hidden="true" />
<span>{editing ? t("edit.saveChanges") : t("register.saveReport")}</span>
</button>
{editing && onCancelEdit && (
<button className="secondary-action" onClick={onCancelEdit} type="button">
{t("edit.cancelEdit")}
</button>
)}
</div>
</section>
);
}
const requiredReportFields: Array<{
key: "work_ability" | "energy_level" | "mental_clarity" | "symptom_burden" | "effort_strain";
label: string;
negative?: boolean;
}> = [
{ key: "work_ability", label: "Arbeidsevne akkurat nå" },
{ key: "energy_level", label: "Energinivå" },
{ key: "mental_clarity", label: "Mental klarhet / konsentrasjon" },
{ key: "symptom_burden", label: "Symptombelastning", negative: true },
{ key: "effort_strain", label: "Anstrengelse / belastning", negative: true }
];
function reportFieldLabel(key: (typeof requiredReportFields)[number]["key"], t: ReturnType<typeof useI18n>["t"]) {
const map: Record<typeof key, string> = {
work_ability: t("register.workAbility"),
energy_level: t("register.energyLevel"),
mental_clarity: t("register.mentalClarity"),
symptom_burden: t("register.symptomBurden"),
effort_strain: t("register.effortStrain")
};
return map[key];
}
function ScaleField({
label,
value,
guidance,
tooltip,
invalid,
compact,
onChange
}: {
label: string;
value?: ScaleValue;
guidance: Record<ScaleValue, string>;
tooltip?: string;
invalid?: boolean;
compact?: boolean;
onChange: (value: ScaleValue) => void;
}) {
const { t } = useI18n();
return (
<section className={`${compact ? "field-card compact" : "field-card"} ${invalid ? "invalid" : ""}`}>
<div className="field-head">
<div className="inline-head">
<h2>{label}</h2>
{tooltip && <Tooltip text={tooltip} />}
</div>
<p>
{t("register.oneEqualsFiveEquals", { low: guidance[1].toLowerCase(), high: guidance[5].toLowerCase() })}
</p>
</div>
<div className="scale-grid" role="group" aria-label={label}>
{([1, 2, 3, 4, 5] as ScaleValue[]).map((number) => (
<button
className={value === number ? "scale-button selected" : "scale-button"}
key={number}
onClick={() => onChange(number)}
type="button"
aria-pressed={value === number}
title={guidance[number]}
>
<strong>{number}</strong>
<span>{guidance[number]}</span>
</button>
))}
</div>
</section>
);
}
function Tooltip({ text, label }: { text: string; label?: string }) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [open]);
return (
<span className={open ? "tooltip open" : "tooltip"} onMouseLeave={() => setOpen(false)}>
<button
className="tooltip-button"
type="button"
aria-label={label ?? t("overview.explainTrend")}
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
onFocus={() => setOpen(true)}
>
<HelpCircle aria-hidden="true" />
</button>
<span role="tooltip">{text}</span>
</span>
);
}
function SelectField<T extends string>({
label,
value,
options,
onChange
}: {
label: string;
value?: T;
options: Record<T, string>;
onChange: (value: T | undefined) => void;
}) {
const { t } = useI18n();
return (
<label className="select-field">
<span>{label}</span>
<select value={value ?? ""} onChange={(event) => onChange((event.target.value || undefined) as T | undefined)}>
<option value="">{t("common.notSelected")}</option>
{(Object.keys(options) as T[]).map((key) => (
<option key={key} value={key}>
{options[key]}
</option>
))}
</select>
</label>
);
}
function ChipGroup<T extends string>({
label,
options,
labels,
values,
onToggle
}: {
label: string;
options: T[];
labels: Record<T, string>;
values: T[];
onToggle: (value: T) => void;
}) {
return (
<section className="chip-section">
<h3>{label}</h3>
<div className="chips">
{options.map((option) => (
<button
className={values.includes(option) ? "chip selected" : "chip"}
key={option}
onClick={() => onToggle(option)}
type="button"
aria-pressed={values.includes(option)}
>
{labels[option]}
</button>
))}
</div>
</section>
);
}
function createFollowupDraftFromFollowup(followup: DelayedFollowup): FollowupDraft {
return {
parent_report_id: findParentId(followup),
related_report_id: findParentId(followup),
worse_than_before: followup.worse_than_before,
delayed_symptoms: followup.delayed_symptoms,
recovery_status: followup.recovery_status,
note: followup.note ?? ""
};
}
function FollowupForm({
demoMode,
editFollowup,
reports,
onCancelEdit,
onSaved
}: {
demoMode: boolean;
editFollowup?: DelayedFollowup | null;
reports: StoredEntry[];
onCancelEdit?: () => void;
onSaved: () => void;
}) {
const { labels, t } = useI18n();
const editing = Boolean(editFollowup);
const editAllowed = !editFollowup || isEditableWithin24Hours(editFollowup);
const [attempted, setAttempted] = useState(false);
const [savedText, setSavedText] = useState("");
const workReports = reports
.filter((entry): entry is Report => entry.type === "work_report")
.sort((a, b) => getEntryTime(b) - getEntryTime(a));
const latestReport = workReports[0] ?? getLatestWorkReport(demoMode);
const editParent = editFollowup ? workReports.find((report) => report.id === findParentId(editFollowup)) : undefined;
const parentOptions = Object.fromEntries(
[latestReport, editParent]
.filter((report): report is Report => Boolean(report))
.filter((report, index, array) => array.findIndex((candidate) => candidate.id === report.id) === index)
.map((report) => [report.id, formatReportPickerLabel(report, labels, t)])
);
const [draft, setDraft] = useState<FollowupDraft>(() =>
editFollowup
? createFollowupDraftFromFollowup(editFollowup)
: { parent_report_id: latestReport?.id, related_report_id: latestReport?.id, delayed_symptoms: [], note: "" }
);
const selectedParentIsLatest =
!draft.parent_report_id ||
!latestReport ||
draft.parent_report_id === latestReport.id ||
Boolean(editFollowup && draft.parent_report_id === findParentId(editFollowup));
useEffect(() => {
setDraft(
editFollowup
? createFollowupDraftFromFollowup(editFollowup)
: { parent_report_id: latestReport?.id, related_report_id: latestReport?.id, delayed_symptoms: [], note: "" }
);
setAttempted(false);
setSavedText("");
}, [demoMode, editFollowup?.id, latestReport?.id]);
const submit = () => {
setAttempted(true);
if (!draft.worse_than_before || !selectedParentIsLatest || !editAllowed) return;
if (editFollowup) updateFollowup(editFollowup.id, draft, demoMode);
else createFollowup(draft, demoMode);
setDraft({ parent_report_id: latestReport?.id, related_report_id: latestReport?.id, delayed_symptoms: [], note: "" });
setAttempted(false);
setSavedText(editFollowup ? t("edit.saved") : t("followup.saved"));
onSaved();
window.setTimeout(() => setSavedText(""), 2400);
};
const toggleSymptom = (value: DelayedSymptom) => {
setDraft((current) => ({
...current,
delayed_symptoms: current.delayed_symptoms.includes(value)
? current.delayed_symptoms.filter((item) => item !== value)
: [...current.delayed_symptoms, value]
}));
};
return (
<section className="flow">
<div className="notice">
<FileClock aria-hidden="true" />
<span>{editing ? t("edit.followupIntro") : t("followup.intro")}</span>
</div>
{editing && !editAllowed && <p className="error-text">{t("edit.expired")}</p>}
<section className={attempted && !draft.worse_than_before ? "field-card invalid" : "field-card"}>
<div className="field-head">
<h2>{t("followup.worseQuestion")}</h2>
<p>{t("followup.chooseOne")}</p>
</div>
<div className="status-grid four">
{(Object.keys(worseLabels) as WorseThanBefore[]).map((value) => (
<button
className={draft.worse_than_before === value ? "status-button selected" : "status-button"}
key={value}
onClick={() => setDraft((current) => ({ ...current, worse_than_before: value }))}
type="button"
>
{labels.worseLabels[value]}
</button>
))}
</div>
</section>
<section className="field-card">
<SelectField
label={t("followup.connectReport")}
value={draft.parent_report_id}
options={parentOptions}
onChange={(value) => setDraft((current) => ({ ...current, parent_report_id: value, related_report_id: value }))}
/>
{attempted && !selectedParentIsLatest && <p className="error-text">{t("followup.latestOnly")}</p>}
</section>
<details className="details-panel">
<summary>{t("register.optionalDetails")}</summary>
<div className="details-content">
<ChipGroup
label={t("followup.delayedSymptoms")}
options={delayedSymptomOptions}
labels={labels.delayedSymptomLabels}
values={draft.delayed_symptoms}
onToggle={toggleSymptom}
/>
<SelectField
label={t("followup.recovery")}
value={draft.recovery_status}
options={labels.recoveryLabels}
onChange={(value) => setDraft((current) => ({ ...current, recovery_status: value as RecoveryStatus | undefined }))}
/>
<label className="text-field">
<span>{t("register.note")}</span>
<textarea
maxLength={1000}
value={draft.note}
onChange={(event) => setDraft((current) => ({ ...current, note: event.target.value }))}
rows={4}
/>
</label>
</div>
</details>
<div className="sticky-actions">
{attempted && !draft.worse_than_before && <p className="error-text">{t("followup.missingWorse")}</p>}
{savedText && <p className="success-text">{savedText}</p>}
<button className="primary-action" disabled={!editAllowed} onClick={submit} type="button">
<Send aria-hidden="true" />
<span>{editing ? t("edit.saveChanges") : t("followup.saveFollowup")}</span>
</button>
{editing && onCancelEdit && (
<button className="secondary-action" onClick={onCancelEdit} type="button">
{t("edit.cancelEdit")}
</button>
)}
</div>
</section>
);
}
function Overview({
entries,
selectedEntry,
onSelect,
onClose,
onRequestEdit,
onRequestDelete
}: {
entries: StoredEntry[];
selectedEntry?: StoredEntry;
onSelect: (id: string) => void;
onClose: () => void;
onRequestEdit: (entry: StoredEntry) => void;
onRequestDelete: (entry: StoredEntry) => void;
}) {
const { labels, t } = useI18n();
const [period, setPeriod] = useState<TrendPeriod>(() => readPeriod());
const followupsByParent = useMemo(() => groupFollowups(entries), [entries]);
const orphanFollowups = useMemo(
() => entries.filter((entry): entry is DelayedFollowup => entry.type === "delayed_followup" && !findParentId(entry)),
[entries]
);
const reports = useMemo(() => entries.filter((entry): entry is Report => entry.type === "work_report"), [entries]);
const setStoredPeriod = (next: TrendPeriod) => {
setPeriod(next);
localStorage.setItem(PERIOD_KEY, JSON.stringify(next));
};
return (
<section className="overview-layout">
<div className="overview-main">
<PeriodControls period={period} onChange={setStoredPeriod} />
<Trend entries={entries} period={period} />
<div className="entry-list">
{reports.length === 0 && orphanFollowups.length === 0 && <p className="empty-text">{t("overview.noReports")}</p>}
{reports.map((entry) => (
<ReportListItem
key={entry.id}
report={entry}
followups={followupsByParent.get(entry.id) ?? []}
onSelect={onSelect}
/>
))}
{orphanFollowups.map((entry) => (
<button className="entry-row" key={entry.id} onClick={() => onSelect(entry.id)} type="button">
<div>
<strong>{t("followup.orphan")}</strong>
<span>{formatDateTime(entry)}</span>
</div>
<span className="followup-worse">{t("followup.worsePrefix")}: <strong>{labels.worseLabels[entry.worse_than_before]}</strong></span>
</button>
))}
</div>
</div>
{selectedEntry && <ReportModal entry={selectedEntry} onClose={onClose} onRequestEdit={onRequestEdit} onRequestDelete={onRequestDelete} />}
</section>
);
}
function ReportModal({
entry,
onClose,
onRequestEdit,
onRequestDelete
}: {
entry: StoredEntry;
onClose: () => void;
onRequestEdit: (entry: StoredEntry) => void;
onRequestDelete: (entry: StoredEntry) => void;
}) {
const { t } = useI18n();
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onClose]);
return (
<div className="modal-overlay report-overlay" role="presentation" onMouseDown={onClose}>
<section className="report-modal" role="dialog" aria-modal="true" onMouseDown={(event) => event.stopPropagation()}>
<button className="modal-close" onClick={onClose} type="button" aria-label={t("overview.closeReport")}>
<X aria-hidden="true" />
</button>
<EntryDetails entry={entry} onRequestEdit={onRequestEdit} onRequestDelete={onRequestDelete} />
</section>
</div>
);
}
function ReportListItem({
report,
followups,
onSelect
}: {
report: Report;
followups: DelayedFollowup[];
onSelect: (id: string) => void;
}) {
const { labels, t } = useI18n();
const score = getReportScore(report);
return (
<div className="report-group">
<button className="entry-row" onClick={() => onSelect(report.id)} type="button">
<div>
<strong>{report.workplace || t("overview.report")}</strong>
<span>
{formatDateTime(report)}
{report.work_start_time || report.work_end_time ? ` - ${report.work_start_time || "?"}-${report.work_end_time || "?"}` : ""}
</span>
</div>
<div className="metrics-line">
<MetricTag code="A" value={report.work_ability} />
<MetricTag code="E" value={report.energy_level} />
<MetricTag code="K" value={report.mental_clarity} />
<MetricTag code="S" value={report.symptom_burden} />
<MetricTag code="B" value={report.effort_strain} />
{score !== null && <MetricTag code="T" value={`${score}%`} />}
</div>
</button>
{followups.length > 0 && (
<div className="followup-list">
{followups.map((followup) => (
<button className="followup-row" key={followup.id} onClick={() => onSelect(followup.id)} type="button">
<span>{t("overview.followup")} {formatDateTime(followup)}</span>
<span className="followup-worse">{t("followup.worsePrefix")}: <strong>{labels.worseLabels[followup.worse_than_before]}</strong></span>
</button>
))}
</div>
)}
</div>
);
}
function MetricTag({ code, value }: { code: keyof typeof categoryTooltipTexts; value: React.ReactNode }) {
const { labels } = useI18n();
return (
<span title={`${labels.categoryTooltipTexts[code]}: ${value}`}>
{code} {value}
</span>
);
}
function VerificationIndicator({ entry }: { entry: StoredEntry }) {
const { t } = useI18n();
const status = entry.integrity?.verification_status;
if (!status || status === "valid") return null;
return (
<button
className="verification-warning"
type="button"
title={verificationTooltip(status)}
aria-label={t("verification.invalidLabel")}
onClick={(event) => event.stopPropagation()}
>
!
</button>
);
}
type TrendPeriod = {
preset: PresetRange;
startDate: string;
endDate: string;
};
function PeriodControls({ period, onChange }: { period: TrendPeriod; onChange: (period: TrendPeriod) => void }) {
const { t } = useI18n();
const datePlaceholder = t("overview.datePlaceholder");
const applyPreset = (preset: PresetRange) => {
const today = new Date();
const endDate = toDateInput(today);
if (preset === "all") {
onChange({ preset, startDate: "", endDate: "" });
return;
}
if (preset === "custom") {
onChange({ ...period, preset });
return;
}
const start = new Date(today);
const days = { "7d": 7, "1m": 31, "3m": 92, "6m": 183, "1y": 365, "3y": 1095, "5y": 1825 }[preset];
start.setDate(start.getDate() - days);
onChange({ preset, startDate: toDateInput(start), endDate });
};
return (
<section className="period-panel">
<div className="period-presets">
{[
["7d", t("overview.week")],
["1m", t("overview.month")],
["3m", t("overview.threeMonths")],
["6m", t("overview.sixMonths")],
["1y", t("overview.year")],
["3y", t("overview.threeYears")],
["5y", t("overview.fiveYears")],
["all", t("overview.all")]
].map(([preset, label]) => (
<button
className={period.preset === preset ? "chip selected" : "chip"}
key={preset}
onClick={() => applyPreset(preset as PresetRange)}
type="button"
>
{label}
</button>
))}
</div>
<div className="date-range">
<label>
<span>{t("overview.fromDate")}</span>
<input
type="date"
value={period.startDate}
placeholder={datePlaceholder}
aria-label={`${t("overview.fromDate")} ${datePlaceholder}`}
onChange={(event) => onChange({ ...period, preset: "custom", startDate: event.target.value })}
/>
</label>
<label>
<span>{t("overview.toDate")}</span>
<input
type="date"
value={period.endDate}
placeholder={datePlaceholder}
aria-label={`${t("overview.toDate")} ${datePlaceholder}`}
onChange={(event) => onChange({ ...period, preset: "custom", endDate: event.target.value })}
/>
</label>
</div>
</section>
);
}
function Trend({ entries, period, showScoreBands = false }: { entries: StoredEntry[]; period: TrendPeriod; showScoreBands?: boolean }) {
const { labels, language, t } = useI18n();
const [visibility, setVisibility] = useState<Record<string, LineVisibility>>({
total_score_percent: "dim",
followup_response: "normal"
});
const [fullscreenOpen, setFullscreenOpen] = useState(false);
const reports = useMemo(() => {
return filterReportsByPeriod(entries, period);
}, [entries, period]);
const followupResponses = useMemo(
() => showScoreBands ? filterFollowupResponsesByPeriod(buildFollowupResponsePoints(entries), period) : [],
[entries, period, showScoreBands]
);
const workTimeBands = useMemo(() => filterWorkTimeBandsByPeriod(buildWorkTimeBands(entries), period), [entries, period]);
const periodAssessment = showScoreBands ? calculatePeriodWorkAbilityAssessment(reports) : null;
const metrics = trendMetrics;
if (reports.length < 2) {
return (
<section className="trend-panel">
<div className="trend-head">
<div className="inline-head">
<h2>{t("overview.trend")}</h2>
<Tooltip text={labels.tooltipTexts.total_score_percent} label={t("overview.explainTrend")} />
</div>
{showScoreBands && (
<button className="graph-zoom-button" onClick={() => setFullscreenOpen(true)} type="button" aria-label={t("graphModal.open")}>
<Search aria-hidden="true" />
</button>
)}
</div>
<p className="empty-text">{t("overview.minimumTwo")}</p>
{showScoreBands && <PeriodAssessment assessment={periodAssessment} />}
{showScoreBands && <FullscreenGraphModal entries={entries} initialPeriod={period} open={fullscreenOpen} onClose={() => setFullscreenOpen(false)} />}
</section>
);
}
const width = 760;
const height = 260;
const padding = { left: 36, right: 12, top: 18, bottom: 44 };
const responseState = visibility[followupResponseMetric.key] ?? "normal";
const visibleFollowupResponses = showScoreBands && responseState !== "hidden" ? followupResponses : [];
const times = [
...reports.map(getEntryTime),
...visibleFollowupResponses.map((point) => getEntryTime(point.followup)),
...workTimeBands.flatMap((band) => [band.startAt, band.endAt])
];
const [periodStart, periodEnd] = periodBounds(period);
const minTime = graphDomainStart(times, periodStart);
const maxTime = periodEnd ?? Math.max(...times);
const range = Math.max(1, maxTime - minTime);
const x = (time: number) => padding.left + ((time - minTime) / range) * (width - padding.left - padding.right);
const y = (value: number) => padding.top + (1 - value / 100) * (height - padding.top - padding.bottom);
const markers = createTimeMarkers(minTime, maxTime);
const averages = metrics
.map((metric) => ({
...metric,
average: average(reports.map((report) => normalizeReportMetric(report, metric.key)).filter(isNumber))
}))
.filter((metric) => metric.average !== null);
const cycleVisibility = (key: string) => {
setVisibility((current) => {
const next = current[key] === "dim" ? "hidden" : current[key] === "hidden" ? "normal" : "dim";
return { ...current, [key]: next };
});
};
return (
<section className="trend-panel">
<div className="trend-head">
<div className="inline-head">
<h2>{t("overview.trend")}</h2>
<Tooltip text={t("overview.trendTooltip")} label={t("overview.explainTrend")} />
</div>
<div className="trend-actions">
<p>{t("overview.average")}: {Math.round(averages.find((item) => item.key === "total_score_percent")?.average ?? 0)}%</p>
{showScoreBands && (
<button className="graph-zoom-button" onClick={() => setFullscreenOpen(true)} type="button" aria-label={t("graphModal.open")}>
<Search aria-hidden="true" />
</button>
)}
</div>
</div>
<svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label={t("overview.chartLabel")}>
{workTimeBands.map((band) => (
<rect
aria-label={t("graphModal.workTimeTooltip")}
className="work-time-band"
key={`work-time-${band.reportId}-${band.startAt}`}
x={x(band.startAt)}
y={padding.top}
width={Math.max(1, x(band.endAt) - x(band.startAt))}
height={height - padding.top - padding.bottom}
/>
))}
{[0, 25, 50, 75, 100].map((line) => (
<g key={line}>
<line className="grid-line" x1={padding.left} x2={width - padding.right} y1={y(line)} y2={y(line)} />
<text className="axis-label" x="0" y={y(line) + 4}>
{line}
</text>
</g>
))}
{markers.map((marker) => (
<g key={`${marker.time}-${marker.label}`}>
<line className="time-marker" x1={x(marker.time)} x2={x(marker.time)} y1={padding.top} y2={height - padding.bottom} />
<text className="axis-label" x={x(marker.time)} y={height - 18} textAnchor="middle">
{marker.label}
</text>
</g>
))}
{metrics.flatMap((metric) => {
const state = visibility[metric.key] ?? "normal";
if (state === "hidden") return [];
const points = reports
.map((report) => {
const value = normalizeReportMetric(report, metric.key);
const time = getEntryTime(report);
return value === null ? null : { x: x(time), y: y(value), time };
})
.filter(isPoint);
return createLineSegments(points, padding.left).map((segment, index) => (
<line
key={`${metric.key}-${index}`}
x1={segment.x1}
x2={segment.x2}
y1={segment.y1}
y2={segment.y2}
stroke={metric.color}
strokeWidth={metric.key === "total_score_percent" ? "2.1" : "1.75"}
strokeLinecap="round"
opacity={state === "dim" ? 0.4 : 1}
strokeDasharray={segment.dashed ? "8 8" : undefined}
/>
));
})}
{metrics.flatMap((metric) => {
const state = visibility[metric.key] ?? "normal";
if (state === "hidden") return [];
return reports
.map((report) => {
const value = normalizeReportMetric(report, metric.key);
const time = getEntryTime(report);
return value === null ? null : { x: x(time), y: y(value), time };
})
.filter(isPoint)
.map((point) => (
<circle
key={`${metric.key}-point-${point.time}`}
cx={point.x}
cy={point.y}
r={metric.key === "total_score_percent" ? 4.5 : 4}
fill={metric.color}
opacity={state === "dim" ? 0.45 : 0.92}
/>
));
})}
{visibleFollowupResponses.map((point) => {
const parentTime = getEntryTime(point.parent);
const followupTime = getEntryTime(point.followup);
const opacity = responseState === "dim" ? 0.42 : 0.9;
return (
<g key={`followup-response-${point.followup.id}`} opacity={opacity}>
<line className="followup-link" x1={x(parentTime)} x2={x(followupTime)} y1={y(point.parentScore)} y2={y(point.effectScore)} />
<line className="followup-effect-bar" x1={x(followupTime)} x2={x(followupTime)} y1={y(point.parentScore)} y2={y(point.effectScore)} />
<circle className="followup-response-point" cx={x(followupTime)} cy={y(point.effectScore)} r="5" />
</g>
);
})}
</svg>
<div className="legend interactive">
{metrics.map((metric) => {
const state = visibility[metric.key] ?? "normal";
const metricAverage = averages.find((item) => item.key === metric.key)?.average ?? null;
const band = metric.key === "total_score_percent" && showScoreBands ? getScoreBand(metricAverage) : null;
return (
<button
className={`legend-chip ${state}`}
key={metric.key}
onClick={() => cycleVisibility(metric.key)}
type="button"
title={band ? `${metric.label}: ${Math.round(metricAverage ?? 0)}% - ${translateScoreBand(band, t)}. ${t("scoring.disclaimer")}` : metric.label}
>
<i style={{ background: metric.color }} /> {metric.short} {metric.label}
{band && <span className="legend-band">- {translateScoreBand(band, t, "short")}</span>}
</button>
);
})}
{showScoreBands && (
<button
className={`legend-chip ${responseState}`}
onClick={() => cycleVisibility(followupResponseMetric.key)}
type="button"
title={t("graphModal.followupResponseHelp")}
>
<i style={{ background: followupResponseMetric.color }} /> {followupResponseMetric.short} {t(followupResponseMetric.labelKey)}
</button>
)}
</div>
<p className="empty-text">{t("overview.dashedLine")}</p>
<p className="empty-text">{t("graphModal.workTimeLegend")}</p>
<div className="average-grid">
{averages.map((metric) => (
<span key={metric.key}>
{metric.short}: {Math.round(metric.average ?? 0)}%
</span>
))}
</div>
{showScoreBands && <PeriodAssessment assessment={periodAssessment} />}
{showScoreBands && <FullscreenGraphModal entries={entries} initialPeriod={period} open={fullscreenOpen} onClose={() => setFullscreenOpen(false)} />}
</section>
);
}
function FullscreenGraphModal({
entries,
initialPeriod,
open,
onClose
}: {
entries: StoredEntry[];
initialPeriod: TrendPeriod;
open: boolean;
onClose: () => void;
}) {
const { t } = useI18n();
const [period, setPeriod] = useState(initialPeriod);
const [visibility, setVisibility] = useState<Record<string, LineVisibility>>({ total_score_percent: "dim", followup_response: "normal" });
const [inspect, setInspect] = useState<{ x: number; report: Report | null } | null>(null);
const closeRef = useRef<HTMLButtonElement | null>(null);
const svgRef = useRef<SVGSVGElement | null>(null);
const modalRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!open) return;
setPeriod(initialPeriod);
window.setTimeout(() => closeRef.current?.focus(), 0);
}, [initialPeriod, open]);
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
if (event.key !== "Tab" || !modalRef.current) return;
const focusable = Array.from(
modalRef.current.querySelectorAll<HTMLElement>("button, input, select, textarea, [href], [tabindex]:not([tabindex='-1'])")
).filter((element) => !element.hasAttribute("disabled"));
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onClose, open]);
const reports = useMemo(() => filterReportsByPeriod(entries, period), [entries, period]);
const followupsByParent = useMemo(() => groupFollowups(entries), [entries]);
const followupResponses = useMemo(() => filterFollowupResponsesByPeriod(buildFollowupResponsePoints(entries), period), [entries, period]);
const workTimeBands = useMemo(() => filterWorkTimeBandsByPeriod(buildWorkTimeBands(entries), period), [entries, period]);
const width = 1120;
const height = 520;
const padding = { left: 44, right: 24, top: 26, bottom: 58 };
const responseState = visibility[followupResponseMetric.key] ?? "normal";
const visibleFollowupResponses = responseState !== "hidden" ? followupResponses : [];
const times = [
...reports.map(getEntryTime),
...visibleFollowupResponses.map((point) => getEntryTime(point.followup)),
...workTimeBands.flatMap((band) => [band.startAt, band.endAt])
];
const [periodStart, periodEnd] = periodBounds(period);
const minTime = times.length ? graphDomainStart(times, periodStart) : graphDomainStart([Date.now()], periodStart);
const maxTime = periodEnd ?? (times.length ? Math.max(...times) : minTime + 1);
const range = Math.max(1, maxTime - minTime);
const x = (time: number) => padding.left + ((time - minTime) / range) * (width - padding.left - padding.right);
const y = (value: number) => padding.top + (1 - value / 100) * (height - padding.top - padding.bottom);
const markers = createTimeMarkers(minTime, maxTime);
const cycleVisibility = (key: string) => {
setVisibility((current) => {
const next = current[key] === "dim" ? "hidden" : current[key] === "hidden" ? "normal" : "dim";
return { ...current, [key]: next };
});
};
const inspectAt = (clientX: number) => {
if (!svgRef.current || reports.length === 0) return;
const rect = svgRef.current.getBoundingClientRect();
const localX = ((clientX - rect.left) / rect.width) * width;
const nearest = reports
.map((report) => ({ report, distance: Math.abs(x(getEntryTime(report)) - localX) }))
.sort((a, b) => a.distance - b.distance)[0];
setInspect({
x: Math.min(width - padding.right, Math.max(padding.left, localX)),
report: nearest && nearest.distance <= 16 ? nearest.report : null
});
};
if (!open) return null;
return (
<div className="modal-overlay graph-modal-overlay" role="presentation" onMouseDown={onClose}>
<section ref={modalRef} className="graph-modal" role="dialog" aria-modal="true" aria-labelledby="graph-modal-title" onMouseDown={(event) => event.stopPropagation()}>
<div className="graph-modal-head">
<div>
<p className="eyebrow">{t("evaluator.eyebrow")}</p>
<h2 id="graph-modal-title">{t("graphModal.title")}</h2>
</div>
<button ref={closeRef} className="modal-close" onClick={onClose} type="button" aria-label={t("graphModal.close")}>
<X aria-hidden="true" />
</button>
</div>
<div className="graph-modal-controls">
<PeriodControls period={period} onChange={setPeriod} />
<section className="period-panel">
<div className="field-head">
<h2>{t("graphModal.visibleLines")}</h2>
<p>{t("graphModal.visibleHelp")}</p>
</div>
<div className="legend interactive">
{trendMetrics.map((metric) => {
const state = visibility[metric.key] ?? "normal";
return (
<button className={`legend-chip ${state}`} key={metric.key} onClick={() => cycleVisibility(metric.key)} type="button">
<i style={{ background: metric.color }} /> {metric.short} {metric.label}
</button>
);
})}
<button className={`legend-chip ${responseState}`} onClick={() => cycleVisibility(followupResponseMetric.key)} type="button" title={t("graphModal.followupResponseHelp")}>
<i style={{ background: followupResponseMetric.color }} /> {followupResponseMetric.short} {t(followupResponseMetric.labelKey)}
</button>
</div>
</section>
</div>
<div className="graph-modal-body fullscreen-graph-body">
<p className="empty-text fullscreen-graph-help">{t("graphModal.snapHelp")}</p>
{reports.length < 1 ? (
<p className="empty-text">{t("graphModal.noReports")}</p>
) : (
<div className="graph-inspection-layout">
<div className="graph-workspace">
<div className="fullscreen-graph-chart-shell">
<svg
ref={svgRef}
className="fullscreen-chart"
viewBox={`0 0 ${width} ${height}`}
role="img"
aria-label={t("graphModal.chartLabel")}
onPointerLeave={() => setInspect(null)}
onPointerMove={(event) => inspectAt(event.clientX)}
>
{workTimeBands.map((band) => (
<rect
aria-label={t("graphModal.workTimeTooltip")}
className="work-time-band"
key={`fullscreen-work-time-${band.reportId}-${band.startAt}`}
x={x(band.startAt)}
y={padding.top}
width={Math.max(1, x(band.endAt) - x(band.startAt))}
height={height - padding.top - padding.bottom}
/>
))}
{[0, 25, 50, 75, 100].map((line) => (
<g key={line}>
<line className="grid-line" x1={padding.left} x2={width - padding.right} y1={y(line)} y2={y(line)} />
<text className="axis-label" x="0" y={y(line) + 4}>
{line}
</text>
</g>
))}
{markers.map((marker) => (
<g key={`${marker.time}-${marker.label}`}>
<line className="time-marker" x1={x(marker.time)} x2={x(marker.time)} y1={padding.top} y2={height - padding.bottom} />
<text className="axis-label" x={x(marker.time)} y={height - 22} textAnchor="middle">
{marker.label}
</text>
</g>
))}
{trendMetrics.flatMap((metric) => {
const state = visibility[metric.key] ?? "normal";
if (state === "hidden") return [];
const points = reports
.map((report) => {
const value = normalizeReportMetric(report, metric.key);
const time = getEntryTime(report);
return value === null ? null : { x: x(time), y: y(value), time };
})
.filter(isPoint);
return createLineSegments(points, padding.left).map((segment, index) => (
<line
key={`${metric.key}-fullscreen-${index}`}
x1={segment.x1}
x2={segment.x2}
y1={segment.y1}
y2={segment.y2}
stroke={metric.color}
strokeWidth="1.75"
strokeLinecap="round"
opacity={state === "dim" ? 0.4 : 1}
strokeDasharray={segment.dashed ? "8 8" : undefined}
/>
));
})}
{visibleFollowupResponses.map((point) => {
const parentTime = getEntryTime(point.parent);
const followupTime = getEntryTime(point.followup);
const opacity = responseState === "dim" ? 0.42 : 0.9;
return (
<g key={`fullscreen-followup-response-${point.followup.id}`} opacity={opacity}>
<line className="followup-link" x1={x(parentTime)} x2={x(followupTime)} y1={y(point.parentScore)} y2={y(point.effectScore)} />
<line className="followup-effect-bar" x1={x(followupTime)} x2={x(followupTime)} y1={y(point.parentScore)} y2={y(point.effectScore)} />
<circle className="followup-response-point" cx={x(followupTime)} cy={y(point.effectScore)} r="6" />
</g>
);
})}
{trendMetrics.flatMap((metric) => {
const state = visibility[metric.key] ?? "normal";
if (state === "hidden") return [];
return reports
.map((report) => {
const value = normalizeReportMetric(report, metric.key);
const time = getEntryTime(report);
return value === null ? null : { x: x(time), y: y(value), time, reportId: report.id };
})
.filter((point): point is ChartPoint & { reportId: string } => Boolean(point))
.map((point) => (
<circle
key={`${metric.key}-fullscreen-point-${point.reportId}`}
className={inspect?.report?.id === point.reportId ? "chart-point active" : "chart-point"}
cx={point.x}
cy={point.y}
r={inspect?.report?.id === point.reportId ? 6.5 : 4.5}
fill={metric.color}
opacity={state === "dim" ? 0.45 : 0.95}
/>
));
})}
{inspect && <line className="inspect-line" x1={inspect.report ? x(getEntryTime(inspect.report)) : inspect.x} x2={inspect.report ? x(getEntryTime(inspect.report)) : inspect.x} y1={padding.top} y2={height - padding.bottom} />}
</svg>
</div>
<GraphSelectedPills report={inspect?.report ?? null} />
</div>
<GraphInspectTooltip
followups={inspect?.report ? followupsByParent.get(inspect.report.id) ?? [] : []}
report={inspect?.report ?? null}
responsePoints={inspect?.report ? followupResponses.filter((point) => point.parent.id === inspect.report?.id) : []}
workTimeBand={inspect?.report ? workTimeBands.find((band) => band.reportId === inspect.report?.id) ?? null : null}
visibility={visibility}
/>
</div>
)}
</div>
</section>
</div>
);
}
function GraphSelectedPills({ report }: { report: Report | null }) {
const { labels, t } = useI18n();
if (!report) {
return <div className="graph-selected-pills fullscreen-graph-below" aria-hidden="true" />;
}
return (
<div className="graph-selected-pills fullscreen-graph-below">
<PillGroup title={t("details.taskTypes")} values={report.task_types} labels={labels.taskTypeLabels} />
<PillGroup title={t("details.accommodations")} values={report.helpful_accommodations} labels={labels.accommodationLabels} />
</div>
);
}
function PillGroup<T extends string>({ title, values, labels }: { title: string; values: T[]; labels: Record<T, string> }) {
if (values.length === 0) return null;
return (
<div>
<strong>{title}</strong>
<span className="graph-status-pill-row">
{values.map((value) => (
<i className="graph-status-pill" key={value}>{labels[value]}</i>
))}
</span>
</div>
);
}
function GraphInspectTooltip({
followups,
report,
responsePoints,
workTimeBand,
visibility
}: {
followups: DelayedFollowup[];
report: Report | null;
responsePoints: FollowupResponsePoint[];
workTimeBand: WorkTimeBand | null;
visibility: Record<string, LineVisibility>;
}) {
const { labels, t } = useI18n();
if (!report) {
return (
<aside className="graph-inspect-panel">
<h3>{t("graphModal.noPoint")}</h3>
<p>{t("graphModal.noPointHelp")}</p>
</aside>
);
}
return (
<aside className="graph-inspect-panel">
<h3>{t("graphModal.nearestReport")}</h3>
<p>
{formatDateTime(report)}
<br />
{report.workplace || t("common.unknownWorkplace")}
</p>
{workTimeBand && (
<p>
<strong>{t("graphModal.workTime")}:</strong> {formatTimeRange(workTimeBand.startAt, workTimeBand.endAt)}
</p>
)}
<dl>
{trendMetrics
.filter((metric) => (visibility[metric.key] ?? "normal") !== "hidden")
.map((metric) => {
const value = normalizeReportMetric(report, metric.key);
return (
<div key={metric.key}>
<dt>{metric.short}</dt>
<dd>{value === null ? "-" : `${Math.round(value)}%`}</dd>
</div>
);
})}
</dl>
<section className="graph-followups">
<h4>{t("details.followups")}</h4>
{followups.length === 0 && <p>{t("graphModal.noFollowups")}</p>}
{followups
.slice()
.sort((a, b) => getEntryTime(a) - getEntryTime(b))
.map((followup) => {
const response = responsePoints.find((point) => point.followup.id === followup.id);
return (
<div key={followup.id}>
<strong>{formatDateTime(followup)}</strong>
<span>{t("details.worseValue", { value: labels.worseLabels[followup.worse_than_before] })}</span>
{followup.recovery_status && <span>{t("details.recoveryValue", { value: labels.recoveryLabels[followup.recovery_status] })}</span>}
{response && <span>{t("graphModal.followupDelay", { hours: response.offsetHours })}</span>}
{response && <span>{t("graphModal.followupEffect", { score: response.effectScore, parent: response.parentScore })}</span>}
{followup.note && <p>{followup.note}</p>}
</div>
);
})}
</section>
{(visibility[followupResponseMetric.key] ?? "normal") !== "hidden" && responsePoints.length > 0 && (
<p className="empty-text">{t("graphModal.followupResponseDisclaimer")}</p>
)}
</aside>
);
}
function PeriodAssessment({ assessment }: { assessment: ReturnType<typeof calculatePeriodWorkAbilityAssessment> | null }) {
const { t } = useI18n();
return (
<div className="period-assessment">
<div>
<strong>{t("scoring.periodLabel")}</strong>
<span>
{assessment?.score_percent === null || !assessment
? t("scoring.periodEmpty")
: `${assessment.score_percent} % - ${translateScoreBand(assessment.score_band, t)}`}
</span>
</div>
<Tooltip
text={t("scoring.periodTooltip")}
label={t("scoring.periodLabel")}
/>
</div>
);
}
function EntryDetails({
entry,
onRequestEdit,
onRequestDelete
}: {
entry: StoredEntry;
onRequestEdit?: (entry: StoredEntry) => void;
onRequestDelete?: (entry: StoredEntry) => void;
}) {
const { labels, t } = useI18n();
const related =
entry.type === "delayed_followup" && findParentId(entry) ? listReports().find((item) => item.id === findParentId(entry)) : undefined;
const editable = isEditableWithin24Hours(entry);
return (
<div className="details-view">
<div className="detail-head">
<div>
<p className="eyebrow">{entry.type === "work_report" ? t("overview.report") : t("overview.followup")}</p>
<h2>{entry.type === "work_report" ? entry.workplace || t("common.unknownWorkplace") : formatDateTime(entry)}</h2>
</div>
</div>
{entry.type === "work_report" ? (
<dl className="detail-grid">
<Detail label={t("details.time")} value={formatDateTime(entry)} />
<Detail label={t("register.workplace")} value={entry.workplace || t("common.unknownWorkplace")} />
<Detail label={t("details.workTime")} value={`${entry.work_start_time || t("common.notSelected")} - ${entry.work_end_time || t("common.notSelected")}`} />
<Detail label={t("details.totalScore")} value={`${getReportScore(entry) ?? calculateReportScore(entry) ?? t("common.notCalculated")}%`} />
<Detail label={t("details.workAbility")} value={entry.work_ability} />
<Detail label={t("details.energy")} value={entry.energy_level} />
<Detail label={t("details.physicalEnergy")} value={entry.physical_energy} />
<Detail label={t("details.mentalEnergy")} value={entry.mental_energy} />
<Detail label={t("details.mentalClarity")} value={entry.mental_clarity} />
<Detail label={t("details.symptoms")} value={entry.symptom_burden} />
<Detail label={t("details.strain")} value={entry.effort_strain} />
<Detail label={t("details.status")} value={labels.statusLabels[entry.status]} />
<Detail label={t("details.productivity")} value={entry.perceived_productivity ?? t("common.notSelected")} />
<Detail label={t("details.taskCompletion")} value={entry.task_completion ? labels.taskCompletionLabels[entry.task_completion] : t("common.notSelected")} />
<Detail label={t("details.taskTypes")} value={labelsFor(entry.task_types, labels.taskTypeLabels, t)} />
<Detail label={t("details.limitations")} value={labelsFor(entry.main_limitations, labels.limitationLabels, t)} />
<Detail label={t("details.accommodations")} value={labelsFor(entry.helpful_accommodations, labels.accommodationLabels, t)} />
<Detail label={t("details.comment")} value={entry.note ?? t("details.noComment")} />
</dl>
) : (
<dl className="detail-grid">
<Detail label={t("details.worse")} value={labels.worseLabels[entry.worse_than_before]} />
<Detail label={t("details.delayedSymptoms")} value={labelsFor(entry.delayed_symptoms, labels.delayedSymptomLabels, t)} />
<Detail label={t("details.recovery")} value={entry.recovery_status ? labels.recoveryLabels[entry.recovery_status] : t("common.notSelected")} />
<Detail label={t("details.connectedReport")} value={related ? formatDateTime(related) : t("common.notSelected")} />
<Detail label={t("details.comment")} value={entry.note ?? t("details.noComment")} />
</dl>
)}
{onRequestEdit && (
<section className="edit-zone">
{editable ? (
<button className="secondary-action" onClick={() => onRequestEdit(entry)} type="button">
<Save aria-hidden="true" />
<span>{t("edit.edit")}</span>
</button>
) : (
<p className="empty-text">{t("edit.expired")}</p>
)}
</section>
)}
{onRequestDelete && (
<section className="danger-zone">
<h3>{t("danger.title")}</h3>
<p>{t("danger.deleteEntryHelp")}</p>
<button className="secondary-action danger" onClick={() => onRequestDelete(entry)} type="button">
<Trash2 aria-hidden="true" />
<span>{entry.type === "work_report" ? t("danger.deleteReport") : t("danger.deleteFollowup")}</span>
</button>
</section>
)}
</div>
);
}
function Detail({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
);
}
function PrivacyPanel({ demoMode, entries, onDeleted }: { demoMode: boolean; entries: StoredEntry[]; onDeleted: () => void }) {
const { t } = useI18n();
const [confirmDeleteAll, setConfirmDeleteAll] = useState(false);
const [pendingImport, setPendingImport] = useState<{ text: string; summary: ImportSummary; fileName: string } | null>(null);
const [importMessage, setImportMessage] = useState("");
const [exportDialog, setExportDialog] = useState<"json" | "email" | null>(null);
const [exportMessage, setExportMessage] = useState("");
const clear = () => {
deleteAllReports(demoMode);
setConfirmDeleteAll(false);
onDeleted();
};
const chooseImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
const summary = previewImportReports(text, demoMode);
setPendingImport({ text, summary, fileName: file.name });
setImportMessage("");
} catch (error) {
setPendingImport(null);
setImportMessage(error instanceof Error ? error.message : "Importfilen kunne ikke leses.");
} finally {
event.target.value = "";
}
};
const confirmImport = () => {
if (!pendingImport) return;
const summary = importReportsFromJson(pendingImport.text, demoMode);
const flagged = summary.verification
? summary.verification.modified + summary.verification.missing + summary.verification.unsupported + summary.verification.error
: 0;
setPendingImport(null);
setImportMessage(
`Import fullført: ${summary.reports_added} nye rapporter, ${summary.followups_added} nye oppfølginger, ${summary.duplicates_skipped} duplikater hoppet over.${summary.demo_entries_detected ? " Demodata ble importert i demo-modus." : ""}${flagged > 0 ? " Noen rapporter kunne ikke verifiseres." : ""}`
);
onDeleted();
};
return (
<section className="privacy-panel">
<div className="notice warning">
<ShieldAlert aria-hidden="true" />
<span>{demoMode ? t("privacy.demoWarning") : t("privacy.warning")}</span>
</div>
<div className="action-grid">
<button className="secondary-action" onClick={() => setExportDialog("json")} type="button" disabled={entries.length === 0}>
<Download aria-hidden="true" />
<span>{t("privacy.exportJson")}</span>
</button>
<button className="secondary-action" onClick={() => setExportDialog("email")} type="button" disabled={entries.length === 0}>
<Send aria-hidden="true" />
<span>{t("privacy.exportEmail")}</span>
</button>
</div>
<div className="privacy-management-row">
<label className="secondary-action file-inline">
<Upload aria-hidden="true" />
<span>{t("privacy.importBackup")}</span>
<input accept="application/json,.json" onChange={chooseImport} type="file" />
</label>
<button className="secondary-action danger" onClick={() => setConfirmDeleteAll(true)} type="button" disabled={entries.length === 0}>
<Trash2 aria-hidden="true" />
<span>{t("privacy.deleteAll")}</span>
</button>
</div>
<div className="notice">
<FileJson aria-hidden="true" />
<span>{t("privacy.importHelp")}</span>
</div>
{pendingImport && (
<section className="import-review">
<h2>{t("privacy.importFrom", { fileName: pendingImport.fileName })}</h2>
<p>
{t("privacy.importStats", {
reports: pendingImport.summary.reports_added,
followups: pendingImport.summary.followups_added,
duplicates: pendingImport.summary.duplicates_skipped,
conflicts: pendingImport.summary.conflicts_detected
})}
</p>
{Boolean(pendingImport.summary.demo_entries_detected) && (
<p className="error-text">{t("privacy.demoImportWarning", { count: pendingImport.summary.demo_entries_detected ?? 0 })}</p>
)}
<div className="action-grid">
<button className="secondary-action" onClick={() => setPendingImport(null)} type="button">
{t("privacy.cancelImport")}
</button>
<button className="primary-action" onClick={confirmImport} type="button">
{t("privacy.importData")}
</button>
</div>
</section>
)}
{importMessage && <p className="success-text">{importMessage}</p>}
{exportMessage && <p className="success-text">{exportMessage}</p>}
<p className="privacy-copy">
{t("privacy.baseCopy")}
</p>
<div className="privacy-copy privacy-sections">
<section>
<h2>{t("privacy.storageTitle")}</h2>
<p>{t("privacy.storageBody")}</p>
</section>
<section>
<h2>{t("privacy.backupTitle")}</h2>
<p>{t("privacy.backupBody")}</p>
</section>
<section>
<h2>{t("privacy.sharingTitle")}</h2>
<p>{t("privacy.sharingBody")}</p>
</section>
<section>
<h2>{t("privacy.sensitiveTitle")}</h2>
<p>{t("privacy.sensitiveBody")}</p>
</section>
</div>
<p className="success-text">{t("privacy.exportIntegrity")}</p>
{exportDialog && (
<ExportModal
demoMode={demoMode}
mode={exportDialog}
onClose={() => setExportDialog(null)}
onMessage={setExportMessage}
/>
)}
<ConfirmDeleteModal open={confirmDeleteAll} itemName={t("privacy.deleteAllName")} onCancel={() => setConfirmDeleteAll(false)} onConfirm={clear} />
</section>
);
}
function ExportModal({
demoMode,
mode,
onClose,
onMessage
}: {
demoMode: boolean;
mode: "json" | "email";
onClose: () => void;
onMessage: (message: string) => void;
}) {
const { t } = useI18n();
const [range, setRange] = useState<ExportRange>({});
const [recipient, setRecipient] = useState("");
const [rangeOpen, setRangeOpen] = useState(false);
const [serverConsent, setServerConsent] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [emailBackup, setEmailBackup] = useState<{ recipient: string; subject: string; body: string } | null>(null);
const isEmail = mode === "email";
const validate = () => {
try {
assertValidExportRange(range);
} catch {
setError(t("privacy.exportInvalidRange"));
return false;
}
if (isEmail && !isProbablyEmail(recipient)) {
setError(t("privacy.invalidEmail"));
return false;
}
if (isEmail && !serverConsent) {
setError(t("privacy.serverStorageConsentRequired"));
return false;
}
setError("");
return true;
};
const createFile = () => {
const filename = buildExportFilename(demoMode, range);
const json = exportReports(demoMode, range);
return new File([json], filename, { type: "application/json" });
};
const submit = async () => {
if (!validate()) return;
setBusy(true);
if (!isEmail) {
const file = createFile();
downloadFile(file);
setBusy(false);
onClose();
return;
}
try {
const share = await createSecureShare(exportReports(demoMode, range));
const subject = t("privacy.emailSubject");
const appUrl = window.location.origin;
const body = t("privacy.secureEmailBody", { appUrl, shareUrl: share.shareUrl });
const nextBackup = { recipient: recipient.trim(), subject, body };
setEmailBackup(nextBackup);
window.location.href = `mailto:${encodeURIComponent(nextBackup.recipient)}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
onMessage(t("privacy.emailLinkCreated"));
} catch (error) {
setError(error instanceof Error ? error.message : t("privacy.emailLinkFailed"));
} finally {
setBusy(false);
}
};
const submitDisabled = busy || (isEmail && (!isProbablyEmail(recipient) || !serverConsent));
return (
<div className="modal-overlay report-overlay" role="presentation" onMouseDown={onClose}>
<section className="export-modal" role="dialog" aria-modal="true" aria-labelledby="export-modal-title" onMouseDown={(event) => event.stopPropagation()}>
<button className="modal-close" onClick={onClose} type="button" aria-label={t("common.close")}>
<X aria-hidden="true" />
</button>
<div className="field-head">
<h2 id="export-modal-title">{isEmail ? t("privacy.emailExportTitle") : t("privacy.exportRangeTitle")}</h2>
<p>{isEmail ? t("privacy.emailClientNotice") : t("privacy.exportRangeIntro")}</p>
</div>
<p className="notice warning">{t("privacy.exportSensitiveNotice")}</p>
{isEmail && (
<label className="text-field">
<span>{t("privacy.recipientEmail")}</span>
<input
inputMode="email"
onChange={(event) => setRecipient(event.target.value)}
placeholder={t("privacy.recipientPlaceholder")}
type="email"
value={recipient}
/>
</label>
)}
{isEmail ? (
<>
<button className="secondary-action compact-toggle" onClick={() => setRangeOpen((open) => !open)} type="button">
<FileClock aria-hidden="true" />
<span>{t("privacy.limitPeriod")}</span>
</button>
{rangeOpen && <ExportRangeFields range={range} onChange={setRange} />}
<p className="empty-text">{t("privacy.allDataSelected")}</p>
<label className="consent-check">
<input checked={serverConsent} onChange={(event) => setServerConsent(event.target.checked)} type="checkbox" />
<span>{t("privacy.serverStorageConsent")}</span>
</label>
</>
) : (
<>
<ExportRangeFields range={range} onChange={setRange} />
<p className="empty-text">{t("privacy.allDataDefault")}</p>
</>
)}
{error && <p className="error-text">{error}</p>}
<div className="action-grid">
<button className="secondary-action" onClick={onClose} type="button">
{t("common.cancel")}
</button>
<button className="primary-action" disabled={submitDisabled} onClick={submit} type="button">
{isEmail ? t("privacy.createEmail") : t("privacy.exportJson")}
</button>
</div>
{emailBackup && (
<section className="copy-backup">
<h3>{t("privacy.copyPasteBackupTitle")}</h3>
<p>{t("privacy.copyPasteBackupIntro")}</p>
<label className="text-field">
<span>{t("privacy.recipientEmail")}</span>
<input readOnly value={emailBackup.recipient} />
</label>
<label className="text-field">
<span>{t("privacy.emailSubjectLabel")}</span>
<input readOnly value={emailBackup.subject} />
</label>
<label className="text-field">
<span>{t("privacy.emailBodyLabel")}</span>
<textarea readOnly rows={10} value={emailBackup.body} />
</label>
</section>
)}
</section>
</div>
);
}
function ExportRangeFields({ range, onChange }: { range: ExportRange; onChange: (range: ExportRange) => void }) {
const { t } = useI18n();
const datePlaceholder = t("overview.datePlaceholder");
return (
<div className="date-range export-date-range">
<label>
<span>{t("privacy.fromDate")}</span>
<input
aria-label={`${t("privacy.fromDate")} ${datePlaceholder}`}
onChange={(event) => onChange({ ...range, fromDate: event.target.value || undefined })}
placeholder={datePlaceholder}
type="date"
value={range.fromDate ?? ""}
/>
</label>
<label>
<span>{t("privacy.toDate")}</span>
<input
aria-label={`${t("privacy.toDate")} ${datePlaceholder}`}
onChange={(event) => onChange({ ...range, toDate: event.target.value || undefined })}
placeholder={datePlaceholder}
type="date"
value={range.toDate ?? ""}
/>
</label>
</div>
);
}
function downloadFile(file: File) {
const url = URL.createObjectURL(file);
const link = document.createElement("a");
link.href = url;
link.download = file.name;
link.click();
URL.revokeObjectURL(url);
}
function isProbablyEmail(value: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
}
function SecureSharePage() {
const { t } = useI18n();
const request = useMemo(() => parseShareRequestFromLocation(window.location), []);
const [status, setStatus] = useState<"idle" | "loading" | "done" | "error">("idle");
const [message, setMessage] = useState("");
const importShare = async () => {
if (!request.exportId) {
setStatus("error");
setMessage(t("share.notFound"));
return;
}
if (!request.key) {
setStatus("error");
setMessage(t("share.missingKey"));
return;
}
setStatus("loading");
setMessage("");
try {
const shared = await fetchSecureShare(request.exportId);
if (shared.status !== "available") {
if (shared.status === "deleted") throw new Error(t("share.alreadyDeleted"));
if (shared.status === "expired") throw new Error(t("share.expired"));
throw new Error(t("share.notFound"));
}
const payload = await decryptSecureSharePayload(shared, request.key);
const result = importExportPayload(JSON.stringify(payload), "", undefined, false);
await confirmSecureShareImport(request.exportId);
setStatus("done");
setMessage(
t("share.imported", {
reports: result.added,
skipped: result.skipped
})
);
} catch (error) {
setStatus("error");
setMessage(error instanceof Error ? error.message : t("share.decryptFailed"));
}
};
const initialError = !request.key ? t("share.missingKey") : !request.exportId ? t("share.notFound") : "";
return (
<div className="app-shell share-shell">
<header className="topbar">
<div>
<p className="eyebrow">{t("app.eyebrow")}</p>
<h1>{t("share.title")}</h1>
</div>
<a className="text-link" href="/veileder">
{t("app.evaluator")}
</a>
</header>
<main>
<section className="share-card">
<div className="notice">
<ShieldAlert aria-hidden="true" />
<span>{t("share.intro")}</span>
</div>
<p>{t("share.activeImportNotice")}</p>
{initialError && <p className="error-text">{initialError}</p>}
{message && <p className={status === "done" ? "success-text" : "error-text"}>{message}</p>}
<div className="action-grid">
<button className="primary-action" disabled={Boolean(initialError) || status === "loading" || status === "done"} onClick={importShare} type="button">
<Download aria-hidden="true" />
<span>{status === "loading" ? t("share.fetching") : t("share.fetchAndImport")}</span>
</button>
<a className="secondary-action" href="/veileder">
<UserRound aria-hidden="true" />
<span>{t("share.openAdvisor")}</span>
</a>
</div>
{status === "error" && <p className="empty-text">{t("share.requestNewShare")}</p>}
</section>
</main>
<AppFooter demoMode={false} onDemoModeChange={() => undefined} showDemoToggle={false} />
</div>
);
}
function EvaluatorApp() {
const { labels, t } = useI18n();
const [demoMode, setDemoMode] = useState(() => isDemoModeEnabled());
const [profiles, setProfiles] = useState<EvaluatorProfile[]>(() => listEvaluatorProfiles(isDemoModeEnabled()));
const [selectedId, setSelectedId] = useState<string | null>(() => listEvaluatorProfiles(isDemoModeEnabled())[0]?.id ?? null);
const [displayName, setDisplayName] = useState("");
const [targetId, setTargetId] = useState("");
const [message, setMessage] = useState("");
const [personNote, setPersonNote] = useState("");
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [sortDir, setSortDir] = useState<"newest" | "oldest">("newest");
const [deletePersonRequest, setDeletePersonRequest] = useState<EvaluatorProfile | null>(null);
const selected = profiles.find((profile) => profile.id === selectedId);
const selectedEntry = selectedEntryId ? selected?.reports.find((entry) => entry.id === selectedEntryId) : undefined;
const period = readPeriod();
useEffect(() => {
if (selected) setPersonNote(getPersonNote(selected)?.text ?? "");
}, [selected?.id]);
useEffect(() => {
const next = listEvaluatorProfiles(demoMode);
setProfiles(next);
setSelectedId((current) => (current && next.some((profile) => profile.id === current) ? current : next[0]?.id ?? null));
setSelectedEntryId(null);
}, [demoMode]);
const refresh = (nextSelectedId?: string) => {
const next = listEvaluatorProfiles(demoMode);
setProfiles(next);
setSelectedId((current) => nextSelectedId ?? (current && next.some((profile) => profile.id === current) ? current : next[0]?.id ?? null));
};
const changeDemoMode = (enabled: boolean) => {
setDemoModeEnabled(enabled);
if (enabled) {
ensureDemoData();
ensureDemoEvaluatorProfiles();
}
setDemoMode(enabled);
setMessage(enabled ? t("demo.notice") : "");
};
const importFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const result = importExportPayload(await file.text(), displayName, targetId || undefined, demoMode);
const flagged = result.verification.modified + result.verification.missing + result.verification.unsupported + result.verification.error;
const actionText =
result.target_resolution === "matched_existing_person"
? "Importen ble knyttet til eksisterende bruker."
: "Opprettet ny bruker.";
setMessage(
`${actionText} ${result.added} nye rapporter, ${result.skipped} duplikater hoppet over.${result.demo_entries_detected > 0 ? ` ADVARSEL: Importen inneholder ${result.demo_entries_detected} demodata-oppføringer.` : ""}${flagged > 0 ? " Noen rapporter kunne ikke verifiseres." : ""}`
);
setDisplayName("");
setTargetId("");
refresh(result.profile.id);
} catch (error) {
setMessage(error instanceof Error ? error.message : "Import feilet.");
} finally {
event.target.value = "";
}
};
const importBackup = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
const demoEntries = countEvaluatorBackupDemoEntries(text);
importEvaluatorBackup(text, demoMode);
setMessage(`${t("evaluator.importedBackup")}${demoEntries > 0 ? ` ADVARSEL: Backupen inneholder ${demoEntries} demodata-oppføringer.` : ""}`);
refresh();
} catch (error) {
setMessage(error instanceof Error ? error.message : "Backup kunne ikke importeres.");
} finally {
event.target.value = "";
}
};
const downloadBackup = () => {
const blob = new Blob([exportEvaluatorBackup(demoMode)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${demoMode ? "arbeidspuls-demo" : "arbeidspuls"}-veilederdata-${new Date().toISOString().slice(0, 10)}.json`;
link.click();
URL.revokeObjectURL(url);
};
const saveName = () => {
if (!selected) return;
renameEvaluatorProfile(selected.id, selected.display_name, demoMode);
refresh(selected.id);
};
const savePersonNote = () => {
if (!selected) return;
upsertEvaluatorNote(selected.id, personNote, { note_type: "person" }, demoMode);
refresh(selected.id);
};
const deleteSelectedPerson = () => {
if (!deletePersonRequest) return;
deleteEvaluatorProfile(deletePersonRequest.id, demoMode);
const remaining = listEvaluatorProfiles(demoMode);
setProfiles(remaining);
setSelectedId(remaining[0]?.id ?? null);
setSelectedEntryId(null);
setDeletePersonRequest(null);
};
const reports = useMemo(() => {
if (!selected) return [];
const lowered = search.trim().toLowerCase();
return selected.reports
.filter((entry): entry is Report => entry.type === "work_report")
.filter((report) => {
if (!lowered) return true;
return [report.workplace, report.note, report.report_date, labels.statusLabels[report.status]]
.filter(Boolean)
.join(" ")
.toLowerCase()
.includes(lowered);
})
.sort((a, b) => (sortDir === "newest" ? getEntryTime(b) - getEntryTime(a) : getEntryTime(a) - getEntryTime(b)));
}, [labels.statusLabels, search, selected, sortDir]);
const followupsByParent = useMemo(() => (selected ? groupFollowups(selected.reports) : new Map<string, DelayedFollowup[]>()), [selected]);
return (
<div className={demoMode ? "app-shell evaluator-shell demo-active" : "app-shell evaluator-shell"}>
<header className="topbar">
<div>
<p className="eyebrow">{t("evaluator.eyebrow")}</p>
<h1>{t("evaluator.title")}</h1>
</div>
<a className="text-link" href="/">
{t("evaluator.backToApp")}
</a>
</header>
{demoMode && (
<div className="notice warning">
<ShieldAlert aria-hidden="true" />
<span>{t("demo.notice")}</span>
</div>
)}
<main className="evaluator-layout">
<section className="evaluator-sidebar">
<div className="field-card">
<div className="field-head">
<h2>{t("evaluator.importJson")}</h2>
<p>{t("evaluator.importHelp")}</p>
</div>
<label className="text-field">
<span>{t("evaluator.displayName")}</span>
<input value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder={t("evaluator.optionalImportName")} />
</label>
<label className="select-field">
<span>{t("evaluator.connectExisting")}</span>
<select value={targetId} onChange={(event) => setTargetId(event.target.value)}>
<option value="">{t("evaluator.autoMatch")}</option>
{profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.display_name}
</option>
))}
</select>
</label>
<label className="file-button">
<Upload aria-hidden="true" />
<span>{t("evaluator.chooseJson")}</span>
<input accept="application/json,.json" onChange={importFile} type="file" />
</label>
<div className="evaluator-backup-actions">
<button className="secondary-action" onClick={downloadBackup} type="button" disabled={profiles.length === 0}>
<Download aria-hidden="true" />
<span>{t("evaluator.exportBackup")}</span>
</button>
<label className="secondary-action file-inline">
<Upload aria-hidden="true" />
<span>{t("evaluator.importBackup")}</span>
<input accept="application/json,.json" onChange={importBackup} type="file" />
</label>
</div>
{message && <p className="success-text">{message}</p>}
</div>
<div className="profile-list">
{profiles.map((profile) => (
<button
className={selectedId === profile.id ? "profile-row active" : "profile-row"}
key={profile.id}
onClick={() => {
setSelectedId(profile.id);
setSelectedEntryId(null);
}}
type="button"
>
<UserRound aria-hidden="true" />
<span>{profile.display_name}</span>
<small>{t("evaluator.reportCount", { count: profile.reports.length })}</small>
</button>
))}
</div>
</section>
<section className="evaluator-main">
{!selected && (
<div className="notice">
<FileJson aria-hidden="true" />
<span>{t("evaluator.importStart")}</span>
</div>
)}
{selected && (
<div className="evaluator-main-grid">
<div className="evaluator-left-workspace">
{selected.contains_demo_data && (
<div className="notice warning">
<ShieldAlert aria-hidden="true" />
<span>{t("evaluator.demoDataWarning")}</span>
</div>
)}
<section className="field-card evaluator-name-card">
<label className="text-field">
<span>{t("evaluator.displayName")}</span>
<input
value={selected.display_name}
onChange={(event) => {
const value = event.target.value;
setProfiles((current) =>
current.map((profile) => (profile.id === selected.id ? { ...profile, display_name: value } : profile))
);
}}
/>
</label>
<div className="evaluator-name-actions">
<button className="secondary-action" onClick={saveName} type="button">
<Save aria-hidden="true" />
<span>{t("evaluator.saveName")}</span>
</button>
<button
className="secondary-action danger"
onClick={() => setDeletePersonRequest(selected)}
title={t("evaluator.deletePersonTitle")}
type="button"
>
<Trash2 aria-hidden="true" />
<span>{t("evaluator.deletePerson")}</span>
</button>
</div>
</section>
<Trend entries={selected.reports} period={period} showScoreBands />
<section className="field-card">
<div className="field-head">
<h2>{t("evaluator.personNote")}</h2>
<p>{t("evaluator.personNoteHelp")}</p>
</div>
<textarea
value={personNote}
onChange={(event) => setPersonNote(event.target.value)}
rows={4}
/>
<button className="primary-action" onClick={savePersonNote} type="button">
<Save aria-hidden="true" />
<span>{t("evaluator.saveNote")}</span>
</button>
{getPersonNote(selected) && <p className="empty-text">{t("evaluator.changedAt", { time: formatIsoDateTime(getPersonNote(selected)!.updated_at) })}</p>}
</section>
<div className="review-tools">
<label className="search-field">
<Search aria-hidden="true" />
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={t("evaluator.searchReports")} />
</label>
<select value={sortDir} onChange={(event) => setSortDir(event.target.value as "newest" | "oldest")}>
<option value="newest">{t("evaluator.newest")}</option>
<option value="oldest">{t("evaluator.oldest")}</option>
</select>
</div>
<div className="entry-list">
{reports.map((report) => (
<ReviewReportCard
followups={followupsByParent.get(report.id) ?? []}
key={report.id}
notes={selected.evaluator_notes}
onSelect={setSelectedEntryId}
report={report}
selectedId={selectedEntryId}
/>
))}
</div>
</div>
<aside className="detail-pane evaluator-detail-pane">
{selectedEntry ? (
<ReviewDetailPane
entry={selectedEntry}
followups={selectedEntry.type === "work_report" ? followupsByParent.get(selectedEntry.id) ?? [] : []}
notes={selected.evaluator_notes}
profileId={selected.id}
demoMode={demoMode}
onSaved={() => refresh(selected.id)}
onClose={() => setSelectedEntryId(null)}
/>
) : (
<div className="detail-empty-state">
<h2>{t("evaluator.noSelectedTitle")}</h2>
<p>{t("evaluator.noSelectedBody")}</p>
</div>
)}
</aside>
</div>
)}
</section>
</main>
<AppFooter demoMode={demoMode} onDemoModeChange={changeDemoMode} />
<ConfirmDeleteModal
open={Boolean(deletePersonRequest)}
itemName={
deletePersonRequest
? `${deletePersonRequest.display_name}, alle importerte rapporter, oppfølginger og veiledernotater knyttet til denne brukeren`
: ""
}
onCancel={() => setDeletePersonRequest(null)}
onConfirm={deleteSelectedPerson}
/>
</div>
);
}
function countEvaluatorBackupDemoEntries(jsonText: string) {
try {
const parsed = JSON.parse(jsonText) as { data_mode?: string; profiles?: EvaluatorProfile[] };
if (!Array.isArray(parsed.profiles)) return 0;
const count = parsed.profiles.reduce(
(total, profile) => total + (profile.contains_demo_data ? 1 : 0) + profile.reports.filter((entry) => entry.demo || entry.id.startsWith("demo-arbeidspuls")).length,
0
);
return parsed.data_mode === "demo" && count === 0 ? 1 : count;
} catch {
return 0;
}
}
function ReviewReportCard({
report,
followups,
notes,
selectedId,
onSelect
}: {
report: Report;
followups: DelayedFollowup[];
notes: EvaluatorProfile["evaluator_notes"];
selectedId: string | null;
onSelect: (id: string) => void;
}) {
const { labels, t } = useI18n();
const reportNote = getReportNote(notes, report.id);
const hasFollowupNote = followups.some((followup) => getFollowupNote(notes, followup.id));
const selected = selectedId === report.id || followups.some((followup) => followup.id === selectedId);
const score = getReportScore(report) ?? calculateReportScore(report);
const band = getScoreBand(score);
const scoreItems: ScorePillProps[] = [
{
code: "T",
value: score === null ? null : `${score}%`,
score,
title: score === null ? `${t("details.totalScore")}: ${t("common.notCalculated")}` : `${t("details.totalScore")}: ${score}%${band ? ` - ${translateScoreBand(band, t)}` : ""}`
},
{
code: "A",
value: report.work_ability ?? null,
score: normalizeReportMetric(report, "work_ability"),
title: `${labels.categoryTooltipTexts.A}: ${report.work_ability ?? t("common.notProvided")}`
},
{
code: "E",
value: report.energy_level ?? null,
score: normalizeReportMetric(report, "energy_level"),
title: `${labels.categoryTooltipTexts.E}: ${report.energy_level ?? t("common.notProvided")}`
},
{
code: "K",
value: report.mental_clarity ?? null,
score: normalizeReportMetric(report, "mental_clarity"),
title: `${labels.categoryTooltipTexts.K}: ${report.mental_clarity ?? t("common.notProvided")}`
},
{
code: "S",
value: report.symptom_burden ?? null,
score: normalizeReportMetric(report, "symptom_burden"),
title: `${labels.categoryTooltipTexts.S}: ${report.symptom_burden ?? t("common.notProvided")} / 5`
},
{
code: "B",
value: report.effort_strain ?? null,
score: normalizeReportMetric(report, "effort_strain"),
title: `${labels.categoryTooltipTexts.B}: ${report.effort_strain ?? t("common.notProvided")} / 5`
}
];
return (
<div className={selected ? "report-group selected-review" : "report-group"}>
<button className="entry-row review-card-main" onClick={() => onSelect(report.id)} type="button">
<div className="report-primary-info">
<strong>{report.workplace || t("common.unknownWorkplace")}</strong>
<span>{formatDateTime(report)}</span>
</div>
<div className="report-score-area">
<div className="report-score-grid" aria-label={t("evaluator.scoreValues")}>
{scoreItems.map((item) => (
<ScorePill key={item.code} {...item} />
))}
</div>
<div className="report-meta-pill-row">
{report.demo && <span className="demo-meta-pill" title={t("evaluator.demoDataWarning")}>{t("demo.label")}</span>}
{report.integrity?.verification_status && report.integrity.verification_status !== "valid" && (
<span className="verification-warning meta-pill" title={verificationTooltip(report.integrity.verification_status)}>
! <span>{t("evaluator.validationError")}</span>
</span>
)}
{followups.length > 0 && <span title={t("evaluator.hasFollowup")}>{t("overview.followup")} {followups.length}</span>}
{report.note && <span title={t("evaluator.hasComment")}>{t("evaluator.comment")}</span>}
{reportNote && <span title={t("evaluator.hasEvaluatorNote")}>{t("evaluator.evaluatorNote")}</span>}
{hasFollowupNote && <span title={t("evaluator.followupNoteBadge")}>{t("evaluator.followupNoteBadge")}</span>}
</div>
</div>
</button>
<button className="review-open" onClick={() => onSelect(report.id)} type="button">
{t("evaluator.details")}
</button>
{followups.length > 0 && (
<div className="followup-list">
{followups.map((followup) => (
<button className="followup-row" key={followup.id} onClick={() => onSelect(followup.id)} type="button">
<span>{formatDateTime(followup)}</span>
<strong>
{t("followup.worsePrefix")}: {labels.worseLabels[followup.worse_than_before]} {getFollowupNote(notes, followup.id) ? `- ${t("evaluator.evaluatorNote")}` : ""}
</strong>
</button>
))}
</div>
)}
</div>
);
}
type ScorePillProps = {
code: keyof typeof categoryTooltipTexts;
value: React.ReactNode | null;
score: number | null;
title: string;
};
function ScorePill({ code, value, score, title }: ScorePillProps) {
return (
<span className={`report-score-pill ${scorePillClass(score)}`} title={title}>
{code} {value ?? "-"}
</span>
);
}
function scorePillClass(score: number | null | undefined) {
if (score === null || score === undefined || Number.isNaN(score)) return "score-missing";
const value = Math.max(0, Math.min(100, score));
if (value >= 90) return "score-high";
if (value >= 70) return "score-good";
if (value >= 50) return "score-moderate";
if (value >= 30) return "score-low";
return "score-very-low";
}
function ReviewDetailPane({
entry,
followups,
notes,
profileId,
demoMode,
onSaved,
onClose
}: {
entry: StoredEntry;
followups: DelayedFollowup[];
notes: EvaluatorProfile["evaluator_notes"];
profileId: string;
demoMode: boolean;
onSaved: () => void;
onClose: () => void;
}) {
const { t } = useI18n();
const currentNote = entry.type === "work_report" ? getReportNote(notes, entry.id) : getFollowupNote(notes, entry.id);
const [noteText, setNoteText] = useState(currentNote?.text ?? "");
const [confirmDeleteNote, setConfirmDeleteNote] = useState(false);
useEffect(() => {
setNoteText(currentNote?.text ?? "");
}, [entry.id, currentNote?.id, currentNote?.updated_at]);
const save = () => {
if (entry.type === "work_report") {
upsertEvaluatorNote(profileId, noteText, { note_type: "report", related_report_id: entry.id }, demoMode);
} else {
upsertEvaluatorNote(profileId, noteText, {
note_type: "followup",
related_followup_id: entry.id,
parent_report_id: findParentId(entry)
}, demoMode);
}
onSaved();
};
const remove = () => {
if (entry.type === "work_report") {
deleteEvaluatorNote(profileId, { note_type: "report", related_report_id: entry.id }, demoMode);
} else {
deleteEvaluatorNote(profileId, { note_type: "followup", related_followup_id: entry.id }, demoMode);
}
setNoteText("");
onSaved();
};
return (
<div className="review-detail">
<div className="detail-head">
<div>
<p className="eyebrow">{entry.type === "work_report" ? t("evaluator.reportDetails") : t("evaluator.followupDetails")}</p>
<h2>{entry.type === "work_report" ? entry.workplace || t("common.unknownWorkplace") : formatDateTime(entry)}</h2>
</div>
<button className="secondary-action slim" onClick={onClose} type="button" aria-label={t("evaluator.closeDetails")}>
{t("evaluator.closeDetails")}
</button>
</div>
{entry.type === "work_report" ? <ReportDetailSections report={entry} followups={followups} /> : <FollowupDetailSections followup={entry} />}
<section className="field-card compact">
<div className="field-head">
<h2>{entry.type === "work_report" ? t("evaluator.reportNote") : t("evaluator.followupNote")}</h2>
<p>{t("evaluator.noteHelp")}</p>
</div>
<textarea value={noteText} onChange={(event) => setNoteText(event.target.value)} rows={4} />
<div className="action-grid">
<button className="primary-action" onClick={save} type="button">
{t("evaluator.saveNote")}
</button>
<button className="secondary-action danger" onClick={() => setConfirmDeleteNote(true)} type="button" disabled={!currentNote && !noteText}>
{t("evaluator.deleteNote")}
</button>
</div>
</section>
<ConfirmDeleteModal
open={confirmDeleteNote}
itemName={entry.type === "work_report" ? t("evaluator.reportNote") : t("evaluator.followupNote")}
onCancel={() => setConfirmDeleteNote(false)}
onConfirm={() => {
remove();
setConfirmDeleteNote(false);
}}
/>
{entry.type === "work_report" ? <ReportTechnicalDetails report={entry} /> : <FollowupTechnicalDetails followup={entry} />}
</div>
);
}
function ReportDetailSections({ report, followups }: { report: Report; followups: DelayedFollowup[] }) {
const { labels, language, t } = useI18n();
const score = getReportScore(report) ?? calculateReportScore(report);
const band = getScoreBand(score);
const verificationStatus = report.integrity?.verification_status;
const hasVerificationWarning = verificationStatus && verificationStatus !== "valid";
return (
<div className="detail-sections">
<DetailSection title={t("details.summary")}>
{report.demo && <Detail label={t("details.dataMode")} value={t("demo.label")} />}
{hasVerificationWarning && (
<Detail
label={t("details.integrity")}
value={<span className="verification-warning meta-pill" title={verificationTooltip(verificationStatus)}>! <span>{translateVerificationStatus(verificationStatus, t)}</span></span>}
/>
)}
<Detail label={t("details.time")} value={formatDateTime(report)} />
<Detail label={t("register.workplace")} value={report.workplace || t("common.unknownWorkplace")} />
<Detail label={t("details.workTime")} value={`${report.work_start_time || t("common.notProvided")} - ${report.work_end_time || t("common.notProvided")}`} />
<Detail label={t("details.status")} value={labels.statusLabels[report.status]} />
<Detail label={t("details.totalScore")} value={score === null ? t("common.notCalculated") : `${score}%`} />
<Detail label={t("details.functionLevel")} value={translateScoreBand(band, t)} />
<Detail label={t("details.description")} value={translateScoreBandDescription(band, language) ?? "Totalscore mangler eller kan ikke beregnes."} />
<Detail label={t("details.important")} value={t("scoring.disclaimer")} />
</DetailSection>
<DetailSection title={t("details.coreValues")}>
<Detail label={t("details.workAbility")} value={t("details.outOfFive", { value: report.work_ability })} />
<Detail label={t("details.energy")} value={t("details.outOfFive", { value: report.energy_level })} />
<Detail label={t("details.mentalClarity")} value={t("details.outOfFive", { value: report.mental_clarity })} />
<Detail label={t("details.symptoms")} value={t("details.outOfFive", { value: report.symptom_burden })} />
<Detail label={t("details.strain")} value={t("details.outOfFive", { value: report.effort_strain })} />
</DetailSection>
{hasReportDetails(report) ? (
<DetailSection title={t("details.details")}>
<Detail label={t("details.physicalEnergy")} value={t("details.outOfFive", { value: report.physical_energy })} />
<Detail label={t("details.mentalEnergy")} value={t("details.outOfFive", { value: report.mental_energy })} />
{report.perceived_productivity && <Detail label={t("details.productivity")} value={t("details.outOfFive", { value: report.perceived_productivity })} />}
{report.task_completion && <Detail label={t("details.taskCompletion")} value={labels.taskCompletionLabels[report.task_completion]} />}
{report.task_types.length > 0 && <Detail label={t("details.taskTypes")} value={labelsFor(report.task_types, labels.taskTypeLabels, t)} />}
{report.main_limitations.length > 0 && <Detail label={t("details.limitations")} value={labelsFor(report.main_limitations, labels.limitationLabels, t)} />}
{report.helpful_accommodations.length > 0 && <Detail label={t("details.accommodations")} value={labelsFor(report.helpful_accommodations, labels.accommodationLabels, t)} />}
</DetailSection>
) : (
<p className="empty-text">{t("details.noOptionalDetails")}</p>
)}
{report.note && (
<DetailSection title={t("details.userComment")}>
<Detail label={t("details.comment")} value={report.note} />
</DetailSection>
)}
{followups.length > 0 && (
<DetailSection title={t("details.followups")}>
{followups.map((followup) => (
<Detail
key={followup.id}
label={formatDateTime(followup)}
value={`${t("details.worseValue", { value: labels.worseLabels[followup.worse_than_before] })}${followup.recovery_status ? ` - ${t("details.recoveryValue", { value: labels.recoveryLabels[followup.recovery_status] })}` : ""}`}
/>
))}
</DetailSection>
)}
</div>
);
}
function FollowupDetailSections({ followup }: { followup: DelayedFollowup }) {
const { labels, t } = useI18n();
const verificationStatus = followup.integrity?.verification_status;
const hasVerificationWarning = verificationStatus && verificationStatus !== "valid";
return (
<div className="detail-sections">
<DetailSection title={t("details.summary")}>
{followup.demo && <Detail label={t("details.dataMode")} value={t("demo.label")} />}
{hasVerificationWarning && (
<Detail
label={t("details.integrity")}
value={<span className="verification-warning meta-pill" title={verificationTooltip(verificationStatus)}>! <span>{translateVerificationStatus(verificationStatus, t)}</span></span>}
/>
)}
<Detail label={t("details.time")} value={formatDateTime(followup)} />
<Detail label={t("details.worse")} value={labels.worseLabels[followup.worse_than_before]} />
{followup.recovery_status && <Detail label={t("details.recovery")} value={labels.recoveryLabels[followup.recovery_status]} />}
</DetailSection>
{(followup.delayed_symptoms.length > 0 || followup.note) && (
<DetailSection title={t("details.details")}>
{followup.delayed_symptoms.length > 0 && <Detail label={t("details.delayedSymptoms")} value={labelsFor(followup.delayed_symptoms, labels.delayedSymptomLabels, t)} />}
{followup.note && <Detail label={t("details.userComment")} value={followup.note} />}
</DetailSection>
)}
</div>
);
}
function ReportTechnicalDetails({ report }: { report: Report }) {
const { t } = useI18n();
return (
<>
<TechnicalDetails>
<p>{t("details.technicalDescription")}</p>
<DetailSection title={t("details.technical")}>
<Detail label={t("details.reportId")} value={report.id} />
<Detail label="Type" value={report.type} />
<Detail label={t("details.created")} value={formatIsoDateTime(report.created_at)} />
{report.updated_at && <Detail label={t("details.updated")} value={formatIsoDateTime(report.updated_at)} />}
<Detail label={t("details.reportDate")} value={report.report_date} />
<Detail label={t("details.reportTime")} value={report.report_time} />
<Detail label={t("details.integrityStatus")} value={translateVerificationStatus(report.integrity?.verification_status, t)} />
<Detail label={t("details.explanation")} value={report.integrity?.verification_reason || verificationTooltip(report.integrity?.verification_status)} />
<Detail label={t("details.algorithm")} value={report.integrity?.algorithm || t("common.notProvided")} />
<Detail label="Canonicalization" value={report.integrity?.canonicalization || t("common.notProvided")} />
<Detail label={t("details.signed")} value={report.integrity?.signed_at ? formatIsoDateTime(report.integrity.signed_at) : t("common.notProvided")} />
<Detail label={t("details.payloadHash")} value={report.integrity?.payload_hash_sha256 || t("common.notProvided")} />
</DetailSection>
</TechnicalDetails>
<RawDataDetails data={report} />
</>
);
}
function FollowupTechnicalDetails({ followup }: { followup: DelayedFollowup }) {
const { t } = useI18n();
return (
<>
<TechnicalDetails>
<p>{t("details.technicalDescription")}</p>
<DetailSection title={t("details.technical")}>
<Detail label={t("details.followupId")} value={followup.id} />
<Detail label={t("details.connectedReport")} value={findParentId(followup) || t("common.notSelected")} />
<Detail label="Type" value={followup.type} />
<Detail label={t("details.created")} value={formatIsoDateTime(followup.created_at)} />
{followup.updated_at && <Detail label={t("details.updated")} value={formatIsoDateTime(followup.updated_at)} />}
<Detail label={t("details.reportDate")} value={followup.report_date} />
<Detail label={t("details.reportTime")} value={followup.report_time} />
<Detail label={t("details.integrityStatus")} value={translateVerificationStatus(followup.integrity?.verification_status, t)} />
<Detail label={t("details.explanation")} value={followup.integrity?.verification_reason || verificationTooltip(followup.integrity?.verification_status)} />
<Detail label={t("details.algorithm")} value={followup.integrity?.algorithm || t("common.notProvided")} />
<Detail label={t("details.payloadHash")} value={followup.integrity?.payload_hash_sha256 || t("common.notProvided")} />
</DetailSection>
</TechnicalDetails>
<RawDataDetails data={followup} />
</>
);
}
function TechnicalDetails({ children }: { children: React.ReactNode }) {
const { t } = useI18n();
return (
<details className="technical-details">
<summary>{t("details.technical")}</summary>
{children}
</details>
);
}
function RawDataDetails({ data }: { data: StoredEntry }) {
const { t } = useI18n();
const [copied, setCopied] = useState(false);
const raw = JSON.stringify(data, null, 2);
const copy = async () => {
await navigator.clipboard?.writeText(raw);
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
};
return (
<details className="raw-details">
<summary>{t("details.rawData")}</summary>
<p>{t("details.rawDescription")}</p>
<button className="secondary-action slim" onClick={copy} type="button">
{copied ? t("common.copied") : t("details.copyRaw")}
</button>
<pre>{raw}</pre>
</details>
);
}
function hasReportDetails(report: Report) {
return Boolean(
report.physical_energy ||
report.mental_energy ||
report.perceived_productivity ||
report.task_completion ||
report.task_types.length ||
report.main_limitations.length ||
report.helpful_accommodations.length
);
}
function DetailSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="detail-section">
<h3>{title}</h3>
<dl className="detail-grid">{children}</dl>
</section>
);
}
function labelsFor<T extends string>(values: T[] | undefined, labels: Record<T, string>, t?: ReturnType<typeof useI18n>["t"]) {
return values?.length ? values.map((value) => labels[value]).join(", ") : t ? t("common.notSelected") : "Ikke valgt";
}
function getPersonNote(profile: EvaluatorProfile) {
return profile.evaluator_notes.find((note) => (note.note_type ?? "person") === "person" && !note.related_report_id && !note.related_followup_id);
}
function getReportNote(notes: EvaluatorProfile["evaluator_notes"], reportId: string) {
return notes.find((note) => (note.note_type === "report" || (!note.note_type && note.related_report_id)) && note.related_report_id === reportId);
}
function getFollowupNote(notes: EvaluatorProfile["evaluator_notes"], followupId: string) {
return notes.find((note) => note.note_type === "followup" && note.related_followup_id === followupId);
}
function currentDateLocale() {
return localStorage.getItem("arbeidspuls.language") === "en" ? "en-GB" : "nb-NO";
}
export function formatDateTime(entry: StoredEntry) {
const parsed = new Date(`${entry.report_date}T${entry.report_time || "00:00:00"}`);
if (Number.isNaN(parsed.getTime())) return `${entry.report_date} ${entry.report_time}`;
const locale = currentDateLocale();
const weekday = parsed.toLocaleDateString(locale, { weekday: "long" });
const date = parsed.toLocaleDateString(locale, { day: "2-digit", month: "2-digit", year: "numeric" });
return `${capitalize(weekday)} ${date} ${entry.report_time}`;
}
function formatShortDate(entry: StoredEntry) {
const parsed = new Date(`${entry.report_date}T${entry.report_time || "00:00:00"}`);
if (Number.isNaN(parsed.getTime())) return entry.report_date;
const locale = currentDateLocale();
const weekday = parsed.toLocaleDateString(locale, { weekday: "long" });
const date = parsed.toLocaleDateString(locale, { day: "2-digit", month: "2-digit", year: "2-digit" });
return `${capitalize(weekday)} ${date}`;
}
function formatReportPickerLabel(report: Report, labels: ReturnType<typeof useI18n>["labels"], t: ReturnType<typeof useI18n>["t"]) {
const time = report.report_time.slice(0, 5);
const workplace = report.workplace?.trim();
const context = `${t("details.workAbility").toLowerCase()} ${report.work_ability}, ${labels.statusLabels[report.status].toLowerCase()}`;
return workplace
? `${formatShortDate(report)} ${time} - ${workplace} (${context})`
: `${formatShortDate(report)} ${time} - ${t("common.unknownWorkplace")} (${context})`;
}
function formatIsoDateTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const locale = currentDateLocale();
const weekday = date.toLocaleDateString(locale, { weekday: "long" });
const formatted = date.toLocaleDateString(locale, { day: "2-digit", month: "2-digit", year: "numeric" });
const time = date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
return `${capitalize(weekday)} ${formatted} ${time}`;
}
function capitalize(value: string) {
return value.charAt(0).toUpperCase() + value.slice(1);
}
function findParentId(followup: DelayedFollowup) {
return followup.parent_report_id ?? followup.related_report_id;
}
function groupFollowups(entries: StoredEntry[]) {
const groups = new Map<string, DelayedFollowup[]>();
entries
.filter((entry): entry is DelayedFollowup => entry.type === "delayed_followup" && Boolean(findParentId(entry)))
.forEach((followup) => {
const parentId = findParentId(followup);
if (!parentId) return;
groups.set(parentId, [...(groups.get(parentId) ?? []), followup].sort((a, b) => getEntryTime(b) - getEntryTime(a)));
});
return groups;
}
function filterReportsByPeriod(entries: StoredEntry[], period: TrendPeriod) {
const [start, end] = periodBounds(period);
return entries
.filter((entry): entry is Report => entry.type === "work_report")
.filter((report) => {
const time = getEntryTime(report);
if (start && time < start) return false;
if (end && time > end) return false;
return true;
})
.sort((a, b) => getEntryTime(a) - getEntryTime(b));
}
function filterFollowupResponsesByPeriod(points: FollowupResponsePoint[], period: TrendPeriod) {
const [start, end] = periodBounds(period);
return points.filter((point) => {
const time = getEntryTime(point.followup);
if (start && time < start) return false;
if (end && time > end) return false;
return true;
});
}
function readPeriod(): TrendPeriod {
const fallback = { preset: "all" as PresetRange, startDate: "", endDate: "" };
try {
const parsed = JSON.parse(localStorage.getItem(PERIOD_KEY) ?? "");
return parsed?.preset ? parsed : fallback;
} catch {
return fallback;
}
}
function periodBounds(period: TrendPeriod): [number | null, number | null] {
const start = period.startDate ? new Date(`${period.startDate}T00:00:00`).getTime() : null;
const end = period.endDate ? new Date(`${period.endDate}T23:59:59`).getTime() : null;
return [start, end];
}
export function graphDomainStart(times: number[], selectedStart: number | null = null) {
const fallback = times.length ? Math.min(...times) : Date.now();
return startOfDayTime(selectedStart ?? fallback);
}
function startOfDayTime(time: number) {
const date = new Date(time);
date.setHours(0, 0, 0, 0);
return date.getTime();
}
function toDateInput(date: Date) {
return [date.getFullYear(), String(date.getMonth() + 1).padStart(2, "0"), String(date.getDate()).padStart(2, "0")].join("-");
}
function createTimeMarkers(minTime: number, maxTime: number) {
const span = maxTime - minTime;
const candidates = [
{ unit: "time", step: 60 * 60 * 1000 },
{ unit: "day", step: 24 * 60 * 60 * 1000 },
{ unit: "week", step: 7 * 24 * 60 * 60 * 1000 },
{ unit: "month", step: 31 * 24 * 60 * 60 * 1000 },
{ unit: "year", step: 365 * 24 * 60 * 60 * 1000 }
];
const selected = candidates.find((candidate) => span / candidate.step <= 8) ?? candidates[candidates.length - 1];
const start = floorToInterval(minTime, selected.step);
const markers: Array<{ time: number; label: string }> = [];
for (let time = start; time <= maxTime; time += selected.step) {
if (time >= minTime) markers.push({ time, label: formatMarker(time, selected.unit) });
}
if (!markers.some((marker) => marker.time === minTime)) {
markers.unshift({ time: minTime, label: formatMarker(minTime, "day") });
}
return markers.slice(0, 10);
}
function floorToInterval(time: number, interval: number) {
return Math.floor(time / interval) * interval;
}
function formatMarker(time: number, unit: string) {
const date = new Date(time);
const locale = currentDateLocale();
if (unit === "time") return date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
if (unit === "day" || unit === "week") return date.toLocaleDateString(locale, { day: "2-digit", month: "2-digit" });
if (unit === "month") return date.toLocaleDateString(locale, { month: "short", year: "2-digit" });
return date.toLocaleDateString(locale, { year: "numeric" });
}
function formatTimeRange(startAt: number, endAt: number) {
const locale = currentDateLocale();
const options: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit" };
return `${new Date(startAt).toLocaleTimeString(locale, options)}-${new Date(endAt).toLocaleTimeString(locale, options)}`;
}
function average(values: number[]) {
if (values.length === 0) return null;
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
type ChartPoint = { x: number; y: number; time: number };
type WorkTimeBand = {
reportId: string;
startAt: number;
endAt: number;
workplace?: string;
};
type FollowupResponsePoint = {
followup: DelayedFollowup;
parent: Report;
parentScore: number;
effectScore: number;
impact: number;
offsetHours: number;
};
const followupResponseMetric = {
key: "followup_response",
labelKey: "graphModal.followupResponse",
short: "O",
color: "#b2642a"
};
function isPoint(value: ChartPoint | null): value is ChartPoint {
return Boolean(value);
}
export function calculateFollowupEffectScore(
parentScore: number,
worseThanBefore: string,
recoveryStatus?: string
) {
const worseImpact: Record<string, number> = {
bedre: 10,
nei: 0,
ikke_verre: 0,
litt: -10,
litt_verre: -10,
moderat: -25,
moderat_verre: -25,
mye: -40,
mye_verre: -40,
svært_mye: -60,
svaert_mye: -60,
svært_mye_verre: -60,
svaert_mye_verre: -60
};
const recoveryAdjustment: Record<string, number> = {
fullt_restituert: 5,
tilbake_til_baseline: 5,
samme_dag: 5,
delvis_restituert: 0,
en_dag: 0,
to_tre_dager: -5,
ikke_restituert: -10,
ikke_tilbake_til_baseline: -10,
fire_pluss_dager: -10,
forverret: -15
};
const impact = (worseImpact[worseThanBefore] ?? 0) + (recoveryStatus ? recoveryAdjustment[recoveryStatus] ?? 0 : 0);
return Math.max(0, Math.min(100, parentScore + impact));
}
export function buildFollowupResponsePoints(entries: StoredEntry[]) {
const reports = new Map(entries.filter((entry): entry is Report => entry.type === "work_report").map((report) => [report.id, report]));
return entries
.filter((entry): entry is DelayedFollowup => entry.type === "delayed_followup")
.map((followup): FollowupResponsePoint | null => {
const parentId = findParentId(followup);
const parent = parentId ? reports.get(parentId) : undefined;
if (!parent) return null;
const parentScore = getReportScore(parent) ?? calculateReportScore(parent);
if (parentScore === null) return null;
const effectScore = calculateFollowupEffectScore(parentScore, followup.worse_than_before, followup.recovery_status);
return {
followup,
parent,
parentScore,
effectScore,
impact: effectScore - parentScore,
offsetHours: Math.round(((getEntryTime(followup) - getEntryTime(parent)) / (60 * 60 * 1000)) * 10) / 10
};
})
.filter((point): point is FollowupResponsePoint => Boolean(point))
.sort((a, b) => getEntryTime(a.followup) - getEntryTime(b.followup));
}
export function buildWorkTimeBands(entries: StoredEntry[]) {
return entries
.filter((entry): entry is Report => entry.type === "work_report")
.map((report): WorkTimeBand | null => {
if (!report.work_start_time || !report.work_end_time) return null;
const startAt = new Date(`${report.report_date}T${report.work_start_time}`).getTime();
let endAt = new Date(`${report.report_date}T${report.work_end_time}`).getTime();
if (!Number.isFinite(startAt) || !Number.isFinite(endAt)) return null;
if (endAt <= startAt) endAt += 24 * 60 * 60 * 1000;
return {
reportId: report.id,
startAt,
endAt,
workplace: report.workplace
};
})
.filter((band): band is WorkTimeBand => Boolean(band))
.sort((a, b) => a.startAt - b.startAt);
}
function filterWorkTimeBandsByPeriod(bands: WorkTimeBand[], period: TrendPeriod) {
const [start, end] = periodBounds(period);
return bands.filter((band) => {
if (start && band.endAt < start) return false;
if (end && band.startAt > end) return false;
return true;
});
}
export function createLineSegments(points: ChartPoint[], chartLeft: number) {
const gapThresholdMs = 3 * 24 * 60 * 60 * 1000;
if (points.length === 0) return [];
const segments = [
{
x1: chartLeft,
x2: points[0].x,
y1: points[0].y,
y2: points[0].y,
dashed: true
}
];
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1];
const current = points[index];
segments.push({
x1: previous.x,
y1: previous.y,
x2: current.x,
y2: current.y,
dashed: current.time - previous.time >= gapThresholdMs
});
}
return segments;
}
function isNumber(value: number | null): value is number {
return typeof value === "number" && Number.isFinite(value);
}
export default App;