feat: Initial commit of FitTrack_GarminSync project

This commit is contained in:
2025-10-10 12:20:48 -07:00
parent d0e29fbeb4
commit 18f9f6fa18
229 changed files with 21035 additions and 42 deletions

View File

@@ -0,0 +1 @@
# Empty file to mark this directory as a Python package

View File

@@ -0,0 +1,107 @@
import os
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
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})
@app.get("/activities")
async def activities_page(request: Request):
"""Activities page route"""
if not templates:
return JSONResponse({"message": "Activities endpoint"})
return templates.TemplateResponse("activities.html", {"request": request})
# Error handlers
@app.exception_handler(404)
async def not_found_handler(request: Request, exc):
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)}
)

View File

@@ -0,0 +1,478 @@
from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from garminsync.database import Activity, DaemonConfig, SyncLog, get_session
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,
}
# Add sync status
from garminsync.daemon import daemon_instance
daemon_data["sync_in_progress"] = daemon_instance.is_sync_in_progress() if hasattr(daemon_instance, 'is_sync_in_progress') 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
import os
from datetime import datetime
from pathlib import Path
from garminsync.database import Activity, sync_database
from garminsync.garmin import GarminClient
# 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(
status: str = None,
operation: str = None,
date: str = None,
page: int = 1,
per_page: int = 20,
):
"""Get sync logs with filtering and pagination"""
session = get_session()
try:
query = session.query(SyncLog)
# Apply filters
if status:
query = query.filter(SyncLog.status == status)
if operation:
query = query.filter(SyncLog.operation == operation)
if date:
# Filter by date (assuming ISO format)
query = query.filter(SyncLog.timestamp.like(f"{date}%"))
# Get total count for pagination
total = query.count()
# Apply pagination
logs = (
query.order_by(SyncLog.timestamp.desc())
.offset((page - 1) * per_page)
.limit(per_page)
.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, "total": total, "page": page, "per_page": per_page}
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()
@router.post("/activities/{activity_id}/reprocess")
async def reprocess_activity(activity_id: int):
"""Reprocess a single activity to update metrics"""
from garminsync.database import Activity, get_session
from garminsync.activity_parser import get_activity_metrics
session = get_session()
try:
activity = session.query(Activity).get(activity_id)
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
metrics = get_activity_metrics(activity, force_reprocess=True)
if metrics:
# Update activity metrics
activity.activity_type = metrics.get("activityType", {}).get("typeKey")
activity.duration = int(float(metrics.get("duration", 0))) if metrics.get("duration") else activity.duration
activity.distance = float(metrics.get("distance", 0)) if metrics.get("distance") else activity.distance
activity.max_heart_rate = int(float(metrics.get("maxHR", 0))) if metrics.get("maxHR") else activity.max_heart_rate
activity.avg_heart_rate = int(float(metrics.get("avgHR", 0))) if metrics.get("avgHR") else activity.avg_heart_rate
activity.avg_power = float(metrics.get("avgPower", 0)) if metrics.get("avgPower") else activity.avg_power
activity.calories = int(float(metrics.get("calories", 0))) if metrics.get("calories") else activity.calories
# Mark as reprocessed
activity.reprocessed = True
session.commit()
return {"message": f"Activity {activity_id} reprocessed successfully"}
except Exception as e:
session.rollback()
raise HTTPException(status_code=500, detail=f"Reprocessing failed: {str(e)}")
finally:
session.close()
@router.post("/reprocess")
async def reprocess_activities(all: bool = False):
"""Reprocess all activities or just missing ones"""
from garminsync.daemon import daemon_instance
try:
# Trigger reprocess job in daemon
daemon_instance.reprocess_activities()
return {"message": "Reprocess job started in background"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to start reprocess job: {str(e)}")
@router.get("/activities")
async def get_activities(
page: int = 1,
per_page: int = 50,
activity_type: str = None,
date_from: str = None,
date_to: str = None,
):
"""Get paginated activities with filtering"""
session = get_session()
try:
query = session.query(Activity)
# Apply filters
if activity_type:
query = query.filter(Activity.activity_type == activity_type)
if date_from:
query = query.filter(Activity.start_time >= date_from)
if date_to:
query = query.filter(Activity.start_time <= date_to)
# Get total count for pagination
total = query.count()
# Apply pagination
activities = (
query.order_by(Activity.start_time.desc())
.offset((page - 1) * per_page)
.limit(per_page)
.all()
)
activity_data = []
for activity in activities:
activity_data.append(
{
"activity_id": activity.activity_id,
"start_time": activity.start_time,
"activity_type": activity.activity_type,
"duration": activity.duration,
"distance": activity.distance,
"max_heart_rate": activity.max_heart_rate,
"avg_heart_rate": activity.avg_heart_rate,
"avg_power": activity.avg_power,
"calories": activity.calories,
"filename": activity.filename,
"downloaded": activity.downloaded,
"created_at": activity.created_at,
"last_sync": activity.last_sync,
}
)
return {
"activities": activity_data,
"total": total,
"page": page,
"per_page": per_page,
}
finally:
session.close()
@router.get("/activities/{activity_id}")
async def get_activity_details(activity_id: int):
"""Get detailed activity information"""
session = get_session()
try:
activity = (
session.query(Activity).filter(Activity.activity_id == activity_id).first()
)
if not activity:
raise HTTPException(
status_code=404, detail=f"Activity with ID {activity_id} not found"
)
return {
"id": activity.activity_id,
"name": activity.filename or "Unnamed Activity",
"distance": activity.distance,
"duration": activity.duration,
"start_time": activity.start_time,
"activity_type": activity.activity_type,
"max_heart_rate": activity.max_heart_rate,
"avg_power": activity.avg_power,
"calories": activity.calories,
"filename": activity.filename,
"downloaded": activity.downloaded,
"created_at": activity.created_at,
"last_sync": activity.last_sync,
}
finally:
session.close()
@router.get("/dashboard/stats")
async def get_dashboard_stats():
"""Get comprehensive dashboard statistics"""
from garminsync.database import get_offline_stats
return get_offline_stats()
@router.get("/api/activities")
async def get_api_activities(page: int = 1, per_page: int = 10):
"""Get paginated activities for API"""
session = get_session()
try:
# Use the existing get_paginated method from Activity class
pagination = Activity.get_paginated(page, per_page)
activities = pagination.items
total_pages = pagination.pages
current_page = pagination.page
total_items = pagination.total
if not activities and page > 1:
raise HTTPException(
status_code=404, detail=f"No activities found for page {page}"
)
if not activities and page == 1 and total_items == 0:
raise HTTPException(status_code=404, detail="No activities found")
if not activities:
raise HTTPException(status_code=404, detail="No activities found")
return {
"activities": [
{
"id": activity.activity_id,
"name": activity.filename or "Unnamed Activity",
"distance": activity.distance,
"duration": activity.duration,
"start_time": activity.start_time,
"activity_type": activity.activity_type,
"max_heart_rate": activity.max_heart_rate,
"avg_power": activity.avg_power,
"calories": activity.calories,
"downloaded": activity.downloaded,
"created_at": activity.created_at,
"last_sync": activity.last_sync,
"device": activity.device or "Unknown",
"intensity": activity.intensity or "Unknown",
"average_speed": activity.average_speed,
"elevation_gain": activity.elevation_gain,
"heart_rate_zones": activity.heart_rate_zones or [],
"power_zones": activity.power_zones or [],
"training_effect": activity.training_effect or 0,
"training_effect_label": activity.training_effect_label
or "Unknown",
}
for activity in activities
],
"total_pages": total_pages,
"current_page": current_page,
"total_items": total_items,
"page_size": per_page,
"status": "success",
}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"An error occurred while fetching activities: {str(e)}",
)
finally:
session.close()

View File

@@ -0,0 +1,140 @@
class ActivitiesPage {
constructor() {
this.currentPage = 1;
this.pageSize = 25;
this.totalPages = 1;
this.activities = [];
this.filters = {};
this.init();
}
init() {
this.loadActivities();
this.setupEventListeners();
}
async loadActivities() {
try {
const params = new URLSearchParams({
page: this.currentPage,
per_page: this.pageSize,
...this.filters
});
const response = await fetch(`/api/activities?${params}`);
if (!response.ok) {
throw new Error('Failed to load activities');
}
const data = await response.json();
this.activities = data.activities;
this.totalPages = Math.ceil(data.total / this.pageSize);
this.renderTable();
this.renderPagination();
} catch (error) {
console.error('Failed to load activities:', error);
this.showError('Failed to load activities');
}
}
renderTable() {
const tbody = document.getElementById('activities-tbody');
if (!tbody) return;
if (!this.activities || this.activities.length === 0) {
tbody.innerHTML = '<tr><td colspan="6">No activities found</td></tr>';
return;
}
tbody.innerHTML = '';
this.activities.forEach((activity, index) => {
const row = this.createTableRow(activity, index);
tbody.appendChild(row);
});
}
createTableRow(activity, index) {
const row = document.createElement('tr');
row.className = index % 2 === 0 ? 'row-even' : 'row-odd';
row.innerHTML = `
<td>${Utils.formatDate(activity.start_time)}</td>
<td>${activity.activity_type || '-'}</td>
<td>${Utils.formatDuration(activity.duration)}</td>
<td>${Utils.formatDistance(activity.distance)}</td>
<td>${Utils.formatHeartRate(activity.max_heart_rate)}</td>
<td>${Utils.formatHeartRate(activity.avg_heart_rate)}</td>
<td>${Utils.formatPower(activity.avg_power)}</td>
<td>${activity.calories ? activity.calories.toLocaleString() : '-'}</td>
`;
return row;
}
renderPagination() {
const pagination = document.getElementById('pagination');
if (!pagination) return;
if (this.totalPages <= 1) {
pagination.innerHTML = '';
return;
}
let paginationHtml = '';
// Previous button
paginationHtml += `
<li class="${this.currentPage === 1 ? 'disabled' : ''}">
<a href="#" onclick="activitiesPage.changePage(${this.currentPage - 1}); return false;">Previous</a>
</li>
`;
// Page numbers
for (let i = 1; i <= this.totalPages; i++) {
if (i === 1 || i === this.totalPages || (i >= this.currentPage - 2 && i <= this.currentPage + 2)) {
paginationHtml += `
<li class="${i === this.currentPage ? 'active' : ''}">
<a href="#" onclick="activitiesPage.changePage(${i}); return false;">${i}</a>
</li>
`;
} else if (i === this.currentPage - 3 || i === this.currentPage + 3) {
paginationHtml += '<li><span>...</span></li>';
}
}
// Next button
paginationHtml += `
<li class="${this.currentPage === this.totalPages ? 'disabled' : ''}">
<a href="#" onclick="activitiesPage.changePage(${this.currentPage + 1}); return false;">Next</a>
</li>
`;
pagination.innerHTML = paginationHtml;
}
changePage(page) {
if (page < 1 || page > this.totalPages) return;
this.currentPage = page;
this.loadActivities();
}
setupEventListeners() {
// We can add filter event listeners here if needed
}
showError(message) {
const tbody = document.getElementById('activities-tbody');
if (tbody) {
tbody.innerHTML = `<tr><td colspan="6">Error: ${message}</td></tr>`;
}
}
}
// Initialize activities page when DOM is loaded
let activitiesPage;
document.addEventListener('DOMContentLoaded', function() {
activitiesPage = new ActivitiesPage();
});

View File

@@ -0,0 +1,3 @@
// This file is deprecated and no longer used.
// The functionality has been moved to home.js, activities.js, and logs.js
// This file is kept for backward compatibility but is empty.

View File

@@ -0,0 +1 @@
// This file is deprecated and no longer used.

View File

@@ -0,0 +1,200 @@
/* Table Styling */
.activities-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.activities-table thead {
background-color: #000;
color: white;
}
.activities-table th {
padding: 12px 16px;
text-align: left;
font-weight: 600;
border-right: 1px solid #333;
}
.activities-table th:last-child {
border-right: none;
}
.activities-table td {
padding: 12px 16px;
border-bottom: 1px solid #eee;
}
.activities-table .row-even {
background-color: #f8f9fa;
}
.activities-table .row-odd {
background-color: #ffffff;
}
.activities-table tr:hover {
background-color: #e9ecef;
}
/* Sync Button Styling */
.btn-primary.btn-large {
width: 100%;
padding: 15px;
font-size: 16px;
font-weight: 600;
border-radius: var(--border-radius);
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
border: none;
color: white;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-primary.btn-large:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,123,255,0.3);
}
.btn-primary.btn-large:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
/* Statistics Card */
.statistics-card .stat-item {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
padding: 8px 0;
border-bottom: 1px solid #eee;
}
.statistics-card .stat-item:last-child {
border-bottom: none;
}
.statistics-card label {
font-weight: 500;
color: #666;
}
.statistics-card span {
font-weight: 600;
color: #333;
}
/* Pagination */
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: center;
}
.pagination {
display: flex;
list-style: none;
padding: 0;
margin: 0;
}
.pagination li {
margin: 0 5px;
}
.pagination a {
display: block;
padding: 8px 12px;
text-decoration: none;
color: var(--primary-color);
border: 1px solid #ddd;
border-radius: 4px;
transition: all 0.2s ease;
}
.pagination a:hover {
background-color: #f0f0f0;
}
.pagination .active a {
background-color: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
.pagination .disabled a {
color: #ccc;
cursor: not-allowed;
}
/* Form elements */
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
.form-control {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: var(--border-radius);
font-family: var(--font-family);
font-size: 14px;
}
.form-control:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(0,123,255,0.25);
}
/* Badges */
.badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
}
.badge-success {
background-color: var(--success-color);
color: white;
}
.badge-error {
background-color: var(--danger-color);
color: white;
}
.badge-warning {
background-color: var(--warning-color);
color: #212529;
}
/* Table responsive */
.table-container {
overflow-x: auto;
}
/* Activities table card */
.activities-table-card {
padding: 0;
}
.activities-table-card .card-header {
padding: 20px;
margin-bottom: 0;
}
/* Activities container */
.activities-container {
margin-top: 20px;
}

View File

@@ -0,0 +1,144 @@
class HomePage {
constructor() {
this.logSocket = null;
this.statsRefreshInterval = null;
this.init();
}
init() {
this.attachEventListeners();
this.setupRealTimeUpdates();
this.loadInitialData();
}
attachEventListeners() {
const syncButton = document.getElementById('sync-now-btn');
if (syncButton) {
syncButton.addEventListener('click', () => this.triggerSync());
}
}
async triggerSync() {
const btn = document.getElementById('sync-now-btn');
const status = document.getElementById('sync-status');
if (!btn || !status) return;
btn.disabled = true;
btn.innerHTML = '<i class="icon-loading"></i> Syncing...';
status.textContent = 'Sync in progress...';
status.className = 'sync-status syncing';
try {
const response = await fetch('/api/sync/trigger', {method: 'POST'});
const result = await response.json();
if (response.ok) {
status.textContent = 'Sync completed successfully';
status.className = 'sync-status success';
this.updateStats();
} else {
throw new Error(result.detail || 'Sync failed');
}
} catch (error) {
status.textContent = `Sync failed: ${error.message}`;
status.className = 'sync-status error';
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="icon-sync"></i> Sync Now';
// Reset status message after 5 seconds
setTimeout(() => {
if (status.className.includes('success')) {
status.textContent = 'Ready to sync';
status.className = 'sync-status';
}
}, 5000);
}
}
setupRealTimeUpdates() {
// Poll for log updates every 5 seconds during active operations
this.startLogPolling();
// Update stats every 30 seconds
this.statsRefreshInterval = setInterval(() => {
this.updateStats();
}, 30000);
}
async startLogPolling() {
// For now, we'll update logs every 10 seconds
setInterval(() => {
this.updateLogs();
}, 10000);
}
async updateStats() {
try {
const response = await fetch('/api/dashboard/stats');
if (!response.ok) {
throw new Error('Failed to fetch stats');
}
const stats = await response.json();
const totalEl = document.getElementById('total-activities');
const downloadedEl = document.getElementById('downloaded-activities');
const missingEl = document.getElementById('missing-activities');
if (totalEl) totalEl.textContent = stats.total;
if (downloadedEl) downloadedEl.textContent = stats.downloaded;
if (missingEl) missingEl.textContent = stats.missing;
} catch (error) {
console.error('Failed to update stats:', error);
}
}
async updateLogs() {
try {
const response = await fetch('/api/status');
if (!response.ok) {
throw new Error('Failed to fetch logs');
}
const data = await response.json();
this.renderLogs(data.recent_logs);
} catch (error) {
console.error('Failed to update logs:', error);
}
}
renderLogs(logs) {
const logContent = document.getElementById('log-content');
if (!logContent) return;
if (!logs || logs.length === 0) {
logContent.innerHTML = '<div class="log-entry">No recent activity</div>';
return;
}
const logsHtml = logs.map(log => `
<div class="log-entry">
<span class="timestamp">${Utils.formatTimestamp(log.timestamp)}</span>
<span class="status ${log.status === 'success' ? 'success' : 'error'}">
${log.status}
</span>
${log.operation}: ${log.message || ''}
${log.activities_downloaded > 0 ? `Downloaded ${log.activities_downloaded} activities` : ''}
</div>
`).join('');
logContent.innerHTML = logsHtml;
}
async loadInitialData() {
// Load initial logs
await this.updateLogs();
}
}
// Initialize home page when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
new HomePage();
});

View File

@@ -0,0 +1,179 @@
// Global variables for pagination and filtering
let currentPage = 1;
const logsPerPage = 20;
let totalLogs = 0;
let currentFilters = {};
class LogsPage {
constructor() {
this.currentPage = 1;
this.init();
}
init() {
this.loadLogs();
this.setupEventListeners();
}
async loadLogs() {
try {
// Build query string from filters
const params = new URLSearchParams({
page: this.currentPage,
per_page: logsPerPage,
...currentFilters
}).toString();
const response = await fetch(`/api/logs?${params}`);
if (!response.ok) {
throw new Error('Failed to fetch logs');
}
const data = await response.json();
totalLogs = data.total;
this.renderLogs(data.logs);
this.renderPagination();
} catch (error) {
console.error('Error loading logs:', error);
Utils.showError('Failed to load logs: ' + error.message);
}
}
renderLogs(logs) {
const tbody = document.getElementById('logs-tbody');
if (!tbody) return;
tbody.innerHTML = '';
if (!logs || logs.length === 0) {
tbody.innerHTML = '<tr><td colspan="6">No logs found</td></tr>';
return;
}
logs.forEach(log => {
const row = document.createElement('tr');
row.className = 'row-odd'; // For alternating row colors
row.innerHTML = `
<td>${Utils.formatTimestamp(log.timestamp)}</td>
<td>${log.operation}</td>
<td><span class="badge badge-${log.status === 'success' ? 'success' :
log.status === 'error' ? 'error' :
'warning'}">${log.status}</span></td>
<td>${log.message || ''}</td>
<td>${log.activities_processed}</td>
<td>${log.activities_downloaded}</td>
`;
tbody.appendChild(row);
});
}
renderPagination() {
const totalPages = Math.ceil(totalLogs / logsPerPage);
const pagination = document.getElementById('pagination');
if (!pagination) return;
if (totalPages <= 1) {
pagination.innerHTML = '';
return;
}
let paginationHtml = '';
// Previous button
paginationHtml += `
<li class="${this.currentPage === 1 ? 'disabled' : ''}">
<a href="#" onclick="logsPage.changePage(${this.currentPage - 1}); return false;">Previous</a>
</li>
`;
// Page numbers
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= this.currentPage - 2 && i <= this.currentPage + 2)) {
paginationHtml += `
<li class="${i === this.currentPage ? 'active' : ''}">
<a href="#" onclick="logsPage.changePage(${i}); return false;">${i}</a>
</li>
`;
} else if (i === this.currentPage - 3 || i === this.currentPage + 3) {
paginationHtml += '<li><span>...</span></li>';
}
}
// Next button
paginationHtml += `
<li class="${this.currentPage === totalPages ? 'disabled' : ''}">
<a href="#" onclick="logsPage.changePage(${this.currentPage + 1}); return false;">Next</a>
</li>
`;
pagination.innerHTML = paginationHtml;
}
changePage(page) {
if (page < 1 || page > Math.ceil(totalLogs / logsPerPage)) return;
this.currentPage = page;
this.loadLogs();
}
refreshLogs() {
this.currentPage = 1;
this.loadLogs();
}
applyFilters() {
currentFilters = {
status: document.getElementById('status-filter').value,
operation: document.getElementById('operation-filter').value,
date: document.getElementById('date-filter').value
};
this.currentPage = 1;
this.loadLogs();
}
async clearLogs() {
if (!confirm('Are you sure you want to clear all logs? This cannot be undone.')) return;
try {
const response = await fetch('/api/logs', { method: 'DELETE' });
if (response.ok) {
Utils.showSuccess('Logs cleared successfully');
this.refreshLogs();
} else {
throw new Error('Failed to clear logs');
}
} catch (error) {
console.error('Error clearing logs:', error);
Utils.showError('Failed to clear logs: ' + error.message);
}
}
setupEventListeners() {
// Event listeners are handled in the global functions below
}
}
// Initialize logs page when DOM is loaded
let logsPage;
document.addEventListener('DOMContentLoaded', function() {
logsPage = new LogsPage();
});
// Global functions for backward compatibility with HTML onclick attributes
function changePage(page) {
if (logsPage) logsPage.changePage(page);
}
function refreshLogs() {
if (logsPage) logsPage.refreshLogs();
}
function applyFilters() {
if (logsPage) logsPage.applyFilters();
}
function clearLogs() {
if (logsPage) logsPage.clearLogs();
}

View File

@@ -0,0 +1,52 @@
class Navigation {
constructor() {
this.currentPage = this.getCurrentPage();
this.render();
}
getCurrentPage() {
return window.location.pathname === '/activities' ? 'activities' : 'home';
}
render() {
const nav = document.querySelector('.navigation');
if (nav) {
nav.innerHTML = this.getNavigationHTML();
this.attachEventListeners();
}
}
getNavigationHTML() {
return `
<nav class="nav-tabs">
<button class="nav-tab ${this.currentPage === 'home' ? 'active' : ''}"
data-page="home">Home</button>
<button class="nav-tab ${this.currentPage === 'activities' ? 'active' : ''}"
data-page="activities">Activities</button>
</nav>
`;
}
attachEventListeners() {
const tabs = document.querySelectorAll('.nav-tab');
tabs.forEach(tab => {
tab.addEventListener('click', (e) => {
const page = e.target.getAttribute('data-page');
this.navigateToPage(page);
});
});
}
navigateToPage(page) {
if (page === 'home') {
window.location.href = '/';
} else if (page === 'activities') {
window.location.href = '/activities';
}
}
}
// Initialize navigation when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
new Navigation();
});

View File

@@ -0,0 +1,78 @@
/* Mobile-first responsive design */
@media (max-width: 768px) {
.layout-grid {
grid-template-columns: 1fr;
gap: 15px;
}
.sidebar {
order: 2;
}
.main-content {
order: 1;
}
.activities-table {
font-size: 12px;
}
.activities-table th,
.activities-table td {
padding: 8px 10px;
}
.nav-tabs {
flex-direction: column;
}
.container {
padding: 0 10px;
}
.card {
padding: 15px;
}
.btn {
padding: 8px 15px;
font-size: 14px;
}
.btn-large {
padding: 12px 20px;
font-size: 15px;
}
}
@media (max-width: 480px) {
.activities-table {
display: block;
overflow-x: auto;
white-space: nowrap;
}
.stat-item {
flex-direction: column;
gap: 5px;
}
.log-content {
padding: 5px;
font-size: 0.8rem;
}
.log-entry {
padding: 5px;
}
.pagination a {
padding: 6px 10px;
font-size: 14px;
}
.form-control {
padding: 8px;
font-size: 14px;
}
}

View File

@@ -0,0 +1,268 @@
/* CSS Variables for consistent theming */
:root {
--primary-color: #007bff;
--secondary-color: #6c757d;
--success-color: #28a745;
--danger-color: #dc3545;
--warning-color: #ffc107;
--light-gray: #f8f9fa;
--dark-gray: #343a40;
--border-radius: 8px;
--box-shadow: 0 2px 10px rgba(0,0,0,0.1);
--font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
/* Reset and base styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-family);
background-color: #f5f7fa;
color: #333;
line-height: 1.6;
}
/* CSS Grid Layout System */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
.layout-grid {
display: grid;
grid-template-columns: 300px 1fr;
gap: 20px;
min-height: calc(100vh - 60px);
}
/* Modern Card Components */
.card {
background: white;
border-radius: var(--border-radius);
box-shadow: var(--box-shadow);
padding: 20px;
margin-bottom: 20px;
}
.card-header {
font-weight: 600;
font-size: 1.2rem;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
/* Navigation */
.navigation {
margin-bottom: 20px;
}
.nav-tabs {
display: flex;
background: white;
border-radius: var(--border-radius);
box-shadow: var(--box-shadow);
padding: 5px;
}
.nav-tab {
flex: 1;
padding: 12px 20px;
border: none;
background: transparent;
cursor: pointer;
font-weight: 500;
border-radius: var(--border-radius);
transition: all 0.2s ease;
}
.nav-tab:hover {
background-color: #f0f0f0;
}
.nav-tab.active {
background-color: var(--primary-color);
color: white;
}
/* Buttons */
.btn {
padding: 10px 20px;
border: none;
border-radius: var(--border-radius);
cursor: pointer;
font-weight: 500;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary-color) 0%, #0056b3 100%);
color: white;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,123,255,0.3);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
background-color: var(--secondary-color);
color: white;
}
.btn-success {
background-color: var(--success-color);
color: white;
}
.btn-danger {
background-color: var(--danger-color);
color: white;
}
.btn-warning {
background-color: var(--warning-color);
color: #212529;
}
.btn-large {
padding: 15px 25px;
font-size: 16px;
}
/* Icons */
.icon-sync::before {
content: "↻";
margin-right: 8px;
}
.icon-loading::before {
content: "⏳";
margin-right: 8px;
}
/* Status display */
.sync-status {
margin-top: 15px;
padding: 10px;
border-radius: var(--border-radius);
text-align: center;
font-weight: 500;
}
.sync-status.syncing {
background-color: #e3f2fd;
color: var(--primary-color);
}
.sync-status.success {
background-color: #e8f5e9;
color: var(--success-color);
}
.sync-status.error {
background-color: #ffebee;
color: var(--danger-color);
}
/* Statistics */
.stat-item {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
padding: 8px 0;
border-bottom: 1px solid #eee;
}
.stat-item:last-child {
border-bottom: none;
}
.stat-item label {
font-weight: 500;
color: #666;
}
.stat-item span {
font-weight: 600;
color: #333;
}
/* Log display */
.log-content {
max-height: 400px;
overflow-y: auto;
padding: 10px;
background-color: #f8f9fa;
border-radius: var(--border-radius);
font-family: monospace;
font-size: 0.9rem;
}
.log-entry {
margin-bottom: 8px;
padding: 8px;
border-left: 3px solid #ddd;
background-color: white;
border-radius: 0 var(--border-radius) var(--border-radius) 0;
}
.log-entry .timestamp {
font-size: 0.8rem;
color: #666;
margin-right: 10px;
}
.log-entry .status {
padding: 2px 6px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
}
.log-entry .status.success {
background-color: var(--success-color);
color: white;
}
.log-entry .status.error {
background-color: var(--danger-color);
color: white;
}
/* Responsive Design */
@media (max-width: 768px) {
.layout-grid {
grid-template-columns: 1fr;
gap: 15px;
}
.sidebar {
order: 2;
}
.main-content {
order: 1;
}
.nav-tabs {
flex-direction: column;
}
.container {
padding: 0 10px;
}
}

View File

@@ -0,0 +1,56 @@
// Utility functions for the GarminSync application
class Utils {
// Format date for display
static formatDate(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString();
}
// Format duration from seconds to HH:MM:SS
static formatDuration(seconds) {
if (!seconds) return '-';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secondsLeft = seconds % 60;
return `${hours}:${minutes.toString().padStart(2, '0')}:${secondsLeft.toString().padStart(2, '0')}`;
}
// Format distance from meters to kilometers
static formatDistance(meters) {
if (!meters) return '-';
return `${(meters / 1000).toFixed(1)} km`;
}
// Format power from watts
static formatPower(watts) {
return watts ? `${Math.round(watts)}W` : '-';
}
// Format heart rate (adds 'bpm')
static formatHeartRate(hr) {
return hr ? `${hr} bpm` : '-';
}
// Show error message
static showError(message) {
console.error(message);
// In a real implementation, you might want to show this in the UI
alert(`Error: ${message}`);
}
// Show success message
static showSuccess(message) {
console.log(message);
// In a real implementation, you might want to show this in the UI
}
// Format timestamp for log entries
static formatTimestamp(timestamp) {
if (!timestamp) return '';
return new Date(timestamp).toLocaleString();
}
}
// Make Utils available globally
window.Utils = Utils;

View File

@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block content %}
<div class="container">
<div class="navigation"></div>
<div class="activities-container">
<div class="card activities-table-card">
<div class="card-header">
<h3>Activities</h3>
</div>
<div class="table-container">
<table class="activities-table" id="activities-table">
<thead>
<tr>
<th>Date</th>
<th>Activity Type</th>
<th>Duration</th>
<th>Distance</th>
<th>Max HR</th>
<th>Avg HR</th>
<th>Power</th>
<th>Calories</th>
</tr>
</thead>
<tbody id="activities-tbody">
<!-- Data populated by JavaScript -->
</tbody>
</table>
</div>
<div class="pagination-container">
<div class="pagination" id="pagination">
<!-- Pagination controls -->
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block page_scripts %}
<script src="/static/activities.js"></script>
{% endblock %}

View File

@@ -0,0 +1,154 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Activity Details - GarminSync</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/static/styles.css" rel="stylesheet">
</head>
<body>
<div class="container mt-4">
<h1 class="mb-4">Activity Details</h1>
<div id="activity-details">
<!-- Activity details will be populated by JavaScript -->
</div>
<div class="mt-4">
<h2>Analysis Metrics</h2>
<table class="table table-striped" id="metrics-table">
<thead>
<tr>
<th>Metric</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<!-- Metrics will be populated by JavaScript -->
</tbody>
</table>
</div>
<div class="mt-4">
<button id="reprocess-btn" class="btn btn-warning">
<span id="spinner" class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
Reprocess Activity
</button>
<div id="reprocess-result" class="mt-2"></div>
</div>
<div class="mt-4">
<a href="/activities" class="btn btn-secondary">Back to Activities</a>
</div>
</div>
<script src="/static/utils.js"></script>
<script>
document.addEventListener('DOMContentLoaded', async function() {
const activityId = new URLSearchParams(window.location.search).get('id');
if (!activityId) {
showError('Activity ID not provided');
return;
}
// Load activity details
await loadActivity(activityId);
// Setup reprocess button
document.getElementById('reprocess-btn').addEventListener('click', () => {
reprocessActivity(activityId);
});
});
async function loadActivity(activityId) {
try {
const response = await fetch(`/api/activities/${activityId}`);
if (!response.ok) {
throw new Error('Failed to load activity details');
}
const activity = await response.json();
renderActivity(activity);
} catch (error) {
showError(`Error loading activity: ${error.message}`);
}
}
function renderActivity(activity) {
const detailsEl = document.getElementById('activity-details');
detailsEl.innerHTML = `
<div class="card">
<div class="card-body">
<h5 class="card-title">${activity.name}</h5>
<p class="card-text">
<strong>Date:</strong> ${formatDateTime(activity.start_time)}<br>
<strong>Type:</strong> ${activity.activity_type}<br>
<strong>Duration:</strong> ${formatDuration(activity.duration)}<br>
<strong>Distance:</strong> ${formatDistance(activity.distance)}<br>
<strong>Status:</strong>
<span class="badge ${activity.reprocessed ? 'bg-success' : 'bg-secondary'}">
${activity.reprocessed ? 'Processed' : 'Not Processed'}
</span>
</p>
</div>
</div>
`;
// Render metrics
const metrics = [
{ name: 'Max Heart Rate', value: activity.max_heart_rate, unit: 'bpm' },
{ name: 'Avg Heart Rate', value: activity.avg_heart_rate, unit: 'bpm' },
{ name: 'Avg Power', value: activity.avg_power, unit: 'W' },
{ name: 'Calories', value: activity.calories, unit: 'kcal' },
{ name: 'Gear Ratio', value: activity.gear_ratio, unit: '' },
{ name: 'Gear Inches', value: activity.gear_inches, unit: '' }
];
const tableBody = document.getElementById('metrics-table').querySelector('tbody');
tableBody.innerHTML = '';
metrics.forEach(metric => {
if (metric.value !== undefined) {
const row = document.createElement('tr');
row.innerHTML = `<td>${metric.name}</td><td>${metric.value} ${metric.unit}</td>`;
tableBody.appendChild(row);
}
});
}
async function reprocessActivity(activityId) {
const btn = document.getElementById('reprocess-btn');
const spinner = document.getElementById('spinner');
const resultEl = document.getElementById('reprocess-result');
btn.disabled = true;
spinner.classList.remove('d-none');
resultEl.innerHTML = '';
resultEl.classList.remove('alert-success', 'alert-danger');
try {
const response = await fetch(`/api/activities/${activityId}/reprocess`, {
method: 'POST'
});
if (!response.ok) {
const error = await response.text();
throw new Error(error);
}
resultEl.innerHTML = `<div class="alert alert-success">Activity reprocessed successfully!</div>`;
// Reload activity data to show updated metrics
await loadActivity(activityId);
} catch (error) {
console.error('Reprocess error:', error);
resultEl.innerHTML = `<div class="alert alert-danger">${error.message || 'Reprocessing failed'}</div>`;
} finally {
spinner.classList.add('d-none');
btn.disabled = false;
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GarminSync</title>
<link href="/static/style.css" rel="stylesheet">
<link href="/static/components.css" rel="stylesheet">
<link href="/static/responsive.css" rel="stylesheet">
</head>
<body>
{% block content %}{% endblock %}
<script src="/static/navigation.js"></script>
<script src="/static/utils.js"></script>
{% block page_scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,151 @@
{% extends "base.html" %}
{% block content %}
<div class="container">
<div class="navigation"></div>
<div class="card">
<div class="card-header">
<h3>GarminSync Configuration</h3>
</div>
<div class="card-body">
<div class="card mb-4">
<div class="card-header">Daemon Settings</div>
<div class="card-body">
<form id="daemon-config-form">
<div class="form-group">
<label for="daemon-enabled">Enable Daemon</label>
<input type="checkbox" id="daemon-enabled" {% if config.enabled %}checked{% endif %}>
</div>
<div class="form-group">
<label for="cron-schedule">Synchronization Schedule</label>
<input type="text" class="form-control" id="cron-schedule"
value="{{ config.schedule_cron }}"
placeholder="0 */6 * * *"
title="Cron expression (every 6 hours by default)">
<small class="form-text text-muted">
Cron format: minute hour day(month) month day(week)
</small>
</div>
<button type="submit" class="btn btn-primary">Save Settings</button>
</form>
</div>
</div>
<div class="card">
<div class="card-header">Daemon Status</div>
<div class="card-body">
<div class="stat-item">
<label>Current Status:</label>
<span id="daemon-status-text">{{ config.status|capitalize }}</span>
</div>
<div class="stat-item">
<label>Last Run:</label>
<span id="daemon-last-run">{{ config.last_run or 'Never' }}</span>
</div>
<div class="stat-item">
<label>Next Run:</label>
<span id="daemon-next-run">{{ config.next_run or 'Not scheduled' }}</span>
</div>
<div class="mt-3">
<button id="start-daemon-btn" class="btn btn-success">
Start Daemon
</button>
<button id="stop-daemon-btn" class="btn btn-danger">
Stop Daemon
</button>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block page_scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Form submission handler
document.getElementById('daemon-config-form').addEventListener('submit', async function(e) {
e.preventDefault();
const enabled = document.getElementById('daemon-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) {
Utils.showSuccess('Configuration saved successfully');
updateStatus();
} else {
const error = await response.json();
Utils.showError(`Error: ${error.detail}`);
}
} catch (error) {
Utils.showError('Failed to save configuration: ' + error.message);
}
});
// Daemon control buttons
document.getElementById('start-daemon-btn').addEventListener('click', async function() {
try {
const response = await fetch('/api/daemon/start', { method: 'POST' });
if (response.ok) {
Utils.showSuccess('Daemon started successfully');
updateStatus();
} else {
const error = await response.json();
Utils.showError(`Error: ${error.detail}`);
}
} catch (error) {
Utils.showError('Failed to start daemon: ' + error.message);
}
});
document.getElementById('stop-daemon-btn').addEventListener('click', async function() {
try {
const response = await fetch('/api/daemon/stop', { method: 'POST' });
if (response.ok) {
Utils.showSuccess('Daemon stopped successfully');
updateStatus();
} else {
const error = await response.json();
Utils.showError(`Error: ${error.detail}`);
}
} catch (error) {
Utils.showError('Failed to stop daemon: ' + error.message);
}
});
// Initial status update
updateStatus();
async function updateStatus() {
try {
const response = await fetch('/api/status');
const data = await response.json();
// Update status display
document.getElementById('daemon-status-text').textContent =
data.daemon.running ? 'Running' : 'Stopped';
document.getElementById('daemon-last-run').textContent =
data.daemon.last_run || 'Never';
document.getElementById('daemon-next-run').textContent =
data.daemon.next_run || 'Not scheduled';
} catch (error) {
console.error('Failed to update status:', error);
}
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block content %}
<div class="container">
<div class="navigation"></div>
<div class="layout-grid">
<!-- Left Sidebar -->
<div class="sidebar">
<div class="card sync-card">
<button id="sync-now-btn" class="btn btn-primary btn-large">
<i class="icon-sync"></i>
Sync Now
</button>
<div class="sync-status" id="sync-status">
Ready to sync
</div>
</div>
<div class="card statistics-card">
<h3>Statistics</h3>
<div class="stat-item">
<label>Total Activities:</label>
<span id="total-activities">{{stats.total}}</span>
</div>
<div class="stat-item">
<label>Downloaded:</label>
<span id="downloaded-activities">{{stats.downloaded}}</span>
</div>
<div class="stat-item">
<label>Missing:</label>
<span id="missing-activities">{{stats.missing}}</span>
</div>
</div>
</div>
<!-- Right Content Area -->
<div class="main-content">
<div class="card log-display">
<div class="card-header">
<h3>Log Data</h3>
</div>
<div class="log-content" id="log-content">
<!-- Real-time log updates will appear here -->
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block page_scripts %}
<script src="/static/home.js"></script>
{% endblock %}

View File

@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block content %}
<div class="container">
<div class="navigation"></div>
<div class="card">
<div class="card-header">
<h3>Sync Logs</h3>
</div>
<div class="card-body">
<!-- Filters -->
<div class="card mb-4">
<div class="card-header">Filters</div>
<div class="card-body">
<div class="form-group">
<label for="status-filter">Status</label>
<select id="status-filter" class="form-control">
<option value="">All Statuses</option>
<option value="success">Success</option>
<option value="error">Error</option>
<option value="partial">Partial</option>
</select>
</div>
<div class="form-group">
<label for="operation-filter">Operation</label>
<select id="operation-filter" class="form-control">
<option value="">All Operations</option>
<option value="sync">Sync</option>
<option value="download">Download</option>
<option value="daemon">Daemon</option>
</select>
</div>
<div class="form-group">
<label for="date-filter">Date</label>
<input type="date" id="date-filter" class="form-control">
</div>
<button class="btn btn-primary" onclick="applyFilters()">Apply Filters</button>
<button class="btn btn-secondary" onclick="refreshLogs()">Refresh</button>
<button class="btn btn-warning" onclick="clearLogs()">Clear Logs</button>
</div>
</div>
<!-- Logs Table -->
<div class="table-container">
<table class="activities-table" id="logs-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Operation</th>
<th>Status</th>
<th>Message</th>
<th>Activities Processed</th>
<th>Activities Downloaded</th>
</tr>
</thead>
<tbody id="logs-tbody">
<!-- Populated by JavaScript -->
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="pagination-container">
<div class="pagination" id="pagination">
<!-- Populated by JavaScript -->
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block page_scripts %}
<script src="/static/logs.js"></script>
{% endblock %}

View File

@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""
Simple test script to verify the new UI is working correctly
"""
import sys
import time
from pathlib import Path
import requests
# Add the parent directory to the path to import garminsync modules
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
def test_ui_endpoints():
"""Test that the new UI endpoints are working correctly"""
base_url = "http://localhost:8000"
# Test endpoints to check
endpoints = [
"/",
"/activities",
"/config",
"/logs",
"/api/status",
"/api/activities/stats",
"/api/dashboard/stats",
]
print("Testing UI endpoints...")
failed_endpoints = []
for endpoint in endpoints:
try:
url = base_url + endpoint
print(f"Testing {url}...")
response = requests.get(url, timeout=10)
if response.status_code == 200:
print(f"{endpoint} - OK")
else:
print(f"{endpoint} - Status code: {response.status_code}")
failed_endpoints.append(endpoint)
except requests.exceptions.ConnectionError:
print(f"{endpoint} - Connection error (server not running?)")
failed_endpoints.append(endpoint)
except requests.exceptions.Timeout:
print(f"{endpoint} - Timeout")
failed_endpoints.append(endpoint)
except Exception as e:
print(f"{endpoint} - Error: {e}")
failed_endpoints.append(endpoint)
if failed_endpoints:
print(f"\nFailed endpoints: {failed_endpoints}")
return False
else:
print("\nAll endpoints are working correctly!")
return True
def test_api_endpoints():
"""Test that the new API endpoints are working correctly"""
base_url = "http://localhost:8000"
# Test API endpoints
api_endpoints = [
("/api/activities", "GET"),
(
"/api/activities/1",
"GET",
), # This might fail if activity doesn't exist, which is OK
("/api/dashboard/stats", "GET"),
]
print("\nTesting API endpoints...")
for endpoint, method in api_endpoints:
try:
url = base_url + endpoint
print(f"Testing {method} {url}...")
if method == "GET":
response = requests.get(url, timeout=10)
else:
response = requests.post(url, timeout=10)
# For activity details, 404 is acceptable if activity doesn't exist
if endpoint == "/api/activities/1" and response.status_code == 404:
print(f"{endpoint} - OK (404 expected if activity doesn't exist)")
continue
if response.status_code == 200:
print(f"{endpoint} - OK")
# Try to parse JSON
try:
data = response.json()
print(
f" Response keys: {list(data.keys()) if isinstance(data, dict) else 'Not a dict'}"
)
except:
print(" Response is not JSON")
else:
print(f"{endpoint} - Status code: {response.status_code}")
except requests.exceptions.ConnectionError:
print(f"{endpoint} - Connection error (server not running?)")
except requests.exceptions.Timeout:
print(f"{endpoint} - Timeout")
except Exception as e:
print(f"{endpoint} - Error: {e}")
if __name__ == "__main__":
print("GarminSync UI Test Script")
print("=" * 30)
# Test UI endpoints
ui_success = test_ui_endpoints()
# Test API endpoints
test_api_endpoints()
print("\n" + "=" * 30)
if ui_success:
print("UI tests completed successfully!")
sys.exit(0)
else:
print("Some UI tests failed!")
sys.exit(1)