feat(cis): add Supabase integration, canvas event logger, and sessions tab (Phase 2)

- Connect transcriptionStore to Supabase (start/stop session, save segments)
- Add CanvasEventLogger for silent TLDraw activity tracking
- Add Sessions tab to CCTranscriptionPanel with past sessions list
- Auto-detect timetable context on panel mount
- Flush canvas events to API every 5 seconds during recording
This commit is contained in:
2026-05-20 22:06:31 +00:00
parent 2ee4e4afe7
commit 6bbed42f55
3 changed files with 740 additions and 88 deletions
+205 -12
View File
@@ -1,4 +1,5 @@
import { create } from 'zustand';
import { supabase } from '../supabaseClient';
export interface TranscriptionSegment {
text: string;
@@ -7,40 +8,142 @@ export interface TranscriptionSegment {
end: number;
}
export interface TranscriptionSession {
id: string;
user_id: string;
title: string | null;
started_at: string;
ended_at: string | null;
duration_seconds: number | null;
timetable_period_id: string | null;
timetable_event_type: string | null;
timetable_event_label: string | null;
auto_tagged: boolean;
word_count: number;
segment_count: number;
}
export interface TimetablePeriod {
period_id: string | null;
event_type: string | null;
event_label: string | null;
start_time: string | null;
end_time: string | null;
}
interface TranscriptionState {
// Session state
isRecording: boolean;
isConnecting: boolean;
activeSession: TranscriptionSession | null;
// Live feed
completedSegments: TranscriptionSegment[];
currentSegment: TranscriptionSegment | null;
// Canvas event buffer (flushed to API every 5s)
pendingCanvasEvents: any[];
// Timetable context
timetableContext: TimetablePeriod | null;
// UI state
wordCount: number;
elapsedSeconds: number;
startSession: () => void;
stopSession: () => void;
saveSegment: (text: string, isFinal: boolean, metadata: { start: number; end: number }) => void;
// Actions
startSession: (timetableTag?: TimetablePeriod) => Promise<void>;
stopSession: () => Promise<void>;
saveSegment: (text: string, isFinal: boolean, metadata: { start: number; end: number }) => Promise<void>;
resetSession: () => void;
tickElapsed: () => void;
addCanvasEvent: (event: any) => void;
flushCanvasEvents: () => Promise<void>;
loadSessions: () => Promise<TranscriptionSession[]>;
setTimetableContext: (context: TimetablePeriod | null) => void;
}
export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
isRecording: false,
isConnecting: false,
activeSession: null,
completedSegments: [],
currentSegment: null,
pendingCanvasEvents: [],
timetableContext: null,
wordCount: 0,
elapsedSeconds: 0,
startSession: () => {
set({ isRecording: true, isConnecting: false, elapsedSeconds: 0 });
setTimetableContext: (context) => {
set({ timetableContext: context });
},
stopSession: () => {
set({ isRecording: false, isConnecting: false });
startSession: async (timetableTag?: TimetablePeriod) => {
set({ isRecording: true, isConnecting: false, elapsedSeconds: 0, timetableContext: timetableTag || null });
// Create session in Supabase
try {
const user = await supabase.auth.getUser();
if (!user.data.user) {
console.error('No authenticated user');
return;
}
const sessionData = {
user_id: user.data.user.id,
title: timetableTag?.event_label || 'Untitled Session',
canvas_type: 'teaching-canvas',
timetable_period_id: timetableTag?.period_id || null,
timetable_event_type: timetableTag?.event_type || null,
timetable_event_label: timetableTag?.event_label || null,
auto_tagged: !!timetableTag,
};
const { data, error } = await supabase
.from('transcription_sessions')
.insert(sessionData)
.select()
.single();
if (error) {
console.error('Failed to create session:', error);
return;
}
set({ activeSession: data });
} catch (error) {
console.error('Error starting session:', error);
}
},
saveSegment: (text: string, isFinal: boolean, metadata: { start: number; end: number }) => {
const { completedSegments, currentSegment } = get();
stopSession: async () => {
const { activeSession, completedSegments } = get();
if (activeSession) {
try {
await supabase
.from('transcription_sessions')
.update({
ended_at: new Date().toISOString(),
word_count: get().wordCount,
segment_count: completedSegments.length,
})
.eq('id', activeSession.id);
} catch (error) {
console.error('Failed to end session:', error);
}
}
set({
isRecording: false,
isConnecting: false,
activeSession: null,
});
},
saveSegment: async (text: string, isFinal: boolean, metadata: { start: number; end: number }) => {
const { completedSegments, currentSegment, activeSession, wordCount } = get();
if (isFinal) {
// Final segment — move current to completed, clear current
const newCompleted = [...completedSegments];
@@ -51,7 +154,29 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
(sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length,
0
);
set({ completedSegments: newCompleted, currentSegment: null, wordCount: newWordCount });
set({
completedSegments: newCompleted,
currentSegment: null,
wordCount: newWordCount
});
// Save to Supabase if session is active
if (activeSession) {
try {
const sequenceIndex = newCompleted.length - 1;
await supabase.from('transcription_segments').insert({
session_id: activeSession.id,
sequence_index: sequenceIndex,
text: text,
start_seconds: metadata.start,
end_seconds: metadata.end,
is_final: true,
});
} catch (error) {
console.error('Failed to save segment:', error);
}
}
} else {
// In-progress segment
set({ currentSegment: { text, isFinal: false, ...metadata } });
@@ -59,10 +184,78 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
},
resetSession: () => {
set({ isRecording: false, isConnecting: false, completedSegments: [], currentSegment: null, wordCount: 0, elapsedSeconds: 0 });
set({
isRecording: false,
isConnecting: false,
completedSegments: [],
currentSegment: null,
wordCount: 0,
elapsedSeconds: 0,
activeSession: null,
pendingCanvasEvents: [],
timetableContext: null,
});
},
tickElapsed: () => {
set((state) => ({ elapsedSeconds: state.elapsedSeconds + 1 }));
},
addCanvasEvent: (event) => {
set((state) => ({
pendingCanvasEvents: [...state.pendingCanvasEvents, event],
}));
},
flushCanvasEvents: async () => {
const { pendingCanvasEvents, activeSession } = get();
if (pendingCanvasEvents.length === 0) return;
const eventsToFlush = [...pendingCanvasEvents];
try {
for (const event of eventsToFlush) {
await supabase.from('canvas_events').insert({
session_id: activeSession?.id || null,
user_id: (await supabase.auth.getUser()).data.user?.id || '',
timestamp: new Date().toISOString(),
session_elapsed_seconds: event.sessionElapsedSeconds || null,
event_type: event.eventType,
event_payload: event.payload || {},
canvas_snapshot_url: event.snapshotUrl || null,
tldraw_page_id: event.pageId || null,
tldraw_shape_ids: event.shapeIds || null,
});
}
set({ pendingCanvasEvents: [] });
} catch (error) {
console.error('Failed to flush canvas events:', error);
}
},
loadSessions: async (): Promise<TranscriptionSession[]> => {
try {
const user = await supabase.auth.getUser();
if (!user.data.user) return [];
const { data, error } = await supabase
.from('transcription_sessions')
.select('*')
.eq('user_id', user.data.user.id)
.order('started_at', { ascending: false })
.limit(50);
if (error) {
console.error('Failed to load sessions:', error);
return [];
}
return data || [];
} catch (error) {
console.error('Error loading sessions:', error);
return [];
}
},
}));