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
) : (
)}
)}
);
}
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}
}
{mode === 'login' ? (
No account?
) : (
Already have an account?
)}
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render();