# GarminSync Improvement Plan - Junior Developer Guide ## Overview This plan focuses on keeping things simple while making meaningful improvements. We'll avoid complex async patterns and stick to a single-container approach. --- ## Phase 1: Fix Blocking Issues & Add GPX Support (Week 1-2) ### Problem: Sync blocks the web UI **Current Issue:** When sync runs, users can't use the web interface. ### Solution: Simple Threading Instead of complex async, use Python's threading module: ```python # garminsync/daemon.py - Update sync_and_download method import threading from datetime import datetime class GarminSyncDaemon: def __init__(self): self.scheduler = BackgroundScheduler() self.running = False self.web_server = None self.sync_lock = threading.Lock() # Prevent multiple syncs self.sync_in_progress = False def sync_and_download(self): """Non-blocking sync job""" # Check if sync is already running if not self.sync_lock.acquire(blocking=False): logger.info("Sync already in progress, skipping...") return try: self.sync_in_progress = True self._do_sync_work() finally: self.sync_in_progress = False self.sync_lock.release() def _do_sync_work(self): """The actual sync logic (moved from sync_and_download)""" # ... existing sync code here ... ``` ### Add GPX Parser Create a new parser for GPX files: ```python # garminsync/parsers/gpx_parser.py import xml.etree.ElementTree as ET from datetime import datetime def parse_gpx_file(file_path): """Parse GPX file to extract activity metrics""" try: tree = ET.parse(file_path) root = tree.getroot() # GPX uses different namespace ns = {'gpx': 'http://www.topografix.com/GPX/1/1'} # Extract basic info track = root.find('.//gpx:trk', ns) if not track: return None # Get track points track_points = root.findall('.//gpx:trkpt', ns) if not track_points: return None # Calculate basic metrics start_time = None end_time = None total_distance = 0.0 elevations = [] prev_point = None for point in track_points: # Get time time_elem = point.find('gpx:time', ns) if time_elem is not None: current_time = datetime.fromisoformat(time_elem.text.replace('Z', '+00:00')) if start_time is None: start_time = current_time end_time = current_time # Get elevation ele_elem = point.find('gpx:ele', ns) if ele_elem is not None: elevations.append(float(ele_elem.text)) # Calculate distance if prev_point is not None: lat1, lon1 = float(prev_point.get('lat')), float(prev_point.get('lon')) lat2, lon2 = float(point.get('lat')), float(point.get('lon')) total_distance += calculate_distance(lat1, lon1, lat2, lon2) prev_point = point # Calculate duration duration = None if start_time and end_time: duration = (end_time - start_time).total_seconds() return { "activityType": {"typeKey": "other"}, # GPX doesn't specify activity type "summaryDTO": { "duration": duration, "distance": total_distance, "maxHR": None, # GPX rarely has HR data "avgPower": None, "calories": None } } except Exception as e: print(f"Error parsing GPX file: {e}") return None def calculate_distance(lat1, lon1, lat2, lon2): """Calculate distance between two GPS points using Haversine formula""" import math # Convert to radians lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2]) # Haversine formula dlat = lat2 - lat1 dlon = lon2 - lon1 a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2 c = 2 * math.asin(math.sqrt(a)) # Earth's radius in meters earth_radius = 6371000 return c * earth_radius ``` ### Update Activity Parser ```python # garminsync/activity_parser.py - Add GPX support def detect_file_type(file_path): """Detect file format (FIT, XML, GPX, or unknown)""" try: with open(file_path, 'rb') as f: header = f.read(256) # Read more to catch GPX # Check for XML-based formats if b'= 8 and header[4:8] == b'.FIT': return 'fit' if (len(header) >= 8 and (header[0:4] == b'.FIT' or header[4:8] == b'FIT.' or header[8:12] == b'.FIT')): return 'fit' return 'unknown' except Exception as e: return 'error' # Update get_activity_metrics to include GPX def get_activity_metrics(activity, client=None): """Get activity metrics from local file or Garmin API""" metrics = None if activity.filename and os.path.exists(activity.filename): file_type = detect_file_type(activity.filename) if file_type == 'fit': metrics = parse_fit_file(activity.filename) elif file_type == 'xml': metrics = parse_xml_file(activity.filename) elif file_type == 'gpx': from .parsers.gpx_parser import parse_gpx_file metrics = parse_gpx_file(activity.filename) # Only call Garmin API if we don't have local file data if not metrics and client: try: metrics = client.get_activity_details(activity.activity_id) except Exception: pass return metrics ``` --- ## Phase 2: Better File Storage & Reduce API Calls (Week 3-4) ### Problem: We're calling Garmin API unnecessarily when we have the file ### Solution: Smart Caching Strategy ```python # garminsync/database.py - Add file-first approach def sync_database(garmin_client): """Sync local database with Garmin Connect activities""" session = get_session() try: # Get activities list from Garmin (lightweight call) activities = garmin_client.get_activities(0, 1000) if not activities: print("No activities returned from Garmin API") return for activity_data in activities: activity_id = activity_data.get("activityId") start_time = activity_data.get("startTimeLocal") if not activity_id or not start_time: continue existing = session.query(Activity).filter_by(activity_id=activity_id).first() if not existing: activity = Activity( activity_id=activity_id, start_time=start_time, downloaded=False, created_at=datetime.now().isoformat(), last_sync=datetime.now().isoformat(), ) session.add(activity) session.flush() else: activity = existing # Only get detailed metrics if we don't have a file OR file parsing failed if not activity.filename or not activity.duration: # Try to get metrics from file first if activity.filename and os.path.exists(activity.filename): metrics = get_activity_metrics(activity, client=None) # File only else: metrics = None # Only call API if file parsing failed or no file if not metrics: print(f"Getting details from API for activity {activity_id}") metrics = get_activity_metrics(activity, garmin_client) else: print(f"Using cached file data for activity {activity_id}") # Update activity with metrics if metrics: update_activity_from_metrics(activity, metrics) activity.last_sync = datetime.now().isoformat() session.commit() except Exception as e: session.rollback() raise e finally: session.close() def update_activity_from_metrics(activity, metrics): """Helper function to update activity from metrics data""" if not metrics: return activity.activity_type = metrics.get("activityType", {}).get("typeKey") summary = metrics.get("summaryDTO", {}) if summary.get("duration"): activity.duration = int(float(summary["duration"])) if summary.get("distance"): activity.distance = float(summary["distance"]) if summary.get("maxHR"): activity.max_heart_rate = int(float(summary["maxHR"])) if summary.get("avgHR"): activity.avg_heart_rate = int(float(summary["avgHR"])) if summary.get("avgPower"): activity.avg_power = float(summary["avgPower"]) if summary.get("calories"): activity.calories = int(float(summary["calories"])) ``` ### Add Original File Storage ```python # garminsync/database.py - Update Activity model class Activity(Base): __tablename__ = "activities" activity_id = Column(Integer, primary_key=True) start_time = Column(String, nullable=False) activity_type = Column(String, nullable=True) duration = Column(Integer, nullable=True) distance = Column(Float, nullable=True) max_heart_rate = Column(Integer, nullable=True) avg_heart_rate = Column(Integer, nullable=True) avg_power = Column(Float, nullable=True) calories = Column(Integer, nullable=True) filename = Column(String, unique=True, nullable=True) original_filename = Column(String, nullable=True) # NEW: Store original name file_type = Column(String, nullable=True) # NEW: Store detected file type file_size = Column(Integer, nullable=True) # NEW: Store file size downloaded = Column(Boolean, default=False, nullable=False) created_at = Column(String, nullable=False) last_sync = Column(String, nullable=True) metrics_source = Column(String, nullable=True) # NEW: 'file' or 'api' ``` --- ## Phase 3: Enhanced UI with Filtering & Stats (Week 5-6) ### Add Database Indexing ```python # Create new migration file: migrations/versions/003_add_indexes.py from alembic import op import sqlalchemy as sa def upgrade(): # Add indexes for common queries op.create_index('ix_activities_activity_type', 'activities', ['activity_type']) op.create_index('ix_activities_start_time', 'activities', ['start_time']) op.create_index('ix_activities_downloaded', 'activities', ['downloaded']) op.create_index('ix_activities_duration', 'activities', ['duration']) op.create_index('ix_activities_distance', 'activities', ['distance']) def downgrade(): op.drop_index('ix_activities_activity_type') op.drop_index('ix_activities_start_time') op.drop_index('ix_activities_downloaded') op.drop_index('ix_activities_duration') op.drop_index('ix_activities_distance') ``` ### Enhanced Activities API with Filtering ```python # garminsync/web/routes.py - Update activities endpoint @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, min_distance: float = None, max_distance: float = None, min_duration: int = None, max_duration: int = None, sort_by: str = "start_time", # NEW: sorting sort_order: str = "desc" # NEW: sort direction ): """Get paginated activities with enhanced 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) if min_distance: query = query.filter(Activity.distance >= min_distance * 1000) # Convert km to m if max_distance: query = query.filter(Activity.distance <= max_distance * 1000) if min_duration: query = query.filter(Activity.duration >= min_duration * 60) # Convert min to sec if max_duration: query = query.filter(Activity.duration <= max_duration * 60) # Apply sorting sort_column = getattr(Activity, sort_by, Activity.start_time) if sort_order.lower() == "asc": query = query.order_by(sort_column.asc()) else: query = query.order_by(sort_column.desc()) # Get total count for pagination total = query.count() # Apply pagination activities = query.offset((page - 1) * per_page).limit(per_page).all() return { "activities": [activity_to_dict(activity) for activity in activities], "total": total, "page": page, "per_page": per_page, "total_pages": (total + per_page - 1) // per_page } finally: session.close() def activity_to_dict(activity): """Convert activity to dictionary with computed fields""" return { "activity_id": activity.activity_id, "start_time": activity.start_time, "activity_type": activity.activity_type, "duration": activity.duration, "duration_formatted": format_duration(activity.duration), "distance": activity.distance, "distance_km": round(activity.distance / 1000, 2) if activity.distance else None, "pace": calculate_pace(activity.distance, activity.duration), "max_heart_rate": activity.max_heart_rate, "avg_heart_rate": activity.avg_heart_rate, "avg_power": activity.avg_power, "calories": activity.calories, "downloaded": activity.downloaded, "file_type": activity.file_type, "metrics_source": activity.metrics_source } def calculate_pace(distance_m, duration_s): """Calculate pace in min/km""" if not distance_m or not duration_s or distance_m == 0: return None distance_km = distance_m / 1000 pace_s_per_km = duration_s / distance_km minutes = int(pace_s_per_km // 60) seconds = int(pace_s_per_km % 60) return f"{minutes}:{seconds:02d}" ``` ### Enhanced Frontend with Filtering ```javascript // garminsync/web/static/activities.js - Add filtering capabilities class ActivitiesPage { constructor() { this.currentPage = 1; this.pageSize = 25; this.totalPages = 1; this.activities = []; this.filters = {}; this.sortBy = 'start_time'; this.sortOrder = 'desc'; this.init(); } init() { this.setupFilterForm(); this.loadActivities(); this.setupEventListeners(); } setupFilterForm() { // Create filter form dynamically const filterHtml = `

Filters

`; // Insert before activities table const container = document.querySelector('.activities-container'); container.insertAdjacentHTML('afterbegin', filterHtml); } setupEventListeners() { // Apply filters document.getElementById('apply-filters').addEventListener('click', () => { this.applyFilters(); }); // Clear filters document.getElementById('clear-filters').addEventListener('click', () => { this.clearFilters(); }); // Toggle filter visibility document.getElementById('toggle-filters').addEventListener('click', (e) => { const filterForm = document.getElementById('filter-form'); const isVisible = filterForm.style.display !== 'none'; filterForm.style.display = isVisible ? 'none' : 'block'; e.target.textContent = isVisible ? 'Show' : 'Hide'; }); } applyFilters() { this.filters = { activity_type: document.getElementById('activity-type-filter').value, date_from: document.getElementById('date-from-filter').value, date_to: document.getElementById('date-to-filter').value, min_distance: document.getElementById('min-distance-filter').value, max_distance: document.getElementById('max-distance-filter').value }; this.sortBy = document.getElementById('sort-by-filter').value; this.sortOrder = document.getElementById('sort-order-filter').value; // Remove empty filters Object.keys(this.filters).forEach(key => { if (!this.filters[key]) { delete this.filters[key]; } }); this.currentPage = 1; this.loadActivities(); } clearFilters() { // Reset all filter inputs document.getElementById('activity-type-filter').value = ''; document.getElementById('date-from-filter').value = ''; document.getElementById('date-to-filter').value = ''; document.getElementById('min-distance-filter').value = ''; document.getElementById('max-distance-filter').value = ''; document.getElementById('sort-by-filter').value = 'start_time'; document.getElementById('sort-order-filter').value = 'desc'; // Reset internal state this.filters = {}; this.sortBy = 'start_time'; this.sortOrder = 'desc'; this.currentPage = 1; this.loadActivities(); } 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 || '-'} ${activity.duration_formatted || '-'} ${activity.distance_km ? activity.distance_km + ' km' : '-'} ${activity.pace || '-'} ${Utils.formatHeartRate(activity.max_heart_rate)} ${Utils.formatHeartRate(activity.avg_heart_rate)} ${Utils.formatPower(activity.avg_power)} ${activity.calories ? activity.calories.toLocaleString() : '-'} ${activity.file_type || 'API'} `; return row; } } ``` --- ## Phase 4: Activity Stats & Trends (Week 7-8) ### Add Statistics API ```python # garminsync/web/routes.py - Add comprehensive stats @router.get("/stats/summary") async def get_activity_summary(): """Get comprehensive activity statistics""" session = get_session() try: # Basic counts total_activities = session.query(Activity).count() downloaded_activities = session.query(Activity).filter_by(downloaded=True).count() # Activity type breakdown type_stats = session.query( Activity.activity_type, func.count(Activity.activity_id).label('count'), func.sum(Activity.distance).label('total_distance'), func.sum(Activity.duration).label('total_duration'), func.sum(Activity.calories).label('total_calories') ).filter( Activity.activity_type.isnot(None) ).group_by(Activity.activity_type).all() # Monthly stats (last 12 months) monthly_stats = session.query( func.strftime('%Y-%m', Activity.start_time).label('month'), func.count(Activity.activity_id).label('count'), func.sum(Activity.distance).label('total_distance'), func.sum(Activity.duration).label('total_duration') ).filter( Activity.start_time >= (datetime.now() - timedelta(days=365)).isoformat() ).group_by( func.strftime('%Y-%m', Activity.start_time) ).order_by('month').all() # Personal records records = { 'longest_distance': session.query(Activity).filter( Activity.distance.isnot(None) ).order_by(Activity.distance.desc()).first(), 'longest_duration': session.query(Activity).filter( Activity.duration.isnot(None) ).order_by(Activity.duration.desc()).first(), 'highest_calories': session.query(Activity).filter( Activity.calories.isnot(None) ).order_by(Activity.calories.desc()).first() } return { "summary": { "total_activities": total_activities, "downloaded_activities": downloaded_activities, "sync_percentage": round((downloaded_activities / total_activities) * 100, 1) if total_activities > 0 else 0 }, "by_type": [ { "activity_type": stat.activity_type, "count": stat.count, "total_distance_km": round(stat.total_distance / 1000, 1) if stat.total_distance else 0, "total_duration_hours": round(stat.total_duration / 3600, 1) if stat.total_duration else 0, "total_calories": stat.total_calories or 0 } for stat in type_stats ], "monthly": [ { "month": stat.month, "count": stat.count, "total_distance_km": round(stat.total_distance / 1000, 1) if stat.total_distance else 0, "total_duration_hours": round(stat.total_duration / 3600, 1) if stat.total_duration else 0 } for stat in monthly_stats ], "records": { "longest_distance": { "distance_km": round(records['longest_distance'].distance / 1000, 1) if records['longest_distance'] and records['longest_distance'].distance else 0, "date": records['longest_distance'].start_time if records['longest_distance'] else None }, "longest_duration": { "duration_hours": round(records['longest_duration'].duration / 3600, 1) if records['longest_duration'] and records['longest_duration'].duration else 0, "date": records['longest_duration'].start_time if records['longest_duration'] else None }, "highest_calories": { "calories": records['highest_calories'].calories if records['highest_calories'] and records['highest_calories'].calories else 0, "date": records['highest_calories'].start_time if records['highest_calories'] else None } } } finally: session.close() ``` ### Simple Charts with Chart.js ```html

Activity Statistics

{{ stats.total }}

Total Activities

{{ stats.downloaded }}

Downloaded

-

Sync %

Activity Types

Monthly Activity

``` ```javascript // garminsync/web/static/stats.js - Simple chart implementation class StatsPage { constructor() { this.charts = {}; this.init(); } async init() { await this.loadStats(); this.createCharts(); } async loadStats() { try { const response = await fetch('/api/stats/summary'); this.stats = await response.json(); this.updateSummaryCards(); } catch (error) { console.error('Failed to load stats:', error); } } updateSummaryCards() { document.getElementById('total-activities').textContent = this.stats.summary.total_activities; document.getElementById('downloaded-activities').textContent = this.stats.summary.downloaded_activities; document.getElementById('sync-percentage').textContent = this.stats.summary.sync_percentage + '%'; } createCharts() { this.createActivityTypesChart(); this.createMonthlyChart(); } createActivityTypesChart() { const ctx = document.getElementById('activity-types-chart').getContext('2d'); const data = this.stats.by_type.map(item => ({ label: item.activity_type, data: item.count })); this.charts.activityTypes = new Chart(ctx, { type: 'doughnut', data: { labels: data.map(item => item.label), datasets: [{ data: data.map(item => item.data), backgroundColor: [ '#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', '#FF9F40', '#FF6384', '#C9CBCF' ] }] }, options: { responsive: true, plugins: { legend: { position: 'bottom' }, tooltip: { callbacks: { label: function(context) { const label = context.label || ''; const value = context.parsed; const total = context.dataset.data.reduce((a, b) => a + b, 0); const percentage = ((value / total) * 100).toFixed(1); return `${label}: ${value} (${percentage}%)`; } } } } } }); } createMonthlyChart() { const ctx = document.getElementById('monthly-chart').getContext('2d'); const monthlyData = this.stats.monthly; this.charts.monthly = new Chart(ctx, { type: 'line', data: { labels: monthlyData.map(item => item.month), datasets: [ { label: 'Activities', data: monthlyData.map(item => item.count), borderColor: '#36A2EB', backgroundColor: 'rgba(54, 162, 235, 0.1)', yAxisID: 'y' }, { label: 'Distance (km)', data: monthlyData.map(item => item.total_distance_km), borderColor: '#FF6384', backgroundColor: 'rgba(255, 99, 132, 0.1)', yAxisID: 'y1' } ] }, options: { responsive: true, plugins: { legend: { position: 'top' }, tooltip: { mode: 'index', intersect: false } }, scales: { y: { type: 'linear', display: true, position: 'left', title: { display: true, text: 'Number of Activities' } }, y1: { type: 'linear', display: true, position: 'right', title: { display: true, text: 'Distance (km)' }, grid: { drawOnChartArea: false, }, } } } }); } } // Initialize when DOM is ready document.addEventListener('DOMContentLoaded', function() { if (document.getElementById('activity-types-chart')) { new StatsPage(); } }); ``` --- ## Phase 5: File Management & Storage Optimization (Week 9-10) ### Problem: Better file organization and storage ### Solution: Organized File Storage with Metadata ```python # garminsync/file_manager.py - New file for managing activity files import os import hashlib from pathlib import Path from datetime import datetime import shutil class ActivityFileManager: """Manages activity file storage with proper organization""" def __init__(self, base_data_dir=None): self.base_dir = Path(base_data_dir or os.getenv("DATA_DIR", "data")) self.activities_dir = self.base_dir / "activities" self.activities_dir.mkdir(parents=True, exist_ok=True) def save_activity_file(self, activity_id, file_data, original_filename=None): """ Save activity file with proper organization Returns: (filepath, file_info) """ # Detect file type from data file_type = self._detect_file_type_from_data(file_data) # Generate file hash for deduplication file_hash = hashlib.md5(file_data).hexdigest() # Create organized directory structure: activities/YYYY/MM/ activity_date = self._extract_date_from_activity_id(activity_id) year_month_dir = self.activities_dir / activity_date.strftime("%Y") / activity_date.strftime("%m") year_month_dir.mkdir(parents=True, exist_ok=True) # Generate filename extension = self._get_extension_for_type(file_type) filename = f"activity_{activity_id}_{file_hash[:8]}.{extension}" filepath = year_month_dir / filename # Check if file already exists (deduplication) if filepath.exists(): existing_size = filepath.stat().st_size if existing_size == len(file_data): print(f"File already exists for activity {activity_id}, skipping...") return str(filepath), self._get_file_info(filepath, file_data, file_type) # Save file with open(filepath, 'wb') as f: f.write(file_data) file_info = self._get_file_info(filepath, file_data, file_type) print(f"Saved activity {activity_id} to {filepath}") return str(filepath), file_info def _detect_file_type_from_data(self, data): """Detect file type from binary data""" if len(data) >= 8 and data[4:8] == b'.FIT': return 'fit' elif b'= 2: activity_id = int(parts[1]) if activity_id not in valid_activity_ids: orphaned_files.append(file_path) except (ValueError, IndexError): continue # Remove orphaned files for file_path in orphaned_files: print(f"Removing orphaned file: {file_path}") file_path.unlink() return len(orphaned_files) ``` ### Update Download Process ```python # garminsync/daemon.py - Update sync_and_download to use file manager from .file_manager import ActivityFileManager class GarminSyncDaemon: def __init__(self): self.scheduler = BackgroundScheduler() self.running = False self.web_server = None self.sync_lock = threading.Lock() self.sync_in_progress = False self.file_manager = ActivityFileManager() # NEW def sync_and_download(self): """Scheduled job function with improved file handling""" session = None try: self.log_operation("sync", "started") from .database import sync_database from .garmin import GarminClient client = GarminClient() sync_database(client) downloaded_count = 0 session = get_session() missing_activities = ( session.query(Activity).filter_by(downloaded=False).all() ) for activity in missing_activities: try: # Download activity data fit_data = client.download_activity_fit(activity.activity_id) # Save using file manager filepath, file_info = self.file_manager.save_activity_file( activity.activity_id, fit_data ) # Update activity record activity.filename = filepath activity.file_type = file_info['type'] activity.file_size = file_info['size'] activity.downloaded = True activity.last_sync = datetime.now().isoformat() # Get metrics from file metrics = get_activity_metrics(activity, client=None) # File only if metrics: update_activity_from_metrics(activity, metrics) activity.metrics_source = 'file' else: # Fallback to API if file parsing fails metrics = get_activity_metrics(activity, client) if metrics: update_activity_from_metrics(activity, metrics) activity.metrics_source = 'api' session.commit() downloaded_count += 1 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") 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() ``` --- ## Phase 6: Advanced Features & Polish (Week 11-12) ### Add Activity Search ```python # garminsync/web/routes.py - Add search endpoint @router.get("/activities/search") async def search_activities( q: str, # Search query page: int = 1, per_page: int = 20 ): """Search activities by various fields""" session = get_session() try: # Build search query query = session.query(Activity) search_terms = q.lower().split() for term in search_terms: # Search in multiple fields query = query.filter( or_( Activity.activity_type.ilike(f'%{term}%'), Activity.filename.ilike(f'%{term}%'), # Add more searchable fields as needed ) ) total = query.count() activities = query.order_by(Activity.start_time.desc()).offset( (page - 1) * per_page ).limit(per_page).all() return { "activities": [activity_to_dict(activity) for activity in activities], "total": total, "page": page, "per_page": per_page, "query": q } finally: session.close() ``` ### Add Bulk Operations ```javascript // garminsync/web/static/bulk-operations.js class BulkOperations { constructor() { this.selectedActivities = new Set(); this.init(); } init() { this.addBulkControls(); this.setupEventListeners(); } addBulkControls() { const bulkHtml = ` `; document.querySelector('.activities-table-card').insertAdjacentHTML('afterbegin', bulkHtml); } setupEventListeners() { // Add checkboxes to table this.addCheckboxesToTable(); // Bulk action buttons document.getElementById('clear-selection').addEventListener('click', () => { this.clearSelection(); }); document.getElementById('bulk-reprocess').addEventListener('click', () => { this.reprocessSelectedFiles(); }); } addCheckboxesToTable() { // Add header checkbox const headerRow = document.querySelector('.activities-table thead tr'); headerRow.insertAdjacentHTML('afterbegin', ''); // Add row checkboxes const rows = document.querySelectorAll('.activities-table tbody tr'); rows.forEach((row, index) => { const activityId = this.extractActivityIdFromRow(row); row.insertAdjacentHTML('afterbegin', `` ); }); // Setup checkbox events document.getElementById('select-all').addEventListener('change', (e) => { this.selectAll(e.target.checked); }); document.querySelectorAll('.activity-checkbox').forEach(checkbox => { checkbox.addEventListener('change', (e) => { this.toggleActivity(e.target.dataset.activityId, e.target.checked); }); }); } extractActivityIdFromRow(row) { // Extract activity ID from the row (you'll need to adjust this based on your table structure) return row.dataset.activityId || row.cells[1].textContent; // Adjust as needed } selectAll(checked) { document.querySelectorAll('.activity-checkbox').forEach(checkbox => { checkbox.checked = checked; this.toggleActivity(checkbox.dataset.activityId, checked); }); } toggleActivity(activityId, selected) { if (selected) { this.selectedActivities.add(activityId); } else { this.selectedActivities.delete(activityId); } this.updateBulkControls(); } updateBulkControls() { const count = this.selectedActivities.size; const bulkDiv = document.getElementById('bulk-operations'); const countSpan = document.getElementById('selected-count'); countSpan.textContent = count; bulkDiv.style.display = count > 0 ? 'block' : 'none'; } clearSelection() { this.selectedActivities.clear(); document.querySelectorAll('.activity-checkbox').forEach(checkbox => { checkbox.checked = false; }); document.getElementById('select-all').checked = false; this.updateBulkControls(); } async reprocessSelectedFiles() { if (this.selectedActivities.size === 0) return; const button = document.getElementById('bulk-reprocess'); button.disabled = true; button.textContent = 'Processing...'; try { const response = await fetch('/api/activities/reprocess', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ activity_ids: Array.from(this.selectedActivities) }) }); if (response.ok) { Utils.showSuccess('Files reprocessed successfully'); // Refresh the page or reload data window.location.reload(); } else { throw new Error('Reprocessing failed'); } } catch (error) { Utils.showError('Failed to reprocess files: ' + error.message); } finally { button.disabled = false; button.textContent = 'Reprocess Files'; } } } ``` ### Add Configuration Management ```python # garminsync/web/routes.py - Add configuration endpoints @router.get("/config") async def get_configuration(): """Get current configuration""" session = get_session() try: daemon_config = session.query(DaemonConfig).first() return { "sync": { "enabled": daemon_config.enabled if daemon_config else True, "schedule": daemon_config.schedule_cron if daemon_config else "0 */6 * * *", "status": daemon_config.status if daemon_config else "stopped" }, "storage": { "data_dir": os.getenv("DATA_DIR", "data"), "total_activities": session.query(Activity).count(), "downloaded_files": session.query(Activity).filter_by(downloaded=True).count() }, "api": { "garmin_configured": bool(os.getenv("GARMIN_EMAIL") and os.getenv("GARMIN_PASSWORD")), "rate_limit_delay": 2 # seconds between API calls } } finally: session.close() @router.post("/config/sync") async def update_sync_config(config_data: dict): """Update sync configuration""" session = get_session() try: daemon_config = session.query(DaemonConfig).first() if not daemon_config: daemon_config = DaemonConfig() session.add(daemon_config) if 'enabled' in config_data: daemon_config.enabled = config_data['enabled'] if 'schedule' in config_data: # Validate cron expression try: from apscheduler.triggers.cron import CronTrigger CronTrigger.from_crontab(config_data['schedule']) daemon_config.schedule_cron = config_data['schedule'] except ValueError as e: raise HTTPException(status_code=400, detail=f"Invalid cron expression: {e}") session.commit() return {"message": "Configuration updated successfully"} finally: session.close() ``` --- ## Testing & Deployment Guide ### Simple Testing Strategy ```python # tests/test_basic_functionality.py - Basic tests for junior developers import pytest import os import tempfile from pathlib import Path def test_file_type_detection(): """Test that we can detect different file types correctly""" from garminsync.activity_parser import detect_file_type # Create temporary test files with tempfile.NamedTemporaryFile(suffix='.fit', delete=False) as f: # Write FIT file header f.write(b'\x0E\x10\x43\x08.FIT\x00\x00\x00\x00') fit_file = f.name with tempfile.NamedTemporaryFile(suffix='.gpx', delete=False) as f: f.write(b'') gpx_file = f.name try: assert detect_file_type(fit_file) == 'fit' assert detect_file_type(gpx_file) == 'gpx' finally: os.unlink(fit_file) os.unlink(gpx_file) def test_activity_metrics_parsing(): """Test that we can parse activity metrics""" # This would test your parsing functions pass # Run with: python -m pytest tests/ ``` ### Deployment Checklist ```yaml # docker-compose.yml - Updated for new features version: '3.8' services: garminsync: build: . ports: - "8888:8888" environment: - GARMIN_EMAIL=${GARMIN_EMAIL} - GARMIN_PASSWORD=${GARMIN_PASSWORD} - DATA_DIR=/data volumes: - ./data:/data - ./logs:/app/logs restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8888/health"] interval: 30s timeout: 10s retries: 3 ``` --- ## Summary & Next Steps ### What This Plan Achieves: 1. **Non-blocking sync** - Users can browse while sync runs 2. **Multi-format support** - FIT, TCX, GPX files 3. **Reduced API calls** - File-first approach with smart caching 4. **Enhanced UI** - Filtering, search, stats, and trends 5. **Better file management** - Organized storage with deduplication 6. **Simple architecture** - Single container, threading instead of complex async ### Implementation Tips for Junior Developers: - **Start small** - Implement one phase at a time - **Test frequently** - Run the app after each major change - **Keep backups** - Always backup your database before migrations - **Use logging** - Add print statements and logs liberally - **Ask for help** - Don't hesitate to ask questions about complex parts ### Estimated Timeline: - **Phase 1-2**: 2-4 weeks (core improvements) - **Phase 3-4**: 2-4 weeks (UI enhancements) - **Phase 5-6**: 2-4 weeks (advanced features) Would you like me to elaborate on any specific phase or create detailed code examples for any particular feature?