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

23
.env.example Normal file
View File

@@ -0,0 +1,23 @@
# Supabase Configuration
# Sign up at https://supabase.io to get your project URL and anon key
REACT_APP_SUPABASE_URL=https://your-supabase-project.supabase.co
REACT_APP_SUPABASE_ANON_KEY=your-supabase-anon-key-here
# Database Password (for reference - not used directly in app)
SUPABASE_DB_PASSWORD=your-database-password-here
# Application Settings
REACT_APP_APP_NAME=Time Tracker
REACT_APP_VERSION=1.0.0
# Development Settings
GENERATE_SOURCEMAP=false
# Instructions:
# 1. Copy this file to .env
# 2. Replace placeholder values with your actual credentials
# 3. Restart the development server after making changes
# Security Note:
# Never commit .env file to version control
# Only variables prefixed with REACT_APP_ are embedded in the client bundle

7
.gitignore vendored
View File

@@ -17,7 +17,12 @@
.env.development.local
.env.test.local
.env.production.local
.env
# Additional environment files
.env.*
!.env.example
npm-debug.log*
yarn-debug.log*
yarn-error.log*
yarn-error.log*

144
README.md
View File

@@ -1,4 +1,144 @@
# Getting Started with Create React App
# Time Tracker App
A React-based time tracking application with user authentication and session management.
## Features
- User authentication system with Supabase Auth
- Start/stop time tracking functionality
- Pause/resume functionality for breaks
- Session history management
- Edit current and past sessions
- Create new sessions with simplified time input
- Integration with Supabase for data persistence
- Responsive design for desktop and mobile
## Pages
1. **Login/Register Page** - Secure authentication entry point
2. **Dashboard** - Main page with timer controls
3. **Session History** - View, edit, and create sessions
## Setup Instructions
1. Clone the repository (if applicable)
2. Install dependencies:
```
npm install
```
3. Set up environment variables:
- Copy `.env.example` to `.env`
- Update the values in `.env` with your Supabase credentials:
```
REACT_APP_SUPABASE_URL=your_supabase_project_url_here
REACT_APP_SUPABASE_ANON_KEY=your_supabase_anon_key_here
```
4. Start the development server:
```
npm start
```
## Database Setup
### Supabase Tables
Create these tables in your Supabase project:
#### Users Table
```sql
-- Users table (handled by Supabase Auth)
-- Supabase automatically creates auth.users table
```
#### Timers Table
```sql
-- Create timers table
create table if not exists timers (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users(id) not null,
start_time timestamptz not null,
end_time timestamptz,
duration_ms integer,
pauses json,
created_at timestamptz default now()
);
-- Enable RLS (Row Level Security)
alter table timers enable row level security;
-- Create policy to allow users to access their own timers only
create policy "Users can access their own timers" on timers
for all using (auth.uid() = user_id);
-- Grant permissions
grant usage on schema public to anon, authenticated;
grant all on table timers to anon, authenticated;
```
## Session Management Features
### Creating New Sessions
- Click "Create New Session" in Session History
- Select date and enter start/end times (HH:MM format)
- Add pauses with start/end times as needed
- Save to store in Supabase database
### Editing Sessions
- Edit current active session
- Edit past sessions from history
- Modify start/end times and pause periods
- Changes automatically saved to database
### Timer Controls
- Start/Stop schedule tracking
- Take breaks with Pause/Resume functionality
- Real-time work time calculation (excluding pause time)
## Security
- Environment variables are stored in `.env` (excluded from git)
- Sensitive data never stored in browser localStorage or sessionStorage
- All session data persists only in user session and external database
- Review `.gitignore` to ensure sensitive files are not committed
- Supabase Auth handles user authentication securely
## Implementation Details
- Built with React and React Router for navigation
- Uses Context API for state management
- Implements a clean, responsive UI with CSS
- Session data persists in user session and Supabase database
- Integrates with Supabase API for data persistence and authentication
## File Structure
```
src/
├── components/ # Reusable UI components
├── context/ # Authentication and state context
├── pages/ # Page components (Login, Dashboard, Sessions)
├── services/ # API integration services
└── App.js # Main app component with routing
```
## Database Integration
The app includes a service layer for Supabase integration:
- Sessions are saved to the database when stopped
- Session history is loaded from the database on login
- All database operations are handled through the sessionService
- User authentication is managed by Supabase Auth
To connect to your own Supabase project:
1. Create a Supabase account at https://supabase.io
2. Create a new project
3. Get your project URL and anon key from project settings
4. Create the required tables using the SQL provided above
5. Update the environment variables with your credentials
---
# Original Create React App Documentation
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
@@ -67,4 +207,4 @@ This section has moved here: [https://facebook.github.io/create-react-app/docs/d
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)

261
package-lock.json generated
View File

@@ -8,12 +8,17 @@
"name": "time-tracker-app",
"version": "0.1.0",
"dependencies": {
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/react-fontawesome": "^3.3.0",
"@supabase/supabase-js": "^2.103.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^13.5.0",
"axios": "^1.15.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.0",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
}
@@ -2456,6 +2461,53 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.2.0.tgz",
"integrity": "sha512-IpR0bER9FY25p+e7BmFH25MZKEwFHTfRAfhOyJubgiDnoJNsSvJ7nigLraHtp4VOG/cy8D7uiV0dLkHOne5Fhw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.2.0.tgz",
"integrity": "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.2.0.tgz",
"integrity": "sha512-YTVITFGN0/24PxzXrwqCgnyd7njDuzp5ZvaCx5nq/jg55kUYd94Nj8UTchBdBofi/L0nwRfjGOg0E41d2u9T1w==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/react-fontawesome": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-3.3.0.tgz",
"integrity": "sha512-EHmHeTf8WgO29sdY3iX/7ekE3gNUdlc2RW6mm/FzELlHFKfTrA9S4MlyquRR+RRCRCn8+jXfLFpLGB2l7wCWyw==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@fortawesome/fontawesome-svg-core": "~6 || ~7",
"react": "^18.0.0 || ^19.0.0"
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@@ -3100,6 +3152,113 @@
"@sinonjs/commons": "^1.7.0"
}
},
"node_modules/@supabase/auth-js": {
"version": "2.103.0",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.103.0.tgz",
"integrity": "sha512-6zAanO6c+6gpHOlt5Lb9TlBBkJdZiUWkWCJKAxzkywBDcwaHlLJKXnjQGX6GyVCyKRR1e7sTq4re/yRTH6U/9A==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.103.0",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.103.0.tgz",
"integrity": "sha512-YrneV2NjskUkkmkZ2Jt2n3elBgbWzV4Y1M9MM370z2Zd5ZPFqFbY8KIoPwuNjtAGE9YrpKBxnbZqeF07BiN9Og==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/phoenix": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.0.tgz",
"integrity": "sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw==",
"license": "MIT"
},
"node_modules/@supabase/postgrest-js": {
"version": "2.103.0",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.103.0.tgz",
"integrity": "sha512-rC3sRxYdPZymkp2CZR1MiNQgbOleD01bGsW8VxEKRR5nMkLZ1NgAS1QTQf78Wh30czFyk505ZYr9Od8/mWT2TA==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.103.0",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.103.0.tgz",
"integrity": "sha512-gcPtXzZ6izyyBVf2of7K3dEt8CScPJn8VcSlQq6oWL9QoE1kqfQl0oFrOMHd5qrcADewxI7OxxosLB8W4XqtIQ==",
"license": "MIT",
"dependencies": {
"@supabase/phoenix": "^0.4.0",
"@types/ws": "^8.18.1",
"tslib": "2.8.1",
"ws": "^8.18.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js/node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/@supabase/storage-js": {
"version": "2.103.0",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.103.0.tgz",
"integrity": "sha512-DHmlvdAXwtOmZNbkIZi4lkobPR3XjIzoOgzoz5duMf6G+sDeY015YrzMJCnqdccuYr7X5x4yYuSwF//RoN2dvQ==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.103.0",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.103.0.tgz",
"integrity": "sha512-j/6q5+LtXbR/YOLSLhy7Na74RD1cV2v+KwIIuuqMEjk1JpLEEyu0ynwDHpGoxMncDQl+R5FogaVqZm+85lZvtw==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.103.0",
"@supabase/functions-js": "2.103.0",
"@supabase/postgrest-js": "2.103.0",
"@supabase/realtime-js": "2.103.0",
"@supabase/storage-js": "2.103.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@surma/rollup-plugin-off-main-thread": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
@@ -4803,6 +4962,33 @@
"node": ">=4"
}
},
"node_modules/axios": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/axios/node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -9020,6 +9206,15 @@
"node": ">=10.17.0"
}
},
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -13472,6 +13667,15 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
@@ -13763,6 +13967,57 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz",
"integrity": "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.0.tgz",
"integrity": "sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ==",
"license": "MIT",
"dependencies": {
"react-router": "7.14.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-router/node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/react-scripts": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz",
@@ -14641,6 +14896,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",

View File

@@ -3,12 +3,17 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/react-fontawesome": "^3.3.0",
"@supabase/supabase-js": "^2.103.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^13.5.0",
"axios": "^1.15.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.0",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
},
@@ -16,7 +21,8 @@
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
"eject": "react-scripts eject",
"setup": "node setup.js"
},
"eslintConfig": {
"extends": [

56
public/LOGO_FICOSA.svg Normal file
View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 1190.55 841.89" style="enable-background:new 0 0 1190.55 841.89;" xml:space="preserve">
<style type="text/css">
.Arched_x0020_Green{fill:url(#SVGID_1_);stroke:#FFFFFF;stroke-width:0.25;stroke-miterlimit:1;}
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#9A7E17;}
.st1{fill:#9A7E17;}
</style>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="0" y1="841.8896" x2="0.7071" y2="841.1825">
<stop offset="0" style="stop-color:#20AC4B"/>
<stop offset="0.9831" style="stop-color:#19361A"/>
</linearGradient>
<g>
<path class="st0" d="M160.73,347.48l2.08,4.43l1.48,3.02l1.54,3.29l2.08,4.63l-22.35,46.92l-2.15-3.96l-1.94-3.96l-3.83-7.85
l-7.25-15.37l-3.62-7.72l-3.76-7.78l-1.81-3.96l-2.02-3.96l-1.94-3.96l-2.15-4.1L160.73,347.48z M214.63,488.23l-0.34,0.67
l-0.94,1.21l-1.54,1.61l-1.14,0.94l-1.28,0.81l-1.34,0.67l-1.34,0.61l-1.55,0.6l-1.54,0.34l-1.48,0.34l-0.81,0.13h-0.8l-1.68,0.13
h-1.54l-1.61-0.13l-1.68-0.27l-1.61-0.2l-1.88-0.6l-1.61-0.67l-0.81-0.34l-0.81-0.47l-1.41-0.94l-1.41-1.21l-1.21-1.14l-1.14-1.41
l-1.07-1.41l-1.01-1.34l-0.94-1.68l-0.8-1.48l-0.81-1.54l-0.74-1.61l-0.67-1.54l-1.41-3.02l14.83-32.08l14.83-32.02l14.9-32.22
l7.58-15.97l7.58-15.84h46.65l-33.22,70.54L214.63,488.23z M276.04,492.8h-45.57l-5.97-11.82l23.09-48.59L276.04,492.8z
M214.29,354.6l4.97,11.34l-7.32,15.98l-7.45,15.84l-14.9,31.61l-15.03,31.54l-7.58,15.91l-7.65,15.71h-46.45l33.16-70.07
l33.09-69.94l0.47-0.74l0.47-0.67l0.47-0.67l1.14-1.21l2.01-1.48l0.67-0.47l0.74-0.47l1.61-0.74l0.8-0.34l0.81-0.33l1.68-0.47
l0.88-0.27l0.94-0.07l1.74-0.27l1.88-0.07h1.88l1.81,0.2l1.75,0.2l1.88,0.47l1.74,0.6l0.67,0.27l0.67,0.2l0.88,0.34l0.67,0.47
l0.67,0.47l0.74,0.47l0.67,0.47l0.74,0.61l0.67,0.54l1.21,1.34l0.54,0.67l0.47,0.67l0.47,0.74l0.47,0.81L214.29,354.6z"/>
<path class="st1" d="M992.08,370.8c1.94-1.89,5.03-3.88,9.35-3.92c5.46-0.25,10.49,2.91,13.05,7.57l62.93,93.56h-30.89
l-20.18-29.64h-22.55h-27.53l-18.92,29.64h-30.96l41.23-61.15l4.44-6.98l17.25-25.41C990.01,373.11,990.98,371.87,992.08,370.8z
M996.64,375.48c-0.7,0.68-1.27,1.47-1.72,2.34l-17.45,25.69l-4.39,6.89l-34.42,51.07h15.08l18.92-29.64h31.12h26.01l20.18,29.64
h15.15l-56.29-83.68c-1.26-2.52-4.29-4.5-7.18-4.37C999.42,373.41,997.75,374.4,996.64,375.48z M1005.28,395.2l19.45,28.98h-22.53
h-24.8l19.86-28.98C999.85,391.44,1002.57,391.41,1005.28,395.2z M1001.26,400.89l-11.46,16.74h12.4h10.27L1001.26,400.89z
M488.35,371.29v96.72h-24.52v-96.72H488.35z M481.81,377.83h-11.43v83.64h11.43V377.83z M819,431.75
c-10.8,0-18.72-8.89-18.72-19.35v-22.39c0-10.19,7.96-19.04,18.72-19.04h86.72c10.32,0,19.04,8.72,19.04,19.04v6.62v5.16h-8h-7.88
h-8.32v-3.71c-0.22-1.47-1.66-3.23-4.3-3.23h-68.11c-2.49,0-3.98,2.61-3.98,4.93v3.47c0,2.66,2.53,3.98,4.3,3.98h36.26h40.99
c10.32,0,19.04,8.72,19.04,19.04v22.39c0,10.68-8.76,19.04-19.04,19.04H819c-10.72,0-18.72-8.49-18.72-19.04v-5.05v-5.48h7.69h7.88
h8.32v3.2c1.23,2.38,3.58,2.79,4.93,2.79h66.85c3.22,0,5.24-2.25,5.24-3.98v-3.47c0-3-2.38-4.93-4.93-4.93h-36.26H819z
M818.53,444.68h-2.68h-7.88h-1.14v3.98c0,7.11,5.24,12.5,12.18,12.5h86.72c6.75,0,12.49-5.52,12.49-12.5v-22.39
c0-6.71-5.79-12.5-12.49-12.5h-40.99h-36.26c-3.28,0-10.84-2.46-10.84-10.52v-3.47c0-3.35,2.29-11.47,10.52-11.47h68.11
c5.04,0,8.81,3.14,10.25,6.94h2.37h7.88h1.46v-5.24c0-6.71-5.79-12.49-12.49-12.49H819c-6.89,0-12.18,5.66-12.18,12.49v22.39
c0,7.19,5.33,12.81,12.18,12.81h40.99h36.26c5.66,0,11.47,4.37,11.47,11.47v3.47c0,6.46-6.18,10.52-11.78,10.52h-66.85
C826.93,450.67,821.44,450,818.53,444.68z M612.84,408.41l-0.49-8.85c0-3.16-2.78-4.7-4.62-4.7h-73.47c-4.28,0-4.93,3.09-4.93,4.93
v39.42c0,3.8,3.31,4.93,4.93,4.93h73.47c3.39,0,4.61-2.99,4.61-4.3v-9.26h8h8.2h7.68v9.89v8.51c0,10.32-8.72,19.04-19.03,19.04
h-92.71c-10.32,0-19.04-8.71-19.04-19.04v-58.96c0-10.41,8.8-18.72,19.04-18.72h92.71c9.78,0,19.03,7.56,19.03,18.72v11.98v6.42
h-7.68h-8.2H612.84z M629.69,401.87v-11.86c0-7.13-5.88-12.18-12.49-12.18h-92.71c-6.79,0-12.5,5.56-12.5,12.18v58.96
c0,6.71,5.79,12.5,12.5,12.5h92.71c6.71,0,12.49-5.79,12.49-12.5v-8.51v-3.35h-1.14h-8.2h-1.46v2.72c0,3.11-2.57,10.84-11.16,10.84
h-73.47c-2.79,0-11.47-2.03-11.47-11.47v-39.42c0-2.57,1.25-11.47,11.47-11.47h73.47c3.82,0,11.04,2.91,11.15,10.97l0.14,2.59h1.32
h8.2H629.69z M358,394.86c-1.54,0-4.3,1.16-4.3,4.61v5.36c0,1.76,0.76,4.58,8.66,4.3l85.63,0v23.57H358
c-4.15,0.26-4.3,4.23-4.3,4.61v30.7h-23.57v-78.63c0-10.62,8.09-18.09,20.3-18.09h97.87v23.57H358z M358,388.32h83.76v-10.49
h-91.33c-9.23,0-13.76,5.14-13.76,11.55v72.09h10.49v-24.16c0,0-0.01-0.69,0.16-1.72c0.89-5.42,4.78-9.19,10.61-9.43h83.51v-10.48
l-78.92,0c-9.76,0.35-15.37-3.77-15.37-10.84v-5.36C347.17,390.95,354.5,388.32,358,388.32z M781.91,440.46v8.51
c0,10.19-7.96,19.04-18.72,19.04h-93.02c-10.49,0-18.4-8.89-18.4-19.04v-58.96c0-10.23,8-18.72,18.4-18.72h93.02
c10.23,0,18.72,7.68,18.72,18.72v48.24V440.46z M775.37,440.46v-2.21v-48.24c0-7.25-5.39-12.18-12.18-12.18h-93.02
c-6.62,0-11.86,5.38-11.86,12.18v58.96c0,6.88,5.33,12.5,11.86,12.5h93.02c6.89,0,12.18-5.66,12.18-12.5V440.46z M758.34,399.47
c0-3.16-3.09-4.61-4.3-4.61h-74.1c-3.65,0-4.61,3.09-4.61,4.93v39.42c0,3.8,3.62,4.93,4.61,4.93h73.79c3.16,0,4.61-3.09,4.61-4.3
v-8.2V399.47z M764.88,399.47v32.16v8.2c0,3.21-2.96,10.84-11.15,10.84h-73.79c-2.16,0-11.15-2.03-11.15-11.47v-39.42
c0-2.57,1.56-11.47,11.15-11.47h74.1C757.26,388.32,764.88,391.28,764.88,399.47z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -9,7 +9,7 @@
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo_ficosa.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

BIN
public/logo_ficosa.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -8,12 +8,12 @@
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"src": "LOGO_FICOSA.svg",
"type": "image/svg+xml",
"sizes": "192x192"
},
{
"src": "logo512.png",
"src": "logo_ficosa.png",
"type": "image/png",
"sizes": "512x512"
}

51
setup.js Normal file
View File

@@ -0,0 +1,51 @@
#!/usr/bin/env node
// Setup script to help users configure their environment
const fs = require('fs');
const path = require('path');
console.log('Time Tracker App Setup Script');
console.log('============================');
// Check if .env file exists
const envPath = path.join(__dirname, '.env');
const envExamplePath = path.join(__dirname, '.env.example');
if (!fs.existsSync(envPath)) {
console.log('\n.env file not found. Creating one from .env.example...');
if (fs.existsSync(envExamplePath)) {
fs.copyFileSync(envExamplePath, envPath);
console.log('.env file created successfully!');
console.log('Please edit .env file with your Supabase credentials.');
} else {
console.log('Warning: .env.example not found. Creating minimal .env file...');
const minimalEnv = `
# Supabase Configuration
REACT_APP_SUPABASE_URL=https://your-supabase-project.supabase.co
REACT_APP_SUPABASE_ANON_KEY=your-supabase-anon-key-here
# Application Settings
REACT_APP_APP_NAME=Time Tracker
`;
fs.writeFileSync(envPath, minimalEnv);
console.log('.env file created successfully!');
console.log('Please edit .env file with your Supabase credentials.');
}
} else {
console.log('\n.env file already exists. Skipping creation.');
}
console.log('\nNext steps:');
console.log('1. Edit .env file with your Supabase URL and anon key');
console.log('2. Run "npm install" to install dependencies');
console.log('3. Run "npm start" to start the development server');
console.log('\nFor Supabase setup:');
console.log('- Visit https://supabase.io to create an account');
console.log('- Create a new project');
console.log('- Find your project URL and anon key in the project settings');
console.log('- Create the required tables (users and timers) in the SQL editor');

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);