Fas 1b punkt 6: First Scan Coach + aktiveringsmätning

This commit is contained in:
Sven (AAMOS AI)
2026-08-06 04:14:49 +07:00
parent 2f3cdf8016
commit bd8df59138
29 changed files with 9378 additions and 29 deletions
+134
View File
@@ -0,0 +1,134 @@
import { eq } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import * as schema from "./schema/index.js";
type Db = NodePgDatabase<typeof schema>;
export interface ActivationState {
firstScanCompletedAt: Date | null;
fifthItemConfirmedAt: Date | null;
firstRecipeRecommendationViewedAt: Date | null;
firstRecipeSavedOrStartedAt: Date | null;
activatedAt: Date | null;
firstCookingSessionCompletedAt: Date | null;
inventoryUpdatedAfterCookingAt: Date | null;
valueConfirmedAt: Date | null;
}
function milestoneColumn(milestone: keyof ActivationState) {
return schema.households[milestone] as (typeof schema.households)["firstScanCompletedAt"];
}
const activationMilestones: Array<keyof ActivationState> = [
"firstScanCompletedAt",
"fifthItemConfirmedAt",
"firstRecipeRecommendationViewedAt",
"firstRecipeSavedOrStartedAt",
"activatedAt",
"firstCookingSessionCompletedAt",
"inventoryUpdatedAfterCookingAt",
"valueConfirmedAt",
];
function isActivationMilestone(m: string): m is keyof ActivationState {
return activationMilestones.includes(m as keyof ActivationState);
}
/**
* Markera en milstolpe och utvärdera aktiveringsstatus.
* Endast den första bekräftelsen sparas; anrop är idempotenta.
*/
export async function markMilestone(
db: Db,
householdId: string,
milestone: keyof ActivationState,
): Promise<{ wasNew: boolean; activatedNow: boolean; valueConfirmedNow: boolean }> {
if (!isActivationMilestone(milestone)) {
throw new Error(`Unknown activation milestone: ${milestone}`);
}
const [before] = await db
.select({
milestoneAt: milestoneColumn(milestone),
firstScanCompletedAt: schema.households.firstScanCompletedAt,
fifthItemConfirmedAt: schema.households.fifthItemConfirmedAt,
firstRecipeRecommendationViewedAt: schema.households.firstRecipeRecommendationViewedAt,
firstRecipeSavedOrStartedAt: schema.households.firstRecipeSavedOrStartedAt,
activatedAt: schema.households.activatedAt,
firstCookingSessionCompletedAt: schema.households.firstCookingSessionCompletedAt,
inventoryUpdatedAfterCookingAt: schema.households.inventoryUpdatedAfterCookingAt,
valueConfirmedAt: schema.households.valueConfirmedAt,
})
.from(schema.households)
.where(eq(schema.households.id, householdId));
if (!before) {
throw new Error(`Household not found: ${householdId}`);
}
const wasNew = before.milestoneAt == null;
const wasActivated = before.activatedAt != null;
const wasValueConfirmed = before.valueConfirmedAt != null;
if (!wasNew) {
return { wasNew: false, activatedNow: false, valueConfirmedNow: false };
}
const isActivatedNow =
before.firstScanCompletedAt != null &&
before.fifthItemConfirmedAt != null &&
before.firstRecipeRecommendationViewedAt != null &&
(before.firstRecipeSavedOrStartedAt != null || milestone === "firstRecipeSavedOrStartedAt");
const activatedNow = !wasActivated && isActivatedNow;
const isValueConfirmedNow =
isActivatedNow &&
(before.firstCookingSessionCompletedAt != null || milestone === "firstCookingSessionCompletedAt") &&
(before.inventoryUpdatedAfterCookingAt != null || milestone === "inventoryUpdatedAfterCookingAt");
const valueConfirmedNow = !wasValueConfirmed && isValueConfirmedNow;
const updates: Partial<typeof schema.households.$inferInsert> = {
[milestone]: new Date(),
updatedAt: new Date(),
};
if (activatedNow) {
updates.activatedAt = new Date();
}
if (valueConfirmedNow) {
updates.valueConfirmedAt = new Date();
}
await db.update(schema.households).set(updates).where(eq(schema.households.id, householdId));
return { wasNew, activatedNow, valueConfirmedNow };
}
export async function getActivationState(db: Db, householdId: string): Promise<ActivationState> {
const [row] = await db
.select({
firstScanCompletedAt: schema.households.firstScanCompletedAt,
fifthItemConfirmedAt: schema.households.fifthItemConfirmedAt,
firstRecipeRecommendationViewedAt: schema.households.firstRecipeRecommendationViewedAt,
firstRecipeSavedOrStartedAt: schema.households.firstRecipeSavedOrStartedAt,
activatedAt: schema.households.activatedAt,
firstCookingSessionCompletedAt: schema.households.firstCookingSessionCompletedAt,
inventoryUpdatedAfterCookingAt: schema.households.inventoryUpdatedAfterCookingAt,
valueConfirmedAt: schema.households.valueConfirmedAt,
})
.from(schema.households)
.where(eq(schema.households.id, householdId));
return row ?? {
firstScanCompletedAt: null,
fifthItemConfirmedAt: null,
firstRecipeRecommendationViewedAt: null,
firstRecipeSavedOrStartedAt: null,
activatedAt: null,
firstCookingSessionCompletedAt: null,
inventoryUpdatedAfterCookingAt: null,
valueConfirmedAt: null,
};
}
+1
View File
@@ -3,3 +3,4 @@ export * as schema from "./schema/index.js";
export * from "./schema/index.js";
export * from "./analytics-gdpr.js";
export * from "./release-gates.js";
export * from "./activation.js";
@@ -21,6 +21,24 @@ export const households = pgTable(
name: text("name").notNull(),
inviteCode: text("invite_code").notNull(),
weeklyBudgetMinor: integer("weekly_budget_minor"),
/** Fas 1b: aktiveringsmått (spec §4.2) */
firstScanCompletedAt: timestamp("first_scan_completed_at", { withTimezone: true }),
fifthItemConfirmedAt: timestamp("fifth_item_confirmed_at", { withTimezone: true }),
firstRecipeRecommendationViewedAt: timestamp(
"first_recipe_recommendation_viewed_at",
{ withTimezone: true },
),
firstRecipeSavedOrStartedAt: timestamp("first_recipe_saved_or_started_at", {
withTimezone: true,
}),
activatedAt: timestamp("activated_at", { withTimezone: true }),
firstCookingSessionCompletedAt: timestamp("first_cooking_session_completed_at", {
withTimezone: true,
}),
inventoryUpdatedAfterCookingAt: timestamp("inventory_updated_after_cooking_at", {
withTimezone: true,
}),
valueConfirmedAt: timestamp("value_confirmed_at", { withTimezone: true }),
/** ISO 4217. Pengagränsen går vid hushållet (i18n-spec §20) alla belopp i hushållet tolkas i denna valuta. */
currencyCode: varchar("currency_code", { length: 3 }).notNull().default("SEK"),
createdAt: createdAt(),
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it, beforeAll, afterAll } from "vitest";
import { eq, inArray } from "drizzle-orm";
import { createDatabase, closeDatabase, markMilestone, getActivationState, schema } from "@app/database";
describe("activation", () => {
const { db } = createDatabase();
const emails = ["act-test-1@example.invalid", "act-test-2@example.invalid"];
const inviteCodes = ["ACT123", "ACT223"];
async function cleanup() {
await db.delete(schema.householdMembers).where(
inArray(
schema.householdMembers.householdId,
db.select({ id: schema.households.id }).from(schema.households).where(inArray(schema.households.inviteCode, inviteCodes)),
),
);
await db.delete(schema.households).where(inArray(schema.households.inviteCode, inviteCodes));
await db.delete(schema.users).where(inArray(schema.users.email, emails));
}
beforeAll(cleanup);
afterAll(async () => {
await cleanup();
await closeDatabase();
});
it("marks first scan and activates household", async () => {
const [user] = await db
.insert(schema.users)
.values({ email: emails[0]!, displayName: "Act" })
.returning();
const [household] = await db
.insert(schema.households)
.values({ name: "Act family", inviteCode: inviteCodes[0]! })
.returning();
await db.insert(schema.householdMembers).values({
householdId: household.id,
userId: user.id,
role: "owner",
});
const r1 = await markMilestone(db, household.id, "firstScanCompletedAt");
expect(r1.wasNew).toBe(true);
expect(r1.activatedNow).toBe(false);
await markMilestone(db, household.id, "fifthItemConfirmedAt");
await markMilestone(db, household.id, "firstRecipeRecommendationViewedAt");
const r2 = await markMilestone(db, household.id, "firstRecipeSavedOrStartedAt");
expect(r2.activatedNow).toBe(true);
const state = await getActivationState(db, household.id);
expect(state.activatedAt).not.toBeNull();
expect(state.valueConfirmedAt).toBeNull();
await markMilestone(db, household.id, "firstCookingSessionCompletedAt");
const r3 = await markMilestone(db, household.id, "inventoryUpdatedAfterCookingAt");
expect(r3.valueConfirmedNow).toBe(true);
const state2 = await getActivationState(db, household.id);
expect(state2.valueConfirmedAt).not.toBeNull();
});
it("idempotent: does not move timestamps backward", async () => {
const [user] = await db
.insert(schema.users)
.values({ email: emails[1]!, displayName: "Act2" })
.returning();
const [household] = await db
.insert(schema.households)
.values({ name: "Act2 family", inviteCode: inviteCodes[1]! })
.returning();
await db.insert(schema.householdMembers).values({
householdId: household.id,
userId: user.id,
role: "owner",
});
await markMilestone(db, household.id, "firstScanCompletedAt");
const state1 = await getActivationState(db, household.id);
await new Promise((r) => setTimeout(r, 20));
await markMilestone(db, household.id, "firstScanCompletedAt");
const state2 = await getActivationState(db, household.id);
expect(state2.firstScanCompletedAt?.getTime()).toBe(state1.firstScanCompletedAt?.getTime());
});
});
+2
View File
@@ -75,6 +75,8 @@ export const ANALYTICS_HOUSEHOLD_EVENT_NAMES = [
"household_invite_sent",
"household_invite_accepted",
"second_member_first_action",
"household_activated",
"household_value_confirmed",
] as const;
// ---------------------------------------------------------------------------