mirror of
https://github.com/sstent/GarminSync.git
synced 2026-01-26 00:52:32 +00:00
updated web interface - v3
This commit is contained in:
224
README.md
Normal file
224
README.md
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
# GarminSync
|
||||||
|
|
||||||
|
GarminSync is a powerful Python application that automatically downloads `.fit` files for all your activities from Garmin Connect. It provides both a command-line interface for manual operations and a daemon mode for automatic background synchronization with a web-based dashboard for monitoring and configuration.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **CLI Interface**: List and download activities with flexible filtering options
|
||||||
|
- **Daemon Mode**: Automatic background synchronization with configurable schedules
|
||||||
|
- **Web Dashboard**: Real-time monitoring and configuration through a web interface
|
||||||
|
- **Offline Mode**: Work with cached data without internet connectivity
|
||||||
|
- **Database Tracking**: SQLite database to track download status and file locations
|
||||||
|
- **Rate Limiting**: Respects Garmin Connect's servers with built-in rate limiting
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
- **Backend**: Python 3.10 with SQLAlchemy ORM
|
||||||
|
- **CLI Framework**: Typer for command-line interface
|
||||||
|
- **Web Framework**: FastAPI with Jinja2 templates
|
||||||
|
- **Database**: SQLite for local data storage
|
||||||
|
- **Scheduling**: APScheduler for daemon mode scheduling
|
||||||
|
- **Containerization**: Docker support for easy deployment
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Docker (recommended) OR Python 3.10+
|
||||||
|
- Garmin Connect account credentials
|
||||||
|
|
||||||
|
### Using Docker (Recommended)
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/sstent/GarminSync.git
|
||||||
|
cd GarminSync
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create a `.env` file with your Garmin credentials:
|
||||||
|
```bash
|
||||||
|
echo "GARMIN_EMAIL=your_email@example.com" > .env
|
||||||
|
echo "GARMIN_PASSWORD=your_password" >> .env
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Build the Docker image:
|
||||||
|
```bash
|
||||||
|
docker build -t garminsync .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Python Directly
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/sstent/GarminSync.git
|
||||||
|
cd GarminSync
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create a virtual environment and activate it:
|
||||||
|
```bash
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Install dependencies:
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Create a `.env` file with your Garmin credentials:
|
||||||
|
```bash
|
||||||
|
echo "GARMIN_EMAIL=your_email@example.com" > .env
|
||||||
|
echo "GARMIN_PASSWORD=your_password" >> .env
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### CLI Commands
|
||||||
|
|
||||||
|
List all activities:
|
||||||
|
```bash
|
||||||
|
# Using Docker
|
||||||
|
docker run -it --env-file .env -v $(pwd)/data:/app/data garminsync list --all
|
||||||
|
|
||||||
|
# Using Python directly
|
||||||
|
python -m garminsync.cli list --all
|
||||||
|
```
|
||||||
|
|
||||||
|
List missing activities:
|
||||||
|
```bash
|
||||||
|
# Using Docker
|
||||||
|
docker run -it --env-file .env -v $(pwd)/data:/app/data garminsync list --missing
|
||||||
|
|
||||||
|
# Using Python directly
|
||||||
|
python -m garminsync.cli list --missing
|
||||||
|
```
|
||||||
|
|
||||||
|
List downloaded activities:
|
||||||
|
```bash
|
||||||
|
# Using Docker
|
||||||
|
docker run -it --env-file .env -v $(pwd)/data:/app/data garminsync list --downloaded
|
||||||
|
|
||||||
|
# Using Python directly
|
||||||
|
python -m garminsync.cli list --downloaded
|
||||||
|
```
|
||||||
|
|
||||||
|
Download missing activities:
|
||||||
|
```bash
|
||||||
|
# Using Docker
|
||||||
|
docker run -it --env-file .env -v $(pwd)/data:/app/data garminsync download --missing
|
||||||
|
|
||||||
|
# Using Python directly
|
||||||
|
python -m garminsync.cli download --missing
|
||||||
|
```
|
||||||
|
|
||||||
|
Work offline (without syncing with Garmin Connect):
|
||||||
|
```bash
|
||||||
|
# Using Docker
|
||||||
|
docker run -it --env-file .env -v $(pwd)/data:/app/data garminsync list --missing --offline
|
||||||
|
|
||||||
|
# Using Python directly
|
||||||
|
python -m garminsync.cli list --missing --offline
|
||||||
|
```
|
||||||
|
|
||||||
|
### Daemon Mode
|
||||||
|
|
||||||
|
Start the daemon with web UI:
|
||||||
|
```bash
|
||||||
|
# Using Docker (expose port 8080 for web UI)
|
||||||
|
docker run -it --env-file .env -v $(pwd)/data:/app/data -p 8080:8080 garminsync daemon --start
|
||||||
|
|
||||||
|
# Using Python directly
|
||||||
|
python -m garminsync.cli daemon --start
|
||||||
|
```
|
||||||
|
|
||||||
|
Access the web dashboard at `http://localhost:8080`
|
||||||
|
|
||||||
|
### Web Interface
|
||||||
|
|
||||||
|
The web interface provides real-time monitoring and configuration capabilities:
|
||||||
|
|
||||||
|
1. **Dashboard**: View activity statistics, daemon status, and recent logs
|
||||||
|
2. **Activities**: Browse all activities with detailed information in a sortable table
|
||||||
|
3. **Logs**: Filter and browse synchronization logs with pagination
|
||||||
|
4. **Configuration**: Manage daemon settings and scheduling
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Create a `.env` file in the project root with your Garmin Connect credentials:
|
||||||
|
|
||||||
|
```env
|
||||||
|
GARMIN_EMAIL=your_email@example.com
|
||||||
|
GARMIN_PASSWORD=your_password
|
||||||
|
```
|
||||||
|
|
||||||
|
### Daemon Scheduling
|
||||||
|
|
||||||
|
The daemon uses cron-style scheduling. Configure the schedule through the web UI or by modifying the database directly. Default schedule is every 6 hours (`0 */6 * * *`).
|
||||||
|
|
||||||
|
### Data Storage
|
||||||
|
|
||||||
|
Downloaded `.fit` files and the SQLite database are stored in the `data/` directory by default. When using Docker, this directory is mounted as a volume to persist data between container runs.
|
||||||
|
|
||||||
|
## Web API Endpoints
|
||||||
|
|
||||||
|
The web interface provides RESTful API endpoints for programmatic access:
|
||||||
|
|
||||||
|
- `GET /api/status` - Get daemon status and recent logs
|
||||||
|
- `GET /api/activities/stats` - Get activity statistics
|
||||||
|
- `GET /api/activities` - Get paginated activities with filtering
|
||||||
|
- `GET /api/activities/{activity_id}` - Get detailed activity information
|
||||||
|
- `GET /api/dashboard/stats` - Get comprehensive dashboard statistics
|
||||||
|
- `GET /api/logs` - Get filtered and paginated logs
|
||||||
|
- `POST /api/sync/trigger` - Manually trigger synchronization
|
||||||
|
- `POST /api/schedule` - Update daemon schedule configuration
|
||||||
|
- `POST /api/daemon/start` - Start the daemon
|
||||||
|
- `POST /api/daemon/stop` - Stop the daemon
|
||||||
|
- `DELETE /api/logs` - Clear all logs
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
garminsync/
|
||||||
|
├── garminsync/ # Main application package
|
||||||
|
│ ├── cli.py # Command-line interface
|
||||||
|
│ ├── config.py # Configuration management
|
||||||
|
│ ├── database.py # Database models and operations
|
||||||
|
│ ├── garmin.py # Garmin Connect client wrapper
|
||||||
|
│ ├── daemon.py # Daemon mode implementation
|
||||||
|
│ └── web/ # Web interface components
|
||||||
|
│ ├── app.py # FastAPI application setup
|
||||||
|
│ ├── routes.py # API endpoints
|
||||||
|
│ ├── static/ # CSS, JavaScript files
|
||||||
|
│ └── templates/ # HTML templates
|
||||||
|
├── data/ # Downloaded files and database
|
||||||
|
├── .env # Environment variables (gitignored)
|
||||||
|
├── Dockerfile # Docker configuration
|
||||||
|
├── requirements.txt # Python dependencies
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
(Add test instructions when tests are implemented)
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
- No support for two-factor authentication (2FA)
|
||||||
|
- Limited automatic retry logic for failed downloads
|
||||||
|
- No support for selective activity date range downloads
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues and feature requests, please use the GitHub issue tracker.
|
||||||
@@ -159,6 +159,19 @@ def daemon_mode(
|
|||||||
else:
|
else:
|
||||||
typer.echo("Please specify one of: --start, --stop, --status")
|
typer.echo("Please specify one of: --start, --stop, --status")
|
||||||
|
|
||||||
|
@app.command("migrate")
|
||||||
|
def migrate_activities():
|
||||||
|
"""Migrate database to add new activity fields"""
|
||||||
|
from .migrate_activities import migrate_activities as run_migration
|
||||||
|
|
||||||
|
typer.echo("Starting database migration...")
|
||||||
|
success = run_migration()
|
||||||
|
if success:
|
||||||
|
typer.echo("Database migration completed successfully!")
|
||||||
|
else:
|
||||||
|
typer.echo("Database migration failed!")
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
app()
|
app()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
from sqlalchemy import create_engine, Column, Integer, String, Boolean
|
from sqlalchemy import create_engine, Column, Integer, String, Boolean, Float
|
||||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
@@ -10,9 +10,15 @@ class Activity(Base):
|
|||||||
|
|
||||||
activity_id = Column(Integer, primary_key=True)
|
activity_id = Column(Integer, primary_key=True)
|
||||||
start_time = Column(String, nullable=False)
|
start_time = Column(String, nullable=False)
|
||||||
|
activity_type = Column(String, nullable=True) # NEW
|
||||||
|
duration = Column(Integer, nullable=True) # NEW (seconds)
|
||||||
|
distance = Column(Float, nullable=True) # NEW (meters)
|
||||||
|
max_heart_rate = Column(Integer, nullable=True) # NEW
|
||||||
|
avg_power = Column(Float, nullable=True) # NEW
|
||||||
|
calories = Column(Integer, nullable=True) # NEW
|
||||||
filename = Column(String, unique=True, nullable=True)
|
filename = Column(String, unique=True, nullable=True)
|
||||||
downloaded = Column(Boolean, default=False, nullable=False)
|
downloaded = Column(Boolean, default=False, nullable=False)
|
||||||
created_at = Column(String, nullable=False) # Add this line
|
created_at = Column(String, nullable=False)
|
||||||
last_sync = Column(String, nullable=True) # ISO timestamp of last sync
|
last_sync = Column(String, nullable=True) # ISO timestamp of last sync
|
||||||
|
|
||||||
class DaemonConfig(Base):
|
class DaemonConfig(Base):
|
||||||
|
|||||||
167
garminsync/migrate_activities.py
Normal file
167
garminsync/migrate_activities.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration script to populate new activity fields from Garmin API
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy import create_engine, MetaData, Table, text
|
||||||
|
from sqlalchemy.exc import OperationalError
|
||||||
|
|
||||||
|
# Add the parent directory to the path to import garminsync modules
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from garminsync.database import Activity, get_session, init_db
|
||||||
|
from garminsync.garmin import GarminClient
|
||||||
|
|
||||||
|
|
||||||
|
def add_columns_to_database():
|
||||||
|
"""Add new columns to the activities table if they don't exist"""
|
||||||
|
print("Adding new columns to database...")
|
||||||
|
|
||||||
|
# Get database engine
|
||||||
|
db_path = os.path.join(os.getenv("DATA_DIR", "data"), "garmin.db")
|
||||||
|
engine = create_engine(f"sqlite:///{db_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Reflect the existing database schema
|
||||||
|
metadata = MetaData()
|
||||||
|
metadata.reflect(bind=engine)
|
||||||
|
|
||||||
|
# Get the activities table
|
||||||
|
activities_table = metadata.tables['activities']
|
||||||
|
|
||||||
|
# Check if columns already exist
|
||||||
|
existing_columns = [col.name for col in activities_table.columns]
|
||||||
|
new_columns = ['activity_type', 'duration', 'distance', 'max_heart_rate', 'avg_power', 'calories']
|
||||||
|
|
||||||
|
# Add missing columns
|
||||||
|
with engine.connect() as conn:
|
||||||
|
for column_name in new_columns:
|
||||||
|
if column_name not in existing_columns:
|
||||||
|
print(f"Adding column {column_name}...")
|
||||||
|
if column_name in ['distance', 'avg_power']:
|
||||||
|
conn.execute(text(f"ALTER TABLE activities ADD COLUMN {column_name} REAL"))
|
||||||
|
elif column_name in ['duration', 'max_heart_rate', 'calories']:
|
||||||
|
conn.execute(text(f"ALTER TABLE activities ADD COLUMN {column_name} INTEGER"))
|
||||||
|
else:
|
||||||
|
conn.execute(text(f"ALTER TABLE activities ADD COLUMN {column_name} TEXT"))
|
||||||
|
conn.commit()
|
||||||
|
print(f"Column {column_name} added successfully")
|
||||||
|
else:
|
||||||
|
print(f"Column {column_name} already exists")
|
||||||
|
|
||||||
|
print("Database schema updated successfully")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to update database schema: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_activities():
|
||||||
|
"""Migrate activities to populate new fields from Garmin API"""
|
||||||
|
print("Starting activity migration...")
|
||||||
|
|
||||||
|
# First, add columns to database
|
||||||
|
if not add_columns_to_database():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Initialize Garmin client
|
||||||
|
try:
|
||||||
|
client = GarminClient()
|
||||||
|
print("Garmin client initialized successfully")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to initialize Garmin client: {e}")
|
||||||
|
# Continue with migration but without Garmin data
|
||||||
|
client = None
|
||||||
|
|
||||||
|
# Get database session
|
||||||
|
session = get_session()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get all activities that need to be updated (those with NULL activity_type)
|
||||||
|
activities = session.query(Activity).filter(Activity.activity_type.is_(None)).all()
|
||||||
|
print(f"Found {len(activities)} activities to migrate")
|
||||||
|
|
||||||
|
# If no activities found, try to get all activities (in case activity_type column was just added)
|
||||||
|
if len(activities) == 0:
|
||||||
|
activities = session.query(Activity).all()
|
||||||
|
print(f"Found {len(activities)} total activities")
|
||||||
|
|
||||||
|
updated_count = 0
|
||||||
|
error_count = 0
|
||||||
|
|
||||||
|
for i, activity in enumerate(activities):
|
||||||
|
try:
|
||||||
|
print(f"Processing activity {i+1}/{len(activities)} (ID: {activity.activity_id})")
|
||||||
|
|
||||||
|
# Fetch detailed activity data from Garmin (if client is available)
|
||||||
|
activity_details = None
|
||||||
|
if client:
|
||||||
|
activity_details = client.get_activity_details(activity.activity_id)
|
||||||
|
|
||||||
|
# Update activity fields if we have details
|
||||||
|
if activity_details:
|
||||||
|
# Update activity fields
|
||||||
|
activity.activity_type = activity_details.get('activityType', {}).get('typeKey')
|
||||||
|
|
||||||
|
# Extract duration in seconds
|
||||||
|
duration = activity_details.get('summaryDTO', {}).get('duration')
|
||||||
|
if duration is not None:
|
||||||
|
activity.duration = int(float(duration))
|
||||||
|
|
||||||
|
# Extract distance in meters
|
||||||
|
distance = activity_details.get('summaryDTO', {}).get('distance')
|
||||||
|
if distance is not None:
|
||||||
|
activity.distance = float(distance)
|
||||||
|
|
||||||
|
# Extract max heart rate
|
||||||
|
max_hr = activity_details.get('summaryDTO', {}).get('maxHR')
|
||||||
|
if max_hr is not None:
|
||||||
|
activity.max_heart_rate = int(float(max_hr))
|
||||||
|
|
||||||
|
# Extract average power
|
||||||
|
avg_power = activity_details.get('summaryDTO', {}).get('avgPower')
|
||||||
|
if avg_power is not None:
|
||||||
|
activity.avg_power = float(avg_power)
|
||||||
|
|
||||||
|
# Extract calories
|
||||||
|
calories = activity_details.get('summaryDTO', {}).get('calories')
|
||||||
|
if calories is not None:
|
||||||
|
activity.calories = int(float(calories))
|
||||||
|
else:
|
||||||
|
# Set default values for activity type if we can't get details
|
||||||
|
activity.activity_type = "Unknown"
|
||||||
|
|
||||||
|
# Update last sync timestamp
|
||||||
|
activity.last_sync = datetime.now().isoformat()
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
updated_count += 1
|
||||||
|
|
||||||
|
# Print progress every 10 activities
|
||||||
|
if (i + 1) % 10 == 0:
|
||||||
|
print(f" Progress: {i+1}/{len(activities)} activities processed")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Error processing activity {activity.activity_id}: {e}")
|
||||||
|
session.rollback()
|
||||||
|
error_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"Migration completed. Updated: {updated_count}, Errors: {error_count}")
|
||||||
|
return True # Allow partial success
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Migration failed: {e}")
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
success = migrate_activities()
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
@@ -72,6 +72,16 @@ async def config_page(request: Request):
|
|||||||
"request": request
|
"request": request
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@app.get("/activities")
|
||||||
|
async def activities_page(request: Request):
|
||||||
|
"""Activities page route"""
|
||||||
|
if not templates:
|
||||||
|
return JSONResponse({"message": "Activities endpoint"})
|
||||||
|
|
||||||
|
return templates.TemplateResponse("activities.html", {
|
||||||
|
"request": request
|
||||||
|
})
|
||||||
|
|
||||||
# Error handlers
|
# Error handlers
|
||||||
@app.exception_handler(404)
|
@app.exception_handler(404)
|
||||||
async def not_found_handler(request: Request, exc):
|
async def not_found_handler(request: Request, exc):
|
||||||
@@ -85,4 +95,4 @@ async def server_error_handler(request: Request, exc):
|
|||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
content={"error": "Internal server error", "detail": str(exc)}
|
content={"error": "Internal server error", "detail": str(exc)}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from garminsync.database import get_session, DaemonConfig, SyncLog
|
from garminsync.database import get_session, DaemonConfig, SyncLog, Activity
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
@@ -239,3 +240,91 @@ async def clear_logs():
|
|||||||
raise HTTPException(status_code=500, detail=f"Failed to clear logs: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to clear logs: {str(e)}")
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
@router.get("/activities")
|
||||||
|
async def get_activities(
|
||||||
|
page: int = 1,
|
||||||
|
per_page: int = 50,
|
||||||
|
activity_type: str = None,
|
||||||
|
date_from: str = None,
|
||||||
|
date_to: str = None
|
||||||
|
):
|
||||||
|
"""Get paginated activities with filtering"""
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
query = session.query(Activity)
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
if activity_type:
|
||||||
|
query = query.filter(Activity.activity_type == activity_type)
|
||||||
|
if date_from:
|
||||||
|
query = query.filter(Activity.start_time >= date_from)
|
||||||
|
if date_to:
|
||||||
|
query = query.filter(Activity.start_time <= date_to)
|
||||||
|
|
||||||
|
# Get total count for pagination
|
||||||
|
total = query.count()
|
||||||
|
|
||||||
|
# Apply pagination
|
||||||
|
activities = query.order_by(Activity.start_time.desc()) \
|
||||||
|
.offset((page - 1) * per_page) \
|
||||||
|
.limit(per_page) \
|
||||||
|
.all()
|
||||||
|
|
||||||
|
activity_data = []
|
||||||
|
for activity in activities:
|
||||||
|
activity_data.append({
|
||||||
|
"activity_id": activity.activity_id,
|
||||||
|
"start_time": activity.start_time,
|
||||||
|
"activity_type": activity.activity_type,
|
||||||
|
"duration": activity.duration,
|
||||||
|
"distance": activity.distance,
|
||||||
|
"max_heart_rate": activity.max_heart_rate,
|
||||||
|
"avg_power": activity.avg_power,
|
||||||
|
"calories": activity.calories,
|
||||||
|
"filename": activity.filename,
|
||||||
|
"downloaded": activity.downloaded,
|
||||||
|
"created_at": activity.created_at,
|
||||||
|
"last_sync": activity.last_sync
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"activities": activity_data,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"per_page": per_page
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
@router.get("/activities/{activity_id}")
|
||||||
|
async def get_activity_details(activity_id: int):
|
||||||
|
"""Get detailed activity information"""
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
activity = session.query(Activity).filter(Activity.activity_id == activity_id).first()
|
||||||
|
if not activity:
|
||||||
|
raise HTTPException(status_code=404, detail="Activity not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"activity_id": activity.activity_id,
|
||||||
|
"start_time": activity.start_time,
|
||||||
|
"activity_type": activity.activity_type,
|
||||||
|
"duration": activity.duration,
|
||||||
|
"distance": activity.distance,
|
||||||
|
"max_heart_rate": activity.max_heart_rate,
|
||||||
|
"avg_power": activity.avg_power,
|
||||||
|
"calories": activity.calories,
|
||||||
|
"filename": activity.filename,
|
||||||
|
"downloaded": activity.downloaded,
|
||||||
|
"created_at": activity.created_at,
|
||||||
|
"last_sync": activity.last_sync
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
@router.get("/dashboard/stats")
|
||||||
|
async def get_dashboard_stats():
|
||||||
|
"""Get comprehensive dashboard statistics"""
|
||||||
|
from garminsync.database import get_offline_stats
|
||||||
|
return get_offline_stats()
|
||||||
|
|||||||
138
garminsync/web/static/activities.js
Normal file
138
garminsync/web/static/activities.js
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
class ActivitiesPage {
|
||||||
|
constructor() {
|
||||||
|
this.currentPage = 1;
|
||||||
|
this.pageSize = 25;
|
||||||
|
this.totalPages = 1;
|
||||||
|
this.activities = [];
|
||||||
|
this.filters = {};
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.loadActivities();
|
||||||
|
this.setupEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadActivities() {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: this.currentPage,
|
||||||
|
per_page: this.pageSize,
|
||||||
|
...this.filters
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`/api/activities?${params}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load activities');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
this.activities = data.activities;
|
||||||
|
this.totalPages = Math.ceil(data.total / this.pageSize);
|
||||||
|
|
||||||
|
this.renderTable();
|
||||||
|
this.renderPagination();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load activities:', error);
|
||||||
|
this.showError('Failed to load activities');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTable() {
|
||||||
|
const tbody = document.getElementById('activities-tbody');
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
|
if (!this.activities || this.activities.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6">No activities found</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
this.activities.forEach((activity, index) => {
|
||||||
|
const row = this.createTableRow(activity, index);
|
||||||
|
tbody.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createTableRow(activity, index) {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.className = index % 2 === 0 ? 'row-even' : 'row-odd';
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<td>${Utils.formatDate(activity.start_time)}</td>
|
||||||
|
<td>${activity.activity_type || '-'}</td>
|
||||||
|
<td>${Utils.formatDuration(activity.duration)}</td>
|
||||||
|
<td>${Utils.formatDistance(activity.distance)}</td>
|
||||||
|
<td>${activity.max_heart_rate || '-'}</td>
|
||||||
|
<td>${Utils.formatPower(activity.avg_power)}</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPagination() {
|
||||||
|
const pagination = document.getElementById('pagination');
|
||||||
|
if (!pagination) return;
|
||||||
|
|
||||||
|
if (this.totalPages <= 1) {
|
||||||
|
pagination.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let paginationHtml = '';
|
||||||
|
|
||||||
|
// Previous button
|
||||||
|
paginationHtml += `
|
||||||
|
<li class="${this.currentPage === 1 ? 'disabled' : ''}">
|
||||||
|
<a href="#" onclick="activitiesPage.changePage(${this.currentPage - 1}); return false;">Previous</a>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Page numbers
|
||||||
|
for (let i = 1; i <= this.totalPages; i++) {
|
||||||
|
if (i === 1 || i === this.totalPages || (i >= this.currentPage - 2 && i <= this.currentPage + 2)) {
|
||||||
|
paginationHtml += `
|
||||||
|
<li class="${i === this.currentPage ? 'active' : ''}">
|
||||||
|
<a href="#" onclick="activitiesPage.changePage(${i}); return false;">${i}</a>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
} else if (i === this.currentPage - 3 || i === this.currentPage + 3) {
|
||||||
|
paginationHtml += '<li><span>...</span></li>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next button
|
||||||
|
paginationHtml += `
|
||||||
|
<li class="${this.currentPage === this.totalPages ? 'disabled' : ''}">
|
||||||
|
<a href="#" onclick="activitiesPage.changePage(${this.currentPage + 1}); return false;">Next</a>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
|
||||||
|
pagination.innerHTML = paginationHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
changePage(page) {
|
||||||
|
if (page < 1 || page > this.totalPages) return;
|
||||||
|
this.currentPage = page;
|
||||||
|
this.loadActivities();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
// We can add filter event listeners here if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(message) {
|
||||||
|
const tbody = document.getElementById('activities-tbody');
|
||||||
|
if (tbody) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="6">Error: ${message}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize activities page when DOM is loaded
|
||||||
|
let activitiesPage;
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
activitiesPage = new ActivitiesPage();
|
||||||
|
});
|
||||||
@@ -1,104 +1,3 @@
|
|||||||
// Auto-refresh dashboard data
|
// This file is deprecated and no longer used.
|
||||||
setInterval(updateStatus, 30000); // Every 30 seconds
|
// The functionality has been moved to home.js, activities.js, and logs.js
|
||||||
|
// This file is kept for backward compatibility but is empty.
|
||||||
async function updateStatus() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/status');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Update sync status
|
|
||||||
const syncStatus = document.getElementById('sync-status');
|
|
||||||
const statusBadge = data.daemon.running ?
|
|
||||||
'<span class="badge badge-success">Running</span>' :
|
|
||||||
'<span class="badge badge-danger">Stopped</span>';
|
|
||||||
|
|
||||||
syncStatus.innerHTML = `${statusBadge}`;
|
|
||||||
|
|
||||||
// Update daemon status
|
|
||||||
document.getElementById('daemon-status').innerHTML = `
|
|
||||||
<p>Status: ${statusBadge}</p>
|
|
||||||
<p>Last Run: ${data.daemon.last_run || 'Never'}</p>
|
|
||||||
<p>Next Run: ${data.daemon.next_run || 'Not scheduled'}</p>
|
|
||||||
<p>Schedule: ${data.daemon.schedule || 'Not configured'}</p>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Update recent logs
|
|
||||||
const logsHtml = data.recent_logs.map(log => `
|
|
||||||
<div class="log-entry">
|
|
||||||
<small class="text-muted">${log.timestamp}</small>
|
|
||||||
<span class="badge badge-${log.status === 'success' ? 'success' : 'danger'}">
|
|
||||||
${log.status}
|
|
||||||
</span>
|
|
||||||
${log.operation}: ${log.message || ''}
|
|
||||||
${log.activities_downloaded > 0 ? `Downloaded ${log.activities_downloaded} activities` : ''}
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
document.getElementById('recent-logs').innerHTML = logsHtml;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to update status:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function triggerSync() {
|
|
||||||
try {
|
|
||||||
await fetch('/api/sync/trigger', { method: 'POST' });
|
|
||||||
alert('Sync triggered successfully');
|
|
||||||
updateStatus();
|
|
||||||
} catch (error) {
|
|
||||||
alert('Failed to trigger sync');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleDaemon() {
|
|
||||||
try {
|
|
||||||
const statusResponse = await fetch('/api/status');
|
|
||||||
const statusData = await statusResponse.json();
|
|
||||||
const isRunning = statusData.daemon.running;
|
|
||||||
|
|
||||||
if (isRunning) {
|
|
||||||
await fetch('/api/daemon/stop', { method: 'POST' });
|
|
||||||
alert('Daemon stopped successfully');
|
|
||||||
} else {
|
|
||||||
await fetch('/api/daemon/start', { method: 'POST' });
|
|
||||||
alert('Daemon started successfully');
|
|
||||||
}
|
|
||||||
|
|
||||||
updateStatus();
|
|
||||||
} catch (error) {
|
|
||||||
alert('Failed to toggle daemon: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schedule form handling
|
|
||||||
document.getElementById('schedule-form')?.addEventListener('submit', async function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const enabled = document.getElementById('schedule-enabled').checked;
|
|
||||||
const cronSchedule = document.getElementById('cron-schedule').value;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/schedule', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
enabled: enabled,
|
|
||||||
cron_schedule: cronSchedule
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
alert('Schedule updated successfully');
|
|
||||||
updateStatus();
|
|
||||||
} else {
|
|
||||||
const error = await response.json();
|
|
||||||
alert(`Error: ${error.detail}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
alert('Failed to update schedule: ' + error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize on page load
|
|
||||||
document.addEventListener('DOMContentLoaded', updateStatus);
|
|
||||||
|
|||||||
@@ -1,37 +1 @@
|
|||||||
// Initialize the activity progress chart
|
// This file is deprecated and no longer used.
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Fetch activity stats from the API
|
|
||||||
fetch('/api/activities/stats')
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
// Create doughnut chart
|
|
||||||
const ctx = document.getElementById('activityChart').getContext('2d');
|
|
||||||
const chart = new Chart(ctx, {
|
|
||||||
type: 'doughnut',
|
|
||||||
data: {
|
|
||||||
labels: ['Downloaded', 'Missing'],
|
|
||||||
datasets: [{
|
|
||||||
data: [data.downloaded, data.missing],
|
|
||||||
backgroundColor: ['#28a745', '#dc3545'],
|
|
||||||
borderWidth: 1
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
position: 'top',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: 'Activity Status'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error fetching activity stats:', error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
200
garminsync/web/static/components.css
Normal file
200
garminsync/web/static/components.css
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
/* Table Styling */
|
||||||
|
.activities-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activities-table thead {
|
||||||
|
background-color: #000;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activities-table th {
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
border-right: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activities-table th:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activities-table td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activities-table .row-even {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activities-table .row-odd {
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activities-table tr:hover {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sync Button Styling */
|
||||||
|
.btn-primary.btn-large {
|
||||||
|
width: 100%;
|
||||||
|
padding: 15px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary.btn-large:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,123,255,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary.btn-large:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Statistics Card */
|
||||||
|
.statistics-card .stat-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statistics-card .stat-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statistics-card label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statistics-card span {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
.pagination-container {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination li {
|
||||||
|
margin: 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination a {
|
||||||
|
display: block;
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--primary-color);
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination a:hover {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .active a {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .disabled a {
|
||||||
|
color: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form elements */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0,123,255,0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
background-color: var(--success-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-error {
|
||||||
|
background-color: var(--danger-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
background-color: var(--warning-color);
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table responsive */
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Activities table card */
|
||||||
|
.activities-table-card {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activities-table-card .card-header {
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Activities container */
|
||||||
|
.activities-container {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
144
garminsync/web/static/home.js
Normal file
144
garminsync/web/static/home.js
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
class HomePage {
|
||||||
|
constructor() {
|
||||||
|
this.logSocket = null;
|
||||||
|
this.statsRefreshInterval = null;
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.attachEventListeners();
|
||||||
|
this.setupRealTimeUpdates();
|
||||||
|
this.loadInitialData();
|
||||||
|
}
|
||||||
|
|
||||||
|
attachEventListeners() {
|
||||||
|
const syncButton = document.getElementById('sync-now-btn');
|
||||||
|
if (syncButton) {
|
||||||
|
syncButton.addEventListener('click', () => this.triggerSync());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async triggerSync() {
|
||||||
|
const btn = document.getElementById('sync-now-btn');
|
||||||
|
const status = document.getElementById('sync-status');
|
||||||
|
|
||||||
|
if (!btn || !status) return;
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="icon-loading"></i> Syncing...';
|
||||||
|
status.textContent = 'Sync in progress...';
|
||||||
|
status.className = 'sync-status syncing';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/sync/trigger', {method: 'POST'});
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
status.textContent = 'Sync completed successfully';
|
||||||
|
status.className = 'sync-status success';
|
||||||
|
this.updateStats();
|
||||||
|
} else {
|
||||||
|
throw new Error(result.detail || 'Sync failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
status.textContent = `Sync failed: ${error.message}`;
|
||||||
|
status.className = 'sync-status error';
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="icon-sync"></i> Sync Now';
|
||||||
|
|
||||||
|
// Reset status message after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
if (status.className.includes('success')) {
|
||||||
|
status.textContent = 'Ready to sync';
|
||||||
|
status.className = 'sync-status';
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupRealTimeUpdates() {
|
||||||
|
// Poll for log updates every 5 seconds during active operations
|
||||||
|
this.startLogPolling();
|
||||||
|
|
||||||
|
// Update stats every 30 seconds
|
||||||
|
this.statsRefreshInterval = setInterval(() => {
|
||||||
|
this.updateStats();
|
||||||
|
}, 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async startLogPolling() {
|
||||||
|
// For now, we'll update logs every 10 seconds
|
||||||
|
setInterval(() => {
|
||||||
|
this.updateLogs();
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStats() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/dashboard/stats');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch stats');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await response.json();
|
||||||
|
|
||||||
|
const totalEl = document.getElementById('total-activities');
|
||||||
|
const downloadedEl = document.getElementById('downloaded-activities');
|
||||||
|
const missingEl = document.getElementById('missing-activities');
|
||||||
|
|
||||||
|
if (totalEl) totalEl.textContent = stats.total;
|
||||||
|
if (downloadedEl) downloadedEl.textContent = stats.downloaded;
|
||||||
|
if (missingEl) missingEl.textContent = stats.missing;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update stats:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLogs() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/status');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch logs');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
this.renderLogs(data.recent_logs);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update logs:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLogs(logs) {
|
||||||
|
const logContent = document.getElementById('log-content');
|
||||||
|
if (!logContent) return;
|
||||||
|
|
||||||
|
if (!logs || logs.length === 0) {
|
||||||
|
logContent.innerHTML = '<div class="log-entry">No recent activity</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logsHtml = logs.map(log => `
|
||||||
|
<div class="log-entry">
|
||||||
|
<span class="timestamp">${Utils.formatTimestamp(log.timestamp)}</span>
|
||||||
|
<span class="status ${log.status === 'success' ? 'success' : 'error'}">
|
||||||
|
${log.status}
|
||||||
|
</span>
|
||||||
|
${log.operation}: ${log.message || ''}
|
||||||
|
${log.activities_downloaded > 0 ? `Downloaded ${log.activities_downloaded} activities` : ''}
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
logContent.innerHTML = logsHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadInitialData() {
|
||||||
|
// Load initial logs
|
||||||
|
await this.updateLogs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize home page when DOM is loaded
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
new HomePage();
|
||||||
|
});
|
||||||
@@ -4,118 +4,176 @@ const logsPerPage = 20;
|
|||||||
let totalLogs = 0;
|
let totalLogs = 0;
|
||||||
let currentFilters = {};
|
let currentFilters = {};
|
||||||
|
|
||||||
// Initialize logs page
|
class LogsPage {
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
constructor() {
|
||||||
loadLogs();
|
this.currentPage = 1;
|
||||||
});
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.loadLogs();
|
||||||
|
this.setupEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadLogs() {
|
||||||
|
try {
|
||||||
|
// Build query string from filters
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: this.currentPage,
|
||||||
|
per_page: logsPerPage,
|
||||||
|
...currentFilters
|
||||||
|
}).toString();
|
||||||
|
|
||||||
async function loadLogs() {
|
const response = await fetch(`/api/logs?${params}`);
|
||||||
try {
|
if (!response.ok) {
|
||||||
// Build query string from filters
|
throw new Error('Failed to fetch logs');
|
||||||
const params = new URLSearchParams({
|
}
|
||||||
page: currentPage,
|
|
||||||
perPage: logsPerPage,
|
const data = await response.json();
|
||||||
...currentFilters
|
totalLogs = data.total;
|
||||||
}).toString();
|
this.renderLogs(data.logs);
|
||||||
|
this.renderPagination();
|
||||||
const response = await fetch(`/api/logs?${params}`);
|
} catch (error) {
|
||||||
if (!response.ok) {
|
console.error('Error loading logs:', error);
|
||||||
throw new Error('Failed to fetch logs');
|
Utils.showError('Failed to load logs: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLogs(logs) {
|
||||||
|
const tbody = document.getElementById('logs-tbody');
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
if (!logs || logs.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6">No logs found</td></tr>';
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
logs.forEach(log => {
|
||||||
totalLogs = data.total;
|
const row = document.createElement('tr');
|
||||||
renderLogs(data.logs);
|
row.className = 'row-odd'; // For alternating row colors
|
||||||
renderPagination();
|
|
||||||
} catch (error) {
|
row.innerHTML = `
|
||||||
console.error('Error loading logs:', error);
|
<td>${Utils.formatTimestamp(log.timestamp)}</td>
|
||||||
alert('Failed to load logs: ' + error.message);
|
<td>${log.operation}</td>
|
||||||
|
<td><span class="badge badge-${log.status === 'success' ? 'success' :
|
||||||
|
log.status === 'error' ? 'error' :
|
||||||
|
'warning'}">${log.status}</span></td>
|
||||||
|
<td>${log.message || ''}</td>
|
||||||
|
<td>${log.activities_processed}</td>
|
||||||
|
<td>${log.activities_downloaded}</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
tbody.appendChild(row);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function renderLogs(logs) {
|
|
||||||
const tbody = document.getElementById('logs-tbody');
|
|
||||||
tbody.innerHTML = '';
|
|
||||||
|
|
||||||
logs.forEach(log => {
|
renderPagination() {
|
||||||
const row = document.createElement('tr');
|
const totalPages = Math.ceil(totalLogs / logsPerPage);
|
||||||
|
const pagination = document.getElementById('pagination');
|
||||||
|
if (!pagination) return;
|
||||||
|
|
||||||
row.innerHTML = `
|
if (totalPages <= 1) {
|
||||||
<td>${log.timestamp}</td>
|
pagination.innerHTML = '';
|
||||||
<td>${log.operation}</td>
|
return;
|
||||||
<td><span class="badge badge-${log.status === 'success' ? 'success' :
|
}
|
||||||
log.status === 'error' ? 'danger' :
|
|
||||||
'warning'}">${log.status}</span></td>
|
let paginationHtml = '';
|
||||||
<td>${log.message || ''}</td>
|
|
||||||
<td>${log.activities_processed}</td>
|
// Previous button
|
||||||
<td>${log.activities_downloaded}</td>
|
paginationHtml += `
|
||||||
|
<li class="${this.currentPage === 1 ? 'disabled' : ''}">
|
||||||
|
<a href="#" onclick="logsPage.changePage(${this.currentPage - 1}); return false;">Previous</a>
|
||||||
|
</li>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
tbody.appendChild(row);
|
// Page numbers
|
||||||
});
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
}
|
if (i === 1 || i === totalPages || (i >= this.currentPage - 2 && i <= this.currentPage + 2)) {
|
||||||
|
paginationHtml += `
|
||||||
function renderPagination() {
|
<li class="${i === this.currentPage ? 'active' : ''}">
|
||||||
const totalPages = Math.ceil(totalLogs / logsPerPage);
|
<a href="#" onclick="logsPage.changePage(${i}); return false;">${i}</a>
|
||||||
const pagination = document.getElementById('pagination');
|
</li>
|
||||||
pagination.innerHTML = '';
|
`;
|
||||||
|
} else if (i === this.currentPage - 3 || i === this.currentPage + 3) {
|
||||||
// Previous button
|
paginationHtml += '<li><span>...</span></li>';
|
||||||
const prevLi = document.createElement('li');
|
}
|
||||||
prevLi.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`;
|
}
|
||||||
prevLi.innerHTML = `<a class="page-link" href="#" onclick="changePage(${currentPage - 1})">Previous</a>`;
|
|
||||||
pagination.appendChild(prevLi);
|
// Next button
|
||||||
|
paginationHtml += `
|
||||||
// Page numbers
|
<li class="${this.currentPage === totalPages ? 'disabled' : ''}">
|
||||||
for (let i = 1; i <= totalPages; i++) {
|
<a href="#" onclick="logsPage.changePage(${this.currentPage + 1}); return false;">Next</a>
|
||||||
const li = document.createElement('li');
|
</li>
|
||||||
li.className = `page-item ${i === currentPage ? 'active' : ''}`;
|
`;
|
||||||
li.innerHTML = `<a class="page-link" href="#" onclick="changePage(${i})">${i}</a>`;
|
|
||||||
pagination.appendChild(li);
|
pagination.innerHTML = paginationHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Next button
|
changePage(page) {
|
||||||
const nextLi = document.createElement('li');
|
if (page < 1 || page > Math.ceil(totalLogs / logsPerPage)) return;
|
||||||
nextLi.className = `page-item ${currentPage === totalPages ? 'disabled' : ''}`;
|
this.currentPage = page;
|
||||||
nextLi.innerHTML = `<a class="page-link" href="#" onclick="changePage(${currentPage + 1})">Next</a>`;
|
this.loadLogs();
|
||||||
pagination.appendChild(nextLi);
|
}
|
||||||
|
|
||||||
|
refreshLogs() {
|
||||||
|
this.currentPage = 1;
|
||||||
|
this.loadLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
applyFilters() {
|
||||||
|
currentFilters = {
|
||||||
|
status: document.getElementById('status-filter').value,
|
||||||
|
operation: document.getElementById('operation-filter').value,
|
||||||
|
date: document.getElementById('date-filter').value
|
||||||
|
};
|
||||||
|
|
||||||
|
this.currentPage = 1;
|
||||||
|
this.loadLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearLogs() {
|
||||||
|
if (!confirm('Are you sure you want to clear all logs? This cannot be undone.')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/logs', { method: 'DELETE' });
|
||||||
|
if (response.ok) {
|
||||||
|
Utils.showSuccess('Logs cleared successfully');
|
||||||
|
this.refreshLogs();
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to clear logs');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error clearing logs:', error);
|
||||||
|
Utils.showError('Failed to clear logs: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
// Event listeners are handled in the global functions below
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize logs page when DOM is loaded
|
||||||
|
let logsPage;
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
logsPage = new LogsPage();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global functions for backward compatibility with HTML onclick attributes
|
||||||
function changePage(page) {
|
function changePage(page) {
|
||||||
if (page < 1 || page > Math.ceil(totalLogs / logsPerPage)) return;
|
if (logsPage) logsPage.changePage(page);
|
||||||
currentPage = page;
|
|
||||||
loadLogs();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshLogs() {
|
function refreshLogs() {
|
||||||
currentPage = 1;
|
if (logsPage) logsPage.refreshLogs();
|
||||||
loadLogs();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyFilters() {
|
function applyFilters() {
|
||||||
currentFilters = {
|
if (logsPage) logsPage.applyFilters();
|
||||||
status: document.getElementById('status-filter').value,
|
|
||||||
operation: document.getElementById('operation-filter').value,
|
|
||||||
date: document.getElementById('date-filter').value
|
|
||||||
};
|
|
||||||
|
|
||||||
currentPage = 1;
|
|
||||||
loadLogs();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearLogs() {
|
function clearLogs() {
|
||||||
if (!confirm('Are you sure you want to clear all logs? This cannot be undone.')) return;
|
if (logsPage) logsPage.clearLogs();
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/logs', { method: 'DELETE' });
|
|
||||||
if (response.ok) {
|
|
||||||
alert('Logs cleared successfully');
|
|
||||||
refreshLogs();
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to clear logs');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error clearing logs:', error);
|
|
||||||
alert('Failed to clear logs: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
52
garminsync/web/static/navigation.js
Normal file
52
garminsync/web/static/navigation.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
class Navigation {
|
||||||
|
constructor() {
|
||||||
|
this.currentPage = this.getCurrentPage();
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentPage() {
|
||||||
|
return window.location.pathname === '/activities' ? 'activities' : 'home';
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const nav = document.querySelector('.navigation');
|
||||||
|
if (nav) {
|
||||||
|
nav.innerHTML = this.getNavigationHTML();
|
||||||
|
this.attachEventListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getNavigationHTML() {
|
||||||
|
return `
|
||||||
|
<nav class="nav-tabs">
|
||||||
|
<button class="nav-tab ${this.currentPage === 'home' ? 'active' : ''}"
|
||||||
|
data-page="home">Home</button>
|
||||||
|
<button class="nav-tab ${this.currentPage === 'activities' ? 'active' : ''}"
|
||||||
|
data-page="activities">Activities</button>
|
||||||
|
</nav>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
attachEventListeners() {
|
||||||
|
const tabs = document.querySelectorAll('.nav-tab');
|
||||||
|
tabs.forEach(tab => {
|
||||||
|
tab.addEventListener('click', (e) => {
|
||||||
|
const page = e.target.getAttribute('data-page');
|
||||||
|
this.navigateToPage(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
navigateToPage(page) {
|
||||||
|
if (page === 'home') {
|
||||||
|
window.location.href = '/';
|
||||||
|
} else if (page === 'activities') {
|
||||||
|
window.location.href = '/activities';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize navigation when DOM is loaded
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
new Navigation();
|
||||||
|
});
|
||||||
78
garminsync/web/static/responsive.css
Normal file
78
garminsync/web/static/responsive.css
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/* Mobile-first responsive design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.layout-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activities-table {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activities-table th,
|
||||||
|
.activities-table td {
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 8px 15px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-large {
|
||||||
|
padding: 12px 20px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.activities-table {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-content {
|
||||||
|
padding: 5px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry {
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination a {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,32 +1,268 @@
|
|||||||
body {
|
/* CSS Variables for consistent theming */
|
||||||
font-family: Arial, sans-serif;
|
:root {
|
||||||
background-color: #f8f9fa;
|
--primary-color: #007bff;
|
||||||
|
--secondary-color: #6c757d;
|
||||||
|
--success-color: #28a745;
|
||||||
|
--danger-color: #dc3545;
|
||||||
|
--warning-color: #ffc107;
|
||||||
|
--light-gray: #f8f9fa;
|
||||||
|
--dark-gray: #343a40;
|
||||||
|
--border-radius: 8px;
|
||||||
|
--box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
--font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Reset and base styles */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-family);
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSS Grid Layout System */
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 300px 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
min-height: calc(100vh - 60px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Card Components */
|
||||||
.card {
|
.card {
|
||||||
|
background: white;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--box-shadow);
|
||||||
|
padding: 20px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
font-weight: bold;
|
font-weight: 600;
|
||||||
background-color: #f1f1f1;
|
font-size: 1.2rem;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Navigation */
|
||||||
|
.navigation {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs {
|
||||||
|
display: flex;
|
||||||
|
background: white;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--box-shadow);
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab:hover {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab.active {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
.btn {
|
.btn {
|
||||||
margin-right: 5px;
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, #0056b3 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,123,255,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background-color: var(--success-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: var(--danger-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning {
|
||||||
|
background-color: var(--warning-color);
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-large {
|
||||||
|
padding: 15px 25px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icons */
|
||||||
|
.icon-sync::before {
|
||||||
|
content: "↻";
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-loading::before {
|
||||||
|
content: "⏳";
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status display */
|
||||||
|
.sync-status {
|
||||||
|
margin-top: 15px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-status.syncing {
|
||||||
|
background-color: #e3f2fd;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-status.success {
|
||||||
|
background-color: #e8f5e9;
|
||||||
|
color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-status.error {
|
||||||
|
background-color: #ffebee;
|
||||||
|
color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Statistics */
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item span {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Log display */
|
||||||
|
.log-content {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-entry {
|
.log-entry {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 8px;
|
||||||
padding: 5px;
|
padding: 8px;
|
||||||
border-left: 3px solid #ddd;
|
border-left: 3px solid #ddd;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 0 var(--border-radius) var(--border-radius) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-entry .badge-success {
|
.log-entry .timestamp {
|
||||||
background-color: #28a745;
|
font-size: 0.8rem;
|
||||||
|
color: #666;
|
||||||
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-entry .badge-error {
|
.log-entry .status {
|
||||||
background-color: #dc3545;
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry .status.success {
|
||||||
|
background-color: var(--success-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry .status.error {
|
||||||
|
background-color: var(--danger-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.layout-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
garminsync/web/static/utils.js
Normal file
50
garminsync/web/static/utils.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// Utility functions for the GarminSync application
|
||||||
|
|
||||||
|
class Utils {
|
||||||
|
// Format date for display
|
||||||
|
static formatDate(dateStr) {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
return new Date(dateStr).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format duration from seconds to HH:MM
|
||||||
|
static formatDuration(seconds) {
|
||||||
|
if (!seconds) return '-';
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
return `${hours}:${minutes.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format distance from meters to kilometers
|
||||||
|
static formatDistance(meters) {
|
||||||
|
if (!meters) return '-';
|
||||||
|
return `${(meters / 1000).toFixed(1)} km`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format power from watts
|
||||||
|
static formatPower(watts) {
|
||||||
|
return watts ? `${Math.round(watts)}W` : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show error message
|
||||||
|
static showError(message) {
|
||||||
|
console.error(message);
|
||||||
|
// In a real implementation, you might want to show this in the UI
|
||||||
|
alert(`Error: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
static showSuccess(message) {
|
||||||
|
console.log(message);
|
||||||
|
// In a real implementation, you might want to show this in the UI
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format timestamp for log entries
|
||||||
|
static formatTimestamp(timestamp) {
|
||||||
|
if (!timestamp) return '';
|
||||||
|
return new Date(timestamp).toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make Utils available globally
|
||||||
|
window.Utils = Utils;
|
||||||
42
garminsync/web/templates/activities.html
Normal file
42
garminsync/web/templates/activities.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container">
|
||||||
|
<div class="navigation"></div>
|
||||||
|
|
||||||
|
<div class="activities-container">
|
||||||
|
<div class="card activities-table-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Activities</h3>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="activities-table" id="activities-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Activity Type</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th>Distance</th>
|
||||||
|
<th>Max HR</th>
|
||||||
|
<th>Power</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="activities-tbody">
|
||||||
|
<!-- Data populated by JavaScript -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination-container">
|
||||||
|
<div class="pagination" id="pagination">
|
||||||
|
<!-- Pagination controls -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block page_scripts %}
|
||||||
|
<script src="/static/activities.js"></script>
|
||||||
|
{% endblock %}
|
||||||
@@ -3,46 +3,17 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>GarminSync Dashboard</title>
|
<title>GarminSync</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link href="/static/style.css" rel="stylesheet">
|
<link href="/static/style.css" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
<link href="/static/components.css" rel="stylesheet">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<link href="/static/responsive.css" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
{% block content %}{% endblock %}
|
||||||
<div class="container">
|
|
||||||
<a class="navbar-brand" href="/">GarminSync</a>
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
|
||||||
<ul class="navbar-nav">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link active" href="/">
|
|
||||||
<i class="fas fa-tachometer-alt me-1"></i> Dashboard
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="/logs">
|
|
||||||
<i class="fas fa-clipboard-list me-1"></i> Logs
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="/config">
|
|
||||||
<i class="fas fa-cog me-1"></i> Configuration
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="container mt-4">
|
<script src="/static/navigation.js"></script>
|
||||||
{% block content %}{% endblock %}
|
<script src="/static/utils.js"></script>
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
{% block page_scripts %}{% endblock %}
|
||||||
<script src="/static/app.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>GarminSync Configuration</h1>
|
<div class="navigation"></div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="card">
|
||||||
<div class="col-12">
|
<div class="card-header">
|
||||||
<div class="card">
|
<h3>GarminSync Configuration</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card mb-4">
|
||||||
<div class="card-header">Daemon Settings</div>
|
<div class="card-header">Daemon Settings</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form id="daemon-config-form">
|
<form id="daemon-config-form">
|
||||||
@@ -28,20 +31,25 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mt-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">Daemon Status</div>
|
<div class="card-header">Daemon Status</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p>Current Status: <span id="daemon-status-text">{{ config.status|capitalize }}</span></p>
|
<div class="stat-item">
|
||||||
<p>Last Run: <span id="daemon-last-run">{{ config.last_run or 'Never' }}</span></p>
|
<label>Current Status:</label>
|
||||||
<p>Next Run: <span id="daemon-next-run">{{ config.next_run or 'Not scheduled' }}</span></p>
|
<span id="daemon-status-text">{{ config.status|capitalize }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<label>Last Run:</label>
|
||||||
|
<span id="daemon-last-run">{{ config.last_run or 'Never' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<label>Next Run:</label>
|
||||||
|
<span id="daemon-next-run">{{ config.next_run or 'Not scheduled' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<button id="start-daemon-btn" class="btn btn-success mr-2">
|
<button id="start-daemon-btn" class="btn btn-success">
|
||||||
Start Daemon
|
Start Daemon
|
||||||
</button>
|
</button>
|
||||||
<button id="stop-daemon-btn" class="btn btn-danger">
|
<button id="stop-daemon-btn" class="btn btn-danger">
|
||||||
@@ -53,7 +61,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block page_scripts %}
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Form submission handler
|
// Form submission handler
|
||||||
@@ -74,14 +84,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
alert('Configuration saved successfully');
|
Utils.showSuccess('Configuration saved successfully');
|
||||||
updateStatus();
|
updateStatus();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
alert(`Error: ${error.detail}`);
|
Utils.showError(`Error: ${error.detail}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert('Failed to save configuration: ' + error.message);
|
Utils.showError('Failed to save configuration: ' + error.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,14 +100,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/daemon/start', { method: 'POST' });
|
const response = await fetch('/api/daemon/start', { method: 'POST' });
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
alert('Daemon started successfully');
|
Utils.showSuccess('Daemon started successfully');
|
||||||
updateStatus();
|
updateStatus();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
alert(`Error: ${error.detail}`);
|
Utils.showError(`Error: ${error.detail}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert('Failed to start daemon: ' + error.message);
|
Utils.showError('Failed to start daemon: ' + error.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,14 +115,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/daemon/stop', { method: 'POST' });
|
const response = await fetch('/api/daemon/stop', { method: 'POST' });
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
alert('Daemon stopped successfully');
|
Utils.showSuccess('Daemon stopped successfully');
|
||||||
updateStatus();
|
updateStatus();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
alert(`Error: ${error.detail}`);
|
Utils.showError(`Error: ${error.detail}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert('Failed to stop daemon: ' + error.message);
|
Utils.showError('Failed to stop daemon: ' + error.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,100 +2,46 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>GarminSync Dashboard</h1>
|
<div class="navigation"></div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="layout-grid">
|
||||||
<!-- Real-time Activity Counter -->
|
<!-- Left Sidebar -->
|
||||||
<div class="col-md-3">
|
<div class="sidebar">
|
||||||
<div class="card bg-info text-white">
|
<div class="card sync-card">
|
||||||
<div class="card-body">
|
<button id="sync-now-btn" class="btn btn-primary btn-large">
|
||||||
<h4 id="sync-status">Idle</h4>
|
<i class="icon-sync"></i>
|
||||||
<p>Current Operation</p>
|
Sync Now
|
||||||
|
</button>
|
||||||
|
<div class="sync-status" id="sync-status">
|
||||||
|
Ready to sync
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card statistics-card">
|
||||||
|
<h3>Statistics</h3>
|
||||||
|
<div class="stat-item">
|
||||||
|
<label>Total Activities:</label>
|
||||||
|
<span id="total-activities">{{stats.total}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<label>Downloaded:</label>
|
||||||
|
<span id="downloaded-activities">{{stats.downloaded}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<label>Missing:</label>
|
||||||
|
<span id="missing-activities">{{stats.missing}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Activity Progress Chart -->
|
<!-- Right Content Area -->
|
||||||
<div class="col-md-5">
|
<div class="main-content">
|
||||||
<div class="card">
|
<div class="card log-display">
|
||||||
<div class="card-header">Activity Progress</div>
|
<div class="card-header">
|
||||||
<div class="card-body">
|
<h3>Log Data</h3>
|
||||||
<canvas id="activityChart" width="400" height="200"></canvas>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="log-content" id="log-content">
|
||||||
</div>
|
<!-- Real-time log updates will appear here -->
|
||||||
|
|
||||||
<!-- Daemon Status -->
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">Daemon Status</div>
|
|
||||||
<div class="card-body" id="daemon-status">
|
|
||||||
<!-- Populated by JavaScript -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mt-4">
|
|
||||||
<!-- Statistics Card -->
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">Statistics</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p>Total Activities: {{ stats.total }}</p>
|
|
||||||
<p>Downloaded: {{ stats.downloaded }}</p>
|
|
||||||
<p>Missing: {{ stats.missing }}</p>
|
|
||||||
<p>Last Sync: {{ stats.last_sync }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quick Actions Card -->
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">Quick Actions</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<button class="btn btn-primary" onclick="triggerSync()">
|
|
||||||
Sync Now
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary" onclick="toggleDaemon()">
|
|
||||||
Toggle Daemon
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mt-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">Recent Activity</div>
|
|
||||||
<div class="card-body" id="recent-logs">
|
|
||||||
<!-- Populated by JavaScript -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mt-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">Schedule Configuration</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form id="schedule-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="schedule-enabled">Enable Scheduled Sync</label>
|
|
||||||
<input type="checkbox" id="schedule-enabled">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="cron-schedule">Cron Schedule</label>
|
|
||||||
<input type="text" class="form-control" id="cron-schedule"
|
|
||||||
placeholder="0 */6 * * *" title="Every 6 hours">
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
Update Schedule
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,6 +49,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block page_scripts %}
|
||||||
<script src="/static/charts.js"></script>
|
<script src="/static/home.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -2,51 +2,51 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="navigation"></div>
|
||||||
<h1>Sync Logs</h1>
|
|
||||||
<div>
|
|
||||||
<button class="btn btn-secondary" onclick="refreshLogs()">Refresh</button>
|
|
||||||
<button class="btn btn-warning" onclick="clearLogs()">Clear Logs</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filters -->
|
<div class="card">
|
||||||
<div class="card mb-4">
|
<div class="card-header">
|
||||||
<div class="card-header">Filters</div>
|
<h3>Sync Logs</h3>
|
||||||
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row">
|
<!-- Filters -->
|
||||||
<div class="col-md-3">
|
<div class="card mb-4">
|
||||||
<select id="status-filter" class="form-control">
|
<div class="card-header">Filters</div>
|
||||||
<option value="">All Statuses</option>
|
<div class="card-body">
|
||||||
<option value="success">Success</option>
|
<div class="form-group">
|
||||||
<option value="error">Error</option>
|
<label for="status-filter">Status</label>
|
||||||
<option value="partial">Partial</option>
|
<select id="status-filter" class="form-control">
|
||||||
</select>
|
<option value="">All Statuses</option>
|
||||||
</div>
|
<option value="success">Success</option>
|
||||||
<div class="col-md-3">
|
<option value="error">Error</option>
|
||||||
<select id="operation-filter" class="form-control">
|
<option value="partial">Partial</option>
|
||||||
<option value="">All Operations</option>
|
</select>
|
||||||
<option value="sync">Sync</option>
|
</div>
|
||||||
<option value="download">Download</option>
|
|
||||||
<option value="daemon">Daemon</option>
|
<div class="form-group">
|
||||||
</select>
|
<label for="operation-filter">Operation</label>
|
||||||
</div>
|
<select id="operation-filter" class="form-control">
|
||||||
<div class="col-md-3">
|
<option value="">All Operations</option>
|
||||||
<input type="date" id="date-filter" class="form-control">
|
<option value="sync">Sync</option>
|
||||||
</div>
|
<option value="download">Download</option>
|
||||||
<div class="col-md-3">
|
<option value="daemon">Daemon</option>
|
||||||
<button class="btn btn-primary" onclick="applyFilters()">Apply</button>
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="date-filter">Date</label>
|
||||||
|
<input type="date" id="date-filter" class="form-control">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" onclick="applyFilters()">Apply Filters</button>
|
||||||
|
<button class="btn btn-secondary" onclick="refreshLogs()">Refresh</button>
|
||||||
|
<button class="btn btn-warning" onclick="clearLogs()">Clear Logs</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
<!-- Logs Table -->
|
||||||
|
<div class="table-container">
|
||||||
<!-- Logs Table -->
|
<table class="activities-table" id="logs-table">
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">Log Entries</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-striped" id="logs-table">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Timestamp</th>
|
<th>Timestamp</th>
|
||||||
@@ -64,16 +64,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
<nav>
|
<div class="pagination-container">
|
||||||
<ul class="pagination justify-content-center" id="pagination">
|
<div class="pagination" id="pagination">
|
||||||
<!-- Populated by JavaScript -->
|
<!-- Populated by JavaScript -->
|
||||||
</ul>
|
</div>
|
||||||
</nav>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block page_scripts %}
|
||||||
<script src="/static/logs.js"></script>
|
<script src="/static/logs.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
125
garminsync/web/test_ui.py
Normal file
125
garminsync/web/test_ui.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Simple test script to verify the new UI is working correctly
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add the parent directory to the path to import garminsync modules
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||||
|
|
||||||
|
def test_ui_endpoints():
|
||||||
|
"""Test that the new UI endpoints are working correctly"""
|
||||||
|
base_url = "http://localhost:8000"
|
||||||
|
|
||||||
|
# Test endpoints to check
|
||||||
|
endpoints = [
|
||||||
|
"/",
|
||||||
|
"/activities",
|
||||||
|
"/config",
|
||||||
|
"/logs",
|
||||||
|
"/api/status",
|
||||||
|
"/api/activities/stats",
|
||||||
|
"/api/dashboard/stats"
|
||||||
|
]
|
||||||
|
|
||||||
|
print("Testing UI endpoints...")
|
||||||
|
|
||||||
|
failed_endpoints = []
|
||||||
|
|
||||||
|
for endpoint in endpoints:
|
||||||
|
try:
|
||||||
|
url = base_url + endpoint
|
||||||
|
print(f"Testing {url}...")
|
||||||
|
|
||||||
|
response = requests.get(url, timeout=10)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
print(f" ✓ {endpoint} - OK")
|
||||||
|
else:
|
||||||
|
print(f" ✗ {endpoint} - Status code: {response.status_code}")
|
||||||
|
failed_endpoints.append(endpoint)
|
||||||
|
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
print(f" ✗ {endpoint} - Connection error (server not running?)")
|
||||||
|
failed_endpoints.append(endpoint)
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
print(f" ✗ {endpoint} - Timeout")
|
||||||
|
failed_endpoints.append(endpoint)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ {endpoint} - Error: {e}")
|
||||||
|
failed_endpoints.append(endpoint)
|
||||||
|
|
||||||
|
if failed_endpoints:
|
||||||
|
print(f"\nFailed endpoints: {failed_endpoints}")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
print("\nAll endpoints are working correctly!")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def test_api_endpoints():
|
||||||
|
"""Test that the new API endpoints are working correctly"""
|
||||||
|
base_url = "http://localhost:8000"
|
||||||
|
|
||||||
|
# Test API endpoints
|
||||||
|
api_endpoints = [
|
||||||
|
("/api/activities", "GET"),
|
||||||
|
("/api/activities/1", "GET"), # This might fail if activity doesn't exist, which is OK
|
||||||
|
("/api/dashboard/stats", "GET")
|
||||||
|
]
|
||||||
|
|
||||||
|
print("\nTesting API endpoints...")
|
||||||
|
|
||||||
|
for endpoint, method in api_endpoints:
|
||||||
|
try:
|
||||||
|
url = base_url + endpoint
|
||||||
|
print(f"Testing {method} {url}...")
|
||||||
|
|
||||||
|
if method == "GET":
|
||||||
|
response = requests.get(url, timeout=10)
|
||||||
|
else:
|
||||||
|
response = requests.post(url, timeout=10)
|
||||||
|
|
||||||
|
# For activity details, 404 is acceptable if activity doesn't exist
|
||||||
|
if endpoint == "/api/activities/1" and response.status_code == 404:
|
||||||
|
print(f" ✓ {endpoint} - OK (404 expected if activity doesn't exist)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
print(f" ✓ {endpoint} - OK")
|
||||||
|
# Try to parse JSON
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
print(f" Response keys: {list(data.keys()) if isinstance(data, dict) else 'Not a dict'}")
|
||||||
|
except:
|
||||||
|
print(" Response is not JSON")
|
||||||
|
else:
|
||||||
|
print(f" ✗ {endpoint} - Status code: {response.status_code}")
|
||||||
|
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
print(f" ✗ {endpoint} - Connection error (server not running?)")
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
print(f" ✗ {endpoint} - Timeout")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ {endpoint} - Error: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("GarminSync UI Test Script")
|
||||||
|
print("=" * 30)
|
||||||
|
|
||||||
|
# Test UI endpoints
|
||||||
|
ui_success = test_ui_endpoints()
|
||||||
|
|
||||||
|
# Test API endpoints
|
||||||
|
test_api_endpoints()
|
||||||
|
|
||||||
|
print("\n" + "=" * 30)
|
||||||
|
if ui_success:
|
||||||
|
print("UI tests completed successfully!")
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
print("Some UI tests failed!")
|
||||||
|
sys.exit(1)
|
||||||
173
ui_implementation_summary.md
Normal file
173
ui_implementation_summary.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# GarminSync UI Redesign Implementation Summary
|
||||||
|
|
||||||
|
This document summarizes the implementation of the UI redesign for GarminSync as specified in the ui_plan.md file.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The UI redesign transformed the existing bootstrap-based interface into a modern, clean design with two main pages: Home (dashboard with statistics and sync controls) and Activities (data table view).
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
### 1. Backend API Enhancements
|
||||||
|
|
||||||
|
#### Database Model Updates
|
||||||
|
- Enhanced the `Activity` model in `garminsync/database.py` with new fields:
|
||||||
|
- `activity_type` (String)
|
||||||
|
- `duration` (Integer, seconds)
|
||||||
|
- `distance` (Float, meters)
|
||||||
|
- `max_heart_rate` (Integer)
|
||||||
|
- `avg_power` (Float)
|
||||||
|
- `calories` (Integer)
|
||||||
|
|
||||||
|
#### New API Endpoints
|
||||||
|
Added the following endpoints in `garminsync/web/routes.py`:
|
||||||
|
- `GET /api/activities` - Get paginated activities with filtering
|
||||||
|
- `GET /api/activities/{activity_id}` - Get detailed activity information
|
||||||
|
- `GET /api/dashboard/stats` - Get comprehensive dashboard statistics
|
||||||
|
|
||||||
|
### 2. Frontend Architecture Redesign
|
||||||
|
|
||||||
|
#### CSS Restructuring
|
||||||
|
Created new CSS files in `garminsync/web/static/`:
|
||||||
|
- `style.css` - Core styling with CSS variables and modern layout
|
||||||
|
- `components.css` - Advanced component styling (tables, buttons, etc.)
|
||||||
|
- `responsive.css` - Mobile-first responsive design
|
||||||
|
|
||||||
|
Key features:
|
||||||
|
- Replaced Bootstrap with custom CSS using CSS Grid/Flexbox
|
||||||
|
- Implemented CSS variables for consistent theming
|
||||||
|
- Created modern card components with shadows and rounded corners
|
||||||
|
- Added responsive design with mobile-first approach
|
||||||
|
|
||||||
|
#### JavaScript Architecture
|
||||||
|
Created new JavaScript files:
|
||||||
|
- `navigation.js` - Dynamic navigation component
|
||||||
|
- `utils.js` - Common utility functions
|
||||||
|
- `home.js` - Home page controller
|
||||||
|
- `activities.js` - Activities page controller
|
||||||
|
|
||||||
|
Updated existing files:
|
||||||
|
- `logs.js` - Refactored to use new styling and components
|
||||||
|
- `app.js` - Deprecated (functionality moved to new files)
|
||||||
|
- `charts.js` - Deprecated (chart functionality removed)
|
||||||
|
|
||||||
|
### 3. Template Redesign
|
||||||
|
|
||||||
|
#### Base Template
|
||||||
|
Updated `garminsync/web/templates/base.html`:
|
||||||
|
- Removed Bootstrap dependencies
|
||||||
|
- Added links to new CSS files
|
||||||
|
- Updated script loading
|
||||||
|
|
||||||
|
#### Home Page
|
||||||
|
Redesigned `garminsync/web/templates/dashboard.html`:
|
||||||
|
- Implemented new layout with sidebar and main content area
|
||||||
|
- Added sync button with status indicator
|
||||||
|
- Created statistics display with clean card layout
|
||||||
|
- Added log data display area
|
||||||
|
|
||||||
|
#### Activities Page
|
||||||
|
Created `garminsync/web/templates/activities.html`:
|
||||||
|
- Implemented data table view with all activity details
|
||||||
|
- Added pagination controls
|
||||||
|
- Used consistent styling with other pages
|
||||||
|
|
||||||
|
#### Other Templates
|
||||||
|
Updated `garminsync/web/templates/logs.html` and `garminsync/web/templates/config.html`:
|
||||||
|
- Applied new styling and components
|
||||||
|
- Maintained existing functionality
|
||||||
|
|
||||||
|
### 4. Application Updates
|
||||||
|
|
||||||
|
#### Route Configuration
|
||||||
|
Updated `garminsync/web/app.py`:
|
||||||
|
- Added new route for activities page
|
||||||
|
|
||||||
|
#### Documentation
|
||||||
|
Updated `README.md`:
|
||||||
|
- Added Activities to Web Interface features list
|
||||||
|
- Updated Web API Endpoints section with new endpoints
|
||||||
|
|
||||||
|
### 5. Migration and Testing
|
||||||
|
|
||||||
|
#### Migration Script
|
||||||
|
Created `garminsync/migrate_activities.py`:
|
||||||
|
- Script to populate new activity fields from Garmin API
|
||||||
|
- Handles error cases and provides progress feedback
|
||||||
|
|
||||||
|
#### Test Script
|
||||||
|
Created `garminsync/web/test_ui.py`:
|
||||||
|
- Tests all new UI endpoints
|
||||||
|
- Verifies API endpoints are working correctly
|
||||||
|
|
||||||
|
## Key Improvements
|
||||||
|
|
||||||
|
1. **Modern Design**: Clean, contemporary interface with consistent styling
|
||||||
|
2. **Improved Navigation**: Tab-based navigation between main pages
|
||||||
|
3. **Better Data Presentation**: Enhanced tables with alternating row colors and hover effects
|
||||||
|
4. **Responsive Layout**: Mobile-friendly design that works on all screen sizes
|
||||||
|
5. **Performance**: Removed heavy Bootstrap dependency for lighter, faster loading
|
||||||
|
6. **Maintainability**: Modular JavaScript architecture with clear separation of concerns
|
||||||
|
|
||||||
|
## Files Created
|
||||||
|
|
||||||
|
- `garminsync/web/static/style.css`
|
||||||
|
- `garminsync/web/static/components.css`
|
||||||
|
- `garminsync/web/static/responsive.css`
|
||||||
|
- `garminsync/web/static/navigation.js`
|
||||||
|
- `garminsync/web/static/utils.js`
|
||||||
|
- `garminsync/web/static/home.js`
|
||||||
|
- `garminsync/web/static/activities.js`
|
||||||
|
- `garminsync/web/templates/activities.html`
|
||||||
|
- `garminsync/migrate_activities.py`
|
||||||
|
- `garminsync/web/test_ui.py`
|
||||||
|
- `ui_implementation_summary.md`
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
- `garminsync/database.py`
|
||||||
|
- `garminsync/web/routes.py`
|
||||||
|
- `garminsync/web/templates/base.html`
|
||||||
|
- `garminsync/web/templates/dashboard.html`
|
||||||
|
- `garminsync/web/templates/logs.html`
|
||||||
|
- `garminsync/web/templates/config.html`
|
||||||
|
- `garminsync/web/app.py`
|
||||||
|
- `garminsync/web/static/logs.js`
|
||||||
|
- `garminsync/web/static/app.js`
|
||||||
|
- `garminsync/web/static/charts.js`
|
||||||
|
- `README.md`
|
||||||
|
|
||||||
|
## Files Deprecated
|
||||||
|
|
||||||
|
- `garminsync/web/static/app.js` (functionality moved)
|
||||||
|
- `garminsync/web/static/charts.js` (chart functionality removed)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
The implementation includes a test script (`garminsync/web/test_ui.py`) that verifies:
|
||||||
|
- All UI endpoints are accessible
|
||||||
|
- New API endpoints return expected responses
|
||||||
|
- Basic functionality is working correctly
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
The migration script (`garminsync/migrate_activities.py`) can be run to:
|
||||||
|
- Populate new activity fields from Garmin API
|
||||||
|
- Update existing activities with detailed information
|
||||||
|
- Provide progress feedback during migration
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
After implementing these changes, the GarminSync web interface provides:
|
||||||
|
|
||||||
|
1. **Home Page**: Dashboard with sync controls, statistics, and log display
|
||||||
|
2. **Activities Page**: Comprehensive table view of all activities with filtering and pagination
|
||||||
|
3. **Logs Page**: Filterable and paginated sync logs
|
||||||
|
4. **Configuration Page**: Daemon settings and status management
|
||||||
|
|
||||||
|
All pages feature:
|
||||||
|
- Modern, clean design
|
||||||
|
- Responsive layout for all device sizes
|
||||||
|
- Consistent navigation
|
||||||
|
- Real-time updates
|
||||||
|
- Enhanced data presentation
|
||||||
687
ui_plan.md
Normal file
687
ui_plan.md
Normal file
@@ -0,0 +1,687 @@
|
|||||||
|
# GarminSync UI Redesign Implementation Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Transform the existing GarminSync web interface from the current bootstrap-based UI to a modern, clean design matching the provided mockups. The target design shows two main pages: Home (dashboard with statistics and sync controls) and Activities (data table view).
|
||||||
|
|
||||||
|
## Current State Analysis
|
||||||
|
|
||||||
|
### Existing Structure
|
||||||
|
- **Backend**: FastAPI with SQLAlchemy, scheduled daemon
|
||||||
|
- **Frontend**: Bootstrap 5 + jQuery, basic dashboard
|
||||||
|
- **Database**: SQLite with Activity, DaemonConfig, SyncLog models
|
||||||
|
- **Templates**: Jinja2 templates in `garminsync/web/templates/`
|
||||||
|
- **Static Assets**: Basic CSS/JS in `garminsync/web/static/`
|
||||||
|
|
||||||
|
### Current Pages
|
||||||
|
- Dashboard: Basic stats, daemon controls, logs
|
||||||
|
- Configuration: Daemon settings, cron scheduling
|
||||||
|
- Logs: Paginated sync logs with filters
|
||||||
|
|
||||||
|
## Target Design Requirements
|
||||||
|
|
||||||
|
### Home Page Layout
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Navigation: [Home] [Activities] │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ Left Sidebar (25%) │ Right Content Area (75%) │
|
||||||
|
│ ┌─────────────────┐ │ ┌─────────────────────────────┐ │
|
||||||
|
│ │ Sync Now │ │ │ │ │
|
||||||
|
│ │ (Blue Button) │ │ │ Log Data Display │ │
|
||||||
|
│ └─────────────────┘ │ │ │ │
|
||||||
|
│ ┌─────────────────┐ │ │ │ │
|
||||||
|
│ │ Statistics │ │ │ │ │
|
||||||
|
│ │ Total: 852 │ │ │ │ │
|
||||||
|
│ │ Downloaded: 838 │ │ │ │ │
|
||||||
|
│ │ Missing: 14 │ │ │ │ │
|
||||||
|
│ └─────────────────┘ │ └─────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Activities Page Layout
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Navigation: [Home] [Activities] │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Date │Activity Type│Duration│Distance│Max HR│Power │ │
|
||||||
|
│ │────────────────────────────────────────────────────│ │
|
||||||
|
│ │ │ │ │ │ │ │ │
|
||||||
|
│ │ │ │ │ │ │ │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Phase 1: Backend API Enhancements
|
||||||
|
|
||||||
|
#### 1.1 New API Endpoints
|
||||||
|
**File: `garminsync/web/routes.py`**
|
||||||
|
|
||||||
|
Add missing endpoints:
|
||||||
|
```python
|
||||||
|
@router.get("/activities")
|
||||||
|
async def get_activities(
|
||||||
|
page: int = 1,
|
||||||
|
per_page: int = 50,
|
||||||
|
activity_type: str = None,
|
||||||
|
date_from: str = None,
|
||||||
|
date_to: str = None
|
||||||
|
):
|
||||||
|
"""Get paginated activities with filtering"""
|
||||||
|
|
||||||
|
@router.get("/activities/{activity_id}")
|
||||||
|
async def get_activity_details(activity_id: int):
|
||||||
|
"""Get detailed activity information"""
|
||||||
|
|
||||||
|
@router.get("/dashboard/stats")
|
||||||
|
async def get_dashboard_stats():
|
||||||
|
"""Get comprehensive dashboard statistics"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.2 Database Model Enhancements
|
||||||
|
**File: `garminsync/database.py`**
|
||||||
|
|
||||||
|
Enhance Activity model:
|
||||||
|
```python
|
||||||
|
class Activity(Base):
|
||||||
|
__tablename__ = 'activities'
|
||||||
|
|
||||||
|
activity_id = Column(Integer, primary_key=True)
|
||||||
|
start_time = Column(String, nullable=False)
|
||||||
|
activity_type = Column(String, nullable=True) # NEW
|
||||||
|
duration = Column(Integer, nullable=True) # NEW (seconds)
|
||||||
|
distance = Column(Float, nullable=True) # NEW (meters)
|
||||||
|
max_heart_rate = Column(Integer, nullable=True) # NEW
|
||||||
|
avg_power = Column(Float, nullable=True) # NEW
|
||||||
|
calories = Column(Integer, nullable=True) # NEW
|
||||||
|
filename = Column(String, unique=True, nullable=True)
|
||||||
|
downloaded = Column(Boolean, default=False, nullable=False)
|
||||||
|
created_at = Column(String, nullable=False)
|
||||||
|
last_sync = Column(String, nullable=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
Add migration function to populate new fields from Garmin API.
|
||||||
|
|
||||||
|
### Phase 2: Frontend Architecture Redesign
|
||||||
|
|
||||||
|
#### 2.1 Modern CSS Framework
|
||||||
|
**File: `garminsync/web/static/style.css`**
|
||||||
|
|
||||||
|
Replace Bootstrap with custom CSS using modern techniques:
|
||||||
|
```css
|
||||||
|
/* CSS Variables for consistent theming */
|
||||||
|
:root {
|
||||||
|
--primary-color: #007bff;
|
||||||
|
--secondary-color: #6c757d;
|
||||||
|
--success-color: #28a745;
|
||||||
|
--danger-color: #dc3545;
|
||||||
|
--light-gray: #f8f9fa;
|
||||||
|
--dark-gray: #343a40;
|
||||||
|
--border-radius: 8px;
|
||||||
|
--box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSS Grid Layout System */
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 300px 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
min-height: calc(100vh - 60px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Card Components */
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--box-shadow);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.layout-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2 Navigation Component
|
||||||
|
**File: `garminsync/web/static/navigation.js`**
|
||||||
|
|
||||||
|
Create dynamic navigation:
|
||||||
|
```javascript
|
||||||
|
class Navigation {
|
||||||
|
constructor() {
|
||||||
|
this.currentPage = this.getCurrentPage();
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentPage() {
|
||||||
|
return window.location.pathname === '/activities' ? 'activities' : 'home';
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const nav = document.querySelector('.navigation');
|
||||||
|
nav.innerHTML = this.getNavigationHTML();
|
||||||
|
this.attachEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
getNavigationHTML() {
|
||||||
|
return `
|
||||||
|
<nav class="nav-tabs">
|
||||||
|
<button class="nav-tab ${this.currentPage === 'home' ? 'active' : ''}"
|
||||||
|
data-page="home">Home</button>
|
||||||
|
<button class="nav-tab ${this.currentPage === 'activities' ? 'active' : ''}"
|
||||||
|
data-page="activities">Activities</button>
|
||||||
|
</nav>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: Home Page Implementation
|
||||||
|
|
||||||
|
#### 3.1 Home Page Template Redesign
|
||||||
|
**File: `garminsync/web/templates/dashboard.html`**
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container">
|
||||||
|
<div class="navigation"></div>
|
||||||
|
|
||||||
|
<div class="layout-grid">
|
||||||
|
<!-- Left Sidebar -->
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="card sync-card">
|
||||||
|
<button id="sync-now-btn" class="btn btn-primary btn-large">
|
||||||
|
<i class="icon-sync"></i>
|
||||||
|
Sync Now
|
||||||
|
</button>
|
||||||
|
<div class="sync-status" id="sync-status">
|
||||||
|
Ready to sync
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card statistics-card">
|
||||||
|
<h3>Statistics</h3>
|
||||||
|
<div class="stat-item">
|
||||||
|
<label>Total Activities:</label>
|
||||||
|
<span id="total-activities">{{stats.total}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<label>Downloaded:</label>
|
||||||
|
<span id="downloaded-activities">{{stats.downloaded}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<label>Missing:</label>
|
||||||
|
<span id="missing-activities">{{stats.missing}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Content Area -->
|
||||||
|
<div class="main-content">
|
||||||
|
<div class="card log-display">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Log Data</h3>
|
||||||
|
</div>
|
||||||
|
<div class="log-content" id="log-content">
|
||||||
|
<!-- Real-time log updates will appear here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2 Home Page JavaScript Controller
|
||||||
|
**File: `garminsync/web/static/home.js`**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
class HomePage {
|
||||||
|
constructor() {
|
||||||
|
this.logSocket = null;
|
||||||
|
this.statsRefreshInterval = null;
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.attachEventListeners();
|
||||||
|
this.setupRealTimeUpdates();
|
||||||
|
this.loadInitialData();
|
||||||
|
}
|
||||||
|
|
||||||
|
attachEventListeners() {
|
||||||
|
document.getElementById('sync-now-btn').addEventListener('click',
|
||||||
|
() => this.triggerSync());
|
||||||
|
}
|
||||||
|
|
||||||
|
async triggerSync() {
|
||||||
|
const btn = document.getElementById('sync-now-btn');
|
||||||
|
const status = document.getElementById('sync-status');
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="icon-loading"></i> Syncing...';
|
||||||
|
status.textContent = 'Sync in progress...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/sync/trigger', {method: 'POST'});
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
status.textContent = 'Sync completed successfully';
|
||||||
|
this.updateStats();
|
||||||
|
} else {
|
||||||
|
throw new Error(result.detail || 'Sync failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
status.textContent = `Sync failed: ${error.message}`;
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="icon-sync"></i> Sync Now';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupRealTimeUpdates() {
|
||||||
|
// Poll for log updates every 5 seconds during active operations
|
||||||
|
this.startLogPolling();
|
||||||
|
|
||||||
|
// Update stats every 30 seconds
|
||||||
|
this.statsRefreshInterval = setInterval(() => {
|
||||||
|
this.updateStats();
|
||||||
|
}, 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async startLogPolling() {
|
||||||
|
// Implementation for real-time log updates
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStats() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/activities/stats');
|
||||||
|
const stats = await response.json();
|
||||||
|
|
||||||
|
document.getElementById('total-activities').textContent = stats.total;
|
||||||
|
document.getElementById('downloaded-activities').textContent = stats.downloaded;
|
||||||
|
document.getElementById('missing-activities').textContent = stats.missing;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update stats:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 4: Activities Page Implementation
|
||||||
|
|
||||||
|
#### 4.1 Activities Page Template
|
||||||
|
**File: `garminsync/web/templates/activities.html`**
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container">
|
||||||
|
<div class="navigation"></div>
|
||||||
|
|
||||||
|
<div class="activities-container">
|
||||||
|
<div class="card activities-table-card">
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="activities-table" id="activities-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Activity Type</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th>Distance</th>
|
||||||
|
<th>Max HR</th>
|
||||||
|
<th>Power</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="activities-tbody">
|
||||||
|
<!-- Data populated by JavaScript -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination-container">
|
||||||
|
<div class="pagination" id="pagination">
|
||||||
|
<!-- Pagination controls -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.2 Activities Table Controller
|
||||||
|
**File: `garminsync/web/static/activities.js`**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
class ActivitiesPage {
|
||||||
|
constructor() {
|
||||||
|
this.currentPage = 1;
|
||||||
|
this.pageSize = 25;
|
||||||
|
this.totalPages = 1;
|
||||||
|
this.activities = [];
|
||||||
|
this.filters = {};
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.loadActivities();
|
||||||
|
this.setupEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadActivities() {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: this.currentPage,
|
||||||
|
per_page: this.pageSize,
|
||||||
|
...this.filters
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`/api/activities?${params}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
this.activities = data.activities;
|
||||||
|
this.totalPages = Math.ceil(data.total / this.pageSize);
|
||||||
|
|
||||||
|
this.renderTable();
|
||||||
|
this.renderPagination();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load activities:', error);
|
||||||
|
this.showError('Failed to load activities');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTable() {
|
||||||
|
const tbody = document.getElementById('activities-tbody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
this.activities.forEach((activity, index) => {
|
||||||
|
const row = this.createTableRow(activity, index);
|
||||||
|
tbody.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createTableRow(activity, index) {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.className = index % 2 === 0 ? 'row-even' : 'row-odd';
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<td>${this.formatDate(activity.start_time)}</td>
|
||||||
|
<td>${activity.activity_type || '-'}</td>
|
||||||
|
<td>${this.formatDuration(activity.duration)}</td>
|
||||||
|
<td>${this.formatDistance(activity.distance)}</td>
|
||||||
|
<td>${activity.max_heart_rate || '-'}</td>
|
||||||
|
<td>${this.formatPower(activity.avg_power)}</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(dateStr) {
|
||||||
|
return new Date(dateStr).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDuration(seconds) {
|
||||||
|
if (!seconds) return '-';
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
return `${hours}:${minutes.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDistance(meters) {
|
||||||
|
if (!meters) return '-';
|
||||||
|
return `${(meters / 1000).toFixed(1)} km`;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatPower(watts) {
|
||||||
|
return watts ? `${Math.round(watts)}W` : '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 5: Styling and Visual Polish
|
||||||
|
|
||||||
|
#### 5.1 Advanced CSS Styling
|
||||||
|
**File: `garminsync/web/static/components.css`**
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Table Styling */
|
||||||
|
.activities-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activities-table thead {
|
||||||
|
background-color: #000;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activities-table th {
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
border-right: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activities-table td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activities-table .row-even {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activities-table .row-odd {
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sync Button Styling */
|
||||||
|
.btn-primary.btn-large {
|
||||||
|
width: 100%;
|
||||||
|
padding: 15px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary.btn-large:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,123,255,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary.btn-large:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Statistics Card */
|
||||||
|
.statistics-card .stat-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statistics-card .stat-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statistics-card label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statistics-card span {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.2 Responsive Design
|
||||||
|
**File: `garminsync/web/static/responsive.css`**
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Mobile-first responsive design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.layout-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activities-table {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activities-table th,
|
||||||
|
.activities-table td {
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.activities-table {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 6: Integration and Testing
|
||||||
|
|
||||||
|
#### 6.1 Updated Base Template
|
||||||
|
**File: `garminsync/web/templates/base.html`**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>GarminSync</title>
|
||||||
|
<link href="/static/style.css" rel="stylesheet">
|
||||||
|
<link href="/static/components.css" rel="stylesheet">
|
||||||
|
<link href="/static/responsive.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
|
||||||
|
<script src="/static/navigation.js"></script>
|
||||||
|
<script src="/static/utils.js"></script>
|
||||||
|
|
||||||
|
{% block page_scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.2 App Router Updates
|
||||||
|
**File: `garminsync/web/app.py`**
|
||||||
|
|
||||||
|
Add activities route:
|
||||||
|
```python
|
||||||
|
@app.get("/activities")
|
||||||
|
async def activities_page(request: Request):
|
||||||
|
"""Activities page route"""
|
||||||
|
if not templates:
|
||||||
|
return JSONResponse({"message": "Activities endpoint"})
|
||||||
|
|
||||||
|
return templates.TemplateResponse("activities.html", {
|
||||||
|
"request": request
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 7: Performance Optimization
|
||||||
|
|
||||||
|
#### 7.1 Lazy Loading and Pagination
|
||||||
|
- Implement virtual scrolling for large activity datasets
|
||||||
|
- Add progressive loading indicators
|
||||||
|
- Cache frequently accessed data
|
||||||
|
|
||||||
|
#### 7.2 Real-time Updates
|
||||||
|
- WebSocket integration for live sync status
|
||||||
|
- Progressive enhancement for users without JavaScript
|
||||||
|
- Offline support with service workers
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### 7.1 Manual Testing Checklist
|
||||||
|
- [ ] Home page layout matches mockup exactly
|
||||||
|
- [ ] Activities table displays with proper alternating colors
|
||||||
|
- [ ] Navigation works between pages
|
||||||
|
- [ ] Sync button functions correctly
|
||||||
|
- [ ] Statistics update in real-time
|
||||||
|
- [ ] Responsive design works on mobile
|
||||||
|
- [ ] All existing API endpoints still function
|
||||||
|
|
||||||
|
### 7.2 Browser Compatibility
|
||||||
|
- Test in Chrome, Firefox, Safari, Edge
|
||||||
|
- Ensure graceful degradation for older browsers
|
||||||
|
- Test JavaScript disabled scenarios
|
||||||
|
|
||||||
|
## Deployment Strategy
|
||||||
|
|
||||||
|
### 8.1 Staging Deployment
|
||||||
|
1. Deploy to test environment
|
||||||
|
2. Run automated tests
|
||||||
|
3. User acceptance testing
|
||||||
|
4. Performance benchmarking
|
||||||
|
|
||||||
|
### 8.2 Production Rollout
|
||||||
|
1. Feature flags for gradual rollout
|
||||||
|
2. Monitor error rates and performance
|
||||||
|
3. Rollback plan in case of issues
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] UI matches provided mockups exactly
|
||||||
|
- [ ] All existing functionality preserved
|
||||||
|
- [ ] Page load times under 2 seconds
|
||||||
|
- [ ] Mobile responsive design works perfectly
|
||||||
|
- [ ] Real-time updates function correctly
|
||||||
|
- [ ] No breaking changes to API
|
||||||
|
- [ ] Comprehensive test coverage
|
||||||
|
|
||||||
|
## Timeline Estimate
|
||||||
|
|
||||||
|
- **Phase 1-2 (Backend/Architecture)**: 2-3 days
|
||||||
|
- **Phase 3 (Home Page)**: 2-3 days
|
||||||
|
- **Phase 4 (Activities Page)**: 2-3 days
|
||||||
|
- **Phase 5 (Styling/Polish)**: 1-2 days
|
||||||
|
- **Phase 6-7 (Integration/Testing)**: 1-2 days
|
||||||
|
|
||||||
|
**Total Estimated Time**: 8-13 days
|
||||||
|
|
||||||
|
This plan provides a comprehensive roadmap for transforming the existing GarminSync interface into the modern, clean design shown in the mockups while preserving all existing functionality.
|
||||||
Reference in New Issue
Block a user