/* @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?.[0] || 'U'}
}
{user.name}
{user.email}
Sign Out
{tabs.map(t => (
setTab(t.id)}
>
{t.icon}
{t.label.split(' ').slice(1).join(' ')}
))}
{tab === 'log' && }
{tab === 'metronome' && }
{tab === 'stats' && }
{tab === 'advisor' && (
AI Practice Advisor
Get personalized next steps based on your logged sessions.
{advisorBusy ? 'Analyzing...' : 'Generate Advice'}
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' ? (
Back to My Data
) : 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.
Share as
setShareRole(e.target.value)}>
Student
Teacher
Create Share Code
{shareCode &&
Code: {shareCode}
}
Export practice data
Download your logged sessions as a CSV file for spreadsheets or backup.
Export CSV
{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( );