release 1.1.2 graph layout and work time
This commit is contained in:
parent
76ec48bfef
commit
4c351d7242
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "arbeidspuls",
|
"name": "arbeidspuls",
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "arbeidspuls",
|
"name": "arbeidspuls",
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lucide-react": "^0.475.0",
|
"lucide-react": "^0.475.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "arbeidspuls",
|
"name": "arbeidspuls",
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
const CACHE_NAME = "arbeidspuls-v6";
|
const CACHE_NAME = "arbeidspuls-v7";
|
||||||
const ASSETS = ["/", "/index.html", "/manifest.webmanifest", "/icon.svg"];
|
const ASSETS = ["/", "/index.html", "/manifest.webmanifest", "/icon.svg"];
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
|
|||||||
670
src/App.tsx
670
src/App.tsx
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,19 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createLineSegments } from "./App";
|
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", () => {
|
describe("grafsegmenter", () => {
|
||||||
it("lager striplet horisontal startlinje og stripler hull på minst tre dager", () => {
|
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 });
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
58
src/i18n.tsx
58
src/i18n.tsx
@ -118,7 +118,17 @@ const nb = {
|
|||||||
saved: "Oppfølging lagret lokalt.",
|
saved: "Oppfølging lagret lokalt.",
|
||||||
saveFollowup: "Lagre oppfølging",
|
saveFollowup: "Lagre oppfølging",
|
||||||
orphan: "Oppfølging uten valgt hovedrapport",
|
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: {
|
overview: {
|
||||||
noReports: "Ingen rapporter er lagret ennå.",
|
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.",
|
dashedLine: "Striplet linje betyr at det er 3 dager eller mer mellom rapportene.",
|
||||||
from: "Fra",
|
from: "Fra",
|
||||||
to: "Til",
|
to: "Til",
|
||||||
|
fromDate: "Fra dato",
|
||||||
|
toDate: "Til dato",
|
||||||
|
datePlaceholder: "dd/mm/åååå",
|
||||||
all: "Alt",
|
all: "Alt",
|
||||||
week: "1 uke",
|
week: "1 uke",
|
||||||
month: "1 måned",
|
month: "1 måned",
|
||||||
@ -258,6 +271,7 @@ const nb = {
|
|||||||
followupId: "Oppfølgings-ID",
|
followupId: "Oppfølgings-ID",
|
||||||
connectedReport: "Tilknyttet rapport",
|
connectedReport: "Tilknyttet rapport",
|
||||||
created: "Opprettet",
|
created: "Opprettet",
|
||||||
|
updated: "Oppdatert",
|
||||||
reportDate: "Rapportdato",
|
reportDate: "Rapportdato",
|
||||||
reportTime: "Rapporttid",
|
reportTime: "Rapporttid",
|
||||||
integrityStatus: "Integritetsstatus",
|
integrityStatus: "Integritetsstatus",
|
||||||
@ -292,7 +306,16 @@ const nb = {
|
|||||||
noPoint: "Ingen rapportpunkt valgt",
|
noPoint: "Ingen rapportpunkt valgt",
|
||||||
noPointHelp: "Hold musepekeren nær et punkt for å se faktiske rapportverdier.",
|
noPointHelp: "Hold musepekeren nær et punkt for å se faktiske rapportverdier.",
|
||||||
nearestReport: "Nærmeste rapport",
|
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: {
|
scoring: {
|
||||||
disclaimer:
|
disclaimer:
|
||||||
@ -397,7 +420,17 @@ const en: TranslationTree = {
|
|||||||
saved: "Follow-up saved locally.",
|
saved: "Follow-up saved locally.",
|
||||||
saveFollowup: "Save follow-up",
|
saveFollowup: "Save follow-up",
|
||||||
orphan: "Follow-up without selected main report",
|
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: {
|
overview: {
|
||||||
noReports: "No reports have been saved yet.",
|
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.",
|
dashedLine: "A dashed line means there are 3 days or more between reports.",
|
||||||
from: "From",
|
from: "From",
|
||||||
to: "To",
|
to: "To",
|
||||||
|
fromDate: "From date",
|
||||||
|
toDate: "To date",
|
||||||
|
datePlaceholder: "dd/mm/yyyy",
|
||||||
all: "All",
|
all: "All",
|
||||||
week: "1 week",
|
week: "1 week",
|
||||||
month: "1 month",
|
month: "1 month",
|
||||||
@ -536,6 +572,7 @@ const en: TranslationTree = {
|
|||||||
followupId: "Follow-up ID",
|
followupId: "Follow-up ID",
|
||||||
connectedReport: "Connected report",
|
connectedReport: "Connected report",
|
||||||
created: "Created",
|
created: "Created",
|
||||||
|
updated: "Updated",
|
||||||
reportDate: "Report date",
|
reportDate: "Report date",
|
||||||
reportTime: "Report time",
|
reportTime: "Report time",
|
||||||
integrityStatus: "Integrity status",
|
integrityStatus: "Integrity status",
|
||||||
@ -570,7 +607,16 @@ const en: TranslationTree = {
|
|||||||
noPoint: "No report point selected",
|
noPoint: "No report point selected",
|
||||||
noPointHelp: "Hover near a point to see actual report values.",
|
noPointHelp: "Hover near a point to see actual report values.",
|
||||||
nearestReport: "Nearest report",
|
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: {
|
scoring: {
|
||||||
disclaimer:
|
disclaimer:
|
||||||
@ -795,6 +841,10 @@ export function translateScoreBandDescription(band: { label: string; description
|
|||||||
return key ? descriptions[key] : band.description;
|
return key ? descriptions[key] : band.description;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function datePlaceholderForLanguage(language: Language) {
|
||||||
|
return readTranslation(language, "overview.datePlaceholder");
|
||||||
|
}
|
||||||
|
|
||||||
function readStoredLanguage(): Language {
|
function readStoredLanguage(): Language {
|
||||||
const stored = localStorage.getItem(LANGUAGE_KEY);
|
const stored = localStorage.getItem(LANGUAGE_KEY);
|
||||||
return stored === "en" || stored === "nb-NO" ? stored : "nb-NO";
|
return stored === "en" || stored === "nb-NO" ? stored : "nb-NO";
|
||||||
|
|||||||
@ -1,6 +1,15 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { exportReports, importReportsFromJson, listReports, previewImportReports, setDemoModeEnabled } from "./storage";
|
import {
|
||||||
import type { ExportPayload, Report } from "./types";
|
createFollowup,
|
||||||
|
exportReports,
|
||||||
|
importReportsFromJson,
|
||||||
|
listReports,
|
||||||
|
previewImportReports,
|
||||||
|
setDemoModeEnabled,
|
||||||
|
updateFollowup,
|
||||||
|
updateReport
|
||||||
|
} from "./storage";
|
||||||
|
import type { DelayedFollowup, ExportPayload, Report } from "./types";
|
||||||
|
|
||||||
const store = new Map<string, string>();
|
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(true).some((entry) => entry.id === "demo-arbeidspuls-test" && entry.demo)).toBe(true);
|
||||||
expect(listReports()).toHaveLength(0);
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -127,6 +127,7 @@ export function createFollowup(draft: FollowupDraft, demoMode = false): DelayedF
|
|||||||
if (!draft.worse_than_before) {
|
if (!draft.worse_than_before) {
|
||||||
throw new Error("Oppfølgingen mangler svar på om du er verre enn før økten.");
|
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 = {
|
const followup: DelayedFollowup = {
|
||||||
...baseFields(),
|
...baseFields(),
|
||||||
@ -145,6 +146,83 @@ export function createFollowup(draft: FollowupDraft, demoMode = false): DelayedF
|
|||||||
return signedFollowup;
|
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[] {
|
export function listReports(demoMode = false): StoredEntry[] {
|
||||||
if (demoMode) ensureDemoData();
|
if (demoMode) ensureDemoData();
|
||||||
return readEntries(modeFromDemoFlag(demoMode)).sort((a, b) => getEntryTime(b) - getEntryTime(a));
|
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");
|
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 {
|
export function getReport(id: string, demoMode = false): StoredEntry | undefined {
|
||||||
return readEntries(modeFromDemoFlag(demoMode)).find((entry) => entry.id === id);
|
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>) {
|
export function isDemoEntry(entry: Partial<StoredEntry>) {
|
||||||
return entry.demo === true || typeof entry.id === "string" && entry.id.startsWith("demo-arbeidspuls");
|
return entry.demo === true || typeof entry.id === "string" && entry.id.startsWith("demo-arbeidspuls");
|
||||||
}
|
}
|
||||||
|
|||||||
155
src/styles.css
155
src/styles.css
@ -788,6 +788,12 @@ textarea {
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.date-range small {
|
||||||
|
color: #65706e;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.trend-head {
|
.trend-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@ -1055,17 +1061,41 @@ textarea {
|
|||||||
min-height: 0;
|
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 {
|
.graph-inspection-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 240px;
|
grid-template-columns: minmax(0, 1fr) 280px;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-height: 0;
|
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 {
|
.fullscreen-chart {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 380px;
|
height: 100%;
|
||||||
max-height: calc(100vh - 310px);
|
min-height: 0;
|
||||||
|
max-height: none;
|
||||||
border: 1px solid #e4e8e5;
|
border: 1px solid #e4e8e5;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #fffefa;
|
background: #fffefa;
|
||||||
@ -1125,6 +1155,103 @@ textarea {
|
|||||||
font-weight: 900;
|
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 {
|
.confirm-modal {
|
||||||
width: min(420px, 100%);
|
width: min(420px, 100%);
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -1162,6 +1289,16 @@ textarea {
|
|||||||
padding: 14px;
|
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 h3,
|
||||||
.danger-zone p {
|
.danger-zone p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -1786,8 +1923,16 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fullscreen-chart {
|
.fullscreen-chart {
|
||||||
min-height: 300px;
|
min-height: 0;
|
||||||
max-height: none;
|
}
|
||||||
|
|
||||||
|
.graph-workspace,
|
||||||
|
.fullscreen-graph-chart-shell {
|
||||||
|
min-height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-workspace {
|
||||||
|
grid-template-rows: minmax(320px, 48vh) minmax(96px, auto);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -77,6 +77,7 @@ export interface Report {
|
|||||||
report_time: string;
|
report_time: string;
|
||||||
report_date: string;
|
report_date: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
type: "work_report";
|
type: "work_report";
|
||||||
workplace?: string;
|
workplace?: string;
|
||||||
work_start_time?: string;
|
work_start_time?: string;
|
||||||
@ -127,6 +128,7 @@ export interface DelayedFollowup {
|
|||||||
report_time: string;
|
report_time: string;
|
||||||
report_date: string;
|
report_date: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
type: "delayed_followup";
|
type: "delayed_followup";
|
||||||
parent_report_id?: string;
|
parent_report_id?: string;
|
||||||
related_report_id?: string;
|
related_report_id?: string;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user