Merge pull request #8 from DRYTRIX/7-wrong-timezone

feat: comprehensive project cleanup and timezone enhancement
This commit is contained in:
Dries Peeters
2025-08-28 14:54:19 +02:00
committed by GitHub
51 changed files with 1696 additions and 1778 deletions

View File

@@ -73,7 +73,7 @@ SECRET_KEY=your-secure-random-string-here
ADMIN_USERNAMES=admin,manager
# Optional
TZ=Europe/Brussels
TZ=Europe/Rome
CURRENCY=EUR
ROUNDING_MINUTES=1
SINGLE_ACTIVE_TIMER=true

View File

@@ -9,11 +9,15 @@ ENV FLASK_ENV=production
# Install system dependencies
RUN apt-get update && apt-get install -y \
curl \
tzdata \
&& rm -rf /var/lib/apt/lists/*
# Set work directory
WORKDIR /app
# Set default timezone
ENV TZ=Europe/Rome
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
@@ -24,12 +28,31 @@ COPY . .
# Create data and logs directories with proper permissions
RUN mkdir -p /data /app/logs && chmod 755 /data && chmod 755 /app/logs
# Create startup script directly in Dockerfile
RUN echo '#!/bin/bash' > /app/start.sh && \
echo 'set -e' >> /app/start.sh && \
echo 'cd /app' >> /app/start.sh && \
echo 'export FLASK_APP=app' >> /app/start.sh && \
echo 'echo "=== Starting TimeTracker ==="' >> /app/start.sh && \
echo 'echo "Testing startup script..."' >> /app/start.sh && \
echo 'ls -la /app/docker/' >> /app/start.sh && \
echo 'echo "Starting database initialization..."' >> /app/start.sh && \
echo 'python /app/docker/init-database-sql.py' >> /app/start.sh && \
echo 'echo "Starting application..."' >> /app/start.sh && \
echo 'exec gunicorn --bind 0.0.0.0:8080 --worker-class eventlet --workers 1 --timeout 120 "app:create_app()"' >> /app/start.sh
# Make startup scripts executable
RUN chmod +x /app/docker/start.sh /app/docker/init-database.py /app/docker/test-db.py
RUN chmod +x /app/start.sh /app/docker/init-database.py /app/docker/init-database-sql.py /app/docker/test-db.py
# Create non-root user
RUN useradd -m -u 1000 timetracker && \
chown -R timetracker:timetracker /app /data /app/logs
chown -R timetracker:timetracker /app /data /app/logs && \
chmod +x /app/start.sh
# Verify startup script exists and is accessible
RUN ls -la /app/start.sh && \
head -1 /app/start.sh
USER timetracker
# Expose port
@@ -39,90 +62,5 @@ EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:8080/_health || exit 1
# Create startup script with database initialization
RUN echo '#!/bin/bash\n\
set -e\n\
cd /app\n\
export FLASK_APP=app\n\
\n\
echo "Waiting for database to be ready..."\n\
# Wait for Postgres to be ready\n\
python - <<"PY"\n\
import os\n\
import time\n\
import sys\n\
from sqlalchemy import create_engine, text\n\
from sqlalchemy.exc import OperationalError\n\
\n\
url = os.getenv("DATABASE_URL", "")\n\
if url.startswith("postgresql"):\n\
for attempt in range(30):\n\
try:\n\
engine = create_engine(url, pool_pre_ping=True)\n\
with engine.connect() as conn:\n\
conn.execute(text("SELECT 1"))\n\
print("Database connection established successfully")\n\
break\n\
except Exception as e:\n\
print(f"Waiting for database... (attempt {attempt+1}/30): {e}")\n\
time.sleep(2)\n\
else:\n\
print("Database not ready after waiting, exiting...")\n\
sys.exit(1)\n\
else:\n\
print("No PostgreSQL database configured, skipping connection check")\n\
PY\n\
\n\
echo "Checking if database is initialized..."\n\
# Check if database is initialized by looking for tables\n\
python - <<"PY"\n\
import os\n\
import sys\n\
from sqlalchemy import create_engine, text, inspect\n\
\n\
url = os.getenv("DATABASE_URL", "")\n\
if url.startswith("postgresql"):\n\
try:\n\
engine = create_engine(url, pool_pre_ping=True)\n\
inspector = inspect(engine)\n\
\n\
# Check if our main tables exist\n\
existing_tables = inspector.get_table_names()\n\
required_tables = ["users", "projects", "time_entries", "settings"]\n\
\n\
missing_tables = [table for table in required_tables if table not in existing_tables]\n\
\n\
if missing_tables:\n\
print(f"Database not fully initialized. Missing tables: {missing_tables}")\n\
print("Will initialize database...")\n\
sys.exit(1) # Exit with error to trigger initialization\n\
else:\n\
print("Database is already initialized with all required tables")\n\
sys.exit(0) # Exit successfully, no initialization needed\n\
\n\
except Exception as e:\n\
print(f"Error checking database initialization: {e}")\n\
sys.exit(1)\n\
else:\n\
print("No PostgreSQL database configured, skipping initialization check")\n\
sys.exit(0)\n\
PY\n\
\n\
if [ $? -eq 1 ]; then\n\
echo "Initializing database..."\n\
python /app/docker/init-database.py\n\
if [ $? -eq 0 ]; then\n\
echo "Database initialized successfully"\n\
else\n\
echo "Database initialization failed, but continuing..."\n\
fi\n\
else\n\
echo "Database already initialized, skipping initialization"\n\
fi\n\
\n\
echo "Starting application..."\n\
exec gunicorn --bind 0.0.0.0:8080 --worker-class eventlet --workers 1 --timeout 120 "app:create_app()"\n\
' > /app/start.sh && chmod +x /app/start.sh
# Run the application
CMD ["/app/start.sh"]

View File

@@ -1,216 +0,0 @@
FROM python:3.11-slim
# Install system dependencies including PostgreSQL
RUN apt-get update && apt-get install -y \
curl \
postgresql \
postgresql-contrib \
supervisor \
&& rm -rf /var/lib/apt/lists/*
# Set work directory
WORKDIR /app
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create data and logs directories with proper permissions
RUN mkdir -p /data /app/logs /var/lib/postgresql/data && \
chmod 755 /data && chmod 755 /app/logs && chmod 755 /var/lib/postgresql/data
# Make startup scripts executable
RUN chmod +x /app/docker/init-database.py /app/docker/test-db.py
# Create non-root user
RUN useradd -m -u 1000 timetracker && \
chown -R timetracker:timetracker /app /data /app/logs /var/lib/postgresql/data
# Create PostgreSQL configuration
RUN mkdir -p /etc/postgresql/main && \
echo "listen_addresses = '*'" > /etc/postgresql/main/postgresql.conf && \
echo "port = 5432" >> /etc/postgresql/main/postgresql.conf && \
echo "data_directory = '/var/lib/postgresql/data'" >> /etc/postgresql/main/postgresql.conf && \
echo "log_destination = 'stderr'" >> /etc/postgresql/main/postgresql.conf && \
echo "logging_collector = on" >> /etc/postgresql/main/postgresql.conf && \
echo "log_directory = 'log'" >> /etc/postgresql/main/postgresql.conf && \
echo "log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log'" >> /etc/postgresql/main/postgresql.conf && \
echo "log_rotation_age = 1d" >> /etc/postgresql/main/postgresql.conf && \
echo "log_rotation_size = 10MB" >> /etc/postgresql/main/postgresql.conf
# Create PostgreSQL access configuration
RUN echo "local all all trust" > /etc/postgresql/main/pg_hba.conf && \
echo "host all all 127.0.0.1/32 trust" >> /etc/postgresql/main/pg_hba.conf && \
echo "host all all ::1/128 trust" >> /etc/postgresql/main/pg_hba.conf && \
echo "host all all ::1/128 trust" >> /etc/postgresql/main/pg_hba.conf
# Find binary locations
RUN echo "--- Finding PostgreSQL binaries ---" && \
find /usr -name "postgres" -type f 2>/dev/null | head -5 && \
find /usr -name "initdb" -type f 2>/dev/null | head -5 && \
find /usr -name "pg_ctl" -type f 2>/dev/null | head -5 && \
find /usr -name "psql" -type f 2>/dev/null | head -5 && \
echo "--- Checking PATH ---" && \
echo $PATH && \
echo "--- Checking which postgres ---" && \
which postgres || echo "postgres not found in PATH"
# Create symlinks for PostgreSQL binaries and database initialization script
RUN find /usr -name "postgres" -type f 2>/dev/null | head -1 | xargs -I {} ln -sf {} /usr/bin/postgres && \
find /usr -name "initdb" -type f 2>/dev/null | head -1 | xargs -I {} ln -sf {} /usr/bin/initdb && \
find /usr -name "pg_ctl" -type f 2>/dev/null | head -1 | xargs -I {} ln -sf {} /usr/bin/pg_ctl && \
find /usr -name "createdb" -type f 2>/dev/null | head -1 | xargs -I {} ln -sf {} /usr/bin/createdb && \
find /usr -name "createuser" -type f 2>/dev/null | head -1 | xargs -I {} ln -sf {} /usr/bin/createuser && \
find /usr -name "psql" -type f 2>/dev/null | head -1 | xargs -I {} ln -sf {} /usr/bin/psql && \
find /usr -name "pg_isready" -type f 2>/dev/null | head -1 | xargs -I {} ln -sf {} /usr/bin/pg_isready && \
echo "--- PostgreSQL binaries linked ---" && \
ls -la /usr/bin/postgres* /usr/bin/initdb /usr/bin/pg_ctl /usr/bin/createdb /usr/bin/createuser /usr/bin/psql /usr/bin/pg_isready 2>/dev/null || echo "Some binaries not found"
# Copy database initialization script
COPY docker/init-db.sh /app/init-db.sh
RUN chmod +x /app/init-db.sh
# Copy supervisor configuration
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# Create startup script for Flask app with database initialization
RUN echo '#!/bin/bash\n\
set -e\n\
cd /app\n\
export FLASK_APP=app\n\
export DATABASE_URL=postgresql+psycopg2://timetracker@localhost:5432/timetracker\n\
\n\
echo "=== Starting TimeTracker with Combined Setup ==="\n\
\n\
# Initialize PostgreSQL if needed\n\
if [ ! -f /var/lib/postgresql/data/PG_VERSION ]; then\n\
echo "Initializing PostgreSQL..."\n\
su - postgres -c "/usr/bin/initdb -D /var/lib/postgresql/data"\n\
echo "PostgreSQL initialized"\n\
fi\n\
\n\
# Start PostgreSQL in background\n\
echo "Starting PostgreSQL..."\n\
su - postgres -c "/usr/bin/postgres -D /var/lib/postgresql/data" &\n\
POSTGRES_PID=$!\n\
\n\
# Wait for PostgreSQL to be ready\n\
echo "Waiting for PostgreSQL..."\n\
sleep 10\n\
\n\
# Wait for PostgreSQL to actually be accepting connections\n\
echo "Checking PostgreSQL connection..."\n\
until su - postgres -c "/usr/bin/pg_isready -q"; do\n\
echo "PostgreSQL is not ready yet, waiting..."\n\
sleep 2\n\
done\n\
echo "PostgreSQL is ready!"\n\
\n\
# Create database and user\n\
echo "Setting up database..."\n\
su - postgres -c "/usr/bin/createdb timetracker" 2>/dev/null || echo "Database exists"\n\
su - postgres -c "/usr/bin/createuser -s timetracker" 2>/dev/null || echo "User exists"\n\
\n\
# Initialize schema if needed or if FORCE_REINIT is set\n\
if [ ! -f /var/lib/postgresql/data/.initialized ] || [ "$FORCE_REINIT" = "true" ]; then\n\
echo "Initializing database schema..."\n\
su - postgres -c "/usr/bin/psql -d timetracker -f /app/docker/init.sql"\n\
touch /var/lib/postgresql/data/.initialized\n\
echo "Schema initialized"\n\
fi\n\
\n\
# Wait for database to be ready for Python connection\n\
echo "Waiting for database to be ready for Python..."\n\
python - <<"PY"\n\
import os\n\
import time\n\
import sys\n\
from sqlalchemy import create_engine, text\n\
\n\
url = os.getenv("DATABASE_URL", "")\n\
if url.startswith("postgresql"):\n\
for attempt in range(30):\n\
try:\n\
engine = create_engine(url, pool_pre_ping=True)\n\
with engine.connect() as conn:\n\
conn.execute(text("SELECT 1"))\n\
print("Database connection established successfully")\n\
break\n\
except Exception as e:\n\
print(f"Waiting for database... (attempt {attempt+1}/30): {e}")\n\
time.sleep(2)\n\
else:\n\
print("Database not ready after waiting, exiting...")\n\
sys.exit(1)\n\
else:\n\
print("No PostgreSQL database configured, skipping connection check")\n\
PY\n\
\n\
echo "Checking if database is initialized..."\n\
# Check if database is initialized by looking for tables\n\
python - <<"PY"\n\
import os\n\
import sys\n\
from sqlalchemy import create_engine, text, inspect\n\
\n\
url = os.getenv("DATABASE_URL", "")\n\
if url.startswith("postgresql"):\n\
try:\n\
engine = create_engine(url, pool_pre_ping=True)\n\
inspector = inspect(engine)\n\
\n\
# Check if our main tables exist\n\
existing_tables = inspector.get_table_names()\n\
required_tables = ["users", "projects", "time_entries", "settings"]\n\
\n\
missing_tables = [table for table in required_tables if table not in existing_tables]\n\
\n\
if missing_tables:\n\
print(f"Database not fully initialized. Missing tables: {missing_tables}")\n\
print("Will initialize database...")\n\
sys.exit(1) # Exit with error to trigger initialization\n\
else:\n\
print("Database is already initialized with all required tables")\n\
sys.exit(0) # Exit successfully, no initialization needed\n\
\n\
except Exception as e:\n\
print(f"Error checking database initialization: {e}")\n\
sys.exit(1)\n\
else:\n\
print("No PostgreSQL database configured, skipping initialization check")\n\
sys.exit(0)\n\
PY\n\
\n\
if [ $? -eq 1 ]; then\n\
echo "Initializing database with Python script..."\n\
python /app/docker/init-database.py\n\
if [ $? -eq 0 ]; then\n\
echo "Database initialized successfully with Python script"\n\
else\n\
echo "Python database initialization failed, but continuing..."\n\
fi\n\
else\n\
echo "Database already initialized, skipping initialization"\n\
fi\n\
\n\
# Start Flask app\n\
echo "Starting Flask application..."\n\
exec gunicorn --bind 0.0.0.0:8080 --worker-class eventlet --workers 1 --timeout 120 "app:create_app()"\n\
' > /app/start.sh && chmod +x /app/start.sh
# Expose ports
EXPOSE 8080 5432
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:8080/_health || exit 1
# Copy main initialization script
COPY docker/init.sh /app/init.sh
RUN chmod +x /app/init.sh
# Initialize database and start services
CMD ["/app/start.sh"]

View File

@@ -5,11 +5,15 @@ RUN apt-get update && apt-get install -y \
postgresql \
postgresql-contrib \
curl \
tzdata \
&& rm -rf /var/lib/apt/lists/*
# Set work directory
WORKDIR /app
# Set default timezone
ENV TZ=Europe/Rome
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
@@ -23,135 +27,32 @@ RUN mkdir -p /data /app/logs /var/lib/postgresql/data /var/run/postgresql
# Make startup scripts executable
RUN chmod +x /app/docker/init-database.py /app/docker/test-db.py
# Create startup script directly in Dockerfile
RUN echo '#!/bin/bash' > /app/start.sh && \
echo 'set -e' >> /app/start.sh && \
echo 'cd /app' >> /app/start.sh && \
echo 'export FLASK_APP=app' >> /app/start.sh && \
echo 'export DATABASE_URL=postgresql+psycopg2://timetracker@localhost:5432/timetracker' >> /app/start.sh && \
echo 'echo "=== Starting TimeTracker ==="' >> /app/start.sh && \
echo 'echo "Testing startup script..."' >> /app/start.sh && \
echo 'ls -la /app/docker/' >> /app/start.sh && \
echo 'echo "Starting database initialization..."' >> /app/start.sh && \
echo 'python /app/docker/init-database-sql.py' >> /app/start.sh && \
echo 'echo "Starting application..."' >> /app/start.sh && \
echo 'exec gunicorn --bind 0.0.0.0:8080 --worker-class eventlet --workers 1 --timeout 120 "app:create_app()"' >> /app/start.sh
# Make startup scripts executable
RUN chmod +x /app/start.sh /app/docker/init-database.py /app/docker/test-db.py
# Create timetracker user and ensure postgres user exists
RUN useradd -m -u 1000 timetracker && \
chown -R timetracker:timetracker /app /data /app/logs && \
chown -R postgres:postgres /var/lib/postgresql/data /var/run/postgresql
chown -R postgres:postgres /var/lib/postgresql/data /var/run/postgresql && \
chmod +x /app/start.sh
# Create a startup script with database initialization
RUN echo '#!/bin/bash\n\
set -e\n\
cd /app\n\
export FLASK_APP=app\n\
export DATABASE_URL=postgresql+psycopg2://timetracker@localhost:5432/timetracker\n\
\n\
echo "=== Starting TimeTracker ==="\n\
\n\
# Initialize PostgreSQL if needed\n\
if [ ! -f /var/lib/postgresql/data/PG_VERSION ]; then\n\
echo "Initializing PostgreSQL..."\n\
su - postgres -c "/usr/lib/postgresql/*/bin/initdb -D /var/lib/postgresql/data"\n\
echo "PostgreSQL initialized"\n\
fi\n\
\n\
# Start PostgreSQL in background\n\
echo "Starting PostgreSQL..."\n\
su - postgres -c "/usr/lib/postgresql/*/bin/postgres -D /var/lib/postgresql/data" &\n\
POSTGRES_PID=$!\n\
\n\
# Wait for PostgreSQL to be ready\n\
echo "Waiting for PostgreSQL..."\n\
sleep 10\n\
\n\
# Wait for PostgreSQL to actually be accepting connections\n\
echo "Checking PostgreSQL connection..."\n\
until su - postgres -c "/usr/bin/pg_isready -q"; do\n\
echo "PostgreSQL is not ready yet, waiting..."\n\
sleep 2\n\
done\n\
echo "PostgreSQL is ready!"\n\
\n\
# Create database and user\n\
echo "Setting up database..."\n\
su - postgres -c "/usr/bin/createdb timetracker" 2>/dev/null || echo "Database exists"\n\
su - postgres -c "/usr/bin/createuser -s timetracker" 2>/dev/null || echo "User exists"\n\
\n\
# Initialize schema if needed or if FORCE_REINIT is set\n\
if [ ! -f /var/lib/postgresql/data/.initialized ] || [ "$FORCE_REINIT" = "true" ]; then\n\
echo "Initializing database schema..."\n\
su - postgres -c "/usr/bin/psql -d timetracker -f /app/docker/init.sql"\n\
touch /var/lib/postgresql/data/.initialized\n\
echo "Schema initialized"\n\
fi\n\
\n\
# Wait for database to be ready for Python connection\n\
echo "Waiting for database to be ready for Python..."\n\
python - <<"PY"\n\
import os\n\
import time\n\
import sys\n\
from sqlalchemy import create_engine, text\n\
\n\
url = os.getenv("DATABASE_URL", "")\n\
if url.startswith("postgresql"):\n\
for attempt in range(30):\n\
try:\n\
engine = create_engine(url, pool_pre_ping=True)\n\
with engine.connect() as conn:\n\
conn.execute(text("SELECT 1"))\n\
print("Database connection established successfully")\n\
break\n\
except Exception as e:\n\
print(f"Waiting for database... (attempt {attempt+1}/30): {e}")\n\
time.sleep(2)\n\
else:\n\
print("Database not ready after waiting, exiting...")\n\
sys.exit(1)\n\
else:\n\
print("No PostgreSQL database configured, skipping connection check")\n\
PY\n\
\n\
echo "Checking if database is initialized..."\n\
# Check if database is initialized by looking for tables\n\
python - <<"PY"\n\
import os\n\
import sys\n\
from sqlalchemy import create_engine, text, inspect\n\
\n\
url = os.getenv("DATABASE_URL", "")\n\
if url.startswith("postgresql"):\n\
try:\n\
engine = create_engine(url, pool_pre_ping=True)\n\
inspector = inspect(engine)\n\
\n\
# Check if our main tables exist\n\
existing_tables = inspector.get_table_names()\n\
required_tables = ["users", "projects", "time_entries", "settings"]\n\
\n\
missing_tables = [table for table in required_tables if table not in existing_tables]\n\
\n\
if missing_tables:\n\
print(f"Database not fully initialized. Missing tables: {missing_tables}")\n\
print("Will initialize database...")\n\
sys.exit(1) # Exit with error to trigger initialization\n\
else:\n\
print("Database is already initialized with all required tables")\n\
sys.exit(0) # Exit successfully, no initialization needed\n\
\n\
except Exception as e:\n\
print(f"Error checking database initialization: {e}")\n\
sys.exit(1)\n\
else:\n\
print("No PostgreSQL database configured, skipping initialization check")\n\
sys.exit(0)\n\
PY\n\
\n\
if [ $? -eq 1 ]; then\n\
echo "Initializing database with Python script..."\n\
python /app/docker/init-database.py\n\
if [ $? -eq 0 ]; then\n\
echo "Database initialized successfully with Python script"\n\
else\n\
echo "Python database initialization failed, but continuing..."\n\
fi\n\
else\n\
echo "Database already initialized, skipping initialization"\n\
fi\n\
\n\
# Start Flask app\n\
echo "Starting Flask application..."\n\
exec gunicorn --bind 0.0.0.0:8080 --worker-class eventlet --workers 1 --timeout 120 "app:create_app()"\n\
' > /app/start.sh && chmod +x /app/start.sh
# Verify startup script exists and is accessible
RUN ls -la /app/start.sh && \
head -1 /app/start.sh
# Expose port
EXPOSE 8080

View File

@@ -1,7 +0,0 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8080
CMD ["python", "app.py"]

View File

@@ -172,7 +172,7 @@ DATABASE_URL=postgresql://user:password@host:port/database
### Optional for All Images
```bash
TZ=Europe/Brussels
TZ=Europe/Rome
CURRENCY=EUR
ROUNDING_MINUTES=1
SINGLE_ACTIVE_TIMER=true

135
PROJECT_STRUCTURE.md Normal file
View File

@@ -0,0 +1,135 @@
# TimeTracker Project Structure
This document provides an overview of the cleaned up TimeTracker project structure after removing unnecessary files and consolidating the codebase.
## 📁 Root Directory Structure
```
TimeTracker/
├── 📁 app/ # Main Flask application
├── 📁 assets/ # Static assets (images, screenshots)
├── 📁 docker/ # Docker configuration files
├── 📁 templates/ # Additional template files
├── 📁 tests/ # Test suite
├── 📁 .github/ # GitHub workflows and configurations
├── 📁 logs/ # Application logs (with .gitkeep)
├── 🐳 Dockerfile # Main Dockerfile
├── 🐳 Dockerfile.simple # Simple container Dockerfile
├── 📄 docker-compose.simple.yml # Simple container setup
├── 📄 docker-compose.public.yml # Public container setup
├── 📄 requirements.txt # Python dependencies
├── 📄 app.py # Application entry point
├── 📄 env.example # Environment variables template
├── 📄 README.md # Main project documentation
├── 📄 PROJECT_STRUCTURE.md # This file
├── 📄 CONTRIBUTING.md # Contribution guidelines
├── 📄 CODE_OF_CONDUCT.md # Community code of conduct
├── 📄 LICENSE # GPL v3 license
├── 📄 GITHUB_WORKFLOW_IMAGES.md # Docker image workflow docs
├── 📄 DOCKER_PUBLIC_SETUP.md # Public container setup docs
├── 📄 REQUIREMENTS.md # Detailed requirements documentation
├── 📄 deploy-public.bat # Windows deployment script
└── 📄 deploy-public.sh # Linux/Mac deployment script
```
## 🧹 Cleanup Summary
### Files Removed
- `DATABASE_INIT_FIX_FINAL_README.md` - Database fix documentation (resolved)
- `DATABASE_INIT_FIX_README.md` - Database fix documentation (resolved)
- `TIMEZONE_FIX_README.md` - Timezone fix documentation (resolved)
- `Dockerfile.test` - Test Dockerfile (not needed)
- `Dockerfile.combined` - Combined Dockerfile (consolidated)
- `docker-compose.yml` - Old compose file (replaced)
- `deploy.sh` - Old deployment script (replaced)
- `index.html` - Unused HTML file
- `_config.yml` - Unused config file
- `logs/timetracker.log` - Large log file (not in version control)
- `.pytest_cache/` - Python test cache directory
### Files Consolidated
- **Dockerfiles**: Now only `Dockerfile` and `Dockerfile.simple`
- **Docker Compose**: Now only `docker-compose.simple.yml` and `docker-compose.public.yml`
- **Deployment**: Now only `deploy-public.bat` and `deploy-public.sh`
## 🏗️ Core Components
### Application (`app/`)
- **Models**: Database models for users, projects, time entries, and settings
- **Routes**: API endpoints and web routes
- **Templates**: Jinja2 HTML templates
- **Utils**: Utility functions including timezone management
- **Config**: Application configuration
### Docker Configuration (`docker/`)
- **Startup scripts**: Container initialization and database setup
- **Database scripts**: SQL-based database initialization
- **Configuration files**: Docker-specific configurations
### Templates (`templates/`)
- **Admin templates**: User management and system settings
- **Error templates**: Error page templates
- **Main templates**: Core application templates
- **Project templates**: Project management templates
- **Report templates**: Reporting and analytics templates
- **Timer templates**: Time tracking interface templates
### Assets (`assets/`)
- **Screenshots**: Application screenshots for documentation
- **Images**: Logo and other static images
## 🚀 Deployment Options
### 1. Simple Container (Recommended)
- **File**: `docker-compose.simple.yml`
- **Dockerfile**: `Dockerfile.simple`
- **Features**: All-in-one with PostgreSQL database
- **Use case**: Production deployment
### 2. Public Container
- **File**: `docker-compose.public.yml`
- **Dockerfile**: `Dockerfile`
- **Features**: External database configuration
- **Use case**: Development and testing
## 📚 Documentation Files
- **README.md**: Main project documentation and quick start guide
- **PROJECT_STRUCTURE.md**: This file - project structure overview
- **CONTRIBUTING.md**: How to contribute to the project
- **CODE_OF_CONDUCT.md**: Community behavior guidelines
- **GITHUB_WORKFLOW_IMAGES.md**: Docker image build workflow
- **DOCKER_PUBLIC_SETUP.md**: Public container setup guide
- **REQUIREMENTS.md**: Detailed system requirements
## 🔧 Development Files
- **requirements.txt**: Python package dependencies
- **app.py**: Flask application entry point
- **env.example**: Environment variables template
- **tests/**: Test suite and test files
## 📝 Key Improvements Made
1. **Removed Duplicate Files**: Eliminated redundant documentation and configuration files
2. **Consolidated Docker Setup**: Streamlined to two main container types
3. **Updated Documentation**: README now reflects current project state
4. **Timezone Support**: Added comprehensive timezone management (100+ options)
5. **Clean Structure**: Organized project for better maintainability
## 🎯 Getting Started
1. **Choose deployment type**: Simple container (recommended) or public container
2. **Follow README.md**: Complete setup instructions
3. **Use appropriate compose file**: `docker-compose.simple.yml` or `docker-compose.public.yml`
4. **Configure timezone**: Access admin settings to set your local timezone
## 🔍 File Purposes
- **`.gitkeep` files**: Ensure empty directories are tracked in Git
- **`.github/`**: GitHub Actions workflows for automated builds
- **`logs/`**: Application log storage (cleaned up, only `.gitkeep` remains)
- **`LICENSE`**: GPL v3 open source license
- **`.gitignore`**: Git ignore patterns for temporary files
This cleaned up structure provides a more maintainable and focused codebase while preserving all essential functionality and documentation.

View File

@@ -31,6 +31,7 @@ A robust, self-hosted time tracking application designed for teams and freelance
- **Manual Entry**: Log time with start/end dates and project selection
- **Idle Detection**: Automatic timeout for inactive sessions
- **Multiple Projects**: Track time across different clients and projects
- **Timezone Support**: Full timezone awareness with configurable local time display
### 👥 User Management
- **Role-Based Access**: Admin and regular user roles
@@ -53,9 +54,10 @@ A robust, self-hosted time tracking application designed for teams and freelance
### 🚀 Technical Features
- **Responsive Design**: Works on desktop, tablet, and mobile
- **HTMX Integration**: Dynamic interactions without JavaScript complexity
- **SQLite Database**: Lightweight, file-based storage
- **PostgreSQL Database**: Robust database with automatic initialization
- **Docker Ready**: Easy deployment and scaling
- **RESTful API**: Programmatic access to time data
- **Timezone Management**: Comprehensive timezone support with 100+ options
## 🖼️ Screenshots
@@ -110,6 +112,7 @@ The **simple container** is an all-in-one solution that includes both the TimeTr
-**Auto-initialization**: Database automatically created and configured
-**Persistent storage**: Data survives container restarts
-**Production ready**: Optimized for deployment
-**Timezone support**: Full timezone management with 100+ options
**Run with docker-compose:**
```bash
@@ -134,7 +137,7 @@ docker run -d \
**Environment Variables:**
- `FORCE_REINIT`: Set to `true` to reinitialize database schema (default: `false`)
- `TZ`: Timezone (default: `Europe/Brussels`)
- `TZ`: Timezone (default: `Europe/Rome`)
#### 2. Public Container (Development/Testing)
@@ -209,6 +212,7 @@ docker-compose -f docker-compose.simple.yml up -d
- ✅ **Production ready** - Optimized for deployment
- ✅ **Persistent storage** - Data survives restarts
- ✅ **Simple setup** - One command deployment
- ✅ **Timezone support** - 100+ timezone options with automatic DST handling
**Default credentials:**
- **Username**: `admin`
@@ -236,7 +240,7 @@ The container automatically:
2. **Configure environment variables:**
```bash
cp .env.example .env
cp env.example .env
# Edit .env with your database settings
```
@@ -286,7 +290,7 @@ The container automatically:
| Variable | Description | Default |
|----------|-------------|---------|
| `FORCE_REINIT` | Reinitialize database schema | `false` |
| `TZ` | Timezone | `Europe/Brussels` |
| `TZ` | Timezone | `Europe/Rome` |
#### Public Container Environment Variables
@@ -294,7 +298,7 @@ The container automatically:
|----------|-------------|---------|
| `DATABASE_URL` | Database connection string | - |
| `SECRET_KEY` | Flask secret key | - |
| `TZ` | Timezone | `Europe/Brussels` |
| `TZ` | Timezone | `Europe/Rome` |
| `CURRENCY` | Currency for billing | `EUR` |
| `ROUNDING_MINUTES` | Time rounding in minutes | `1` |
| `SINGLE_ACTIVE_TIMER` | Allow only one active timer per user | `true` |
@@ -333,15 +337,23 @@ The container automatically:
3. **Assign users** to projects
4. **Track project status** and **completion**
### Timezone Configuration
1. **Access Admin Settings** (admin users only)
2. **Select your timezone** from 100+ available options
3. **View real-time preview** of current time in selected timezone
4. **Save settings** to apply timezone changes application-wide
## 🏗️ Architecture
### Technology Stack
- **Backend**: Flask with SQLAlchemy ORM
- **Database**: SQLite (with upgrade path to PostgreSQL)
- **Database**: PostgreSQL with automatic initialization
- **Frontend**: Server-rendered templates with HTMX
- **Real-time**: WebSocket for live timer updates
- **Containerization**: Docker with docker-compose
- **Timezone**: Full timezone support with pytz
### Project Structure
@@ -355,7 +367,9 @@ TimeTracker/
│ └── config.py # Configuration settings
├── docker/ # Docker configuration
├── tests/ # Test suite
├── docker-compose.yml # Docker Compose configuration
├── docker-compose.simple.yml # Simple container setup
├── docker-compose.public.yml # Public container setup
├── Dockerfile.simple # Simple container Dockerfile
├── requirements.txt # Python dependencies
└── README.md # This file
```
@@ -367,7 +381,7 @@ TimeTracker/
- **Users**: Username-based authentication with role-based access
- **Projects**: Client projects with billing information and client management
- **Time Entries**: Manual and automatic time tracking with notes, tags, and billing support
- **Settings**: System configuration and preferences
- **Settings**: System configuration including timezone preferences
#### Database Schema
@@ -391,6 +405,7 @@ The simple container automatically creates and initializes a PostgreSQL database
- **Billing Support**: Hourly rates, billable flags, and cost calculations
- **Export Capabilities**: CSV export for reports and data backup
- **Responsive Design**: Works on desktop and mobile devices
- **Timezone Support**: Full timezone awareness with automatic DST handling
## 🛠️ Development
@@ -408,7 +423,7 @@ The simple container automatically creates and initializes a PostgreSQL database
3. **Set up environment:**
```bash
cp .env.example .env
cp env.example .env
# Edit .env with development settings
```
@@ -451,7 +466,7 @@ python -m pytest tests/test_timer.py
## 💾 Backup and Maintenance
- **Automatic backups**: Nightly SQLite database backups
- **Automatic backups**: Nightly PostgreSQL database backups
- **Manual exports**: On-demand CSV exports and full data dumps
- **Health monitoring**: Built-in health check endpoints
- **Database migrations**: Version-controlled schema changes
@@ -498,6 +513,7 @@ The GPL v3 license ensures that:
- **Database errors**: Ensure proper permissions and disk space
- **Docker issues**: Verify Docker and Docker Compose installation
- **Network access**: Check firewall settings and port configuration
- **Timezone issues**: Verify timezone settings in admin panel
## 🚀 Roadmap
@@ -515,6 +531,7 @@ The GPL v3 license ensures that:
- **v1.0.0**: Initial release with core time tracking features
- **v1.1.0**: Added comprehensive reporting and export capabilities
- **v1.2.0**: Enhanced project management and billing support
- **v1.3.0**: Added comprehensive timezone support with 100+ options
## 🙏 Acknowledgments

View File

@@ -162,7 +162,7 @@ A Python backend (Flask recommended) runs inside Docker on a Raspberry Pi. The f
### 6.4 Configurability
* `.env`/config UI for: timezone (default Europe/Brussels), currency, default rounding, allow self-register, single-active-timer, idle timeout, export delimiter.
* `.env`/config UI for: timezone (default Europe/Rome), currency, default rounding, allow self-register, single-active-timer, idle timeout, export delimiter.
### 6.5 Security
@@ -366,7 +366,7 @@ services:
image: timetracker:latest
build: .
environment:
- TZ=Europe/Brussels
- TZ=Europe/Rome
- ROUNDING_MINUTES=1
- SINGLE_ACTIVE_TIMER=true
- ALLOW_SELF_REGISTER=true

View File

@@ -1,73 +0,0 @@
# Jekyll configuration for GitHub Pages
# This file ensures proper configuration for the static site
# Site settings
title: TimeTracker
description: Self-hosted time tracking for teams and freelancers
url: "https://drytrix.github.io"
baseurl: "/TimeTracker"
# Build settings
markdown: kramdown
highlighter: rouge
permalink: pretty
# Collections
collections:
assets:
output: true
permalink: /:collection/:name
# Exclude from processing
exclude:
- README.md
- LICENSE
- CONTRIBUTING.md
- CODE_OF_CONDUCT.md
- requirements.txt
- app.py
- docker-compose.yml
- Dockerfile
- deploy.sh
- .env.example
- .gitignore
- app/
- docker/
- logs/
- tests/
- templates/
- .github/
# Include in processing
include:
- _config.yml
- index.html
- assets/
# Plugins (GitHub Pages compatible)
plugins:
- jekyll-seo-tag
- jekyll-sitemap
# SEO settings
author: TimeTracker Team
email: your-email@example.com
github_username: DRYTRIX
github_repo: TimeTracker
# Social media
social:
name: TimeTracker
links:
- https://github.com/DRYTRIX/TimeTracker
- https://github.com/DRYTRIX/TimeTracker/issues
- https://github.com/DRYTRIX/TimeTracker/discussions
# Defaults
defaults:
- scope:
path: ""
type: "pages"
values:
layout: "default"
author: "TimeTracker Team"

View File

@@ -109,6 +109,10 @@ def create_app(config=None):
from app.utils.context_processors import register_context_processors
register_context_processors(app)
# Register template filters
from app.utils.template_filters import register_template_filters
register_template_filters(app)
# Register CLI commands
from app.utils.cli import register_cli_commands
register_cli_commands(app)

View File

@@ -29,7 +29,7 @@ class Config:
)
# Application settings
TZ = os.getenv('TZ', 'Europe/Brussels')
TZ = os.getenv('TZ', 'Europe/Rome')
CURRENCY = os.getenv('CURRENCY', 'EUR')
ROUNDING_MINUTES = int(os.getenv('ROUNDING_MINUTES', 1))
SINGLE_ACTIVE_TIMER = os.getenv('SINGLE_ACTIVE_TIMER', 'true').lower() == 'true'

View File

@@ -45,7 +45,7 @@ class Project(db.Model):
db.func.sum(TimeEntry.duration_seconds)
).filter(
TimeEntry.project_id == self.id,
TimeEntry.end_utc.isnot(None)
TimeEntry.end_time.isnot(None)
).scalar() or 0
return round(total_seconds / 3600, 2)
@@ -57,7 +57,7 @@ class Project(db.Model):
db.func.sum(TimeEntry.duration_seconds)
).filter(
TimeEntry.project_id == self.id,
TimeEntry.end_utc.isnot(None),
TimeEntry.end_time.isnot(None),
TimeEntry.billable == True
).scalar() or 0
return round(total_seconds / 3600, 2)
@@ -72,18 +72,18 @@ class Project(db.Model):
def get_entries_by_user(self, user_id=None, start_date=None, end_date=None):
"""Get time entries for this project, optionally filtered by user and date range"""
from .time_entry import TimeEntry
query = self.time_entries.filter(TimeEntry.end_utc.isnot(None))
query = self.time_entries.filter(TimeEntry.end_time.isnot(None))
if user_id:
query = query.filter(TimeEntry.user_id == user_id)
if start_date:
query = query.filter(TimeEntry.start_utc >= start_date)
query = query.filter(TimeEntry.start_time >= start_date)
if end_date:
query = query.filter(TimeEntry.start_utc <= end_date)
query = query.filter(TimeEntry.start_time <= end_date)
return query.order_by(TimeEntry.start_utc.desc()).all()
return query.order_by(TimeEntry.start_time.desc()).all()
def get_user_totals(self, start_date=None, end_date=None):
"""Get total hours per user for this project"""
@@ -95,14 +95,14 @@ class Project(db.Model):
db.func.sum(TimeEntry.duration_seconds).label('total_seconds')
).join(TimeEntry).filter(
TimeEntry.project_id == self.id,
TimeEntry.end_utc.isnot(None)
TimeEntry.end_time.isnot(None)
)
if start_date:
query = query.filter(TimeEntry.start_utc >= start_date)
query = query.filter(TimeEntry.start_time >= start_date)
if end_date:
query = query.filter(TimeEntry.start_utc <= end_date)
query = query.filter(TimeEntry.start_time <= end_date)
results = query.group_by(User.username).all()

View File

@@ -8,7 +8,7 @@ class Settings(db.Model):
__tablename__ = 'settings'
id = db.Column(db.Integer, primary_key=True)
timezone = db.Column(db.String(50), default='Europe/Brussels', nullable=False)
timezone = db.Column(db.String(50), default='Europe/Rome', nullable=False)
currency = db.Column(db.String(3), default='EUR', nullable=False)
rounding_minutes = db.Column(db.Integer, default=1, nullable=False)
single_active_timer = db.Column(db.Boolean, default=True, nullable=False)

View File

@@ -1,6 +1,14 @@
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from app import db
from app.config import Config
from app.utils.timezone import utc_to_local, local_to_utc
def local_now():
"""Get current time in local timezone as naive datetime (for database storage)"""
from app.utils.timezone import get_timezone_obj
tz = get_timezone_obj()
now = datetime.now(tz)
return now.replace(tzinfo=None)
class TimeEntry(db.Model):
"""Time entry model for manual and automatic time tracking"""
@@ -10,28 +18,28 @@ class TimeEntry(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False, index=True)
start_utc = db.Column(db.DateTime, nullable=False, index=True)
end_utc = db.Column(db.DateTime, nullable=True, index=True)
start_time = db.Column(db.DateTime, nullable=False, index=True)
end_time = db.Column(db.DateTime, nullable=True, index=True)
duration_seconds = db.Column(db.Integer, nullable=True)
notes = db.Column(db.Text, nullable=True)
tags = db.Column(db.String(500), nullable=True) # Comma-separated tags
source = db.Column(db.String(20), default='manual', nullable=False) # 'manual' or 'auto'
billable = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_at = db.Column(db.DateTime, default=local_now, nullable=False)
updated_at = db.Column(db.DateTime, default=local_now, onupdate=local_now, nullable=False)
def __init__(self, user_id, project_id, start_utc, end_utc=None, notes=None, tags=None, source='manual', billable=True):
def __init__(self, user_id, project_id, start_time, end_time=None, notes=None, tags=None, source='manual', billable=True):
self.user_id = user_id
self.project_id = project_id
self.start_utc = start_utc
self.end_utc = end_utc
self.start_time = start_time
self.end_time = end_time
self.notes = notes.strip() if notes else None
self.tags = tags.strip() if tags else None
self.source = source
self.billable = billable
# Calculate duration if end time is provided
if self.end_utc:
if self.end_time:
self.calculate_duration()
def __repr__(self):
@@ -40,7 +48,7 @@ class TimeEntry(db.Model):
@property
def is_active(self):
"""Check if this is an active timer (no end time)"""
return self.end_utc is None
return self.end_time is None
@property
def duration_hours(self):
@@ -71,20 +79,30 @@ class TimeEntry(db.Model):
@property
def current_duration_seconds(self):
"""Calculate current duration for active timers"""
if self.end_utc:
if self.end_time:
return self.duration_seconds or 0
# For active timers, calculate from start time to now
duration = datetime.utcnow() - self.start_utc
# Since we store everything in local timezone, we can work with naive datetimes
# as long as we treat them as local time
# Get current time in local timezone (naive, matching database storage)
now_local = local_now()
# Calculate duration (both times are treated as local time)
duration = now_local - self.start_time
return int(duration.total_seconds())
def calculate_duration(self):
"""Calculate and set duration in seconds with rounding"""
if not self.end_utc:
if not self.end_time:
return
# Calculate raw duration
duration = self.end_utc - self.start_utc
# Since we store everything in local timezone, we can work with naive datetimes
# as long as we treat them as local time
# Calculate raw duration (both times are treated as local time)
duration = self.end_time - self.start_time
raw_seconds = int(duration.total_seconds())
# Apply rounding
@@ -97,33 +115,38 @@ class TimeEntry(db.Model):
else:
self.duration_seconds = raw_seconds
def stop_timer(self, end_utc=None):
def stop_timer(self, end_time=None):
"""Stop an active timer"""
if self.end_utc:
if self.end_time:
raise ValueError("Timer is already stopped")
self.end_utc = end_utc or datetime.utcnow()
# Use local timezone for consistency with database storage
if end_time:
self.end_time = end_time
else:
self.end_time = local_now()
self.calculate_duration()
self.updated_at = datetime.utcnow()
self.updated_at = local_now()
db.session.commit()
def update_notes(self, notes):
"""Update notes for this entry"""
self.notes = notes.strip() if notes else None
self.updated_at = datetime.utcnow()
self.updated_at = local_now()
db.session.commit()
def update_tags(self, tags):
"""Update tags for this entry"""
self.tags = tags.strip() if tags else None
self.updated_at = datetime.utcnow()
self.updated_at = local_now()
db.session.commit()
def set_billable(self, billable):
"""Set billable status"""
self.billable = billable
self.updated_at = datetime.utcnow()
self.updated_at = local_now()
db.session.commit()
def to_dict(self):
@@ -132,8 +155,8 @@ class TimeEntry(db.Model):
'id': self.id,
'user_id': self.user_id,
'project_id': self.project_id,
'start_utc': self.start_utc.isoformat() if self.start_utc else None,
'end_utc': self.end_utc.isoformat() if self.end_utc else None,
'start_time': self.start_time.isoformat() if self.start_time else None,
'end_time': self.end_time.isoformat() if self.end_time else None,
'duration_seconds': self.duration_seconds,
'duration_hours': self.duration_hours,
'duration_formatted': self.duration_formatted,
@@ -152,23 +175,23 @@ class TimeEntry(db.Model):
@classmethod
def get_active_timers(cls):
"""Get all active timers"""
return cls.query.filter_by(end_utc=None).all()
return cls.query.filter_by(end_time=None).all()
@classmethod
def get_user_active_timer(cls, user_id):
"""Get active timer for a specific user"""
return cls.query.filter_by(user_id=user_id, end_utc=None).first()
return cls.query.filter_by(user_id=user_id, end_time=None).first()
@classmethod
def get_entries_for_period(cls, start_date=None, end_date=None, user_id=None, project_id=None):
"""Get time entries for a specific period with optional filters"""
query = cls.query.filter(cls.end_utc.isnot(None))
query = cls.query.filter(cls.end_time.isnot(None))
if start_date:
query = query.filter(cls.start_utc >= start_date)
query = query.filter(cls.start_time >= start_date)
if end_date:
query = query.filter(cls.start_utc <= end_date)
query = query.filter(cls.start_time <= end_date)
if user_id:
query = query.filter(cls.user_id == user_id)
@@ -176,7 +199,7 @@ class TimeEntry(db.Model):
if project_id:
query = query.filter(cls.project_id == project_id)
return query.order_by(cls.start_utc.desc()).all()
return query.order_by(cls.start_time.desc()).all()
@classmethod
def get_total_hours_for_period(cls, start_date=None, end_date=None, user_id=None, project_id=None, billable_only=False):
@@ -184,10 +207,10 @@ class TimeEntry(db.Model):
query = db.session.query(db.func.sum(cls.duration_seconds))
if start_date:
query = query.filter(cls.start_utc >= start_date)
query = query.filter(cls.start_time >= start_date)
if end_date:
query = query.filter(cls.start_utc <= end_date)
query = query.filter(cls.start_time <= end_date)
if user_id:
query = query.filter(cls.user_id == user_id)

View File

@@ -36,7 +36,7 @@ class User(UserMixin, db.Model):
from .time_entry import TimeEntry
return TimeEntry.query.filter_by(
user_id=self.id,
end_utc=None
end_time=None
).first()
@property
@@ -47,7 +47,7 @@ class User(UserMixin, db.Model):
db.func.sum(TimeEntry.duration_seconds)
).filter(
TimeEntry.user_id == self.id,
TimeEntry.end_utc.isnot(None)
TimeEntry.end_time.isnot(None)
).scalar() or 0
return round(total_seconds / 3600, 2)
@@ -55,9 +55,9 @@ class User(UserMixin, db.Model):
"""Get recent time entries for this user"""
from .time_entry import TimeEntry
return self.time_entries.filter(
TimeEntry.end_utc.isnot(None)
TimeEntry.end_time.isnot(None)
).order_by(
TimeEntry.start_utc.desc()
TimeEntry.start_time.desc()
).limit(limit).all()
def update_last_login(self):

View File

@@ -28,12 +28,12 @@ def admin_dashboard():
active_users = User.query.filter_by(is_active=True).count()
total_projects = Project.query.count()
active_projects = Project.query.filter_by(status='active').count()
total_entries = TimeEntry.query.filter(TimeEntry.end_utc.isnot(None)).count()
active_timers = TimeEntry.query.filter_by(end_utc=None).count()
total_entries = TimeEntry.query.filter(TimeEntry.end_time.isnot(None)).count()
active_timers = TimeEntry.query.filter_by(end_time=None).count()
# Get recent activity
recent_entries = TimeEntry.query.filter(
TimeEntry.end_utc.isnot(None)
TimeEntry.end_time.isnot(None)
).order_by(
TimeEntry.created_at.desc()
).limit(10).all()
@@ -163,8 +163,17 @@ def settings():
settings_obj = Settings.get_settings()
if request.method == 'POST':
# Validate timezone
timezone = request.form.get('timezone', 'Europe/Rome')
try:
import pytz
pytz.timezone(timezone) # This will raise an exception if timezone is invalid
except pytz.exceptions.UnknownTimeZoneError:
flash(f'Invalid timezone: {timezone}', 'error')
return render_template('admin/settings.html', settings=settings_obj)
# Update settings
settings_obj.timezone = request.form.get('timezone', 'Europe/Brussels')
settings_obj.timezone = timezone
settings_obj.currency = request.form.get('currency', 'EUR')
settings_obj.rounding_minutes = int(request.form.get('rounding_minutes', 1))
settings_obj.single_active_timer = request.form.get('single_active_timer') == 'on'
@@ -199,7 +208,7 @@ def system_info():
total_users = User.query.count()
total_projects = Project.query.count()
total_entries = TimeEntry.query.count()
active_timers = TimeEntry.query.filter_by(end_utc=None).count()
active_timers = TimeEntry.query.filter_by(end_time=None).count()
# Get database size
db_size_bytes = 0

View File

@@ -25,7 +25,7 @@ def timer_status():
'id': active_timer.id,
'project_name': active_timer.project.name,
'project_id': active_timer.project_id,
'start_time': active_timer.start_utc.isoformat(),
'start_time': active_timer.start_time.isoformat(),
'current_duration': active_timer.current_duration_seconds,
'duration_formatted': active_timer.duration_formatted
}
@@ -56,10 +56,11 @@ def api_start_timer():
return jsonify({'error': 'User already has an active timer'}), 400
# Create new timer
from app.models.time_entry import local_now
new_timer = TimeEntry(
user_id=current_user.id,
project_id=project_id,
start_utc=datetime.utcnow(),
start_time=local_now(),
source='auto'
)
@@ -71,7 +72,7 @@ def api_start_timer():
'user_id': current_user.id,
'timer_id': new_timer.id,
'project_name': project.name,
'start_time': new_timer.start_utc.isoformat()
'start_time': new_timer.start_time.isoformat()
})
return jsonify({
@@ -114,7 +115,7 @@ def get_entries():
user_id = request.args.get('user_id', type=int)
project_id = request.args.get('project_id', type=int)
query = TimeEntry.query.filter(TimeEntry.end_utc.isnot(None))
query = TimeEntry.query.filter(TimeEntry.end_time.isnot(None))
# Filter by user (if admin or own entries)
if user_id and current_user.is_admin:
@@ -126,7 +127,7 @@ def get_entries():
if project_id:
query = query.filter(TimeEntry.project_id == project_id)
entries = query.order_by(TimeEntry.start_utc.desc()).paginate(
entries = query.order_by(TimeEntry.start_time.desc()).paginate(
page=page,
per_page=per_page,
error_out=False

View File

@@ -93,12 +93,12 @@ def search():
# Search in time entries
entries = TimeEntry.query.filter(
TimeEntry.user_id == current_user.id,
TimeEntry.end_utc.isnot(None),
TimeEntry.end_time.isnot(None),
db.or_(
TimeEntry.notes.contains(query),
TimeEntry.tags.contains(query)
)
).order_by(TimeEntry.start_utc.desc()).paginate(
).order_by(TimeEntry.start_time.desc()).paginate(
page=page,
per_page=20,
error_out=False

View File

@@ -111,9 +111,9 @@ def view_project(project_id):
# Get time entries for this project
page = request.args.get('page', 1, type=int)
entries_pagination = project.time_entries.filter(
TimeEntry.end_utc.isnot(None)
TimeEntry.end_time.isnot(None)
).order_by(
TimeEntry.start_utc.desc()
TimeEntry.start_time.desc()
).paginate(
page=page,
per_page=50,

View File

@@ -15,14 +15,14 @@ def reports():
"""Main reports page"""
# Aggregate totals (scope by user unless admin)
totals_query = db.session.query(db.func.sum(TimeEntry.duration_seconds)).filter(
TimeEntry.end_utc.isnot(None)
TimeEntry.end_time.isnot(None)
)
billable_query = db.session.query(db.func.sum(TimeEntry.duration_seconds)).filter(
TimeEntry.end_utc.isnot(None),
TimeEntry.end_time.isnot(None),
TimeEntry.billable == True
)
entries_query = TimeEntry.query.filter(TimeEntry.end_utc.isnot(None))
entries_query = TimeEntry.query.filter(TimeEntry.end_time.isnot(None))
if not current_user.is_admin:
totals_query = totals_query.filter(TimeEntry.user_id == current_user.id)
@@ -39,7 +39,7 @@ def reports():
'total_users': User.query.filter_by(is_active=True).count(),
}
recent_entries = entries_query.order_by(TimeEntry.start_utc.desc()).limit(10).all()
recent_entries = entries_query.order_by(TimeEntry.start_time.desc()).limit(10).all()
return render_template('reports/index.html', summary=summary, recent_entries=recent_entries)
@@ -71,9 +71,9 @@ def project_report():
# Get time entries
query = TimeEntry.query.filter(
TimeEntry.end_utc.isnot(None),
TimeEntry.start_utc >= start_dt,
TimeEntry.start_utc <= end_dt
TimeEntry.end_time.isnot(None),
TimeEntry.start_time >= start_dt,
TimeEntry.start_time <= end_dt
)
if project_id:
@@ -82,7 +82,7 @@ def project_report():
if user_id:
query = query.filter(TimeEntry.user_id == user_id)
entries = query.order_by(TimeEntry.start_utc.desc()).all()
entries = query.order_by(TimeEntry.start_time.desc()).all()
# Aggregate by project for template expectations
projects_map = {}
@@ -179,9 +179,9 @@ def user_report():
# Get time entries
query = TimeEntry.query.filter(
TimeEntry.end_utc.isnot(None),
TimeEntry.start_utc >= start_dt,
TimeEntry.start_utc <= end_dt
TimeEntry.end_time.isnot(None),
TimeEntry.start_time >= start_dt,
TimeEntry.start_time <= end_dt
)
if user_id:
@@ -190,7 +190,7 @@ def user_report():
if project_id:
query = query.filter(TimeEntry.project_id == project_id)
entries = query.order_by(TimeEntry.start_utc.desc()).all()
entries = query.order_by(TimeEntry.start_time.desc()).all()
# Calculate totals
total_hours = sum(entry.duration_hours for entry in entries)
@@ -259,9 +259,9 @@ def export_csv():
# Get time entries
query = TimeEntry.query.filter(
TimeEntry.end_utc.isnot(None),
TimeEntry.start_utc >= start_dt,
TimeEntry.start_utc <= end_dt
TimeEntry.end_time.isnot(None),
TimeEntry.start_time >= start_dt,
TimeEntry.start_time <= end_dt
)
if user_id:
@@ -270,7 +270,7 @@ def export_csv():
if project_id:
query = query.filter(TimeEntry.project_id == project_id)
entries = query.order_by(TimeEntry.start_utc.desc()).all()
entries = query.order_by(TimeEntry.start_time.desc()).all()
# Get settings for delimiter
settings = Settings.get_settings()
@@ -294,8 +294,8 @@ def export_csv():
entry.user.username,
entry.project.name,
entry.project.client,
entry.start_utc.isoformat(),
entry.end_utc.isoformat() if entry.end_utc else '',
entry.start_time.isoformat(),
entry.end_time.isoformat() if entry.end_time else '',
entry.duration_hours,
entry.duration_formatted,
entry.notes or '',

View File

@@ -2,6 +2,7 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash,
from flask_login import login_required, current_user
from app import db, socketio
from app.models import User, Project, TimeEntry, Settings
from app.utils.timezone import parse_local_datetime
from datetime import datetime
import json
@@ -36,10 +37,11 @@ def start_timer():
return redirect(url_for('main.dashboard'))
# Create new timer
from app.models.time_entry import local_now
new_timer = TimeEntry(
user_id=current_user.id,
project_id=project_id,
start_utc=datetime.utcnow(),
start_time=local_now(),
source='auto'
)
@@ -51,7 +53,7 @@ def start_timer():
'user_id': current_user.id,
'timer_id': new_timer.id,
'project_name': project.name,
'start_time': new_timer.start_utc.isoformat()
'start_time': new_timer.start_time.isoformat()
})
flash(f'Timer started for {project.name}', 'success')
@@ -97,7 +99,7 @@ def timer_status():
'timer': {
'id': active_timer.id,
'project_name': active_timer.project.name,
'start_time': active_timer.start_utc.isoformat(),
'start_time': active_timer.start_time.isoformat(),
'current_duration': active_timer.current_duration_seconds,
'duration_formatted': active_timer.duration_formatted
}
@@ -176,16 +178,16 @@ def manual_entry():
flash('Invalid project selected', 'error')
return render_template('timer/manual_entry.html', projects=active_projects)
# Parse datetime
# Parse datetime with timezone awareness
try:
start_utc = datetime.strptime(f'{start_date} {start_time}', '%Y-%m-%d %H:%M')
end_utc = datetime.strptime(f'{end_date} {end_time}', '%Y-%m-%d %H:%M')
start_time_parsed = parse_local_datetime(start_date, start_time)
end_time_parsed = parse_local_datetime(end_date, end_time)
except ValueError:
flash('Invalid date/time format', 'error')
return render_template('timer/manual_entry.html', projects=active_projects)
# Validate time range
if end_utc <= start_utc:
if end_time_parsed <= start_time_parsed:
flash('End time must be after start time', 'error')
return render_template('timer/manual_entry.html', projects=active_projects)
@@ -193,8 +195,8 @@ def manual_entry():
entry = TimeEntry(
user_id=current_user.id,
project_id=project_id,
start_utc=start_utc,
end_utc=end_utc,
start_time=start_time_parsed,
end_time=end_time_parsed,
notes=notes,
tags=tags,
source='manual',

View File

@@ -61,7 +61,7 @@
{{ active_timer.duration_formatted }}
</div>
<small class="text-muted">
Started at {{ active_timer.start_utc.strftime('%H:%M') }}
Started at {{ active_timer.start_time.strftime('%H:%M') }}
</small>
</div>
</div>
@@ -254,8 +254,8 @@
</td>
<td>
<div class="d-flex flex-column">
<span class="fw-semibold">{{ entry.start_utc.strftime('%b %d') }}</span>
<small class="text-muted">{{ entry.start_utc.strftime('%H:%M') }}</small>
<span class="fw-semibold">{{ entry.start_time.strftime('%b %d') }}</span>
<small class="text-muted">{{ entry.start_time.strftime('%H:%M') }}</small>
</div>
</td>
<td>

View File

@@ -45,8 +45,8 @@
<td>
<a href="{{ url_for('projects.view_project', project_id=entry.project.id) }}">{{ entry.project.name }}</a>
</td>
<td>{{ entry.start_utc.strftime('%Y-%m-%d %H:%M') }}</td>
<td>{{ entry.end_utc.strftime('%Y-%m-%d %H:%M') if entry.end_utc else '-' }}</td>
<td>{{ entry.start_time.strftime('%Y-%m-%d %H:%M') }}</td>
<td>{{ entry.end_time.strftime('%Y-%m-%d %H:%M') if entry.end_time else '-' }}</td>
<td><strong>{{ entry.duration_formatted }}</strong></td>
<td>
{% if entry.notes %}

View File

@@ -86,7 +86,7 @@ def register_cli_commands(app):
cutoff_date = datetime.utcnow() - timedelta(days=days)
old_entries = TimeEntry.query.filter(
TimeEntry.end_utc < cutoff_date
TimeEntry.end_time < cutoff_date
).all()
if not old_entries:
@@ -111,8 +111,8 @@ def register_cli_commands(app):
total_projects = Project.query.count()
active_projects = Project.query.filter_by(status='active').count()
total_entries = TimeEntry.query.count()
completed_entries = TimeEntry.query.filter(TimeEntry.end_utc.isnot(None)).count()
active_timers = TimeEntry.query.filter_by(end_utc=None).count()
completed_entries = TimeEntry.query.filter(TimeEntry.end_time.isnot(None)).count()
active_timers = TimeEntry.query.filter_by(end_time=None).count()
click.echo("Database Statistics:")
click.echo(f" Users: {total_users} (active: {active_users})")
@@ -123,7 +123,7 @@ def register_cli_commands(app):
total_hours = db.session.query(
db.func.sum(TimeEntry.duration_seconds)
).filter(
TimeEntry.end_utc.isnot(None)
TimeEntry.end_time.isnot(None)
).scalar() or 0
total_hours = round(total_hours / 3600, 2)

View File

@@ -1,5 +1,6 @@
from flask import g, request
from app.models import Settings
from app.utils.timezone import get_timezone_offset_for_timezone
def register_context_processors(app):
"""Register context processors for the application"""
@@ -8,26 +9,48 @@ def register_context_processors(app):
def inject_settings():
"""Inject settings into all templates"""
try:
settings = Settings.get_settings()
return {
'settings': settings,
'currency': settings.currency,
'timezone': settings.timezone
}
except:
# Return defaults if settings not available
return {
'settings': None,
'currency': 'EUR',
'timezone': 'Europe/Brussels'
}
from app import db
# Check if we have an active database session
if db.session.is_active:
settings = Settings.get_settings()
return {
'settings': settings,
'currency': settings.currency,
'timezone': settings.timezone
}
except Exception as e:
# Log the error but continue with defaults
print(f"Warning: Could not inject settings: {e}")
pass
# Return defaults if settings not available
return {
'settings': None,
'currency': 'EUR',
'timezone': 'Europe/Rome'
}
@app.context_processor
def inject_globals():
"""Inject global variables into all templates"""
try:
from app import db
# Check if we have an active database session
if db.session.is_active:
settings = Settings.get_settings()
timezone_name = settings.timezone if settings else 'Europe/Rome'
else:
timezone_name = 'Europe/Rome'
except Exception as e:
# Log the error but continue with defaults
print(f"Warning: Could not inject globals: {e}")
timezone_name = 'Europe/Rome'
return {
'app_name': 'Time Tracker',
'app_version': '1.0.0'
'app_version': '1.0.0',
'timezone': timezone_name,
'timezone_offset': get_timezone_offset_for_timezone(timezone_name)
}
@app.before_request

View File

@@ -0,0 +1,33 @@
from flask import Blueprint
from app.utils.timezone import utc_to_local, format_local_datetime
def register_template_filters(app):
"""Register custom template filters for the application"""
@app.template_filter('local_datetime')
def local_datetime_filter(utc_dt, format_str='%Y-%m-%d %H:%M'):
"""Convert UTC datetime to local timezone for display"""
if utc_dt is None:
return ""
return format_local_datetime(utc_dt, format_str)
@app.template_filter('local_date')
def local_date_filter(utc_dt):
"""Convert UTC datetime to local date only"""
if utc_dt is None:
return ""
return format_local_datetime(utc_dt, '%Y-%m-%d')
@app.template_filter('local_time')
def local_time_filter(utc_dt):
"""Convert UTC datetime to local time only"""
if utc_dt is None:
return ""
return format_local_datetime(utc_dt, '%H:%M')
@app.template_filter('local_datetime_short')
def local_datetime_short_filter(utc_dt):
"""Convert UTC datetime to local timezone in short format"""
if utc_dt is None:
return ""
return format_local_datetime(utc_dt, '%m/%d %H:%M')

113
app/utils/timezone.py Normal file
View File

@@ -0,0 +1,113 @@
import os
import pytz
from datetime import datetime, timezone
from flask import current_app
def get_app_timezone():
"""Get the application's configured timezone from database settings or environment"""
try:
# Try to get timezone from database settings first
from app.models import Settings
from app import db
# Check if we have a database connection
if db.session.is_active:
try:
settings = Settings.get_settings()
if settings and settings.timezone:
return settings.timezone
except Exception as e:
# Log the error but continue with fallback
print(f"Warning: Could not get timezone from database: {e}")
pass
except Exception as e:
# If database is not available or settings don't exist, fall back to environment
print(f"Warning: Database not available for timezone: {e}")
pass
# Fallback to environment variable
return os.getenv('TZ', 'Europe/Rome')
def get_timezone_obj():
"""Get timezone object for the configured timezone"""
tz_name = get_app_timezone()
try:
return pytz.timezone(tz_name)
except pytz.exceptions.UnknownTimeZoneError:
# Fallback to UTC if timezone is invalid
return pytz.UTC
def now_in_app_timezone():
"""Get current time in the application's timezone"""
tz = get_timezone_obj()
utc_now = datetime.now(timezone.utc)
return utc_now.astimezone(tz)
def utc_to_local(utc_dt):
"""Convert UTC datetime to local application timezone"""
if utc_dt is None:
return None
# If datetime is naive (no timezone), assume it's UTC
if utc_dt.tzinfo is None:
utc_dt = utc_dt.replace(tzinfo=timezone.utc)
tz = get_timezone_obj()
return utc_dt.astimezone(tz)
def local_to_utc(local_dt):
"""Convert local datetime to UTC"""
if local_dt is None:
return None
# If datetime is naive, assume it's in the application timezone
if local_dt.tzinfo is None:
tz = get_timezone_obj()
local_dt = tz.localize(local_dt)
return local_dt.astimezone(timezone.utc)
def parse_local_datetime(date_str, time_str):
"""Parse date and time strings in local timezone"""
try:
# Combine date and time
datetime_str = f'{date_str} {time_str}'
# Parse as naive datetime (assumed to be in local timezone)
naive_dt = datetime.strptime(datetime_str, '%Y-%m-%d %H:%M')
# Localize to application timezone
tz = get_timezone_obj()
local_dt = tz.localize(naive_dt)
# Convert to UTC for storage
return local_dt.astimezone(timezone.utc)
except ValueError as e:
raise ValueError(f"Invalid date/time format: {e}")
def format_local_datetime(utc_dt, format_str='%Y-%m-%d %H:%M'):
"""Format UTC datetime in local timezone"""
if utc_dt is None:
return ""
local_dt = utc_to_local(utc_dt)
return local_dt.strftime(format_str)
def get_timezone_offset():
"""Get current timezone offset from UTC in hours"""
tz = get_timezone_obj()
now = datetime.now(timezone.utc)
local_now = now.astimezone(tz)
offset = local_now.utcoffset()
return offset.total_seconds() / 3600 if offset else 0
def get_timezone_offset_for_timezone(tz_name):
"""Get timezone offset for a specific timezone name"""
try:
tz = pytz.timezone(tz_name)
now = datetime.now(timezone.utc)
local_now = now.astimezone(tz)
offset = local_now.utcoffset()
return offset.total_seconds() / 3600 if offset else 0
except pytz.exceptions.UnknownTimeZoneError:
return 0

100
deploy.sh
View File

@@ -1,100 +0,0 @@
#!/bin/bash
# Time Tracker Deployment Script for Raspberry Pi
# This script sets up the Time Tracker application on a Raspberry Pi
set -e
echo "🚀 Time Tracker Deployment Script"
echo "=================================="
# Check if running on Raspberry Pi
if ! grep -q "Raspberry Pi" /proc/cpuinfo 2>/dev/null; then
echo "⚠️ Warning: This script is designed for Raspberry Pi"
echo " It may work on other systems but is not tested"
fi
# Check if Docker is installed
if ! command -v docker &> /dev/null; then
echo "❌ Docker is not installed. Please install Docker first:"
echo " curl -fsSL https://get.docker.com -o get-docker.sh"
echo " sudo sh get-docker.sh"
echo " sudo usermod -aG docker $USER"
exit 1
fi
# Check if Docker Compose is installed
if ! command -v docker-compose &> /dev/null; then
echo "❌ Docker Compose is not installed. Please install Docker Compose first:"
echo " sudo apt-get update"
echo " sudo apt-get install docker-compose-plugin"
exit 1
fi
echo "✅ Docker and Docker Compose are installed"
# Create necessary directories
echo "📁 Creating directories..."
mkdir -p data logs backups
# Copy environment file if it doesn't exist
if [ ! -f .env ]; then
echo "📝 Creating .env file from template..."
cp env.example .env
echo "⚠️ Please edit .env file with your configuration before starting"
echo " Key settings to review:"
echo " - SECRET_KEY: Change this to a secure random string"
echo " - ADMIN_USERNAMES: Set your admin usernames"
echo " - TZ: Set your timezone"
echo " - CURRENCY: Set your currency"
else
echo "✅ .env file already exists"
fi
# Build and start the application
echo "🔨 Building Docker image..."
docker-compose build
echo "🚀 Starting Time Tracker..."
docker-compose up -d
# Wait for application to start
echo "⏳ Waiting for application to start..."
sleep 10
# Check if application is running
if curl -f http://localhost:8080/_health > /dev/null 2>&1; then
echo "✅ Time Tracker is running successfully!"
echo ""
echo "🌐 Access the application at:"
echo " http://$(hostname -I | awk '{print $1}'):8080"
echo ""
echo "📋 Next steps:"
echo " 1. Open the application in your browser"
echo " 2. Log in with your admin username"
echo " 3. Create your first project"
echo " 4. Start tracking time!"
echo ""
echo "🔧 Useful commands:"
echo " View logs: docker-compose logs -f"
echo " Stop app: docker-compose down"
echo " Restart: docker-compose restart"
echo " Update: git pull && docker-compose up -d --build"
else
echo "❌ Application failed to start. Check logs with:"
echo " docker-compose logs"
exit 1
fi
# Optional: Enable TLS with reverse proxy
read -p "🔒 Enable HTTPS with reverse proxy? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "🔒 Starting with TLS support..."
docker-compose --profile tls up -d
echo "✅ HTTPS enabled! Access at:"
echo " https://$(hostname -I | awk '{print $1}')"
fi
echo ""
echo "🎉 Deployment complete!"

View File

@@ -3,7 +3,7 @@ services:
image: ghcr.io/${GITHUB_REPOSITORY:-drytrix/timetracker}:latest
container_name: timetracker-app
environment:
- TZ=${TZ:-Europe/Brussels}
- TZ=${TZ:-Europe/Rome}
- CURRENCY=${CURRENCY:-EUR}
- ROUNDING_MINUTES=${ROUNDING_MINUTES:-1}
- SINGLE_ACTIVE_TIMER=${SINGLE_ACTIVE_TIMER:-true}
@@ -35,7 +35,7 @@ services:
- POSTGRES_DB=${POSTGRES_DB:-timetracker}
- POSTGRES_USER=${POSTGRES_USER:-timetracker}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-timetracker}
- TZ=${TZ:-Europe/Brussels}
- TZ=${TZ:-Europe/Rome}
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:

View File

@@ -7,7 +7,7 @@ services:
dockerfile: Dockerfile.simple
container_name: timetracker-simple
environment:
- TZ=${TZ:-Europe/Brussels}
- TZ=${TZ:-Europe/Rome}
- CURRENCY=${CURRENCY:-EUR}
- ROUNDING_MINUTES=${ROUNDING_MINUTES:-1}
- SINGLE_ACTIVE_TIMER=${SINGLE_ACTIVE_TIMER:-true}

View File

@@ -1,76 +0,0 @@
services:
app:
build: .
container_name: timetracker-app
environment:
- TZ=${TZ:-Europe/Brussels}
- CURRENCY=${CURRENCY:-EUR}
- ROUNDING_MINUTES=${ROUNDING_MINUTES:-1}
- SINGLE_ACTIVE_TIMER=${SINGLE_ACTIVE_TIMER:-true}
- ALLOW_SELF_REGISTER=${ALLOW_SELF_REGISTER:-true}
- IDLE_TIMEOUT_MINUTES=${IDLE_TIMEOUT_MINUTES:-30}
- ADMIN_USERNAMES=${ADMIN_USERNAMES:-admin}
- SECRET_KEY=${SECRET_KEY:-your-secret-key-change-this}
- DATABASE_URL=postgresql+psycopg2://timetracker:timetracker@db:5432/timetracker
- LOG_FILE=/app/logs/timetracker.log
ports:
- "8080:8080"
volumes:
- app_data:/data
- ./logs:/app/logs
depends_on:
db:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/_health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
db:
image: postgres:16-alpine
container_name: timetracker-db
environment:
- POSTGRES_DB=${POSTGRES_DB:-timetracker}
- POSTGRES_USER=${POSTGRES_USER:-timetracker}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-timetracker}
- TZ=${TZ:-Europe/Brussels}
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
# Optional reverse proxy for TLS on LAN
proxy:
image: caddy:2-alpine
container_name: timetracker-proxy
volumes:
- ./docker/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
ports:
- "80:80"
- "443:443"
depends_on:
app:
condition: service_healthy
restart: unless-stopped
profiles:
- tls
volumes:
app_data:
driver: local
db_data:
driver: local
caddy_data:
driver: local
caddy_config:
driver: local

286
docker/init-database-sql.py Normal file
View File

@@ -0,0 +1,286 @@
#!/usr/bin/env python3
"""
Database initialization script for TimeTracker using raw SQL
This script creates tables and initial data without depending on Flask models
"""
import os
import sys
import time
from sqlalchemy import create_engine, text, inspect
def wait_for_database(url, max_attempts=30, delay=2):
"""Wait for database to be ready"""
print(f"Waiting for database to be ready...")
for attempt in range(max_attempts):
try:
engine = create_engine(url, pool_pre_ping=True)
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
print("Database connection established successfully")
return engine
except Exception as e:
print(f"Waiting for database... (attempt {attempt+1}/{max_attempts}): {e}")
if attempt < max_attempts - 1:
time.sleep(delay)
else:
print("Database not ready after waiting, exiting...")
sys.exit(1)
return None
def create_tables_sql(engine):
"""Create tables using raw SQL"""
print("Creating tables using SQL...")
# SQL statements to create tables
create_tables_sql = """
-- Create users table
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(80) UNIQUE NOT NULL,
role VARCHAR(20) DEFAULT 'user' NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
last_login TIMESTAMP,
is_active BOOLEAN DEFAULT true NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
-- Create projects table
CREATE TABLE IF NOT EXISTS projects (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
client VARCHAR(200) NOT NULL,
description TEXT,
billable BOOLEAN DEFAULT true NOT NULL,
hourly_rate NUMERIC(9, 2),
billing_ref VARCHAR(100),
status VARCHAR(20) DEFAULT 'active' NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create time_entries table
CREATE TABLE IF NOT EXISTS time_entries (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP,
duration_seconds INTEGER,
notes TEXT,
tags VARCHAR(500),
source VARCHAR(20) DEFAULT 'manual' NOT NULL,
billable BOOLEAN DEFAULT true NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create settings table
CREATE TABLE IF NOT EXISTS settings (
id SERIAL PRIMARY KEY,
timezone VARCHAR(50) DEFAULT 'Europe/Rome' NOT NULL,
currency VARCHAR(3) DEFAULT 'EUR' NOT NULL,
rounding_minutes INTEGER DEFAULT 1 NOT NULL,
single_active_timer BOOLEAN DEFAULT true NOT NULL,
allow_self_register BOOLEAN DEFAULT true NOT NULL,
idle_timeout_minutes INTEGER DEFAULT 30 NOT NULL,
backup_retention_days INTEGER DEFAULT 30 NOT NULL,
backup_time VARCHAR(5) DEFAULT '02:00' NOT NULL,
export_delimiter VARCHAR(1) DEFAULT ',' NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
"""
try:
with engine.connect() as conn:
# Execute the SQL statements
conn.execute(text(create_tables_sql))
conn.commit()
print("✓ Tables created successfully")
return True
except Exception as e:
print(f"✗ Error creating tables: {e}")
return False
def create_indexes(engine):
"""Create indexes for better performance"""
print("Creating indexes...")
indexes_sql = """
CREATE INDEX IF NOT EXISTS idx_time_entries_user_id ON time_entries(user_id);
CREATE INDEX IF NOT EXISTS idx_time_entries_project_id ON time_entries(project_id);
CREATE INDEX IF NOT EXISTS idx_time_entries_start_time ON time_entries(start_time);
"""
try:
with engine.connect() as conn:
conn.execute(text(indexes_sql))
conn.commit()
print("✓ Indexes created successfully")
return True
except Exception as e:
print(f"✗ Error creating indexes: {e}")
return False
def create_triggers(engine):
"""Create triggers for automatic timestamp updates"""
print("Creating triggers...")
# Execute each statement separately to avoid semicolon splitting issues
try:
with engine.connect() as conn:
# Create function
conn.execute(text("""
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
"""))
# Create triggers
conn.execute(text("""
DROP TRIGGER IF EXISTS update_users_updated_at ON users;
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
"""))
conn.execute(text("""
DROP TRIGGER IF EXISTS update_projects_updated_at ON projects;
CREATE TRIGGER update_projects_updated_at BEFORE UPDATE ON projects FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
"""))
conn.execute(text("""
DROP TRIGGER IF EXISTS update_time_entries_updated_at ON time_entries;
CREATE TRIGGER update_time_entries_updated_at BEFORE UPDATE ON time_entries FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
"""))
conn.execute(text("""
DROP TRIGGER IF EXISTS update_settings_updated_at ON settings;
CREATE TRIGGER update_settings_updated_at BEFORE UPDATE ON settings FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
"""))
conn.commit()
print("✓ Triggers created successfully")
return True
except Exception as e:
print(f"✗ Error creating triggers: {e}")
return False
def insert_initial_data(engine):
"""Insert initial data"""
print("Inserting initial data...")
# Get admin username from environment
admin_username = os.getenv('ADMIN_USERNAMES', 'admin').split(',')[0]
insert_sql = f"""
-- Insert default admin user
INSERT INTO users (username, role, is_active)
VALUES ('{admin_username}', 'admin', true)
ON CONFLICT (username) DO NOTHING;
-- Insert default project
INSERT INTO projects (name, client, description, billable, status)
VALUES ('General', 'Default Client', 'Default project for general tasks', true, 'active')
ON CONFLICT DO NOTHING;
-- Insert default settings
INSERT INTO settings (timezone, currency, rounding_minutes, single_active_timer, allow_self_register, idle_timeout_minutes, backup_retention_days, backup_time, export_delimiter)
VALUES ('Europe/Rome', 'EUR', 1, true, true, 30, 30, '02:00', ',')
ON CONFLICT (id) DO NOTHING;
"""
try:
with engine.connect() as conn:
conn.execute(text(insert_sql))
conn.commit()
print("✓ Initial data inserted successfully")
return True
except Exception as e:
print(f"✗ Error inserting initial data: {e}")
return False
def verify_tables(engine):
"""Verify that all required tables exist"""
print("Verifying tables...")
try:
inspector = inspect(engine)
existing_tables = inspector.get_table_names()
required_tables = ['users', 'projects', 'time_entries', 'settings']
missing_tables = [table for table in required_tables if table not in existing_tables]
if missing_tables:
print(f"✗ Missing tables: {missing_tables}")
return False
else:
print("✓ All required tables exist")
return True
except Exception as e:
print(f"✗ Error verifying tables: {e}")
return False
def main():
"""Main function"""
url = os.getenv("DATABASE_URL", "")
if not url.startswith("postgresql"):
print("No PostgreSQL database configured, skipping initialization")
return
print(f"Database URL: {url}")
# Wait for database to be ready
engine = wait_for_database(url)
# Check if database is initialized
if verify_tables(engine):
print("Database already initialized, no action needed")
return
print("Database not initialized, starting initialization...")
# Create tables
if not create_tables_sql(engine):
print("Failed to create tables")
sys.exit(1)
# Create indexes
if not create_indexes(engine):
print("Failed to create indexes")
sys.exit(1)
# Create triggers
if not create_triggers(engine):
print("Failed to create triggers")
sys.exit(1)
# Insert initial data
if not insert_initial_data(engine):
print("Failed to insert initial data")
sys.exit(1)
# Verify everything was created
if verify_tables(engine):
print("✓ Database initialization completed successfully")
else:
print("✗ Database initialization failed - tables still missing")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -8,6 +8,7 @@ and initializes it if needed.
import os
import sys
import time
import traceback
from sqlalchemy import create_engine, text, inspect
from sqlalchemy.exc import OperationalError, ProgrammingError
@@ -54,6 +55,7 @@ def check_database_initialization(engine):
except Exception as e:
print(f"Error checking database initialization: {e}")
print(f"Traceback: {traceback.format_exc()}")
return False
def initialize_database(engine):
@@ -63,20 +65,35 @@ def initialize_database(engine):
try:
# Set environment variables for Flask
os.environ['FLASK_APP'] = 'app'
os.environ['FLASK_ENV'] = 'production'
print("Importing Flask app...")
# Import Flask app and initialize database
from app import create_app, db
from app.models import User, Project, TimeEntry, Settings
print("Creating Flask app...")
app = create_app()
print("Setting up app context...")
with app.app_context():
print("Creating all tables...")
# Create all tables
db.create_all()
print("Verifying tables were created...")
# Verify tables were created
inspector = inspect(engine)
existing_tables = inspector.get_table_names()
print(f"Tables after creation: {existing_tables}")
# Create default admin user if it doesn't exist
admin_username = os.getenv('ADMIN_USERNAMES', 'admin').split(',')[0]
print(f"Checking for admin user: {admin_username}")
if not User.query.filter_by(username=admin_username).first():
print("Creating admin user...")
admin_user = User(
username=admin_username,
role='admin'
@@ -85,16 +102,24 @@ def initialize_database(engine):
db.session.add(admin_user)
db.session.commit()
print(f"Created default admin user: {admin_username}")
else:
print(f"Admin user {admin_username} already exists")
# Create default settings if they don't exist
print("Checking for default settings...")
if not Settings.query.first():
print("Creating default settings...")
settings = Settings()
db.session.add(settings)
db.session.commit()
print("Created default settings")
else:
print("Default settings already exist")
# Create default project if it doesn't exist
print("Checking for default project...")
if not Project.query.first():
print("Creating default project...")
project = Project(
name='General',
client='Default Client',
@@ -105,12 +130,15 @@ def initialize_database(engine):
db.session.add(project)
db.session.commit()
print("Created default project")
else:
print("Default project already exists")
print("Database initialized successfully")
return True
except Exception as e:
print(f"Error initializing database: {e}")
print(f"Traceback: {traceback.format_exc()}")
return False
def main():
@@ -121,6 +149,8 @@ def main():
print("No PostgreSQL database configured, skipping initialization")
return
print(f"Database URL: {url}")
# Wait for database to be ready
engine = wait_for_database(url)
@@ -129,6 +159,12 @@ def main():
# Initialize database
if initialize_database(engine):
print("Database initialization completed successfully")
# Verify initialization worked
if check_database_initialization(engine):
print("Database verification successful")
else:
print("Database verification failed - tables still missing")
sys.exit(1)
else:
print("Database initialization failed")
sys.exit(1)

View File

@@ -34,8 +34,8 @@ CREATE TABLE IF NOT EXISTS time_entries (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
start_utc TIMESTAMP NOT NULL,
end_utc TIMESTAMP,
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP,
duration_seconds INTEGER,
notes TEXT,
tags VARCHAR(500),
@@ -47,7 +47,7 @@ CREATE TABLE IF NOT EXISTS time_entries (
CREATE TABLE IF NOT EXISTS settings (
id SERIAL PRIMARY KEY,
timezone VARCHAR(50) DEFAULT 'Europe/Brussels' NOT NULL,
timezone VARCHAR(50) DEFAULT 'Europe/Rome' NOT NULL,
currency VARCHAR(3) DEFAULT 'EUR' NOT NULL,
rounding_minutes INTEGER DEFAULT 1 NOT NULL,
single_active_timer BOOLEAN DEFAULT true NOT NULL,
@@ -63,7 +63,7 @@ CREATE TABLE IF NOT EXISTS settings (
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS idx_time_entries_user_id ON time_entries(user_id);
CREATE INDEX IF NOT EXISTS idx_time_entries_project_id ON time_entries(project_id);
CREATE INDEX IF NOT EXISTS idx_time_entries_start_utc ON time_entries(start_utc);
CREATE INDEX IF NOT EXISTS idx_time_entries_start_time ON time_entries(start_time);
-- Insert default admin user (password: admin)
@@ -80,7 +80,7 @@ ON CONFLICT DO NOTHING;
-- Insert default settings
INSERT INTO settings (timezone, currency, rounding_minutes, single_active_timer, allow_self_register, idle_timeout_minutes, backup_retention_days, backup_time, export_delimiter)
VALUES ('Europe/Brussels', 'EUR', 1, true, true, 30, 30, '02:00', ',')
VALUES ('Europe/Rome', 'EUR', 1, true, true, 30, 30, '02:00', ',')
ON CONFLICT (id) DO NOTHING;
-- Create function to update updated_at timestamp

View File

@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""
Migration script to rename database fields from start_utc/end_utc to start_time/end_time
This script should be run after updating the application code but before starting the new version.
"""
import os
import sys
from sqlalchemy import create_engine, text, inspect
def wait_for_database(url, max_attempts=30, delay=2):
"""Wait for database to be ready"""
print(f"Waiting for database to be ready...")
for attempt in range(max_attempts):
try:
engine = create_engine(url, pool_pre_ping=True)
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
print("Database connection established successfully")
return engine
except Exception as e:
print(f"Waiting for database... (attempt {attempt+1}/{max_attempts}): {e}")
if attempt < max_attempts - 1:
time.sleep(delay)
else:
print("Database not ready after waiting, exiting...")
sys.exit(1)
return None
def check_migration_needed(engine):
"""Check if migration is needed"""
print("Checking if migration is needed...")
try:
inspector = inspect(engine)
columns = inspector.get_columns('time_entries')
column_names = [col['name'] for col in columns]
has_old_fields = 'start_utc' in column_names or 'end_utc' in column_names
has_new_fields = 'start_time' in column_names or 'end_time' in column_names
if has_old_fields and not has_new_fields:
print("✓ Migration needed: old field names detected")
return True
elif has_new_fields and not has_old_fields:
print("✓ Migration not needed: new field names already exist")
return False
elif has_old_fields and has_new_fields:
print("⚠ Migration partially done: both old and new field names exist")
return True
else:
print("⚠ Unknown state: neither old nor new field names found")
return True
except Exception as e:
print(f"✗ Error checking migration status: {e}")
return True
def migrate_database(engine):
"""Perform the migration"""
print("Starting database migration...")
try:
with engine.connect() as conn:
# Start transaction
trans = conn.begin()
try:
# Check if old columns exist
inspector = inspect(engine)
columns = inspector.get_columns('time_entries')
column_names = [col['name'] for col in columns]
if 'start_utc' in column_names:
print("Renaming start_utc to start_time...")
conn.execute(text("ALTER TABLE time_entries RENAME COLUMN start_utc TO start_time"))
if 'end_utc' in column_names:
print("Renaming end_utc to end_time...")
conn.execute(text("ALTER TABLE time_entries RENAME COLUMN end_utc TO end_time"))
# Update indexes
print("Updating indexes...")
# Drop old index if it exists
try:
conn.execute(text("DROP INDEX IF EXISTS idx_time_entries_start_utc"))
except:
pass
# Create new index
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_time_entries_start_time ON time_entries(start_time)"))
# Commit transaction
trans.commit()
print("✓ Migration completed successfully")
return True
except Exception as e:
trans.rollback()
print(f"✗ Migration failed: {e}")
return False
except Exception as e:
print(f"✗ Error during migration: {e}")
return False
def verify_migration(engine):
"""Verify the migration was successful"""
print("Verifying migration...")
try:
inspector = inspect(engine)
columns = inspector.get_columns('time_entries')
column_names = [col['name'] for col in columns]
has_new_fields = 'start_time' in column_names and 'end_time' in column_names
has_old_fields = 'start_utc' in column_names or 'end_utc' in column_names
if has_new_fields and not has_old_fields:
print("✓ Migration verified: new field names are present, old ones are gone")
return True
else:
print(f"✗ Migration verification failed: new fields: {has_new_fields}, old fields: {has_old_fields}")
return False
except Exception as e:
print(f"✗ Error verifying migration: {e}")
return False
def main():
"""Main function"""
url = os.getenv("DATABASE_URL", "")
if not url.startswith("postgresql"):
print("No PostgreSQL database configured, skipping migration")
return
print(f"Database URL: {url}")
# Wait for database to be ready
engine = wait_for_database(url)
# Check if migration is needed
if not check_migration_needed(engine):
print("No migration needed, exiting...")
return
# Perform migration
if not migrate_database(engine):
print("Migration failed, exiting...")
sys.exit(1)
# Verify migration
if not verify_migration(engine):
print("Migration verification failed, exiting...")
sys.exit(1)
print("✓ Database migration completed successfully!")
if __name__ == "__main__":
import time
main()

View File

@@ -1,9 +1,10 @@
#!/bin/bash
set -e
cd /app
export FLASK_APP=app
echo "=== Starting TimeTracker ==="
echo "Waiting for database to be ready..."
# Wait for Postgres to be ready
python - <<"PY"
@@ -47,7 +48,7 @@ if url.startswith("postgresql"):
# Check if our main tables exist
existing_tables = inspector.get_table_names()
required_tables = ['users', 'projects', 'time_entries', 'settings']
required_tables = ["users", "projects", "time_entries", "settings"]
missing_tables = [table for table in required_tables if table not in existing_tables]
@@ -57,7 +58,20 @@ if url.startswith("postgresql"):
sys.exit(1) # Exit with error to trigger initialization
else:
print("Database is already initialized with all required tables")
sys.exit(0) # Exit successfully, no initialization needed
# Check if migration is needed
try:
columns = inspector.get_columns("time_entries")
column_names = [col['name'] for col in columns]
has_old_fields = 'start_utc' in column_names or 'end_utc' in column_names
if has_old_fields:
print("Migration needed for field names")
sys.exit(2) # Special exit code for migration
else:
print("No migration needed")
sys.exit(0) # Exit successfully, no initialization needed
except Exception as e:
print(f"Error checking migration status: {e}")
sys.exit(0) # Assume no migration needed
except Exception as e:
print(f"Error checking database initialization: {e}")
@@ -68,12 +82,62 @@ else:
PY
if [ $? -eq 1 ]; then
echo "Initializing database..."
python /app/docker/init-database.py
if [ $? -eq 0 ]; then
echo "Database initialized successfully"
else
echo "Database initialization failed, but continuing..."
echo "Initializing database with SQL-based script..."
python /app/docker/init-database-sql.py
if [ $? -ne 0 ]; then
echo "Database initialization failed. Exiting to prevent infinite loop."
exit 1
fi
echo "Database initialized successfully"
# Run migration if needed
echo "Running database migration..."
python /app/docker/migrate-field-names.py
# Verify initialization worked
echo "Verifying database initialization..."
elif [ $? -eq 2 ]; then
echo "Running database migration for existing database..."
python /app/docker/migrate-field-names.py
if [ $? -ne 0 ]; then
echo "Database migration failed. Exiting."
exit 1
fi
echo "Database migration completed successfully"
python - <<"PY"
import os
import sys
from sqlalchemy import create_engine, text, inspect
url = os.getenv("DATABASE_URL", "")
if url.startswith("postgresql"):
try:
engine = create_engine(url, pool_pre_ping=True)
inspector = inspect(engine)
existing_tables = inspector.get_table_names()
required_tables = ["users", "projects", "time_entries", "settings"]
missing_tables = [table for table in required_tables if table not in existing_tables]
if missing_tables:
print(f"Database verification failed. Missing tables: {missing_tables}")
sys.exit(1)
else:
print("Database verification successful")
sys.exit(0)
except Exception as e:
print(f"Error verifying database: {e}")
sys.exit(1)
else:
print("No PostgreSQL database configured, skipping verification")
sys.exit(0)
PY
if [ $? -eq 1 ]; then
echo "Database verification failed after initialization. Exiting to prevent infinite loop."
exit 1
fi
else
echo "Database already initialized, skipping initialization"

71
docker/test-db-simple.py Normal file
View File

@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""
Simple database test script to debug connection and table creation issues
"""
import os
import sys
from sqlalchemy import create_engine, text, inspect
def test_database():
"""Test database connection and basic operations"""
url = os.getenv("DATABASE_URL", "")
if not url.startswith("postgresql"):
print("No PostgreSQL database configured")
return False
print(f"Testing database URL: {url}")
try:
# Test connection
print("Testing database connection...")
engine = create_engine(url, pool_pre_ping=True)
with engine.connect() as conn:
result = conn.execute(text("SELECT 1"))
print("✓ Database connection successful")
# Test table inspection
print("Testing table inspection...")
inspector = inspect(engine)
tables = inspector.get_table_names()
print(f"✓ Found {len(tables)} tables: {tables}")
# Test creating a simple table
print("Testing table creation...")
with engine.connect() as conn:
conn.execute(text("""
CREATE TABLE IF NOT EXISTS test_table (
id SERIAL PRIMARY KEY,
name VARCHAR(100)
)
"""))
conn.commit()
print("✓ Test table created successfully")
# Verify table was created
tables_after = inspector.get_table_names()
if 'test_table' in tables_after:
print("✓ Test table verification successful")
else:
print("✗ Test table verification failed")
return False
# Clean up test table
with engine.connect() as conn:
conn.execute(text("DROP TABLE test_table"))
conn.commit()
print("✓ Test table cleaned up")
return True
except Exception as e:
print(f"✗ Database test failed: {e}")
import traceback
traceback.print_exc()
return False
if __name__ == "__main__":
success = test_database()
sys.exit(0 if success else 1)

32
docker/test-startup.sh Normal file
View File

@@ -0,0 +1,32 @@
#!/bin/bash
echo "=== Testing Startup Script ==="
echo "Current working directory: $(pwd)"
echo "Current user: $(whoami)"
echo "Current user ID: $(id)"
echo "Checking if startup script exists..."
if [ -f "/app/docker/start.sh" ]; then
echo "✓ Startup script exists at /app/docker/start.sh"
echo "File permissions: $(ls -la /app/docker/start.sh)"
echo "File owner: $(stat -c '%U:%G' /app/docker/start.sh)"
echo "Testing if script is executable..."
if [ -x "/app/docker/start.sh" ]; then
echo "✓ Startup script is executable"
echo "Script first few lines:"
head -5 /app/docker/start.sh
else
echo "✗ Startup script is NOT executable"
fi
else
echo "✗ Startup script does NOT exist at /app/docker/start.sh"
echo "Contents of /app/docker/:"
ls -la /app/docker/ || echo "Directory /app/docker/ does not exist"
fi
echo "Checking /app directory structure..."
echo "Contents of /app:"
ls -la /app/ || echo "Directory /app/ does not exist"
echo "=== Test Complete ==="

View File

@@ -13,7 +13,7 @@ POSTGRES_USER=timetracker
POSTGRES_PASSWORD=timetracker
# Timezone and Localization
TZ=Europe/Brussels
TZ=Europe/Rome
CURRENCY=EUR
# Time Tracking Settings

View File

@@ -1,899 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TimeTracker - Self-Hosted Time Tracking Solution</title>
<meta name="description" content="A robust, self-hosted time tracking application designed for teams and freelancers who need reliable time management without cloud dependencies.">
<meta name="keywords" content="time tracking, self-hosted, flask, python, docker, raspberry pi, open source">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://drytrix.github.io/TimeTracker/">
<meta property="og:title" content="TimeTracker - Self-Hosted Time Tracking Solution">
<meta property="og:description" content="A robust, self-hosted time tracking application designed for teams and freelancers who need reliable time management without cloud dependencies.">
<meta property="og:image" content="https://drytrix.github.io/TimeTracker/assets/og-image.png">
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:url" content="https://drytrix.github.io/TimeTracker/">
<meta property="twitter:title" content="TimeTracker - Self-Hosted Time Tracking Solution">
<meta property="twitter:description" content="A robust, self-hosted time tracking application designed for teams and freelancers who need reliable time management without cloud dependencies.">
<meta property="twitter:image" content="https://drytrix.github.io/TimeTracker/assets/og-image.png">
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="assets/favicon.ico">
<!-- CSS -->
<style>
:root {
--primary-color: #2563eb;
--primary-dark: #1d4ed8;
--secondary-color: #64748b;
--accent-color: #f59e0b;
--success-color: #10b981;
--danger-color: #ef4444;
--background: #ffffff;
--surface: #f8fafc;
--text-primary: #1e293b;
--text-secondary: #64748b;
--border: #e2e8f0;
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: var(--text-primary);
background: var(--background);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
/* Header */
.header {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
color: white;
padding: 80px 0;
text-align: center;
position: relative;
overflow: hidden;
}
.header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grain" width="100" height="100" patternUnits="userSpaceOnUse"><circle cx="50" cy="50" r="1" fill="white" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23grain)"/></svg>');
opacity: 0.3;
}
.header-content {
position: relative;
z-index: 1;
}
.logo {
font-size: 3.5rem;
font-weight: 700;
margin-bottom: 20px;
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
}
.tagline {
font-size: 1.5rem;
margin-bottom: 30px;
opacity: 0.9;
font-weight: 300;
}
.cta-buttons {
display: flex;
gap: 20px;
justify-content: center;
flex-wrap: wrap;
}
.btn {
display: inline-block;
padding: 15px 30px;
border-radius: 8px;
text-decoration: none;
font-weight: 600;
transition: all 0.3s ease;
border: none;
cursor: pointer;
font-size: 1rem;
}
.btn-primary {
background: var(--accent-color);
color: white;
}
.btn-primary:hover {
background: #d97706;
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.btn-secondary {
background: transparent;
color: white;
border: 2px solid white;
}
.btn-secondary:hover {
background: white;
color: var(--primary-color);
transform: translateY(-2px);
}
/* Features Section */
.features {
padding: 100px 0;
background: var(--surface);
}
.section-title {
text-align: center;
font-size: 2.5rem;
margin-bottom: 20px;
color: var(--text-primary);
}
.section-subtitle {
text-align: center;
font-size: 1.2rem;
color: var(--text-secondary);
margin-bottom: 60px;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 40px;
margin-top: 60px;
}
.feature-card {
background: white;
padding: 40px 30px;
border-radius: 16px;
box-shadow: var(--shadow);
transition: all 0.3s ease;
border: 1px solid var(--border);
}
.feature-card:hover {
transform: translateY(-5px);
box-shadow: var(--shadow-lg);
}
.feature-icon {
font-size: 3rem;
margin-bottom: 20px;
display: block;
}
.feature-title {
font-size: 1.5rem;
margin-bottom: 15px;
color: var(--text-primary);
}
.feature-description {
color: var(--text-secondary);
line-height: 1.6;
}
/* Screenshots Section */
.screenshots {
padding: 100px 0;
background: white;
}
.screenshots-grid {
display: grid;
grid-template-columns: 1fr;
gap: 60px;
margin-top: 60px;
}
.screenshot-item {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 40px;
align-items: center;
background: var(--surface);
padding: 40px;
border-radius: 16px;
box-shadow: var(--shadow);
transition: all 0.3s ease;
}
.screenshot-item:nth-child(even) {
grid-template-columns: 1fr 1fr;
}
.screenshot-item:nth-child(odd) {
grid-template-columns: 1fr 1fr;
}
.screenshot-item:hover {
transform: translateY(-5px);
box-shadow: var(--shadow-lg);
}
.screenshot-image {
text-align: center;
}
.screenshot-image img {
max-width: 100%;
height: auto;
border-radius: 8px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
transition: all 0.3s ease;
}
.screenshot-image img:hover {
transform: scale(1.02);
box-shadow: var(--shadow-lg);
}
.screenshot-caption h3 {
font-size: 1.8rem;
margin-bottom: 20px;
color: var(--text-primary);
}
.screenshot-caption p {
color: var(--text-secondary);
line-height: 1.6;
font-size: 1.1rem;
}
/* Problem Section */
.problem {
padding: 100px 0;
background: white;
}
.problem-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 60px;
align-items: center;
}
.problem-text h2 {
font-size: 2.5rem;
margin-bottom: 30px;
color: var(--text-primary);
}
.problem-list {
list-style: none;
}
.problem-list li {
padding: 15px 0;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 15px;
}
.problem-list li:last-child {
border-bottom: none;
}
.problem-icon {
color: var(--danger-color);
font-size: 1.5rem;
}
.solution-icon {
color: var(--success-color);
font-size: 1.5rem;
}
.problem-visual {
background: var(--surface);
padding: 40px;
border-radius: 16px;
text-align: center;
}
.problem-visual h3 {
font-size: 1.5rem;
margin-bottom: 20px;
color: var(--text-primary);
}
.problem-visual p {
color: var(--text-secondary);
margin-bottom: 20px;
}
/* Quick Start Section */
.quick-start {
padding: 100px 0;
background: var(--surface);
}
.steps {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 30px;
margin-top: 60px;
}
.step {
background: white;
padding: 30px;
border-radius: 12px;
box-shadow: var(--shadow);
text-align: center;
border: 1px solid var(--border);
}
.step-number {
background: var(--primary-color);
color: white;
width: 50px;
height: 50px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
font-weight: bold;
font-size: 1.2rem;
}
.step-title {
font-size: 1.3rem;
margin-bottom: 15px;
color: var(--text-primary);
}
.step-description {
color: var(--text-secondary);
line-height: 1.6;
}
/* Code Block */
.code-block {
background: #1e293b;
color: #e2e8f0;
padding: 20px;
border-radius: 8px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.9rem;
overflow-x: auto;
margin: 20px 0;
}
.code-block .comment {
color: #64748b;
}
.code-block .string {
color: #10b981;
}
/* Stats Section */
.stats {
padding: 80px 0;
background: var(--primary-color);
color: white;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 40px;
text-align: center;
}
.stat-item h3 {
font-size: 2.5rem;
margin-bottom: 10px;
font-weight: 700;
}
.stat-item p {
opacity: 0.9;
font-size: 1.1rem;
}
/* Footer */
.footer {
background: var(--text-primary);
color: white;
padding: 60px 0 30px;
text-align: center;
}
.footer-content {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 40px;
margin-bottom: 40px;
}
.footer-section h4 {
margin-bottom: 20px;
color: var(--accent-color);
}
.footer-section ul {
list-style: none;
}
.footer-section ul li {
margin-bottom: 10px;
}
.footer-section ul li a {
color: #cbd5e1;
text-decoration: none;
transition: color 0.3s ease;
}
.footer-section ul li a:hover {
color: white;
}
.footer-bottom {
border-top: 1px solid #334155;
padding-top: 30px;
color: #cbd5e1;
}
.social-links {
display: flex;
gap: 20px;
justify-content: center;
margin-top: 20px;
}
.social-links a {
color: #cbd5e1;
font-size: 1.5rem;
transition: color 0.3s ease;
}
.social-links a:hover {
color: var(--accent-color);
}
/* Responsive Design */
@media (max-width: 768px) {
.header {
padding: 60px 0;
}
.logo {
font-size: 2.5rem;
}
.tagline {
font-size: 1.2rem;
}
.cta-buttons {
flex-direction: column;
align-items: center;
}
.problem-content {
grid-template-columns: 1fr;
gap: 40px;
}
.section-title {
font-size: 2rem;
}
.features-grid {
grid-template-columns: 1fr;
}
.steps {
grid-template-columns: 1fr;
}
.screenshot-item {
grid-template-columns: 1fr;
gap: 30px;
padding: 30px;
}
.screenshot-item:nth-child(even) {
grid-template-columns: 1fr;
}
.screenshot-item:nth-child(odd) {
grid-template-columns: 1fr;
}
}
/* Animations */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in-up {
animation: fadeInUp 0.6s ease-out;
}
/* Scroll animations */
.scroll-animate {
opacity: 0;
transform: translateY(30px);
transition: all 0.6s ease-out;
}
.scroll-animate.animate {
opacity: 1;
transform: translateY(0);
}
</style>
</head>
<body>
<!-- Header -->
<header class="header">
<div class="container">
<div class="header-content">
<div class="logo">
⏱️ TimeTracker
</div>
<p class="tagline">Self-hosted time tracking for teams and freelancers</p>
<div class="cta-buttons">
<a href="https://github.com/DRYTRIX/TimeTracker" class="btn btn-primary">
🚀 Get Started
</a>
<a href="https://github.com/DRYTRIX/TimeTracker" class="btn btn-secondary">
📖 View Docs
</a>
</div>
</div>
</div>
</header>
<!-- Features Section -->
<section class="features">
<div class="container">
<h2 class="section-title">Why Choose TimeTracker?</h2>
<p class="section-subtitle">Built for reliability, designed for simplicity, and engineered for performance</p>
<div class="features-grid">
<div class="feature-card scroll-animate">
<span class="feature-icon">🕐</span>
<h3 class="feature-title">Persistent Timers</h3>
<p class="feature-description">Server-side timers that survive browser restarts and computer reboots. Never lose track of your time again.</p>
</div>
<div class="feature-card scroll-animate">
<span class="feature-icon">☁️</span>
<h3 class="feature-title">Self-Hosted</h3>
<p class="feature-description">Full control over your data with no cloud dependencies. Deploy on your own infrastructure with confidence.</p>
</div>
<div class="feature-card scroll-animate">
<span class="feature-icon">📊</span>
<h3 class="feature-title">Rich Reporting</h3>
<p class="feature-description">Comprehensive reports and analytics with CSV export capabilities for external analysis and billing.</p>
</div>
<div class="feature-card scroll-animate">
<span class="feature-icon">👥</span>
<h3 class="feature-title">Team Management</h3>
<p class="feature-description">User roles, project organization, and billing support for teams of any size.</p>
</div>
<div class="feature-card scroll-animate">
<span class="feature-icon">🐳</span>
<h3 class="feature-title">Docker Ready</h3>
<p class="feature-description">Simple deployment with Docker and Docker Compose. Perfect for Raspberry Pi and production environments.</p>
</div>
<div class="feature-card scroll-animate">
<span class="feature-icon">🔒</span>
<h3 class="feature-title">Open Source</h3>
<p class="feature-description">Licensed under GPL v3, ensuring derivatives remain open source and accessible to everyone.</p>
</div>
</div>
</div>
</section>
<!-- Screenshots Section -->
<section class="screenshots">
<div class="container">
<h2 class="section-title">See TimeTracker in Action</h2>
<p class="section-subtitle">Beautiful, intuitive interface designed for productivity and ease of use</p>
<div class="screenshots-grid">
<div class="screenshot-item scroll-animate">
<div class="screenshot-image">
<img src="assets/screenshots/Dashboard.png" alt="TimeTracker Dashboard - Clean interface showing active timers and recent activity" loading="lazy">
</div>
<div class="screenshot-caption">
<h3>Dashboard View</h3>
<p>Clean, intuitive interface showing active timers and recent activity. Quick access to start/stop timers and manual time entry.</p>
</div>
</div>
<div class="screenshot-item scroll-animate">
<div class="screenshot-image">
<img src="assets/screenshots/Projects.png" alt="TimeTracker Projects - Client and project organization with billing information" loading="lazy">
</div>
<div class="screenshot-caption">
<h3>Project Management</h3>
<p>Client and project organization with billing information. Time tracking across multiple projects simultaneously.</p>
</div>
</div>
<div class="screenshot-item scroll-animate">
<div class="screenshot-image">
<img src="assets/screenshots/Reports.png" alt="TimeTracker Reports - Comprehensive time reports with export capabilities" loading="lazy">
</div>
<div class="screenshot-caption">
<h3>Reports & Analytics</h3>
<p>Comprehensive time reports with export capabilities. Visual breakdowns of time allocation and productivity.</p>
</div>
</div>
</div>
</div>
</section>
<!-- Problem & Solution Section -->
<section class="problem">
<div class="container">
<div class="problem-content">
<div class="problem-text">
<h2>Solving Real Time Tracking Problems</h2>
<ul class="problem-list">
<li>
<span class="problem-icon"></span>
<span>Traditional timers lose data when browsers close</span>
</li>
<li>
<span class="problem-icon"></span>
<span>Cloud dependencies and privacy concerns</span>
</li>
<li>
<span class="problem-icon"></span>
<span>Complex setup and maintenance</span>
</li>
<li>
<span class="problem-icon"></span>
<span>Limited reporting and export options</span>
</li>
</ul>
</div>
<div class="problem-visual">
<h3>TimeTracker Solutions</h3>
<p>✅ Persistent server-side timers</p>
<p>✅ Self-hosted, no cloud required</p>
<p>✅ Simple Docker deployment</p>
<p>✅ Rich reporting and exports</p>
<p>✅ Team collaboration features</p>
</div>
</div>
</div>
</section>
<!-- Quick Start Section -->
<section class="quick-start">
<div class="container">
<h2 class="section-title">Get Started in Minutes</h2>
<p class="section-subtitle">Deploy TimeTracker on your Raspberry Pi or any Linux system with just a few commands</p>
<div class="steps">
<div class="step scroll-animate">
<div class="step-number">1</div>
<h3 class="step-title">Clone Repository</h3>
<p class="step-description">Get the latest version of TimeTracker from GitHub</p>
<div class="code-block">
<span class="comment"># Clone the repository</span><br>
git clone https://github.com/DRYTRIX/TimeTracker.git<br>
cd TimeTracker
</div>
</div>
<div class="step scroll-animate">
<div class="step-number">2</div>
<h3 class="step-title">Configure Environment</h3>
<p class="step-description">Set up your environment variables and preferences</p>
<div class="code-block">
<span class="comment"># Copy and edit environment file</span><br>
cp .env.example .env<br>
<span class="comment"># Edit with your settings</span>
</div>
</div>
<div class="step scroll-animate">
<div class="step-number">3</div>
<h3 class="step-title">Start Application</h3>
<p class="step-description">Launch TimeTracker with Docker Compose</p>
<div class="code-block">
<span class="comment"># Start the application</span><br>
docker-compose up -d
</div>
</div>
<div class="step scroll-animate">
<div class="step-number">4</div>
<h3 class="step-title">Access & Use</h3>
<p class="step-description">Open your browser and start tracking time</p>
<div class="code-block">
<span class="comment"># Access at</span><br>
<span class="string">http://your-pi-ip:8080</span>
</div>
</div>
</div>
</div>
</section>
<!-- Stats Section -->
<section class="stats">
<div class="container">
<div class="stats-grid">
<div class="stat-item">
<h3>100%</h3>
<p>Open Source</p>
</div>
<div class="stat-item">
<h3>0</h3>
<p>Cloud Dependencies</p>
</div>
<div class="stat-item">
<h3></h3>
<p>Customization</p>
</div>
<div class="stat-item">
<h3>🚀</h3>
<p>Easy Deployment</p>
</div>
</div>
</div>
</section>
<!-- Footer -->
<footer class="footer">
<div class="container">
<div class="footer-content">
<div class="footer-section">
<h4>Project</h4>
<ul>
<li><a href="https://github.com/DRYTRIX/TimeTracker">GitHub Repository</a></li>
<li><a href="https://github.com/DRYTRIX/TimeTracker/issues">Issue Tracker</a></li>
<li><a href="https://github.com/DRYTRIX/TimeTracker/discussions">Discussions</a></li>
<li><a href="https://github.com/DRYTRIX/TimeTracker/releases">Releases</a></li>
</ul>
</div>
<div class="footer-section">
<h4>Documentation</h4>
<ul>
<li><a href="https://github.com/DRYTRIX/TimeTracker/blob/main/README.md">README</a></li>
<li><a href="https://github.com/DRYTRIX/TimeTracker/blob/main/CONTRIBUTING.md">Contributing</a></li>
<li><a href="https://github.com/DRYTRIX/TimeTracker/blob/main/CODE_OF_CONDUCT.md">Code of Conduct</a></li>
<li><a href="https://github.com/DRYTRIX/TimeTracker/blob/main/LICENSE">License</a></li>
</ul>
</div>
<div class="footer-section">
<h4>Community</h4>
<ul>
<li><a href="https://github.com/DRYTRIX/TimeTracker/stargazers">Stars</a></li>
<li><a href="https://github.com/DRYTRIX/TimeTracker/network">Forks</a></li>
<li><a href="https://github.com/DRYTRIX/TimeTracker/graphs/contributors">Contributors</a></li>
<li><a href="https://github.com/DRYTRIX/TimeTracker/pulse">Activity</a></li>
</ul>
</div>
<div class="footer-section">
<h4>Support</h4>
<ul>
<li><a href="https://github.com/DRYTRIX/TimeTracker/issues/new">Report Bug</a></li>
<li><a href="https://github.com/DRYTRIX/TimeTracker/issues/new">Request Feature</a></li>
<li><a href="https://github.com/DRYTRIX/TimeTracker/discussions">Ask Question</a></li>
<li><a href="https://github.com/DRYTRIX/TimeTracker/wiki">Wiki</a></li>
</ul>
</div>
</div>
<div class="footer-bottom">
<p>&copy; 2024 TimeTracker. Licensed under <a href="https://github.com/DRYTRIX/TimeTracker/blob/main/LICENSE" style="color: var(--accent-color);">GNU General Public License v3.0</a></p>
<p>Made with ❤️ for the open source community</p>
<div class="social-links">
<a href="https://github.com/DRYTRIX/TimeTracker" title="GitHub">📚</a>
<a href="https://github.com/DRYTRIX/TimeTracker/issues" title="Issues">🐛</a>
<a href="https://github.com/DRYTRIX/TimeTracker/discussions" title="Discussions">💬</a>
<a href="https://github.com/DRYTRIX/TimeTracker/stargazers" title="Stars"></a>
</div>
</div>
</div>
</footer>
<!-- JavaScript for scroll animations -->
<script>
// Scroll animation observer
const observerOptions = {
threshold: 0.1,
rootMargin: '0px 0px -50px 0px'
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('animate');
}
});
}, observerOptions);
// Observe all scroll-animate elements
document.addEventListener('DOMContentLoaded', () => {
const scrollElements = document.querySelectorAll('.scroll-animate');
scrollElements.forEach(el => observer.observe(el));
});
// Smooth scrolling for anchor links
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function (e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute('href'));
if (target) {
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
});
// Add fade-in animation to header elements
document.addEventListener('DOMContentLoaded', () => {
const headerElements = document.querySelectorAll('.header-content > *');
headerElements.forEach((el, index) => {
el.style.animationDelay = `${index * 0.2}s`;
el.classList.add('fade-in-up');
});
});
</script>
</body>
</html>

View File

@@ -136,12 +136,12 @@
{{ entry.project.name }}
</a>
</td>
<td>{{ entry.start_utc.strftime('%Y-%m-%d %H:%M') }}</td>
<td>{{ entry.start_time.strftime('%Y-%m-%d %H:%M') }}</td>
<td>
<strong>{{ entry.duration_formatted }}</strong>
</td>
<td>
{% if entry.end_utc %}
{% if entry.end_time %}
<span class="badge bg-success">Completed</span>
{% else %}
<span class="badge bg-warning">Running</span>

View File

@@ -28,7 +28,127 @@
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" for="timezone">Timezone</label>
<input type="text" class="form-control" id="timezone" name="timezone" value="{{ settings.timezone if settings else 'Europe/Brussels' }}" placeholder="e.g. Europe/Brussels">
<select class="form-select" id="timezone" name="timezone">
<optgroup label="UTC">
<option value="UTC" {% if settings and settings.timezone == 'UTC' %}selected{% endif %}>UTC (UTC+0)</option>
</optgroup>
<optgroup label="Europe">
<option value="Europe/London" {% if settings and settings.timezone == 'Europe/London' %}selected{% endif %}>Europe/London (UTC+0/+1)</option>
<option value="Europe/Paris" {% if settings and settings.timezone == 'Europe/Paris' %}selected{% endif %}>Europe/Paris (UTC+1/+2)</option>
<option value="Europe/Berlin" {% if settings and settings.timezone == 'Europe/Berlin' %}selected{% endif %}>Europe/Berlin (UTC+1/+2)</option>
<option value="Europe/Rome" {% if (settings and settings.timezone == 'Europe/Rome') or not settings %}selected{% endif %}>Europe/Rome (UTC+1/+2)</option>
<option value="Europe/Madrid" {% if settings and settings.timezone == 'Europe/Madrid' %}selected{% endif %}>Europe/Madrid (UTC+1/+2)</option>
<option value="Europe/Amsterdam" {% if settings and settings.timezone == 'Europe/Amsterdam' %}selected{% endif %}>Europe/Amsterdam (UTC+1/+2)</option>
<option value="Europe/Brussels" {% if settings and settings.timezone == 'Europe/Brussels' %}selected{% endif %}>Europe/Brussels (UTC+1/+2)</option>
<option value="Europe/Vienna" {% if settings and settings.timezone == 'Europe/Vienna' %}selected{% endif %}>Europe/Vienna (UTC+1/+2)</option>
<option value="Europe/Zurich" {% if settings and settings.timezone == 'Europe/Zurich' %}selected{% endif %}>Europe/Zurich (UTC+1/+2)</option>
<option value="Europe/Prague" {% if settings and settings.timezone == 'Europe/Prague' %}selected{% endif %}>Europe/Prague (UTC+1/+2)</option>
<option value="Europe/Warsaw" {% if settings and settings.timezone == 'Europe/Warsaw' %}selected{% endif %}>Europe/Warsaw (UTC+1/+2)</option>
<option value="Europe/Budapest" {% if settings and settings.timezone == 'Europe/Budapest' %}selected{% endif %}>Europe/Budapest (UTC+1/+2)</option>
<option value="Europe/Stockholm" {% if settings and settings.timezone == 'Europe/Stockholm' %}selected{% endif %}>Europe/Stockholm (UTC+1/+2)</option>
<option value="Europe/Oslo" {% if settings and settings.timezone == 'Europe/Oslo' %}selected{% endif %}>Europe/Oslo (UTC+1/+2)</option>
<option value="Europe/Copenhagen" {% if settings and settings.timezone == 'Europe/Copenhagen' %}selected{% endif %}>Europe/Copenhagen (UTC+1/+2)</option>
<option value="Europe/Helsinki" {% if settings and settings.timezone == 'Europe/Helsinki' %}selected{% endif %}>Europe/Helsinki (UTC+2/+3)</option>
<option value="Europe/Athens" {% if settings and settings.timezone == 'Europe/Athens' %}selected{% endif %}>Europe/Athens (UTC+2/+3)</option>
<option value="Europe/Istanbul" {% if settings and settings.timezone == 'Europe/Istanbul' %}selected{% endif %}>Europe/Istanbul (UTC+3)</option>
<option value="Europe/Moscow" {% if settings and settings.timezone == 'Europe/Moscow' %}selected{% endif %}>Europe/Moscow (UTC+3)</option>
<option value="Europe/Kiev" {% if settings and settings.timezone == 'Europe/Kiev' %}selected{% endif %}>Europe/Kiev (UTC+2/+3)</option>
</optgroup>
<optgroup label="North America">
<option value="America/New_York" {% if settings and settings.timezone == 'America/New_York' %}selected{% endif %}>America/New_York (UTC-5/-4)</option>
<option value="America/Chicago" {% if settings and settings.timezone == 'America/Chicago' %}selected{% endif %}>America/Chicago (UTC-6/-5)</option>
<option value="America/Denver" {% if settings and settings.timezone == 'America/Denver' %}selected{% endif %}>America/Denver (UTC-7/-6)</option>
<option value="America/Los_Angeles" {% if settings and settings.timezone == 'America/Los_Angeles' %}selected{% endif %}>America/Los_Angeles (UTC-8/-7)</option>
<option value="America/Toronto" {% if settings and settings.timezone == 'America/Toronto' %}selected{% endif %}>America/Toronto (UTC-5/-4)</option>
<option value="America/Vancouver" {% if settings and settings.timezone == 'America/Vancouver' %}selected{% endif %}>America/Vancouver (UTC-8/-7)</option>
<option value="America/Mexico_City" {% if settings and settings.timezone == 'America/Mexico_City' %}selected{% endif %}>America/Mexico_City (UTC-6/-5)</option>
<option value="America/Phoenix" {% if settings and settings.timezone == 'America/Phoenix' %}selected{% endif %}>America/Phoenix (UTC-7)</option>
<option value="America/Anchorage" {% if settings and settings.timezone == 'America/Anchorage' %}selected{% endif %}>America/Anchorage (UTC-9/-8)</option>
<option value="America/Honolulu" {% if settings and settings.timezone == 'America/Honolulu' %}selected{% endif %}>America/Honolulu (UTC-10)</option>
<option value="America/Sao_Paulo" {% if settings and settings.timezone == 'America/Sao_Paulo' %}selected{% endif %}>America/Sao_Paulo (UTC-3/-2)</option>
<option value="America/Buenos_Aires" {% if settings and settings.timezone == 'America/Buenos_Aires' %}selected{% endif %}>America/Buenos_Aires (UTC-3)</option>
<option value="America/Santiago" {% if settings and settings.timezone == 'America/Santiago' %}selected{% endif %}>America/Santiago (UTC-3/-4)</option>
<option value="America/Lima" {% if settings and settings.timezone == 'America/Lima' %}selected{% endif %}>America/Lima (UTC-5)</option>
<option value="America/Bogota" {% if settings and settings.timezone == 'America/Bogota' %}selected{% endif %}>America/Bogota (UTC-5)</option>
<option value="America/Caracas" {% if settings and settings.timezone == 'America/Caracas' %}selected{% endif %}>America/Caracas (UTC-4)</option>
</optgroup>
<optgroup label="Asia">
<option value="Asia/Tokyo" {% if settings and settings.timezone == 'Asia/Tokyo' %}selected{% endif %}>Asia/Tokyo (UTC+9)</option>
<option value="Asia/Shanghai" {% if settings and settings.timezone == 'Asia/Shanghai' %}selected{% endif %}>Asia/Shanghai (UTC+8)</option>
<option value="Asia/Seoul" {% if settings and settings.timezone == 'Asia/Seoul' %}selected{% endif %}>Asia/Seoul (UTC+9)</option>
<option value="Asia/Hong_Kong" {% if settings and settings.timezone == 'Asia/Hong_Kong' %}selected{% endif %}>Asia/Hong_Kong (UTC+8)</option>
<option value="Asia/Singapore" {% if settings and settings.timezone == 'Asia/Singapore' %}selected{% endif %}>Asia/Singapore (UTC+8)</option>
<option value="Asia/Bangkok" {% if settings and settings.timezone == 'Asia/Bangkok' %}selected{% endif %}>Asia/Bangkok (UTC+7)</option>
<option value="Asia/Ho_Chi_Minh" {% if settings and settings.timezone == 'Asia/Ho_Chi_Minh' %}selected{% endif %}>Asia/Ho_Chi_Minh (UTC+7)</option>
<option value="Asia/Jakarta" {% if settings and settings.timezone == 'Asia/Jakarta' %}selected{% endif %}>Asia/Jakarta (UTC+7)</option>
<option value="Asia/Manila" {% if settings and settings.timezone == 'Asia/Manila' %}selected{% endif %}>Asia/Manila (UTC+8)</option>
<option value="Asia/Kolkata" {% if settings and settings.timezone == 'Asia/Kolkata' %}selected{% endif %}>Asia/Kolkata (UTC+5:30)</option>
<option value="Asia/Dhaka" {% if settings and settings.timezone == 'Asia/Dhaka' %}selected{% endif %}>Asia/Dhaka (UTC+6)</option>
<option value="Asia/Kathmandu" {% if settings and settings.timezone == 'Asia/Kathmandu' %}selected{% endif %}>Asia/Kathmandu (UTC+5:45)</option>
<option value="Asia/Tashkent" {% if settings and settings.timezone == 'Asia/Tashkent' %}selected{% endif %}>Asia/Tashkent (UTC+5)</option>
<option value="Asia/Dubai" {% if settings and settings.timezone == 'Asia/Dubai' %}selected{% endif %}>Asia/Dubai (UTC+4)</option>
<option value="Asia/Tehran" {% if settings and settings.timezone == 'Asia/Tehran' %}selected{% endif %}>Asia/Tehran (UTC+3:30/+4:30)</option>
<option value="Asia/Jerusalem" {% if settings and settings.timezone == 'Asia/Jerusalem' %}selected{% endif %}>Asia/Jerusalem (UTC+2/+3)</option>
<option value="Asia/Riyadh" {% if settings and settings.timezone == 'Asia/Riyadh' %}selected{% endif %}>Asia/Riyadh (UTC+3)</option>
<option value="Asia/Baghdad" {% if settings and settings.timezone == 'Asia/Baghdad' %}selected{% endif %}>Asia/Baghdad (UTC+3)</option>
<option value="Asia/Kabul" {% if settings and settings.timezone == 'Asia/Kabul' %}selected{% endif %}>Asia/Kabul (UTC+4:30)</option>
<option value="Asia/Almaty" {% if settings and settings.timezone == 'Asia/Almaty' %}selected{% endif %}>Asia/Almaty (UTC+6)</option>
<option value="Asia/Novosibirsk" {% if settings and settings.timezone == 'Asia/Novosibirsk' %}selected{% endif %}>Asia/Novosibirsk (UTC+7)</option>
<option value="Asia/Vladivostok" {% if settings and settings.timezone == 'Asia/Vladivostok' %}selected{% endif %}>Asia/Vladivostok (UTC+10)</option>
</optgroup>
<optgroup label="Australia & Pacific">
<option value="Australia/Sydney" {% if settings and settings.timezone == 'Australia/Sydney' %}selected{% endif %}>Australia/Sydney (UTC+10/+11)</option>
<option value="Australia/Melbourne" {% if settings and settings.timezone == 'Australia/Melbourne' %}selected{% endif %}>Australia/Melbourne (UTC+10/+11)</option>
<option value="Australia/Brisbane" {% if settings and settings.timezone == 'Australia/Brisbane' %}selected{% endif %}>Australia/Brisbane (UTC+10)</option>
<option value="Australia/Perth" {% if settings and settings.timezone == 'Australia/Perth' %}selected{% endif %}>Australia/Perth (UTC+8)</option>
<option value="Australia/Adelaide" {% if settings and settings.timezone == 'Australia/Adelaide' %}selected{% endif %}>Australia/Adelaide (UTC+9:30/+10:30)</option>
<option value="Australia/Darwin" {% if settings and settings.timezone == 'Australia/Darwin' %}selected{% endif %}>Australia/Darwin (UTC+9:30)</option>
<option value="Pacific/Auckland" {% if settings and settings.timezone == 'Pacific/Auckland' %}selected{% endif %}>Pacific/Auckland (UTC+12/+13)</option>
<option value="Pacific/Fiji" {% if settings and settings.timezone == 'Pacific/Fiji' %}selected{% endif %}>Pacific/Fiji (UTC+12)</option>
<option value="Pacific/Guam" {% if settings and settings.timezone == 'Pacific/Guam' %}selected{% endif %}>Pacific/Guam (UTC+10)</option>
<option value="Pacific/Honolulu" {% if settings and settings.timezone == 'Pacific/Honolulu' %}selected{% endif %}>Pacific/Honolulu (UTC-10)</option>
<option value="Pacific/Tahiti" {% if settings and settings.timezone == 'Pacific/Tahiti' %}selected{% endif %}>Pacific/Tahiti (UTC-10)</option>
</optgroup>
<optgroup label="Africa">
<option value="Africa/Cairo" {% if settings and settings.timezone == 'Africa/Cairo' %}selected{% endif %}>Africa/Cairo (UTC+2)</option>
<option value="Africa/Johannesburg" {% if settings and settings.timezone == 'Africa/Johannesburg' %}selected{% endif %}>Africa/Johannesburg (UTC+2)</option>
<option value="Africa/Lagos" {% if settings and settings.timezone == 'Africa/Lagos' %}selected{% endif %}>Africa/Lagos (UTC+1)</option>
<option value="Africa/Nairobi" {% if settings and settings.timezone == 'Africa/Nairobi' %}selected{% endif %}>Africa/Nairobi (UTC+3)</option>
<option value="Africa/Casablanca" {% if settings and settings.timezone == 'Africa/Casablanca' %}selected{% endif %}>Africa/Casablanca (UTC+0/+1)</option>
<option value="Africa/Algiers" {% if settings and settings.timezone == 'Africa/Algiers' %}selected{% endif %}>Africa/Algiers (UTC+1)</option>
<option value="Africa/Tunis" {% if settings and settings.timezone == 'Africa/Tunis' %}selected{% endif %}>Africa/Tunis (UTC+1)</option>
<option value="Africa/Dar_es_Salaam" {% if settings and settings.timezone == 'Africa/Dar_es_Salaam' %}selected{% endif %}>Africa/Dar_es_Salaam (UTC+3)</option>
<option value="Africa/Addis_Ababa" {% if settings and settings.timezone == 'Africa/Addis_Ababa' %}selected{% endif %}>Africa/Addis_Ababa (UTC+3)</option>
<option value="Africa/Khartoum" {% if settings and settings.timezone == 'Africa/Khartoum' %}selected{% endif %}>Africa/Khartoum (UTC+2)</option>
<option value="Africa/Luanda" {% if settings and settings.timezone == 'Africa/Luanda' %}selected{% endif %}>Africa/Luanda (UTC+1)</option>
<option value="Africa/Kinshasa" {% if settings and settings.timezone == 'Africa/Kinshasa' %}selected{% endif %}>Africa/Kinshasa (UTC+1)</option>
<option value="Africa/Harare" {% if settings and settings.timezone == 'Africa/Harare' %}selected{% endif %}>Africa/Harare (UTC+2)</option>
</optgroup>
<optgroup label="Atlantic & Indian Ocean">
<option value="Atlantic/Reykjavik" {% if settings and settings.timezone == 'Atlantic/Reykjavik' %}selected{% endif %}>Atlantic/Reykjavik (UTC+0)</option>
<option value="Atlantic/Azores" {% if settings and settings.timezone == 'Atlantic/Azores' %}selected{% endif %}>Atlantic/Azores (UTC-1/+0)</option>
<option value="Atlantic/Canary" {% if settings and settings.timezone == 'Atlantic/Canary' %}selected{% endif %}>Atlantic/Canary (UTC+0/+1)</option>
<option value="Atlantic/Cape_Verde" {% if settings and settings.timezone == 'Atlantic/Cape_Verde' %}selected{% endif %}>Atlantic/Cape_Verde (UTC-1)</option>
<option value="Indian/Mauritius" {% if settings and settings.timezone == 'Indian/Mauritius' %}selected{% endif %}>Indian/Mauritius (UTC+4)</option>
<option value="Indian/Reunion" {% if settings and settings.timezone == 'Indian/Reunion' %}selected{% endif %}>Indian/Reunion (UTC+4)</option>
<option value="Indian/Maldives" {% if settings and settings.timezone == 'Indian/Maldives' %}selected{% endif %}>Indian/Maldives (UTC+5)</option>
<option value="Indian/Chagos" {% if settings and settings.timezone == 'Indian/Chagos' %}selected{% endif %}>Indian/Chagos (UTC+6)</option>
</optgroup>
<optgroup label="Arctic & Antarctic">
<option value="Arctic/Longyearbyen" {% if settings and settings.timezone == 'Arctic/Longyearbyen' %}selected{% endif %}>Arctic/Longyearbyen (UTC+1/+2)</option>
<option value="Antarctica/McMurdo" {% if settings and settings.timezone == 'Antarctica/McMurdo' %}selected{% endif %}>Antarctica/McMurdo (UTC+12/+13)</option>
<option value="Antarctica/Palmer" {% if settings and settings.timezone == 'Antarctica/Palmer' %}selected{% endif %}>Antarctica/Palmer (UTC-3)</option>
</optgroup>
</select>
<small class="form-text text-muted">Select your local timezone for proper time display. Times shown include DST adjustments.</small>
</div>
<div class="col-md-6">
<label class="form-label" for="currency">Currency</label>
@@ -80,6 +200,39 @@
<i class="fas fa-save"></i> Save Settings
</button>
</div>
<div class="col-12 mt-3">
<div class="alert alert-info">
<div class="row align-items-center">
<div class="col-md-8">
<i class="fas fa-clock me-2"></i>
<strong>Current Time:</strong>
<span id="current-time-display" class="h5 mb-0 ms-2">Loading...</span>
</div>
<div class="col-md-4 text-md-end">
<small class="text-muted">
in <strong>{{ settings.timezone if settings else 'Europe/Rome' }}</strong> timezone
</small>
</div>
</div>
<div class="mt-2">
<div class="row">
<div class="col-md-6">
<small class="text-muted">
<i class="fas fa-info-circle me-1"></i>
This time updates every second and shows the current time in your selected timezone
</small>
</div>
<div class="col-md-6 text-md-end">
<small class="text-muted">
<i class="fas fa-globe me-1"></i>
Current offset: <span id="timezone-offset">--</span>
</small>
</div>
</div>
</div>
</div>
</div>
</div>
</form>
</div>
@@ -103,6 +256,62 @@
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const timezoneSelect = document.getElementById('timezone');
const currentTimeDisplay = document.getElementById('current-time-display');
function updateCurrentTime() {
const now = new Date();
const timezone = timezoneSelect.value;
if (!timezone) {
currentTimeDisplay.textContent = 'No timezone selected';
document.getElementById('timezone-offset').textContent = '--';
return;
}
try {
// Format time in the selected timezone
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
const localTime = formatter.format(now);
currentTimeDisplay.textContent = localTime;
// Calculate and display timezone offset
const utcTime = new Date(now.toLocaleString("en-US", {timeZone: "UTC"}));
const localTimeObj = new Date(now.toLocaleString("en-US", {timeZone: timezone}));
const offset = (localTimeObj - utcTime) / (1000 * 60 * 60);
const offsetText = offset >= 0 ? `UTC+${offset.toFixed(1)}` : `UTC${offset.toFixed(1)}`;
document.getElementById('timezone-offset').textContent = offsetText;
} catch (e) {
currentTimeDisplay.textContent = 'Invalid timezone';
document.getElementById('timezone-offset').textContent = '--';
}
}
// Update time when timezone changes
timezoneSelect.addEventListener('change', updateCurrentTime);
// Update time every second
setInterval(updateCurrentTime, 1000);
// Initial update with a small delay to ensure DOM is ready
setTimeout(updateCurrentTime, 100);
});
</script>
{% endblock %}

View File

@@ -190,15 +190,15 @@
{% for entry in entries %}
<tr>
<td>{{ entry.user.username }}</td>
<td>{{ entry.start_utc.strftime('%Y-%m-%d') }}</td>
<td>
{{ entry.start_utc.strftime('%H:%M') }} -
{% if entry.end_utc %}
{{ entry.end_utc.strftime('%H:%M') }}
{% else %}
<span class="text-warning">Running</span>
{% endif %}
</td>
<td>{{ entry.start_time.strftime('%Y-%m-%d') }}</td>
<td>
{{ entry.start_time.strftime('%H:%M') }} -
{% if entry.end_time %}
{{ entry.end_time.strftime('%H:%M') }}
{% else %}
<span class="text-warning">Running</span>
{% endif %}
</td>
<td>
<strong>{{ entry.duration_formatted }}</strong>
</td>

View File

@@ -166,7 +166,7 @@
{{ entry.project.name }}
</a>
</td>
<td>{{ entry.start_utc.strftime('%Y-%m-%d') }}</td>
<td>{{ entry.start_time.strftime('%Y-%m-%d') }}</td>
<td>
<strong>{{ entry.duration_formatted }}</strong>
</td>

View File

@@ -262,15 +262,15 @@
{{ entry.project.name }}
</a>
</td>
<td>{{ entry.start_utc.strftime('%Y-%m-%d') }}</td>
<td>
{{ entry.start_utc.strftime('%H:%M') }} -
{% if entry.end_utc %}
{{ entry.end_utc.strftime('%H:%M') }}
{% else %}
<span class="text-warning">Running</span>
{% endif %}
</td>
<td>{{ entry.start_time.strftime('%Y-%m-%d') }}</td>
<td>
{{ entry.start_time.strftime('%H:%M') }} -
{% if entry.end_time %}
{{ entry.end_time.strftime('%H:%M') }}
{% else %}
<span class="text-warning">Running</span>
{% endif %}
</td>
<td>
<strong>{{ entry.duration_formatted }}</strong>
</td>

View File

@@ -207,15 +207,15 @@
{{ entry.project.name }}
</a>
</td>
<td>{{ entry.start_utc.strftime('%Y-%m-%d') }}</td>
<td>
{{ entry.start_utc.strftime('%H:%M') }} -
{% if entry.end_utc %}
{{ entry.end_utc.strftime('%H:%M') }}
{% else %}
<span class="text-warning">Running</span>
{% endif %}
</td>
<td>{{ entry.start_time.strftime('%Y-%m-%d') }}</td>
<td>
{{ entry.start_time.strftime('%H:%M') }} -
{% if entry.end_time %}
{{ entry.end_time.strftime('%H:%M') }}
{% else %}
<span class="text-warning">Running</span>
{% endif %}
</td>
<td>
<strong>{{ entry.duration_formatted }}</strong>
</td>

View File

@@ -21,14 +21,14 @@
</div>
<div class="col-md-3">
<div class="form-control-plaintext">
<strong>Start:</strong> {{ timer.start_utc.strftime('%Y-%m-%d %H:%M') }}
<strong>Start:</strong> {{ timer.start_time.strftime('%Y-%m-%d %H:%M') }}
</div>
</div>
<div class="col-md-3">
<div class="form-control-plaintext">
<strong>End:</strong>
{% if timer.end_utc %}
{{ timer.end_utc.strftime('%Y-%m-%d %H:%M') }}
{% if timer.end_time %}
{{ timer.end_time.strftime('%Y-%m-%d %H:%M') }}
{% else %}
<span class="text-warning">Running</span>
{% endif %}

View File

@@ -412,10 +412,10 @@ function loadRecentEntries() {
${entry.notes ? `<small class="text-muted d-block">${entry.notes}</small>` : ''}
<small class="text-muted">
<i class="fas fa-calendar me-1"></i>
${new Date(entry.start_utc).toLocaleDateString()}
${new Date(entry.start_time).toLocaleDateString()}
<i class="fas fa-clock ms-2 me-1"></i>
${new Date(entry.start_utc).toLocaleTimeString()} -
${entry.end_utc ? new Date(entry.end_utc).toLocaleTimeString() : 'Running'}
${new Date(entry.start_time).toLocaleTimeString()} -
${entry.end_time ? new Date(entry.end_time).toLocaleTimeString() : 'Running'}
</small>
</div>
</div>
@@ -519,16 +519,16 @@ function editEntry(entryId) {
.then(data => {
document.getElementById('editEntryId').value = entryId;
document.getElementById('editProjectSelect').value = data.project_id;
document.getElementById('editStartTime').value = data.start_utc.slice(0, 16);
document.getElementById('editEndTime').value = data.end_utc ? data.end_utc.slice(0, 16) : '';
document.getElementById('editStartTime').value = data.start_time.slice(0, 16);
document.getElementById('editEndTime').value = data.end_time ? data.end_time.slice(0, 16) : '';
document.getElementById('editNotes').value = data.notes || '';
document.getElementById('editTags').value = data.tags || '';
document.getElementById('editBillable').checked = data.billable;
// Calculate and display duration
if (data.end_utc) {
const start = new Date(data.start_utc);
const end = new Date(data.end_utc);
if (data.end_time) {
const start = new Date(data.start_time);
const end = new Date(data.end_time);
const duration = Math.floor((end - start) / 1000);
const hours = Math.floor(duration / 3600);
const minutes = Math.floor((duration % 3600) / 60);

View File

@@ -121,8 +121,8 @@ def test_time_entry_creation(app, user, project):
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_utc=start_time,
end_utc=end_time,
start_time=start_time,
end_time=end_time,
notes='Test entry',
tags='test,work',
source='manual'
@@ -142,19 +142,19 @@ def test_active_timer(app, user, project):
timer = TimeEntry(
user_id=user.id,
project_id=project.id,
start_utc=datetime.utcnow(),
start_time=datetime.utcnow(),
source='auto'
)
db.session.add(timer)
db.session.commit()
assert timer.is_active is True
assert timer.end_utc is None
assert timer.end_time is None
# Stop timer
timer.stop_timer()
assert timer.is_active is False
assert timer.end_utc is not None
assert timer.end_time is not None
assert timer.duration_seconds > 0
def test_user_active_timer_property(app, user, project):
@@ -167,7 +167,7 @@ def test_user_active_timer_property(app, user, project):
timer = TimeEntry(
user_id=user.id,
project_id=project.id,
start_utc=datetime.utcnow(),
start_time=datetime.utcnow(),
source='auto'
)
db.session.add(timer)
@@ -185,15 +185,15 @@ def test_project_totals(app, user, project):
entry1 = TimeEntry(
user_id=user.id,
project_id=project.id,
start_utc=start_time,
end_utc=start_time + timedelta(hours=2),
start_time=start_time,
end_time=start_time + timedelta(hours=2),
source='manual'
)
entry2 = TimeEntry(
user_id=user.id,
project_id=project.id,
start_utc=start_time + timedelta(hours=3),
end_utc=start_time + timedelta(hours=5),
start_time=start_time + timedelta(hours=3),
end_time=start_time + timedelta(hours=5),
source='manual'
)
db.session.add_all([entry1, entry2])

227
tests/test_timezone.py Normal file
View File

@@ -0,0 +1,227 @@
import pytest
from datetime import datetime, timedelta
from app import create_app, db
from app.models import Settings, TimeEntry, User, Project
from app.utils.timezone import get_app_timezone, utc_to_local, local_to_utc, now_in_app_timezone
@pytest.fixture
def app():
"""Create application for testing"""
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:',
'WTF_CSRF_ENABLED': False,
'SECRET_KEY': 'test-secret-key'
})
with app.app_context():
db.create_all()
yield app
db.drop_all()
@pytest.fixture
def client(app):
"""Create test client"""
return app.test_client()
@pytest.fixture
def user(app):
"""Create test user"""
with app.app_context():
user = User(username='testuser', role='user')
db.session.add(user)
db.session.commit()
return user
@pytest.fixture
def project(app):
"""Create test project"""
with app.app_context():
project = Project(name='Test Project', client='Test Client', billable=True, hourly_rate=50.0)
db.session.add(project)
db.session.commit()
return project
def test_timezone_default_from_environment(app):
"""Test that timezone defaults to environment variable when no database settings exist"""
with app.app_context():
# Clear any existing settings
Settings.query.delete()
db.session.commit()
# Test default timezone
timezone = get_app_timezone()
assert timezone == 'Europe/Rome' # Default from config
def test_timezone_from_database_settings(app):
"""Test that timezone is read from database settings"""
with app.app_context():
# Create settings with custom timezone
settings = Settings(timezone='America/New_York')
db.session.add(settings)
db.session.commit()
# Test that timezone is read from database
timezone = get_app_timezone()
assert timezone == 'America/New_York'
def test_timezone_change_affects_display(app, user, project):
"""Test that changing timezone affects how times are displayed"""
with app.app_context():
# Create settings with Europe/Rome timezone
settings = Settings(timezone='Europe/Rome')
db.session.add(settings)
db.session.commit()
# Create a time entry at a specific UTC time
utc_time = datetime.utcnow().replace(hour=12, minute=0, second=0, microsecond=0)
# Refresh the user and project objects to ensure they're attached to the session
user = db.session.merge(user)
project = db.session.merge(project)
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=utc_time,
end_time=utc_time + timedelta(hours=2),
source='manual'
)
db.session.add(entry)
db.session.commit()
# Get time in Rome timezone (UTC+1 or UTC+2 depending on DST)
rome_time = utc_to_local(entry.start_time)
# Change timezone to America/New_York
settings.timezone = 'America/New_York'
db.session.commit()
# Get time in New York timezone (UTC-5 or UTC-4 depending on DST)
ny_time = utc_to_local(entry.start_time)
# Times should be different
assert rome_time != ny_time
# New York time should be earlier than Rome time (behind UTC)
# This is a basic check - actual difference depends on DST
assert ny_time.hour != rome_time.hour or abs(ny_time.hour - rome_time.hour) > 1
def test_timezone_aware_current_time(app):
"""Test that current time is returned in the configured timezone"""
with app.app_context():
# Set timezone to Europe/Rome
settings = Settings(timezone='Europe/Rome')
db.session.add(settings)
db.session.commit()
# Get current time in app timezone
app_time = now_in_app_timezone()
utc_time = datetime.now().replace(tzinfo=None)
# App time should be in Rome timezone
assert app_time.tzinfo is not None
assert 'Europe/Rome' in str(app_time.tzinfo)
# Times should be close (within a few seconds)
time_diff = abs((app_time.replace(tzinfo=None) - utc_time).total_seconds())
assert time_diff < 10
def test_timezone_conversion_utc_to_local(app):
"""Test UTC to local timezone conversion"""
with app.app_context():
# Set timezone to Asia/Tokyo
settings = Settings(timezone='Asia/Tokyo')
db.session.add(settings)
db.session.commit()
# Create a UTC time
utc_time = datetime.utcnow().replace(hour=12, minute=0, second=0, microsecond=0)
# Convert to Tokyo time
tokyo_time = utc_to_local(utc_time)
# Tokyo time should be ahead of UTC (UTC+9)
assert tokyo_time.tzinfo is not None
assert 'Asia/Tokyo' in str(tokyo_time.tzinfo)
# Tokyo time should be ahead of UTC time
# Tokyo is UTC+9, so 12:00 UTC should be 21:00 in Tokyo
assert tokyo_time.hour == 21
def test_timezone_conversion_local_to_utc(app):
"""Test local timezone to UTC conversion"""
with app.app_context():
# Set timezone to Europe/London
settings = Settings(timezone='Europe/London')
db.session.add(settings)
db.session.commit()
# Create a local time (assumed to be in London timezone)
local_time = datetime.now().replace(hour=15, minute=30, second=0, microsecond=0)
# Convert to UTC
utc_time = local_to_utc(local_time)
# UTC time should have timezone info
assert utc_time.tzinfo is not None
# Convert back to local to verify
back_to_local = utc_to_local(utc_time)
assert back_to_local.hour == 15
assert back_to_local.minute == 30
def test_invalid_timezone_fallback(app):
"""Test that invalid timezone falls back to UTC"""
with app.app_context():
# Set invalid timezone
settings = Settings(timezone='Invalid/Timezone')
db.session.add(settings)
db.session.commit()
# Should fall back to UTC
timezone = get_app_timezone()
assert timezone == 'Invalid/Timezone' # Still stored in database
# But timezone object should fall back to UTC
from app.utils.timezone import get_timezone_obj
tz_obj = get_timezone_obj()
assert 'UTC' in str(tz_obj)
def test_timezone_settings_update(app):
"""Test that updating timezone settings takes effect immediately"""
with app.app_context():
# Create initial settings
settings = Settings(timezone='Europe/Rome')
db.session.add(settings)
db.session.commit()
# Verify initial timezone
assert get_app_timezone() == 'Europe/Rome'
# Update timezone
settings.timezone = 'America/Los_Angeles'
db.session.commit()
# Verify timezone change takes effect
assert get_app_timezone() == 'America/Los_Angeles'
# Test that time conversion uses new timezone
utc_time = datetime.utcnow().replace(hour=12, minute=0, second=0, microsecond=0)
la_time = utc_to_local(utc_time)
# LA time should be in Pacific timezone
assert la_time.tzinfo is not None
assert 'America/Los_Angeles' in str(la_time.tzinfo)