diff --git a/README.md b/README.md
new file mode 100644
index 0000000..16c7ba3
--- /dev/null
+++ b/README.md
@@ -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.
diff --git a/garminsync/cli.py b/garminsync/cli.py
index 2ba0dbc..728679f 100644
--- a/garminsync/cli.py
+++ b/garminsync/cli.py
@@ -159,6 +159,19 @@ def daemon_mode(
else:
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():
app()
diff --git a/garminsync/database.py b/garminsync/database.py
index 23a0dbe..4944f73 100644
--- a/garminsync/database.py
+++ b/garminsync/database.py
@@ -1,5 +1,5 @@
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.exc import SQLAlchemyError
@@ -10,9 +10,15 @@ class Activity(Base):
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) # Add this line
+ created_at = Column(String, nullable=False)
last_sync = Column(String, nullable=True) # ISO timestamp of last sync
class DaemonConfig(Base):
diff --git a/garminsync/migrate_activities.py b/garminsync/migrate_activities.py
new file mode 100644
index 0000000..023dfd1
--- /dev/null
+++ b/garminsync/migrate_activities.py
@@ -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)
diff --git a/garminsync/web/app.py b/garminsync/web/app.py
index e1c0d0a..abbafea 100644
--- a/garminsync/web/app.py
+++ b/garminsync/web/app.py
@@ -72,6 +72,16 @@ async def config_page(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
@app.exception_handler(404)
async def not_found_handler(request: Request, exc):
@@ -85,4 +95,4 @@ async def server_error_handler(request: Request, exc):
return JSONResponse(
status_code=500,
content={"error": "Internal server error", "detail": str(exc)}
- )
\ No newline at end of file
+ )
diff --git a/garminsync/web/routes.py b/garminsync/web/routes.py
index 1d405ef..0ddc195 100644
--- a/garminsync/web/routes.py
+++ b/garminsync/web/routes.py
@@ -1,6 +1,7 @@
from fastapi import APIRouter, HTTPException
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")
@@ -239,3 +240,91 @@ async def clear_logs():
raise HTTPException(status_code=500, detail=f"Failed to clear logs: {str(e)}")
finally:
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()
diff --git a/garminsync/web/static/activities.js b/garminsync/web/static/activities.js
new file mode 100644
index 0000000..dd378f9
--- /dev/null
+++ b/garminsync/web/static/activities.js
@@ -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 = '
| No activities found |
';
+ 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 = `
+ ${Utils.formatDate(activity.start_time)} |
+ ${activity.activity_type || '-'} |
+ ${Utils.formatDuration(activity.duration)} |
+ ${Utils.formatDistance(activity.distance)} |
+ ${activity.max_heart_rate || '-'} |
+ ${Utils.formatPower(activity.avg_power)} |
+ `;
+
+ return row;
+ }
+
+ renderPagination() {
+ const pagination = document.getElementById('pagination');
+ if (!pagination) return;
+
+ if (this.totalPages <= 1) {
+ pagination.innerHTML = '';
+ return;
+ }
+
+ let paginationHtml = '';
+
+ // Previous button
+ paginationHtml += `
+
+ Previous
+
+ `;
+
+ // 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 += `
+
+ ${i}
+
+ `;
+ } else if (i === this.currentPage - 3 || i === this.currentPage + 3) {
+ paginationHtml += '...';
+ }
+ }
+
+ // Next button
+ paginationHtml += `
+
+ Next
+
+ `;
+
+ 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 = `| Error: ${message} |
`;
+ }
+ }
+}
+
+// Initialize activities page when DOM is loaded
+let activitiesPage;
+document.addEventListener('DOMContentLoaded', function() {
+ activitiesPage = new ActivitiesPage();
+});
diff --git a/garminsync/web/static/app.js b/garminsync/web/static/app.js
index 7b7a6a8..02421e9 100644
--- a/garminsync/web/static/app.js
+++ b/garminsync/web/static/app.js
@@ -1,104 +1,3 @@
-// Auto-refresh dashboard data
-setInterval(updateStatus, 30000); // Every 30 seconds
-
-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 ?
- 'Running' :
- 'Stopped';
-
- syncStatus.innerHTML = `${statusBadge}`;
-
- // Update daemon status
- document.getElementById('daemon-status').innerHTML = `
- Status: ${statusBadge}
- Last Run: ${data.daemon.last_run || 'Never'}
- Next Run: ${data.daemon.next_run || 'Not scheduled'}
- Schedule: ${data.daemon.schedule || 'Not configured'}
- `;
-
- // Update recent logs
- const logsHtml = data.recent_logs.map(log => `
-
- ${log.timestamp}
-
- ${log.status}
-
- ${log.operation}: ${log.message || ''}
- ${log.activities_downloaded > 0 ? `Downloaded ${log.activities_downloaded} activities` : ''}
-
- `).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);
+// This file is deprecated and no longer used.
+// The functionality has been moved to home.js, activities.js, and logs.js
+// This file is kept for backward compatibility but is empty.
diff --git a/garminsync/web/static/charts.js b/garminsync/web/static/charts.js
index 97ae710..617689d 100644
--- a/garminsync/web/static/charts.js
+++ b/garminsync/web/static/charts.js
@@ -1,37 +1 @@
-// Initialize the activity progress chart
-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);
- });
-});
+// This file is deprecated and no longer used.
diff --git a/garminsync/web/static/components.css b/garminsync/web/static/components.css
new file mode 100644
index 0000000..45da7f4
--- /dev/null
+++ b/garminsync/web/static/components.css
@@ -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;
+}
diff --git a/garminsync/web/static/home.js b/garminsync/web/static/home.js
new file mode 100644
index 0000000..2fb1e82
--- /dev/null
+++ b/garminsync/web/static/home.js
@@ -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 = ' 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 = ' 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 = 'No recent activity
';
+ return;
+ }
+
+ const logsHtml = logs.map(log => `
+
+ ${Utils.formatTimestamp(log.timestamp)}
+
+ ${log.status}
+
+ ${log.operation}: ${log.message || ''}
+ ${log.activities_downloaded > 0 ? `Downloaded ${log.activities_downloaded} activities` : ''}
+
+ `).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();
+});
diff --git a/garminsync/web/static/logs.js b/garminsync/web/static/logs.js
index f11a5f1..8ca9f8d 100644
--- a/garminsync/web/static/logs.js
+++ b/garminsync/web/static/logs.js
@@ -4,118 +4,176 @@ const logsPerPage = 20;
let totalLogs = 0;
let currentFilters = {};
-// Initialize logs page
-document.addEventListener('DOMContentLoaded', function() {
- loadLogs();
-});
+class LogsPage {
+ constructor() {
+ 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() {
- try {
- // Build query string from filters
- const params = new URLSearchParams({
- page: currentPage,
- perPage: logsPerPage,
- ...currentFilters
- }).toString();
-
- const response = await fetch(`/api/logs?${params}`);
- if (!response.ok) {
- throw new Error('Failed to fetch logs');
+ const response = await fetch(`/api/logs?${params}`);
+ if (!response.ok) {
+ throw new Error('Failed to fetch logs');
+ }
+
+ const data = await response.json();
+ totalLogs = data.total;
+ this.renderLogs(data.logs);
+ this.renderPagination();
+ } catch (error) {
+ console.error('Error loading logs:', error);
+ 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 = '| No logs found |
';
+ return;
}
- const data = await response.json();
- totalLogs = data.total;
- renderLogs(data.logs);
- renderPagination();
- } catch (error) {
- console.error('Error loading logs:', error);
- alert('Failed to load logs: ' + error.message);
+ logs.forEach(log => {
+ const row = document.createElement('tr');
+ row.className = 'row-odd'; // For alternating row colors
+
+ row.innerHTML = `
+ ${Utils.formatTimestamp(log.timestamp)} |
+ ${log.operation} |
+ ${log.status} |
+ ${log.message || ''} |
+ ${log.activities_processed} |
+ ${log.activities_downloaded} |
+ `;
+
+ tbody.appendChild(row);
+ });
}
-}
-
-function renderLogs(logs) {
- const tbody = document.getElementById('logs-tbody');
- tbody.innerHTML = '';
- logs.forEach(log => {
- const row = document.createElement('tr');
+ renderPagination() {
+ const totalPages = Math.ceil(totalLogs / logsPerPage);
+ const pagination = document.getElementById('pagination');
+ if (!pagination) return;
- row.innerHTML = `
- ${log.timestamp} |
- ${log.operation} |
- ${log.status} |
- ${log.message || ''} |
- ${log.activities_processed} |
- ${log.activities_downloaded} |
+ if (totalPages <= 1) {
+ pagination.innerHTML = '';
+ return;
+ }
+
+ let paginationHtml = '';
+
+ // Previous button
+ paginationHtml += `
+
+ Previous
+
`;
- tbody.appendChild(row);
- });
-}
-
-function renderPagination() {
- const totalPages = Math.ceil(totalLogs / logsPerPage);
- const pagination = document.getElementById('pagination');
- pagination.innerHTML = '';
-
- // Previous button
- const prevLi = document.createElement('li');
- prevLi.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`;
- prevLi.innerHTML = `Previous`;
- pagination.appendChild(prevLi);
-
- // Page numbers
- for (let i = 1; i <= totalPages; i++) {
- const li = document.createElement('li');
- li.className = `page-item ${i === currentPage ? 'active' : ''}`;
- li.innerHTML = `${i}`;
- pagination.appendChild(li);
+ // Page numbers
+ for (let i = 1; i <= totalPages; i++) {
+ if (i === 1 || i === totalPages || (i >= this.currentPage - 2 && i <= this.currentPage + 2)) {
+ paginationHtml += `
+
+ ${i}
+
+ `;
+ } else if (i === this.currentPage - 3 || i === this.currentPage + 3) {
+ paginationHtml += '...';
+ }
+ }
+
+ // Next button
+ paginationHtml += `
+
+ Next
+
+ `;
+
+ pagination.innerHTML = paginationHtml;
}
- // Next button
- const nextLi = document.createElement('li');
- nextLi.className = `page-item ${currentPage === totalPages ? 'disabled' : ''}`;
- nextLi.innerHTML = `Next`;
- pagination.appendChild(nextLi);
+ changePage(page) {
+ if (page < 1 || page > Math.ceil(totalLogs / logsPerPage)) return;
+ this.currentPage = page;
+ this.loadLogs();
+ }
+
+ 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) {
- if (page < 1 || page > Math.ceil(totalLogs / logsPerPage)) return;
- currentPage = page;
- loadLogs();
+ if (logsPage) logsPage.changePage(page);
}
function refreshLogs() {
- currentPage = 1;
- loadLogs();
+ if (logsPage) logsPage.refreshLogs();
}
function applyFilters() {
- currentFilters = {
- status: document.getElementById('status-filter').value,
- operation: document.getElementById('operation-filter').value,
- date: document.getElementById('date-filter').value
- };
-
- currentPage = 1;
- loadLogs();
+ if (logsPage) logsPage.applyFilters();
}
-async function 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) {
- 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);
- }
+function clearLogs() {
+ if (logsPage) logsPage.clearLogs();
}
diff --git a/garminsync/web/static/navigation.js b/garminsync/web/static/navigation.js
new file mode 100644
index 0000000..dd5f0d2
--- /dev/null
+++ b/garminsync/web/static/navigation.js
@@ -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 `
+
+ `;
+ }
+
+ 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();
+});
diff --git a/garminsync/web/static/responsive.css b/garminsync/web/static/responsive.css
new file mode 100644
index 0000000..8836ecf
--- /dev/null
+++ b/garminsync/web/static/responsive.css
@@ -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;
+ }
+}
diff --git a/garminsync/web/static/style.css b/garminsync/web/static/style.css
index ce8dfe5..db1915f 100644
--- a/garminsync/web/static/style.css
+++ b/garminsync/web/static/style.css
@@ -1,32 +1,268 @@
-body {
- font-family: Arial, sans-serif;
- background-color: #f8f9fa;
+/* CSS Variables for consistent theming */
+:root {
+ --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 {
+ background: white;
+ border-radius: var(--border-radius);
+ box-shadow: var(--box-shadow);
+ padding: 20px;
margin-bottom: 20px;
- box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.card-header {
- font-weight: bold;
- background-color: #f1f1f1;
+ font-weight: 600;
+ 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 {
- 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 {
- margin-bottom: 10px;
- padding: 5px;
+ margin-bottom: 8px;
+ padding: 8px;
border-left: 3px solid #ddd;
+ background-color: white;
+ border-radius: 0 var(--border-radius) var(--border-radius) 0;
}
-.log-entry .badge-success {
- background-color: #28a745;
+.log-entry .timestamp {
+ font-size: 0.8rem;
+ color: #666;
+ margin-right: 10px;
}
-.log-entry .badge-error {
- background-color: #dc3545;
+.log-entry .status {
+ 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;
+ }
}
diff --git a/garminsync/web/static/utils.js b/garminsync/web/static/utils.js
new file mode 100644
index 0000000..1a1e74c
--- /dev/null
+++ b/garminsync/web/static/utils.js
@@ -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;
diff --git a/garminsync/web/templates/activities.html b/garminsync/web/templates/activities.html
new file mode 100644
index 0000000..48d039f
--- /dev/null
+++ b/garminsync/web/templates/activities.html
@@ -0,0 +1,42 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+ | Date |
+ Activity Type |
+ Duration |
+ Distance |
+ Max HR |
+ Power |
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block page_scripts %}
+
+{% endblock %}
diff --git a/garminsync/web/templates/base.html b/garminsync/web/templates/base.html
index 2a34e5a..5df9bba 100644
--- a/garminsync/web/templates/base.html
+++ b/garminsync/web/templates/base.html
@@ -3,46 +3,17 @@
- GarminSync Dashboard
-
+ GarminSync
-
-
+
+
-
+ {% block content %}{% endblock %}
-
- {% block content %}{% endblock %}
-
+
+
-
-
+ {% block page_scripts %}{% endblock %}