diff --git a/packages/database/src/ops-summary.ts b/packages/database/src/ops-summary.ts index d26fd7c..4c23628 100644 --- a/packages/database/src/ops-summary.ts +++ b/packages/database/src/ops-summary.ts @@ -13,6 +13,8 @@ import type { OpsSubscriptionsBlock, OpsStoreBlock, OpsFeedbackBlock, + OpsWallBlock, + OpsWallTile, } from "@app/shared-types"; import { SUBSCRIPTION_PLANS, type SubscriptionPlan } from "@app/shared-types"; import type { Database } from "./client.js"; @@ -45,6 +47,24 @@ function asNumber(value: unknown): number { return Number(value ?? 0); } +function formatSekKr(oer: number | null): string { + if (oer === null || Number.isNaN(oer)) return "—"; + const kr = oer / 100; + // Mellanslag (0x20) som tusentalsavgränsare, utan ören. + const formatted = new Intl.NumberFormat("sv-SE", { maximumFractionDigits: 0 }).format(kr); + return `${formatted.replace(/\s/g, " ")} kr`; +} + +function formatPercent(ratio: number | null): string { + if (ratio === null || Number.isNaN(ratio)) return "—"; + return `${Math.round(ratio * 100)} %`; +} + +function formatInt(n: number | null): string { + if (n === null || Number.isNaN(n)) return "—"; + return String(n); +} + function safeDiv(numerator: number, denominator: number): number | null { if (denominator === 0) return null; return numerator / denominator; @@ -533,6 +553,45 @@ function storeBlock(): OpsStoreBlock { return { butik: null, all_fields_phase_2: null }; } +export function buildWall(summary: OpsSummary): OpsWallBlock { + const tiles: OpsWallTile[] = [ + { + label: "MRR", + value: formatSekKr(summary.ekonomi.mrr), + tone: summary.ekonomi.mrr === null ? "warn" : "ok", + }, + { + label: "Aktiva nu", + value: formatInt(summary.anvandare.aktiva_nu), + tone: "ok", + }, + { + label: "Betalande", + value: formatInt(summary.prenumerationer.betalande), + tone: "ok", + }, + { + label: "Trial-konv", + value: formatPercent(summary.prenumerationer.konverteringsgrad_30d), + tone: summary.prenumerationer.konverteringsgrad_30d === null ? "warn" : "ok", + }, + { + label: "Workers", + value: summary.jobb.workers_ok ? "OK" : "NERE", + tone: summary.jobb.workers_ok ? "ok" : "warn", + }, + { + label: "Allergen-brott", + value: formatInt(summary.sakerhet.allergen_invariant_brott), + tone: summary.sakerhet.allergen_invariant_brott === 0 ? "ok" : "warn", + }, + ]; + + return { + boards: [{ title: "Cibello", tiles }], + }; +} + async function feedbackBlock(db: Database): Promise { const counts = await db.execute(sql` SELECT @@ -589,7 +648,7 @@ export async function computeOpsSummary(options: ComputeOpsSummaryOptions): Prom safetyBlock(db), ]); - return { + const base: OpsSummary = { app, ekonomi: economy, anvandare: users, @@ -602,6 +661,11 @@ export async function computeOpsSummary(options: ComputeOpsSummaryOptions): Prom betalning: payment, jobb: queueSummary ?? defaultQueueBlock(), sakerhet: safety, + wall: { boards: [] }, as_of: new Date().toISOString(), }; + + base.wall = buildWall(base); + + return base; } diff --git a/packages/database/test/ops-wall.test.ts b/packages/database/test/ops-wall.test.ts new file mode 100644 index 0000000..847beec --- /dev/null +++ b/packages/database/test/ops-wall.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest"; +import { buildWall } from "../src/ops-summary.js"; +import type { OpsSummary } from "@app/shared-types"; + +function baseSummary(overrides: Partial = {}): OpsSummary { + return { + app: { app: "cibello", generated_at: new Date().toISOString() }, + ekonomi: { mrr: 0, intakt_24h: 0, intakt_7d: 0, valuta: "SEK" }, + anvandare: { aktiva_nu: 0, dau: 0, mau: 0, nya_24h: 0, nya_7d: 0 }, + prenumerationer: { + trial_aktiva: 0, + trial_konverterade_24h: 0, + trial_konverterade_7d: 0, + konverteringsgrad_30d: null, + betalande: 0, + avslutade_24h: 0, + }, + butik: { butik: null, all_fields_phase_2: null }, + feedback: { oppna: 0, nya_24h: 0, senaste: null }, + ai_scan: { + scans_24h: 0, + scans_7d: 0, + lyckandegrad_24h: null, + latens_p50_ms: null, + latens_p95_ms: null, + felfrekvens_24h: null, + senaste_fel: null, + kostnad_usd_mikrocent_manad: 0, + kostnad_usd_mikrocent_24h: null, + kostnad_usd_mikrocent_24h_uppskattad: false, + budget_andel: null, + as_of: new Date().toISOString(), + }, + aktivering: { + scan_inom_24h_andel: null, + lagad_maltid_inom_7d_andel: null, + hushall_andra_medlem_aktiv_andel: null, + retention_d1: null, + retention_d7: null, + retention_d30: null, + }, + engagemang: { + lagade_maltider_24h: 0, + lagade_maltider_7d: 0, + rek_ctr_7d: null, + puffar_skickade_24h: 0, + puffar_atgardsgrad_7d: null, + }, + betalning: { + failed_nu: 0, + grace_period_nu: 0, + trials_utgar_48h: 0, + aterbetalningar_7d: null, + chargebacks_7d: null, + }, + jobb: { + vantande: 0, + aktiva: 0, + misslyckade_24h: 0, + aldsta_vantande_sek: null, + workers_ok: true, + }, + sakerhet: { + allergen_invariant_brott: 0, + overifierade_visade: 0, + food_safety_lint_avvisade_7d: null, + senaste_kontroll: null, + }, + wall: { boards: [] }, + as_of: new Date().toISOString(), + ...overrides, + }; +} + +/** + * Wall-testet verifierar att buildWall är härlett uteslutande ur summary-värden. + * Inga hårdkodade tal får förekomma. + */ +describe("buildWall", () => { + it("formaterar MRR i öre till kr med mellanslag", () => { + const summary = baseSummary({ ekonomi: { ...baseSummary().ekonomi, mrr: 1234567 } }); + const tile = buildWall(summary).boards[0]!.tiles.find((t) => t.label === "MRR")!; + expect(tile.value).toBe("12 346 kr"); + expect(tile.tone).toBe("ok"); + }); + + it("MRR null blir — med warn", () => { + const summary = baseSummary({ ekonomi: { ...baseSummary().ekonomi, mrr: null } }); + const tile = buildWall(summary).boards[0]!.tiles.find((t) => t.label === "MRR")!; + expect(tile.value).toBe("—"); + expect(tile.tone).toBe("warn"); + }); + + it("aktiva nu och betalande visas som heltal", () => { + const summary = baseSummary({ + anvandare: { ...baseSummary().anvandare, aktiva_nu: 42 }, + prenumerationer: { ...baseSummary().prenumerationer, betalande: 7 }, + }); + const tiles = buildWall(summary).boards[0]!.tiles; + expect(tiles.find((t) => t.label === "Aktiva nu")!.value).toBe("42"); + expect(tiles.find((t) => t.label === "Betalande")!.value).toBe("7"); + }); + + it("trial-konvertering blir procent; null blir —/warn", () => { + const withValue = baseSummary({ + prenumerationer: { ...baseSummary().prenumerationer, konverteringsgrad_30d: 0.3149 }, + }); + const tileValue = buildWall(withValue).boards[0]!.tiles.find((t) => t.label === "Trial-konv")!; + expect(tileValue.value).toBe("31 %"); + expect(tileValue.tone).toBe("ok"); + + const withNull = baseSummary(); + const tileNull = buildWall(withNull).boards[0]!.tiles.find((t) => t.label === "Trial-konv")!; + expect(tileNull.value).toBe("—"); + expect(tileNull.tone).toBe("warn"); + }); + + it("workers ok → OK; nere → NERE/warn", () => { + const ok = buildWall(baseSummary()).boards[0]!.tiles.find((t) => t.label === "Workers")!; + expect(ok.value).toBe("OK"); + expect(ok.tone).toBe("ok"); + + const nere = buildWall(baseSummary({ jobb: { ...baseSummary().jobb, workers_ok: false } })).boards[0]!.tiles.find( + (t) => t.label === "Workers", + )!; + expect(nere.value).toBe("NERE"); + expect(nere.tone).toBe("warn"); + }); + + it("allergen-brott 0 är ok; >0 varnar", () => { + const zero = buildWall(baseSummary()).boards[0]!.tiles.find((t) => t.label === "Allergen-brott")!; + expect(zero.value).toBe("0"); + expect(zero.tone).toBe("ok"); + + const brott = buildWall( + baseSummary({ sakerhet: { ...baseSummary().sakerhet, allergen_invariant_brott: 3 } }), + ).boards[0]!.tiles.find((t) => t.label === "Allergen-brott")!; + expect(brott.value).toBe("3"); + expect(brott.tone).toBe("warn"); + }); + + it("alla sex tiles finns i rätt ordning", () => { + const tiles = buildWall(baseSummary()).boards[0]!.tiles; + expect(tiles.map((t) => t.label)).toEqual([ + "MRR", + "Aktiva nu", + "Betalande", + "Trial-konv", + "Workers", + "Allergen-brott", + ]); + }); + + it("ändrat summary-värde ändrar tile (inga hårdkodade tal)", () => { + const first = buildWall(baseSummary({ anvandare: { ...baseSummary().anvandare, aktiva_nu: 1 } })); + const second = buildWall(baseSummary({ anvandare: { ...baseSummary().anvandare, aktiva_nu: 2 } })); + expect(first.boards[0]!.tiles.find((t) => t.label === "Aktiva nu")!.value).toBe("1"); + expect(second.boards[0]!.tiles.find((t) => t.label === "Aktiva nu")!.value).toBe("2"); + }); +}); diff --git a/packages/shared-types/src/ops.ts b/packages/shared-types/src/ops.ts index b06b335..f59edc8 100644 --- a/packages/shared-types/src/ops.ts +++ b/packages/shared-types/src/ops.ts @@ -100,6 +100,21 @@ export interface OpsSafetyBlock { senaste_kontroll: string | null; } +export interface OpsWallTile { + label: string; + value: string; + tone: "ok" | "warn"; +} + +export interface OpsWallBoard { + title: string; + tiles: OpsWallTile[]; +} + +export interface OpsWallBlock { + boards: OpsWallBoard[]; +} + export interface OpsSummary { app: OpsAppBlock; ekonomi: OpsEconomyBlock; @@ -113,5 +128,6 @@ export interface OpsSummary { betalning: OpsPaymentBlock; jobb: OpsQueueBlock; sakerhet: OpsSafetyBlock; + wall: OpsWallBlock; as_of: string; }