/* @component-map * App β€” Main container, tab navigation [app.jsx] * PracticeLog β€” Log and view practice sessions [components/PracticeLog.jsx] * Metronome β€” BPM metronome with sound [components/Metronome.jsx] * Stats β€” Practice statistics and totals [components/Stats.jsx] * @end-component-map */ import { useMemo, useState } from 'react'; import { useAuth, useAI, useCollection } from '@deplixo/sdk'; import { PracticeLog } from './components/PracticeLog.jsx'; import { Metronome } from './components/Metronome.jsx'; import { Stats } from './components/Stats.jsx'; function App() { const { user, loading, logout } = useAuth(); const { items: practiceSessions = [], add: addPracticeSession, update: updatePracticeSession, remove: removePracticeSession } = useCollection('practice-sessions', { personal: true }); const { items: shareLinks = [], add: addShareLink, update: updateShareLink, remove: removeShareLink } = useCollection('practice-shares', { personal: true }); const { generate, loading: advisorLoading, error: advisorError } = useAI(); const [tab, setTab] = useState('log'); const [advisorOutput, setAdvisorOutput] = useState(''); const [advisorLoadingState, setAdvisorLoadingState] = useState(false); const [shareRole, setShareRole] = useState('student'); const [shareCode, setShareCode] = useState(''); const [viewMode, setViewMode] = useState('mine'); const [viewStudentCode, setViewStudentCode] = useState(''); const [viewStudentName, setViewStudentName] = useState(''); const [shareMessage, setShareMessage] = useState(''); const tabs = [ { id: 'log', label: '🎡 Practice Log', icon: '🎡' }, { id: 'metronome', label: '⏱ Metronome', icon: '⏱' }, { id: 'stats', label: 'πŸ“Š Stats', icon: 'πŸ“Š' }, { id: 'advisor', label: '🧠 Advisor', icon: '🧠' }, { id: 'sharing', label: 'πŸ”— Sharing', icon: 'πŸ”—' } ]; const activeShareLinks = useMemo(() => { return [...shareLinks].filter(Boolean); }, [shareLinks]); const teacherVisibleStudents = useMemo(() => { return activeShareLinks.filter(link => link?.role === 'teacher' && (link?.teacherId === user?.id || link?.teacherEmail === user?.email)); }, [activeShareLinks, user]); const currentShareLink = useMemo(() => { return activeShareLinks.find(link => link?.shareCode && link?.shareCode === viewStudentCode) || null; }, [activeShareLinks, viewStudentCode]); const sharedStudentSessions = useMemo(() => { if (viewMode !== 'shared' || !currentShareLink) return []; return [...practiceSessions] .filter(session => { const sessionOwner = session?.userId || session?.ownerId || session?.createdBy || session?.email || ''; const sharesForStudent = currentShareLink?.studentId || currentShareLink?.studentEmail || currentShareLink?.studentName; return sessionOwner === currentShareLink?.studentId || sessionOwner === currentShareLink?.studentEmail || sessionOwner === currentShareLink?.studentName || (sharesForStudent && (session?.studentId === currentShareLink?.studentId || session?.studentEmail === currentShareLink?.studentEmail)); }) .sort((a, b) => { const aTime = new Date(a?.createdAt || a?.date || 0).getTime(); const bTime = new Date(b?.createdAt || b?.date || 0).getTime(); return bTime - aTime; }); }, [practiceSessions, currentShareLink, viewMode]); const advisorContext = useMemo(() => { const sessions = [...practiceSessions].sort((a, b) => { const aTime = new Date(a?.createdAt || a?.date || 0).getTime(); const bTime = new Date(b?.createdAt || b?.date || 0).getTime(); return bTime - aTime; }); const totalMinutes = sessions.reduce((sum, s) => { const duration = Number(s?.duration || s?.minutes || 0); return sum + (Number.isFinite(duration) ? duration : 0); }, 0); const instrumentCounts = sessions.reduce((acc, s) => { const instrument = (s?.instrument || 'Unknown').trim(); acc[instrument] = (acc[instrument] || 0) + 1; return acc; }, {}); const tempoValues = sessions .map(s => Number(s?.tempo || s?.bpm || 0)) .filter(v => Number.isFinite(v) && v > 0); const recentSessions = sessions.slice(0, 7).map(s => ({ instrument: s?.instrument || 'Unknown', piece: s?.piece || s?.pieceName || 'Untitled', duration: Number(s?.duration || s?.minutes || 0) || 0, tempo: Number(s?.tempo || s?.bpm || 0) || null, notes: s?.notes || '' })); return { totalSessions: sessions.length, totalMinutes, instrumentCounts, tempoAvg: tempoValues.length ? Math.round(tempoValues.reduce((a, b) => a + b, 0) / tempoValues.length) : null, recentSessions }; }, [practiceSessions]); const visibleSessions = useMemo(() => { if (viewMode === 'shared' && currentShareLink) return sharedStudentSessions; return [...practiceSessions].sort((a, b) => { const aTime = new Date(a?.createdAt || a?.date || 0).getTime(); const bTime = new Date(b?.createdAt || b?.date || 0).getTime(); return bTime - aTime; }); }, [practiceSessions, sharedStudentSessions, currentShareLink, viewMode]); const visibleStats = useMemo(() => { const sessions = visibleSessions; const totalMinutes = sessions.reduce((sum, s) => { const duration = Number(s?.duration || s?.minutes || 0); return sum + (Number.isFinite(duration) ? duration : 0); }, 0); const tempoValues = sessions.map(s => Number(s?.tempo || s?.bpm || 0)).filter(v => Number.isFinite(v) && v > 0); return { totalSessions: sessions.length, totalMinutes, tempoAvg: tempoValues.length ? Math.round(tempoValues.reduce((a, b) => a + b, 0) / tempoValues.length) : null }; }, [visibleSessions]); const exportPracticeCSV = () => { const sessions = [...practiceSessions].sort((a, b) => { const aTime = new Date(a?.createdAt || a?.date || 0).getTime(); const bTime = new Date(b?.createdAt || b?.date || 0).getTime(); return bTime - aTime; }); const csvEscape = (value) => { const text = value == null ? '' : String(value); return `"${text.replace(/"/g, '""')}"`; }; const rows = [ [ 'date', 'instrument', 'piece', 'duration_minutes', 'tempo_bpm', 'notes' ], ...sessions.map(session => [ session?.createdAt || session?.date || '', session?.instrument || '', session?.piece || session?.pieceName || '', session?.duration || session?.minutes || '', session?.tempo || session?.bpm || '', session?.notes || '' ]) ]; const csvContent = rows.map(row => row.map(csvEscape).join(',')).join('\n'); const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = `practice-sessions-${new Date().toISOString().slice(0, 10)}.csv`; anchor.click(); URL.revokeObjectURL(url); }; const createShareCode = () => Math.random().toString(36).slice(2, 8).toUpperCase(); const handleCreateShare = async () => { if (!user) return; setShareMessage(''); const code = createShareCode(); try { await addShareLink({ shareCode: code, role: shareRole, ownerId: user.id, ownerEmail: user.email, teacherId: shareRole === 'teacher' ? user.id : '', teacherEmail: shareRole === 'teacher' ? user.email : '', studentId: shareRole === 'student' ? user.id : '', studentEmail: shareRole === 'student' ? user.email : '', studentName: user.name || 'Student', createdAt: new Date().toISOString() }); setShareCode(code); setShareMessage(shareRole === 'student' ? 'Share this code with your teacher so they can view your progress.' : 'Teacher link created. Share the code with a student to connect their practice data.'); } catch (e) { setShareMessage('Unable to create share link right now. Please try again.'); } }; const handleViewStudent = () => { const code = viewStudentCode.trim().toUpperCase(); if (!code) { setShareMessage('Enter a share code to view a student profile.'); return; } const match = activeShareLinks.find(link => link?.shareCode === code); if (!match) { setShareMessage('No student found for that share code.'); return; } setViewMode('shared'); setViewStudentName(match?.studentName || 'Student'); setShareMessage(`Viewing ${match?.studentName || 'student'} progress.`); setTab('stats'); }; const handleStopViewing = () => { setViewMode('mine'); setViewStudentCode(''); setViewStudentName(''); setShareMessage(''); }; const handleGenerateAdvisor = async () => { if (!user) return; setAdvisorLoadingState(true); setAdvisorOutput(''); try { const response = await generate({ system: `You are an expert music practice coach. Analyze the user's practice history and provide concise, practical guidance. Return a helpful advisor report with: 1) What to focus on next 2) Specific practice strategies 3) One short weekly action plan Keep the response easy to read with headings and bullets.`, user: `User: ${user.name || 'Practice musician'} Practice summary: - Total sessions: ${advisorContext.totalSessions} - Total minutes: ${advisorContext.totalMinutes} - Average tempo: ${advisorContext.tempoAvg ?? 'Unknown'} - Most practiced instruments: ${Object.entries(advisorContext.instrumentCounts) .sort((a, b) => b[1] - a[1]) .slice(0, 3) .map(([instrument, count]) => `${instrument} (${count})`) .join(', ') || 'None yet'} Recent sessions: ${advisorContext.recentSessions .map((s, i) => `${i + 1}. ${s.instrument} β€” ${s.piece} β€” ${s.duration} min β€” tempo: ${s.tempo ?? 'n/a'} β€” notes: ${s.notes || 'none'}`) .join('\n') || 'No sessions logged yet.'} Please analyze this data and suggest what to focus on next plus optimal practice strategies.`, json: false, }); setAdvisorOutput(response); setTab('advisor'); } catch (e) { setAdvisorOutput('Unable to generate advice right now. Please try again after logging a few more sessions.'); } finally { setAdvisorLoadingState(false); } }; const advisorBusy = advisorLoading || advisorLoadingState; if (loading) { return (

🎸 Practice Log

Signing you in...

); } if (!user) { return (

🎸 Practice Log

Please sign in with Google to access your personal practice data.

Authentication is handled by Deplixo. Once signed in, your practice logs and stats are tied to your account.

); } return (

🎸 Practice Log

Track your musical journey

{user.avatar ? {user.name} :
{user.name?.[0] || 'U'}
}
{user.name} {user.email}
{tab === 'log' && } {tab === 'metronome' && } {tab === 'stats' && } {tab === 'advisor' && (

AI Practice Advisor

Get personalized next steps based on your logged sessions.

Sessions {advisorContext.totalSessions}
Minutes {advisorContext.totalMinutes}
Avg Tempo {advisorContext.tempoAvg ?? 'β€”'}
{advisorError &&

{advisorError}

}
{advisorOutput ?
{advisorOutput}
:

Click β€œGenerate Advice” to analyze your practice history and receive personalized guidance.

}
)} {tab === 'sharing' && (

Teacher / Student Sharing

Create a share code so a teacher can view a student's practice logs and stats.

{viewMode === 'shared' ? ( ) : null}

Share your progress

Choose how this account should be shared. Students can generate a code to give to teachers; teachers can also create a view code for a specific student profile.

{shareCode &&

Code: {shareCode}

}

View a student

Enter the student's share code to switch the stats/logs view to their practice data.

setViewStudentCode(e.target.value)} placeholder="e.g. ABC123" />

Export practice data

Download your logged sessions as a CSV file for spreadsheets or backup.

{shareMessage &&

{shareMessage}

}

Current view

{viewMode === 'shared' && currentShareLink ? `Viewing ${viewStudentName || currentShareLink?.studentName || 'student'} progress.` : 'Viewing your own practice data.'}

Sessions {visibleStats.totalSessions}
Minutes {visibleStats.totalMinutes}
Avg Tempo {visibleStats.tempoAvg ?? 'β€”'}
)}
); } export default App; ReactDOM.createRoot(document.getElementById('root')).render();