Sign in with Google
Use your Google account to access VolunTrack. Your role is assigned automatically based on your email allowlist, domain, or app preference rules.
// DEPLOY_CONFIG: {"triggers": [{"name": "volunteer_hours_50_milestone", "on": "collection.update", "collection": "volunteers", "actions": [{"type": "event", "event_type": "volunteer_hours_milestone_50"}]}, {"name": "volunteer_hours_100_milestone", "on": "collection.update", "collection": "volunteers", "actions": [{"type": "event", "event_type": "volunteer_hours_milestone_100"}]}, {"name": "volunteer_hours_500_milestone", "on": "collection.update", "collection": "volunteers", "actions": [{"type": "event", "event_type": "volunteer_hours_milestone_500"}]}, {"name": "monthly_hours_summary_to_supervisors", "on": "cron", "schedule": "0 9 1 * *", "actions": [{"type": "event", "event_type": "monthly_hours_summary_email"}]}]} import { useMemo, useState, useEffect, useRef } from 'react'; import { useAuth, useCollection, renderChart, exportCSV, exportJSON } from '@deplixo/sdk'; import { Dashboard } from './components/Dashboard.jsx'; import { LogHours } from './components/LogHours.jsx'; import { HoursList } from './components/HoursList.jsx'; import { Volunteers } from './components/Volunteers.jsx'; import { Settings } from './components/Settings.jsx'; function App() { const { user, loading, login, logout } = useAuth(); const [activeTab, setActiveTab] = useState('dashboard'); const [manualRole, setManualRole] = useState('auto'); const hours = useCollection('hours', { personal: true }); const volunteers = useCollection('volunteers', { personal: true }); const prefs = useCollection('preferences', { personal: true }); const normalize = value => String(value || '').trim().toLowerCase(); const roleConfig = useMemo(() => { const emails = new Set( (prefs.items || []) .map(item => item?.googleEmail || item?.email || item?.value) .filter(Boolean) .map(normalize) ); const adminDomains = new Set( (prefs.items || []) .flatMap(item => item?.adminDomains || []) .filter(Boolean) .map(normalize) ); const supervisorDomains = new Set( (prefs.items || []) .flatMap(item => item?.supervisorDomains || []) .filter(Boolean) .map(normalize) ); const explicitRole = normalize( (prefs.items || []).find(item => item?.type === 'role' || item?.key === 'defaultRole')?.role || (prefs.items || []).find(item => item?.type === 'role' || item?.key === 'defaultRole')?.value ); return { emails, adminDomains, supervisorDomains, explicitRole }; }, [prefs.items]); const derivedRole = useMemo(() => { if (!user?.email) return 'volunteer'; const email = normalize(user.email); const domain = email.split('@')[1] || ''; if (roleConfig.emails.has(email)) return 'admin'; if (roleConfig.adminDomains.has(domain)) return 'admin'; if (roleConfig.supervisorDomains.has(domain)) return 'supervisor'; if (roleConfig.explicitRole === 'admin' || roleConfig.explicitRole === 'supervisor' || roleConfig.explicitRole === 'volunteer') { return roleConfig.explicitRole; } if (domain.endsWith('.edu') || domain.endsWith('.org')) return 'supervisor'; return 'volunteer'; }, [roleConfig, user]); const effectiveRole = manualRole === 'auto' ? derivedRole : manualRole; const canManageVolunteers = effectiveRole === 'supervisor' || effectiveRole === 'admin'; const canApproveHours = effectiveRole === 'supervisor' || effectiveRole === 'admin'; const canAccessSettings = effectiveRole === 'admin'; const canExportReports = effectiveRole === 'supervisor' || effectiveRole === 'admin'; const tabs = [ { id: 'dashboard', label: 'Dashboard', icon: '📊' }, { id: 'log', label: 'Log Hours', icon: '⏱️', allowed: ['volunteer', 'supervisor', 'admin'] }, { id: 'hours', label: 'Hours', icon: '📋', allowed: ['volunteer', 'supervisor', 'admin'] }, { id: 'volunteers', label: 'Volunteers', icon: '👥', allowed: ['supervisor', 'admin'] }, { id: 'settings', label: 'Settings', icon: '⚙️', allowed: ['admin'] }, ]; const visibleTabs = tabs.filter(tab => !tab.allowed || tab.allowed.includes(effectiveRole)); const chartData = useMemo(() => { const entries = (hours.items || []).filter(Boolean); const toHours = item => Number(item?.hours ?? item?.amount ?? item?.duration ?? 0) || 0; const getVolunteerName = item => item?.volunteerName || item?.volunteer || item?.name || item?.userName || 'Unknown'; const getActivity = item => item?.activity || item?.project || item?.task || 'Other'; const getDate = item => item?.date || item?.createdAt || item?.loggedAt || item?.timestamp; const volunteerTotals = new Map(); const activityTotals = new Map(); const monthlyTotals = new Map(); entries.forEach(item => { const h = toHours(item); const volunteer = getVolunteerName(item); const activity = getActivity(item); const dateValue = getDate(item); const date = dateValue ? new Date(dateValue) : null; const monthKey = date && !Number.isNaN(date.getTime()) ? `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` : 'Unknown'; volunteerTotals.set(volunteer, (volunteerTotals.get(volunteer) || 0) + h); activityTotals.set(activity, (activityTotals.get(activity) || 0) + h); monthlyTotals.set(monthKey, (monthlyTotals.get(monthKey) || 0) + h); }); const sortDesc = (a, b) => b.value - a.value; const volunteerChart = [...volunteerTotals.entries()].map(([label, value]) => ({ label, value })).sort(sortDesc).slice(0, 8); const activityChart = [...activityTotals.entries()].map(([label, value]) => ({ label, value })).sort(sortDesc).slice(0, 8); const monthlyChart = [...monthlyTotals.entries()] .map(([label, value]) => ({ label, value })) .sort((a, b) => a.label.localeCompare(b.label)); return { volunteerChart, activityChart, monthlyChart }; }, [hours.items]); const reportData = useMemo(() => { const entries = (hours.items || []).filter(Boolean); const getVolunteerName = item => item?.volunteerName || item?.volunteer || item?.name || item?.userName || 'Unknown'; const getActivity = item => item?.activity || item?.project || item?.task || 'Other'; const getDate = item => item?.date || item?.createdAt || item?.loggedAt || item?.timestamp; const toHours = item => Number(item?.hours ?? item?.amount ?? item?.duration ?? 0) || 0; const normalizeStatus = item => normalize(item?.status || item?.approvalStatus || 'pending'); const dateValues = entries .map(item => { const d = getDate(item) ? new Date(getDate(item)) : null; return d && !Number.isNaN(d.getTime()) ? d : null; }) .filter(Boolean); const defaultStart = dateValues.length ? new Date(Math.min(...dateValues.map(d => d.getTime()))) : null; const defaultEnd = dateValues.length ? new Date(Math.max(...dateValues.map(d => d.getTime()))) : null; const volunteerTotals = new Map(); const activityTotals = new Map(); const monthTotals = new Map(); const statusTotals = new Map(); entries.forEach(item => { const hoursValue = toHours(item); const volunteer = getVolunteerName(item); const activity = getActivity(item); const status = normalizeStatus(item); const dateValue = getDate(item); const date = dateValue ? new Date(dateValue) : null; const monthKey = date && !Number.isNaN(date.getTime()) ? `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` : 'Unknown'; volunteerTotals.set(volunteer, (volunteerTotals.get(volunteer) || 0) + hoursValue); activityTotals.set(activity, (activityTotals.get(activity) || 0) + hoursValue); monthTotals.set(monthKey, (monthTotals.get(monthKey) || 0) + hoursValue); statusTotals.set(status, (statusTotals.get(status) || 0) + hoursValue); }); const sortedEntries = [...entries].sort((a, b) => { const aTime = getDate(a) ? new Date(getDate(a)).getTime() : 0; const bTime = getDate(b) ? new Date(getDate(b)).getTime() : 0; return bTime - aTime; }); return { entries: sortedEntries, defaultStart, defaultEnd, volunteerTotals, activityTotals, monthTotals, statusTotals, }; }, [hours.items]); const handleExportGrantReport = (format = 'csv') => { const rows = (reportData.entries || []).map(item => { const dateValue = item?.date || item?.createdAt || item?.loggedAt || item?.timestamp; const date = dateValue ? new Date(dateValue) : null; const volunteer = item?.volunteerName || item?.volunteer || item?.name || item?.userName || 'Unknown'; const activity = item?.activity || item?.project || item?.task || 'Other'; const status = item?.status || item?.approvalStatus || 'pending'; const hoursValue = Number(item?.hours ?? item?.amount ?? item?.duration ?? 0) || 0; return { id: item?.id || '', date: date && !Number.isNaN(date.getTime()) ? date.toISOString().slice(0, 10) : '', volunteer, activity, status, hours: hoursValue, location: item?.location || '', description: item?.description || '', submittedBy: item?.submittedBy || item?.email || '', }; }); const summaryRows = [ ...[...reportData.volunteerTotals.entries()].map(([name, total]) => ({ section: 'Volunteer Breakdown', label: name, totalHours: total })), ...[...reportData.activityTotals.entries()].map(([name, total]) => ({ section: 'Activity Breakdown', label: name, totalHours: total })), ...[...reportData.monthTotals.entries()].map(([name, total]) => ({ section: 'Monthly Breakdown', label: name, totalHours: total })), ...[...reportData.statusTotals.entries()].map(([name, total]) => ({ section: 'Status Breakdown', label: name, totalHours: total })), ]; const baseFilename = `grant-hours-report-${new Date().toISOString().slice(0, 10)}`; if (format === 'json') { exportJSON( { generatedAt: new Date().toISOString(), reportRange: { start: reportData.defaultStart ? reportData.defaultStart.toISOString() : '', end: reportData.defaultEnd ? reportData.defaultEnd.toISOString() : '', }, hours: rows, breakdowns: summaryRows, }, { filename: `${baseFilename}.json` } ); return; } exportCSV(rows, { filename: `${baseFilename}.csv`, columns: ['id', 'date', 'volunteer', 'activity', 'status', 'hours', 'location', 'description', 'submittedBy'], }); }; if (loading) { return
Volunteer Hour Tracker
Use your Google account to access VolunTrack. Your role is assigned automatically based on your email allowlist, domain, or app preference rules.
Volunteer Hour Tracker
Download hours data for grant reports with date range, volunteer, activity, and status breakdowns.
You need supervisor or admin access to manage volunteers.
You need admin access to change settings.
Default behavior is automatic role derivation from Google identity. Admins can temporarily switch the active role for testing or delegated workflows.