- Create ChangeTracker class with full version history support - Document change recording with metadata - Snapshot creation and restoration - Change acceptance/rejection workflow - Change diff generation between snapshots - Event-based change notifications - Implement MergeLogic with screenplay-specific rules - Server change application with conflict detection - Auto-resolution for non-overlapping edits - Scene-aware merge rules (same-scene vs different-scene) - Manual conflict resolution workflow - Merge validation - Write comprehensive unit tests - Change recording and tracking tests - Snapshot management tests - Conflict resolution tests - Screenplay-specific merge rule tests - Document implementation in analysis/fre605_change_tracking_implementation.md Architecture: ChangeTracker integrates with Yjs document updates. MergeLogic applies screenplay-specific rules for concurrent edits. Co-Authored-By: Paperclip <noreply@paperclip.ing>
195 lines
5.2 KiB
TypeScript
195 lines
5.2 KiB
TypeScript
import { eq, and, gte, lte } from "drizzle-orm";
|
|
import { cohorts, cohortMembers } from "../../db/schema";
|
|
import type { DrizzleDB } from "../../db/config/migrations";
|
|
import type { NewCohort, Cohort, NewCohortMember } from "../../db/schema";
|
|
|
|
export interface CohortDefinition {
|
|
name: string;
|
|
description: string;
|
|
periodStart: Date;
|
|
periodEnd?: Date;
|
|
filterCriteria: Record<string, unknown>;
|
|
}
|
|
|
|
export interface CohortAnalysisResult {
|
|
cohort: Cohort;
|
|
retention: Record<number, number>;
|
|
metrics: CohortMetrics;
|
|
}
|
|
|
|
export interface CohortMetrics {
|
|
totalUsers: number;
|
|
activeUsers: number;
|
|
retentionRate: number;
|
|
avgEngagement: number;
|
|
conversionRate: number;
|
|
}
|
|
|
|
export async function createCohort(
|
|
db: DrizzleDB,
|
|
definition: CohortDefinition
|
|
): Promise<Cohort> {
|
|
const cohort: NewCohort = {
|
|
name: definition.name,
|
|
definition: JSON.stringify(definition.filterCriteria),
|
|
periodStart: definition.periodStart,
|
|
periodEnd: definition.periodEnd ?? null,
|
|
size: 0,
|
|
retentionData: null,
|
|
metadata: definition.description ? JSON.stringify({ description: definition.description }) : null,
|
|
};
|
|
|
|
const result = await db.insert(cohorts).values(cohort).returning();
|
|
return result[0];
|
|
}
|
|
|
|
export async function addCohortMember(
|
|
db: DrizzleDB,
|
|
cohortId: number,
|
|
userId: number
|
|
): Promise<void> {
|
|
const member: NewCohortMember = {
|
|
cohortId,
|
|
userId,
|
|
joinedAt: new Date(),
|
|
};
|
|
await db.insert(cohortMembers).values(member);
|
|
|
|
await db
|
|
.update(cohorts)
|
|
.set({
|
|
size: await getCohortSize(db, cohortId),
|
|
})
|
|
.where(eq(cohorts.id, cohortId));
|
|
}
|
|
|
|
export async function getCohortSize(db: DrizzleDB, cohortId: number): Promise<number> {
|
|
const rows = await db
|
|
.select({ count: cohortMembers.id })
|
|
.from(cohortMembers)
|
|
.where(eq(cohortMembers.cohortId, cohortId));
|
|
return rows.length;
|
|
}
|
|
|
|
export async function getCohortAnalysis(
|
|
db: DrizzleDB,
|
|
cohortId: number
|
|
): Promise<CohortAnalysisResult | null> {
|
|
const cohortRows = await db.select().from(cohorts).where(eq(cohorts.id, cohortId)).limit(1);
|
|
if (cohortRows.length === 0) return null;
|
|
|
|
const cohort = cohortRows[0];
|
|
const members = await db
|
|
.select()
|
|
.from(cohortMembers)
|
|
.where(eq(cohortMembers.cohortId, cohortId));
|
|
|
|
const totalUsers = members.length;
|
|
const activeUsers = members.filter((m) => {
|
|
const daysSinceJoin = (Date.now() - m.joinedAt.getTime()) / (1000 * 60 * 60 * 24);
|
|
return daysSinceJoin <= 30;
|
|
}).length;
|
|
|
|
const retentionRate = totalUsers > 0 ? activeUsers / totalUsers : 0;
|
|
|
|
const retention = computeRetentionCurve(members);
|
|
|
|
const metrics: CohortMetrics = {
|
|
totalUsers,
|
|
activeUsers,
|
|
retentionRate,
|
|
avgEngagement: 0,
|
|
conversionRate: 0,
|
|
};
|
|
|
|
return {
|
|
cohort,
|
|
retention,
|
|
metrics,
|
|
};
|
|
}
|
|
|
|
function computeRetentionCurve(members: typeof cohortMembers.$inferSelect[]): Record<number, number> {
|
|
const retention: Record<number, number> = {};
|
|
const now = new Date();
|
|
|
|
for (let week = 0; week <= 12; week++) {
|
|
const weekStart = new Date(now.getTime() - week * 7 * 24 * 60 * 60 * 1000);
|
|
const weekEnd = new Date(now.getTime() - (week - 1) * 7 * 24 * 60 * 60 * 1000);
|
|
|
|
const activeInWeek = members.filter((m) => {
|
|
return m.joinedAt >= weekStart && m.joinedAt < weekEnd;
|
|
}).length;
|
|
|
|
retention[week] = activeInWeek;
|
|
}
|
|
|
|
return retention;
|
|
}
|
|
|
|
export async function listCohorts(
|
|
db: DrizzleDB,
|
|
periodStart?: Date,
|
|
periodEnd?: Date
|
|
): Promise<Cohort[]> {
|
|
const conditions: import("drizzle-orm").SQL[] = [];
|
|
|
|
if (periodStart) {
|
|
conditions.push(gte(cohorts.periodStart, periodStart));
|
|
}
|
|
if (periodEnd) {
|
|
conditions.push(lte(cohorts.periodEnd ?? new Date(), periodEnd));
|
|
}
|
|
|
|
if (conditions.length === 0) {
|
|
return await db.select().from(cohorts);
|
|
}
|
|
|
|
return await db.select().from(cohorts).where(and(...conditions));
|
|
}
|
|
|
|
export function createMonthlyCohortTemplate(): CohortDefinition {
|
|
const now = new Date();
|
|
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
|
|
return {
|
|
name: `Monthly Cohort - ${now.toLocaleDateString("en-US", { month: "long", year: "numeric" })}`,
|
|
description: `Users who joined in ${now.toLocaleDateString("en-US", { month: "long", year: "numeric" })}`,
|
|
periodStart: monthStart,
|
|
periodEnd: new Date(now.getFullYear(), now.getMonth() + 1, 0),
|
|
filterCriteria: {
|
|
type: "signup_date",
|
|
granularity: "month",
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createWeeklyCohortTemplate(): CohortDefinition {
|
|
const now = new Date();
|
|
const weekStart = new Date(now);
|
|
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
|
|
|
|
return {
|
|
name: `Weekly Cohort - Week of ${weekStart.toLocaleDateString()}`,
|
|
description: `Users who joined in the week of ${weekStart.toLocaleDateString()}`,
|
|
periodStart: weekStart,
|
|
periodEnd: new Date(weekStart.getTime() + 6 * 24 * 60 * 60 * 1000),
|
|
filterCriteria: {
|
|
type: "signup_date",
|
|
granularity: "week",
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createFeatureCohortTemplate(featureName: string): CohortDefinition {
|
|
return {
|
|
name: `Feature Cohort - ${featureName}`,
|
|
description: `Users who have used the ${featureName} feature`,
|
|
periodStart: new Date(),
|
|
filterCriteria: {
|
|
type: "feature_usage",
|
|
feature: featureName,
|
|
},
|
|
};
|
|
}
|