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