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,
};
}