diff --git a/Design.md b/Design.md
new file mode 100644
index 0000000..b5d49df
--- /dev/null
+++ b/Design.md
@@ -0,0 +1,727 @@
+# **GarminSync Application Design (Python Version)**
+
+## **Basic Info**
+
+**App Name:** GarminSync
+**What it does:** A CLI application that downloads `.fit` files for every activity in Garmin Connect.
+
+-----
+
+## **Core Features**
+
+### **CLI Mode (Current)**
+1. List all activities (`garminsync list --all`)
+2. List activities that have not been downloaded (`garminsync list --missing`)
+3. List activities that have been downloaded (`garminsync list --downloaded`)
+4. Download all missing activities (`garminsync download --missing`)
+
+### **Enhanced Features (New)**
+5. **Offline Mode**: List activities without polling Garmin Connect (`garminsync list --missing --offline`)
+6. **Daemon Mode**: Run as background service with scheduled downloads (`garminsync daemon --start`)
+7. **Web UI**: Browser-based interface for daemon monitoring and configuration (`http://localhost:8080`)
+
+-----
+
+## **Tech Stack 🐍**
+
+* **Frontend:** CLI (**Python**)
+* **Backend:** **Python**
+* **Database:** SQLite (`garmin.db`)
+* **Hosting:** Docker container
+* **Key Libraries:**
+ * **`python-garminconnect`**: The library for Garmin Connect API communication.
+ * **`typer`**: A modern and easy-to-use CLI framework (built on `click`).
+ * **`python-dotenv`**: For loading credentials from a `.env` file.
+ * **`sqlalchemy`**: A robust ORM for database interaction and schema management.
+ * **`tqdm`**: For creating user-friendly progress bars.
+ * **`fastapi`**: Modern web framework for the daemon web UI.
+ * **`uvicorn`**: ASGI server for running the FastAPI web interface.
+ * **`apscheduler`**: Advanced Python Scheduler for daemon mode scheduling.
+ * **`pydantic`**: Data validation and settings management for configuration.
+ * **`jinja2`**: Template engine for web UI rendering.
+
+-----
+
+## **Data Structure**
+
+The application uses SQLAlchemy ORM with expanded models for daemon functionality:
+
+**SQLAlchemy Models (`database.py`):**
+
+```python
+class Activity(Base):
+ __tablename__ = 'activities'
+
+ activity_id = Column(Integer, primary_key=True)
+ start_time = Column(String, nullable=False)
+ filename = Column(String, unique=True, nullable=True)
+ downloaded = Column(Boolean, default=False, nullable=False)
+ created_at = Column(String, nullable=False) # When record was added
+ last_sync = Column(String, nullable=True) # Last successful sync
+
+class DaemonConfig(Base):
+ __tablename__ = 'daemon_config'
+
+ id = Column(Integer, primary_key=True, default=1)
+ enabled = Column(Boolean, default=True, nullable=False)
+ schedule_cron = Column(String, default="0 */6 * * *", nullable=False) # Every 6 hours
+ last_run = Column(String, nullable=True)
+ next_run = Column(String, nullable=True)
+ status = Column(String, default="stopped", nullable=False) # stopped, running, error
+
+class SyncLog(Base):
+ __tablename__ = 'sync_logs'
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ timestamp = Column(String, nullable=False)
+ operation = Column(String, nullable=False) # sync, download, daemon_start, daemon_stop
+ status = Column(String, nullable=False) # success, error, partial
+ message = Column(String, nullable=True)
+ activities_processed = Column(Integer, default=0, nullable=False)
+ activities_downloaded = Column(Integer, default=0, nullable=False)
+```
+
+-----
+
+## **User Flow**
+
+### **CLI Mode (Existing)**
+1. User sets up credentials in `.env` file with `GARMIN_EMAIL` and `GARMIN_PASSWORD`
+2. User launches the container: `docker run -it --env-file .env -v $(pwd)/data:/app/data garminsync`
+3. User runs commands like `garminsync download --missing`
+4. Application syncs with Garmin Connect, shows progress bars, and downloads activities
+
+### **Offline Mode (New)**
+1. User runs `garminsync list --missing --offline` to view cached data without API calls
+2. Application queries local database only, showing last known state
+3. Useful for checking status without network connectivity or API rate limits
+
+### **Daemon Mode (New)**
+1. User starts daemon: `garminsync daemon --start`
+2. Daemon runs in background, scheduling automatic sync/download operations
+3. User accesses web UI at `http://localhost:8080` for monitoring and configuration
+4. Web UI provides real-time status, logs, and schedule management
+5. Daemon can be stopped with `garminsync daemon --stop` or through web UI
+
+-----
+
+## **File Structure**
+
+```
+/garminsync
+├── garminsync/ # Main application package
+│ ├── __init__.py # Empty package file
+│ ├── cli.py # Typer CLI commands and main entrypoint
+│ ├── config.py # Configuration and environment variable loading
+│ ├── database.py # SQLAlchemy models and database operations
+│ ├── garmin.py # Garmin Connect client wrapper with robust download logic
+│ ├── daemon.py # Daemon mode implementation with APScheduler
+│ ├── web/ # Web UI components
+│ │ ├── __init__.py
+│ │ ├── app.py # FastAPI application setup
+│ │ ├── routes.py # API endpoints for web UI
+│ │ ├── static/ # CSS, JavaScript, images
+│ │ │ ├── style.css
+│ │ │ └── app.js
+│ │ └── templates/ # Jinja2 HTML templates
+│ │ ├── base.html
+│ │ ├── dashboard.html
+│ │ └── config.html
+│ └── utils.py # Shared utilities and helpers
+├── data/ # Directory for downloaded .fit files and SQLite DB
+├── .env # Stores GARMIN_EMAIL/GARMIN_PASSWORD (gitignored)
+├── .gitignore # Excludes .env file
+├── Dockerfile # Production-ready container configuration
+├── Design.md # This design document
+└── requirements.txt # Pinned Python dependencies (updated)
+```
+
+-----
+
+## **Technical Implementation Details**
+
+### **Architecture**
+- **CLI Framework**: Uses Typer with proper type hints and validation
+- **Module Separation**: Clear separation between CLI commands, database operations, and Garmin API interactions
+- **Error Handling**: Comprehensive exception handling with user-friendly error messages
+- **Session Management**: Proper SQLAlchemy session management with cleanup
+
+### **Authentication & Configuration**
+- Credentials loaded via `python-dotenv` from environment variables
+- Configuration validation ensures required credentials are present
+- Garmin client handles authentication automatically with session persistence
+
+### **Database Operations**
+- SQLite database with SQLAlchemy ORM for type safety
+- Database initialization creates tables automatically
+- Sync functionality reconciles local database with Garmin Connect activities
+- Proper transaction management with rollback on errors
+
+### **File Management**
+- Files named with pattern: `activity_{activity_id}_{timestamp}.fit`
+- Timestamp sanitized for filesystem compatibility (colons and spaces replaced)
+- Downloads saved to configurable data directory
+- Database tracks both download status and file paths
+
+### **API Integration**
+- **Rate Limiting**: 2-second delays between API requests to respect Garmin's servers
+- **Robust Downloads**: Multiple fallback methods for downloading FIT files:
+ 1. Default download method
+ 2. Explicit 'fit' format parameter
+ 3. Alternative parameter names and formats
+ 4. Graceful fallback with detailed error reporting
+- **Activity Fetching**: Configurable batch sizes (currently 1000 activities per sync)
+
+### **User Experience**
+- **Progress Indicators**: tqdm progress bars for all long-running operations
+- **Informative Output**: Clear status messages and operation summaries
+- **Input Validation**: Prevents invalid command combinations
+- **Exit Codes**: Proper exit codes for script integration
+
+-----
+
+## **Development Status ✅**
+
+### **✅ Completed Features**
+
+#### **Phase 1: Core Infrastructure**
+- [x] **Dockerfile**: Production-ready Python 3.10 container with proper layer caching
+- [x] **Environment Configuration**: `python-dotenv` integration with validation
+- [x] **CLI Framework**: Complete Typer implementation with type hints and help text
+- [x] **Garmin Integration**: Robust `python-garminconnect` wrapper with authentication
+
+#### **Phase 2: Activity Listing**
+- [x] **Database Schema**: SQLAlchemy models with proper relationships
+- [x] **Database Operations**: Session management, initialization, and sync functionality
+- [x] **List Commands**: All filter options (`--all`, `--missing`, `--downloaded`) implemented
+- [x] **Progress Display**: tqdm integration for user feedback during operations
+
+#### **Phase 3: Download Pipeline**
+- [x] **FIT File Downloads**: Multi-method download approach with fallback strategies
+- [x] **Idempotent Operations**: Prevents re-downloading existing files
+- [x] **Database Updates**: Proper status tracking and file path storage
+- [x] **File Management**: Safe filename generation and directory creation
+
+#### **Phase 4: Polish**
+- [x] **Progress Bars**: Comprehensive tqdm implementation across all operations
+- [x] **Error Handling**: Graceful error handling with informative messages
+- [x] **Container Optimization**: Efficient Docker build with proper dependency management
+
+### **🚧 New Features Implementation Guide**
+
+#### **Feature 1: Offline Mode**
+
+**Implementation Steps:**
+1. **CLI Enhancement** (`cli.py`):
+ ```python
+ @app.command("list")
+ def list_activities(
+ all_activities: bool = False,
+ missing: bool = False,
+ downloaded: bool = False,
+ offline: Annotated[bool, typer.Option("--offline", help="Work offline without syncing")] = False
+ ):
+ if not offline:
+ # Existing sync logic
+ sync_database(client)
+ else:
+ typer.echo("Working in offline mode - using cached data")
+
+ # Rest of listing logic remains the same
+ ```
+
+2. **Database Enhancements** (`database.py`):
+ - Add `last_sync` column to Activity table
+ - Add utility functions for offline status checking
+ ```python
+ def get_offline_stats():
+ """Return statistics about cached data without API calls"""
+ session = get_session()
+ total = session.query(Activity).count()
+ downloaded = session.query(Activity).filter_by(downloaded=True).count()
+ missing = total - downloaded
+ last_sync = session.query(Activity).order_by(Activity.last_sync.desc()).first()
+ return {
+ 'total': total,
+ 'downloaded': downloaded,
+ 'missing': missing,
+ 'last_sync': last_sync.last_sync if last_sync else 'Never'
+ }
+ ```
+
+#### **Feature 2: Daemon Mode**
+
+**Implementation Steps:**
+1. **New Daemon Module** (`daemon.py`):
+ ```python
+ from apscheduler.schedulers.background import BackgroundScheduler
+ from apscheduler.triggers.cron import CronTrigger
+ import signal
+ import sys
+ import time
+ import threading
+ from datetime import datetime
+
+ class GarminSyncDaemon:
+ def __init__(self):
+ self.scheduler = BackgroundScheduler()
+ self.running = False
+ self.web_server = None
+
+ def start(self, web_port=8080):
+ """Start daemon with scheduler and web UI"""
+ # Load configuration from database
+ config = self.load_config()
+
+ # Setup scheduled job
+ if config.enabled:
+ self.scheduler.add_job(
+ func=self.sync_and_download,
+ trigger=CronTrigger.from_crontab(config.schedule_cron),
+ id='sync_job',
+ replace_existing=True
+ )
+
+ # Start scheduler
+ self.scheduler.start()
+ self.running = True
+
+ # Start web UI in separate thread
+ self.start_web_ui(web_port)
+
+ # Setup signal handlers for graceful shutdown
+ signal.signal(signal.SIGINT, self.signal_handler)
+ signal.signal(signal.SIGTERM, self.signal_handler)
+
+ print(f"Daemon started. Web UI available at http://localhost:{web_port}")
+
+ # Keep daemon running
+ try:
+ while self.running:
+ time.sleep(1)
+ except KeyboardInterrupt:
+ self.stop()
+
+ def sync_and_download(self):
+ """Scheduled job function"""
+ try:
+ self.log_operation("sync", "started")
+
+ # Perform sync and download
+ from .garmin import GarminClient
+ from .database import sync_database
+
+ client = GarminClient()
+ activities_before = self.count_missing()
+
+ sync_database(client)
+
+ # Download missing activities
+ downloaded_count = self.download_missing_activities(client)
+
+ self.log_operation("sync", "success",
+ f"Downloaded {downloaded_count} new activities")
+
+ except Exception as e:
+ self.log_operation("sync", "error", str(e))
+
+ def load_config(self):
+ """Load daemon configuration from database"""
+ session = get_session()
+ config = session.query(DaemonConfig).first()
+ if not config:
+ # Create default configuration
+ config = DaemonConfig()
+ session.add(config)
+ session.commit()
+ session.close()
+ return config
+ ```
+
+2. **CLI Integration** (`cli.py`):
+ ```python
+ @app.command("daemon")
+ def daemon_mode(
+ start: Annotated[bool, typer.Option("--start", help="Start daemon")] = False,
+ stop: Annotated[bool, typer.Option("--stop", help="Stop daemon")] = False,
+ status: Annotated[bool, typer.Option("--status", help="Show daemon status")] = False,
+ port: Annotated[int, typer.Option("--port", help="Web UI port")] = 8080
+ ):
+ """Daemon mode operations"""
+ from .daemon import GarminSyncDaemon
+
+ if start:
+ daemon = GarminSyncDaemon()
+ daemon.start(web_port=port)
+ elif stop:
+ # Implementation for stopping daemon (PID file or signal)
+ pass
+ elif status:
+ # Show current daemon status
+ pass
+ ```
+
+#### **Feature 3: Web UI**
+
+**Implementation Steps:**
+1. **FastAPI Application** (`web/app.py`):
+ ```python
+ from fastapi import FastAPI, Request
+ from fastapi.staticfiles import StaticFiles
+ from fastapi.templating import Jinja2Templates
+ from .routes import router
+
+ app = FastAPI(title="GarminSync Dashboard")
+
+ # Mount static files and templates
+ app.mount("/static", StaticFiles(directory="garminsync/web/static"), name="static")
+ templates = Jinja2Templates(directory="garminsync/web/templates")
+
+ # Include API routes
+ app.include_router(router)
+
+ @app.get("/")
+ async def dashboard(request: Request):
+ # Get current statistics
+ from ..database import get_offline_stats
+ stats = get_offline_stats()
+
+ return templates.TemplateResponse("dashboard.html", {
+ "request": request,
+ "stats": stats
+ })
+ ```
+
+2. **API Routes** (`web/routes.py`):
+ ```python
+ from fastapi import APIRouter, HTTPException
+ from pydantic import BaseModel
+ from ..database import get_session, DaemonConfig, SyncLog
+
+ router = APIRouter(prefix="/api")
+
+ class ScheduleConfig(BaseModel):
+ enabled: bool
+ cron_schedule: str
+
+ @router.get("/status")
+ async def get_status():
+ """Get current daemon status"""
+ session = get_session()
+ config = session.query(DaemonConfig).first()
+
+ # Get recent logs
+ logs = session.query(SyncLog).order_by(SyncLog.timestamp.desc()).limit(10).all()
+
+ return {
+ "daemon": {
+ "running": config.status == "running" if config else False,
+ "next_run": config.next_run if config else None,
+ "schedule": config.schedule_cron if config else None
+ },
+ "recent_logs": [
+ {
+ "timestamp": log.timestamp,
+ "operation": log.operation,
+ "status": log.status,
+ "message": log.message
+ } for log in logs
+ ]
+ }
+
+ @router.post("/schedule")
+ async def update_schedule(config: ScheduleConfig):
+ """Update daemon schedule configuration"""
+ session = get_session()
+ daemon_config = session.query(DaemonConfig).first()
+
+ if not daemon_config:
+ daemon_config = DaemonConfig()
+ session.add(daemon_config)
+
+ daemon_config.enabled = config.enabled
+ daemon_config.schedule_cron = config.cron_schedule
+ session.commit()
+
+ return {"message": "Configuration updated successfully"}
+
+ @router.post("/sync/trigger")
+ async def trigger_sync():
+ """Manually trigger a sync operation"""
+ # Implementation to trigger immediate sync
+ pass
+ ```
+
+3. **HTML Templates** (`web/templates/dashboard.html`):
+ ```html
+ {% extends "base.html" %}
+
+ {% block content %}
+
+
GarminSync Dashboard
+
+
+
+
+
+
+
Total Activities: {{ stats.total }}
+
Downloaded: {{ stats.downloaded }}
+
Missing: {{ stats.missing }}
+
Last Sync: {{ stats.last_sync }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% endblock %}
+ ```
+
+4. **JavaScript for Interactivity** (`web/static/app.js`):
+ ```javascript
+ // 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 daemon status
+ document.getElementById('daemon-status').innerHTML = `
+ Status:
+ ${data.daemon.running ? 'Running' : 'Stopped'}
+
+ 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 || ''}
+
+ `).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');
+ }
+ }
+
+ // Initialize on page load
+ document.addEventListener('DOMContentLoaded', updateStatus);
+ ```
+
+### **Updated Requirements** (`requirements.txt`):
+```
+typer==0.9.0
+click==8.1.7
+python-dotenv==1.0.0
+garminconnect==0.2.28
+sqlalchemy==2.0.23
+tqdm==4.66.1
+fastapi==0.104.1
+uvicorn[standard]==0.24.0
+apscheduler==3.10.4
+pydantic==2.5.0
+jinja2==3.1.2
+python-multipart==0.0.6
+aiofiles==23.2.1
+```
+
+### **Docker Updates**:
+```dockerfile
+# Expose web UI port
+EXPOSE 8080
+
+# Update entrypoint to support daemon mode
+ENTRYPOINT ["python", "-m", "garminsync.cli"]
+CMD ["--help"]
+```
+
+### **Usage Examples**:
+
+**Offline Mode:**
+```bash
+# List missing activities without network calls
+docker run --env-file .env -v $(pwd)/data:/app/data garminsync list --missing --offline
+```
+
+**Daemon Mode:**
+```bash
+# Start daemon with web UI on port 8080
+docker run -d --env-file .env -v $(pwd)/data:/app/data -p 8080:8080 garminsync daemon --start
+
+# Access web UI at http://localhost:8080
+```
+
+-----
+
+## **Docker Usage**
+
+### **Build the Container**
+```bash
+docker build -t garminsync .
+```
+
+### **Run with Environment File**
+```bash
+docker run -it --env-file .env -v $(pwd)/data:/app/data garminsync --help
+```
+
+### **Example Commands**
+```bash
+# List all activities
+docker run -it --env-file .env -v $(pwd)/data:/app/data garminsync list --all
+
+# Download missing activities
+docker run -it --env-file .env -v $(pwd)/data:/app/data garminsync download --missing
+```
+
+-----
+
+## **Environment Setup**
+
+Create a `.env` file in the project root:
+```
+GARMIN_EMAIL=your_email@example.com
+GARMIN_PASSWORD=your_password
+```
+
+-----
+
+## **Key Implementation Highlights**
+
+### **Robust Download Logic**
+The `garmin.py` module implements a sophisticated download strategy that tries multiple methods to handle variations in the Garmin Connect API:
+
+```python
+methods_to_try = [
+ lambda: self.client.download_activity(activity_id),
+ lambda: self.client.download_activity(activity_id, fmt='fit'),
+ lambda: self.client.download_activity(activity_id, format='fit'),
+ # ... additional fallback methods
+]
+```
+
+### **Database Synchronization**
+The sync process efficiently updates the local database with new activities from Garmin Connect:
+
+```python
+def sync_database(garmin_client):
+ """Sync local database with Garmin Connect activities"""
+ activities = garmin_client.get_activities(0, 1000)
+ for activity in activities:
+ # Only add new activities, preserve existing download status
+ existing = session.query(Activity).filter_by(activity_id=activity_id).first()
+ if not existing:
+ new_activity = Activity(...)
+ session.add(new_activity)
+```
+
+### **CLI Integration**
+Clean separation between CLI interface and business logic with proper type annotations:
+
+```python
+def list_activities(
+ all_activities: Annotated[bool, typer.Option("--all", help="List all activities")] = False,
+ missing: Annotated[bool, typer.Option("--missing", help="List missing activities")] = False,
+ downloaded: Annotated[bool, typer.Option("--downloaded", help="List downloaded activities")] = False
+):
+```
+
+-----
+
+## **Documentation 📚**
+
+Here are links to the official documentation for the key libraries used:
+
+* **Garmin API:** [python-garminconnect](https://github.com/cyberjunky/python-garminconnect)
+* **CLI Framework:** [Typer](https://typer.tiangolo.com/)
+* **Environment Variables:** [python-dotenv](https://github.com/theskumar/python-dotenv)
+* **Database ORM:** [SQLAlchemy](https://docs.sqlalchemy.org/en/20/)
+* **Progress Bars:** [tqdm](https://github.com/tqdm/tqdm)
+
+-----
+
+## **Performance Considerations**
+
+- **Rate Limiting**: 2-second delays between API requests prevent server overload
+- **Batch Processing**: Fetches up to 1000 activities per sync operation
+- **Efficient Queries**: Database queries optimized for filtering operations
+- **Memory Management**: Proper session cleanup and resource management
+- **Docker Optimization**: Layer caching and minimal base image for faster builds
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..afe1736
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,36 @@
+# Use official Python base image
+FROM python:3.10-slim
+
+# Set environment variables
+ENV PYTHONDONTWRITEBYTECODE=1
+ENV PYTHONUNBUFFERED=1
+
+# Set work directory
+WORKDIR /app
+
+# Install system dependencies
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ build-essential \
+ && rm -rf /var/lib/apt/lists/*
+
+# Copy and install Python dependencies
+COPY requirements.txt .
+RUN pip install --no-cache-dir --upgrade pip && \
+ pip install --no-cache-dir -r requirements.txt
+
+# Copy application code
+COPY garminsync/ ./garminsync/
+
+# Create data directory
+RUN mkdir -p /app/data
+
+# Set environment variables from .env file
+ENV ENV_FILE=/app/.env
+ENV DATA_DIR=/app/data
+
+# Expose web UI port
+EXPOSE 8080
+
+# Update entrypoint to support daemon mode
+ENTRYPOINT ["python", "-m", "garminsync.cli"]
+CMD ["--help"]
diff --git a/garminsync/__init__.py b/garminsync/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/garminsync/cli.py b/garminsync/cli.py
new file mode 100644
index 0000000..2ba0dbc
--- /dev/null
+++ b/garminsync/cli.py
@@ -0,0 +1,166 @@
+import os
+import typer
+from typing_extensions import Annotated
+from .config import load_config
+
+# Initialize environment variables
+load_config()
+
+app = typer.Typer(help="GarminSync - Download Garmin Connect activities", rich_markup_mode=None)
+
+@app.command("list")
+def list_activities(
+ all_activities: Annotated[bool, typer.Option("--all", help="List all activities")] = False,
+ missing: Annotated[bool, typer.Option("--missing", help="List missing activities")] = False,
+ downloaded: Annotated[bool, typer.Option("--downloaded", help="List downloaded activities")] = False,
+ offline: Annotated[bool, typer.Option("--offline", help="Work offline without syncing")] = False
+):
+ """List activities based on specified filters"""
+ from tqdm import tqdm
+ from .database import get_session, Activity, get_offline_stats, sync_database
+ from .garmin import GarminClient
+
+ # Validate input
+ if not any([all_activities, missing, downloaded]):
+ typer.echo("Error: Please specify at least one filter option (--all, --missing, --downloaded)")
+ raise typer.Exit(code=1)
+
+ try:
+ client = GarminClient()
+ session = get_session()
+
+ if not offline:
+ # Sync database with latest activities
+ typer.echo("Syncing activities from Garmin Connect...")
+ sync_database(client)
+ else:
+ # Show offline status with last sync info
+ stats = get_offline_stats()
+ typer.echo(f"Working in offline mode - using cached data (last sync: {stats['last_sync']})")
+
+ # Build query based on filters
+ query = session.query(Activity)
+
+ if all_activities:
+ pass # Return all activities
+ elif missing:
+ query = query.filter_by(downloaded=False)
+ elif downloaded:
+ query = query.filter_by(downloaded=True)
+
+ # Execute query and display results
+ activities = query.all()
+ if not activities:
+ typer.echo("No activities found matching your criteria")
+ return
+
+ # Display results with progress bar
+ typer.echo(f"Found {len(activities)} activities:")
+ for activity in tqdm(activities, desc="Listing activities"):
+ status = "Downloaded" if activity.downloaded else "Missing"
+ typer.echo(f"- ID: {activity.activity_id}, Start: {activity.start_time}, Status: {status}")
+
+ except Exception as e:
+ typer.echo(f"Error: {str(e)}")
+ raise typer.Exit(code=1)
+ finally:
+ if 'session' in locals():
+ session.close()
+
+@app.command("download")
+def download(
+ missing: Annotated[bool, typer.Option("--missing", help="Download missing activities")] = False
+):
+ """Download activities based on specified filters"""
+ from tqdm import tqdm
+ from pathlib import Path
+ from .database import get_session, Activity
+ from .garmin import GarminClient
+
+ # Validate input
+ if not missing:
+ typer.echo("Error: Currently only --missing downloads are supported")
+ raise typer.Exit(code=1)
+
+ try:
+ client = GarminClient()
+ session = get_session()
+
+ # Sync database with latest activities
+ typer.echo("Syncing activities from Garmin Connect...")
+ from .database import sync_database
+ sync_database(client)
+
+ # Get missing activities
+ activities = session.query(Activity).filter_by(downloaded=False).all()
+ if not activities:
+ typer.echo("No missing activities found")
+ return
+
+ # Create data directory if it doesn't exist
+ data_dir = Path(os.getenv("DATA_DIR", "data"))
+ data_dir.mkdir(parents=True, exist_ok=True)
+
+ # Download activities with progress bar
+ typer.echo(f"Downloading {len(activities)} missing activities...")
+ for activity in tqdm(activities, desc="Downloading"):
+ try:
+ # Download FIT data
+ fit_data = client.download_activity_fit(activity.activity_id)
+
+ # Create filename-safe timestamp
+ timestamp = activity.start_time.replace(":", "-").replace(" ", "_")
+ filename = f"activity_{activity.activity_id}_{timestamp}.fit"
+ filepath = data_dir / filename
+
+ # Save file
+ with open(filepath, "wb") as f:
+ f.write(fit_data)
+
+ # Update database
+ activity.filename = str(filepath)
+ activity.downloaded = True
+ session.commit()
+
+ except Exception as e:
+ typer.echo(f"Error downloading activity {activity.activity_id}: {str(e)}")
+ session.rollback()
+
+ typer.echo("Download completed successfully")
+
+ except Exception as e:
+ typer.echo(f"Error: {str(e)}")
+ raise typer.Exit(code=1)
+ finally:
+ if 'session' in locals():
+ session.close()
+
+@app.command("daemon")
+def daemon_mode(
+ start: Annotated[bool, typer.Option("--start", help="Start daemon")] = False,
+ stop: Annotated[bool, typer.Option("--stop", help="Stop daemon")] = False,
+ status: Annotated[bool, typer.Option("--status", help="Show daemon status")] = False,
+ port: Annotated[int, typer.Option("--port", help="Web UI port")] = 8080
+):
+ """Daemon mode operations"""
+ from .daemon import GarminSyncDaemon
+
+ if start:
+ daemon = GarminSyncDaemon()
+ daemon.start(web_port=port)
+ elif stop:
+ # Implementation for stopping daemon (PID file or signal)
+ typer.echo("Stopping daemon...")
+ # TODO: Implement stop (we can use a PID file to stop the daemon)
+ typer.echo("Daemon stop not implemented yet")
+ elif status:
+ # Show current daemon status
+ typer.echo("Daemon status not implemented yet")
+ else:
+ typer.echo("Please specify one of: --start, --stop, --status")
+
+def main():
+ app()
+
+if __name__ == "__main__":
+ main()
diff --git a/garminsync/config.py b/garminsync/config.py
new file mode 100644
index 0000000..5c2447e
--- /dev/null
+++ b/garminsync/config.py
@@ -0,0 +1,15 @@
+from dotenv import load_dotenv
+import os
+
+def load_config():
+ """Load environment variables from .env file"""
+ load_dotenv()
+
+class Config:
+ GARMIN_EMAIL = os.getenv("GARMIN_EMAIL")
+ GARMIN_PASSWORD = os.getenv("GARMIN_PASSWORD")
+
+ @classmethod
+ def validate(cls):
+ if not cls.GARMIN_EMAIL or not cls.GARMIN_PASSWORD:
+ raise ValueError("Missing GARMIN_EMAIL or GARMIN_PASSWORD in environment")
diff --git a/garminsync/daemon.py b/garminsync/daemon.py
new file mode 100644
index 0000000..b7b095b
--- /dev/null
+++ b/garminsync/daemon.py
@@ -0,0 +1,226 @@
+import signal
+import sys
+import time
+import threading
+from datetime import datetime
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.cron import CronTrigger
+from .database import get_session, Activity, DaemonConfig, SyncLog
+from .garmin import GarminClient
+from .utils import logger
+
+class GarminSyncDaemon:
+ def __init__(self):
+ self.scheduler = BackgroundScheduler()
+ self.running = False
+ self.web_server = None
+
+ def start(self, web_port=8080):
+ """Start daemon with scheduler and web UI"""
+ try:
+ # Load configuration from database
+ config_data = self.load_config()
+
+ # Setup scheduled job
+ if config_data['enabled']:
+ self.scheduler.add_job(
+ func=self.sync_and_download,
+ trigger=CronTrigger.from_crontab(config_data['schedule_cron']),
+ id='sync_job',
+ replace_existing=True
+ )
+
+ # Start scheduler
+ self.scheduler.start()
+ self.running = True
+
+ # Update daemon status to running
+ self.update_daemon_status("running")
+
+ # Start web UI in separate thread
+ self.start_web_ui(web_port)
+
+ # Setup signal handlers for graceful shutdown
+ signal.signal(signal.SIGINT, self.signal_handler)
+ signal.signal(signal.SIGTERM, self.signal_handler)
+
+ logger.info(f"Daemon started. Web UI available at http://localhost:{web_port}")
+
+ # Keep daemon running
+ while self.running:
+ time.sleep(1)
+
+ except Exception as e:
+ logger.error(f"Failed to start daemon: {str(e)}")
+ self.update_daemon_status("error")
+ self.stop()
+
+ def sync_and_download(self):
+ """Scheduled job function"""
+ session = None
+ try:
+ self.log_operation("sync", "started")
+
+ # Import here to avoid circular imports
+ from .garmin import GarminClient
+ from .database import sync_database
+
+ # Perform sync and download
+ client = GarminClient()
+
+ # Sync database first
+ sync_database(client)
+
+ # Download missing activities
+ downloaded_count = 0
+ session = get_session()
+ missing_activities = session.query(Activity).filter_by(downloaded=False).all()
+
+ for activity in missing_activities:
+ try:
+ # Use the correct method name
+ fit_data = client.download_activity_fit(activity.activity_id)
+
+ # Save the file
+ import os
+ from pathlib import Path
+ data_dir = Path(os.getenv("DATA_DIR", "data"))
+ data_dir.mkdir(parents=True, exist_ok=True)
+
+ timestamp = activity.start_time.replace(":", "-").replace(" ", "_")
+ filename = f"activity_{activity.activity_id}_{timestamp}.fit"
+ filepath = data_dir / filename
+
+ with open(filepath, "wb") as f:
+ f.write(fit_data)
+
+ activity.filename = str(filepath)
+ activity.downloaded = True
+ activity.last_sync = datetime.now().isoformat()
+ downloaded_count += 1
+ session.commit()
+
+ except Exception as e:
+ logger.error(f"Failed to download activity {activity.activity_id}: {e}")
+ session.rollback()
+
+ self.log_operation("sync", "success",
+ f"Downloaded {downloaded_count} new activities")
+
+ # Update last run time
+ self.update_daemon_last_run()
+
+ except Exception as e:
+ logger.error(f"Sync failed: {e}")
+ self.log_operation("sync", "error", str(e))
+ finally:
+ if session:
+ session.close()
+
+ def load_config(self):
+ """Load daemon configuration from database and return dict"""
+ session = get_session()
+ try:
+ config = session.query(DaemonConfig).first()
+ if not config:
+ # Create default configuration
+ config = DaemonConfig()
+ session.add(config)
+ session.commit()
+ session.refresh(config) # Ensure we have the latest data
+
+ # Return configuration as dictionary to avoid session issues
+ return {
+ 'id': config.id,
+ 'enabled': config.enabled,
+ 'schedule_cron': config.schedule_cron,
+ 'last_run': config.last_run,
+ 'next_run': config.next_run,
+ 'status': config.status
+ }
+ finally:
+ session.close()
+
+ def update_daemon_status(self, status):
+ """Update daemon status in database"""
+ session = get_session()
+ try:
+ config = session.query(DaemonConfig).first()
+ if not config:
+ config = DaemonConfig()
+ session.add(config)
+
+ config.status = status
+ session.commit()
+ finally:
+ session.close()
+
+ def update_daemon_last_run(self):
+ """Update daemon last run timestamp"""
+ session = get_session()
+ try:
+ config = session.query(DaemonConfig).first()
+ if config:
+ config.last_run = datetime.now().isoformat()
+ session.commit()
+ finally:
+ session.close()
+
+ def start_web_ui(self, port):
+ """Start FastAPI web server in a separate thread"""
+ try:
+ from .web.app import app
+ import uvicorn
+
+ def run_server():
+ try:
+ uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")
+ except Exception as e:
+ logger.error(f"Failed to start web server: {e}")
+
+ web_thread = threading.Thread(target=run_server, daemon=True)
+ web_thread.start()
+ self.web_server = web_thread
+ except ImportError as e:
+ logger.warning(f"Could not start web UI: {e}")
+
+ def signal_handler(self, signum, frame):
+ """Handle shutdown signals"""
+ logger.info("Received shutdown signal, stopping daemon...")
+ self.stop()
+
+ def stop(self):
+ """Stop daemon and clean up resources"""
+ if self.scheduler.running:
+ self.scheduler.shutdown()
+ self.running = False
+ self.update_daemon_status("stopped")
+ self.log_operation("daemon", "stopped", "Daemon shutdown completed")
+ logger.info("Daemon stopped")
+
+ def log_operation(self, operation, status, message=None):
+ """Log sync operation to database"""
+ session = get_session()
+ try:
+ log = SyncLog(
+ timestamp=datetime.now().isoformat(),
+ operation=operation,
+ status=status,
+ message=message,
+ activities_processed=0, # Can be updated later if needed
+ activities_downloaded=0 # Can be updated later if needed
+ )
+ session.add(log)
+ session.commit()
+ except Exception as e:
+ logger.error(f"Failed to log operation: {e}")
+ finally:
+ session.close()
+
+ def count_missing(self):
+ """Count missing activities"""
+ session = get_session()
+ try:
+ return session.query(Activity).filter_by(downloaded=False).count()
+ finally:
+ session.close()
\ No newline at end of file
diff --git a/garminsync/database.py b/garminsync/database.py
new file mode 100644
index 0000000..23a0dbe
--- /dev/null
+++ b/garminsync/database.py
@@ -0,0 +1,105 @@
+import os
+from sqlalchemy import create_engine, Column, Integer, String, Boolean
+from sqlalchemy.orm import declarative_base, sessionmaker
+from sqlalchemy.exc import SQLAlchemyError
+
+Base = declarative_base()
+
+class Activity(Base):
+ __tablename__ = 'activities'
+
+ activity_id = Column(Integer, primary_key=True)
+ start_time = Column(String, nullable=False)
+ filename = Column(String, unique=True, nullable=True)
+ downloaded = Column(Boolean, default=False, nullable=False)
+ created_at = Column(String, nullable=False) # Add this line
+ last_sync = Column(String, nullable=True) # ISO timestamp of last sync
+
+class DaemonConfig(Base):
+ __tablename__ = 'daemon_config'
+
+ id = Column(Integer, primary_key=True, default=1)
+ enabled = Column(Boolean, default=True, nullable=False)
+ schedule_cron = Column(String, default="0 */6 * * *", nullable=False) # Every 6 hours
+ last_run = Column(String, nullable=True)
+ next_run = Column(String, nullable=True)
+ status = Column(String, default="stopped", nullable=False) # stopped, running, error
+
+class SyncLog(Base):
+ __tablename__ = 'sync_logs'
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ timestamp = Column(String, nullable=False)
+ operation = Column(String, nullable=False) # sync, download, daemon_start, daemon_stop
+ status = Column(String, nullable=False) # success, error, partial
+ message = Column(String, nullable=True)
+ activities_processed = Column(Integer, default=0, nullable=False)
+ activities_downloaded = Column(Integer, default=0, nullable=False)
+
+def init_db():
+ """Initialize database connection and create tables"""
+ db_path = os.path.join(os.getenv("DATA_DIR", "data"), "garmin.db")
+ engine = create_engine(f"sqlite:///{db_path}")
+ Base.metadata.create_all(engine)
+ return engine
+
+def get_session():
+ """Create a new database session"""
+ engine = init_db()
+ Session = sessionmaker(bind=engine)
+ return Session()
+
+def sync_database(garmin_client):
+ """Sync local database with Garmin Connect activities"""
+ from datetime import datetime
+ session = get_session()
+ try:
+ # Fetch activities from Garmin Connect
+ activities = garmin_client.get_activities(0, 1000)
+
+ # Process activities and update database
+ for activity in activities:
+ activity_id = activity["activityId"]
+ start_time = activity["startTimeLocal"]
+
+ # Check if activity exists in database
+ existing = session.query(Activity).filter_by(activity_id=activity_id).first()
+ if not existing:
+ new_activity = Activity(
+ activity_id=activity_id,
+ start_time=start_time,
+ downloaded=False,
+ created_at=datetime.now().isoformat(), # Add this line
+ last_sync=datetime.now().isoformat()
+ )
+ session.add(new_activity)
+
+ session.commit()
+ except SQLAlchemyError as e:
+ session.rollback()
+ raise e
+ finally:
+ session.close()
+
+def get_offline_stats():
+ """Return statistics about cached data without API calls"""
+ session = get_session()
+ try:
+ total = session.query(Activity).count()
+ downloaded = session.query(Activity).filter_by(downloaded=True).count()
+ missing = total - downloaded
+ # Get most recent sync timestamp
+ last_sync = session.query(Activity).order_by(Activity.last_sync.desc()).first()
+ return {
+ 'total': total,
+ 'downloaded': downloaded,
+ 'missing': missing,
+ 'last_sync': last_sync.last_sync if last_sync else 'Never synced'
+ }
+ finally:
+ session.close()
+
+# Example usage:
+# from .garmin import GarminClient
+# client = GarminClient()
+# sync_database(client)
diff --git a/garminsync/garmin.py b/garminsync/garmin.py
new file mode 100644
index 0000000..f02f07c
--- /dev/null
+++ b/garminsync/garmin.py
@@ -0,0 +1,123 @@
+import os
+import time
+from garminconnect import Garmin
+
+class GarminClient:
+ def __init__(self):
+ self.client = None
+
+ def authenticate(self):
+ """Authenticate using credentials from environment variables"""
+ email = os.getenv("GARMIN_EMAIL")
+ password = os.getenv("GARMIN_PASSWORD")
+
+ if not email or not password:
+ raise ValueError("Garmin credentials not found in environment variables")
+
+ self.client = Garmin(email, password)
+ self.client.login()
+ return self.client
+
+ def get_activities(self, start=0, limit=10):
+ """Get list of activities with rate limiting"""
+ if not self.client:
+ self.authenticate()
+
+ activities = self.client.get_activities(start, limit)
+ time.sleep(2) # Rate limiting
+ return activities
+
+ def download_activity_fit(self, activity_id):
+ """Download .fit file for a specific activity"""
+ if not self.client:
+ self.authenticate()
+
+ print(f"Attempting to download activity {activity_id}")
+
+ # Try multiple methods to download FIT file
+ methods_to_try = [
+ # Method 1: No format parameter (most likely to work)
+ lambda: self.client.download_activity(activity_id),
+
+ # Method 2: Use 'fmt' instead of 'dl_fmt'
+ lambda: self.client.download_activity(activity_id, fmt='fit'),
+
+ # Method 3: Use 'format' parameter
+ lambda: self.client.download_activity(activity_id, format='fit'),
+
+ # Method 4: Try original parameter name with different values
+ lambda: self.client.download_activity(activity_id, dl_fmt='FIT'),
+ lambda: self.client.download_activity(activity_id, dl_fmt='tcx'), # Fallback format
+ ]
+
+ last_exception = None
+
+ for i, method in enumerate(methods_to_try, 1):
+ try:
+ print(f"Trying download method {i}...")
+ fit_data = method()
+
+ if fit_data:
+ print(f"Successfully downloaded {len(fit_data)} bytes using method {i}")
+ time.sleep(2) # Rate limiting
+ return fit_data
+ else:
+ print(f"Method {i} returned empty data")
+
+ except Exception as e:
+ print(f"Method {i} failed: {type(e).__name__}: {e}")
+ last_exception = e
+ continue
+
+ # If all methods failed, raise the last exception
+ raise RuntimeError(f"All download methods failed. Last error: {last_exception}")
+
+ def get_activity_details(self, activity_id):
+ """Get detailed information about a specific activity"""
+ if not self.client:
+ self.authenticate()
+
+ try:
+ activity_details = self.client.get_activity_by_id(activity_id)
+ time.sleep(2) # Rate limiting
+ return activity_details
+ except Exception as e:
+ print(f"Failed to get activity details for {activity_id}: {e}")
+ return None
+
+# Example usage and testing function
+def test_download(activity_id):
+ """Test function to verify download functionality"""
+ client = GarminClient()
+ try:
+ fit_data = client.download_activity_fit(activity_id)
+
+ # Verify the data looks like a FIT file
+ if fit_data and len(fit_data) > 14:
+ # FIT files start with specific header
+ header = fit_data[:14]
+ if b'.FIT' in header or header[8:12] == b'.FIT':
+ print("✅ Downloaded data appears to be a valid FIT file")
+ return fit_data
+ else:
+ print("⚠️ Downloaded data may not be a FIT file")
+ print(f"Header: {header}")
+ return fit_data
+ else:
+ print("❌ Downloaded data is empty or too small")
+ return None
+
+ except Exception as e:
+ print(f"❌ Test failed: {e}")
+ return None
+
+if __name__ == "__main__":
+ # Test with a sample activity ID if provided
+ import sys
+ if len(sys.argv) > 1:
+ test_activity_id = sys.argv[1]
+ print(f"Testing download for activity ID: {test_activity_id}")
+ test_download(test_activity_id)
+ else:
+ print("Usage: python garmin.py ")
+ print("This will test the download functionality with the provided activity ID")
\ No newline at end of file
diff --git a/garminsync/utils.py b/garminsync/utils.py
new file mode 100644
index 0000000..553c848
--- /dev/null
+++ b/garminsync/utils.py
@@ -0,0 +1,85 @@
+import logging
+import sys
+from datetime import datetime
+
+# Configure logging
+def setup_logger(name="garminsync", level=logging.INFO):
+ """Setup logger with consistent formatting"""
+ logger = logging.getLogger(name)
+
+ # Prevent duplicate handlers
+ if logger.handlers:
+ return logger
+
+ logger.setLevel(level)
+
+ # Create console handler
+ handler = logging.StreamHandler(sys.stdout)
+ handler.setLevel(level)
+
+ # Create formatter
+ formatter = logging.Formatter(
+ '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
+ )
+ handler.setFormatter(formatter)
+
+ # Add handler to logger
+ logger.addHandler(handler)
+
+ return logger
+
+# Create default logger instance
+logger = setup_logger()
+
+def format_timestamp(timestamp_str=None):
+ """Format timestamp string for display"""
+ if not timestamp_str:
+ return "Never"
+
+ try:
+ # Parse ISO format timestamp
+ dt = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
+ return dt.strftime("%Y-%m-%d %H:%M:%S")
+ except (ValueError, AttributeError):
+ return timestamp_str
+
+def safe_filename(filename):
+ """Make filename safe for filesystem"""
+ import re
+ # Replace problematic characters
+ safe_name = re.sub(r'[<>:"/\\|?*]', '_', filename)
+ # Replace spaces and colons commonly found in timestamps
+ safe_name = safe_name.replace(':', '-').replace(' ', '_')
+ return safe_name
+
+def bytes_to_human_readable(bytes_count):
+ """Convert bytes to human readable format"""
+ if bytes_count == 0:
+ return "0 B"
+
+ for unit in ['B', 'KB', 'MB', 'GB']:
+ if bytes_count < 1024.0:
+ return f"{bytes_count:.1f} {unit}"
+ bytes_count /= 1024.0
+ return f"{bytes_count:.1f} TB"
+
+def validate_cron_expression(cron_expr):
+ """Basic validation of cron expression"""
+ try:
+ from apscheduler.triggers.cron import CronTrigger
+ # Try to create a CronTrigger with the expression
+ CronTrigger.from_crontab(cron_expr)
+ return True
+ except (ValueError, TypeError):
+ return False
+
+# Utility function for error handling
+def handle_db_error(func):
+ """Decorator for database operations with error handling"""
+ def wrapper(*args, **kwargs):
+ try:
+ return func(*args, **kwargs)
+ except Exception as e:
+ logger.error(f"Database operation failed in {func.__name__}: {e}")
+ raise
+ return wrapper
\ No newline at end of file
diff --git a/garminsync/web/__init__.py b/garminsync/web/__init__.py
new file mode 100644
index 0000000..43c3f42
--- /dev/null
+++ b/garminsync/web/__init__.py
@@ -0,0 +1 @@
+# Empty file to mark this directory as a Python package
diff --git a/garminsync/web/app.py b/garminsync/web/app.py
new file mode 100644
index 0000000..e1c0d0a
--- /dev/null
+++ b/garminsync/web/app.py
@@ -0,0 +1,88 @@
+from fastapi import FastAPI, Request
+from fastapi.staticfiles import StaticFiles
+from fastapi.templating import Jinja2Templates
+from fastapi.responses import JSONResponse
+import os
+from pathlib import Path
+from .routes import router
+
+app = FastAPI(title="GarminSync Dashboard")
+
+# Get the current directory path
+current_dir = Path(__file__).parent
+
+# Mount static files and templates with error handling
+static_dir = current_dir / "static"
+templates_dir = current_dir / "templates"
+
+if static_dir.exists():
+ app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
+
+if templates_dir.exists():
+ templates = Jinja2Templates(directory=str(templates_dir))
+else:
+ templates = None
+
+# Include API routes
+app.include_router(router)
+
+@app.get("/")
+async def dashboard(request: Request):
+ """Dashboard route with fallback for missing templates"""
+ if not templates:
+ # Return JSON response if templates are not available
+ from garminsync.database import get_offline_stats
+ stats = get_offline_stats()
+ return JSONResponse({
+ "message": "GarminSync Dashboard",
+ "stats": stats,
+ "note": "Web UI templates not found, showing JSON response"
+ })
+
+ try:
+ # Get current statistics
+ from garminsync.database import get_offline_stats
+ stats = get_offline_stats()
+
+ return templates.TemplateResponse("dashboard.html", {
+ "request": request,
+ "stats": stats
+ })
+ except Exception as e:
+ return JSONResponse({
+ "error": f"Failed to load dashboard: {str(e)}",
+ "message": "Dashboard unavailable, API endpoints still functional"
+ })
+
+@app.get("/health")
+async def health_check():
+ """Health check endpoint"""
+ return {"status": "healthy", "service": "GarminSync Dashboard"}
+
+@app.get("/config")
+async def config_page(request: Request):
+ """Configuration page"""
+ if not templates:
+ return JSONResponse({
+ "message": "Configuration endpoint",
+ "note": "Use /api/schedule endpoints for configuration"
+ })
+
+ return templates.TemplateResponse("config.html", {
+ "request": request
+ })
+
+# Error handlers
+@app.exception_handler(404)
+async def not_found_handler(request: Request, exc):
+ return JSONResponse(
+ status_code=404,
+ content={"error": "Not found", "path": str(request.url.path)}
+ )
+
+@app.exception_handler(500)
+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
new file mode 100644
index 0000000..2c3b3ff
--- /dev/null
+++ b/garminsync/web/routes.py
@@ -0,0 +1,212 @@
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+from garminsync.database import get_session, DaemonConfig, SyncLog
+
+router = APIRouter(prefix="/api")
+
+class ScheduleConfig(BaseModel):
+ enabled: bool
+ cron_schedule: str
+
+@router.get("/status")
+async def get_status():
+ """Get current daemon status"""
+ session = get_session()
+ try:
+ config = session.query(DaemonConfig).first()
+
+ # Get recent logs
+ logs = session.query(SyncLog).order_by(SyncLog.timestamp.desc()).limit(10).all()
+
+ # Convert to dictionaries to avoid session issues
+ daemon_data = {
+ "running": config.status == "running" if config else False,
+ "next_run": config.next_run if config else None,
+ "schedule": config.schedule_cron if config else None,
+ "last_run": config.last_run if config else None,
+ "enabled": config.enabled if config else False
+ }
+
+ log_data = []
+ for log in logs:
+ log_data.append({
+ "timestamp": log.timestamp,
+ "operation": log.operation,
+ "status": log.status,
+ "message": log.message,
+ "activities_processed": log.activities_processed,
+ "activities_downloaded": log.activities_downloaded
+ })
+
+ return {
+ "daemon": daemon_data,
+ "recent_logs": log_data
+ }
+ finally:
+ session.close()
+
+@router.post("/schedule")
+async def update_schedule(config: ScheduleConfig):
+ """Update daemon schedule configuration"""
+ session = get_session()
+ try:
+ daemon_config = session.query(DaemonConfig).first()
+
+ if not daemon_config:
+ daemon_config = DaemonConfig()
+ session.add(daemon_config)
+
+ daemon_config.enabled = config.enabled
+ daemon_config.schedule_cron = config.cron_schedule
+ session.commit()
+
+ return {"message": "Configuration updated successfully"}
+ except Exception as e:
+ session.rollback()
+ raise HTTPException(status_code=500, detail=f"Failed to update configuration: {str(e)}")
+ finally:
+ session.close()
+
+@router.post("/sync/trigger")
+async def trigger_sync():
+ """Manually trigger a sync operation"""
+ try:
+ # Import here to avoid circular imports
+ from garminsync.garmin import GarminClient
+ from garminsync.database import sync_database, Activity
+ from datetime import datetime
+ import os
+ from pathlib import Path
+
+ # Create client and sync
+ client = GarminClient()
+ sync_database(client)
+
+ # Download missing activities
+ session = get_session()
+ try:
+ missing_activities = session.query(Activity).filter_by(downloaded=False).all()
+ downloaded_count = 0
+
+ data_dir = Path(os.getenv("DATA_DIR", "data"))
+ data_dir.mkdir(parents=True, exist_ok=True)
+
+ for activity in missing_activities:
+ try:
+ fit_data = client.download_activity_fit(activity.activity_id)
+
+ timestamp = activity.start_time.replace(":", "-").replace(" ", "_")
+ filename = f"activity_{activity.activity_id}_{timestamp}.fit"
+ filepath = data_dir / filename
+
+ with open(filepath, "wb") as f:
+ f.write(fit_data)
+
+ activity.filename = str(filepath)
+ activity.downloaded = True
+ activity.last_sync = datetime.now().isoformat()
+ downloaded_count += 1
+ session.commit()
+
+ except Exception as e:
+ print(f"Failed to download activity {activity.activity_id}: {e}")
+ session.rollback()
+
+ return {"message": f"Sync completed successfully. Downloaded {downloaded_count} activities."}
+ finally:
+ session.close()
+
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Sync failed: {str(e)}")
+
+@router.get("/activities/stats")
+async def get_activity_stats():
+ """Get activity statistics"""
+ from garminsync.database import get_offline_stats
+ return get_offline_stats()
+
+@router.get("/logs")
+async def get_logs(limit: int = 50):
+ """Get recent sync logs"""
+ session = get_session()
+ try:
+ logs = session.query(SyncLog).order_by(SyncLog.timestamp.desc()).limit(limit).all()
+
+ log_data = []
+ for log in logs:
+ log_data.append({
+ "id": log.id,
+ "timestamp": log.timestamp,
+ "operation": log.operation,
+ "status": log.status,
+ "message": log.message,
+ "activities_processed": log.activities_processed,
+ "activities_downloaded": log.activities_downloaded
+ })
+
+ return {"logs": log_data}
+ finally:
+ session.close()
+
+@router.post("/daemon/start")
+async def start_daemon():
+ """Start the daemon process"""
+ from garminsync.daemon import daemon_instance
+ try:
+ # Start the daemon in a separate thread to avoid blocking
+ import threading
+ daemon_thread = threading.Thread(target=daemon_instance.start)
+ daemon_thread.daemon = True
+ daemon_thread.start()
+
+ # Update daemon status in database
+ session = get_session()
+ config = session.query(DaemonConfig).first()
+ if not config:
+ config = DaemonConfig()
+ session.add(config)
+ config.status = "running"
+ session.commit()
+
+ return {"message": "Daemon started successfully"}
+ except Exception as e:
+ session.rollback()
+ raise HTTPException(status_code=500, detail=f"Failed to start daemon: {str(e)}")
+ finally:
+ session.close()
+
+@router.post("/daemon/stop")
+async def stop_daemon():
+ """Stop the daemon process"""
+ from garminsync.daemon import daemon_instance
+ try:
+ # Stop the daemon
+ daemon_instance.stop()
+
+ # Update daemon status in database
+ session = get_session()
+ config = session.query(DaemonConfig).first()
+ if config:
+ config.status = "stopped"
+ session.commit()
+
+ return {"message": "Daemon stopped successfully"}
+ except Exception as e:
+ session.rollback()
+ raise HTTPException(status_code=500, detail=f"Failed to stop daemon: {str(e)}")
+ finally:
+ session.close()
+
+@router.delete("/logs")
+async def clear_logs():
+ """Clear all sync logs"""
+ session = get_session()
+ try:
+ session.query(SyncLog).delete()
+ session.commit()
+ return {"message": "Logs cleared successfully"}
+ except Exception as e:
+ session.rollback()
+ raise HTTPException(status_code=500, detail=f"Failed to clear logs: {str(e)}")
+ finally:
+ session.close()
diff --git a/garminsync/web/static/app.js b/garminsync/web/static/app.js
new file mode 100644
index 0000000..9103358
--- /dev/null
+++ b/garminsync/web/static/app.js
@@ -0,0 +1,98 @@
+// 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 daemon status
+ document.getElementById('daemon-status').innerHTML = `
+ Status:
+ ${data.daemon.running ? 'Running' : 'Stopped'}
+
+ 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);
diff --git a/garminsync/web/static/style.css b/garminsync/web/static/style.css
new file mode 100644
index 0000000..ce8dfe5
--- /dev/null
+++ b/garminsync/web/static/style.css
@@ -0,0 +1,32 @@
+body {
+ font-family: Arial, sans-serif;
+ background-color: #f8f9fa;
+}
+
+.card {
+ margin-bottom: 20px;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
+}
+
+.card-header {
+ font-weight: bold;
+ background-color: #f1f1f1;
+}
+
+.btn {
+ margin-right: 5px;
+}
+
+.log-entry {
+ margin-bottom: 10px;
+ padding: 5px;
+ border-left: 3px solid #ddd;
+}
+
+.log-entry .badge-success {
+ background-color: #28a745;
+}
+
+.log-entry .badge-error {
+ background-color: #dc3545;
+}
diff --git a/garminsync/web/templates/base.html b/garminsync/web/templates/base.html
new file mode 100644
index 0000000..e91a55c
--- /dev/null
+++ b/garminsync/web/templates/base.html
@@ -0,0 +1,37 @@
+
+
+
+
+
+ GarminSync Dashboard
+
+
+
+
+
+
+
+ {% block content %}{% endblock %}
+
+
+
+
+
+
diff --git a/garminsync/web/templates/config.html b/garminsync/web/templates/config.html
new file mode 100644
index 0000000..65c3c95
--- /dev/null
+++ b/garminsync/web/templates/config.html
@@ -0,0 +1,141 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+
GarminSync Configuration
+
+
+
+
+
+
+
+
+
Current Status: {{ config.status|capitalize }}
+
Last Run: {{ config.last_run or 'Never' }}
+
Next Run: {{ config.next_run or 'Not scheduled' }}
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/garminsync/web/templates/dashboard.html b/garminsync/web/templates/dashboard.html
new file mode 100644
index 0000000..b74ef32
--- /dev/null
+++ b/garminsync/web/templates/dashboard.html
@@ -0,0 +1,79 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+
GarminSync Dashboard
+
+
+
+
+
+
+
Total Activities: {{ stats.total }}
+
Downloaded: {{ stats.downloaded }}
+
Missing: {{ stats.missing }}
+
Last Sync: {{ stats.last_sync }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/plan.md b/plan.md
new file mode 100644
index 0000000..1d49607
--- /dev/null
+++ b/plan.md
@@ -0,0 +1,203 @@
+# GarminSync Fixes and Updated Requirements
+
+## Primary Issue: Dependency Conflicts
+
+The main error you're encountering is a dependency conflict between `pydantic` and `garth` (a dependency of `garminconnect`). Here's the solution:
+
+### Updated requirements.txt
+```
+typer==0.9.0
+click==8.1.7
+python-dotenv==1.0.0
+garminconnect==0.2.29
+sqlalchemy==2.0.23
+tqdm==4.66.1
+fastapi==0.104.1
+uvicorn[standard]==0.24.0
+apscheduler==3.10.4
+pydantic>=2.0.0,<2.5.0
+jinja2==3.1.2
+python-multipart==0.0.6
+aiofiles==23.2.1
+```
+
+**Key Change**: Changed `pydantic==2.5.0` to `pydantic>=2.0.0,<2.5.0` to avoid the compatibility issue with `garth`.
+
+## Code Issues Found and Fixes
+
+### 1. Missing utils.py File
+Your `daemon.py` imports `from .utils import logger` but this file doesn't exist.
+
+**Fix**: Create `garminsync/utils.py`:
+```python
+import logging
+
+# Configure logging
+logging.basicConfig(
+ level=logging.INFO,
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
+)
+
+logger = logging.getLogger('garminsync')
+```
+
+### 2. Daemon.py Import Issues
+The `daemon.py` file has several import and method call issues.
+
+**Fix for garminsync/daemon.py** (line 56-75):
+```python
+def sync_and_download(self):
+ """Scheduled job function"""
+ try:
+ self.log_operation("sync", "started")
+
+ # Import here to avoid circular imports
+ from .garmin import GarminClient
+ from .database import sync_database
+
+ # Perform sync and download
+ client = GarminClient()
+
+ # Sync database first
+ sync_database(client)
+
+ # Download missing activities
+ downloaded_count = 0
+ session = get_session()
+ missing_activities = session.query(Activity).filter_by(downloaded=False).all()
+
+ for activity in missing_activities:
+ try:
+ # Use the correct method name
+ fit_data = client.download_activity_fit(activity.activity_id)
+
+ # Save the file
+ import os
+ from pathlib import Path
+ data_dir = Path(os.getenv("DATA_DIR", "data"))
+ data_dir.mkdir(parents=True, exist_ok=True)
+
+ timestamp = activity.start_time.replace(":", "-").replace(" ", "_")
+ filename = f"activity_{activity.activity_id}_{timestamp}.fit"
+ filepath = data_dir / filename
+
+ with open(filepath, "wb") as f:
+ f.write(fit_data)
+
+ activity.filename = str(filepath)
+ activity.downloaded = True
+ activity.last_sync = datetime.now().isoformat()
+ downloaded_count += 1
+ session.commit()
+
+ except Exception as e:
+ logger.error(f"Failed to download activity {activity.activity_id}: {e}")
+ session.rollback()
+
+ session.close()
+ self.log_operation("sync", "success",
+ f"Downloaded {downloaded_count} new activities")
+
+ except Exception as e:
+ logger.error(f"Sync failed: {e}")
+ self.log_operation("sync", "error", str(e))
+```
+
+### 3. Missing created_at Field in Database Sync
+The `sync_database` function in `database.py` doesn't set the `created_at` field.
+
+**Fix for garminsync/database.py** (line 64-75):
+```python
+def sync_database(garmin_client):
+ """Sync local database with Garmin Connect activities"""
+ from datetime import datetime
+ session = get_session()
+ try:
+ # Fetch activities from Garmin Connect
+ activities = garmin_client.get_activities(0, 1000)
+
+ # Process activities and update database
+ for activity in activities:
+ activity_id = activity["activityId"]
+ start_time = activity["startTimeLocal"]
+
+ # Check if activity exists in database
+ existing = session.query(Activity).filter_by(activity_id=activity_id).first()
+ if not existing:
+ new_activity = Activity(
+ activity_id=activity_id,
+ start_time=start_time,
+ downloaded=False,
+ created_at=datetime.now().isoformat(), # Add this line
+ last_sync=datetime.now().isoformat()
+ )
+ session.add(new_activity)
+
+ session.commit()
+ except SQLAlchemyError as e:
+ session.rollback()
+ raise e
+ finally:
+ session.close()
+```
+
+### 4. Add Missing created_at Field to Database Model
+The `Activity` model is missing the `created_at` field that's used in the daemon.
+
+**Fix for garminsync/database.py** (line 12):
+```python
+class Activity(Base):
+ __tablename__ = 'activities'
+
+ activity_id = Column(Integer, primary_key=True)
+ start_time = Column(String, nullable=False)
+ filename = Column(String, unique=True, nullable=True)
+ downloaded = Column(Boolean, default=False, nullable=False)
+ created_at = Column(String, nullable=False) # Add this line
+ last_sync = Column(String, nullable=True) # ISO timestamp of last sync
+```
+
+### 5. JavaScript Function Missing in Dashboard
+The dashboard template calls `toggleDaemon()` but this function doesn't exist in the JavaScript.
+
+**Fix for garminsync/web/static/app.js** (add this function):
+```javascript
+async function toggleDaemon() {
+ // TODO: Implement daemon toggle functionality
+ alert('Daemon toggle functionality not yet implemented');
+}
+```
+
+## Testing the Fixes
+
+After applying these fixes:
+
+1. **Rebuild the Docker image**:
+ ```bash
+ docker build -t garminsync .
+ ```
+
+2. **Test the daemon mode**:
+ ```bash
+ docker run -d --env-file .env -v $(pwd)/data:/app/data -p 8080:8080 garminsync daemon --start
+ ```
+
+3. **Check the logs**:
+ ```bash
+ docker logs
+ ```
+
+4. **Access the web UI**:
+ Open http://localhost:8080 in your browser
+
+## Additional Recommendations
+
+1. **Add error handling for missing directories**: The daemon should create the data directory if it doesn't exist.
+
+2. **Improve logging**: Add more detailed logging throughout the application.
+
+3. **Add health checks**: Implement health check endpoints for the daemon.
+
+4. **Database migrations**: Consider adding database migration support for schema changes.
+
+The primary fix for your immediate issue is updating the `pydantic` version constraint in `requirements.txt`. The other fixes address various code quality and functionality issues I found during the review.
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..946fd42
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,13 @@
+typer==0.9.0
+click==8.1.7
+python-dotenv==1.0.0
+garminconnect==0.2.28
+sqlalchemy==2.0.23
+tqdm==4.66.1
+fastapi==0.104.1
+uvicorn[standard]==0.24.0
+apscheduler==3.10.4
+pydantic>=2.0.0,<2.5.0
+jinja2==3.1.2
+python-multipart==0.0.6
+aiofiles==23.2.1