feat(ops): wall board härlett ur summary-värden
This commit is contained in:
@@ -13,6 +13,8 @@ import type {
|
|||||||
OpsSubscriptionsBlock,
|
OpsSubscriptionsBlock,
|
||||||
OpsStoreBlock,
|
OpsStoreBlock,
|
||||||
OpsFeedbackBlock,
|
OpsFeedbackBlock,
|
||||||
|
OpsWallBlock,
|
||||||
|
OpsWallTile,
|
||||||
} from "@app/shared-types";
|
} from "@app/shared-types";
|
||||||
import { SUBSCRIPTION_PLANS, type SubscriptionPlan } from "@app/shared-types";
|
import { SUBSCRIPTION_PLANS, type SubscriptionPlan } from "@app/shared-types";
|
||||||
import type { Database } from "./client.js";
|
import type { Database } from "./client.js";
|
||||||
@@ -45,6 +47,24 @@ function asNumber(value: unknown): number {
|
|||||||
return Number(value ?? 0);
|
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 {
|
function safeDiv(numerator: number, denominator: number): number | null {
|
||||||
if (denominator === 0) return null;
|
if (denominator === 0) return null;
|
||||||
return numerator / denominator;
|
return numerator / denominator;
|
||||||
@@ -533,6 +553,45 @@ function storeBlock(): OpsStoreBlock {
|
|||||||
return { butik: null, all_fields_phase_2: null };
|
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<OpsFeedbackBlock> {
|
async function feedbackBlock(db: Database): Promise<OpsFeedbackBlock> {
|
||||||
const counts = await db.execute(sql`
|
const counts = await db.execute(sql`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -589,7 +648,7 @@ export async function computeOpsSummary(options: ComputeOpsSummaryOptions): Prom
|
|||||||
safetyBlock(db),
|
safetyBlock(db),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
const base: OpsSummary = {
|
||||||
app,
|
app,
|
||||||
ekonomi: economy,
|
ekonomi: economy,
|
||||||
anvandare: users,
|
anvandare: users,
|
||||||
@@ -602,6 +661,11 @@ export async function computeOpsSummary(options: ComputeOpsSummaryOptions): Prom
|
|||||||
betalning: payment,
|
betalning: payment,
|
||||||
jobb: queueSummary ?? defaultQueueBlock(),
|
jobb: queueSummary ?? defaultQueueBlock(),
|
||||||
sakerhet: safety,
|
sakerhet: safety,
|
||||||
|
wall: { boards: [] },
|
||||||
as_of: new Date().toISOString(),
|
as_of: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
base.wall = buildWall(base);
|
||||||
|
|
||||||
|
return base;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> = {}): 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -100,6 +100,21 @@ export interface OpsSafetyBlock {
|
|||||||
senaste_kontroll: string | null;
|
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 {
|
export interface OpsSummary {
|
||||||
app: OpsAppBlock;
|
app: OpsAppBlock;
|
||||||
ekonomi: OpsEconomyBlock;
|
ekonomi: OpsEconomyBlock;
|
||||||
@@ -113,5 +128,6 @@ export interface OpsSummary {
|
|||||||
betalning: OpsPaymentBlock;
|
betalning: OpsPaymentBlock;
|
||||||
jobb: OpsQueueBlock;
|
jobb: OpsQueueBlock;
|
||||||
sakerhet: OpsSafetyBlock;
|
sakerhet: OpsSafetyBlock;
|
||||||
|
wall: OpsWallBlock;
|
||||||
as_of: string;
|
as_of: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user