diff --git a/package-lock.json b/package-lock.json index a34d0fd..1dcbd99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "arbeidspuls", - "version": "1.1.1", + "version": "1.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "arbeidspuls", - "version": "1.1.1", + "version": "1.1.2", "dependencies": { "lucide-react": "^0.475.0", "react": "^19.0.0", diff --git a/package.json b/package.json index 27b50f9..26ace04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "arbeidspuls", - "version": "1.1.1", + "version": "1.1.2", "private": true, "type": "module", "scripts": { diff --git a/public/service-worker.js b/public/service-worker.js index 4125b7d..3f50d4f 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,4 +1,4 @@ -const CACHE_NAME = "arbeidspuls-v6"; +const CACHE_NAME = "arbeidspuls-v7"; const ASSETS = ["/", "/index.html", "/manifest.webmanifest", "/icon.svg"]; self.addEventListener("install", (event) => { diff --git a/src/App.tsx b/src/App.tsx index cf16ac7..ef0361b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -69,12 +69,16 @@ import { deleteReport, ensureDemoData, exportReports, + getLatestWorkReport, getLastWorkReport, importReportsFromJson, + isEditableWithin24Hours, isDemoModeEnabled, listReports, previewImportReports, - setDemoModeEnabled + setDemoModeEnabled, + updateFollowup, + updateReport } from "./storage"; import type { Accommodation, @@ -136,8 +140,12 @@ function AppRoutes() { const [entries, setEntries] = useState(() => listReports(isDemoModeEnabled())); const [selectedEntryId, setSelectedEntryId] = useState(null); const [deleteRequest, setDeleteRequest] = useState<{ id: string; name: string } | null>(null); + const [editEntry, setEditEntry] = useState(null); - const refresh = () => setEntries(listReports(demoMode)); + const refresh = () => { + const next = listReports(demoMode); + setEntries(next); + }; const selectedEntry = selectedEntryId ? entries.find((entry) => entry.id === selectedEntryId) : undefined; const changeDemoMode = (enabled: boolean) => { setDemoModeEnabled(enabled); @@ -145,6 +153,7 @@ function AppRoutes() { setDemoMode(enabled); setSelectedEntryId(null); setDeleteRequest(null); + setEditEntry(null); setEntries(listReports(enabled)); }; @@ -188,14 +197,39 @@ function AppRoutes() { {t("demo.notice")} )} - {view === "registrer" && } - {view === "oppfolging" && } + {view === "registrer" && ( + setEditEntry(null)} + onSaved={() => { + setEditEntry(null); + refresh(); + }} + /> + )} + {view === "oppfolging" && ( + setEditEntry(null)} + onSaved={() => { + setEditEntry(null); + refresh(); + }} + /> + )} {view === "oversikt" && ( 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") })} /> )} @@ -345,9 +379,43 @@ function createInitialReportDraft(demoMode = false): ReportDraft { }; } -function ReportForm({ demoMode, onSaved }: { demoMode: boolean; onSaved: () => void }) { +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 [draft, setDraft] = useState(() => createInitialReportDraft(demoMode)); + const editing = Boolean(editReport); + const editAllowed = !editReport || isEditableWithin24Hours(editReport); + const [draft, setDraft] = useState(() => editReport ? createReportDraftFromReport(editReport) : createInitialReportDraft(demoMode)); const [attempted, setAttempted] = useState(false); const [savedText, setSavedText] = useState(""); @@ -356,10 +424,10 @@ function ReportForm({ demoMode, onSaved }: { demoMode: boolean; onSaved: () => v const canSubmit = missing.length === 0; useEffect(() => { - setDraft(createInitialReportDraft(demoMode)); + setDraft(editReport ? createReportDraftFromReport(editReport) : createInitialReportDraft(demoMode)); setAttempted(false); setSavedText(""); - }, [demoMode]); + }, [demoMode, editReport?.id]); const setScale = (key: keyof ReportDraft, value: ScaleValue) => { setDraft((current) => ({ ...current, [key]: value })); @@ -377,12 +445,13 @@ function ReportForm({ demoMode, onSaved }: { demoMode: boolean; onSaved: () => v const submit = () => { setAttempted(true); - if (!canSubmit) return; + if (!canSubmit || !editAllowed) return; - createReport(draft, demoMode); + if (editReport) updateReport(editReport.id, draft, demoMode); + else createReport(draft, demoMode); setDraft(createInitialReportDraft(demoMode)); setAttempted(false); - setSavedText(t("register.saved")); + setSavedText(editReport ? t("edit.saved") : t("register.saved")); onSaved(); window.setTimeout(() => setSavedText(""), 2400); }; @@ -391,8 +460,9 @@ function ReportForm({ demoMode, onSaved }: { demoMode: boolean; onSaved: () => v
+ {editing && !editAllowed &&

{t("edit.expired")}

}
@@ -528,10 +598,15 @@ function ReportForm({ demoMode, onSaved }: { demoMode: boolean; onSaved: () => v
{attempted && !canSubmit &&

{t("register.missing", { fields: missing.map((field) => field.label).join(", ") })}

} {savedText &&

{savedText}

} - + {editing && onCancelEdit && ( + + )}
); @@ -698,23 +773,76 @@ function ChipGroup({ ); } -function FollowupForm({ demoMode, reports, onSaved }: { demoMode: boolean; reports: StoredEntry[]; onSaved: () => void }) { +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 [draft, setDraft] = useState({ delayed_symptoms: [], note: "" }); + 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(() => + 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) return; + if (!draft.worse_than_before || !selectedParentIsLatest || !editAllowed) return; - createFollowup(draft, demoMode); - setDraft({ delayed_symptoms: [], note: "" }); + 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(t("followup.saved")); + setSavedText(editFollowup ? t("edit.saved") : t("followup.saved")); onSaved(); window.setTimeout(() => setSavedText(""), 2400); }; @@ -732,8 +860,9 @@ function FollowupForm({ demoMode, reports, onSaved }: { demoMode: boolean; repor
+ {editing && !editAllowed &&

{t("edit.expired")}

}
@@ -758,9 +887,10 @@ function FollowupForm({ demoMode, reports, onSaved }: { demoMode: boolean; repor [report.id, formatReportPickerLabel(report)]))} + options={parentOptions} onChange={(value) => setDraft((current) => ({ ...current, parent_report_id: value, related_report_id: value }))} /> + {attempted && !selectedParentIsLatest &&

{t("followup.latestOnly")}

}
@@ -794,10 +924,15 @@ function FollowupForm({ demoMode, reports, onSaved }: { demoMode: boolean; repor
{attempted && !draft.worse_than_before &&

{t("followup.missingWorse")}

} {savedText &&

{savedText}

} - + {editing && onCancelEdit && ( + + )}
); @@ -808,12 +943,14 @@ function Overview({ 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(); @@ -859,7 +996,7 @@ function Overview({ - {selectedEntry && } + {selectedEntry && }
); } @@ -867,10 +1004,12 @@ function Overview({ function ReportModal({ entry, onClose, + onRequestEdit, onRequestDelete }: { entry: StoredEntry; onClose: () => void; + onRequestEdit: (entry: StoredEntry) => void; onRequestDelete: (entry: StoredEntry) => void; }) { const { t } = useI18n(); @@ -888,7 +1027,7 @@ function ReportModal({ - + ); @@ -912,7 +1051,7 @@ function ReportListItem({ {report.workplace || t("overview.report")} {formatDateTime(report)} - {report.work_start_time || report.work_end_time ? ` · ${report.work_start_time || "?"}-${report.work_end_time || "?"}` : ""} + {report.work_start_time || report.work_end_time ? ` - ${report.work_start_time || "?"}-${report.work_end_time || "?"}` : ""}
@@ -972,6 +1111,7 @@ type TrendPeriod = { 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); @@ -1014,20 +1154,26 @@ function PeriodControls({ period, onChange }: { period: TrendPeriod; onChange: (
@@ -1037,12 +1183,18 @@ function PeriodControls({ period, onChange }: { period: TrendPeriod; onChange: ( function Trend({ entries, period, showScoreBands = false }: { entries: StoredEntry[]; period: TrendPeriod; showScoreBands?: boolean }) { const { labels, language, t } = useI18n(); const [visibility, setVisibility] = useState>({ - total_score_percent: "dim" + 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; @@ -1071,7 +1223,13 @@ function Trend({ entries, period, showScoreBands = false }: { entries: StoredEnt const width = 760; const height = 260; const padding = { left: 36, right: 12, top: 18, bottom: 44 }; - const times = reports.map(getEntryTime); + 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 = periodStart ?? Math.min(...times); const maxTime = periodEnd ?? Math.max(...times); @@ -1110,6 +1268,17 @@ function Trend({ entries, period, showScoreBands = false }: { entries: StoredEnt + {workTimeBands.map((band) => ( + + ))} {[0, 25, 50, 75, 100].map((line) => ( @@ -1172,6 +1341,18 @@ function Trend({ entries, period, showScoreBands = false }: { entries: StoredEnt /> )); })} + {visibleFollowupResponses.map((point) => { + const parentTime = getEntryTime(point.parent); + const followupTime = getEntryTime(point.followup); + const opacity = responseState === "dim" ? 0.42 : 0.9; + return ( + + + + + + ); + })}
{metrics.map((metric) => { @@ -1184,15 +1365,26 @@ function Trend({ entries, period, showScoreBands = false }: { entries: StoredEnt 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} + title={band ? `${metric.label}: ${Math.round(metricAverage ?? 0)}% - ${translateScoreBand(band, t)}. ${t("scoring.disclaimer")}` : metric.label} > {metric.short} {metric.label} - {band && · {translateScoreBand(band, t, "short")}} + {band && - {translateScoreBand(band, t, "short")}} ); })} + {showScoreBands && ( + + )}

{t("overview.dashedLine")}

+

{t("graphModal.workTimeLegend")}

{averages.map((metric) => ( @@ -1219,7 +1411,7 @@ function FullscreenGraphModal({ }) { const { t } = useI18n(); const [period, setPeriod] = useState(initialPeriod); - const [visibility, setVisibility] = useState>({ total_score_percent: "dim" }); + const [visibility, setVisibility] = useState>({ total_score_percent: "dim", followup_response: "normal" }); const [inspect, setInspect] = useState<{ x: number; report: Report | null } | null>(null); const closeRef = useRef(null); const svgRef = useRef(null); @@ -1255,10 +1447,19 @@ function FullscreenGraphModal({ }, [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 times = reports.map(getEntryTime); + 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 = periodStart ?? (times.length ? Math.min(...times) : Date.now()); const maxTime = periodEnd ?? (times.length ? Math.max(...times) : minTime + 1); @@ -1318,26 +1519,42 @@ function FullscreenGraphModal({ ); })} +
-
-

{t("graphModal.snapHelp")}

+
+

{t("graphModal.snapHelp")}

{reports.length < 1 ? (

{t("graphModal.noReports")}

) : (
- setInspect(null)} - onPointerMove={(event) => inspectAt(event.clientX)} - > - {[0, 25, 50, 75, 100].map((line) => ( +
+
+ setInspect(null)} + onPointerMove={(event) => inspectAt(event.clientX)} + > + {workTimeBands.map((band) => ( + + ))} + {[0, 25, 50, 75, 100].map((line) => ( @@ -1353,7 +1570,7 @@ function FullscreenGraphModal({ ))} - {trendMetrics.flatMap((metric) => { + {trendMetrics.flatMap((metric) => { const state = visibility[metric.key] ?? "normal"; if (state === "hidden") return []; const points = reports @@ -1377,8 +1594,20 @@ function FullscreenGraphModal({ strokeDasharray={segment.dashed ? "8 8" : undefined} /> )); - })} - {trendMetrics.flatMap((metric) => { + })} + {visibleFollowupResponses.map((point) => { + const parentTime = getEntryTime(point.parent); + const followupTime = getEntryTime(point.followup); + const opacity = responseState === "dim" ? 0.42 : 0.9; + return ( + + + + + + ); + })} + {trendMetrics.flatMap((metric) => { const state = visibility[metric.key] ?? "normal"; if (state === "hidden") return []; return reports @@ -1399,10 +1628,19 @@ function FullscreenGraphModal({ opacity={state === "dim" ? 0.45 : 0.95} /> )); - })} - {inspect && } - - + })} + {inspect && } + +
+ +
+ point.parent.id === inspect.report?.id) : []} + workTimeBand={inspect?.report ? workTimeBands.find((band) => band.reportId === inspect.report?.id) ?? null : null} + visibility={visibility} + />
)}
@@ -1411,8 +1649,47 @@ function FullscreenGraphModal({ ); } -function GraphInspectTooltip({ report, visibility }: { report: Report | null; visibility: Record }) { - const { t } = useI18n(); +function GraphSelectedPills({ report }: { report: Report | null }) { + const { labels, t } = useI18n(); + if (!report) { + return void }) { +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 (
@@ -1511,6 +1825,18 @@ function EntryDetails({ entry, onRequestDelete }: { entry: StoredEntry; onReques )} + {onRequestEdit && ( +
+ {editable ? ( + + ) : ( +

{t("edit.expired")}

+ )} +
+ )} {onRequestDelete && (

{t("danger.title")}

@@ -1772,7 +2098,6 @@ function EvaluatorApp() { const savePersonNote = () => { if (!selected) return; upsertEvaluatorNote(selected.id, personNote, { note_type: "person" }, demoMode); - setPersonNote(""); refresh(selected.id); }; @@ -2047,7 +2372,7 @@ function ReviewReportCard({ 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)}` : ""}` + title: score === null ? `${t("details.totalScore")}: ${t("common.notCalculated")}` : `${t("details.totalScore")}: ${score}%${band ? ` - ${translateScoreBand(band, t)}` : ""}` }, { code: "A", @@ -2116,7 +2441,7 @@ function ReviewReportCard({ ))} @@ -2238,6 +2563,7 @@ function ReviewDetailPane({ setConfirmDeleteNote(false); }} /> + {entry.type === "work_report" ? : }
); } @@ -2298,28 +2624,11 @@ function ReportDetailSections({ report, followups }: { report: Report; followups ))} )} - -

{t("details.technicalDescription")}

- - - - - - - - - - - - - -
-
); } @@ -2348,6 +2657,40 @@ function FollowupDetailSections({ followup }: { followup: DelayedFollowup }) { {followup.note && } )} + + ); +} + +function ReportTechnicalDetails({ report }: { report: Report }) { + const { t } = useI18n(); + return ( + <> + +

{t("details.technicalDescription")}

+ + + + + {report.updated_at && } + + + + + + + + + +
+ + + ); +} + +function FollowupTechnicalDetails({ followup }: { followup: DelayedFollowup }) { + const { t } = useI18n(); + return ( + <>

{t("details.technicalDescription")}

@@ -2355,6 +2698,7 @@ function FollowupDetailSections({ followup }: { followup: DelayedFollowup }) { + {followup.updated_at && } @@ -2364,7 +2708,7 @@ function FollowupDetailSections({ followup }: { followup: DelayedFollowup }) {
- + ); } @@ -2436,35 +2780,44 @@ function getFollowupNote(notes: EvaluatorProfile["evaluator_notes"], followupId: return notes.find((note) => note.note_type === "followup" && note.related_followup_id === followupId); } -function formatDateTime(entry: StoredEntry) { +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 weekday = parsed.toLocaleDateString("nb-NO", { weekday: "long" }); - const date = parsed.toLocaleDateString("nb-NO", { day: "2-digit", month: "2-digit", year: "numeric" }); + 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 weekday = parsed.toLocaleDateString("nb-NO", { weekday: "long" }); - const date = parsed.toLocaleDateString("nb-NO", { day: "2-digit", month: "2-digit", year: "2-digit" }); + 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) { +function formatReportPickerLabel(report: Report, labels: ReturnType["labels"], t: ReturnType["t"]) { const time = report.report_time.slice(0, 5); const workplace = report.workplace?.trim(); - const context = `arbeidsevne ${report.work_ability}, ${statusLabels[report.status].toLowerCase()}`; - return workplace ? `${formatShortDate(report)} ${time} - ${workplace} (${context})` : `${formatShortDate(report)} ${time} - Ukjent arbeidssted (${context})`; + 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 weekday = date.toLocaleDateString("nb-NO", { weekday: "long" }); - const formatted = date.toLocaleDateString("nb-NO", { day: "2-digit", month: "2-digit", year: "numeric" }); - const time = date.toLocaleTimeString("nb-NO", { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + 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}`; } @@ -2501,6 +2854,16 @@ function filterReportsByPeriod(entries: StoredEntry[], period: TrendPeriod) { .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 { @@ -2545,10 +2908,17 @@ function floorToInterval(time: number, interval: number) { function formatMarker(time: number, unit: string) { const date = new Date(time); - if (unit === "time") return date.toLocaleTimeString("nb-NO", { hour: "2-digit", minute: "2-digit" }); - if (unit === "day" || unit === "week") return date.toLocaleDateString("nb-NO", { day: "2-digit", month: "2-digit" }); - if (unit === "month") return date.toLocaleDateString("nb-NO", { month: "short", year: "2-digit" }); - return date.toLocaleDateString("nb-NO", { year: "numeric" }); + 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[]) { @@ -2557,11 +2927,121 @@ function average(values: number[]) { } 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 = { + 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 = { + 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 []; diff --git a/src/graph.test.ts b/src/graph.test.ts index 6674fe8..a4d9f89 100644 --- a/src/graph.test.ts +++ b/src/graph.test.ts @@ -1,5 +1,19 @@ -import { describe, expect, it } from "vitest"; -import { createLineSegments } from "./App"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { buildFollowupResponsePoints, buildWorkTimeBands, calculateFollowupEffectScore, createLineSegments, formatDateTime } from "./App"; +import { datePlaceholderForLanguage } from "./i18n"; +import type { DelayedFollowup, Report } from "./types"; + +const store = new Map(); + +beforeEach(() => { + store.clear(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, value), + removeItem: (key: string) => store.delete(key), + clear: () => store.clear() + }); +}); describe("grafsegmenter", () => { it("lager striplet horisontal startlinje og stripler hull på minst tre dager", () => { @@ -19,3 +33,80 @@ describe("grafsegmenter", () => { expect(segments[2]).toMatchObject({ x1: 40, y1: 40, x2: 100, y2: 20 }); }); }); + +describe("oppfølgingsrespons", () => { + const report: Report = { + id: "rapport-1", + report_time: "08:00:00", + report_date: "2026-05-27", + created_at: "2026-05-27T06:00:00.000Z", + type: "work_report", + work_ability: 4, + energy_level: 4, + mental_clarity: 4, + symptom_burden: 2, + effort_strain: 2, + status: "kan_fortsette", + physical_energy: 4, + mental_energy: 4, + total_score_percent: 80, + task_types: [], + main_limitations: [], + helpful_accommodations: [] + }; + + const followup: DelayedFollowup = { + id: "oppfolging-1", + report_time: "10:00:00", + report_date: "2026-05-28", + created_at: "2026-05-28T08:00:00.000Z", + type: "delayed_followup", + parent_report_id: "rapport-1", + worse_than_before: "mye", + delayed_symptoms: [], + recovery_status: "ikke_tilbake_til_baseline" + }; + + it("beregner visualisert ettereffekt uten å endre parent-score", () => { + expect(calculateFollowupEffectScore(80, "mye", "ikke_tilbake_til_baseline")).toBe(30); + expect(calculateFollowupEffectScore(80, "nei", "tilbake_til_baseline")).toBe(85); + }); + + it("lager responsmodell på faktisk oppfølgingstidspunkt med parent-kobling", () => { + const [point] = buildFollowupResponsePoints([report, followup]); + + expect(point.parent.id).toBe("rapport-1"); + expect(point.followup.id).toBe("oppfolging-1"); + expect(point.parentScore).toBe(80); + expect(point.effectScore).toBe(30); + expect(point.offsetHours).toBe(26); + }); + + it("bruker engelsk ukedag når språk er engelsk", () => { + localStorage.setItem("arbeidspuls.language", "en"); + + expect(formatDateTime(report)).toContain("Wednesday"); + + localStorage.setItem("arbeidspuls.language", "nb-NO"); + expect(formatDateTime(report)).toContain("Onsdag"); + }); + + it("lager arbeidstidsband og håndterer arbeidstid over midnatt", () => { + const daytime = { ...report, id: "rapport-dag", work_start_time: "08:00", work_end_time: "10:00" }; + const overnight = { ...report, id: "rapport-natt", work_start_time: "23:00", work_end_time: "01:00" }; + const missing = { ...report, id: "rapport-uten", work_start_time: undefined, work_end_time: "12:00" }; + + const bands = buildWorkTimeBands([daytime, overnight, missing]); + + expect(bands).toHaveLength(2); + expect(bands[0].reportId).toBe("rapport-dag"); + expect(bands[0].endAt - bands[0].startAt).toBe(2 * 60 * 60 * 1000); + expect(bands[1].reportId).toBe("rapport-natt"); + expect(bands[1].endAt - bands[1].startAt).toBe(2 * 60 * 60 * 1000); + }); + + it("har språktilpassede datoplaceholdere for graffilter", () => { + expect(datePlaceholderForLanguage("nb-NO")).toBe("dd/mm/åååå"); + expect(datePlaceholderForLanguage("en")).toBe("dd/mm/yyyy"); + }); +}); diff --git a/src/i18n.tsx b/src/i18n.tsx index d46276a..5528a59 100644 --- a/src/i18n.tsx +++ b/src/i18n.tsx @@ -118,7 +118,17 @@ const nb = { saved: "Oppfølging lagret lokalt.", saveFollowup: "Lagre oppfølging", orphan: "Oppfølging uten valgt hovedrapport", - worsePrefix: "verre" + worsePrefix: "verre", + latestOnly: "Oppfølging kan bare opprettes for nyeste rapport." + }, + edit: { + edit: "Rediger", + reportIntro: "Rediger rapporten. Redigering er bare mulig innen 24 timer etter opprettelse.", + followupIntro: "Rediger oppfølgingen. Redigering er bare mulig innen 24 timer etter opprettelse.", + saveChanges: "Lagre endringer", + saved: "Endringer lagret lokalt.", + cancelEdit: "Avbryt redigering", + expired: "Redigering er bare mulig innen 24 timer etter opprettelse." }, overview: { noReports: "Ingen rapporter er lagret ennå.", @@ -134,6 +144,9 @@ const nb = { dashedLine: "Striplet linje betyr at det er 3 dager eller mer mellom rapportene.", from: "Fra", to: "Til", + fromDate: "Fra dato", + toDate: "Til dato", + datePlaceholder: "dd/mm/åååå", all: "Alt", week: "1 uke", month: "1 måned", @@ -258,6 +271,7 @@ const nb = { followupId: "Oppfølgings-ID", connectedReport: "Tilknyttet rapport", created: "Opprettet", + updated: "Oppdatert", reportDate: "Rapportdato", reportTime: "Rapporttid", integrityStatus: "Integritetsstatus", @@ -292,7 +306,16 @@ const nb = { noPoint: "Ingen rapportpunkt valgt", noPointHelp: "Hold musepekeren nær et punkt for å se faktiske rapportverdier.", nearestReport: "Nærmeste rapport", - chartLabel: "Fullskjerm trendgraf for rapporter" + chartLabel: "Fullskjerm trendgraf for rapporter", + workTime: "Arbeidstid", + workTimeLegend: "Skyggefelt viser registrert arbeidstid", + workTimeTooltip: "Registrert arbeidstid", + followupResponse: "Oppfølgingsrespons", + followupResponseHelp: "Viser oppfølginger på faktisk tidspunkt og visualiserer ettereffekt etter arbeidsøkten.", + followupResponseDisclaimer: "Dette er en visuell ettereffektindikator, ikke en ny totalscore.", + noFollowups: "Ingen oppfølginger for valgt rapport.", + followupDelay: "Forsinkelse: {hours} timer", + followupEffect: "Ettereffekt: {score}% fra rapportens {parent}%" }, scoring: { disclaimer: @@ -397,7 +420,17 @@ const en: TranslationTree = { saved: "Follow-up saved locally.", saveFollowup: "Save follow-up", orphan: "Follow-up without selected main report", - worsePrefix: "worse" + worsePrefix: "worse", + latestOnly: "Follow-up can only be created for the latest report." + }, + edit: { + edit: "Edit", + reportIntro: "Edit the report. Editing is only available within 24 hours after creation.", + followupIntro: "Edit the follow-up. Editing is only available within 24 hours after creation.", + saveChanges: "Save changes", + saved: "Changes saved locally.", + cancelEdit: "Cancel editing", + expired: "Editing is only available within 24 hours after creation." }, overview: { noReports: "No reports have been saved yet.", @@ -413,6 +446,9 @@ const en: TranslationTree = { dashedLine: "A dashed line means there are 3 days or more between reports.", from: "From", to: "To", + fromDate: "From date", + toDate: "To date", + datePlaceholder: "dd/mm/yyyy", all: "All", week: "1 week", month: "1 month", @@ -536,6 +572,7 @@ const en: TranslationTree = { followupId: "Follow-up ID", connectedReport: "Connected report", created: "Created", + updated: "Updated", reportDate: "Report date", reportTime: "Report time", integrityStatus: "Integrity status", @@ -570,7 +607,16 @@ const en: TranslationTree = { noPoint: "No report point selected", noPointHelp: "Hover near a point to see actual report values.", nearestReport: "Nearest report", - chartLabel: "Fullscreen trend chart for reports" + chartLabel: "Fullscreen trend chart for reports", + workTime: "Work time", + workTimeLegend: "Shaded areas show registered work time", + workTimeTooltip: "Registered work time", + followupResponse: "Follow-up response", + followupResponseHelp: "Shows follow-ups at their actual time and visualizes after-effect from the work session.", + followupResponseDisclaimer: "This is a visual after-effect indicator, not a new total score.", + noFollowups: "No follow-ups for the selected report.", + followupDelay: "Delay: {hours} hours", + followupEffect: "After-effect: {score}% from report {parent}%" }, scoring: { disclaimer: @@ -795,6 +841,10 @@ export function translateScoreBandDescription(band: { label: string; description return key ? descriptions[key] : band.description; } +export function datePlaceholderForLanguage(language: Language) { + return readTranslation(language, "overview.datePlaceholder"); +} + function readStoredLanguage(): Language { const stored = localStorage.getItem(LANGUAGE_KEY); return stored === "en" || stored === "nb-NO" ? stored : "nb-NO"; diff --git a/src/storage.test.ts b/src/storage.test.ts index dfbf369..9dee038 100644 --- a/src/storage.test.ts +++ b/src/storage.test.ts @@ -1,6 +1,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { exportReports, importReportsFromJson, listReports, previewImportReports, setDemoModeEnabled } from "./storage"; -import type { ExportPayload, Report } from "./types"; +import { + createFollowup, + exportReports, + importReportsFromJson, + listReports, + previewImportReports, + setDemoModeEnabled, + updateFollowup, + updateReport +} from "./storage"; +import type { DelayedFollowup, ExportPayload, Report } from "./types"; const store = new Map(); @@ -88,4 +97,105 @@ describe("ordinær brukerimport", () => { expect(listReports(true).some((entry) => entry.id === "demo-arbeidspuls-test" && entry.demo)).toBe(true); expect(listReports()).toHaveLength(0); }); + + it("tillater redigering innen 24 timer og beholder ID", () => { + const fresh = { ...report, created_at: new Date().toISOString() }; + importReportsFromJson(JSON.stringify(payload([fresh]))); + + const updated = updateReport(fresh.id, { + workplace: "Nytt sted", + work_ability: 5, + energy_level: 3, + mental_clarity: 4, + symptom_burden: 2, + effort_strain: 3, + status: "kan_fortsette", + task_types: [], + main_limitations: [], + helpful_accommodations: [], + note: "Oppdatert" + }); + + expect(updated.id).toBe(fresh.id); + expect(updated.created_at).toBe(fresh.created_at); + expect(updated.updated_at).toBeTruthy(); + expect(updated.integrity?.verification_status).toBe("valid"); + const stored = listReports()[0]; + expect(stored.type).toBe("work_report"); + expect(stored.type === "work_report" ? stored.work_ability : null).toBe(5); + }); + + it("blokkerer redigering etter 24 timer", () => { + const old = { ...report, created_at: "2020-01-01T00:00:00.000Z" }; + importReportsFromJson(JSON.stringify(payload([old]))); + + expect(() => + updateReport(old.id, { + work_ability: 5, + energy_level: 3, + mental_clarity: 4, + symptom_burden: 2, + effort_strain: 3, + status: "kan_fortsette", + task_types: [], + main_limitations: [], + helpful_accommodations: [], + note: "" + }) + ).toThrow("24 timer"); + }); + + it("tillater bare nyeste rapport som parent for ny oppfølging", () => { + const older = { ...report, id: "rapport-eldre", created_at: "2026-05-26T06:00:00.000Z", report_date: "2026-05-26" }; + const newer = { ...report, id: "rapport-nyere", created_at: "2026-05-28T06:00:00.000Z", report_date: "2026-05-28" }; + importReportsFromJson(JSON.stringify(payload([older, newer]))); + + expect(() => + createFollowup({ + parent_report_id: older.id, + related_report_id: older.id, + worse_than_before: "litt", + delayed_symptoms: [], + note: "" + }) + ).toThrow("nyeste rapport"); + + const saved = createFollowup({ + parent_report_id: newer.id, + related_report_id: newer.id, + worse_than_before: "litt", + delayed_symptoms: [], + note: "" + }); + expect(saved.parent_report_id).toBe(newer.id); + }); + + it("redigerer oppfølging innen 24 timer uten å endre ID", () => { + const freshReport = { ...report, id: "rapport-fersk", created_at: new Date().toISOString(), report_date: "2026-05-29" }; + const followup: DelayedFollowup = { + id: "oppfolging-fersk", + created_at: new Date().toISOString(), + report_date: "2026-05-29", + report_time: "10:00:00", + type: "delayed_followup", + parent_report_id: freshReport.id, + worse_than_before: "litt", + delayed_symptoms: [], + note: "før" + }; + importReportsFromJson(JSON.stringify({ ...payload([freshReport]), reports: [freshReport, followup] })); + + const updated = updateFollowup(followup.id, { + parent_report_id: freshReport.id, + related_report_id: freshReport.id, + worse_than_before: "mye", + delayed_symptoms: ["fatigue"], + note: "etter" + }); + + expect(updated.id).toBe(followup.id); + expect(updated.created_at).toBe(followup.created_at); + expect(updated.worse_than_before).toBe("mye"); + expect(updated.integrity?.verification_status).toBe("valid"); + }); }); diff --git a/src/storage.ts b/src/storage.ts index 4e46eca..0deb0cd 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -127,6 +127,7 @@ export function createFollowup(draft: FollowupDraft, demoMode = false): DelayedF if (!draft.worse_than_before) { throw new Error("Oppfølgingen mangler svar på om du er verre enn før økten."); } + assertLatestParentIfProvided(draft.parent_report_id || draft.related_report_id, demoMode); const followup: DelayedFollowup = { ...baseFields(), @@ -145,6 +146,83 @@ export function createFollowup(draft: FollowupDraft, demoMode = false): DelayedF return signedFollowup; } +export function updateReport(id: string, draft: ReportDraft, demoMode = false): Report { + const mode = modeFromDemoFlag(demoMode); + const entries = readEntries(mode); + const existing = entries.find((entry): entry is Report => entry.id === id && entry.type === "work_report"); + if (!existing) throw new Error("Rapporten finnes ikke."); + assertEditable(existing); + if ( + !draft.work_ability || + !draft.energy_level || + !draft.mental_clarity || + !draft.symptom_burden || + !draft.effort_strain || + !draft.status + ) { + throw new Error("Rapporten mangler påkrevde felt."); + } + const updated: Report = { + ...existing, + workplace: draft.workplace?.trim() || undefined, + work_start_time: draft.work_start_time || undefined, + work_end_time: draft.work_end_time || undefined, + work_ability: draft.work_ability, + energy_level: draft.energy_level, + mental_clarity: draft.mental_clarity, + symptom_burden: draft.symptom_burden, + effort_strain: draft.effort_strain, + status: draft.status, + physical_energy: draft.physical_energy_detail ?? draft.energy_level, + mental_energy: draft.mental_energy_detail ?? draft.energy_level, + physical_energy_detail: draft.physical_energy_detail, + mental_energy_detail: draft.mental_energy_detail, + perceived_productivity: draft.perceived_productivity, + task_completion: draft.task_completion, + task_types: draft.task_types, + main_limitations: draft.main_limitations, + helpful_accommodations: draft.helpful_accommodations, + note: draft.note.trim() || undefined, + updated_at: new Date().toISOString() + }; + updated.total_score_percent = calculateReportScore(updated) ?? undefined; + const signed = demoMode ? markDemoEntry(attachIntegrity(updated)) as Report : attachIntegrity(stripDemoMarker(updated)); + writeEntries(entries.map((entry) => (entry.id === id ? signed : entry)).sort((a, b) => getEntryTime(b) - getEntryTime(a)), mode); + return signed; +} + +export function updateFollowup(id: string, draft: FollowupDraft, demoMode = false): DelayedFollowup { + const mode = modeFromDemoFlag(demoMode); + const entries = readEntries(mode); + const existing = entries.find((entry): entry is DelayedFollowup => entry.id === id && entry.type === "delayed_followup"); + if (!existing) throw new Error("Oppfølgingen finnes ikke."); + assertEditable(existing); + if (!draft.worse_than_before) throw new Error("Oppfølgingen mangler svar på om du er verre enn før økten."); + const parentId = draft.parent_report_id || draft.related_report_id || undefined; + if (parentId !== (existing.parent_report_id ?? existing.related_report_id)) { + assertLatestParentIfProvided(parentId, demoMode); + } + const updated: DelayedFollowup = { + ...existing, + parent_report_id: parentId, + related_report_id: parentId, + worse_than_before: draft.worse_than_before, + delayed_symptoms: draft.delayed_symptoms, + recovery_status: draft.recovery_status, + note: draft.note.trim() || undefined, + updated_at: new Date().toISOString() + }; + const signed = demoMode ? markDemoEntry(attachIntegrity(updated)) as DelayedFollowup : attachIntegrity(stripDemoMarker(updated)); + writeEntries(entries.map((entry) => (entry.id === id ? signed : entry)).sort((a, b) => getEntryTime(b) - getEntryTime(a)), mode); + return signed; +} + +export function isEditableWithin24Hours(entry: StoredEntry, now = new Date()) { + const created = new Date(entry.created_at).getTime(); + if (!Number.isFinite(created)) return false; + return now.getTime() - created <= 24 * 60 * 60 * 1000; +} + export function listReports(demoMode = false): StoredEntry[] { if (demoMode) ensureDemoData(); return readEntries(modeFromDemoFlag(demoMode)).sort((a, b) => getEntryTime(b) - getEntryTime(a)); @@ -154,6 +232,12 @@ export function getLastWorkReport(demoMode = false): Report | undefined { return listReports(demoMode).find((entry): entry is Report => entry.type === "work_report"); } +export function getLatestWorkReport(demoMode = false): Report | undefined { + return listReports(demoMode) + .filter((entry): entry is Report => entry.type === "work_report") + .sort((a, b) => getEntryTime(b) - getEntryTime(a))[0]; +} + export function getReport(id: string, demoMode = false): StoredEntry | undefined { return readEntries(modeFromDemoFlag(demoMode)).find((entry) => entry.id === id); } @@ -312,6 +396,20 @@ function normalizeStoredEntry(entry: StoredEntry): StoredEntry { }; } +function assertEditable(entry: StoredEntry) { + if (!isEditableWithin24Hours(entry)) { + throw new Error("Redigering er bare mulig innen 24 timer etter opprettelse."); + } +} + +function assertLatestParentIfProvided(parentId: string | undefined, demoMode: boolean) { + if (!parentId) return; + const latest = getLatestWorkReport(demoMode); + if (latest && latest.id !== parentId) { + throw new Error("Oppfølging kan bare opprettes for nyeste rapport."); + } +} + export function isDemoEntry(entry: Partial) { return entry.demo === true || typeof entry.id === "string" && entry.id.startsWith("demo-arbeidspuls"); } diff --git a/src/styles.css b/src/styles.css index cdee224..5eb5c7c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -788,6 +788,12 @@ textarea { font-weight: 800; } +.date-range small { + color: #65706e; + font-size: 0.74rem; + font-weight: 700; +} + .trend-head { display: flex; align-items: flex-start; @@ -1055,17 +1061,41 @@ textarea { min-height: 0; } +.fullscreen-graph-body { + grid-template-rows: auto minmax(0, 1fr); + gap: 0.75rem; +} + +.fullscreen-graph-help { + min-height: 1.4rem; +} + .graph-inspection-layout { display: grid; - grid-template-columns: minmax(0, 1fr) 240px; + grid-template-columns: minmax(0, 1fr) 280px; gap: 12px; min-height: 0; + align-items: start; +} + +.graph-workspace { + display: grid; + gap: 10px; + grid-template-rows: minmax(420px, min(58vh, 620px)) minmax(96px, auto); + min-width: 0; +} + +.fullscreen-graph-chart-shell { + min-height: 420px; + height: min(58vh, 620px); + position: relative; } .fullscreen-chart { width: 100%; - min-height: 380px; - max-height: calc(100vh - 310px); + height: 100%; + min-height: 0; + max-height: none; border: 1px solid #e4e8e5; border-radius: 8px; background: #fffefa; @@ -1125,6 +1155,103 @@ textarea { font-weight: 900; } +.graph-followups { + display: grid; + gap: 8px; + border-top: 1px solid #d9ded8; + padding-top: 8px; +} + +.graph-followups h4, +.graph-followups p { + margin: 0; +} + +.graph-followups h4 { + font-size: 0.9rem; +} + +.graph-followups div { + display: grid; + gap: 3px; + border: 1px solid #d9ded8; + border-radius: 8px; + background: #fffefa; + padding: 8px; +} + +.graph-followups span { + color: #4d5b58; + font-size: 0.82rem; + font-weight: 750; +} + +.graph-selected-pills { + display: grid; + align-content: start; + gap: 8px; + min-height: 96px; + contain: layout; +} + +.graph-selected-pills div { + display: grid; + gap: 5px; +} + +.graph-selected-pills strong { + color: #35413f; + font-size: 0.82rem; +} + +.graph-selected-pills span { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.graph-status-pill { + align-items: center; + border: 1px solid #cdd8d5; + border-radius: 999px; + background: #fffefa; + color: #235b5e; + display: inline-flex; + font-style: normal; + font-size: 0.78rem; + font-weight: 850; + justify-content: center; + line-height: 1.15; + min-height: 1.6rem; + padding: 5px 8px; + white-space: nowrap; +} + +.followup-link { + stroke: #b2642a; + stroke-dasharray: 5 5; + stroke-width: 1.4; +} + +.followup-effect-bar { + stroke: #b2642a; + stroke-linecap: round; + stroke-width: 3; +} + +.followup-response-point { + fill: #fffefa; + stroke: #b2642a; + stroke-width: 2.4; +} + +.work-time-band { + fill: #b2642a; + opacity: 0.12; + stroke: #b2642a; + stroke-opacity: 0.16; +} + .confirm-modal { width: min(420px, 100%); display: grid; @@ -1162,6 +1289,16 @@ textarea { padding: 14px; } +.edit-zone { + display: grid; + gap: 8px; + margin-top: 18px; + border: 1px solid #cdd8d5; + border-radius: 8px; + background: #f7faf8; + padding: 14px; +} + .danger-zone h3, .danger-zone p { margin: 0; @@ -1786,8 +1923,16 @@ textarea { } .fullscreen-chart { - min-height: 300px; - max-height: none; + min-height: 0; + } + + .graph-workspace, + .fullscreen-graph-chart-shell { + min-height: 320px; + } + + .graph-workspace { + grid-template-rows: minmax(320px, 48vh) minmax(96px, auto); } } diff --git a/src/types.ts b/src/types.ts index 1c12ad2..891f4ef 100644 --- a/src/types.ts +++ b/src/types.ts @@ -77,6 +77,7 @@ export interface Report { report_time: string; report_date: string; created_at: string; + updated_at?: string; type: "work_report"; workplace?: string; work_start_time?: string; @@ -127,6 +128,7 @@ export interface DelayedFollowup { report_time: string; report_date: string; created_at: string; + updated_at?: string; type: "delayed_followup"; parent_report_id?: string; related_report_id?: string;