Versión 1.0
This commit is contained in:
23
.env.example
Normal file
23
.env.example
Normal 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
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -17,6 +17,11 @@
|
|||||||
.env.development.local
|
.env.development.local
|
||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Additional environment files
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
|
|||||||
142
README.md
142
README.md
@@ -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).
|
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||||
|
|
||||||
|
|||||||
261
package-lock.json
generated
261
package-lock.json
generated
@@ -8,12 +8,17 @@
|
|||||||
"name": "time-tracker-app",
|
"name": "time-tracker-app",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"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/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"axios": "^1.15.0",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
|
"react-router-dom": "^7.14.0",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"web-vitals": "^2.1.4"
|
"web-vitals": "^2.1.4"
|
||||||
}
|
}
|
||||||
@@ -2456,6 +2461,53 @@
|
|||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"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": {
|
"node_modules/@humanwhocodes/config-array": {
|
||||||
"version": "0.13.0",
|
"version": "0.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||||
@@ -3100,6 +3152,113 @@
|
|||||||
"@sinonjs/commons": "^1.7.0"
|
"@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": {
|
"node_modules/@surma/rollup-plugin-off-main-thread": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
|
"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": ">=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": {
|
"node_modules/axobject-query": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
@@ -9020,6 +9206,15 @@
|
|||||||
"node": ">=10.17.0"
|
"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": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
@@ -13472,6 +13667,15 @@
|
|||||||
"node": ">= 0.10"
|
"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": {
|
"node_modules/psl": {
|
||||||
"version": "1.15.0",
|
"version": "1.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
|
||||||
@@ -13763,6 +13967,57 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/react-scripts": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz",
|
||||||
@@ -14641,6 +14896,12 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
|
|||||||
@@ -3,12 +3,17 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"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/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"axios": "^1.15.0",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
|
"react-router-dom": "^7.14.0",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"web-vitals": "^2.1.4"
|
"web-vitals": "^2.1.4"
|
||||||
},
|
},
|
||||||
@@ -16,7 +21,8 @@
|
|||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
"build": "react-scripts build",
|
"build": "react-scripts build",
|
||||||
"test": "react-scripts test",
|
"test": "react-scripts test",
|
||||||
"eject": "react-scripts eject"
|
"eject": "react-scripts eject",
|
||||||
|
"setup": "node setup.js"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"extends": [
|
"extends": [
|
||||||
|
|||||||
56
public/LOGO_FICOSA.svg
Normal file
56
public/LOGO_FICOSA.svg
Normal 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 |
@@ -9,7 +9,7 @@
|
|||||||
name="description"
|
name="description"
|
||||||
content="Web site created using create-react-app"
|
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
|
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/
|
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
BIN
public/logo_ficosa.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
@@ -8,12 +8,12 @@
|
|||||||
"type": "image/x-icon"
|
"type": "image/x-icon"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "logo192.png",
|
"src": "LOGO_FICOSA.svg",
|
||||||
"type": "image/png",
|
"type": "image/svg+xml",
|
||||||
"sizes": "192x192"
|
"sizes": "192x192"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "logo512.png",
|
"src": "logo_ficosa.png",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"sizes": "512x512"
|
"sizes": "512x512"
|
||||||
}
|
}
|
||||||
|
|||||||
51
setup.js
Normal file
51
setup.js
Normal 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');
|
||||||
1256
src/App.css
1256
src/App.css
File diff suppressed because it is too large
Load Diff
180
src/App.js
180
src/App.js
@@ -1,24 +1,170 @@
|
|||||||
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';
|
import './App.css';
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<div className="App">
|
<AuthProvider>
|
||||||
<header className="App-header">
|
<AppContent />
|
||||||
<img src={logo} className="App-logo" alt="logo" />
|
</AuthProvider>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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;
|
sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
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