Bygg MVP av Guidad Felsökning under /felsokning

Körbar kärna enligt Master Prompt v1.0:

- Append-only händelselogg som enda sanningskälla (domain.ts);
  brief, tidsfördelning, överlämningstext och kundrapport är rena
  projektioner av loggen (projektioner.ts) med tester som låser att
  briefen alltid kan regenereras.
- Deterministisk metodikmotor (metodik.ts): en fråga i taget,
  vibrationsmetodik + generisk metodik vald utifrån felbeskrivningen.
  Framtida AI-integration ansluter i nastaSteg utan modelländringar.
- Flöde: identifiera objekt → bekräfta → felbeskrivning → guidad
  felsökning med foto, mätvärden, observationer och hypoteser
  (hypoteser märks alltid som ej verifierade).
- Tidredovisning med kategorier, inaktivitetsfråga efter 20 min,
  överlämning med ett klick, kundrapport med tidslinje och utskrift.
- Fabriksverktygs-UI: hög kontrast, stora knappar, få val per skärm.
- Lagring i localStorage i MVP:t; docs/MVP.md beskriver omfattning
  och medvetna avgränsningar.

Verifierat: 11 vitest-tester gröna, produktionsbygge ok, Playwright-
röktest genom hela flödet utan sidfel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-02 22:34:29 +00:00
parent e519d1f80d
commit 92fa8d6716
13 changed files with 1772 additions and 0 deletions
@@ -0,0 +1,161 @@
import { describe, expect, it } from "vitest";
import type { Arende, Handelse } from "../domain";
import { nyLoggPost } from "../domain";
import { VIBRATION_METODIK, nastaSteg, valjMetodik } from "../metodik";
import { brief, ejKontrollerat, tidsfordelning } from "../projektioner";
// Hjälpare: bygger ett ärende ur en lista (tidpunkt, händelse) —
// precis som produktionskoden är alla vyer rena funktioner av loggen.
function byggArende(poster: [string, Handelse][]): Arende {
return {
id: "test",
nummer: 1,
skapad: poster[0]?.[0] ?? "2026-08-02T08:00:00Z",
handelser: poster.map(([tidpunkt, handelse]) => nyLoggPost("Anna", handelse, tidpunkt)),
};
}
const OBJEKT: Handelse = {
typ: "objekt_identifierat",
objekt: {
typ: "Fordon",
identifierare: "ABC123",
identifieringsmetod: "Registreringsnummer",
beskrivning: "Volvo XC60 D4 2019",
kund: "Anders Svensson",
},
};
describe("metodikval", () => {
it("väljer vibrationsmetodiken när felbeskrivningen nämner vibration", () => {
expect(valjMetodik("Bilen vibrerar runt 88 km/h").id).toBe("vibration");
expect(valjMetodik("Motorn startar inte").id).toBe("generisk");
});
});
describe("nastaSteg", () => {
it("börjar med första symptomfrågan och går vidare i metodikens ordning", () => {
const arende = byggArende([
["2026-08-02T08:03:00Z", OBJEKT],
["2026-08-02T08:05:00Z", { typ: "felbeskrivning", text: "Bilen vibrerar runt 88 km/h" }],
]);
const steg = nastaSteg(arende, VIBRATION_METODIK);
expect(steg.klart).toBe(false);
expect(steg.steg.id).toBe("symptom");
expect(steg.fraga?.id).toBe("hastighetsberoende");
});
it("hoppar till nästa obesvarade fråga när en besvarats", () => {
const arende = byggArende([
["2026-08-02T08:03:00Z", OBJEKT],
[
"2026-08-02T08:06:00Z",
{ typ: "fraga_besvarad", stegId: "symptom", frageId: "hastighetsberoende", fraga: "…", svar: "Ja" },
],
]);
expect(nastaSteg(arende, VIBRATION_METODIK).fraga?.id).toBe("var");
});
it("är klart när alla frågor och kontroller är dokumenterade", () => {
const poster: [string, Handelse][] = [["2026-08-02T08:00:00Z", OBJEKT]];
for (const steg of VIBRATION_METODIK.steg) {
for (const fraga of steg.fragor ?? []) {
poster.push([
"2026-08-02T08:10:00Z",
{ typ: "fraga_besvarad", stegId: steg.id, frageId: fraga.id, fraga: fraga.text, svar: "Ja" },
]);
}
for (const kontroll of steg.kontroller ?? []) {
poster.push([
"2026-08-02T08:20:00Z",
{ typ: "kontroll_utford", stegId: steg.id, kontrollId: kontroll.id, text: kontroll.text },
]);
}
}
expect(nastaSteg(byggArende(poster), VIBRATION_METODIK).klart).toBe(true);
});
});
describe("ejKontrollerat", () => {
it("krymper när kontroller utförs och härleds helt ur loggen", () => {
const utan = byggArende([["2026-08-02T08:00:00Z", OBJEKT]]);
const alla = ejKontrollerat(utan, VIBRATION_METODIK);
expect(alla).toContain("Kontrollera lufttryck");
const med = byggArende([
["2026-08-02T08:00:00Z", OBJEKT],
[
"2026-08-02T08:15:00Z",
{ typ: "kontroll_utford", stegId: "kontroller", kontrollId: "lufttryck", text: "Kontrollera lufttryck" },
],
]);
const kvar = ejKontrollerat(med, VIBRATION_METODIK);
expect(kvar).not.toContain("Kontrollera lufttryck");
expect(kvar.length).toBe(alla.length - 1);
});
});
describe("tidsfordelning", () => {
it("attribuerar intervall till aktiv kategori och räknar inte paus i totalen", () => {
const arende = byggArende([
["2026-08-02T08:00:00Z", OBJEKT],
// 30 min aktiv felsökning (standardkategori)
["2026-08-02T08:30:00Z", { typ: "kategori_byte", kategori: "paus" }],
// 15 min paus
["2026-08-02T08:45:00Z", { typ: "kategori_byte", kategori: "provkorning" }],
// 20 min provkörning
["2026-08-02T09:05:00Z", { typ: "arende_avslutat" }],
]);
const { totalMs, perKategori } = tidsfordelning(arende);
expect(perKategori.aktiv_felsokning).toBe(30 * 60000);
expect(perKategori.paus).toBe(15 * 60000);
expect(perKategori.provkorning).toBe(20 * 60000);
expect(totalMs).toBe(50 * 60000);
});
it("räknar pågående tid fram till 'nu' för öppna ärenden", () => {
const arende = byggArende([["2026-08-02T08:00:00Z", OBJEKT]]);
const { totalMs } = tidsfordelning(arende, "2026-08-02T08:10:00Z");
expect(totalMs).toBe(10 * 60000);
});
});
describe("brief", () => {
const arende = byggArende([
["2026-08-02T08:03:00Z", OBJEKT],
["2026-08-02T08:05:00Z", { typ: "felbeskrivning", text: "Bilen vibrerar runt 88 km/h" }],
[
"2026-08-02T08:11:00Z",
{ typ: "kontroll_utford", stegId: "kontroller", kontrollId: "lufttryck", text: "Kontrollera lufttryck", resultat: "2,4 bar" },
],
["2026-08-02T08:18:00Z", { typ: "matvarde", beskrivning: "Matningsspänning", varde: "13,9", enhet: "V" }],
["2026-08-02T08:20:00Z", { typ: "observation", text: "Höger framdäck visar ojämnt slitage" }],
["2026-08-02T08:22:00Z", { typ: "hypotes", text: "Obalans i höger framhjul", niva: "lag" }],
]);
it("sammanställer utfört, observerat, ej kontrollerat och nästa steg ur loggen", () => {
const b = brief(arende, VIBRATION_METODIK, "2026-08-02T08:30:00Z");
expect(b.objekt?.identifierare).toBe("ABC123");
expect(b.felbeskrivning).toBe("Bilen vibrerar runt 88 km/h");
expect(b.utfordaKontroller.map((k) => k.text)).toContain("Kontrollera lufttryck");
expect(b.observationer).toContain("Höger framdäck visar ojämnt slitage");
expect(b.observationer).toContain("Matningsspänning: 13,9 V");
expect(b.ejKontrollerat).toContain("Kontrollera hjulbalansering");
expect(b.ejKontrollerat).not.toContain("Kontrollera lufttryck");
expect(b.rekommenderatNastaSteg[0]).toContain("hastighetsberoende");
expect(b.totalArbetstid).toBe("27 min");
});
it("skiljer hypoteser från fakta och markerar felorsak som ej verifierad", () => {
const b = brief(arende, VIBRATION_METODIK, "2026-08-02T08:30:00Z");
expect(b.hypoteser).toEqual([{ text: "Obalans i höger framhjul", niva: "lag" }]);
expect(b.observationer).not.toContain("Obalans i höger framhjul");
expect(b.tillforlitlighet.some((r) => r.niva === "medel" && /inte verifierad/.test(r.text))).toBe(true);
});
it("är regenererbar: samma logg ger alltid samma brief", () => {
const b1 = brief(arende, VIBRATION_METODIK, "2026-08-02T08:30:00Z");
const b2 = brief(arende, VIBRATION_METODIK, "2026-08-02T08:30:00Z");
expect(b2).toEqual(b1);
});
});
+112
View File
@@ -0,0 +1,112 @@
// Domänmodell för Guidad Felsökning.
// Händelseloggen är append-only och är ärendets enda sanningskälla.
// Allt annat (brief, tidrapport, kundrapport) är projektioner av loggen.
export type Tillforlitlighet = "hog" | "medel" | "lag";
export const TILLFORLITLIGHET_LABEL: Record<Tillforlitlighet, string> = {
hog: "🟢 Hög",
medel: "🟡 Medel",
lag: "🔴 Låg",
};
export type TidKategori =
| "aktiv_felsokning"
| "vantetid"
| "administration"
| "reservdelssokning"
| "provkorning"
| "kundkontakt"
| "paus";
export const TIDKATEGORI_LABEL: Record<TidKategori, string> = {
aktiv_felsokning: "Aktiv felsökning",
vantetid: "Väntetid",
administration: "Administration",
reservdelssokning: "Reservdelssökning",
provkorning: "Provkörning",
kundkontakt: "Kundkontakt",
paus: "Paus",
};
export interface Objekt {
typ: string; // t.ex. "Fordon", "Industrimaskin"
identifierare: string; // reg.nr, VIN, serienummer …
identifieringsmetod: string; // "Registreringsnummer", "VIN", "Serienummer", "Manuell inmatning"
beskrivning: string; // t.ex. "Volvo XC60 D4 2019"
kund?: string;
}
// Varje händelse är en av dessa typer. Ingen händelse ändras eller tas bort.
export type Handelse =
| { typ: "objekt_identifierat"; objekt: Objekt }
| { typ: "felbeskrivning"; text: string }
| { typ: "fraga_besvarad"; stegId: string; frageId: string; fraga: string; svar: string }
| { typ: "kontroll_utford"; stegId: string; kontrollId: string; text: string; resultat?: string }
| { typ: "observation"; text: string }
| { typ: "matvarde"; beskrivning: string; varde: string; enhet?: string }
| { typ: "hypotes"; text: string; niva: Exclude<Tillforlitlighet, "hog"> }
| { typ: "foto"; beskrivning: string; dataUrl: string }
| { typ: "kommentar"; text: string }
| { typ: "kategori_byte"; kategori: TidKategori }
| { typ: "inaktivitet_forklarad"; text: string; minuter: number }
| { typ: "overlamning"; fran: string; till?: string }
| { typ: "arende_avslutat" };
export interface LoggPost {
id: string;
tidpunkt: string; // ISO 8601
anvandare: string;
handelse: Handelse;
}
export interface Arende {
id: string;
nummer: number;
skapad: string; // ISO 8601
handelser: LoggPost[];
}
let raknare = 0;
export function nyLoggPost(anvandare: string, handelse: Handelse, tidpunkt?: string): LoggPost {
raknare += 1;
return {
id: `${Date.now().toString(36)}-${raknare.toString(36)}`,
tidpunkt: tidpunkt ?? new Date().toISOString(),
anvandare,
handelse,
};
}
export function handelseRubrik(post: LoggPost): string {
const h = post.handelse;
switch (h.typ) {
case "objekt_identifierat":
return `Objekt identifierat: ${h.objekt.beskrivning} (${h.objekt.identifierare})`;
case "felbeskrivning":
return `Felbeskrivning registrerad`;
case "fraga_besvarad":
return `${h.fraga}${h.svar}`;
case "kontroll_utford":
return h.resultat ? `${h.text}${h.resultat}` : `${h.text} — utförd`;
case "observation":
return `Observation: ${h.text}`;
case "matvarde":
return `Mätvärde: ${h.beskrivning} = ${h.varde}${h.enhet ? ` ${h.enhet}` : ""}`;
case "hypotes":
return `Hypotes (${TILLFORLITLIGHET_LABEL[h.niva]}): ${h.text}`;
case "foto":
return `Foto: ${h.beskrivning}`;
case "kommentar":
return h.text;
case "kategori_byte":
return `Arbetskategori: ${TIDKATEGORI_LABEL[h.kategori]}`;
case "inaktivitet_forklarad":
return `Komplettering (${h.minuter} min utan aktivitet): ${h.text}`;
case "overlamning":
return h.till ? `Arbete överlämnat från ${h.fran} till ${h.till}` : `Arbete överlämnat av ${h.fran}`;
case "arende_avslutat":
return "Felsökning avslutad";
}
}
+31
View File
@@ -0,0 +1,31 @@
// Formatering och bildhantering — utan React-beroenden.
export function tidKlockslag(iso: string): string {
return new Date(iso).toLocaleTimeString("sv-SE", { hour: "2-digit", minute: "2-digit" });
}
export function tidDatum(iso: string): string {
return new Date(iso).toLocaleDateString("sv-SE");
}
// Skalar ner ett foto innan det loggas, så att händelseloggen förblir hanterbar.
export function skalaNerFoto(fil: File, maxSida = 1024): Promise<string> {
return new Promise((resolve, reject) => {
const lasare = new FileReader();
lasare.onerror = () => reject(new Error("Kunde inte läsa filen"));
lasare.onload = () => {
const bild = new Image();
bild.onerror = () => reject(new Error("Kunde inte tolka bilden"));
bild.onload = () => {
const skala = Math.min(1, maxSida / Math.max(bild.width, bild.height));
const canvas = document.createElement("canvas");
canvas.width = Math.round(bild.width * skala);
canvas.height = Math.round(bild.height * skala);
canvas.getContext("2d")!.drawImage(bild, 0, 0, canvas.width, canvas.height);
resolve(canvas.toDataURL("image/jpeg", 0.7));
};
bild.src = lasare.result as string;
};
lasare.readAsDataURL(fil);
});
}
+190
View File
@@ -0,0 +1,190 @@
// Metodikmotor: deterministiska felsökningsflöden.
// Systemet presenterar aldrig en hypotes som ett konstaterat fel —
// motorn föreslår nästa verifierbara steg utifrån vad som redan är dokumenterat.
//
// I MVP:t är metodikerna fördefinierade flöden. Den framtida AI-integrationen
// ansluter här: den väljer/genererar steg, men händelseloggen och
// projektionerna är oförändrade.
import type { Arende } from "./domain";
export interface Fraga {
id: string;
text: string;
svarstyp: "janej" | "text" | "val";
val?: string[];
}
export interface Kontroll {
id: string;
text: string;
}
export interface MetodikSteg {
id: string;
rubrik: string;
beskrivning?: string;
fragor?: Fraga[];
kontroller?: Kontroll[];
}
export interface Metodik {
id: string;
namn: string;
steg: MetodikSteg[];
}
export const VIBRATION_METODIK: Metodik = {
id: "vibration",
namn: "Vibration under körning",
steg: [
{
id: "symptom",
rubrik: "Verifiera symptom",
fragor: [
{ id: "hastighetsberoende", text: "Är vibrationen hastighetsberoende?", svarstyp: "janej" },
{
id: "var",
text: "Var känns vibrationen?",
svarstyp: "val",
val: ["I ratten", "I stolen", "I hela bilen"],
},
{
id: "nar",
text: "När uppstår vibrationen?",
svarstyp: "val",
val: ["Vid acceleration", "Vid jämn fart", "Vid inbromsning", "Alltid under körning"],
},
{
id: "intervall",
text: "Försvinner den över eller under ett visst hastighetsintervall?",
svarstyp: "text",
},
],
},
{
id: "visuell",
rubrik: "Visuell kontroll",
beskrivning: "Fotografera samtliga hjul. Skilj på observation och slutsats.",
kontroller: [
{ id: "foto_vf", text: "Fotografera vänster framhjul" },
{ id: "foto_hf", text: "Fotografera höger framhjul" },
{ id: "foto_vb", text: "Fotografera vänster bakhjul" },
{ id: "foto_hb", text: "Fotografera höger bakhjul" },
{ id: "dot", text: "Dokumentera DOT-/tillverkningsdatum" },
{ id: "slitage", text: "Kontrollera däckslitage" },
],
},
{
id: "kontroller",
rubrik: "Rekommenderade kontroller",
kontroller: [
{ id: "lufttryck", text: "Kontrollera lufttryck" },
{ id: "hjulmoment", text: "Kontrollera hjulmoment" },
{ id: "kast", text: "Kontrollera radial- och sidokast" },
{ id: "balansering", text: "Kontrollera hjulbalansering" },
{ id: "bussningar", text: "Kontrollera bussningar och leder" },
],
},
{
id: "provkorning",
rubrik: "Provkörning",
beskrivning: "Verifiera under provkörning:",
kontroller: [
{ id: "pk_hastighet", text: "Hastighet där vibration uppstår" },
{ id: "pk_acc", text: "Förändring vid acceleration" },
{ id: "pk_motorbroms", text: "Förändring vid motorbroms" },
{ id: "pk_kurva", text: "Förändring vid kurvtagning" },
{ id: "pk_var", text: "Om vibration känns i ratt eller kaross" },
],
},
],
};
export const GENERISK_METODIK: Metodik = {
id: "generisk",
namn: "Generell strukturerad felsökning",
steg: [
{
id: "symptom",
rubrik: "Verifiera symptom",
fragor: [
{ id: "reproducerbart", text: "Går felet att reproducera?", svarstyp: "janej" },
{
id: "nar",
text: "När uppträder felet?",
svarstyp: "val",
val: ["Alltid", "Ibland", "Endast under drift", "Endast vid start"],
},
{ id: "forlopp", text: "Beskriv förloppet när felet uppstår", svarstyp: "text" },
{ id: "forandring", text: "Har något förändrats nyligen (service, reparation, miljö)?", svarstyp: "text" },
],
},
{
id: "visuell",
rubrik: "Visuell kontroll",
kontroller: [
{ id: "foto_objekt", text: "Fotografera objektet och det berörda området" },
{ id: "skador", text: "Kontrollera synliga skador, läckage eller lösa anslutningar" },
{ id: "typskylt", text: "Dokumentera typskylt/märkning" },
],
},
{
id: "grundkontroller",
rubrik: "Grundkontroller",
kontroller: [
{ id: "matning", text: "Utför grundläggande mätningar (spänning, tryck, temperatur — det som är relevant)" },
{ id: "sakringar", text: "Kontrollera säkringar/skydd" },
{ id: "anslutningar", text: "Kontrollera kontaktdon och anslutningar" },
],
},
{
id: "funktionstest",
rubrik: "Funktionstest",
kontroller: [
{ id: "test", text: "Genomför funktionstest och dokumentera resultatet" },
],
},
],
};
const METODIKER = [VIBRATION_METODIK, GENERISK_METODIK];
export function metodikForId(id: string): Metodik {
return METODIKER.find((m) => m.id === id) ?? GENERISK_METODIK;
}
export function valjMetodik(felbeskrivning: string): Metodik {
const text = felbeskrivning.toLowerCase();
if (/vibr|skak|obalans/.test(text)) return VIBRATION_METODIK;
return GENERISK_METODIK;
}
export interface NastaSteg {
steg: MetodikSteg;
fraga?: Fraga;
kontroll?: Kontroll;
klart: boolean;
}
// Nästa steg = första obesvarade frågan, därefter första ej utförda kontrollen,
// i metodikens ordning. Helt härlett ur händelseloggen.
export function nastaSteg(arende: Arende, metodik: Metodik): NastaSteg {
const besvarade = new Set<string>();
const utforda = new Set<string>();
for (const post of arende.handelser) {
const h = post.handelse;
if (h.typ === "fraga_besvarad") besvarade.add(`${h.stegId}/${h.frageId}`);
if (h.typ === "kontroll_utford") utforda.add(`${h.stegId}/${h.kontrollId}`);
}
for (const steg of metodik.steg) {
for (const fraga of steg.fragor ?? []) {
if (!besvarade.has(`${steg.id}/${fraga.id}`)) return { steg, fraga, klart: false };
}
for (const kontroll of steg.kontroller ?? []) {
if (!utforda.has(`${steg.id}/${kontroll.id}`)) return { steg, kontroll, klart: false };
}
}
const sista = metodik.steg[metodik.steg.length - 1];
return { steg: sista, klart: true };
}
+247
View File
@@ -0,0 +1,247 @@
// Projektioner: alla vyer (ärendebrief, tidrapport, kundrapport) är rena
// funktioner av händelseloggen. De kan alltid regenereras — loggen vinner
// om en vy och loggen skulle säga olika saker.
import type { Arende, LoggPost, Objekt, TidKategori, Tillforlitlighet } from "./domain";
import { TIDKATEGORI_LABEL } from "./domain";
import type { Metodik } from "./metodik";
import { nastaSteg } from "./metodik";
export function objekt(arende: Arende): Objekt | undefined {
for (const post of arende.handelser) {
if (post.handelse.typ === "objekt_identifierat") return post.handelse.objekt;
}
return undefined;
}
export function felbeskrivning(arende: Arende): string | undefined {
for (const post of arende.handelser) {
if (post.handelse.typ === "felbeskrivning") return post.handelse.text;
}
return undefined;
}
export function arAvslutat(arende: Arende): boolean {
return arende.handelser.some((p) => p.handelse.typ === "arende_avslutat");
}
export interface UtfordKontroll {
text: string;
resultat?: string;
tidpunkt: string;
anvandare: string;
}
export function utfordaKontroller(arende: Arende): UtfordKontroll[] {
const resultat: UtfordKontroll[] = [];
for (const post of arende.handelser) {
const h = post.handelse;
if (h.typ === "kontroll_utford") {
resultat.push({ text: h.text, resultat: h.resultat, tidpunkt: post.tidpunkt, anvandare: post.anvandare });
}
}
return resultat;
}
export function ejKontrollerat(arende: Arende, metodik: Metodik): string[] {
const utforda = new Set<string>();
for (const post of arende.handelser) {
const h = post.handelse;
if (h.typ === "kontroll_utford") utforda.add(`${h.stegId}/${h.kontrollId}`);
}
const kvar: string[] = [];
for (const steg of metodik.steg) {
for (const kontroll of steg.kontroller ?? []) {
if (!utforda.has(`${steg.id}/${kontroll.id}`)) kvar.push(kontroll.text);
}
}
return kvar;
}
export function observationer(arende: Arende): string[] {
const resultat: string[] = [];
for (const post of arende.handelser) {
const h = post.handelse;
if (h.typ === "observation") resultat.push(h.text);
if (h.typ === "matvarde") resultat.push(`${h.beskrivning}: ${h.varde}${h.enhet ? ` ${h.enhet}` : ""}`);
}
return resultat;
}
export function hypoteser(arende: Arende): { text: string; niva: Tillforlitlighet }[] {
const resultat: { text: string; niva: Tillforlitlighet }[] = [];
for (const post of arende.handelser) {
if (post.handelse.typ === "hypotes") resultat.push({ text: post.handelse.text, niva: post.handelse.niva });
}
return resultat;
}
export function foton(arende: Arende): { beskrivning: string; dataUrl: string; tidpunkt: string }[] {
const resultat: { beskrivning: string; dataUrl: string; tidpunkt: string }[] = [];
for (const post of arende.handelser) {
if (post.handelse.typ === "foto") {
resultat.push({ beskrivning: post.handelse.beskrivning, dataUrl: post.handelse.dataUrl, tidpunkt: post.tidpunkt });
}
}
return resultat;
}
// Tidsredovisning: varje intervall mellan två händelser tillhör den kategori
// som var aktiv när intervallet började. Kategori styrs av kategori_byte-
// händelser; standard är aktiv felsökning. Paus räknas inte in i total tid.
export interface Tidsfordelning {
totalMs: number;
perKategori: Partial<Record<TidKategori, number>>;
}
export function tidsfordelning(arende: Arende, nu?: string): Tidsfordelning {
const poster = arende.handelser;
const perKategori: Partial<Record<TidKategori, number>> = {};
if (poster.length === 0) return { totalMs: 0, perKategori };
let kategori: TidKategori = "aktiv_felsokning";
let forra = new Date(poster[0].tidpunkt).getTime();
let avslutad = false;
const laggTill = (till: number) => {
const ms = Math.max(0, till - forra);
perKategori[kategori] = (perKategori[kategori] ?? 0) + ms;
forra = till;
};
for (const post of poster) {
laggTill(new Date(post.tidpunkt).getTime());
const h = post.handelse;
if (h.typ === "kategori_byte") kategori = h.kategori;
if (h.typ === "arende_avslutat") {
avslutad = true;
break;
}
}
if (!avslutad && nu) laggTill(new Date(nu).getTime());
let totalMs = 0;
for (const [k, ms] of Object.entries(perKategori)) {
if (k !== "paus") totalMs += ms;
}
return { totalMs, perKategori };
}
export function formateraTid(ms: number): string {
const minuter = Math.round(ms / 60000);
const h = Math.floor(minuter / 60);
const m = minuter % 60;
if (h === 0) return `${m} min`;
return `${h} tim ${m} min`;
}
export interface TillforlitlighetsRad {
niva: Tillforlitlighet;
text: string;
}
export function tillforlitlighet(arende: Arende): TillforlitlighetsRad[] {
const rader: TillforlitlighetsRad[] = [];
if (objekt(arende)) rader.push({ niva: "hog", text: "Objekt identifierat" });
if (foton(arende).length > 0) rader.push({ niva: "hog", text: "Bilder dokumenterade" });
if (arende.handelser.some((p) => p.handelse.typ === "matvarde")) {
rader.push({ niva: "hog", text: "Mätvärden registrerade" });
}
if (utfordaKontroller(arende).length > 0) {
rader.push({ niva: "hog", text: "Kontroller dokumenterade" });
}
// Felorsaken är per definition inte verifierad förrän ärendet avslutats
// med en verifierad slutsats — MVP:t markerar den alltid som ej verifierad
// så länge ärendet är öppet.
if (!arAvslutat(arende)) {
rader.push({ niva: "medel", text: "Felorsak ännu inte verifierad" });
}
return rader;
}
export interface Brief {
objekt?: Objekt;
felbeskrivning?: string;
utfordaKontroller: UtfordKontroll[];
observationer: string[];
hypoteser: { text: string; niva: Tillforlitlighet }[];
ejKontrollerat: string[];
rekommenderatNastaSteg: string[];
totalArbetstid: string;
tillforlitlighet: TillforlitlighetsRad[];
avslutat: boolean;
}
// Ärendebriefen: hela den strukturerade sammanfattningen, regenererbar
// ur loggen vid varje anrop.
export function brief(arende: Arende, metodik: Metodik, nu?: string): Brief {
const kvar = ejKontrollerat(arende, metodik);
const nasta = nastaSteg(arende, metodik);
const rekommenderat: string[] = [];
if (!arAvslutat(arende)) {
if (nasta.fraga) rekommenderat.push(`Besvara: ${nasta.fraga.text}`);
else if (nasta.kontroll) rekommenderat.push(nasta.kontroll.text);
for (const text of kvar.slice(0, 3)) {
if (!rekommenderat.includes(text)) rekommenderat.push(text);
}
if (rekommenderat.length === 0) {
rekommenderat.push("Samtliga kontroller i metodiken utförda. Verifiera slutsats eller utöka felsökningen.");
}
}
return {
objekt: objekt(arende),
felbeskrivning: felbeskrivning(arende),
utfordaKontroller: utfordaKontroller(arende),
observationer: observationer(arende),
hypoteser: hypoteser(arende),
ejKontrollerat: kvar,
rekommenderatNastaSteg: rekommenderat.slice(0, 3),
totalArbetstid: formateraTid(tidsfordelning(arende, nu).totalMs),
tillforlitlighet: tillforlitlighet(arende),
avslutat: arAvslutat(arende),
};
}
// Överlämningsrapport som text — används vid "Lämna över arbete".
export function overlamningstext(arende: Arende, metodik: Metodik, nu?: string): string {
const b = brief(arende, metodik, nu);
const rader: string[] = [];
rader.push(`ÖVERLÄMNING Ärende #${arende.nummer}`);
if (b.objekt) rader.push(`Objekt: ${b.objekt.beskrivning} (${b.objekt.identifierare})${b.objekt.kund ? ` Kund: ${b.objekt.kund}` : ""}`);
if (b.felbeskrivning) rader.push(`Felbeskrivning: ${b.felbeskrivning}`);
rader.push("");
rader.push("Utförda kontroller:");
if (b.utfordaKontroller.length === 0) rader.push(" (inga ännu)");
for (const k of b.utfordaKontroller) rader.push(`${k.text}${k.resultat ? `${k.resultat}` : ""}`);
rader.push("");
rader.push("Observationer:");
if (b.observationer.length === 0) rader.push(" (inga ännu)");
for (const o of b.observationer) rader.push(`${o}`);
if (b.hypoteser.length > 0) {
rader.push("");
rader.push("Hypoteser (ej verifierade):");
for (const h of b.hypoteser) rader.push(` ? ${h.text}`);
}
rader.push("");
rader.push("Ej kontrollerat:");
if (b.ejKontrollerat.length === 0) rader.push(" (allt i metodiken utfört)");
for (const e of b.ejKontrollerat) rader.push(` ${e}`);
rader.push("");
rader.push("Rekommenderat nästa steg:");
b.rekommenderatNastaSteg.forEach((s, i) => rader.push(` ${i + 1}. ${s}`));
rader.push("");
rader.push(`Total arbetstid: ${b.totalArbetstid}`);
return rader.join("\n");
}
export function tidsfordelningsRader(arende: Arende, nu?: string): { label: string; tid: string }[] {
const { perKategori } = tidsfordelning(arende, nu);
return (Object.entries(perKategori) as [TidKategori, number][])
.filter(([, ms]) => ms >= 30000)
.sort((a, b) => b[1] - a[1])
.map(([k, ms]) => ({ label: TIDKATEGORI_LABEL[k], tid: formateraTid(ms) }));
}
export function sistaAktivitet(arende: Arende): LoggPost | undefined {
return arende.handelser[arende.handelser.length - 1];
}
+75
View File
@@ -0,0 +1,75 @@
// Tillstånd för Guidad Felsökning (MVP).
// Persistens: localStorage. I produktionsversionen ersätts detta av ett
// API-first-backend med samma händelsemodell — append-only-loggen gör
// synkronisering (inkl. offline-läge) konfliktfri.
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { Arende, Handelse, Objekt } from "./domain";
import { nyLoggPost } from "./domain";
import { felbeskrivning } from "./projektioner";
import { GENERISK_METODIK, valjMetodik, type Metodik } from "./metodik";
interface FelsokningState {
anvandare: string;
arenden: Record<string, Arende>;
nastaNummer: number;
sattAnvandare: (namn: string) => void;
skapaArende: (objekt: Objekt, felbeskrivningText: string) => string;
laggTill: (arendeId: string, handelse: Handelse) => void;
}
export const useFelsokning = create<FelsokningState>()(
persist(
(set, get) => ({
anvandare: "",
arenden: {},
nastaNummer: 1,
sattAnvandare: (namn) => set({ anvandare: namn.trim() }),
skapaArende: (objekt, felbeskrivningText) => {
const { anvandare, nastaNummer } = get();
const id = `arende-${Date.now().toString(36)}`;
const arende: Arende = {
id,
nummer: nastaNummer,
skapad: new Date().toISOString(),
handelser: [
nyLoggPost(anvandare, { typ: "objekt_identifierat", objekt }),
nyLoggPost(anvandare, { typ: "felbeskrivning", text: felbeskrivningText }),
],
};
set((s) => ({
arenden: { ...s.arenden, [id]: arende },
nastaNummer: s.nastaNummer + 1,
}));
return id;
},
laggTill: (arendeId, handelse) => {
const { anvandare } = get();
set((s) => {
const arende = s.arenden[arendeId];
if (!arende) return s;
return {
arenden: {
...s.arenden,
[arendeId]: {
...arende,
handelser: [...arende.handelser, nyLoggPost(anvandare, handelse)],
},
},
};
});
},
}),
{ name: "guidad-felsokning" },
),
);
export function metodikForArende(arende: Arende | undefined): Metodik {
if (!arende) return GENERISK_METODIK;
const text = felbeskrivning(arende);
return text ? valjMetodik(text) : GENERISK_METODIK;
}
+112
View File
@@ -0,0 +1,112 @@
// Delade UI-komponenter för Guidad Felsökning.
// Designfilosofi: professionellt fabriksverktyg — hög kontrast, stora
// träffytor (handskvänligt), få val per skärm, inga distraktioner.
import { Link } from "react-router-dom";
import type { ReactNode } from "react";
import type { Tillforlitlighet } from "./domain";
import { TILLFORLITLIGHET_LABEL } from "./domain";
export function FelsokningSkal({
rubrik,
tillbaka,
hoger,
children,
}: {
rubrik: string;
tillbaka?: { till: string; text: string };
hoger?: ReactNode;
children: ReactNode;
}) {
return (
<div className="min-h-screen bg-zinc-950 text-zinc-50">
<header className="sticky top-0 z-20 border-b-2 border-zinc-700 bg-zinc-900 px-4 py-3">
<div className="mx-auto flex max-w-3xl items-center justify-between gap-3">
<div className="min-w-0">
{tillbaka && (
<Link to={tillbaka.till} className="text-sm font-semibold text-amber-400 hover:underline">
{tillbaka.text}
</Link>
)}
<h1 className="truncate text-xl font-extrabold tracking-tight">{rubrik}</h1>
</div>
{hoger}
</div>
</header>
<main className="mx-auto max-w-3xl px-4 py-5 pb-24">{children}</main>
</div>
);
}
export function StorKnapp({
children,
onClick,
variant = "primar",
disabled,
type = "button",
className = "",
}: {
children: ReactNode;
onClick?: () => void;
variant?: "primar" | "sekundar" | "fara";
disabled?: boolean;
type?: "button" | "submit";
className?: string;
}) {
const farg =
variant === "primar"
? "bg-amber-400 text-zinc-950 hover:bg-amber-300 active:bg-amber-500"
: variant === "fara"
? "bg-red-600 text-white hover:bg-red-500 active:bg-red-700"
: "bg-zinc-800 text-zinc-50 border-2 border-zinc-600 hover:bg-zinc-700 active:bg-zinc-600";
return (
<button
type={type}
onClick={onClick}
disabled={disabled}
className={`min-h-14 w-full rounded-lg px-5 py-3 text-lg font-bold transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${farg} ${className}`}
>
{children}
</button>
);
}
export function Panel({ rubrik, children }: { rubrik?: string; children: ReactNode }) {
return (
<section className="mb-4 rounded-lg border-2 border-zinc-700 bg-zinc-900 p-4">
{rubrik && <h2 className="mb-3 text-sm font-extrabold uppercase tracking-widest text-zinc-400">{rubrik}</h2>}
{children}
</section>
);
}
export function NivaBadge({ niva }: { niva: Tillforlitlighet }) {
return <span className="whitespace-nowrap text-sm font-bold">{TILLFORLITLIGHET_LABEL[niva]}</span>;
}
export function TextFalt({
label,
varde,
satt,
platshallare,
flerRad,
}: {
label: string;
varde: string;
satt: (v: string) => void;
platshallare?: string;
flerRad?: boolean;
}) {
const klass =
"w-full rounded-lg border-2 border-zinc-600 bg-zinc-950 px-4 py-3 text-lg text-zinc-50 placeholder-zinc-500 focus:border-amber-400 focus:outline-none";
return (
<label className="mb-3 block">
<span className="mb-1 block text-sm font-bold uppercase tracking-wide text-zinc-400">{label}</span>
{flerRad ? (
<textarea value={varde} onChange={(e) => satt(e.target.value)} placeholder={platshallare} rows={3} className={klass} />
) : (
<input value={varde} onChange={(e) => satt(e.target.value)} placeholder={platshallare} className={klass} />
)}
</label>
);
}