Fas 1a punkt 2: release_gates tabell + go/no-go-vy

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 23:10:09 +07:00
parent 1250640b1d
commit d1455fb581
16 changed files with 10105 additions and 0 deletions
+1
View File
@@ -2,3 +2,4 @@ export * from "./client.js";
export * as schema from "./schema/index.js";
export * from "./schema/index.js";
export * from "./analytics-gdpr.js";
export * from "./release-gates.js";
+520
View File
@@ -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 };
+1
View File
@@ -17,3 +17,4 @@ export * from "./seasons.js";
export * from "./subscriptions.js";
export * from "./analytics.js";
export * from "./platform.js";
export * from "./releaseGates.js";
@@ -0,0 +1,57 @@
/**
* Release gates schema (spec §17).
* Configurable go/no-go criteria evaluated against analytics events.
*/
import { boolean, doublePrecision, index, integer, pgEnum, pgTable, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core";
import { createdAt, updatedAt } from "./_shared.js";
import {
RELEASE_GATE_CATEGORIES,
RELEASE_GATE_COMPARISONS,
RELEASE_GATE_STATUSES,
tuple,
} from "@app/shared-types";
export const releaseGateCategoryEnum = pgEnum(
"release_gate_category",
tuple(RELEASE_GATE_CATEGORIES),
);
export const releaseGateComparisonEnum = pgEnum(
"release_gate_comparison",
tuple(RELEASE_GATE_COMPARISONS),
);
export const releaseGateStatusEnum = pgEnum(
"release_gate_status",
tuple(RELEASE_GATE_STATUSES),
);
export const releaseGates = pgTable(
"release_gates",
{
id: uuid("id").primaryKey().defaultRandom(),
gateKey: varchar("gate_key", { length: 64 }).notNull().unique(),
category: releaseGateCategoryEnum("category").notNull(),
nameSv: text("name_sv").notNull(),
nameEn: text("name_en").notNull(),
descriptionSv: text("description_sv"),
descriptionEn: text("description_en"),
/** Target threshold (01 for ratios, absolute count for counts). */
targetValue: doublePrecision("target_value").notNull(),
comparison: releaseGateComparisonEnum("comparison").notNull(),
/** How many days of analytics events to include in the evaluation. */
evaluationWindowDays: integer("evaluation_window_days").notNull().default(7),
/** Latest computed value, if any. */
lastValue: doublePrecision("last_value"),
lastEvaluatedAt: timestamp("last_evaluated_at", { withTimezone: true }),
status: releaseGateStatusEnum("status").notNull().default("pending"),
/** True if this gate blocks a broad launch until passed. */
blocking: boolean("blocking").notNull().default(false),
/** Free-form admin notes (e.g., why a gate was waived). */
notes: text("notes"),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [
index("release_gates_category_idx").on(t.category),
index("release_gates_status_idx").on(t.status),
],
);
@@ -0,0 +1,93 @@
import { describe, expect, it, beforeEach, afterAll } from "vitest";
import { eq } from "drizzle-orm";
import { createDatabase, closeDatabase } from "../src/client.js";
import { schema, seedReleaseGates, evaluateReleaseGates, summarizeReleaseGates } from "../src/index.js";
import { BUILT_IN_RELEASE_GATES } from "@app/shared-types";
const { db, pool } = createDatabase();
async function reset() {
await db.delete(schema.productAnalyticsEvents);
await db.delete(schema.releaseGates);
await db
.delete(schema.users)
.where(eq(schema.users.email, "release-gates-test@example.invalid"));
}
async function createTestUser(id: string) {
const [user] = await db
.insert(schema.users)
.values({
id,
email: `release-gates-test-${id.slice(0, 8)}@example.invalid`,
displayName: "Release Gate Test",
locale: "sv-SE",
})
.returning({ id: schema.users.id });
return user!.id;
}
beforeEach(async () => {
await reset();
});
afterAll(async () => {
await reset();
await closeDatabase();
});
describe("release gates", () => {
it("seeds built-in gates idempotently", async () => {
await seedReleaseGates(db);
const first = await db.select().from(schema.releaseGates);
expect(first.length).toBe(BUILT_IN_RELEASE_GATES.length);
await seedReleaseGates(db);
const second = await db.select().from(schema.releaseGates);
expect(second.length).toBe(BUILT_IN_RELEASE_GATES.length);
});
it("evaluates first_scan_rate from product_analytics_events", async () => {
await seedReleaseGates(db);
const userA = await createTestUser("11111111-1111-1111-1111-111111111111");
const userB = await createTestUser("22222222-2222-2222-2222-222222222222");
const now = new Date();
await db.insert(schema.productAnalyticsEvents).values([
{ eventName: "account_created", userId: userA, occurredAt: now, receivedAt: now, properties: {} },
{ eventName: "account_created", userId: userB, occurredAt: now, receivedAt: now, properties: {} },
{ eventName: "scan_completed", userId: userA, occurredAt: now, receivedAt: now, properties: {} },
]);
const results = await evaluateReleaseGates(db);
const firstScan = results.find((g) => g.gateKey === "first_scan_rate");
expect(firstScan).toBeDefined();
expect(firstScan?.value).toBeCloseTo(0.5);
expect(firstScan?.status).toBe("failed"); // target 70%
});
it("summarizes overall go/no-go verdict", async () => {
await seedReleaseGates(db);
const results = await evaluateReleaseGates(db);
const summary = summarizeReleaseGates(results);
expect(summary.overall).toBeOneOf(["go", "no_go", "pending"]);
expect(summary.passed + summary.failed + summary.pending + summary.notMeasurable).toBe(
BUILT_IN_RELEASE_GATES.length,
);
});
it("persists evaluation results", async () => {
await seedReleaseGates(db);
const user = await createTestUser("33333333-3333-3333-3333-333333333333");
const now = new Date();
await db.insert(schema.productAnalyticsEvents).values([
{ eventName: "account_created", userId: user, occurredAt: now, receivedAt: now, properties: {} },
{ eventName: "scan_completed", userId: user, occurredAt: now, receivedAt: now, properties: {} },
]);
await evaluateReleaseGates(db);
const [gate] = await db.select().from(schema.releaseGates).where(eq(schema.releaseGates.gateKey, "first_scan_rate"));
expect(gate?.lastValue).toBeCloseTo(1);
expect(gate?.status).toBe("passed");
expect(gate?.lastEvaluatedAt).not.toBeNull();
});
});
+1
View File
@@ -7,3 +7,4 @@ export * from "./locale.js";
export * from "./money.js";
export * from "./measurement.js";
export * from "./analytics.js";
export * from "./release-gates.js";
+255
View File
@@ -0,0 +1,255 @@
/**
* Release gate definitions and go/no-go taxonomy (spec §17).
*/
export const RELEASE_GATE_CATEGORIES = [
"product",
"quality",
"retention",
"economy",
] as const;
export type ReleaseGateCategory = (typeof RELEASE_GATE_CATEGORIES)[number];
export const RELEASE_GATE_COMPARISONS = ["gte", "lte", "eq"] as const;
export type ReleaseGateComparison = (typeof RELEASE_GATE_COMPARISONS)[number];
export const RELEASE_GATE_STATUSES = [
"pending",
"passed",
"failed",
"blocked",
"not_measurable",
] as const;
export type ReleaseGateStatus = (typeof RELEASE_GATE_STATUSES)[number];
/**
* Built-in release gates seeded from §17 go/no-go criteria.
* Values are starting targets; admins can tune them before launch.
*/
export const BUILT_IN_RELEASE_GATES: Array<{
gateKey: string;
category: ReleaseGateCategory;
nameSv: string;
nameEn: string;
descriptionSv: string;
descriptionEn: string;
targetValue: number;
comparison: ReleaseGateComparison;
evaluationWindowDays: number;
blocking: boolean;
}> = [
// Product
{
gateKey: "first_scan_rate",
category: "product",
nameSv: "Första skanning genomförd",
nameEn: "First scan completed",
descriptionSv: "Andel användare som genomför minst en skanning efter kontoregistrering.",
descriptionEn: "Share of users who complete at least one scan after account creation.",
targetValue: 0.7,
comparison: "gte",
evaluationWindowDays: 7,
blocking: true,
},
{
gateKey: "recipe_view_rate",
category: "product",
nameSv: "Rekommendation visad",
nameEn: "Recommendation viewed",
descriptionSv: "Andel användare som ser minst ett relevant recept.",
descriptionEn: "Share of users who view at least one relevant recipe.",
targetValue: 0.5,
comparison: "gte",
evaluationWindowDays: 7,
blocking: true,
},
{
gateKey: "recipe_save_or_cook_day0",
category: "product",
nameSv: "Recept sparat eller tillagat dag 0",
nameEn: "Recipe saved or cooked on day 0",
descriptionSv: "Andel användare som sparar eller startar ett recept samma dag som registrering.",
descriptionEn: "Share of users who save or start cooking a recipe on registration day.",
targetValue: 0.35,
comparison: "gte",
evaluationWindowDays: 1,
blocking: true,
},
{
gateKey: "second_inventory_event_week1",
category: "product",
nameSv: "Andra lagerhändelsen inom 7 dagar",
nameEn: "Second inventory event within 7 days",
descriptionSv: "Andel användare som lägger till, konsumerar eller kasserar en vara inom en vecka.",
descriptionEn: "Share of users who add, consume, or discard an item within one week.",
targetValue: 0.25,
comparison: "gte",
evaluationWindowDays: 7,
blocking: true,
},
{
gateKey: "household_collaboration_rate",
category: "product",
nameSv: "Hushållssamarbete",
nameEn: "Household collaboration",
descriptionSv: "Andel hushåll där en andra medlem bjuds in eller är aktiv.",
descriptionEn: "Share of households where a second member is invited or active.",
targetValue: 0.2,
comparison: "gte",
evaluationWindowDays: 14,
blocking: false,
},
{
gateKey: "weekly_trusted_meal_week2",
category: "product",
nameSv: "Weekly Trusted Meal vecka 2",
nameEn: "Weekly trusted meal week 2",
descriptionSv: "Andel aktiverade hushåll som lagar minst en måltid under vecka två.",
descriptionEn: "Share of activated households that cook at least one meal in week two.",
targetValue: 0.3,
comparison: "gte",
evaluationWindowDays: 14,
blocking: false,
},
// Quality
{
gateKey: "critical_allergy_errors",
category: "quality",
nameSv: "Kritiska allergifel",
nameEn: "Critical allergy errors",
descriptionSv: "Accepterade kritiska allergifel ska vara noll.",
descriptionEn: "Accepted critical allergy errors must be zero.",
targetValue: 0,
comparison: "lte",
evaluationWindowDays: 7,
blocking: true,
},
{
gateKey: "correction_rate",
category: "quality",
nameSv: "Korrigeringsgrad skanning",
nameEn: "Scan correction rate",
descriptionSv: "Andel skannade varor som korrigeras av användaren.",
descriptionEn: "Share of scanned items that the user corrects.",
targetValue: 0.15,
comparison: "lte",
evaluationWindowDays: 7,
blocking: false,
},
{
gateKey: "sync_conflict_resolution_rate",
category: "quality",
nameSv: "Sync-konflikter lösta",
nameEn: "Sync conflicts resolved",
descriptionSv: "Andel sync-konflikter som löses utan dataförlust.",
descriptionEn: "Share of sync conflicts resolved without data loss.",
targetValue: 0.95,
comparison: "gte",
evaluationWindowDays: 7,
blocking: false,
},
// Retention
{
gateKey: "retention_d1",
category: "retention",
nameSv: "D1-retention",
nameEn: "D1 retention",
descriptionSv: "Andel användare aktiva dagen efter registrering.",
descriptionEn: "Share of users active one day after registration.",
targetValue: 0.4,
comparison: "gte",
evaluationWindowDays: 7,
blocking: true,
},
{
gateKey: "retention_d7",
category: "retention",
nameSv: "D7-retention",
nameEn: "D7 retention",
descriptionSv: "Andel användare aktiva sju dagar efter registrering.",
descriptionEn: "Share of users active seven days after registration.",
targetValue: 0.2,
comparison: "gte",
evaluationWindowDays: 14,
blocking: true,
},
{
gateKey: "retention_d30",
category: "retention",
nameSv: "D30-retention",
nameEn: "D30 retention",
descriptionSv: "Andel användare aktiva 30 dagar efter registrering.",
descriptionEn: "Share of users active 30 days after registration.",
targetValue: 0.1,
comparison: "gte",
evaluationWindowDays: 60,
blocking: true,
},
{
gateKey: "retention_week4_household",
category: "retention",
nameSv: "Hushållsretention vecka 4",
nameEn: "Week 4 household retention",
descriptionSv: "Andel hushåll aktiva under vecka fyra.",
descriptionEn: "Share of households active in week four.",
targetValue: 0.15,
comparison: "gte",
evaluationWindowDays: 35,
blocking: false,
},
{
gateKey: "paid_month2_retention",
category: "retention",
nameSv: "Betald månad 2-retention",
nameEn: "Paid month 2 retention",
descriptionSv: "Andel betalande hushåll kvar vid månad två.",
descriptionEn: "Share of paying households retained at month two.",
targetValue: 0.5,
comparison: "gte",
evaluationWindowDays: 90,
blocking: true,
},
// Economy
{
gateKey: "trial_to_paid_rate",
category: "economy",
nameSv: "Trial till betalande",
nameEn: "Trial to paid",
descriptionSv: "Andel trial-användare som blir betalande.",
descriptionEn: "Share of trial users who convert to paid.",
targetValue: 0.25,
comparison: "gte",
evaluationWindowDays: 30,
blocking: true,
},
{
gateKey: "refund_rate",
category: "economy",
nameSv: "Återbetalningsgrad",
nameEn: "Refund rate",
descriptionSv: "Andel betalningar som återbetalas.",
descriptionEn: "Share of payments refunded.",
targetValue: 0.05,
comparison: "lte",
evaluationWindowDays: 30,
blocking: false,
},
{
gateKey: "monthly_churn",
category: "economy",
nameSv: "Månatlig churn",
nameEn: "Monthly churn",
descriptionSv: "Andel betalande hushåll som avslutar per månad.",
descriptionEn: "Share of paying households that churn per month.",
targetValue: 0.1,
comparison: "lte",
evaluationWindowDays: 60,
blocking: false,
},
];
+1
View File
@@ -13,3 +13,4 @@ export * from "./memory.js";
export * from "./subscriptions.js";
export * from "./locale.js";
export * from "./analytics.js";
export * from "./release-gates.js";
+12
View File
@@ -0,0 +1,12 @@
import { z } from "zod";
import { RELEASE_GATE_CATEGORIES, RELEASE_GATE_COMPARISONS } from "@app/shared-types";
export const releaseGateUpdateSchema = z.object({
targetValue: z.number().finite(),
comparison: z.enum(RELEASE_GATE_COMPARISONS),
evaluationWindowDays: z.number().int().min(1).max(365),
blocking: z.boolean(),
notes: z.string().max(2000).optional(),
});
export type ReleaseGateUpdateInput = z.infer<typeof releaseGateUpdateSchema>;