mirror of
https://github.com/sstent/garminsync-go.git
synced 2026-01-25 16:42:45 +00:00
checkpoint 1
This commit is contained in:
@@ -1,239 +1,103 @@
|
||||
// internal/web/routes.go
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"garminsync/internal/database"
|
||||
"net/http"
|
||||
"html/template"
|
||||
"path/filepath"
|
||||
"os"
|
||||
|
||||
"github.com/yourusername/garminsync/internal/database"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
db database.Database
|
||||
router *mux.Router
|
||||
type WebHandler struct {
|
||||
db *database.SQLiteDB
|
||||
templates map[string]*template.Template
|
||||
}
|
||||
|
||||
func NewServer(db database.Database) *Server {
|
||||
s := &Server{
|
||||
db: db,
|
||||
router: mux.NewRouter(),
|
||||
}
|
||||
|
||||
s.setupRoutes()
|
||||
return s
|
||||
func NewWebHandler(db *database.SQLiteDB) *WebHandler {
|
||||
return &WebHandler{
|
||||
db: db,
|
||||
templates: make(map[string]*template.Template),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) setupRoutes() {
|
||||
// Static files (embedded)
|
||||
s.router.HandleFunc("/", s.handleHome).Methods("GET")
|
||||
s.router.HandleFunc("/health", s.handleHealth).Methods("GET")
|
||||
|
||||
// API routes
|
||||
api := s.router.PathPrefix("/api").Subrouter()
|
||||
|
||||
// Activities
|
||||
api.HandleFunc("/activities", s.handleGetActivities).Methods("GET")
|
||||
api.HandleFunc("/activities/{id:[0-9]+}", s.handleGetActivity).Methods("GET")
|
||||
api.HandleFunc("/activities/search", s.handleSearchActivities).Methods("GET")
|
||||
|
||||
// Stats
|
||||
api.HandleFunc("/stats", s.handleGetStats).Methods("GET")
|
||||
api.HandleFunc("/stats/summary", s.handleGetStatsSummary).Methods("GET")
|
||||
|
||||
// Sync operations
|
||||
api.HandleFunc("/sync", s.handleTriggerSync).Methods("POST")
|
||||
api.HandleFunc("/sync/status", s.handleGetSyncStatus).Methods("GET")
|
||||
|
||||
// Configuration
|
||||
api.HandleFunc("/config", s.handleGetConfig).Methods("GET")
|
||||
api.HandleFunc("/config", s.handleUpdateConfig).Methods("POST")
|
||||
func (h *WebHandler) LoadTemplates(templateDir string) error {
|
||||
layouts, err := filepath.Glob(filepath.Join(templateDir, "layouts", "*.html"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
partials, err := filepath.Glob(filepath.Join(templateDir, "partials", "*.html"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pages, err := filepath.Glob(filepath.Join(templateDir, "pages", "*.html"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, page := range pages {
|
||||
name := filepath.Base(page)
|
||||
|
||||
files := append([]string{page}, layouts...)
|
||||
files = append(files, partials...)
|
||||
|
||||
h.templates[name], err = template.ParseFiles(files...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.router.ServeHTTP(w, r)
|
||||
func (h *WebHandler) Index(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := h.db.GetStats()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.renderTemplate(w, "index.html", stats)
|
||||
}
|
||||
|
||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||
// Serve embedded HTML
|
||||
html := getEmbeddedHTML()
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(html))
|
||||
func (h *WebHandler) ActivityList(w http.ResponseWriter, r *http.Request) {
|
||||
activities, err := h.db.GetActivities(50, 0)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.renderTemplate(w, "activity_list.html", activities)
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
s.writeJSON(w, map[string]string{
|
||||
"status": "healthy",
|
||||
"service": "GarminSync",
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
func (h *WebHandler) ActivityDetail(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract activity ID from URL params
|
||||
activityID, err := strconv.Atoi(r.URL.Query().Get("id"))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid activity ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
activity, err := h.db.GetActivity(activityID)
|
||||
if err != nil {
|
||||
http.Error(w, "Activity not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
h.renderTemplate(w, "activity_detail.html", activity)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetActivities(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse query parameters
|
||||
query := r.URL.Query()
|
||||
|
||||
limit, _ := strconv.Atoi(query.Get("limit"))
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
offset, _ := strconv.Atoi(query.Get("offset"))
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
// Build filters
|
||||
filters := database.ActivityFilters{
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
|
||||
if activityType := query.Get("activity_type"); activityType != "" {
|
||||
filters.ActivityType = activityType
|
||||
}
|
||||
|
||||
if dateFrom := query.Get("date_from"); dateFrom != "" {
|
||||
if t, err := time.Parse("2006-01-02", dateFrom); err == nil {
|
||||
filters.DateFrom = &t
|
||||
}
|
||||
}
|
||||
|
||||
if dateTo := query.Get("date_to"); dateTo != "" {
|
||||
if t, err := time.Parse("2006-01-02", dateTo); err == nil {
|
||||
filters.DateTo = &t
|
||||
}
|
||||
}
|
||||
|
||||
if minDistance := query.Get("min_distance"); minDistance != "" {
|
||||
if d, err := strconv.ParseFloat(minDistance, 64); err == nil {
|
||||
filters.MinDistance = d * 1000 // Convert km to meters
|
||||
}
|
||||
}
|
||||
|
||||
if sortBy := query.Get("sort_by"); sortBy != "" {
|
||||
filters.SortBy = sortBy
|
||||
}
|
||||
|
||||
if sortOrder := query.Get("sort_order"); sortOrder != "" {
|
||||
filters.SortOrder = sortOrder
|
||||
}
|
||||
|
||||
// Get activities
|
||||
activities, err := s.db.FilterActivities(filters)
|
||||
if err != nil {
|
||||
s.writeError(w, "Failed to get activities", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to API response format
|
||||
response := map[string]interface{}{
|
||||
"activities": convertActivitiesToAPI(activities),
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
|
||||
s.writeJSON(w, response)
|
||||
}
|
||||
func (h *WebHandler) renderTemplate(w http.ResponseWriter, name string, data interface{}) {
|
||||
tmpl, ok := h.templates[name]
|
||||
if !ok {
|
||||
http.Error(w, "Template not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
activityID, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
s.writeError(w, "Invalid activity ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
activity, err := s.db.GetActivity(activityID)
|
||||
if err != nil {
|
||||
s.writeError(w, "Activity not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
s.writeJSON(w, convertActivityToAPI(*activity))
|
||||
}
|
||||
|
||||
func (s *Server) handleGetStats(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := s.db.GetStats()
|
||||
if err != nil {
|
||||
s.writeError(w, "Failed to get statistics", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s.writeJSON(w, stats)
|
||||
}
|
||||
|
||||
func (s *Server) handleTriggerSync(w http.ResponseWriter, r *http.Request) {
|
||||
// This would trigger the sync operation
|
||||
// For now, return success
|
||||
s.writeJSON(w, map[string]string{
|
||||
"status": "sync_started",
|
||||
"message": "Sync operation started in background",
|
||||
})
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
func (s *Server) writeJSON(w http.ResponseWriter, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func (s *Server) writeError(w http.ResponseWriter, message string, status int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": message,
|
||||
})
|
||||
}
|
||||
|
||||
func convertActivitiesToAPI(activities []database.Activity) []map[string]interface{} {
|
||||
result := make([]map[string]interface{}, len(activities))
|
||||
for i, activity := range activities {
|
||||
result[i] = convertActivityToAPI(activity)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func convertActivityToAPI(activity database.Activity) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"id": activity.ID,
|
||||
"activity_id": activity.ActivityID,
|
||||
"start_time": activity.StartTime.Format("2006-01-02T15:04:05Z"),
|
||||
"activity_type": activity.ActivityType,
|
||||
"duration": activity.Duration,
|
||||
"duration_formatted": formatDuration(activity.Duration),
|
||||
"distance": activity.Distance,
|
||||
"distance_km": roundFloat(activity.Distance/1000, 2),
|
||||
"max_heart_rate": activity.MaxHeartRate,
|
||||
"avg_heart_rate": activity.AvgHeartRate,
|
||||
"avg_power": activity.AvgPower,
|
||||
"calories": activity.Calories,
|
||||
"file_type": activity.FileType,
|
||||
"downloaded": activity.Downloaded,
|
||||
"created_at": activity.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
"last_sync": activity.LastSync.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
func formatDuration(seconds int) string {
|
||||
if seconds <= 0 {
|
||||
return "-"
|
||||
}
|
||||
|
||||
hours := seconds / 3600
|
||||
minutes := (seconds % 3600) / 60
|
||||
secs := seconds % 60
|
||||
|
||||
if hours > 0 {
|
||||
return fmt.Sprintf("%d:%02d:%02d", hours, minutes, secs)
|
||||
}
|
||||
return fmt.Sprintf("%d:%02d", minutes, secs)
|
||||
}
|
||||
|
||||
func roundFloat(val float64, precision int) float64 {
|
||||
ratio := math.Pow(10, float64(precision))
|
||||
return math.Round(val*ratio) / ratio
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.ExecuteTemplate(w, "base", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
47
internal/web/templates/layouts/base.html
Normal file
47
internal/web/templates/layouts/base.html
Normal file
@@ -0,0 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GarminSync - {{block "title" .}}{{end}}</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #1c6bff;
|
||||
--primary-hover: #0a5af7;
|
||||
}
|
||||
.activity-card {
|
||||
transition: transform 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.activity-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
|
||||
}
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="container-fluid">
|
||||
<ul>
|
||||
<li><strong>GarminSync</strong></li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><a href="/">Dashboard</a></li>
|
||||
<li><a href="/activities">Activities</a></li>
|
||||
<li><a href="/settings">Settings</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<main class="container">
|
||||
{{block "content" .}}{{end}}
|
||||
</main>
|
||||
<footer class="container-fluid">
|
||||
<p>GarminSync v1.0 · Last sync: {{.LastSync}}</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
70
internal/web/templates/pages/activity_detail.html
Normal file
70
internal/web/templates/pages/activity_detail.html
Normal file
@@ -0,0 +1,70 @@
|
||||
{{define "title"}}{{.ActivityName}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<article>
|
||||
<header>
|
||||
<hgroup>
|
||||
<h1>{{.ActivityName}}</h1>
|
||||
<h2>{{.ActivityType}} • {{.StartTime.Format "Jan 2, 2006"}}</h2>
|
||||
</hgroup>
|
||||
</header>
|
||||
|
||||
<div class="grid">
|
||||
<div>
|
||||
<h3>Metrics</h3>
|
||||
<ul>
|
||||
<li><strong>Distance:</strong> {{printf "%.2f km" (div .Distance 1000)}}</li>
|
||||
<li><strong>Duration:</strong> {{.Duration | formatDuration}}</li>
|
||||
<li><strong>Avg HR:</strong> {{.AvgHeartRate}} bpm</li>
|
||||
<li><strong>Avg Power:</strong> {{.AvgPower}}W</li>
|
||||
<li><strong>Calories:</strong> {{.Calories}}</li>
|
||||
<li><strong>Steps:</strong> {{.Steps}}</li>
|
||||
<li><strong>Elevation Gain:</strong> {{.ElevationGain | formatMeters}}m</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Location</h3>
|
||||
{{if and (ne .StartLatitude 0) (ne .StartLongitude 0)}}
|
||||
<div id="map" style="height: 300px; background: #f0f0f0; border-radius: 4px;">
|
||||
<!-- Map will be rendered here -->
|
||||
</div>
|
||||
<script>
|
||||
// Simple static map for now - will be enhanced later
|
||||
const mapEl = document.getElementById('map');
|
||||
const lat = {{.StartLatitude}};
|
||||
const lng = {{.StartLongitude}};
|
||||
mapEl.innerHTML = `
|
||||
<iframe
|
||||
width="100%"
|
||||
height="300"
|
||||
frameborder="0"
|
||||
style="border:0"
|
||||
src="https://www.openstreetmap.org/export/embed.html?bbox=${lng-0.01},${lat-0.01},${lng+0.01},${lat+0.01}&layer=mapnik&marker=${lat},${lng}">
|
||||
</iframe>
|
||||
`;
|
||||
</script>
|
||||
{{else}}
|
||||
<p>No location data available</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<a href="{{.Filename}}" download role="button">Download FIT File</a>
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
// Custom formatting function for duration
|
||||
function formatDuration(seconds) {
|
||||
const hrs = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
return `${hrs}h ${mins}m`;
|
||||
}
|
||||
|
||||
// Register as helper function
|
||||
window.formatDuration = formatDuration;
|
||||
</script>
|
||||
{{end}}
|
||||
27
internal/web/templates/pages/activity_list.html
Normal file
27
internal/web/templates/pages/activity_list.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{{define "title"}}Activities{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<article>
|
||||
<header>
|
||||
<h1>Activity List</h1>
|
||||
<div class="grid">
|
||||
<form method="get">
|
||||
<input type="search" name="q" placeholder="Search activities..."
|
||||
hx-get="/activities" hx-trigger="keyup changed delay:500ms"
|
||||
hx-target="#activity-list" hx-include="this">
|
||||
</form>
|
||||
<select name="type" hx-get="/activities" hx-trigger="change"
|
||||
hx-target="#activity-list" hx-include="previous input">
|
||||
<option value="">All Types</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="cycling">Cycling</option>
|
||||
<option value="swimming">Swimming</option>
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="activity-list" hx-get="/partials/activities" hx-trigger="load">
|
||||
Loading activities...
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
35
internal/web/templates/pages/index.html
Normal file
35
internal/web/templates/pages/index.html
Normal file
@@ -0,0 +1,35 @@
|
||||
{{define "title"}}Dashboard{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<article>
|
||||
<h1>Activity Dashboard</h1>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="card">
|
||||
<header>Total Activities</header>
|
||||
<h2>{{.Total}}</h2>
|
||||
</div>
|
||||
<div class="card">
|
||||
<header>Downloaded</header>
|
||||
<h2>{{.Downloaded}}</h2>
|
||||
</div>
|
||||
<div class="card">
|
||||
<header>Missing</header>
|
||||
<h2>{{.Missing}}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<a href="/sync" role="button" class="secondary">Sync Now</a>
|
||||
<a href="/activities" role="button">View Activities</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article>
|
||||
<h2>Recent Activities</h2>
|
||||
{{/* Will be replaced by HTMX */}}
|
||||
<div id="recent-activities" hx-get="/partials/recent_activities" hx-trigger="load">
|
||||
Loading...
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user