Files
app/src/contexts/NeoUserContext.tsx
T
kcarandClaude Sonnet 4.6 b0c7758135 feat(phase-b): Supabase navigation store, snapshot service, auth wiring
navigationStore: rewritten off Neo4j db names — Supabase whiteboard_rooms table,
  setAuthInfo(token, userId) pattern, auto-creates default room per context on first use
snapshotService: rewritten to Supabase Storage REST (/storage/v1/object/authenticated/cc.users/…),
  setAccessToken() instance method, static methods take accessToken not dbName
AuthContext/NeoUserContext: auth injected into nav store, no Neo4j db names required
singlePlayerPage: loadNodeData no longer calls Neo4j; snapshot wired via accessToken
navigation types: NeoGraphNode updated for Supabase-backed tree structure
transcriptionStore/Service: getSession() removed, accessToken via AuthContext
LLMConfigModal: auth context wiring fixes
GraphNavigator/GraphSidebar: updated nav components

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-26 01:25:15 +01:00

670 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { useAuth } from './AuthContext';
import { useUser } from './UserContext';
import { logger } from '../debugConfig';
import { CCUserNodeProps, CCCalendarNodeProps, CCUserTeacherTimetableNodeProps } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
import { CalendarStructure, WorkerStructure } from '../types/navigation';
import { useNavigationStore } from '../stores/navigationStore';
// Core Node Types
export interface CalendarNode {
id: string;
label: string;
title: string;
node_storage_path: string;
type?: CCCalendarNodeProps['__primarylabel__'];
nodeData?: CCCalendarNodeProps;
}
export interface WorkerNode {
id: string;
label: string;
title: string;
node_storage_path: string;
type?: CCUserTeacherTimetableNodeProps['__primarylabel__'];
nodeData?: CCUserTeacherTimetableNodeProps;
}
// Calendar Structure Types
export interface CalendarDay {
id: string;
date: string;
title: string;
}
export interface CalendarWeek {
id: string;
title: string;
days: { id: string }[];
startDate: string;
endDate: string;
}
export interface CalendarMonth {
id: string;
title: string;
days: { id: string }[];
weeks: { id: string }[];
year: string;
month: string;
}
export interface CalendarYear {
id: string;
title: string;
months: { id: string }[];
year: string;
}
// Worker Structure Types
export interface TimetableEntry {
id: string;
title: string;
type: string;
startTime: string;
endTime: string;
}
export interface ClassEntry {
id: string;
title: string;
type: string;
}
export interface LessonEntry {
id: string;
title: string;
type: string;
}
interface NeoUserContextType {
userNode: CCUserNodeProps | null;
calendarNode: CalendarNode | null;
workerNode: WorkerNode | null;
userDbName: string | null;
workerDbName: string | null;
isLoading: boolean;
isInitialized: boolean;
error: string | null;
// Calendar Navigation
navigateToDay: (id: string) => Promise<void>;
navigateToWeek: (id: string) => Promise<void>;
navigateToMonth: (id: string) => Promise<void>;
navigateToYear: (id: string) => Promise<void>;
currentCalendarNode: CalendarNode | null;
calendarStructure: CalendarStructure | null;
// Worker Navigation
navigateToTimetable: (id: string) => Promise<void>;
navigateToJournal: (id: string) => Promise<void>;
navigateToPlanner: (id: string) => Promise<void>;
navigateToClass: (id: string) => Promise<void>;
navigateToLesson: (id: string) => Promise<void>;
currentWorkerNode: WorkerNode | null;
workerStructure: WorkerStructure | null;
}
const NeoUserContext = createContext<NeoUserContextType>({
userNode: null,
calendarNode: null,
workerNode: null,
userDbName: null,
workerDbName: null,
isLoading: false,
isInitialized: false,
error: null,
navigateToDay: async () => {},
navigateToWeek: async () => {},
navigateToMonth: async () => {},
navigateToYear: async () => {},
navigateToTimetable: async () => {},
navigateToJournal: async () => {},
navigateToPlanner: async () => {},
navigateToClass: async () => {},
navigateToLesson: async () => {},
currentCalendarNode: null,
currentWorkerNode: null,
calendarStructure: null,
workerStructure: null
});
export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const { user, accessToken } = useAuth();
const { profile, isInitialized: isUserInitialized } = useUser();
const navigationStore = useNavigationStore();
const [userNode, setUserNode] = useState<CCUserNodeProps | null>(null);
const [calendarNode] = useState<CalendarNode | null>(null);
const [workerNode] = useState<WorkerNode | null>(null);
const [currentCalendarNode, setCurrentCalendarNode] = useState<CalendarNode | null>(null);
const [currentWorkerNode, setCurrentWorkerNode] = useState<WorkerNode | null>(null);
const [calendarStructure] = useState<CalendarStructure | null>(null);
const [workerStructure] = useState<WorkerStructure | null>(null);
const [userDbName, setUserDbName] = useState<string | null>(null);
const [workerDbName, setWorkerDbName] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isInitialized, setIsInitialized] = useState(false);
const [error, setError] = useState<string | null>(null);
// Use ref for initialization tracking to prevent re-renders
const initializationRef = React.useRef({
hasStarted: false,
isComplete: false
});
// Add base properties for node data
const getBaseNodeProps = () => ({
title: '',
w: 200,
h: 200,
headerColor: '#000000',
backgroundColor: '#ffffff',
isLocked: false,
__primarylabel__: 'UserTeacherTimetable',
uuid_string: '',
node_storage_path: '',
created: new Date().toISOString(),
merged: new Date().toISOString(),
state: {
parentId: null,
isPageChild: false,
hasChildren: false,
bindings: [],
},
defaultComponent: true,
});
// Initialize context when dependencies are ready
useEffect(() => {
logger.debug('neo-user-context', '🔄 useEffect triggered', {
isUserInitialized,
hasProfile: !!profile,
hasUser: !!user,
isInitialized,
hasStarted: initializationRef.current.hasStarted
});
if (!isUserInitialized) {
logger.debug('neo-user-context', '⏳ Waiting for user context initialization');
return;
}
if (!profile) {
if (!initializationRef.current.isComplete) {
setIsLoading(false);
setIsInitialized(true);
initializationRef.current.isComplete = true;
logger.debug('neo-user-context', '️ No profile available; marking context initialized');
}
return;
}
if (isInitialized || initializationRef.current.hasStarted) {
logger.debug('neo-user-context', '️ Initialization already in progress or complete', {
isInitialized,
hasStarted: initializationRef.current.hasStarted
});
return;
}
const initializeContext = async () => {
try {
initializationRef.current.hasStarted = true;
setIsLoading(true);
setError(null);
// Inject auth into navigation store so Supabase queries work
if (user?.id && accessToken) {
navigationStore.setAuthInfo(accessToken, user.id);
}
// Initialize user node in profile context
logger.debug('neo-user-context', '🔄 Starting context initialization');
// Initialize user node — race against 8s timeout so spinner never hangs
const switchTimeout = new Promise<void>((_, reject) =>
setTimeout(() => reject(new Error('switchContext timed out after 8000ms')), 8000)
)
try {
await Promise.race([
navigationStore.switchContext({
main: 'profile',
base: 'profile',
extended: 'overview'
}, null, null),
switchTimeout
]);
const userNavigationNode = navigationStore.context.node;
if (userNavigationNode?.id && userNavigationNode?.data) {
const userNodeData: CCUserNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'User',
uuid_string: userNavigationNode.id,
node_storage_path: userNavigationNode.node_storage_path || '',
title: String(userNavigationNode.data?.user_name || 'User'),
user_name: String(userNavigationNode.data?.user_name || 'User'),
user_email: user?.email || '',
user_type: 'User',
user_id: userNavigationNode.id,
worker_node_data: JSON.stringify(userNavigationNode.data || {})
};
setUserNode(userNodeData);
logger.debug('neo-user-context', '✅ User node loaded from navigation store');
} else if (userNavigationNode?.id) {
logger.debug('neo-user-context', '️ User node exists but data not yet loaded - will retry later', {
nodeId: userNavigationNode.id,
hasData: !!userNavigationNode.data
});
} else {
logger.debug('neo-user-context', '️ No user node in navigation store yet - will retry later');
}
} catch (navError) {
logger.warn('neo-user-context', '⚠️ Navigation store initialization failed - continuing without user node', {
error: navError instanceof Error ? navError.message : String(navError)
});
// Continue without user node - this is not critical for basic functionality
}
// Set final state — userDbName signals auth availability for UI guards
setUserDbName(user?.id || null);
setWorkerDbName(null);
setIsInitialized(true);
setIsLoading(false);
initializationRef.current.isComplete = true;
logger.debug('neo-user-context', '✅ Context initialization complete');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to initialize user context';
logger.error('neo-user-context', '❌ Failed to initialize context', { error: errorMessage });
setError(errorMessage);
setIsLoading(false);
setIsInitialized(true);
initializationRef.current.isComplete = true;
}
};
initializeContext();
}, [user, profile, isUserInitialized, navigationStore, isInitialized]);
// Calendar Navigation Functions
const navigateToDay = async (id: string) => {
if (!user?.id) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'calendar',
extended: 'day'
}, null, null);
const node = navigationStore.context.node;
if (node?.data) {
const nodeData: CCCalendarNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'CalendarDay',
uuid_string: id || node.id,
node_storage_path: node.node_storage_path || '',
title: node.label,
name: node.label,
calendar_type: 'day',
calendar_name: node.label,
start_date: new Date().toISOString(),
end_date: new Date().toISOString()
};
setCurrentCalendarNode({
id: id || node.id,
label: node.label,
title: node.label,
node_storage_path: node.node_storage_path || '',
type: 'CalendarDay',
nodeData
});
}
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to navigate to day');
} finally {
setIsLoading(false);
}
};
const navigateToWeek = async (id: string) => {
if (!user?.id) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'calendar',
extended: 'week'
}, null, null);
const node = navigationStore.context.node;
if (node?.data) {
const nodeData: CCCalendarNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'CalendarWeek',
uuid_string: id || node.id,
node_storage_path: node.node_storage_path || '',
title: node.label,
name: node.label,
calendar_type: 'week',
calendar_name: node.label,
start_date: new Date().toISOString(),
end_date: new Date().toISOString()
};
setCurrentCalendarNode({
id: id || node.id,
label: node.label,
title: node.label,
node_storage_path: node.node_storage_path || '',
type: 'CalendarWeek',
nodeData
});
}
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to navigate to week');
} finally {
setIsLoading(false);
}
};
const navigateToMonth = async (id: string) => {
if (!user?.id) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'calendar',
extended: 'month'
}, null, null);
const node = navigationStore.context.node;
if (node?.data) {
const nodeData: CCCalendarNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'CalendarMonth',
uuid_string: id || node.id,
node_storage_path: node.node_storage_path || '',
title: node.label,
name: node.label,
calendar_type: 'month',
calendar_name: node.label,
start_date: new Date().toISOString(),
end_date: new Date().toISOString()
};
setCurrentCalendarNode({
id: id || node.id,
label: node.label,
title: node.label,
node_storage_path: node.node_storage_path || '',
type: 'CalendarMonth',
nodeData
});
}
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to navigate to month');
} finally {
setIsLoading(false);
}
};
const navigateToYear = async (id: string) => {
if (!user?.id) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'calendar',
extended: 'year'
}, null, null);
const node = navigationStore.context.node;
if (node?.data) {
const nodeData: CCCalendarNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'CalendarYear',
uuid_string: id || node.id,
node_storage_path: node.node_storage_path || '',
title: node.label,
name: node.label,
calendar_type: 'year',
calendar_name: node.label,
start_date: new Date().toISOString(),
end_date: new Date().toISOString()
};
setCurrentCalendarNode({
id: id || node.id,
label: node.label,
title: node.label,
node_storage_path: node.node_storage_path || '',
type: 'CalendarYear',
nodeData
});
}
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to navigate to year');
} finally {
setIsLoading(false);
}
};
// Worker Navigation Functions
const navigateToTimetable = async (id: string) => {
if (!user?.id) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'teaching',
extended: 'timetable'
}, null, null);
const node = navigationStore.context.node;
if (node?.data) {
const nodeData: CCUserTeacherTimetableNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'UserTeacherTimetable',
uuid_string: id || node.id,
node_storage_path: node.node_storage_path || '',
title: node.label,
school_db_name: workerDbName || '',
school_timetable_id: id || node.id
};
setCurrentWorkerNode({
id: id || node.id,
label: node.label,
title: node.label,
node_storage_path: node.node_storage_path || '',
type: 'UserTeacherTimetable',
nodeData
});
}
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to navigate to timetable');
} finally {
setIsLoading(false);
}
};
const navigateToJournal = async (id: string) => {
if (!user?.id) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'teaching',
extended: 'journal'
}, null, null);
const node = navigationStore.context.node;
if (node?.data) {
const nodeData: CCUserTeacherTimetableNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'UserTeacherTimetable',
uuid_string: id || node.id,
node_storage_path: node.node_storage_path || '',
title: node.label,
school_db_name: workerDbName || '',
school_timetable_id: id || node.id
};
setCurrentWorkerNode({
id: id || node.id,
label: node.label,
title: node.label,
node_storage_path: node.node_storage_path || '',
type: 'UserTeacherTimetable',
nodeData
});
}
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to navigate to journal');
} finally {
setIsLoading(false);
}
};
const navigateToPlanner = async (id: string) => {
if (!user?.id) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'teaching',
extended: 'planner'
}, null, null);
const node = navigationStore.context.node;
if (node?.data) {
const nodeData: CCUserTeacherTimetableNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'UserTeacherTimetable',
uuid_string: id || node.id,
node_storage_path: node.node_storage_path || '',
title: node.label,
school_db_name: workerDbName || '',
school_timetable_id: id || node.id
};
setCurrentWorkerNode({
id: id || node.id,
label: node.label,
title: node.label,
node_storage_path: node.node_storage_path || '',
type: 'UserTeacherTimetable',
nodeData
});
}
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to navigate to planner');
} finally {
setIsLoading(false);
}
};
const navigateToClass = async (id: string) => {
if (!user?.id) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'teaching',
extended: 'classes'
}, null, null);
await navigationStore.navigate(id, '');
const node = navigationStore.context.node;
if (node?.data) {
const nodeData: CCUserTeacherTimetableNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'UserTeacherTimetable',
uuid_string: node.id,
node_storage_path: node.node_storage_path || '',
title: node.label,
school_db_name: workerDbName || '',
school_timetable_id: node.id
};
setCurrentWorkerNode({
id: node.id,
label: node.label,
title: node.label,
node_storage_path: node.node_storage_path || '',
type: 'UserTeacherTimetable',
nodeData
});
}
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to navigate to class');
} finally {
setIsLoading(false);
}
};
const navigateToLesson = async (id: string) => {
if (!user?.id) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'teaching',
extended: 'lessons'
}, null, null);
await navigationStore.navigate(id, '');
const node = navigationStore.context.node;
if (node?.data) {
const nodeData: CCUserTeacherTimetableNodeProps = {
...getBaseNodeProps(),
__primarylabel__: 'UserTeacherTimetable',
uuid_string: node.id,
node_storage_path: node.node_storage_path || '',
title: node.label,
school_db_name: workerDbName || '',
school_timetable_id: node.id
};
setCurrentWorkerNode({
id: node.id,
label: node.label,
title: node.label,
node_storage_path: node.node_storage_path || '',
type: 'UserTeacherTimetable',
nodeData
});
}
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to navigate to lesson');
} finally {
setIsLoading(false);
}
};
return (
<NeoUserContext.Provider value={{
userNode,
calendarNode,
workerNode,
userDbName,
workerDbName,
isLoading,
isInitialized,
error,
navigateToDay,
navigateToWeek,
navigateToMonth,
navigateToYear,
navigateToTimetable,
navigateToJournal,
navigateToPlanner,
navigateToClass,
navigateToLesson,
currentCalendarNode,
currentWorkerNode,
calendarStructure,
workerStructure
}}>
{children}
</NeoUserContext.Provider>
);
};
export const useNeoUser = () => useContext(NeoUserContext);