Fas 1a punkt 2: release_gates tabell + go/no-go-vy
This commit is contained in:
@@ -0,0 +1,520 @@
|
||||
/**
|
||||
* Release gate evaluator (spec §17).
|
||||
* Computes go/no-go status from product_analytics_events.
|
||||
*/
|
||||
import { sql } from "drizzle-orm";
|
||||
import type { Database } from "./client.js";
|
||||
import { schema } from "./index.js";
|
||||
import {
|
||||
BUILT_IN_RELEASE_GATES,
|
||||
RELEASE_GATE_STATUSES,
|
||||
type ReleaseGateComparison,
|
||||
type ReleaseGateStatus,
|
||||
} from "@app/shared-types";
|
||||
|
||||
export interface GateEvaluation {
|
||||
gateKey: string;
|
||||
category: string;
|
||||
nameSv: string;
|
||||
nameEn: string;
|
||||
targetValue: number;
|
||||
comparison: ReleaseGateComparison;
|
||||
value: number | null;
|
||||
status: ReleaseGateStatus;
|
||||
lastEvaluatedAt: string;
|
||||
blocking: boolean;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
function compare(value: number, target: number, comparison: ReleaseGateComparison): boolean {
|
||||
if (comparison === "gte") return value >= target;
|
||||
if (comparison === "lte") return value <= target;
|
||||
return Math.abs(value - target) < Number.EPSILON;
|
||||
}
|
||||
|
||||
function decideStatus(
|
||||
value: number | null,
|
||||
target: number,
|
||||
comparison: ReleaseGateComparison,
|
||||
measurable = true,
|
||||
): ReleaseGateStatus {
|
||||
if (!measurable || value === null || Number.isNaN(value)) return "not_measurable";
|
||||
return compare(value, target, comparison) ? "passed" : "failed";
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the built-in release gates from §17. Idempotent – existing rows are
|
||||
* left untouched so admin tuning is preserved.
|
||||
*/
|
||||
export async function seedReleaseGates(db: Database): Promise<void> {
|
||||
for (const gate of BUILT_IN_RELEASE_GATES) {
|
||||
await db
|
||||
.insert(schema.releaseGates)
|
||||
.values({
|
||||
gateKey: gate.gateKey,
|
||||
category: gate.category,
|
||||
nameSv: gate.nameSv,
|
||||
nameEn: gate.nameEn,
|
||||
descriptionSv: gate.descriptionSv,
|
||||
descriptionEn: gate.descriptionEn,
|
||||
targetValue: gate.targetValue,
|
||||
comparison: gate.comparison,
|
||||
evaluationWindowDays: gate.evaluationWindowDays,
|
||||
blocking: gate.blocking,
|
||||
status: "pending",
|
||||
})
|
||||
.onConflictDoNothing({ target: schema.releaseGates.gateKey });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate all release gates and persist results.
|
||||
*/
|
||||
export async function evaluateReleaseGates(db: Database): Promise<GateEvaluation[]> {
|
||||
const gates = await db.select().from(schema.releaseGates).orderBy(schema.releaseGates.gateKey);
|
||||
const now = new Date();
|
||||
const results: GateEvaluation[] = [];
|
||||
|
||||
for (const gate of gates) {
|
||||
const value = await evaluateGate(db, gate.gateKey, gate.evaluationWindowDays);
|
||||
const status = decideStatus(value, gate.targetValue, gate.comparison, value !== null);
|
||||
const updated = await db
|
||||
.update(schema.releaseGates)
|
||||
.set({
|
||||
lastValue: value,
|
||||
lastEvaluatedAt: now,
|
||||
status,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(sql`${schema.releaseGates.id} = ${gate.id}`)
|
||||
.returning();
|
||||
const row = updated[0] ?? gate;
|
||||
results.push({
|
||||
gateKey: row.gateKey,
|
||||
category: row.category,
|
||||
nameSv: row.nameSv,
|
||||
nameEn: row.nameEn,
|
||||
targetValue: row.targetValue,
|
||||
comparison: row.comparison,
|
||||
value: row.lastValue,
|
||||
status: row.status,
|
||||
lastEvaluatedAt: row.lastEvaluatedAt?.toISOString() ?? now.toISOString(),
|
||||
blocking: row.blocking,
|
||||
notes: row.notes ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a single gate by key. Returns null if not measurable yet.
|
||||
*/
|
||||
export async function evaluateGate(
|
||||
db: Database,
|
||||
gateKey: string,
|
||||
windowDays: number,
|
||||
): Promise<number | null> {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setUTCDate(start.getUTCDate() - windowDays);
|
||||
const startIso = start.toISOString();
|
||||
const endIso = end.toISOString();
|
||||
|
||||
switch (gateKey) {
|
||||
case "first_scan_rate":
|
||||
return userConversion(db, "account_created", "scan_completed", startIso, endIso);
|
||||
|
||||
case "recipe_view_rate":
|
||||
return userConversion(db, "account_created", "recommendations_viewed", startIso, endIso);
|
||||
|
||||
case "recipe_save_or_cook_day0":
|
||||
return sameDayConversion(db, ["recipe_saved", "cooking_session_started"], startIso, endIso);
|
||||
|
||||
case "second_inventory_event_week1":
|
||||
return repeatedEventRate(
|
||||
db,
|
||||
["inventory_item_added", "inventory_item_consumed", "inventory_item_discarded"],
|
||||
2,
|
||||
startIso,
|
||||
endIso,
|
||||
);
|
||||
|
||||
case "household_collaboration_rate":
|
||||
return householdCollaborationRate(db, startIso, endIso);
|
||||
|
||||
case "weekly_trusted_meal_week2":
|
||||
return weeklyTrustedMealWeek2(db, startIso, endIso);
|
||||
|
||||
case "critical_allergy_errors":
|
||||
// No dedicated event yet; report not_measurable until §5 allergy trust is built.
|
||||
return null;
|
||||
|
||||
case "correction_rate":
|
||||
return correctionRate(db, startIso, endIso);
|
||||
|
||||
case "sync_conflict_resolution_rate":
|
||||
return conflictResolutionRate(db, startIso, endIso);
|
||||
|
||||
case "retention_d1":
|
||||
return cohortRetention(db, 1, startIso, endIso);
|
||||
|
||||
case "retention_d7":
|
||||
return cohortRetention(db, 7, startIso, endIso);
|
||||
|
||||
case "retention_d30":
|
||||
return cohortRetention(db, 30, startIso, endIso);
|
||||
|
||||
case "retention_week4_household":
|
||||
return householdWeekRetention(db, 4, startIso, endIso);
|
||||
|
||||
case "paid_month2_retention":
|
||||
// Requires subscription lifecycle data not yet collected in analytics events.
|
||||
return null;
|
||||
|
||||
case "trial_to_paid_rate":
|
||||
return userConversion(db, "trial_started", "subscription_started", startIso, endIso);
|
||||
|
||||
case "refund_rate":
|
||||
// Requires refund events from billing provider.
|
||||
return null;
|
||||
|
||||
case "monthly_churn":
|
||||
// Requires subscription status history.
|
||||
return null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metric helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function userConversion(
|
||||
db: Database,
|
||||
fromEvent: string,
|
||||
toEvent: string,
|
||||
startIso: string,
|
||||
endIso: string,
|
||||
): Promise<number | null> {
|
||||
const raw = await db.execute(sql`
|
||||
WITH base AS (
|
||||
SELECT DISTINCT user_id
|
||||
FROM product_analytics_events
|
||||
WHERE event_name = ${fromEvent}
|
||||
AND occurred_at >= ${startIso}
|
||||
AND occurred_at < ${endIso}
|
||||
AND user_id IS NOT NULL
|
||||
),
|
||||
converted AS (
|
||||
SELECT DISTINCT b.user_id
|
||||
FROM base b
|
||||
INNER JOIN product_analytics_events e ON e.user_id = b.user_id
|
||||
WHERE e.event_name = ${toEvent}
|
||||
AND e.occurred_at >= ${startIso}
|
||||
AND e.occurred_at < ${endIso}
|
||||
)
|
||||
SELECT
|
||||
(SELECT count(*) FROM base) AS total,
|
||||
(SELECT count(*) FROM converted) AS converted
|
||||
`);
|
||||
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as {
|
||||
total: number;
|
||||
converted: number;
|
||||
} | undefined;
|
||||
if (!row || Number(row.total) === 0) return null;
|
||||
return Number(row.converted) / Number(row.total);
|
||||
}
|
||||
|
||||
function inClause(values: string[]) {
|
||||
return sql`(${sql.join(values.map((v) => sql`${v}`), sql`, `)})`;
|
||||
}
|
||||
|
||||
async function sameDayConversion(
|
||||
db: Database,
|
||||
toEvents: string[],
|
||||
startIso: string,
|
||||
endIso: string,
|
||||
): Promise<number | null> {
|
||||
const raw = await db.execute(sql`
|
||||
WITH base AS (
|
||||
SELECT user_id, (occurred_at AT TIME ZONE 'UTC')::date AS cohort_date
|
||||
FROM product_analytics_events
|
||||
WHERE event_name = 'account_created'
|
||||
AND occurred_at >= ${startIso}
|
||||
AND occurred_at < ${endIso}
|
||||
AND user_id IS NOT NULL
|
||||
),
|
||||
converted AS (
|
||||
SELECT DISTINCT b.user_id
|
||||
FROM base b
|
||||
INNER JOIN product_analytics_events e ON e.user_id = b.user_id
|
||||
WHERE e.event_name IN ${inClause(toEvents)}
|
||||
AND e.occurred_at >= ${startIso}
|
||||
AND e.occurred_at < ${endIso}
|
||||
AND (e.occurred_at AT TIME ZONE 'UTC')::date = b.cohort_date
|
||||
)
|
||||
SELECT
|
||||
(SELECT count(DISTINCT user_id) FROM base) AS total,
|
||||
(SELECT count(*) FROM converted) AS converted
|
||||
`);
|
||||
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as {
|
||||
total: number;
|
||||
converted: number;
|
||||
} | undefined;
|
||||
if (!row || Number(row.total) === 0) return null;
|
||||
return Number(row.converted) / Number(row.total);
|
||||
}
|
||||
|
||||
async function repeatedEventRate(
|
||||
db: Database,
|
||||
events: string[],
|
||||
minOccurrences: number,
|
||||
startIso: string,
|
||||
endIso: string,
|
||||
): Promise<number | null> {
|
||||
const raw = await db.execute(sql`
|
||||
WITH base AS (
|
||||
SELECT DISTINCT user_id
|
||||
FROM product_analytics_events
|
||||
WHERE event_name = 'account_created'
|
||||
AND occurred_at >= ${startIso}
|
||||
AND occurred_at < ${endIso}
|
||||
AND user_id IS NOT NULL
|
||||
),
|
||||
event_counts AS (
|
||||
SELECT b.user_id, count(*) AS n
|
||||
FROM base b
|
||||
INNER JOIN product_analytics_events e ON e.user_id = b.user_id
|
||||
WHERE e.event_name IN ${inClause(events)}
|
||||
AND e.occurred_at >= ${startIso}
|
||||
AND e.occurred_at < ${endIso}
|
||||
GROUP BY b.user_id
|
||||
HAVING count(*) >= ${minOccurrences}
|
||||
)
|
||||
SELECT
|
||||
(SELECT count(*) FROM base) AS total,
|
||||
(SELECT count(*) FROM event_counts) AS converted
|
||||
`);
|
||||
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as {
|
||||
total: number;
|
||||
converted: number;
|
||||
} | undefined;
|
||||
if (!row || Number(row.total) === 0) return null;
|
||||
return Number(row.converted) / Number(row.total);
|
||||
}
|
||||
|
||||
async function householdCollaborationRate(
|
||||
db: Database,
|
||||
startIso: string,
|
||||
endIso: string,
|
||||
): Promise<number | null> {
|
||||
const raw = await db.execute(sql`
|
||||
WITH base AS (
|
||||
SELECT DISTINCT household_id
|
||||
FROM product_analytics_events
|
||||
WHERE event_name = 'household_created'
|
||||
AND occurred_at >= ${startIso}
|
||||
AND occurred_at < ${endIso}
|
||||
AND household_id IS NOT NULL
|
||||
),
|
||||
collaborative AS (
|
||||
SELECT DISTINCT b.household_id
|
||||
FROM base b
|
||||
INNER JOIN product_analytics_events e ON e.household_id = b.household_id
|
||||
WHERE e.event_name IN ('household_invite_sent', 'household_invite_accepted', 'second_member_first_action')
|
||||
AND e.occurred_at >= ${startIso}
|
||||
AND e.occurred_at < ${endIso}
|
||||
)
|
||||
SELECT
|
||||
(SELECT count(*) FROM base) AS total,
|
||||
(SELECT count(*) FROM collaborative) AS converted
|
||||
`);
|
||||
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as {
|
||||
total: number;
|
||||
converted: number;
|
||||
} | undefined;
|
||||
if (!row || Number(row.total) === 0) return null;
|
||||
return Number(row.converted) / Number(row.total);
|
||||
}
|
||||
|
||||
async function weeklyTrustedMealWeek2(
|
||||
db: Database,
|
||||
startIso: string,
|
||||
endIso: string,
|
||||
): Promise<number | null> {
|
||||
const raw = await db.execute(sql`
|
||||
WITH activated AS (
|
||||
SELECT DISTINCT household_id,
|
||||
min(occurred_at) AS first_activated_at
|
||||
FROM product_analytics_events
|
||||
WHERE event_name = 'cooking_session_completed'
|
||||
AND household_id IS NOT NULL
|
||||
AND occurred_at >= ${startIso}
|
||||
AND occurred_at < ${endIso}
|
||||
GROUP BY household_id
|
||||
),
|
||||
week2_cooks AS (
|
||||
SELECT DISTINCT a.household_id
|
||||
FROM activated a
|
||||
INNER JOIN product_analytics_events e
|
||||
ON e.household_id = a.household_id
|
||||
AND e.event_name = 'cooking_session_completed'
|
||||
AND e.occurred_at >= a.first_activated_at + INTERVAL '7 days'
|
||||
AND e.occurred_at < a.first_activated_at + INTERVAL '14 days'
|
||||
)
|
||||
SELECT
|
||||
(SELECT count(*) FROM activated) AS total,
|
||||
(SELECT count(*) FROM week2_cooks) AS converted
|
||||
`);
|
||||
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as {
|
||||
total: number;
|
||||
converted: number;
|
||||
} | undefined;
|
||||
if (!row || Number(row.total) === 0) return null;
|
||||
return Number(row.converted) / Number(row.total);
|
||||
}
|
||||
|
||||
async function correctionRate(
|
||||
db: Database,
|
||||
startIso: string,
|
||||
endIso: string,
|
||||
): Promise<number | null> {
|
||||
const raw = await db.execute(sql`
|
||||
SELECT
|
||||
count(*) FILTER (WHERE event_name = 'scan_item_corrected') AS corrected,
|
||||
count(*) FILTER (WHERE event_name IN ('scan_item_confirmed', 'scan_item_corrected')) AS total
|
||||
FROM product_analytics_events
|
||||
WHERE event_name IN ('scan_item_confirmed', 'scan_item_corrected')
|
||||
AND occurred_at >= ${startIso}
|
||||
AND occurred_at < ${endIso}
|
||||
`);
|
||||
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as {
|
||||
corrected: number;
|
||||
total: number;
|
||||
} | undefined;
|
||||
if (!row || Number(row.total) === 0) return null;
|
||||
return Number(row.corrected) / Number(row.total);
|
||||
}
|
||||
|
||||
async function conflictResolutionRate(
|
||||
db: Database,
|
||||
startIso: string,
|
||||
endIso: string,
|
||||
): Promise<number | null> {
|
||||
const raw = await db.execute(sql`
|
||||
SELECT
|
||||
count(*) FILTER (WHERE event_name = 'inventory_conflict_resolved') AS resolved,
|
||||
count(*) FILTER (WHERE event_name IN ('inventory_conflict_created', 'inventory_conflict_resolved')) AS total
|
||||
FROM product_analytics_events
|
||||
WHERE event_name IN ('inventory_conflict_created', 'inventory_conflict_resolved')
|
||||
AND occurred_at >= ${startIso}
|
||||
AND occurred_at < ${endIso}
|
||||
`);
|
||||
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as {
|
||||
resolved: number;
|
||||
total: number;
|
||||
} | undefined;
|
||||
if (!row || Number(row.total) === 0) return null;
|
||||
return Number(row.resolved) / Number(row.total);
|
||||
}
|
||||
|
||||
async function cohortRetention(
|
||||
db: Database,
|
||||
day: number,
|
||||
startIso: string,
|
||||
endIso: string,
|
||||
): Promise<number | null> {
|
||||
const raw = await db.execute(sql`
|
||||
WITH cohort AS (
|
||||
SELECT DISTINCT user_id, (occurred_at AT TIME ZONE 'UTC')::date AS cohort_date
|
||||
FROM product_analytics_events
|
||||
WHERE event_name = 'account_created'
|
||||
AND occurred_at >= ${startIso}
|
||||
AND occurred_at < ${endIso}
|
||||
AND user_id IS NOT NULL
|
||||
),
|
||||
active AS (
|
||||
SELECT DISTINCT c.user_id
|
||||
FROM cohort c
|
||||
INNER JOIN product_analytics_events e ON e.user_id = c.user_id
|
||||
WHERE (e.occurred_at AT TIME ZONE 'UTC')::date = c.cohort_date + ${day}::integer
|
||||
)
|
||||
SELECT
|
||||
(SELECT count(DISTINCT user_id) FROM cohort) AS total,
|
||||
(SELECT count(*) FROM active) AS active
|
||||
`);
|
||||
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as {
|
||||
total: number;
|
||||
active: number;
|
||||
} | undefined;
|
||||
if (!row || Number(row.total) === 0) return null;
|
||||
return Number(row.active) / Number(row.total);
|
||||
}
|
||||
|
||||
async function householdWeekRetention(
|
||||
db: Database,
|
||||
week: number,
|
||||
startIso: string,
|
||||
endIso: string,
|
||||
): Promise<number | null> {
|
||||
const raw = await db.execute(sql`
|
||||
WITH cohort AS (
|
||||
SELECT DISTINCT household_id,
|
||||
(occurred_at AT TIME ZONE 'UTC')::date AS cohort_date
|
||||
FROM product_analytics_events
|
||||
WHERE event_name = 'household_created'
|
||||
AND occurred_at >= ${startIso}
|
||||
AND occurred_at < ${endIso}
|
||||
AND household_id IS NOT NULL
|
||||
),
|
||||
active AS (
|
||||
SELECT DISTINCT c.household_id
|
||||
FROM cohort c
|
||||
INNER JOIN product_analytics_events e ON e.household_id = c.household_id
|
||||
WHERE (e.occurred_at AT TIME ZONE 'UTC')::date >= c.cohort_date + ${(week - 1) * 7}::integer
|
||||
AND (e.occurred_at AT TIME ZONE 'UTC')::date < c.cohort_date + ${week * 7}::integer
|
||||
)
|
||||
SELECT
|
||||
(SELECT count(DISTINCT household_id) FROM cohort) AS total,
|
||||
(SELECT count(*) FROM active) AS active
|
||||
`);
|
||||
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as {
|
||||
total: number;
|
||||
active: number;
|
||||
} | undefined;
|
||||
if (!row || Number(row.total) === 0) return null;
|
||||
return Number(row.active) / Number(row.total);
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarise the current go/no-go verdict across all gates.
|
||||
*/
|
||||
export function summarizeReleaseGates(gates: GateEvaluation[]): {
|
||||
overall: "go" | "no_go" | "pending";
|
||||
passed: number;
|
||||
failed: number;
|
||||
blocked: number;
|
||||
pending: number;
|
||||
notMeasurable: number;
|
||||
} {
|
||||
let failed = 0;
|
||||
let blocked = 0;
|
||||
let pending = 0;
|
||||
let notMeasurable = 0;
|
||||
let passed = 0;
|
||||
for (const g of gates) {
|
||||
if (g.status === "passed") passed++;
|
||||
else if (g.status === "failed") {
|
||||
failed++;
|
||||
if (g.blocking) blocked++;
|
||||
} else if (g.status === "not_measurable") notMeasurable++;
|
||||
else pending++;
|
||||
}
|
||||
const overall = blocked > 0 ? "no_go" : failed > 0 ? "no_go" : pending > 0 ? "pending" : "go";
|
||||
return { overall, passed, failed, blocked, pending, notMeasurable };
|
||||
}
|
||||
|
||||
export { RELEASE_GATE_STATUSES };
|
||||
Reference in New Issue
Block a user