release 1.1.2 graph layout and work time

This commit is contained in:
Franz Rolfsvaag 2026-05-29 02:22:57 +02:00
parent 76ec48bfef
commit 4c351d7242
10 changed files with 1088 additions and 112 deletions

4
package-lock.json generated
View File

@ -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",

View File

@ -1,6 +1,6 @@
{
"name": "arbeidspuls",
"version": "1.1.1",
"version": "1.1.2",
"private": true,
"type": "module",
"scripts": {

View File

@ -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) => {

File diff suppressed because it is too large Load Diff

View File

@ -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<string, string>();
beforeEach(() => {
store.clear();
vi.stubGlobal("localStorage", {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, value),
removeItem: (key: string) => store.delete(key),
clear: () => store.clear()
});
});
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");
});
});

View File

@ -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";

View File

@ -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<string, string>();
@ -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");
});
});

View File

@ -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<StoredEntry>) {
return entry.demo === true || typeof entry.id === "string" && entry.id.startsWith("demo-arbeidspuls");
}

View File

@ -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);
}
}

View File

@ -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;