Backend-grund, synklager, Live Share-vy och elsystemmetodik

- Supabase-migration: felsokning_arenden + felsokning_handelser med
  RLS för inloggade och append-only garanterat i databasen (inga
  update/delete-rättigheter). Live Share förberedd via security
  definer-funktionen hamta_delat_arende(delningskod) så att anonyma
  läsare aldrig når tabellerna direkt; kategoribyten filtreras bort.
  Realtime-publicering aktiverad för händelsetabellen.
- Synklager i klienten (synk.ts): deterministisk, konfliktfri
  ihopflätning av händelser per id (testad: dedupe, tidsordning,
  idempotens), push av lokala och pull av kollegors händelser var
  15:e sekund. Utan inloggning: lokalt läge, status i ärendehuvudet.
- Live Share-vy (/felsokning/dela/:id): skrivskyddad livevy med
  status ✔/🔄/, bilder, mätvärdestabell, tidslinje och rekommenderat
  nästa steg. Varje ärende får slumpad delningskod vid skapande.
- Ny metodik: Elsystem/strömförsörjning enligt relä-exemplet i
  visionen, med mätvärdeskrav på alla mätpunkter.

Verifierat: 17 vitest-tester gröna, produktionsbygge ok, Playwright-
röktest inkl. Live Share-vyn.

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:48:03 +00:00
parent 7b6619133f
commit fcded6bd43
11 changed files with 488 additions and 7 deletions
@@ -28,8 +28,8 @@ const OBJEKT: Handelse = {
describe("verifierade checklistor", () => {
it("varje kontroll i metodikerna har ett definierat minimikrav", async () => {
const { GENERISK_METODIK } = await import("../metodik");
for (const metodik of [VIBRATION_METODIK, GENERISK_METODIK]) {
const { METODIKER } = await import("../metodik");
for (const metodik of METODIKER) {
for (const steg of metodik.steg) {
for (const kontroll of steg.kontroller ?? []) {
expect(kontroll.krav, `${metodik.id}/${steg.id}/${kontroll.id}`).toBeDefined();
@@ -44,6 +44,12 @@ describe("metodikval", () => {
expect(valjMetodik("Bilen vibrerar runt 88 km/h").id).toBe("vibration");
expect(valjMetodik("Motorn startar inte").id).toBe("generisk");
});
it("väljer elsystemmetodiken vid elrelaterade symptom", () => {
expect(valjMetodik("Reläet klickar inte").id).toBe("elsystem");
expect(valjMetodik("Ingen spänning till bränslepumpen").id).toBe("elsystem");
expect(valjMetodik("Belysningen fungerar inte").id).toBe("elsystem");
});
});
describe("nastaSteg", () => {
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import type { LoggPost } from "../domain";
import { flataIhop } from "../synk";
function post(id: string, tidpunkt: string, anvandare = "Anna"): LoggPost {
return { id, tidpunkt, anvandare, handelse: { typ: "kommentar", text: id } };
}
describe("flataIhop", () => {
const lokala = [post("a", "2026-08-02T08:00:00.000Z"), post("c", "2026-08-02T08:10:00.000Z")];
const fjarr = [post("a", "2026-08-02T08:00:00.000Z"), post("b", "2026-08-02T08:05:00.000Z", "Johan")];
it("flätar ihop två enheters loggar utan dubbletter, i tidsordning", () => {
const resultat = flataIhop(lokala, fjarr);
expect(resultat.map((p) => p.id)).toEqual(["a", "b", "c"]);
});
it("skriver aldrig över: alla poster från båda källorna finns kvar", () => {
const resultat = flataIhop(lokala, fjarr);
expect(resultat).toHaveLength(3);
expect(resultat.find((p) => p.id === "b")?.anvandare).toBe("Johan");
});
it("är idempotent och deterministisk", () => {
const enGang = flataIhop(lokala, fjarr);
const tvaGanger = flataIhop(enGang, fjarr);
expect(tvaGanger).toEqual(enGang);
expect(flataIhop(fjarr, lokala).map((p) => p.id)).toEqual(enGang.map((p) => p.id));
});
it("använder id som stabil ordning vid exakt samma tidpunkt", () => {
const samtidiga = flataIhop(
[post("x2", "2026-08-02T08:00:00.000Z")],
[post("x1", "2026-08-02T08:00:00.000Z")],
);
expect(samtidiga.map((p) => p.id)).toEqual(["x1", "x2"]);
});
});
+3
View File
@@ -65,6 +65,9 @@ export interface Arende {
id: string;
nummer: number;
skapad: string; // ISO 8601
// Slumpad kod för Live Share-länken; sätts vid skapande och kan aldrig
// ändras (ärenderaden är append-only även i databasen).
delningskod?: string;
handelser: LoggPost[];
}
+61 -1
View File
@@ -153,7 +153,64 @@ export const GENERISK_METODIK: Metodik = {
],
};
const METODIKER = [VIBRATION_METODIK, GENERISK_METODIK];
// Elsystem-metodik enligt visionens relä-exempel: mätningar med krav på
// värden, reläkontroll och funktionstest.
export const ELSYSTEM_METODIK: Metodik = {
id: "elsystem",
namn: "Elsystem / strömförsörjning",
steg: [
{
id: "symptom",
rubrik: "Verifiera symptom",
fragor: [
{ id: "reproducerbart", text: "Går felet att reproducera?", svarstyp: "janej" },
{
id: "omfattning",
text: "Är funktionen helt död eller delvis fungerande?",
svarstyp: "val",
val: ["Helt död", "Delvis fungerande", "Intermittent"],
},
{ id: "funktion", text: "Vilken funktion saknas eller felar?", svarstyp: "text" },
{ id: "forandring", text: "Har något åtgärdats eller förändrats nyligen?", svarstyp: "text" },
],
},
{
id: "visuell",
rubrik: "Visuell kontroll",
kontroller: [
{ id: "foto_komponent", text: "Fotografera komponenten och dess anslutningar", krav: "foto" },
{ id: "skador", text: "Kontrollera synliga skador, korrosion och lösa kontaktdon", krav: "kommentar" },
],
},
{
id: "matningar",
rubrik: "Mätningar",
kontroller: [
{ id: "batterispanning", text: "Mät batterispänning", krav: "matvarde" },
{ id: "sakringar", text: "Kontrollera berörda säkringar", krav: "kommentar" },
{ id: "matningsspanning", text: "Mät matningsspänning vid komponenten", krav: "matvarde" },
{ id: "jord", text: "Mät spänningsfall mot jord", krav: "matvarde" },
],
},
{
id: "rela",
rubrik: "Relä och styrsignal",
kontroller: [
{ id: "rela_klick", text: "Kontrollera om reläet klickar vid aktivering", krav: "kommentar" },
{ id: "styrsignal", text: "Mät styrsignal till reläet", krav: "matvarde" },
],
},
{
id: "funktionstest",
rubrik: "Funktionstest",
kontroller: [
{ id: "test", text: "Genomför funktionstest och dokumentera resultatet", krav: "kommentar" },
],
},
],
};
export const METODIKER = [VIBRATION_METODIK, ELSYSTEM_METODIK, GENERISK_METODIK];
export function metodikForId(id: string): Metodik {
return METODIKER.find((m) => m.id === id) ?? GENERISK_METODIK;
@@ -162,6 +219,9 @@ export function metodikForId(id: string): Metodik {
export function valjMetodik(felbeskrivning: string): Metodik {
const text = felbeskrivning.toLowerCase();
if (/vibr|skak|obalans/.test(text)) return VIBRATION_METODIK;
if (/relä|rela\b|säkring|elektr|spänning|volt\b|batteri|lampa|belysning|ström|kortslut/.test(text)) {
return ELSYSTEM_METODIK;
}
return GENERISK_METODIK;
}
+21 -1
View File
@@ -5,7 +5,7 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { Arende, Handelse, Objekt } from "./domain";
import type { Arende, Handelse, LoggPost, Objekt } from "./domain";
import { nyLoggPost } from "./domain";
import { felbeskrivning } from "./projektioner";
import { GENERISK_METODIK, valjMetodik, type Metodik } from "./metodik";
@@ -17,6 +17,15 @@ interface FelsokningState {
sattAnvandare: (namn: string) => void;
skapaArende: (objekt: Objekt, felbeskrivningText: string) => string;
laggTill: (arendeId: string, handelse: Handelse) => void;
// Används endast av synken: ersätter listan med den ihopflätade versionen.
// Semantiken är fortfarande append-only — flätningen lägger bara till.
sammanfoga: (arendeId: string, handelser: LoggPost[]) => void;
}
function nyDelningskod(): string {
return Array.from(crypto.getRandomValues(new Uint8Array(12)), (b) =>
"abcdefghijklmnopqrstuvwxyz0123456789"[b % 36],
).join("");
}
export const useFelsokning = create<FelsokningState>()(
@@ -35,6 +44,7 @@ export const useFelsokning = create<FelsokningState>()(
id,
nummer: nastaNummer,
skapad: new Date().toISOString(),
delningskod: nyDelningskod(),
handelser: [
nyLoggPost(anvandare, { typ: "objekt_identifierat", objekt }),
nyLoggPost(anvandare, { typ: "felbeskrivning", text: felbeskrivningText }),
@@ -63,6 +73,16 @@ export const useFelsokning = create<FelsokningState>()(
};
});
},
sammanfoga: (arendeId, handelser) => {
set((s) => {
const arende = s.arenden[arendeId];
if (!arende || handelser.length === arende.handelser.length) return s;
return {
arenden: { ...s.arenden, [arendeId]: { ...arende, handelser } },
};
});
},
}),
{ name: "guidad-felsokning" },
),
+98
View File
@@ -0,0 +1,98 @@
// Synkronisering mot backend. Append-only-modellen gör den konfliktfri:
// händelser flätas ihop per id och ingenting skrivs över — enheter kan
// arbeta offline och synka i efterhand utan att någon post går förlorad.
//
// Kräver inloggning (Supabase Auth). Utan session arbetar appen vidare
// i lokalt läge; synk återupptas när användaren loggat in.
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Arende, Handelse, LoggPost } from "./domain";
// Deterministisk ihopflätning: dedupe per id, ordning per tidpunkt (id som
// stabil sekundärnyckel). Samma indata ger alltid samma resultat.
export function flataIhop(lokala: LoggPost[], fjarr: LoggPost[]): LoggPost[] {
const perId = new Map<string, LoggPost>();
for (const post of [...lokala, ...fjarr]) {
if (!perId.has(post.id)) perId.set(post.id, post);
}
return [...perId.values()].sort(
(a, b) => a.tidpunkt.localeCompare(b.tidpunkt) || a.id.localeCompare(b.id),
);
}
export type SynkStatus = "lokal" | "synkar" | "synkad" | "offline";
export interface SynkResultat {
status: SynkStatus;
handelser?: LoggPost[];
}
interface FjarrHandelseRad {
id: string;
tidpunkt: string;
anvandare: string;
handelse: Handelse;
}
async function klient(): Promise<SupabaseClient | undefined> {
try {
const { supabase } = await import("@/integrations/supabase/client");
// Tabellerna är nya — den genererade Database-typen regenereras av
// Lovable/Supabase CLI; tills dess används klienten otypad här.
return supabase as unknown as SupabaseClient;
} catch {
return undefined;
}
}
export async function synkroniseraArende(arende: Arende): Promise<SynkResultat> {
const supabase = await klient();
if (!supabase) return { status: "lokal" };
try {
const { data } = await supabase.auth.getSession();
if (!data.session) return { status: "lokal" };
const { error: arendeFel } = await supabase.from("felsokning_arenden").upsert(
{
id: arende.id,
nummer: arende.nummer,
skapad: arende.skapad,
delningskod: arende.delningskod ?? null,
},
{ onConflict: "id", ignoreDuplicates: true },
);
if (arendeFel) throw arendeFel;
const { data: fjarrRader, error: lasFel } = await supabase
.from("felsokning_handelser")
.select("id,tidpunkt,anvandare,handelse")
.eq("arende_id", arende.id);
if (lasFel) throw lasFel;
const fjarr: LoggPost[] = ((fjarrRader ?? []) as FjarrHandelseRad[]).map((rad) => ({
id: rad.id,
tidpunkt: new Date(rad.tidpunkt).toISOString(),
anvandare: rad.anvandare,
handelse: rad.handelse,
}));
const fjarrIds = new Set(fjarr.map((p) => p.id));
const nya = arende.handelser
.filter((p) => !fjarrIds.has(p.id))
.map((p) => ({
id: p.id,
arende_id: arende.id,
tidpunkt: p.tidpunkt,
anvandare: p.anvandare,
handelse: p.handelse,
}));
if (nya.length > 0) {
const { error: skrivFel } = await supabase.from("felsokning_handelser").insert(nya);
if (skrivFel) throw skrivFel;
}
return { status: "synkad", handelser: flataIhop(arende.handelser, fjarr) };
} catch {
return { status: "offline" };
}
}