Checking your session...

Board

Board Menu

About this Board
Board Cover
Change Background
Board Labels
Board Members
Import Tasks
Export Board
Duplicate Board
Archive Board
LC
Coach Lucy
Your Project Management Guide
* 2. Replace saveToStorage() and loadFromStorage() at line ~2821 with this module * 3. Initialize on page load after all global variables are declared */ // ═══════════════════════════════════════════════════════════════════════════ // CONSTANTS AND CONFIGURATION // ═══════════════════════════════════════════════════════════════════════════ const FFH_PM_CONFIG = { supabaseUrl: 'https://kfdwkiarqxlstlsnrilj.supabase.co', supabaseKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtmZHdraWFycXhsc3Rsc25yaWxqIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzI4MjYwOTksImV4cCI6MjA4ODQwMjA5OX0.a6Vlw7QOb96N1ypsrUqVoiGDy7iYz1KudBdOnVSIf0c', localStorageKey: 'ffh_pm_v3', syncDebounceMs: 1000, batchSyncMs: 2000, }; // ═══════════════════════════════════════════════════════════════════════════ // GLOBAL STATE // ═══════════════════════════════════════════════════════════════════════════ let ffhSupabaseState = { client: null, session: null, isInitialized: false, isConnected: false, userId: null, syncInProgress: false, lastSyncTime: null, pendingChanges: null, syncTimeoutId: null, initPromise: null, }; // Make Supabase client accessible to other modules window.ffhSupabaseClient = null; // ═══════════════════════════════════════════════════════════════════════════ // LOGGING UTILITY // ═══════════════════════════════════════════════════════════════════════════ const ffhLog = { debug: (msg, data) => { console.log(`[FFH-PM-SYNC DEBUG] ${msg}`, data || ''); }, info: (msg, data) => { console.info(`[FFH-PM-SYNC INFO] ${msg}`, data || ''); }, warn: (msg, data) => { console.warn(`[FFH-PM-SYNC WARN] ${msg}`, data || ''); }, error: (msg, data) => { console.error(`[FFH-PM-SYNC ERROR] ${msg}`, data || ''); }, }; // ═══════════════════════════════════════════════════════════════════════════ // CONNECTION STATUS INDICATOR // ═══════════════════════════════════════════════════════════════════════════ function initializeConnectionIndicator() { const indicator = document.createElement('div'); indicator.id = 'ffh-sync-status'; indicator.style.cssText = ` position: fixed; bottom: 20px; right: 20px; z-index: 999; width: 12px; height: 12px; border-radius: 50%; background: #94a3b8; box-shadow: 0 0 8px rgba(0,0,0,0.2); cursor: pointer; transition: background 0.3s ease; margin: 0; padding: 0; `; indicator.title = 'Sync Status: Offline (Local Mode)'; document.body.appendChild(indicator); indicator.addEventListener('click', () => { const status = ffhSupabaseState.isConnected ? 'Connected to Supabase' : 'Local Mode (Offline)'; const lastSync = ffhSupabaseState.lastSyncTime ? new Date(ffhSupabaseState.lastSyncTime).toLocaleTimeString() : 'Never'; alert(`Sync Status:\n- ${status}\n- Last Sync: ${lastSync}\n- Session: ${ffhSupabaseState.userId ? 'Authenticated' : 'Demo Mode'}`); }); window.ffhSyncIndicator = indicator; return indicator; } function updateConnectionIndicator() { const indicator = window.ffhSyncIndicator; if (!indicator) return; if (ffhSupabaseState.isConnected && ffhSupabaseState.session) { indicator.style.background = '#16a34a'; // green indicator.title = 'Sync Status: Connected to Supabase'; } else { indicator.style.background = '#94a3b8'; // gray indicator.title = 'Sync Status: Offline (Local Mode)'; } } // ═══════════════════════════════════════════════════════════════════════════ // INITIALIZATION // ═══════════════════════════════════════════════════════════════════════════ async function initializeSupabaseIntegration() { if (ffhSupabaseState.initPromise) { return ffhSupabaseState.initPromise; } ffhSupabaseState.initPromise = (async () => { try { ffhLog.info('Initializing Supabase integration...'); // Check if Supabase library is loaded if (!window.supabase) { throw new Error('Supabase library not loaded. Add to head.'); } // Initialize Supabase client ffhSupabaseState.client = window.supabase.createClient( FFH_PM_CONFIG.supabaseUrl, FFH_PM_CONFIG.supabaseKey ); window.ffhSupabaseClient = ffhSupabaseState.client; // Check for active session const { data: { session }, error: sessionError } = await ffhSupabaseState.client.auth.getSession(); if (sessionError) { ffhLog.warn('Session check failed', sessionError.message); ffhSupabaseState.isConnected = false; ffhSupabaseState.session = null; } else if (session) { ffhSupabaseState.session = session; ffhSupabaseState.userId = session.user.id; ffhSupabaseState.isConnected = true; ffhLog.info('Authenticated session found', { userId: ffhSupabaseState.userId }); } else { ffhLog.info('No active session - running in demo mode'); ffhSupabaseState.isConnected = false; ffhSupabaseState.session = null; } // Initialize connection indicator initializeConnectionIndicator(); updateConnectionIndicator(); ffhSupabaseState.isInitialized = true; /* ┌─────────────────────────────────────────────────────────┐ │ PATCH 3: Consent Check for IDEAS Boards (FERPA) │ └─────────────────────────────────────────────────────────┘ */ // Check for educational_records consent (for IDEAS boards) try { const { data: consents, error: consentError } = await ffhSupabaseState.client .from('consent_records') .select('id, consent_type, granted, created_at') .eq('user_id', ffhSupabaseState.userId) .eq('consent_type', 'educational_records') .order('created_at', { ascending: false }) .limit(1); if (!consentError && consents && consents.length > 0) { ffhSupabaseState.educationalConsentGranted = consents[0].granted === true; ffhLog.info('Educational records consent: ' + (ffhSupabaseState.educationalConsentGranted ? 'granted' : 'denied')); } else { ffhSupabaseState.educationalConsentGranted = false; ffhLog.info('No educational records consent found'); } } catch (e) { ffhLog.debug('Educational consent query failed', e.message); ffhSupabaseState.educationalConsentGranted = false; } ffhLog.info('Supabase integration initialized', { connected: ffhSupabaseState.isConnected, userId: ffhSupabaseState.userId }); return true; } catch (error) { ffhLog.error('Failed to initialize Supabase', error); ffhSupabaseState.isInitialized = false; ffhSupabaseState.isConnected = false; initializeConnectionIndicator(); return false; } })(); return ffhSupabaseState.initPromise; } // ═══════════════════════════════════════════════════════════════════════════ // DATA MODEL MAPPING // ═══════════════════════════════════════════════════════════════════════════ /** * Convert in-memory board object to Supabase pm_boards format * Separates tasks into pm_tasks table */ function serializeBoardForSupabase(board) { const { tasks, ...boardData } = board; return { id: boardData.id, name: boardData.name || '', description: boardData.description || '', color: boardData.color || '#3b82f6', background: boardData.background || '', cover_url: boardData.cover_url || '', board_type: boardData.board_type || 'kanban', is_archived: boardData.is_archived || false, sort_order: boardData.sort_order || 0, ideas_config: boardData.ideas_config ? JSON.parse(typeof boardData.ideas_config === 'string' ? boardData.ideas_config : JSON.stringify(boardData.ideas_config)) : {}, settings: boardData.settings ? JSON.parse(typeof boardData.settings === 'string' ? boardData.settings : JSON.stringify(boardData.settings)) : {}, members: boardData.members || [], owner_id: ffhSupabaseState.userId || null, organization_id: boardData.organization_id || null, created_at: boardData.created_at || new Date().toISOString(), updated_at: new Date().toISOString(), }; } /** * Convert task object to Supabase pm_tasks format */ function serializeTaskForSupabase(task, boardId) { return { id: task.id, board_id: boardId, title: task.title || '', description: task.description || '', stage: task.stage || 'todo', priority: task.priority || 'medium', owner_id: ffhSupabaseState.userId || null, organization_id: null, labels: task.labels || [], assignees: task.assignees || [], due_date: task.due_date || null, estimate_hours: task.estimate_hours || null, cost_estimate: task.cost_estimate || null, checklist: task.checklist || [], cover_url: task.cover_url || '', sort_order: task.sort_order || 0, time_logged_seconds: task.time_logged_seconds || 0, coins_earned: task.coins_earned || 0, is_archived: task.is_archived || false, created_at: task.created_at || new Date().toISOString(), updated_at: new Date().toISOString(), }; } /** * Deserialize Supabase board with nested tasks */ function deserializeBoardFromSupabase(boardRow, taskRows = []) { return { id: boardRow.id, name: boardRow.name, description: boardRow.description, color: boardRow.color, background: boardRow.background, cover_url: boardRow.cover_url, board_type: boardRow.board_type, is_archived: boardRow.is_archived, sort_order: boardRow.sort_order, ideas_config: boardRow.ideas_config || {}, settings: boardRow.settings || {}, members: boardRow.members || [], organization_id: boardRow.organization_id, created_at: boardRow.created_at, updated_at: boardRow.updated_at, tasks: taskRows.map(task => ({ id: task.id, title: task.title, description: task.description, stage: task.stage, priority: task.priority, labels: task.labels || [], assignees: task.assignees || [], due_date: task.due_date, estimate_hours: task.estimate_hours, cost_estimate: task.cost_estimate, checklist: task.checklist || [], cover_url: task.cover_url, sort_order: task.sort_order, time_logged_seconds: task.time_logged_seconds || 0, coins_earned: task.coins_earned || 0, is_archived: task.is_archived, created_at: task.created_at, updated_at: task.updated_at, })), }; } // ═══════════════════════════════════════════════════════════════════════════ // SUPABASE PERSISTENCE // ═══════════════════════════════════════════════════════════════════════════ /** * Save all data to Supabase (full sync) * Called when user is authenticated */ async function syncToSupabase(boardsData, nextId, nextBoardId, userCoins) { if (!ffhSupabaseState.isConnected || !ffhSupabaseState.client) { ffhLog.warn('Sync attempted but not connected to Supabase'); return false; } if (ffhSupabaseState.syncInProgress) { ffhLog.debug('Sync already in progress, queueing changes'); ffhSupabaseState.pendingChanges = { boardsData, nextId, nextBoardId, userCoins }; return null; // null = queued } ffhSupabaseState.syncInProgress = true; try { ffhLog.info('Starting Supabase sync', { boardCount: boardsData.length }); // Sync metadata (nextId, nextBoardId, userCoins) as a singleton record // Using a special system board with id='_system' for storing metadata const metadataRecord = { id: '_system', name: 'System Metadata', description: 'Reserved for system use', color: '#000000', background: '', cover_url: '', board_type: 'system', is_archived: true, sort_order: -1, ideas_config: { nextId, nextBoardId, userCoins }, settings: {}, members: [], owner_id: ffhSupabaseState.userId, organization_id: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }; // Upsert metadata const { error: metadataError } = await ffhSupabaseState.client .from('pm_boards') .upsert([metadataRecord], { onConflict: 'id' }); if (metadataError) { ffhLog.warn('Failed to sync metadata', metadataError.message); } // Sync each board and its tasks for (const board of boardsData) { try { // Serialize board const boardForDb = serializeBoardForSupabase(board); // Upsert board const { error: boardError } = await ffhSupabaseState.client .from('pm_boards') .upsert([boardForDb], { onConflict: 'id' }); if (boardError) { ffhLog.error(`Failed to sync board ${board.id}`, boardError.message); continue; } // Upsert tasks for this board if (board.tasks && board.tasks.length > 0) { const tasksForDb = board.tasks.map(task => serializeTaskForSupabase(task, board.id)); const { error: tasksError } = await ffhSupabaseState.client .from('pm_tasks') .upsert(tasksForDb, { onConflict: 'id' }); if (tasksError) { ffhLog.error(`Failed to sync tasks for board ${board.id}`, tasksError.message); } } } catch (boardSyncError) { ffhLog.error(`Error syncing board ${board.id}`, boardSyncError); } } ffhSupabaseState.lastSyncTime = new Date().toISOString(); ffhLog.info('Supabase sync completed successfully'); updateConnectionIndicator(); return true; } catch (error) { ffhLog.error('Fatal error during Supabase sync', error); return false; } finally { ffhSupabaseState.syncInProgress = false; // Process any pending changes if (ffhSupabaseState.pendingChanges) { const pending = ffhSupabaseState.pendingChanges; ffhSupabaseState.pendingChanges = null; // Recursively sync pending changes await syncToSupabase(pending.boardsData, pending.nextId, pending.nextBoardId, pending.userCoins); } } } /** * Load all data from Supabase (full load) * Called during initialization when user is authenticated */ async function loadFromSupabase() { if (!ffhSupabaseState.isConnected || !ffhSupabaseState.client) { ffhLog.warn('Load attempted but not connected to Supabase'); return null; } try { ffhLog.info('Loading data from Supabase...'); // Load all boards for this user const { data: boards, error: boardsError } = await ffhSupabaseState.client .from('pm_boards') .select('*') .eq('owner_id', ffhSupabaseState.userId) .order('sort_order', { ascending: true }); if (boardsError) { ffhLog.error('Failed to load boards from Supabase', boardsError.message); return null; } // Separate metadata from boards let nextId = 1; let nextBoardId = 1; let userCoins = 0; const regularBoards = []; // Load all tasks const { data: allTasks, error: tasksError } = await ffhSupabaseState.client .from('pm_tasks') .select('*') .eq('owner_id', ffhSupabaseState.userId); if (tasksError) { ffhLog.error('Failed to load tasks from Supabase', tasksError.message); return null; } // Create task map by board_id const tasksByBoard = {}; (allTasks || []).forEach(task => { if (!tasksByBoard[task.board_id]) { tasksByBoard[task.board_id] = []; } tasksByBoard[task.board_id].push(task); }); // Process boards (boards || []).forEach(boardRow => { if (boardRow.id === '_system') { // Extract metadata from system board if (boardRow.ideas_config) { nextId = boardRow.ideas_config.nextId || 1; nextBoardId = boardRow.ideas_config.nextBoardId || 1; userCoins = boardRow.ideas_config.userCoins || 0; } } else { // Regular board const boardTasks = tasksByBoard[boardRow.id] || []; const boardData = deserializeBoardFromSupabase(boardRow, boardTasks); regularBoards.push(boardData); } }); ffhLog.info('Successfully loaded from Supabase', { boardCount: regularBoards.length, taskCount: allTasks ? allTasks.length : 0, }); return { boards: regularBoards, nextId, nextBoardId, userCoins, }; } catch (error) { ffhLog.error('Fatal error loading from Supabase', error); return null; } } // ═══════════════════════════════════════════════════════════════════════════ // HYBRID PERSISTENCE (Public API) // ═══════════════════════════════════════════════════════════════════════════ /** * Main save function - replaces original saveToStorage() * Intelligently syncs to Supabase if connected, or falls back to localStorage */ function saveToStorage() { // Always save to localStorage as cache // ┌─────────────────────────────────────────────────────────────────┐ // │ PATCH 1: FERPA Guard - Skip localStorage for IDEAS boards │ // └─────────────────────────────────────────────────────────────────┘ const isIdeasBoard = currentBoard && currentBoard.hasOwnProperty('ideasConfig'); const skipLocalStorage = ffhSupabaseState.isConnected && isIdeasBoard; if (!skipLocalStorage) { // Normal caching for non-IDEAS or unauthenticated try { localStorage.setItem(FFH_PM_CONFIG.localStorageKey, JSON.stringify({ boards, nextId, nextBoardId, userCoins, })); ffhLog.debug('Saved to localStorage'); } catch (storageError) { ffhLog.error('Failed to save to localStorage', storageError.message); } } else { ffhLog.debug('Skipped localStorage: authenticated on IDEAS board (FERPA compliance)'); } // If connected to Supabase, sync to remote if (ffhSupabaseState.isConnected && ffhSupabaseState.client) { // Debounce Supabase sync to avoid hammering the API if (ffhSupabaseState.syncTimeoutId) { clearTimeout(ffhSupabaseState.syncTimeoutId); } ffhSupabaseState.syncTimeoutId = setTimeout(async () => { ffhLog.debug('Executing debounced Supabase sync...'); const result = await syncToSupabase(boards, nextId, nextBoardId, userCoins); if (result === null) { ffhLog.debug('Sync queued (another sync in progress)'); } else if (result) { ffhLog.debug('Sync successful'); } else { ffhLog.warn('Sync failed, data persisted to localStorage'); } }, FFH_PM_CONFIG.syncDebounceMs); } } /** * Main load function - replaces original loadFromStorage() * Tries Supabase first if connected, then falls back to localStorage */ async function loadFromStorage() { // Ensure Supabase is initialized await initializeSupabaseIntegration(); let loadedData = null; // Try to load from Supabase if connected if (ffhSupabaseState.isConnected && ffhSupabaseState.client) { ffhLog.info('Attempting to load from Supabase...'); loadedData = await loadFromSupabase(); } // Fall back to localStorage if Supabase load failed or not connected if (!loadedData) { ffhLog.info('Loading from localStorage...'); try { const stored = localStorage.getItem(FFH_PM_CONFIG.localStorageKey); if (stored) { const parsed = JSON.parse(stored); if (parsed.boards) loadedData = parsed; } } catch (parseError) { ffhLog.error('Failed to parse localStorage data', parseError.message); } } // Apply loaded data to global variables if (loadedData) { if (loadedData.boards) boards = loadedData.boards; if (typeof loadedData.nextId !== 'undefined') nextId = loadedData.nextId; if (typeof loadedData.nextBoardId !== 'undefined') nextBoardId = loadedData.nextBoardId; if (typeof loadedData.userCoins !== 'undefined') userCoins = loadedData.userCoins; ffhLog.info('Data loaded successfully', { source: ffhSupabaseState.isConnected ? 'Supabase' : 'localStorage', boardCount: boards.length, }); } else { ffhLog.info('No previously saved data found - starting fresh'); } } /** * Force a full sync to Supabase (manual trigger) * Useful for ensuring consistency */ async function forceSupabaseSync() { if (!ffhSupabaseState.isConnected) { ffhLog.warn('Cannot sync: not connected to Supabase'); return false; } ffhLog.info('Force sync initiated...'); if (ffhSupabaseState.syncTimeoutId) { clearTimeout(ffhSupabaseState.syncTimeoutId); } const result = await syncToSupabase(boards, nextId, nextBoardId, userCoins); if (result) { ffhLog.info('Force sync completed successfully'); } else { ffhLog.error('Force sync failed'); } return result; } /** * Get current sync status */ function getSyncStatus() { return { isInitialized: ffhSupabaseState.isInitialized, isConnected: ffhSupabaseState.isConnected, userId: ffhSupabaseState.userId, lastSyncTime: ffhSupabaseState.lastSyncTime, syncInProgress: ffhSupabaseState.syncInProgress, mode: ffhSupabaseState.isConnected ? 'Supabase + LocalStorage' : 'LocalStorage Only', }; } // ═══════════════════════════════════════════════════════════════════════════ // EXPORTS TO WINDOW // ═══════════════════════════════════════════════════════════════════════════ /* ┌─────────────────────────────────────────────────────────────────┐ │ PATCH 2: PM Audit Logging Helper (FERPA Compliance) │ └─────────────────────────────────────────────────────────────────┘ */ async function ffhPmAuditLog(action, resourceType, resourceId, details = {}) { if (!ffhSupabaseState.isConnected || !ffhSupabaseState.client || !ffhSupabaseState.userId) { return; } try { const { error } = await ffhSupabaseState.client .from('audit_log') .insert({ user_id: ffhSupabaseState.userId, action: action, resource_type: resourceType, resource_id: resourceId, details: details, ip_address: null, user_agent: navigator.userAgent }); if (error) { ffhLog.debug('PM audit log insert failed', error); } } catch (e) { ffhLog.debug('PM audit log exception', e.message); } } /* ┌─────────────────────────────────────────────────────────────────┐ │ PATCH 4: PM 30-Minute Inactivity Timeout │ └─────────────────────────────────────────────────────────────────┘ */ const FFHPmInactivityTimer = { warningTimeoutId: null, signoutTimeoutId: null, warningMs: 25 * 60 * 1000, signoutMs: 30 * 60 * 1000, isWarningShown: false, reset() { if (this.warningTimeoutId) clearTimeout(this.warningTimeoutId); if (this.signoutTimeoutId) clearTimeout(this.signoutTimeoutId); this.isWarningShown = false; if (!ffhSupabaseState || !ffhSupabaseState.isConnected) { return; } this.warningTimeoutId = setTimeout(() => { this.showWarning(); }, this.warningMs); this.signoutTimeoutId = setTimeout(() => { this.signout(); }, this.signoutMs); }, showWarning() { if (this.isWarningShown) return; this.isWarningShown = true; const msg = document.createElement('div'); msg.id = 'ffhPmInactivityWarning'; msg.setAttribute('role', 'alert'); msg.setAttribute('aria-live', 'assertive'); msg.style.cssText = 'position:fixed;bottom:24px;right:24px;background:#ff6b35;color:#fff;padding:16px 20px;border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.15);z-index:9998;font-size:14px;max-width:300px;line-height:1.5;'; msg.textContent = 'Your session will expire in 5 minutes due to inactivity. Click to extend.'; msg.style.cursor = 'pointer'; msg.addEventListener('click', () => { FFHPmInactivityTimer.reset(); msg.remove(); this.isWarningShown = false; }); document.body.appendChild(msg); }, async signout() { try { const overlay = document.createElement('div'); overlay.id = 'ffhPmSessionExpiredOverlay'; overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); overlay.setAttribute('aria-labelledby', 'ffhPmSessionExpiredTitle'); overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(15,32,68,0.9);display:flex;justify-content:center;align-items:center;z-index:10000;'; const content = document.createElement('div'); content.style.cssText = 'background:#fff;border-radius:8px;padding:40px;text-align:center;max-width:400px;box-shadow:0 20px 60px rgba(0,0,0,0.3);'; content.innerHTML = '

Session Expired

Your session expired due to 30 minutes of inactivity. Please log in again.

'; overlay.appendChild(content); document.body.appendChild(overlay); if (ffhSupabaseState && ffhSupabaseState.client) { await ffhSupabaseState.client.auth.signOut(); } // Clear sensitive in-memory data try { if (typeof boards !== 'undefined') { boards = []; } } catch (e) { ffhLog.debug('Could not clear boards', e.message); } const btn = document.getElementById('ffhPmSessionExpiredBtn'); if (btn) { btn.addEventListener('click', () => { window.location.href = '/'; }); } } catch (e) { ffhLog.error('PM session signout failed', e.message); } } }; function initPmInactivityListeners() { const events = ['click', 'keypress', 'scroll', 'touchstart', 'mousemove']; events.forEach(e => { document.addEventListener(e, () => { FFHPmInactivityTimer.reset(); }, { passive: true }); }); FFHPmInactivityTimer.reset(); } window.ffhSupabaseIntegration = { initialize: initializeSupabaseIntegration, forceSync: forceSupabaseSync, getStatus: getSyncStatus, loadFromStorage, saveToStorage, }; ffhLog.info('FFH PM Supabase Integration module loaded');