FRE-605: Implement Phase 4 Change Tracking & Merge Logic

- 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>
This commit is contained in:
2026-04-25 02:14:54 -04:00
parent 7c684a42cc
commit b89575fb6e
26 changed files with 3346 additions and 70 deletions

View File

@@ -0,0 +1,194 @@
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,
},
};
}