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:
@@ -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", () => {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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" },
|
||||
),
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user