File size: 3,624 Bytes
65d68f5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 | import { apiGet, apiPost } from "@/lib/api/client";
import type { ActivityEvent, ActivityKind } from "@/lib/student-activity";
type ApiEnvelope<T> = {
success: boolean;
data: T;
meta: {
timestamp: string;
message?: string;
};
};
export type ProgressSyncEvent = {
event_id: string;
event_type: ActivityKind;
title: string;
detail?: string;
subject?: string;
chapter_id?: string;
mission_id?: string;
occurred_at: string;
sample: boolean;
};
export type ProgressSyncResult = {
synced: boolean;
user_id: string;
events_received: number;
events_stored: number;
duplicates: number;
};
export type ProgressSyncSummary = {
user_id: string;
total_sessions: number;
total_events: number;
sample_events: number;
total_duration_seconds: number;
by_type: Partial<Record<ActivityKind, number>>;
last_activity_at: string | null;
recent_events: ActivityEvent[];
};
const activityKinds = new Set<ActivityKind>([
"question_asked",
"lesson_started",
"lesson_completed",
"note_saved",
"quiz_attempted",
"demo_class_completed",
]);
function optionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}
export function toProgressSyncEvent(event: ActivityEvent): ProgressSyncEvent {
return {
event_id: event.id,
event_type: event.kind,
title: event.title,
...(event.detail ? { detail: event.detail } : {}),
...(event.subject ? { subject: event.subject } : {}),
...(event.chapterId ? { chapter_id: event.chapterId } : {}),
...(event.missionId ? { mission_id: event.missionId } : {}),
occurred_at: event.at,
sample: Boolean(event.sample),
};
}
function normalizeRemoteEvent(value: unknown): ActivityEvent | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const row = value as Record<string, unknown>;
if (
typeof row.id !== "string" ||
typeof row.kind !== "string" ||
!activityKinds.has(row.kind as ActivityKind) ||
typeof row.title !== "string" ||
typeof row.at !== "string" ||
Number.isNaN(Date.parse(row.at))
) {
return null;
}
const kind = row.kind as ActivityKind;
const sample = row.sample === true;
if (kind === "demo_class_completed" && !sample) return null;
return {
id: row.id,
kind,
title: row.title,
at: row.at,
...(optionalString(row.detail) ? { detail: optionalString(row.detail) } : {}),
...(optionalString(row.subject) ? { subject: optionalString(row.subject) } : {}),
...(optionalString(row.chapterId) ? { chapterId: optionalString(row.chapterId) } : {}),
...(optionalString(row.missionId) ? { missionId: optionalString(row.missionId) } : {}),
...(kind === "demo_class_completed"
? { provenance: "demo" as const, sample: true }
: sample
? { sample: true }
: {}),
};
}
export async function syncProgressEvents(
events: ActivityEvent[],
): Promise<ProgressSyncResult> {
const response = await apiPost<
ApiEnvelope<ProgressSyncResult>,
{ events: ProgressSyncEvent[] }
>("/sync/progress", {
events: events.map(toProgressSyncEvent),
});
return response.data;
}
export async function getSyncedProgress(): Promise<ProgressSyncSummary> {
const response =
await apiGet<ApiEnvelope<Omit<ProgressSyncSummary, "recent_events"> & {
recent_events: unknown[];
}>>("/sync/progress");
return {
...response.data,
recent_events: response.data.recent_events
.map(normalizeRemoteEvent)
.filter((event): event is ActivityEvent => event !== null),
};
}
|