Versión 1.0
This commit is contained in:
1260
src/App.css
1260
src/App.css
File diff suppressed because it is too large
Load Diff
184
src/App.js
184
src/App.js
@@ -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
539
src/context/AuthContext.js
Normal 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);
|
||||
};
|
||||
@@ -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
273
src/pages/Calendar.js
Normal 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
147
src/pages/Dashboard.js
Normal 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
100
src/pages/Login.js
Normal 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
351
src/pages/Profile.js
Normal 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
716
src/pages/Sessions.js
Normal 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;
|
||||
246
src/services/sessionService.js
Normal file
246
src/services/sessionService.js
Normal 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;
|
||||
8
src/services/supabaseClient.js
Normal file
8
src/services/supabaseClient.js
Normal 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);
|
||||
Reference in New Issue
Block a user