const { useState, useEffect, useMemo } = React; // Lightweight inline icons (SVG) to avoid external deps const Icon = ({ path, size = 20, className = '' }) => ( ); const ChevronLeft = (p) => ; const ChevronRight = (p) => ; const Save = (p) => ; const Edit2 = (p) => ; const Check = (p) => ; const CalendarIcon = (p) => ; const Clock = (p) => ; const X = (p) => ; // Helper to format minutes into "Xh Ym" const formatTime = (totalMinutes) => { if (!totalMinutes) return '0h 0m'; const h = Math.floor(totalMinutes / 60); const m = totalMinutes % 60; return `${h}h ${m}m`; }; function useAuth() { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const refresh = async () => { try { const res = await fetch('api/me'); if (res.ok) { const data = await res.json(); setUser({ username: data.username }); } else { setUser(null); } } catch (_) { setUser(null); } finally { setLoading(false); } }; useEffect(() => { refresh(); }, []); const login = async (username, password) => { const res = await fetch('api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); if (!res.ok) throw new Error('Invalid credentials'); await refresh(); }; const register = async (username, password) => { const res = await fetch('api/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d.error || 'Registration failed'); } await refresh(); }; const logout = async () => { await fetch('api/logout', { method: 'POST' }); setUser(null); }; return { user, loading, login, register, logout }; } function App() { const { user, loading: loadingUser, login, register, logout } = useAuth(); const [monthData, setMonthData] = useState({ goalHours: 0, logs: {} }); const [loadingData, setLoadingData] = useState(false); const [savePending, setSavePending] = useState(false); const [exporting, setExporting] = useState(false); const [currentDate, setCurrentDate] = useState(new Date()); const [isEditingGoal, setIsEditingGoal] = useState(false); const [tempGoal, setTempGoal] = useState(''); const year = currentDate.getFullYear(); const month = currentDate.getMonth(); // 0-indexed const monthKey = `${year}-${String(month + 1).padStart(2, '0')}`; // Fetch data for current month when user or month changes useEffect(() => { if (!user) return; setLoadingData(true); fetch(`api/month?year=${year}&month=${month + 1}`) .then(r => r.json()) .then(d => setMonthData({ goalHours: d?.goalHours || 0, logs: d?.logs || {} })) .catch(() => setMonthData({ goalHours: 0, logs: {} })) .finally(() => setLoadingData(false)); }, [user?.username, year, month]); // Save to server whenever data changes (debounced) useEffect(() => { if (!user) return; const h = setTimeout(() => { setSavePending(true); fetch('api/month', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ year, month: month + 1, goalHours: monthData.goalHours, logs: monthData.logs }) }) .finally(() => setSavePending(false)); }, 300); return () => clearTimeout(h); }, [monthData, user?.username, year, month]); useEffect(() => { setTempGoal(monthData.goalHours || 0); setIsEditingGoal(false); }, [monthData.goalHours, monthKey]); const getDaysInMonth = (y, m) => new Date(y, m + 1, 0).getDate(); const getFirstDayOfMonth = (y, m) => new Date(y, m, 1).getDay(); const daysInMonth = getDaysInMonth(year, month); const startDay = getFirstDayOfMonth(year, month); const currentGoalHours = monthData.goalHours || 0; const currentGoalMinutes = currentGoalHours * 60; const totalMinutesWorked = useMemo(() => { let sum = 0; for (let d = 1; d <= daysInMonth; d++) { const dateKey = `${monthKey}-${String(d).padStart(2, '0')}`; sum += (monthData.logs[dateKey] || 0); } return sum; }, [monthData.logs, monthKey, daysInMonth]); const exportCsv = async () => { try { setExporting(true); const res = await fetch('api/export'); if (!res.ok) throw new Error('Export failed'); const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; const ts = new Date().toISOString().slice(0,19).replace(/[:T]/g,'-'); a.download = `work_time_export_${user?.username || 'data'}_${ts}.csv`; document.body.appendChild(a); a.click(); URL.revokeObjectURL(url); a.remove(); } catch (_) { alert('Failed to export data.'); } finally { setExporting(false); } }; const percentage = currentGoalMinutes > 0 ? Math.min(100, Math.round((totalMinutesWorked / currentGoalMinutes) * 100)) : 0; const minutesLeft = Math.max(0, currentGoalMinutes - totalMinutesWorked); const handlePrevMonth = () => setCurrentDate(new Date(year, month - 1, 1)); const handleNextMonth = () => setCurrentDate(new Date(year, month + 1, 1)); const handleGoalSave = () => { const raw = parseFloat(String(tempGoal).replace(',', '.')); const gh = Number.isFinite(raw) ? Math.round(raw * 2) / 2 : 0; // allow half hours setMonthData(prev => ({ ...prev, goalHours: gh })); setIsEditingGoal(false); }; const handleLogUpdate = (day, minutes) => { const dateKey = `${monthKey}-${String(day).padStart(2, '0')}`; setMonthData(prev => ({ ...prev, logs: { ...prev.logs, [dateKey]: minutes } })); }; if (loadingUser || loadingData) return
Loading...
; if (!user) return ; return (

Work Tracker

Monthly Overview

Signed in as {user.username}
{currentDate.toLocaleDateString('default', { month: 'short', year: 'numeric' })}
{!isEditingGoal && ( )}
{isEditingGoal ? (
setTempGoal(e.target.value)} onFocus={(e) => e.target.select()} className="w-full text-3xl font-bold text-slate-800 bg-slate-50 border-b-2 border-blue-500 outline-none py-1 px-2 rounded-t" autoFocus />
) : (
setIsEditingGoal(true)}> {monthData.goalHours || 0} hours target
)}
Total Worked
{formatTime(totalMinutesWorked)}
Left to do
{formatTime(minutesLeft)}
Progress {savePending ? '(saving...)' : ''} {percentage}%
{['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day, i) => (
{day} {['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][i]}
))}
{Array.from({ length: new Date(year, month, 1).getDay() }).map((_, i) => (
))} {Array.from({ length: startDay }).map((_, i) => (
))} {Array.from({ length: daysInMonth }).map((_, i) => { const day = i + 1; const dateKey = `${monthKey}-${String(day).padStart(2, '0')}`; const minutes = monthData.logs[dateKey] || 0; const isToday = new Date().getDate() === day && new Date().getMonth() === month && new Date().getFullYear() === year; return ( handleLogUpdate(day, m)} /> ); })} {Array.from({ length: (7 - ((startDay + daysInMonth) % 7)) % 7 }).map((_, i) => (
))} {Array.from({ length: (7 - ((startDay + daysInMonth) % 7)) % 7 }).map((_, i) => (
))}
); } function DayCell({ day, isToday, minutes, onSave }) { const [isEditing, setIsEditing] = useState(false); const [h, setH] = useState(Math.floor(minutes / 60)); const [m, setM] = useState(minutes % 60); const [showMinutes, setShowMinutes] = useState(false); useEffect(() => { setH(Math.floor(minutes / 60)); setM(minutes % 60); }, [minutes]); const handleSave = (e) => { e.stopPropagation(); const total = (parseInt(h) || 0) * 60 + (parseInt(m) || 0); onSave(total); setIsEditing(false); }; const handleCancel = (e) => { e.stopPropagation(); setH(Math.floor(minutes / 60)); setM(minutes % 60); setShowMinutes(false); setIsEditing(false); }; const hasData = minutes > 0; return (
!isEditing && setIsEditing(true)} className={`relative min-h-[100px] md:min-h-[140px] p-1 md:p-3 flex flex-col transition-all cursor-pointer border-r border-slate-100 last:border-r-0 ${isToday ? 'bg-blue-50 ring-2 ring-inset ring-blue-500 z-10' : 'bg-white active:bg-slate-50 hover:bg-slate-50'}`}>
{day} {hasData && !isEditing && (
Done
)} {hasData && !isEditing && (
)}
{isEditing ? (
e.stopPropagation()} onChange={e => setH(e.target.value)} onFocus={(e) => e.target.select()} autoFocus />
{!showMinutes ? ( ) : (
e.stopPropagation()} onChange={e => setM(e.target.value)} onFocus={(e) => e.target.select()} />
)}
) : (
{hasData ? (
{formatTime(minutes)} recorded
) : (
+
Add
)}
)}
); } function AuthView({ onLogin, onRegister }) { const [mode, setMode] = useState('login'); // 'login' | 'register' const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const submit = async (e) => { e.preventDefault(); setError(''); setLoading(true); try { if (mode === 'login') await onLogin(username, password); else await onRegister(username, password); } catch (e) { setError(e.message || 'Request failed'); } finally { setLoading(false); } }; return (

Work Tracker

{mode === 'login' ? 'Sign in' : 'Create account'}

{error &&
{error}
}
setUsername(e.target.value)} autoFocus />
setPassword(e.target.value)} />
{mode === 'login' ? ( No account? ) : ( Already have an account? )}
); } const root = ReactDOM.createRoot(document.getElementById('root')); root.render();