Versión 1.0

This commit is contained in:
Antoni Nuñez Romeu
2026-04-09 23:42:49 +02:00
parent 72bf48f7f8
commit b3f7d6bf98
24 changed files with 4335 additions and 48 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,171 @@
import logo from './logo.svg';
import React, { useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate, Link, useLocation, useNavigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './context/AuthContext';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Sessions from './pages/Sessions';
import Calendar from './pages/Calendar';
import Profile from './pages/Profile';
import './App.css';
function App() {
// Protected route component
const ProtectedRoute = ({ children }) => {
const { isAuthenticated } = useAuth();
return isAuthenticated ? children : <Navigate to="/" />;
};
// Public route component (redirects authenticated users away from login)
const PublicRoute = ({ children }) => {
const { isAuthenticated } = useAuth();
return !isAuthenticated ? children : <Navigate to="/dashboard" />;
};
const AppNavBar = () => {
const { isAuthenticated, logout, user } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate('/');
};
const isActive = (path) => location.pathname === path;
const profileAvatar = user?.user_metadata?.avatar_url;
const profileInitial = (user?.user_metadata?.full_name || user?.email || 'U').charAt(0).toUpperCase();
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.js</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
<>
<nav className="app-navbar">
<div className="app-navbar-inner">
<Link to={isAuthenticated ? '/dashboard' : '/'} className="app-logo">
<img src="/LOGO_FICOSA.svg" alt="FICOSA logo" className="app-logo-mark" />
<span className="app-logo-text">Time Tracker</span>
</Link>
<div className="app-navbar-desktop">
{isAuthenticated && (
<>
<Link to="/dashboard" className={`nav-link ${isActive('/dashboard') ? 'active' : ''}`}>
Dashboard
</Link>
<Link to="/sessions" className={`nav-link ${isActive('/sessions') ? 'active' : ''}`}>
Session History
</Link>
<Link to="/calendar" className={`nav-link ${isActive('/calendar') ? 'active' : ''}`}>
Calendar
</Link>
</>
)}
{isAuthenticated && (
<Link to="/profile" className="nav-profile-pill" aria-label="Open profile">
{profileAvatar ? (
<img src={profileAvatar} alt="Profile" className="nav-profile-avatar" />
) : (
<span className="nav-profile-fallback">{profileInitial}</span>
)}
</Link>
)}
{isAuthenticated && (
<button type="button" onClick={handleLogout} className="logout-button">
Logout
</button>
)}
</div>
</div>
</nav>
{isAuthenticated && (
<div className="mobile-webapp-nav">
<Link to="/dashboard" className={`mobile-webapp-link ${isActive('/dashboard') ? 'active' : ''}`}>
<span className="mobile-webapp-icon"></span>
<span className="mobile-webapp-label">Dashboard</span>
</Link>
<Link to="/sessions" className={`mobile-webapp-link ${isActive('/sessions') ? 'active' : ''}`}>
<span className="mobile-webapp-icon">🗂</span>
<span className="mobile-webapp-label">Sessions</span>
</Link>
<Link to="/calendar" className={`mobile-webapp-link ${isActive('/calendar') ? 'active' : ''}`}>
<span className="mobile-webapp-icon">📅</span>
<span className="mobile-webapp-label">Calendar</span>
</Link>
<Link to="/profile" className={`mobile-webapp-link ${isActive('/profile') ? 'active' : ''}`}>
{profileAvatar ? (
<img src={profileAvatar} alt="Profile" className="mobile-webapp-avatar" />
) : (
<span className="mobile-webapp-icon">👤</span>
)}
</Link>
<button type="button" onClick={handleLogout} className="mobile-webapp-link mobile-webapp-logout">
<span className="mobile-webapp-icon"></span>
<span className="mobile-webapp-label">Logout</span>
</button>
</div>
)}
</>
);
};
function AppContent() {
useEffect(() => {
document.documentElement.setAttribute('data-theme', 'dark');
localStorage.setItem('timeTrackerTheme', 'dark');
}, []);
return (
<Router>
<AppNavBar />
<Routes>
<Route
path="/"
element={
<PublicRoute>
<Login />
</PublicRoute>
}
/>
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/sessions"
element={
<ProtectedRoute>
<Sessions />
</ProtectedRoute>
}
/>
<Route
path="/calendar"
element={
<ProtectedRoute>
<Calendar />
</ProtectedRoute>
}
/>
<Route
path="/profile"
element={
<ProtectedRoute>
<Profile />
</ProtectedRoute>
}
/>
</Routes>
</Router>
);
}
export default App;
function App() {
return (
<AuthProvider>
<AppContent />
</AuthProvider>
);
}
export default App;

539
src/context/AuthContext.js Normal file
View File

@@ -0,0 +1,539 @@
import React, { createContext, useState, useContext, useEffect } from 'react';
import sessionService from '../services/sessionService';
import { supabase } from '../services/supabaseClient';
const AuthContext = createContext();
export const AuthProvider = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [user, setUser] = useState(null);
const [sessionData, setSessionData] = useState({
currentTimeEntry: null,
activeTimer: null,
pausedTimer: null,
sessions: []
});
const [loading, setLoading] = useState(true);
const createTicker = () => setInterval(() => {}, 1000);
const isPersistedSession = (id) => typeof id === 'string';
const toIsoPauses = (pauses = []) =>
pauses.map((pause) => ({
start: new Date(pause.start).toISOString(),
end: pause.end ? new Date(pause.end).toISOString() : null
}));
// Check for existing session on app load
useEffect(() => {
const checkSession = async () => {
try {
const { data: { session } } = await supabase.auth.getSession();
if (session) {
setIsAuthenticated(true);
setUser(session.user);
await loadSessions(session.user.id);
}
} catch (error) {
console.error('Error checking session:', error);
} finally {
setLoading(false);
}
};
checkSession();
// Listen for auth changes
const { data: authListener } = supabase.auth.onAuthStateChange(
async (event, session) => {
if (event === 'SIGNED_IN') {
setIsAuthenticated(true);
setUser(session.user);
await loadSessions(session.user.id);
} else if (event === 'SIGNED_OUT') {
setIsAuthenticated(false);
setUser(null);
setSessionData({
currentTimeEntry: null,
activeTimer: null,
pausedTimer: null,
sessions: []
});
}
}
);
return () => {
authListener.subscription.unsubscribe();
};
}, []);
// Load sessions from database when user logs in
const loadSessions = async (userId) => {
try {
const sessions = await sessionService.getSessions(userId);
const activeSession = sessions.find((session) => !session.endTime) || null;
setSessionData((prev) => {
if (prev.activeTimer) {
clearInterval(prev.activeTimer);
}
if (prev.pausedTimer) {
clearInterval(prev.pausedTimer);
}
const normalizedActiveSession = activeSession
? {
...activeSession,
pauses: (activeSession.pauses || []).map((pause) => ({
start: new Date(pause.start),
end: pause.end ? new Date(pause.end) : null
}))
}
: null;
const isPaused = normalizedActiveSession
? normalizedActiveSession.pauses.some((pause) => pause.end === null)
: false;
return {
...prev,
sessions: sessions,
currentTimeEntry: normalizedActiveSession,
activeTimer: normalizedActiveSession && !isPaused ? createTicker() : null,
pausedTimer: normalizedActiveSession && isPaused ? createTicker() : null
};
});
} catch (error) {
console.error('Failed to load sessions:', error);
// Fallback to empty sessions
setSessionData(prev => ({
...prev,
sessions: [],
currentTimeEntry: null,
activeTimer: null,
pausedTimer: null
}));
}
};
// Refresh sessions from database
const refreshSessions = async () => {
if (user) {
try {
const sessions = await sessionService.getSessions(user.id);
const activeSession = sessions.find((session) => !session.endTime) || null;
setSessionData((prev) => {
const normalizedActiveSession = activeSession
? {
...activeSession,
pauses: (activeSession.pauses || []).map((pause) => ({
start: new Date(pause.start),
end: pause.end ? new Date(pause.end) : null
}))
}
: null;
if (!normalizedActiveSession) {
if (prev.activeTimer) {
clearInterval(prev.activeTimer);
}
if (prev.pausedTimer) {
clearInterval(prev.pausedTimer);
}
return {
...prev,
sessions: sessions,
currentTimeEntry: null,
activeTimer: null,
pausedTimer: null
};
}
const isPaused = normalizedActiveSession.pauses.some((pause) => pause.end === null);
const sameSession = prev.currentTimeEntry && prev.currentTimeEntry.id === normalizedActiveSession.id;
if (sameSession) {
return {
...prev,
sessions: sessions,
currentTimeEntry: normalizedActiveSession
};
}
if (prev.activeTimer) {
clearInterval(prev.activeTimer);
}
if (prev.pausedTimer) {
clearInterval(prev.pausedTimer);
}
return {
...prev,
sessions: sessions,
currentTimeEntry: normalizedActiveSession,
activeTimer: !isPaused ? createTicker() : null,
pausedTimer: isPaused ? createTicker() : null
};
});
} catch (error) {
console.error('Failed to refresh sessions:', error);
}
}
};
// Make a session active
const makeSessionActive = (session) => {
// Stop any existing timer
if (sessionData.activeTimer) {
clearInterval(sessionData.activeTimer);
}
// Stop any existing pause timer
if (sessionData.pausedTimer) {
clearInterval(sessionData.pausedTimer);
}
// Create new time entry based on the session
const newTimeEntry = {
id: session.id,
startTime: session.startTime,
endTime: null,
duration: 0,
userId: user.id,
pauses: (session.pauses || []).map((pause) => ({
start: new Date(pause.start),
end: pause.end ? new Date(pause.end) : null
}))
};
// Start timer
const activeTimer = setInterval(() => {
// Timer updates display
}, 1000);
setSessionData(prev => ({
...prev,
currentTimeEntry: newTimeEntry,
activeTimer: activeTimer,
pausedTimer: null
}));
};
const updateCurrentSessionEntry = (updatedEntry) => {
setSessionData((prev) => {
if (!prev.currentTimeEntry) {
return prev;
}
return {
...prev,
currentTimeEntry: {
...prev.currentTimeEntry,
...updatedEntry
}
};
});
};
const refreshUser = async () => {
try {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
if (data?.user) {
setUser(data.user);
}
return { success: true, user: data?.user ?? null };
} catch (error) {
console.error('Failed to refresh user:', error);
return { success: false, error: error.message };
}
};
const login = async (email, password) => {
try {
const userData = await sessionService.authenticateUser(email, password);
setIsAuthenticated(true);
setUser(userData);
// Initialize session data for the user
setSessionData({
currentTimeEntry: null,
activeTimer: null,
pausedTimer: null,
sessions: []
});
// Load sessions from database
await loadSessions(userData.id);
return { success: true };
} catch (error) {
console.error('Login error:', error);
return { success: false, error: error.message };
}
};
const register = async (email, password) => {
try {
const userData = await sessionService.registerUser(email, password);
setIsAuthenticated(true);
setUser(userData);
// Initialize session data for the user
setSessionData({
currentTimeEntry: null,
activeTimer: null,
pausedTimer: null,
sessions: []
});
return { success: true };
} catch (error) {
console.error('Registration error:', error);
return { success: false, error: error.message };
}
};
const logout = async () => {
try {
await sessionService.logoutUser();
setIsAuthenticated(false);
setUser(null);
setSessionData({
currentTimeEntry: null,
activeTimer: null,
pausedTimer: null,
sessions: []
});
} catch (error) {
console.error('Logout error:', error);
}
};
const startTimer = async () => {
const startTime = new Date();
try {
const result = await sessionService.createSession({
userId: user.id,
startTime: startTime.toISOString(),
endTime: null,
pauses: []
});
const newTimeEntry = {
id: result?.data?.id ?? Date.now(),
startTime: startTime,
endTime: null,
duration: 0,
userId: user.id,
pauses: []
};
setSessionData((prev) => ({
...prev,
currentTimeEntry: newTimeEntry,
activeTimer: setInterval(() => {
// Timer will update the display but we'll calculate duration on stop
}, 1000)
}));
await refreshSessions();
} catch (error) {
console.error('Failed to start session:', error);
}
};
const stopTimer = async () => {
if (sessionData.activeTimer) {
clearInterval(sessionData.activeTimer);
}
// If there's an active pause, end it
if (sessionData.pausedTimer) {
clearInterval(sessionData.pausedTimer);
}
const endTime = new Date();
const updatedTimeEntry = {
...sessionData.currentTimeEntry,
endTime: endTime,
duration: endTime - sessionData.currentTimeEntry.startTime
};
// Save session to database
try {
if (isPersistedSession(updatedTimeEntry.id)) {
await sessionService.updateSession(updatedTimeEntry.id, {
start_time: new Date(updatedTimeEntry.startTime).toISOString(),
end_time: new Date(updatedTimeEntry.endTime).toISOString(),
pauses: toIsoPauses(updatedTimeEntry.pauses)
});
} else {
await sessionService.saveSession(updatedTimeEntry);
}
// Refresh sessions to include the new one
await refreshSessions();
// Show time spent notification
const workTime = calculateWorkTime(updatedTimeEntry);
alert(`Session completed! Work time: ${formatDuration(workTime)}`);
} catch (error) {
console.error('Failed to save session:', error);
}
setSessionData(prev => ({
...prev,
currentTimeEntry: null,
activeTimer: null,
pausedTimer: null,
sessions: [...prev.sessions, updatedTimeEntry]
}));
};
const pauseTimer = () => {
const pauseStart = new Date();
// Pause the main timer
if (sessionData.activeTimer) {
clearInterval(sessionData.activeTimer);
}
// Start the pause timer
const pausedTimer = setInterval(() => {
// Pause timer updates the display
}, 1000);
setSessionData((prev) => {
const updatedCurrent = {
...prev.currentTimeEntry,
pauses: [...prev.currentTimeEntry.pauses, { start: pauseStart, end: null }]
};
if (isPersistedSession(updatedCurrent.id)) {
sessionService.updateSession(updatedCurrent.id, {
start_time: new Date(updatedCurrent.startTime).toISOString(),
end_time: null,
pauses: toIsoPauses(updatedCurrent.pauses)
}).catch((error) => console.error('Failed to save pause state:', error));
}
return {
...prev,
activeTimer: null,
pausedTimer: pausedTimer,
currentTimeEntry: updatedCurrent
};
});
};
const resumeTimer = () => {
const pauseEnd = new Date();
// End the pause timer
if (sessionData.pausedTimer) {
clearInterval(sessionData.pausedTimer);
}
// Update the last pause with end time
const updatedPauses = [...sessionData.currentTimeEntry.pauses];
const lastPauseIndex = updatedPauses.length - 1;
if (lastPauseIndex >= 0) {
updatedPauses[lastPauseIndex] = {
...updatedPauses[lastPauseIndex],
end: pauseEnd
};
}
// Restart the main timer
const activeTimer = setInterval(() => {
// Main timer updates the display
}, 1000);
setSessionData((prev) => {
const updatedCurrent = {
...prev.currentTimeEntry,
pauses: updatedPauses
};
if (isPersistedSession(updatedCurrent.id)) {
sessionService.updateSession(updatedCurrent.id, {
start_time: new Date(updatedCurrent.startTime).toISOString(),
end_time: null,
pauses: toIsoPauses(updatedCurrent.pauses)
}).catch((error) => console.error('Failed to save resume state:', error));
}
return {
...prev,
activeTimer: activeTimer,
pausedTimer: null,
currentTimeEntry: updatedCurrent
};
});
};
const calculateWorkTime = (session) => {
// Calculate total pause time
let totalPauseTime = 0;
if (session.pauses) {
session.pauses.forEach(pause => {
if (pause.end) {
totalPauseTime += new Date(pause.end) - new Date(pause.start);
} else if (session.endTime) {
// If session has ended but pause hasn't, count pause until session end
totalPauseTime += new Date(session.endTime) - new Date(pause.start);
} else {
// If session is still ongoing and pause hasn't ended, count pause until now
totalPauseTime += new Date() - new Date(pause.start);
}
});
}
// Work time is total duration minus pause time
const totalDuration = session.endTime
? new Date(session.endTime) - new Date(session.startTime)
: new Date() - new Date(session.startTime);
return totalDuration - totalPauseTime;
};
const formatDuration = (milliseconds) => {
if (!milliseconds) return '00:00:00';
const totalSeconds = Math.floor(milliseconds / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
const value = {
isAuthenticated,
user,
sessionData,
loading,
login,
register,
logout,
startTimer,
stopTimer,
pauseTimer,
resumeTimer,
refreshSessions,
makeSessionActive,
updateCurrentSessionEntry,
refreshUser,
calculateWorkTime,
formatDuration
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
return useContext(AuthContext);
};

View File

@@ -5,6 +5,7 @@ body {
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
}
code {

273
src/pages/Calendar.js Normal file
View File

@@ -0,0 +1,273 @@
import React, { useMemo, useRef, useState } from 'react';
import { useAuth } from '../context/AuthContext';
const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
const getMonday = (date) => {
const copy = new Date(date);
const day = copy.getDay();
const diff = day === 0 ? -6 : 1 - day;
copy.setDate(copy.getDate() + diff);
copy.setHours(0, 0, 0, 0);
return copy;
};
const formatTime = (date) =>
new Date(date).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
const formatDate = (date) =>
new Date(date).toLocaleDateString('en-GB', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
const Calendar = () => {
const { isAuthenticated, sessionData, formatDuration } = useAuth();
const [weekStart, setWeekStart] = useState(() => getMonday(new Date()));
const datePickerRef = useRef(null);
const weekDays = useMemo(
() =>
DAY_NAMES.map((_, index) => {
const day = new Date(weekStart);
day.setDate(weekStart.getDate() + index);
return day;
}),
[weekStart]
);
const sessionsByDay = useMemo(() => {
const map = new Map();
weekDays.forEach((day) => {
map.set(day.toDateString(), []);
});
const mergedSessions = [...sessionData.sessions];
if (sessionData.currentTimeEntry) {
const current = sessionData.currentTimeEntry;
const existingIndex = mergedSessions.findIndex(
(session) => String(session.id) === String(current.id)
);
if (existingIndex >= 0) {
mergedSessions[existingIndex] = {
...mergedSessions[existingIndex],
...current
};
} else {
mergedSessions.push(current);
}
}
mergedSessions.forEach((session) => {
if (!session?.startTime) {
return;
}
const key = new Date(session.startTime).toDateString();
if (map.has(key)) {
map.get(key).push(session);
}
});
map.forEach((items, key) => {
map.set(
key,
[...items].sort((a, b) => new Date(a.startTime) - new Date(b.startTime))
);
});
return map;
}, [sessionData.sessions, sessionData.currentTimeEntry, weekDays]);
const weekRange = `${formatDate(weekDays[0])} - ${formatDate(weekDays[4])}`;
const selectedDateValue = weekStart.toISOString().split('T')[0];
const getPauseDuration = (session) => {
if (!session?.pauses?.length) {
return 0;
}
const sessionStartTs = new Date(session.startTime).getTime();
const sessionEndTs = session.endTime ? new Date(session.endTime).getTime() : Date.now();
const parsePausePoint = (value, fallbackDay) => {
if (!value) {
return { date: null, isTimeOnly: false };
}
if (value instanceof Date) {
return { date: value, isTimeOnly: false };
}
if (typeof value === 'string') {
// Handles "HH:mm" and "HH:mm:ss" values saved by edit forms.
if (/^\d{2}:\d{2}(:\d{2})?$/.test(value)) {
const [hours, minutes, seconds = '0'] = value.split(':').map(Number);
const parsed = new Date(fallbackDay);
parsed.setHours(hours, minutes, seconds, 0);
return { date: parsed, isTimeOnly: true };
}
const parsed = new Date(value);
if (!Number.isNaN(parsed.getTime())) {
return { date: parsed, isTimeOnly: false };
}
}
return { date: null, isTimeOnly: false };
};
return session.pauses.reduce((total, pause) => {
const sessionBaseDay = new Date(session.startTime);
const startParsed = parsePausePoint(pause?.start, sessionBaseDay);
if (!startParsed.date) {
return total;
}
const endParsed = pause?.end
? parsePausePoint(pause.end, sessionBaseDay)
: { date: new Date(sessionEndTs), isTimeOnly: false };
if (!endParsed.date) {
return total;
}
let startTs = startParsed.date.getTime();
let endTs = endParsed.date.getTime();
// If both values are time-only and end is before start, assume next day.
if (startParsed.isTimeOnly && endParsed.isTimeOnly && endTs < startTs) {
endTs += 24 * 60 * 60 * 1000;
}
// Keep break duration bounded by the session range.
startTs = Math.max(startTs, sessionStartTs);
endTs = Math.min(endTs, sessionEndTs);
const delta = endTs - startTs;
return total + (Number.isFinite(delta) ? Math.max(delta, 0) : 0);
}, 0);
};
if (!isAuthenticated) {
return <div>Loading...</div>;
}
return (
<div className="calendar-container">
<header className="calendar-header">
<div>
<p className="eyebrow">Planner</p>
<h1>Working Week Calendar</h1>
<p className="page-subtitle">{weekRange}</p>
</div>
<div className="calendar-controls">
<button
type="button"
className="nav-button"
onClick={() =>
setWeekStart((prev) => {
const next = new Date(prev);
next.setDate(prev.getDate() - 7);
return next;
})
}
>
Previous Week
</button>
<button type="button" className="nav-button" onClick={() => setWeekStart(getMonday(new Date()))}>
Current Week
</button>
<button
type="button"
className="nav-button"
onClick={() => {
const picker = datePickerRef.current;
if (!picker) return;
if (picker.showPicker) {
picker.showPicker();
} else {
picker.click();
}
}}
>
Pick Date
</button>
<input
ref={datePickerRef}
type="date"
lang="en-GB"
className="calendar-date-input"
value={selectedDateValue}
onChange={(e) => {
if (!e.target.value) return;
setWeekStart(getMonday(new Date(`${e.target.value}T00:00:00`)));
}}
aria-label="Select a date to jump to its week"
/>
<button
type="button"
className="nav-button"
onClick={() =>
setWeekStart((prev) => {
const next = new Date(prev);
next.setDate(prev.getDate() + 7);
return next;
})
}
>
Next Week
</button>
</div>
</header>
<section className="calendar-grid">
{weekDays.map((day, index) => {
const sessions = sessionsByDay.get(day.toDateString()) || [];
return (
<article key={day.toISOString()} className="calendar-day-card">
<div className="calendar-day-head">
<span className="calendar-day-name">{DAY_NAMES[index]}</span>
<span className="calendar-day-date">{formatDate(day)}</span>
</div>
<div className="calendar-day-body">
{sessions.length === 0 ? (
<p className="calendar-empty">No sessions</p>
) : (
sessions.map((session) => {
const totalDuration = session.endTime
? session.duration
: new Date() - new Date(session.startTime);
const breakDuration = getPauseDuration(session);
const workDuration = Math.max(totalDuration - breakDuration, 0);
return (
<div key={session.id} className="calendar-session-block">
<div className="calendar-segment calendar-segment-time">
<span className="calendar-segment-label">Time</span>
<span className="calendar-segment-value">{formatDuration(workDuration)}</span>
</div>
<div className="calendar-segment calendar-segment-break">
<span className="calendar-segment-label">Break Time</span>
<span className="calendar-segment-value">{formatDuration(breakDuration)}</span>
</div>
<div className="calendar-segment calendar-segment-total">
<span className="calendar-segment-label">
{formatTime(session.startTime)} - {session.endTime ? formatTime(session.endTime) : 'Active'}
</span>
<span className="calendar-segment-value">{formatDuration(totalDuration)}</span>
</div>
</div>
);
})
)}
</div>
</article>
);
})}
</section>
</div>
);
};
export default Calendar;

147
src/pages/Dashboard.js Normal file
View File

@@ -0,0 +1,147 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
const Dashboard = () => {
const { isAuthenticated, sessionData, startTimer, stopTimer, pauseTimer, resumeTimer } = useAuth();
const [elapsedTime, setElapsedTime] = useState(0);
const [pauseTime, setPauseTime] = useState(0);
const navigate = useNavigate();
// Update timer display when timer is active
useEffect(() => {
let interval = null;
if (sessionData.currentTimeEntry && sessionData.activeTimer) {
// Main timer is running
interval = setInterval(() => {
const now = new Date();
const totalElapsed = now - sessionData.currentTimeEntry.startTime;
// Calculate pause time
let totalPauseTime = 0;
sessionData.currentTimeEntry.pauses.forEach(pause => {
if (pause.end) {
totalPauseTime += pause.end - pause.start;
} else {
// Current pause is still ongoing
totalPauseTime += now - pause.start;
}
});
setPauseTime(totalPauseTime);
setElapsedTime(totalElapsed - totalPauseTime);
}, 1000);
} else if (sessionData.currentTimeEntry && sessionData.pausedTimer) {
// Timer is paused
interval = setInterval(() => {
const now = new Date();
const pauseStart = sessionData.currentTimeEntry.pauses[sessionData.currentTimeEntry.pauses.length - 1].start;
const currentPauseTime = now - pauseStart;
setPauseTime(prev => {
// Calculate previous pause time
let previousPauseTime = 0;
sessionData.currentTimeEntry.pauses.slice(0, -1).forEach(pause => {
if (pause.end) {
previousPauseTime += pause.end - pause.start;
}
});
return previousPauseTime + currentPauseTime;
});
}, 1000);
} else {
// No timer running
setElapsedTime(0);
setPauseTime(0);
}
return () => {
if (interval) clearInterval(interval);
};
}, [sessionData]);
const handleStartStop = () => {
if (sessionData.currentTimeEntry) {
stopTimer();
} else {
startTimer();
}
};
const handlePauseResume = () => {
if (sessionData.pausedTimer) {
resumeTimer();
} else {
pauseTimer();
}
};
const formatTime = (milliseconds) => {
const totalSeconds = Math.floor(milliseconds / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
if (!isAuthenticated) {
return <div>Loading...</div>;
}
const isTimerRunning = sessionData.currentTimeEntry !== null;
const isPaused = sessionData.pausedTimer !== null;
return (
<div className="dashboard-container">
<header className="dashboard-header">
<div>
<p className="eyebrow">Workspace</p>
<h1>Time Tracker Dashboard</h1>
</div>
</header>
<div className="timer-section">
<div className="timer-display">
<h2>Current Session</h2>
<span className={`status-badge ${isPaused ? 'paused' : isTimerRunning ? 'active' : 'idle'}`}>
{isPaused ? 'Paused' : isTimerRunning ? 'Active' : 'Idle'}
</span>
<div className="time">{formatTime(elapsedTime)}</div>
{isTimerRunning && (
<div className="pause-info">
<div className="pause-time">Break time spent: {formatTime(pauseTime)}</div>
</div>
)}
</div>
<div className="timer-controls">
<button
onClick={handleStartStop}
className={`timer-button ${isTimerRunning ? 'stop' : 'start'}`}
>
{isTimerRunning ? 'Stop Schedule' : 'Start Schedule'}
</button>
{isTimerRunning && (
<button
onClick={handlePauseResume}
className={`timer-button pause ${isPaused ? 'resume' : 'pause'}`}
>
{isPaused ? 'Resume Work' : 'Take Break'}
</button>
)}
</div>
</div>
<div className="navigation-links">
<button onClick={() => navigate('/sessions')} className="nav-button">
View All Sessions
</button>
</div>
</div>
);
};
export default Dashboard;

100
src/pages/Login.js Normal file
View File

@@ -0,0 +1,100 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
const Login = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isRegistering, setIsRegistering] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login, register } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
if (isRegistering) {
const result = await register(email, password);
if (result.success) {
navigate('/dashboard');
} else {
setError(result.error || 'Registration failed');
}
} else {
const result = await login(email, password);
if (result.success) {
navigate('/dashboard');
} else {
setError(result.error || 'Login failed');
}
}
} catch (err) {
setError(isRegistering ? 'Registration failed' : 'Login failed');
} finally {
setLoading(false);
}
};
return (
<div className="login-container">
<div className="login-form">
<div className="login-brand">
<img src="/logo_ficosa.png" alt="FICOSA logo" className="login-brand-mark" />
<span className="login-brand-title">Time Tracker</span>
</div>
<p className="eyebrow">Welcome back</p>
<h2>{isRegistering ? 'Register' : 'Login'} to Time Tracker</h2>
<p className="page-subtitle">
{isRegistering ? 'Create your account and start tracking time.' : 'Sign in to continue your work session.'}
</p>
{error && <div className="error-message">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="email">Email:</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="form-group">
<label htmlFor="password">Password:</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<button
type="submit"
className="login-button"
disabled={loading}
>
{loading ? 'Processing...' : (isRegistering ? 'Register' : 'Login')}
</button>
</form>
<div className="auth-toggle">
<button
onClick={() => setIsRegistering(!isRegistering)}
className="toggle-button"
>
{isRegistering
? 'Already have an account? Login'
: "Don't have an account? Register"}
</button>
</div>
</div>
</div>
);
};
export default Login;

351
src/pages/Profile.js Normal file
View File

@@ -0,0 +1,351 @@
import React, { useEffect, useRef, useState } from 'react';
import { useAuth } from '../context/AuthContext';
import { supabase } from '../services/supabaseClient';
const Profile = () => {
const { isAuthenticated, user, refreshUser } = useAuth();
const [form, setForm] = useState({
fullName: '',
phone: '',
company: ''
});
const [avatarUrl, setAvatarUrl] = useState('');
const [avatarFile, setAvatarFile] = useState(null);
const [avatarPreview, setAvatarPreview] = useState('');
const fileInputRef = useRef(null);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const UPLOAD_TIMEOUT_MS = 20000;
const REQUEST_TIMEOUT_MS = 15000;
const MAX_IMAGE_SIDE = 720;
const TARGET_MAX_BYTES = 220 * 1024;
useEffect(() => {
if (!user) return;
setForm({
fullName: user.user_metadata?.full_name || '',
phone: user.user_metadata?.phone || '',
company: user.user_metadata?.company || ''
});
setAvatarUrl(user.user_metadata?.avatar_url || '');
}, [user]);
useEffect(() => {
if (!avatarFile) {
setAvatarPreview('');
return undefined;
}
const objectUrl = URL.createObjectURL(avatarFile);
setAvatarPreview(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [avatarFile]);
const handleChange = (field, value) => {
setForm((prev) => ({ ...prev, [field]: value }));
};
const withTimeout = (promise, ms, timeoutMessage) =>
Promise.race([
promise,
new Promise((_, reject) => {
setTimeout(() => reject(new Error(timeoutMessage)), ms);
})
]);
const blobToDataUrl = (blob) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error('Could not read image file.'));
reader.readAsDataURL(blob);
});
const loadImage = (src) =>
new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Could not process selected image.'));
img.src = src;
});
const canvasToBlob = (canvas, type, quality) =>
new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('Could not optimize image.'));
return;
}
resolve(blob);
},
type,
quality
);
});
const optimizeAvatarImage = async (file) => {
if (!file?.type?.startsWith('image/')) {
return file;
}
const dataUrl = await blobToDataUrl(file);
const img = await loadImage(dataUrl);
const maxSide = Math.max(img.width, img.height);
const scale = maxSide > MAX_IMAGE_SIDE ? MAX_IMAGE_SIDE / maxSide : 1;
const targetWidth = Math.max(1, Math.round(img.width * scale));
const targetHeight = Math.max(1, Math.round(img.height * scale));
const canvas = document.createElement('canvas');
canvas.width = targetWidth;
canvas.height = targetHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
return file;
}
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
const preferPng = file.type === 'image/png' && file.size < TARGET_MAX_BYTES;
const outputType = preferPng ? 'image/png' : 'image/jpeg';
if (outputType === 'image/png') {
const pngBlob = await canvasToBlob(canvas, outputType);
return new File([pngBlob], 'avatar.png', { type: outputType });
}
let quality = 0.84;
let bestBlob = await canvasToBlob(canvas, outputType, quality);
while (bestBlob.size > TARGET_MAX_BYTES && quality > 0.5) {
quality -= 0.08;
bestBlob = await canvasToBlob(canvas, outputType, quality);
}
return new File([bestBlob], 'avatar.jpg', { type: outputType });
};
const isBucketMissingError = (err) => {
const msg = `${err?.message || ''}`.toLowerCase();
return msg.includes('bucket not found') || msg.includes('not found');
};
const isPolicyError = (err) => {
const msg = `${err?.message || ''}`.toLowerCase();
return msg.includes('permission') || msg.includes('policy') || msg.includes('unauthorized') || msg.includes('forbidden');
};
const isTimeoutError = (err) => `${err?.message || ''}`.toLowerCase().includes('timed out');
const verifyUploadPrerequisites = async (userId) => {
try {
const { error: listError } = await withTimeout(
supabase.storage.from('profile_pics').list(userId, { limit: 1 }),
8000,
'Storage pre-check timed out.'
);
if (listError) {
if (isBucketMissingError(listError)) {
throw new Error('Storage bucket "profile_pics" was not found.');
}
if (isPolicyError(listError)) {
throw new Error('Storage policy denied access. Check bucket RLS/policies for authenticated users.');
}
if (!isTimeoutError(listError)) {
throw listError;
}
}
} catch (err) {
if (isTimeoutError(err)) {
// Do not block upload for pre-check timeout; actual upload may still succeed.
return;
}
throw err;
}
};
const uploadAvatar = async (filePath, fileToUpload) =>
withTimeout(
supabase.storage
.from('profile_pics')
.upload(filePath, fileToUpload, { upsert: true, contentType: fileToUpload.type || 'image/jpeg' }),
UPLOAD_TIMEOUT_MS,
'Profile picture upload timed out. Check bucket/policies/network and try again.'
);
const handleSave = async (e) => {
e.preventDefault();
if (saving) return;
setSaving(true);
setError('');
setMessage('');
try {
if (!user?.id) {
throw new Error('User session not available. Please log in again.');
}
let nextAvatarUrl = avatarUrl;
if (avatarFile) {
await verifyUploadPrerequisites(user.id);
const optimizedAvatar = await optimizeAvatarImage(avatarFile);
const extension = optimizedAvatar.name.split('.').pop()?.toLowerCase() || 'jpg';
const filePath = `${user.id}/avatar.${extension}`;
const { error: uploadError } = await uploadAvatar(filePath, optimizedAvatar);
if (uploadError) {
if (isBucketMissingError(uploadError)) {
throw new Error(
'Storage bucket "profile_pics" was not found. Create it in Supabase Storage and try again.'
);
}
if (isPolicyError(uploadError)) {
throw new Error('Upload denied by storage policy. Allow authenticated users to write to profile_pics/{uid}/...');
}
throw uploadError;
}
const { data: publicData } = supabase.storage
.from('profile_pics')
.getPublicUrl(filePath);
if (!publicData?.publicUrl) {
throw new Error('Could not generate public URL for profile picture.');
}
nextAvatarUrl = `${publicData.publicUrl}?v=${Date.now()}`;
}
const metadataToSave = {
...user?.user_metadata,
full_name: form.fullName.trim(),
phone: form.phone.trim(),
company: form.company.trim(),
avatar_url: nextAvatarUrl
};
const { data: updateData, error: updateError } = await withTimeout(
supabase.auth.updateUser({
data: metadataToSave
}),
REQUEST_TIMEOUT_MS,
'Profile update timed out. Please try again.'
);
if (updateError) throw updateError;
const updatedMetadata = updateData?.user?.user_metadata || metadataToSave;
setForm({
fullName: updatedMetadata?.full_name || '',
phone: updatedMetadata?.phone || '',
company: updatedMetadata?.company || ''
});
setAvatarUrl(nextAvatarUrl);
setAvatarFile(null);
// Refresh app-wide user state, but don't fail profile save if this call is slow.
withTimeout(refreshUser(), REQUEST_TIMEOUT_MS, 'Background user refresh timed out.').catch(() => {});
setMessage('Profile updated successfully.');
} catch (err) {
console.error('Profile update error:', err);
setError(err?.message || 'Could not update profile.');
} finally {
setSaving(false);
}
};
if (!isAuthenticated) {
return <div>Loading...</div>;
}
return (
<div className="profile-container">
<header className="profile-header">
<div>
<p className="eyebrow">Account</p>
<h1>My Profile</h1>
<p className="page-subtitle">Manage your personal information.</p>
</div>
</header>
<form className="profile-form-card" onSubmit={handleSave}>
{error && <div className="error-message">{error}</div>}
{message && <div className="success-message">{message}</div>}
<div className="profile-avatar-uploader">
<button
type="button"
className="profile-avatar-trigger"
onClick={() => fileInputRef.current?.click()}
aria-label="Upload profile picture"
>
{avatarPreview || avatarUrl ? (
<img src={avatarPreview || avatarUrl} alt="Profile avatar" className="profile-avatar-preview" />
) : (
<div className="profile-avatar-fallback">
{(form.fullName || user?.email || 'U').charAt(0).toUpperCase()}
</div>
)}
<span className="profile-avatar-overlay">Change photo</span>
</button>
<div className="profile-avatar-meta">
<div className="profile-avatar-title">Profile Picture</div>
<div className="profile-avatar-subtitle">
Upload a square image (PNG/JPG). It will be shown in the navigation bar.
</div>
{avatarFile && <div className="profile-avatar-subtitle">Selected: {avatarFile.name}</div>}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="profile-file-input-hidden"
onChange={(e) => setAvatarFile(e.target.files?.[0] || null)}
/>
</div>
</div>
<div className="form-group">
<label>Email (read-only):</label>
<input type="text" value={user?.email || ''} readOnly className="readonly-input" />
</div>
<div className="form-group">
<label>Full Name:</label>
<input
type="text"
value={form.fullName}
onChange={(e) => handleChange('fullName', e.target.value)}
placeholder="Your full name"
/>
</div>
<div className="form-group">
<label>Phone:</label>
<input
type="text"
value={form.phone}
onChange={(e) => handleChange('phone', e.target.value)}
placeholder="Phone number"
/>
</div>
<div className="form-group">
<label>Company:</label>
<input
type="text"
value={form.company}
onChange={(e) => handleChange('company', e.target.value)}
placeholder="Company"
/>
</div>
<div className="form-actions">
<button type="submit" className="save-button" disabled={saving}>
{saving ? 'Saving...' : 'Save Profile'}
</button>
</div>
</form>
</div>
);
};
export default Profile;

716
src/pages/Sessions.js Normal file
View File

@@ -0,0 +1,716 @@
import React, { useRef, useState } from 'react';
import { useAuth } from '../context/AuthContext';
import sessionService from '../services/sessionService';
const Sessions = () => {
const { isAuthenticated, sessionData, refreshSessions, user, makeSessionActive, updateCurrentSessionEntry } = useAuth();
const [editingSession, setEditingSession] = useState(null);
const [creatingNewSession, setCreatingNewSession] = useState(false);
const [formError, setFormError] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [editForm, setEditForm] = useState({
date: '',
dateDisplay: '',
startTime: '',
endTime: '',
pauses: [],
makeActive: false // New field for making session active
});
const nativeDatePickerRef = useRef(null);
const parseDateValue = (value, fallbackDate) => {
if (!value) return null;
if (value instanceof Date) return value;
if (typeof value === 'string') {
if (/^\d{2}:\d{2}(:\d{2})?$/.test(value) && fallbackDate) {
const [h, m, s = '0'] = value.split(':').map(Number);
const d = new Date(fallbackDate);
d.setHours(h, m, s, 0);
return d;
}
const d = new Date(value);
if (!Number.isNaN(d.getTime())) return d;
}
return null;
};
const formatDateTime = (value, fallbackDate = null) => {
const date = parseDateValue(value, fallbackDate);
if (!date) return 'N/A';
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${day}/${month}/${year} ${hours}:${minutes}`;
};
const formatDateForInput = (date) => {
if (!date) return '';
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const formatDateForDisplay = (dateValue) => {
if (!dateValue) return '';
const [year, month, day] = dateValue.split('-');
if (!year || !month || !day) return '';
return `${day}/${month}/${year}`;
};
const parseDateDisplay = (value) => {
if (!value) return '';
const match = value.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
if (!match) return '';
const [, day, month, year] = match;
const paddedDay = String(day).padStart(2, '0');
const paddedMonth = String(month).padStart(2, '0');
const date = new Date(`${year}-${paddedMonth}-${paddedDay}T00:00:00`);
if (Number.isNaN(date.getTime())) return '';
return `${year}-${paddedMonth}-${paddedDay}`;
};
const normalizeTimeInput = (value) => {
if (!value) return '';
const trimmed = value.trim().toUpperCase().replace(/\s+/g, '');
const twentyFourMatch = trimmed.match(/^([01]?\d|2[0-3]):([0-5]\d)(:[0-5]\d)?$/);
if (twentyFourMatch) {
return `${String(Number(twentyFourMatch[1])).padStart(2, '0')}:${twentyFourMatch[2]}`;
}
const twelveHourMatch = trimmed.match(/^(0?[1-9]|1[0-2]):([0-5]\d)(:[0-5]\d)?(AM|PM)$/);
if (!twelveHourMatch) return '';
let hour = Number(twelveHourMatch[1]);
const minute = twelveHourMatch[2];
const suffix = twelveHourMatch[4];
if (suffix === 'AM' && hour === 12) hour = 0;
if (suffix === 'PM' && hour !== 12) hour += 12;
return `${String(hour).padStart(2, '0')}:${minute}`;
};
const formatDateOnly = (date) => {
if (!date) return '';
return new Date(date).toLocaleDateString('en-GB', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
};
const formatTimeForInput = (date) => {
if (!date) return '';
const d = new Date(date);
return d.toTimeString().slice(0, 5); // HH:mm
};
const formatDuration = (milliseconds) => {
if (!milliseconds) return '00:00:00';
const totalSeconds = Math.floor(milliseconds / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
const calculateWorkTime = (session) => {
// Calculate total pause time
let totalPauseTime = 0;
if (session.pauses) {
session.pauses.forEach(pause => {
if (pause.end) {
totalPauseTime += new Date(pause.end) - new Date(pause.start);
} else if (session.endTime) {
// If session has ended but pause hasn't, count pause until session end
totalPauseTime += new Date(session.endTime) - new Date(pause.start);
}
});
}
// Work time is total duration minus pause time
return session.duration - totalPauseTime;
};
const startEditing = (session) => {
setFormError('');
setEditingSession(session);
setEditForm({
id: session.id,
date: formatDateForInput(session.startTime),
dateDisplay: formatDateForDisplay(formatDateForInput(session.startTime)),
startTime: formatTimeForInput(session.startTime),
endTime: session.endTime ? formatTimeForInput(session.endTime) : '',
pauses: (session.pauses || []).map((pause) => ({
start: formatTimeForInput(pause.start),
end: pause.end ? formatTimeForInput(pause.end) : ''
})),
makeActive: false
});
};
const startEditingCurrent = (session) => {
setFormError('');
setEditingSession('current');
setEditForm({
id: 'current',
date: formatDateForInput(session.startTime),
dateDisplay: formatDateForDisplay(formatDateForInput(session.startTime)),
startTime: formatTimeForInput(session.startTime),
endTime: session.endTime ? formatTimeForInput(session.endTime) : '',
pauses: (session.pauses || []).map((pause) => ({
start: formatTimeForInput(pause.start),
end: pause.end ? formatTimeForInput(pause.end) : ''
})),
makeActive: false
});
};
const startCreating = () => {
setFormError('');
setCreatingNewSession(true);
setEditingSession('new');
// Set default to today with default times
const today = formatDateForInput(new Date());
setEditForm({
date: today,
dateDisplay: formatDateForDisplay(today),
startTime: '09:00',
endTime: '17:00',
pauses: [],
makeActive: false
});
};
const cancelEditing = () => {
setFormError('');
setEditingSession(null);
setCreatingNewSession(false);
setEditForm({
date: '',
dateDisplay: '',
startTime: '',
endTime: '',
pauses: [],
makeActive: false
});
};
const combineDateAndTime = (date, time) => {
if (!date || !time) return null;
const normalizedTime = normalizeTimeInput(time);
if (!normalizedTime) return null;
return new Date(`${date}T${normalizedTime}`);
};
const withTimeout = async (promise, timeoutMs, timeoutMessage) => {
let timerId;
try {
const timeoutPromise = new Promise((_, reject) => {
timerId = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
});
return await Promise.race([promise, timeoutPromise]);
} finally {
clearTimeout(timerId);
}
};
const getResolvedFormDate = () => {
if (editForm.date) return editForm.date;
return parseDateDisplay(editForm.dateDisplay);
};
const saveChanges = async () => {
if (isSaving) {
return;
}
setIsSaving(true);
try {
setFormError('');
if (!user?.id) {
setFormError('Your user session is not ready yet. Please reload and try again.');
return;
}
const resolvedDate = getResolvedFormDate();
if (!resolvedDate) {
setFormError('Please provide a valid date (dd/mm/yyyy).');
return;
}
const toActiveSessionShape = (record) => ({
id: record.id,
startTime: new Date(record.start_time ?? record.startTime),
endTime: record.end_time ? new Date(record.end_time) : null,
duration: record.duration_ms ?? record.duration ?? 0,
userId: record.user_id ?? record.userId ?? user?.id,
pauses: record.pauses || []
});
const normalizePauses = () =>
(editForm.pauses || [])
.map((pause) => {
if (!pause?.start) {
return null;
}
const start = combineDateAndTime(resolvedDate, pause.start);
if (!start) {
return null;
}
const end = pause.end ? combineDateAndTime(resolvedDate, pause.end) : null;
return {
start: start.toISOString(),
end: end ? end.toISOString() : null
};
})
.filter(Boolean);
const normalizePausesForCurrent = () =>
(editForm.pauses || [])
.map((pause) => {
if (!pause?.start) {
return null;
}
const start = combineDateAndTime(resolvedDate, pause.start);
if (!start) {
return null;
}
const end = pause.end ? combineDateAndTime(resolvedDate, pause.end) : null;
return {
start,
end
};
})
.filter(Boolean);
if (creatingNewSession) {
// Create new session with date + time combination
const startDateTime = combineDateAndTime(resolvedDate, editForm.startTime);
const endDateTime = editForm.makeActive ? null : combineDateAndTime(resolvedDate, editForm.endTime);
if (!startDateTime || (!editForm.makeActive && !endDateTime)) {
setFormError('Please provide valid start/end times in HH:mm format.');
return;
}
if (!editForm.makeActive && endDateTime <= startDateTime) {
setFormError('End time must be later than start time.');
return;
}
const newSessionData = {
userId: user.id,
startTime: startDateTime.toISOString(),
endTime: endDateTime ? endDateTime.toISOString() : null,
pauses: normalizePauses()
};
const result = await withTimeout(
sessionService.createSession(newSessionData),
15000,
'Request timed out while creating session. Check DB/network and try again.'
);
if (result.success) {
await withTimeout(
refreshSessions(),
15000,
'Request timed out while refreshing sessions after create.'
);
if (editForm.makeActive && result.data) {
makeSessionActive(toActiveSessionShape(result.data));
}
}
} else if (editingSession === 'current') {
const startDateTime = combineDateAndTime(resolvedDate, editForm.startTime);
if (!startDateTime) {
setFormError('Please provide a valid start time in HH:mm format.');
return;
}
const normalizedPausesForDb = normalizePauses();
updateCurrentSessionEntry({
startTime: startDateTime,
endTime: null,
pauses: normalizePausesForCurrent()
});
if (sessionData.currentTimeEntry?.id && sessionData.currentTimeEntry.id !== 'current') {
await withTimeout(
sessionService.updateSession(sessionData.currentTimeEntry.id, {
start_time: startDateTime.toISOString(),
end_time: null,
pauses: normalizedPausesForDb
}),
15000,
'Request timed out while updating current session.'
);
await withTimeout(
refreshSessions(),
15000,
'Request timed out while refreshing sessions after current-session update.'
);
}
cancelEditing();
return;
} else if (editingSession && editingSession.id) {
// Update existing session
const startDateTime = combineDateAndTime(resolvedDate, editForm.startTime);
const endDateTime = editForm.makeActive ? null : combineDateAndTime(resolvedDate, editForm.endTime);
if (startDateTime && endDateTime && endDateTime <= startDateTime) {
setFormError('End time must be later than start time.');
return;
}
const updateData = {
start_time: startDateTime ? startDateTime.toISOString() : undefined,
end_time: endDateTime ? endDateTime.toISOString() : null,
pauses: normalizePauses()
};
const result = await withTimeout(
sessionService.updateSession(editForm.id, updateData),
15000,
'Request timed out while updating session.'
);
if (result.success) {
await withTimeout(
refreshSessions(),
15000,
'Request timed out while refreshing sessions after update.'
);
if (editForm.makeActive && result.data) {
makeSessionActive(toActiveSessionShape(result.data));
}
}
}
// Cancel editing after save
cancelEditing();
} catch (error) {
console.error('Error saving session:', error);
setFormError(error?.message ? `Could not save session: ${error.message}` : 'Could not save session. Please verify date/time values and try again.');
} finally {
setIsSaving(false);
}
};
const deleteSession = async () => {
if (!editingSession || editingSession === 'current' || creatingNewSession || !editForm.id) {
return;
}
const shouldDelete = window.confirm('Are you sure you want to delete this session?');
if (!shouldDelete) {
return;
}
try {
await sessionService.deleteSession(editForm.id);
await refreshSessions();
cancelEditing();
} catch (error) {
console.error('Error deleting session:', error);
}
};
const addPause = () => {
setEditForm(prev => ({
...prev,
pauses: [...prev.pauses, { start: '', end: '' }]
}));
};
const updatePause = (index, field, value) => {
setEditForm(prev => {
const newPauses = [...prev.pauses];
newPauses[index] = { ...newPauses[index], [field]: value };
return { ...prev, pauses: newPauses };
});
};
const removePause = (index) => {
setEditForm(prev => ({
...prev,
pauses: prev.pauses.filter((_, i) => i !== index)
}));
};
// Check if end time is in the future
const isEndTimeInFuture = () => {
if (!editForm.date || !editForm.endTime) return false;
const endDateTime = combineDateAndTime(editForm.date, editForm.endTime);
return endDateTime && endDateTime > new Date();
};
if (!isAuthenticated) {
return <div>Loading...</div>;
}
return (
<div className="sessions-container">
<header className="sessions-header">
<div>
<p className="eyebrow">History</p>
<h1>Session History</h1>
</div>
</header>
{(editingSession || creatingNewSession) && (
<div className="edit-session-form">
<h2>{creatingNewSession ? 'Create New Session' : editingSession === 'current' ? 'Edit Current Session' : 'Edit Session'}</h2>
{formError && <div className="error-message">{formError}</div>}
<div className="form-group">
<label>Date:</label>
<div className="date-input-row">
<input
type="text"
value={editForm.dateDisplay}
placeholder="dd/mm/yyyy"
onChange={(e) => {
const nextDisplay = e.target.value;
const parsedDate = parseDateDisplay(nextDisplay);
setEditForm({
...editForm,
dateDisplay: nextDisplay,
date: nextDisplay === '' ? '' : parsedDate || editForm.date
});
}}
/>
<button
type="button"
className="date-picker-button"
aria-label="Open calendar"
title="Open calendar"
onClick={() => {
const picker = nativeDatePickerRef.current;
if (!picker) return;
if (picker.showPicker) {
picker.showPicker();
} else {
picker.click();
}
}}
>
📅
</button>
<input
ref={nativeDatePickerRef}
type="date"
lang="en-GB"
className="native-date-picker-hidden"
value={editForm.date}
onChange={(e) => {
const nextDate = e.target.value;
setEditForm({
...editForm,
date: nextDate,
dateDisplay: formatDateForDisplay(nextDate)
});
}}
aria-label="Select date"
/>
</div>
</div>
<div className="form-group">
<label>Start Time:</label>
<input
type="time"
value={editForm.startTime}
step="60"
onChange={(e) => setEditForm({...editForm, startTime: e.target.value})}
/>
</div>
{!editForm.makeActive && (
<div className="form-group">
<label>End Time:</label>
<input
type="time"
value={editForm.endTime}
step="60"
onChange={(e) => setEditForm({...editForm, endTime: e.target.value})}
/>
</div>
)}
{/* Show "Make Active" checkbox if end time is in the future */}
{(isEndTimeInFuture() || creatingNewSession) && (
<div className="form-group">
<label>
<input
type="checkbox"
checked={editForm.makeActive}
onChange={(e) => setEditForm({...editForm, makeActive: e.target.checked})}
/>
Make this session active now
</label>
</div>
)}
<div className="pauses-section">
<h3>Pauses</h3>
{editForm.pauses.map((pause, index) => (
<div key={index} className="pause-edit-row">
<input
type="time"
value={pause.start}
step="60"
onChange={(e) => updatePause(index, 'start', e.target.value)}
/>
<input
type="time"
value={pause.end}
step="60"
onChange={(e) => updatePause(index, 'end', e.target.value)}
/>
<button
onClick={() => removePause(index)}
className="remove-pause-btn"
>
Remove
</button>
</div>
))}
<button onClick={addPause} className="add-pause-btn">
Add Pause
</button>
</div>
<div className="form-actions">
<button onClick={saveChanges} className="save-button" disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save Changes'}
</button>
{editingSession && editingSession !== 'current' && !creatingNewSession && (
<button onClick={deleteSession} className="delete-button" disabled={isSaving}>
Delete Session
</button>
)}
<button onClick={cancelEditing} className="cancel-button" disabled={isSaving}>
Cancel
</button>
</div>
</div>
)}
<div className="current-session">
<h2>Current Session</h2>
{sessionData.currentTimeEntry ? (
<div className="session-item current">
<div className="session-info">
<div><strong>Start Time:</strong> {formatDateTime(sessionData.currentTimeEntry.startTime)}</div>
<div><strong>Status:</strong> <span className="active-status">Active</span></div>
{sessionData.currentTimeEntry.pauses && sessionData.currentTimeEntry.pauses.length > 0 && (
<div>
<strong>Pauses:</strong>
{sessionData.currentTimeEntry.pauses.map((pause, index) => (
<div key={index} className="pause-history-item">
{formatDateTime(pause.start, sessionData.currentTimeEntry.startTime)} -{' '}
{pause.end
? formatDateTime(pause.end, sessionData.currentTimeEntry.startTime)
: 'Currently paused'}
</div>
))}
</div>
)}
<button
onClick={() => startEditingCurrent(sessionData.currentTimeEntry)}
className="edit-session-button"
>
Edit Current Session
</button>
</div>
</div>
) : (
<p className="empty-text">No active session</p>
)}
</div>
<div className="past-sessions">
<div className="past-sessions-header">
<h2>Past Sessions</h2>
<button onClick={startCreating} className="nav-button">
Create New Session
</button>
</div>
{sessionData.sessions.filter((session) => session.endTime).length > 0 ? (
<div className="sessions-grid">
{[...sessionData.sessions]
.filter((session) => session.endTime)
.reverse()
.map((session) => (
<div key={session.id} className="session-item past">
{editingSession === session ? (
// Editing view would go here
<div>Editing...</div>
) : (
// Display view
<div className="session-info">
<div className="session-card-header">
<span className="session-date">{formatDateOnly(session.startTime)}</span>
<span className="session-tag">Completed</span>
</div>
<div className="session-row">
<strong>Start Time:</strong> {formatDateTime(session.startTime)}
</div>
<div className="session-row">
<strong>End Time:</strong> {formatDateTime(session.endTime)}
</div>
<div className="session-stats-grid">
<div className="session-stat">
<span className="session-stat-label">Total Duration</span>
<span className="session-stat-value">{formatDuration(session.duration)}</span>
</div>
<div className="session-stat">
<span className="session-stat-label">Pause Time</span>
<span className="session-stat-value">
{formatDuration(
session.pauses?.reduce((total, pause) => {
const start = parseDateValue(pause.start, session.startTime);
const end = parseDateValue(pause.end, session.startTime);
if (!start || !end) return total;
const pauseDuration = end - start;
return total + (Number.isFinite(pauseDuration) ? Math.max(pauseDuration, 0) : 0);
}, 0)
)}
</span>
</div>
<div className="session-stat">
<span className="session-stat-label">Work Time</span>
<span className="session-stat-value">{formatDuration(calculateWorkTime(session))}</span>
</div>
</div>
<button
onClick={() => startEditing(session)}
className="edit-session-button"
>
Edit Session
</button>
</div>
)}
</div>
))}
</div>
) : (
<p className="empty-text">No past sessions found</p>
)}
</div>
</div>
);
};
export default Sessions;

View File

@@ -0,0 +1,246 @@
// Service for Supabase integration
import { supabase } from './supabaseClient';
class SessionService {
// Function to save a session to Supabase
async saveSession(sessionData) {
try {
const { data, error } = await supabase
.from('timers')
.insert([
{
user_id: sessionData.userId,
start_time: sessionData.startTime.toISOString(),
end_time: sessionData.endTime?.toISOString() || null,
duration_ms: sessionData.duration,
pauses: sessionData.pauses || []
}
])
.select();
if (error) {
console.error('Supabase error saving session:', error);
throw error;
}
console.log('Session saved to Supabase:', data);
return { success: true, id: data[0].id };
} catch (error) {
console.error('Error saving session to Supabase:', error);
throw error;
}
}
// Function to create a new session in Supabase
async createSession(sessionData) {
try {
// Convert time inputs to Date objects
let startTime, endTime;
if (typeof sessionData.startTime === 'string' && sessionData.startTime.includes('T')) {
// Full datetime string
startTime = new Date(sessionData.startTime);
endTime = sessionData.endTime ? new Date(sessionData.endTime) : null;
} else {
// Assuming sessionData.startTime is already a Date object
startTime = sessionData.startTime;
endTime = sessionData.endTime;
}
// Calculate duration
const durationMs = endTime ? endTime - startTime : 0;
// Format pauses properly
const formattedPauses = sessionData.pauses ? sessionData.pauses.map(pause => ({
start: typeof pause.start === 'string' && pause.start.includes('T')
? new Date(pause.start).toISOString()
: pause.start,
end: pause.end
? (typeof pause.end === 'string' && pause.end.includes('T')
? new Date(pause.end).toISOString()
: pause.end)
: null
})) : [];
const { data, error } = await supabase
.from('timers')
.insert([
{
user_id: sessionData.userId,
start_time: startTime.toISOString(),
end_time: endTime ? endTime.toISOString() : null,
duration_ms: durationMs,
pauses: formattedPauses
}
])
.select();
if (error) {
console.error('Supabase error creating session:', error);
throw error;
}
console.log('Session created in Supabase:', data);
return { success: true, data: data[0] };
} catch (error) {
console.error('Error creating session in Supabase:', error);
throw error;
}
}
// Function to update a session in Supabase
async updateSession(sessionId, sessionData) {
try {
// Calculate duration from start and end times if both are provided
let durationMs = sessionData.duration_ms;
if (sessionData.start_time && sessionData.end_time) {
const startTime = new Date(sessionData.start_time);
const endTime = new Date(sessionData.end_time);
durationMs = endTime - startTime;
} else if (sessionData.end_time === null) {
durationMs = 0;
}
// Format pauses properly
const formattedPauses = sessionData.pauses ? sessionData.pauses.map(pause => ({
start: new Date(pause.start).toISOString(),
end: pause.end ? new Date(pause.end).toISOString() : null
})) : [];
const { data, error } = await supabase
.from('timers')
.update({
start_time: sessionData.start_time ? new Date(sessionData.start_time).toISOString() : undefined,
end_time: sessionData.end_time ? new Date(sessionData.end_time).toISOString() : null,
duration_ms: durationMs,
pauses: formattedPauses
})
.eq('id', sessionId)
.select();
if (error) {
console.error('Supabase error updating session:', error);
throw error;
}
console.log('Session updated in Supabase:', data);
return { success: true, data: data[0] };
} catch (error) {
console.error('Error updating session in Supabase:', error);
throw error;
}
}
// Function to delete a session in Supabase
async deleteSession(sessionId) {
try {
const { error } = await supabase
.from('timers')
.delete()
.eq('id', sessionId);
if (error) {
console.error('Supabase error deleting session:', error);
throw error;
}
console.log('Session deleted from Supabase:', sessionId);
return { success: true };
} catch (error) {
console.error('Error deleting session from Supabase:', error);
throw error;
}
}
// Function to fetch sessions from Supabase
async getSessions(userId) {
try {
const { data, error } = await supabase
.from('timers')
.select('*')
.eq('user_id', userId)
.order('start_time', { ascending: false });
if (error) {
console.error('Supabase error fetching sessions:', error);
throw error;
}
console.log('Sessions fetched from Supabase:', data);
return data.map(record => ({
id: record.id,
startTime: new Date(record.start_time),
endTime: record.end_time ? new Date(record.end_time) : null,
duration: record.duration_ms || 0,
userId: record.user_id,
pauses: record.pauses || []
}));
} catch (error) {
console.error('Error fetching sessions from Supabase:', error);
throw error;
}
}
// Function to authenticate user
async authenticateUser(email, password) {
try {
// Supabase handles authentication
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
console.error('Authentication error:', error);
throw error;
}
return data.user;
} catch (error) {
console.error('Error authenticating user:', error);
throw error;
}
}
// Function to register user
async registerUser(email, password) {
try {
const { data, error } = await supabase.auth.signUp({
email,
password,
});
if (error) {
console.error('Registration error:', error);
throw error;
}
return data.user;
} catch (error) {
console.error('Error registering user:', error);
throw error;
}
}
// Function to logout user
async logoutUser() {
try {
const { error } = await supabase.auth.signOut();
if (error) {
console.error('Logout error:', error);
throw error;
}
return true;
} catch (error) {
console.error('Error logging out user:', error);
throw error;
}
}
}
// Export singleton instance
const sessionService = new SessionService();
export default sessionService;

View File

@@ -0,0 +1,8 @@
import { createClient } from '@supabase/supabase-js';
// Supabase configuration from environment variables
const supabaseUrl = process.env.REACT_APP_SUPABASE_URL;
const supabaseAnonKey = process.env.REACT_APP_SUPABASE_ANON_KEY;
// Create Supabase client
export const supabase = createClient(supabaseUrl, supabaseAnonKey);