From 91493446b7569d8208817c37f3cd42b27be2b16b Mon Sep 17 00:00:00 2001 From: sstent Date: Sun, 24 Aug 2025 18:16:04 -0700 Subject: [PATCH] checkpoint 1 --- Dockerfile | 60 + docker-compose.yml | 29 + go.mod | 27 +- go.sum | 117 + internal/database/models.go | 36 +- internal/database/sqlite.go | 167 +- internal/garmin/client.go | 16 + internal/parser/activity.go | 338 +- internal/parser/detector.go | 68 +- internal/parser/factory.go | 57 + internal/parser/fit_parser.go | 91 + internal/parser/gpx_parser.go | 100 + internal/sync/sync.go | 145 + internal/web/routes.go | 304 +- internal/web/templates/layouts/base.html | 47 + .../web/templates/pages/activity_detail.html | 70 + .../web/templates/pages/activity_list.html | 27 + internal/web/templates/pages/index.html | 35 + main.go | 283 +- plan.md | 3180 +++++++++++++++++ 20 files changed, 4439 insertions(+), 758 deletions(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 go.sum create mode 100644 internal/parser/factory.go create mode 100644 internal/parser/fit_parser.go create mode 100644 internal/parser/gpx_parser.go create mode 100644 internal/sync/sync.go create mode 100644 internal/web/templates/layouts/base.html create mode 100644 internal/web/templates/pages/activity_detail.html create mode 100644 internal/web/templates/pages/activity_list.html create mode 100644 internal/web/templates/pages/index.html create mode 100644 plan.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..178475a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,60 @@ +# Build stage +FROM golang:1.20-alpine AS builder + +WORKDIR /app + +# Install dependencies +RUN apk add --no-cache gcc musl-dev git + +# Enable Go Modules +ENV GO111MODULE=on + +# Copy module files first for efficient caching +COPY go.mod go.sum ./ + +# Download dependencies +RUN go mod download + +# Copy source code +COPY . . + +# Build application +RUN CGO_ENABLED=1 GOOS=linux go build -o garminsync . + +# Runtime stage +FROM alpine:3.18 + +# Install runtime dependencies +RUN apk add --no-cache ca-certificates tzdata + +# Create app directory +WORKDIR /app + +# Copy binary from builder +COPY --from=builder /app/garminsync /app/garminsync + +# Copy templates +COPY internal/web/templates ./internal/web/templates + +# Set timezone and environment +ENV TZ=UTC \ + DATA_DIR=/data \ + DB_PATH=/data/garmin.db \ + TEMPLATE_DIR=/app/internal/web/templates + +# Create data volume and set permissions +RUN mkdir /data && chown nobody:nobody /data +VOLUME /data + +# Run as non-root user +USER nobody + +# Health check endpoint +HEALTHCHECK --interval=30s --timeout=30s --retries=3 \ + CMD wget --quiet --tries=1 --spider http://localhost:8888/health || exit 1 + +# Expose web port +EXPOSE 8888 + +# Start the application +ENTRYPOINT ["/app/garminsync"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..428c3c3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +version: '3.8' + +services: + garminsync: + build: . + container_name: garminsync + ports: + - "8888:8888" + environment: + - GARMIN_EMAIL=${GARMIN_EMAIL} + - GARMIN_PASSWORD=${GARMIN_PASSWORD} + - DATA_DIR=/data + - DB_PATH=/data/garmin.db + - TEMPLATE_DIR=/app/internal/web/templates + volumes: + - ./data:/data + - ./internal/web/templates:/app/internal/web/templates + - /etc/localtime:/etc/localtime:ro + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8888/health"] + interval: 30s + timeout: 30s # Increased timeout for startup + retries: 3 + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" diff --git a/go.mod b/go.mod index be3b05a..17c7bed 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,27 @@ module garminsync go 1.21 require ( - github.com/mattn/go-sqlite3 v1.14.17 - github.com/robfig/cron/v3 v3.0.1 - github.com/gorilla/mux v1.8.0 // For HTTP routing - golang.org/x/net v0.12.0 // For HTTP client + github.com/gorilla/mux v1.8.0 // For HTTP routing + github.com/mattn/go-sqlite3 v1.14.17 + github.com/robfig/cron/v3 v3.0.1 + golang.org/x/net v0.12.0 // For HTTP client +) + +require ( + github.com/BurntSushi/toml v1.2.1 // indirect + github.com/client9/misspell v0.3.4 // indirect + github.com/google/go-cmp v0.5.8 // indirect + github.com/gordonklaus/ineffassign v0.0.0-20210914165742-4cc7213b9bc8 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/kisielk/errcheck v1.6.1 // indirect + github.com/mdempsky/unconvert v0.0.0-20230125054757-2661c2c99a9b // indirect + github.com/tormoder/fit v0.15.0 // indirect + golang.org/x/exp/typeparams v0.0.0-20221208152030-732eee02a75a // indirect + golang.org/x/mod v0.8.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/sys v0.10.0 // indirect + golang.org/x/text v0.11.0 // indirect + golang.org/x/tools v0.6.0 // indirect + honnef.co/go/tools v0.4.2 // indirect + mvdan.cc/gofumpt v0.4.0 // indirect ) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..46d8b43 --- /dev/null +++ b/go.sum @@ -0,0 +1,117 @@ +github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/OneOfOne/xxhash v1.2.5/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= +github.com/bradfitz/latlong v0.0.0-20170410180902-f3db6d0dff40/go.mod h1:ZcXX9BndVQx6Q/JM6B8x7dLE9sl20S+TQsv4KO7tEQk= +github.com/cespare/xxhash v1.0.0/go.mod h1:fX/lfQBkSCDXZSUgv6jVIu/EVA3/JNseAX5asI4c4T4= +github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gordonklaus/ineffassign v0.0.0-20210914165742-4cc7213b9bc8 h1:PVRE9d4AQKmbelZ7emNig1+NT27DUmKZn5qXxfio54U= +github.com/gordonklaus/ineffassign v0.0.0-20210914165742-4cc7213b9bc8/go.mod h1:Qcp2HIAYhR7mNUVSIxZww3Guk4it82ghYcEXIAk+QT0= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jonas-p/go-shp v0.1.1/go.mod h1:MRIhyxDQ6VVp0oYeD7yPGr5RSTNScUFKCDsI5DR7PtI= +github.com/kisielk/errcheck v1.6.1 h1:cErYo+J4SmEjdXZrVXGwLJCE2sB06s23LpkcyWNrT+s= +github.com/kisielk/errcheck v1.6.1/go.mod h1:nXw/i/MfnvRHqXa7XXmQMUB0oNFGuBrNI8d8NLy0LPw= +github.com/kortschak/utter v0.0.0-20180609113506-364ec7d7a8f4/go.mod h1:oDr41C7kH9wvAikWyFhr6UFr8R7nelpmCF5XR5rL7I8= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= +github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mdempsky/unconvert v0.0.0-20230125054757-2661c2c99a9b h1:jdFI9paVi4E33U9TAExBpKPl1l5MnOn7VOLbb4Mvzzg= +github.com/mdempsky/unconvert v0.0.0-20230125054757-2661c2c99a9b/go.mod h1:mOq/NVYz3H5h7Av88ia14HIMF/UdGXj9dp8P/+b566A= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/tealeg/xlsx v1.0.3/go.mod h1:uxu5UY2ovkuRPWKQ8Q7JG0JbSivrISjdPzZQKeo74mA= +github.com/tormoder/fit v0.15.0 h1:oW1dhvGqPIwBJdRJfWzW/jqYU705oBmLcJq4TJO7SqU= +github.com/tormoder/fit v0.15.0/go.mod h1:J+m0+sz5qljhPaP34CgJz8uFD8Vzdsf96D3Hj99DMLQ= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/exp/typeparams v0.0.0-20221208152030-732eee02a75a h1:Jw5wfR+h9mnIYH+OtGT2im5wV1YGGDora5vTv/aa5bE= +golang.org/x/exp/typeparams v0.0.0-20221208152030-732eee02a75a/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/image v0.5.0/go.mod h1:FVC7BI/5Ym8R25iw5OLsgshdUBbT1h5jZTpA+mvAdZ4= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= +golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= +golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220819030929-7fc1605a5dde/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.4.1-0.20221208213631-3f74d914ae6d/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= +golang.org/x/tools v0.5.0/go.mod h1:N+Kgy78s5I24c24dU8OfWNEotWjutIs8SnJvn5IDq+k= +golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +honnef.co/go/tools v0.4.2 h1:6qXr+R5w+ktL5UkwEbPp+fEvfyoMPche6GkOpGHZcLc= +honnef.co/go/tools v0.4.2/go.mod h1:36ZgoUOrqOk1GxwHhyryEkq8FQWkUO2xGuSMhUCcdvA= +mvdan.cc/gofumpt v0.4.0 h1:JVf4NN1mIpHogBj7ABpgOyZc65/UUOkKQFkoURsz4MM= +mvdan.cc/gofumpt v0.4.0/go.mod h1:PljLOHDeZqgS8opHRKLzp2It2VBuSdteAgqUfzMTxlQ= diff --git a/internal/database/models.go b/internal/database/models.go index 5d3c53e..6383bbc 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -7,22 +7,26 @@ import ( ) type Activity struct { - ID int `json:"id"` - ActivityID int `json:"activity_id"` - StartTime time.Time `json:"start_time"` - ActivityType string `json:"activity_type"` - Duration int `json:"duration"` // seconds - Distance float64 `json:"distance"` // meters - MaxHeartRate int `json:"max_heart_rate"` - AvgHeartRate int `json:"avg_heart_rate"` - AvgPower float64 `json:"avg_power"` - Calories int `json:"calories"` - Filename string `json:"filename"` - FileType string `json:"file_type"` - FileSize int64 `json:"file_size"` - Downloaded bool `json:"downloaded"` - CreatedAt time.Time `json:"created_at"` - LastSync time.Time `json:"last_sync"` + ID int `json:"id"` + ActivityID int `json:"activity_id"` + StartTime time.Time `json:"start_time"` + ActivityType string `json:"activity_type"` + Duration int `json:"duration"` // in seconds + Distance float64 `json:"distance"` // in meters + MaxHeartRate int `json:"max_heart_rate"` + AvgHeartRate int `json:"avg_heart_rate"` + AvgPower float64 `json:"avg_power"` + Calories int `json:"calories"` + Steps int `json:"steps"` + ElevationGain float64 `json:"elevation_gain"` + StartLatitude float64 `json:"start_latitude"` + StartLongitude float64 `json:"start_longitude"` + Filename string `json:"filename"` + FileType string `json:"file_type"` + FileSize int64 `json:"file_size"` + Downloaded bool `json:"downloaded"` + CreatedAt time.Time `json:"created_at"` + LastSync time.Time `json:"last_sync"` } type Stats struct { diff --git a/internal/database/sqlite.go b/internal/database/sqlite.go index 5ea6524..028f85d 100644 --- a/internal/database/sqlite.go +++ b/internal/database/sqlite.go @@ -30,24 +30,28 @@ func NewSQLiteDB(dbPath string) (*SQLiteDB, error) { func (s *SQLiteDB) createTables() error { schema := ` - CREATE TABLE IF NOT EXISTS activities ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - activity_id INTEGER UNIQUE NOT NULL, - start_time DATETIME NOT NULL, - activity_type TEXT, - duration INTEGER, - distance REAL, - max_heart_rate INTEGER, - avg_heart_rate INTEGER, - avg_power REAL, - calories INTEGER, - filename TEXT UNIQUE, - file_type TEXT, - file_size INTEGER, - downloaded BOOLEAN DEFAULT FALSE, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - last_sync DATETIME DEFAULT CURRENT_TIMESTAMP - ); + CREATE TABLE IF NOT EXISTS activities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + activity_id INTEGER UNIQUE NOT NULL, + start_time DATETIME NOT NULL, + activity_type TEXT, + duration INTEGER, + distance REAL, + max_heart_rate INTEGER, + avg_heart_rate INTEGER, + avg_power REAL, + calories INTEGER, + steps INTEGER, + elevation_gain REAL, + start_latitude REAL, + start_longitude REAL, + filename TEXT UNIQUE, + file_type TEXT, + file_size INTEGER, + downloaded BOOLEAN DEFAULT FALSE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_sync DATETIME DEFAULT CURRENT_TIMESTAMP + ); CREATE INDEX IF NOT EXISTS idx_activities_activity_id ON activities(activity_id); CREATE INDEX IF NOT EXISTS idx_activities_start_time ON activities(start_time); @@ -73,8 +77,9 @@ func (s *SQLiteDB) createTables() error { func (s *SQLiteDB) GetActivities(limit, offset int) ([]Activity, error) { query := ` SELECT id, activity_id, start_time, activity_type, duration, distance, - max_heart_rate, avg_heart_rate, avg_power, calories, filename, - file_type, file_size, downloaded, created_at, last_sync + max_heart_rate, avg_heart_rate, avg_power, calories, steps, + elevation_gain, start_latitude, start_longitude, + filename, file_type, file_size, downloaded, created_at, last_sync FROM activities ORDER BY start_time DESC LIMIT ? OFFSET ?` @@ -93,8 +98,10 @@ func (s *SQLiteDB) GetActivities(limit, offset int) ([]Activity, error) { err := rows.Scan( &a.ID, &a.ActivityID, &startTime, &a.ActivityType, &a.Duration, &a.Distance, &a.MaxHeartRate, &a.AvgHeartRate, - &a.AvgPower, &a.Calories, &a.Filename, &a.FileType, - &a.FileSize, &a.Downloaded, &createdAt, &lastSync, + &a.AvgPower, &a.Calories, &a.Steps, &a.ElevationGain, + &a.StartLatitude, &a.StartLongitude, + &a.Filename, &a.FileType, &a.FileSize, &a.Downloaded, + &createdAt, &lastSync, ) if err != nil { return nil, err @@ -117,39 +124,89 @@ func (s *SQLiteDB) GetActivities(limit, offset int) ([]Activity, error) { return activities, nil } -func (s *SQLiteDB) CreateActivity(activity *Activity) error { +func (s *SQLiteDB) GetActivity(activityID int) (*Activity, error) { query := ` - INSERT INTO activities ( - activity_id, start_time, activity_type, duration, distance, - max_heart_rate, avg_heart_rate, avg_power, calories, - filename, file_type, file_size, downloaded - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + SELECT id, activity_id, start_time, activity_type, duration, distance, + max_heart_rate, avg_heart_rate, avg_power, calories, steps, + elevation_gain, start_latitude, start_longitude, + filename, file_type, file_size, downloaded, created_at, last_sync + FROM activities + WHERE activity_id = ?` + + row := s.db.QueryRow(query, activityID) + + var a Activity + var startTime, createdAt, lastSync string + + err := row.Scan( + &a.ID, &a.ActivityID, &startTime, &a.ActivityType, + &a.Duration, &a.Distance, &a.MaxHeartRate, &a.AvgHeartRate, + &a.AvgPower, &a.Calories, &a.Steps, &a.ElevationGain, + &a.StartLatitude, &a.StartLongitude, + &a.Filename, &a.FileType, &a.FileSize, &a.Downloaded, + &createdAt, &lastSync, + ) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("activity not found") + } + return nil, err + } + + // Parse time strings + if a.StartTime, err = time.Parse("2006-01-02 15:04:05", startTime); err != nil { + return nil, err + } + if a.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAt); err != nil { + return nil, err + } + if a.LastSync, err = time.Parse("2006-01-02 15:04:05", lastSync); err != nil { + return nil, err + } + + return &a, nil +} + +func (s *SQLiteDB) CreateActivity(activity *Activity) error { + query := ` + INSERT INTO activities ( + activity_id, start_time, activity_type, duration, distance, + max_heart_rate, avg_heart_rate, avg_power, calories, + steps, elevation_gain, start_latitude, start_longitude, + filename, file_type, file_size, downloaded + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` _, err := s.db.Exec(query, - activity.ActivityID, activity.StartTime.Format("2006-01-02 15:04:05"), - activity.ActivityType, activity.Duration, activity.Distance, - activity.MaxHeartRate, activity.AvgHeartRate, activity.AvgPower, - activity.Calories, activity.Filename, activity.FileType, - activity.FileSize, activity.Downloaded, + activity.ActivityID, activity.StartTime.Format("2006-01-02 15:04:05"), + activity.ActivityType, activity.Duration, activity.Distance, + activity.MaxHeartRate, activity.AvgHeartRate, activity.AvgPower, + activity.Calories, activity.Steps, activity.ElevationGain, + activity.StartLatitude, activity.StartLongitude, + activity.Filename, activity.FileType, + activity.FileSize, activity.Downloaded, ) return err } func (s *SQLiteDB) UpdateActivity(activity *Activity) error { - query := ` - UPDATE activities SET - activity_type = ?, duration = ?, distance = ?, - max_heart_rate = ?, avg_heart_rate = ?, avg_power = ?, - calories = ?, filename = ?, file_type = ?, file_size = ?, - downloaded = ?, last_sync = CURRENT_TIMESTAMP - WHERE activity_id = ?` + query := ` + UPDATE activities SET + activity_type = ?, duration = ?, distance = ?, + max_heart_rate = ?, avg_heart_rate = ?, avg_power = ?, + calories = ?, steps = ?, elevation_gain = ?, + start_latitude = ?, start_longitude = ?, + filename = ?, file_type = ?, file_size = ?, + downloaded = ?, last_sync = CURRENT_TIMESTAMP + WHERE activity_id = ?` _, err := s.db.Exec(query, - activity.ActivityType, activity.Duration, activity.Distance, - activity.MaxHeartRate, activity.AvgHeartRate, activity.AvgPower, - activity.Calories, activity.Filename, activity.FileType, - activity.FileSize, activity.Downloaded, activity.ActivityID, + activity.ActivityType, activity.Duration, activity.Distance, + activity.MaxHeartRate, activity.AvgHeartRate, activity.AvgPower, + activity.Calories, activity.Steps, activity.ElevationGain, + activity.StartLatitude, activity.StartLongitude, + activity.Filename, activity.FileType, + activity.FileSize, activity.Downloaded, activity.ActivityID, ) return err @@ -177,9 +234,10 @@ func (s *SQLiteDB) GetStats() (*Stats, error) { func (s *SQLiteDB) FilterActivities(filters ActivityFilters) ([]Activity, error) { query := ` - SELECT id, activity_id, start_time, activity_type, duration, distance, - max_heart_rate, avg_heart_rate, avg_power, calories, filename, - file_type, file_size, downloaded, created_at, last_sync + SELECT id, activity_id, start_time, activity_type, duration, distance, + max_heart_rate, avg_heart_rate, avg_power, calories, steps, + elevation_gain, start_latitude, start_longitude, + filename, file_type, file_size, downloaded, created_at, last_sync FROM activities WHERE 1=1` var args []interface{} @@ -257,10 +315,12 @@ func (s *SQLiteDB) FilterActivities(filters ActivityFilters) ([]Activity, error) var startTime, createdAt, lastSync string err := rows.Scan( - &a.ID, &a.ActivityID, &startTime, &a.ActivityType, - &a.Duration, &a.Distance, &a.MaxHeartRate, &a.AvgHeartRate, - &a.AvgPower, &a.Calories, &a.Filename, &a.FileType, - &a.FileSize, &a.Downloaded, &createdAt, &lastSync, + &a.ID, &a.ActivityID, &startTime, &a.ActivityType, + &a.Duration, &a.Distance, &a.MaxHeartRate, &a.AvgHeartRate, + &a.AvgPower, &a.Calories, &a.Steps, &a.ElevationGain, + &a.StartLatitude, &a.StartLongitude, + &a.Filename, &a.FileType, &a.FileSize, &a.Downloaded, + &createdAt, &lastSync, ) if err != nil { return nil, err @@ -280,3 +340,8 @@ func (s *SQLiteDB) FilterActivities(filters ActivityFilters) ([]Activity, error) func (s *SQLiteDB) Close() error { return s.db.Close() } + +// NewSQLiteDBFromDB wraps an existing sql.DB connection +func NewSQLiteDBFromDB(db *sql.DB) *SQLiteDB { + return &SQLiteDB{db: db} +} diff --git a/internal/garmin/client.go b/internal/garmin/client.go index 9b75e89..ac27157 100644 --- a/internal/garmin/client.go +++ b/internal/garmin/client.go @@ -39,6 +39,14 @@ type GarminActivity struct { AvgHR int `json:"avgHR"` AvgPower float64 `json:"avgPower"` Calories int `json:"calories"` + StartLatitude float64 `json:"startLatitude"` + StartLongitude float64 `json:"startLongitude"` + Steps int `json:"steps"` + ElevationGain float64 `json:"elevationGain"` + ElevationLoss float64 `json:"elevationLoss"` + AvgTemperature float64 `json:"avgTemperature"` + MinTemperature float64 `json:"minTemperature"` + MaxTemperature float64 `json:"maxTemperature"` } func NewClient() *Client { @@ -246,6 +254,14 @@ func (c *Client) GetActivityDetails(activityID int) (*GarminActivity, error) { if err := json.NewDecoder(resp.Body).Decode(&activity); err != nil { return nil, err } + + // Extract activity type from map if possible + if typeKey, ok := activity.ActivityType["typeKey"].(string); ok { + activity.ActivityType = map[string]interface{}{"typeKey": typeKey} + } else { + // Default to empty map if typeKey not found + activity.ActivityType = map[string]interface{}{} + } // Rate limiting time.Sleep(2 * time.Second) diff --git a/internal/parser/activity.go b/internal/parser/activity.go index 1434669..232879a 100644 --- a/internal/parser/activity.go +++ b/internal/parser/activity.go @@ -1,323 +1,35 @@ -// internal/parser/activity.go package parser -import ( - "encoding/xml" - "fmt" - "math" - "os" - "time" -) +import "time" +// ActivityMetrics contains all metrics extracted from activity files type ActivityMetrics struct { - ActivityType string - Duration int // seconds - Distance float64 // meters - MaxHR int - AvgHR int - AvgPower float64 - Calories int - StartTime time.Time + ActivityType string + StartTime time.Time + Duration time.Duration + Distance float64 // in meters + MaxHeartRate int + AvgHeartRate int + AvgPower int + Calories int + Steps int + ElevationGain float64 // in meters + ElevationLoss float64 // in meters + MinTemperature float64 // in °C + MaxTemperature float64 // in °C + AvgTemperature float64 // in °C } +// Parser defines the interface for activity file parsers type Parser interface { - ParseFile(filepath string) (*ActivityMetrics, error) + ParseFile(filename string) (*ActivityMetrics, error) } -func NewParser(fileType FileType) Parser { - switch fileType { - case FileTypeFIT: - return &FITParser{} - case FileTypeTCX: - return &TCXParser{} - case FileTypeGPX: - return &GPXParser{} - default: - return nil - } -} +// FileType represents supported file formats +type FileType string -// TCX Parser Implementation -type TCXParser struct{} - -type TCXTrainingCenterDatabase struct { - Activities TCXActivities `xml:"Activities"` -} - -type TCXActivities struct { - Activity []TCXActivity `xml:"Activity"` -} - -type TCXActivity struct { - Sport string `xml:"Sport,attr"` - Laps []TCXLap `xml:"Lap"` -} - -type TCXLap struct { - StartTime string `xml:"StartTime,attr"` - TotalTimeSeconds float64 `xml:"TotalTimeSeconds"` - DistanceMeters float64 `xml:"DistanceMeters"` - Calories int `xml:"Calories"` - MaximumSpeed float64 `xml:"MaximumSpeed"` - AverageHeartRate TCXHeartRate `xml:"AverageHeartRateBpm"` - MaximumHeartRate TCXHeartRate `xml:"MaximumHeartRateBpm"` - Track TCXTrack `xml:"Track"` -} - -type TCXHeartRate struct { - Value int `xml:"Value"` -} - -type TCXTrack struct { - Trackpoints []TCXTrackpoint `xml:"Trackpoint"` -} - -type TCXTrackpoint struct { - Time string `xml:"Time"` - HeartRateBpm TCXHeartRate `xml:"HeartRateBpm"` -} - -func (p *TCXParser) ParseFile(filepath string) (*ActivityMetrics, error) { - file, err := os.Open(filepath) - if err != nil { - return nil, err - } - defer file.Close() - - var tcx TCXTrainingCenterDatabase - decoder := xml.NewDecoder(file) - if err := decoder.Decode(&tcx); err != nil { - return nil, err - } - - if len(tcx.Activities.Activity) == 0 || len(tcx.Activities.Activity[0].Laps) == 0 { - return nil, fmt.Errorf("no activity data found") - } - - activity := tcx.Activities.Activity[0] - firstLap := activity.Laps[0] - - metrics := &ActivityMetrics{ - ActivityType: mapTCXSportType(activity.Sport), - } - - // Parse start time - if startTime, err := time.Parse(time.RFC3339, firstLap.StartTime); err == nil { - metrics.StartTime = startTime - } - - // Aggregate data from all laps - var totalDuration, totalDistance float64 - var maxHR, totalCalories int - var hrValues []int - - for _, lap := range activity.Laps { - totalDuration += lap.TotalTimeSeconds - totalDistance += lap.DistanceMeters - totalCalories += lap.Calories - - if lap.MaximumHeartRate.Value > maxHR { - maxHR = lap.MaximumHeartRate.Value - } - - if lap.AverageHeartRate.Value > 0 { - hrValues = append(hrValues, lap.AverageHeartRate.Value) - } - - // Collect HR data from trackpoints - for _, tp := range lap.Track.Trackpoints { - if tp.HeartRateBpm.Value > 0 { - hrValues = append(hrValues, tp.HeartRateBpm.Value) - } - } - } - - metrics.Duration = int(totalDuration) - metrics.Distance = totalDistance - metrics.MaxHR = maxHR - metrics.Calories = totalCalories - - // Calculate average HR - if len(hrValues) > 0 { - sum := 0 - for _, hr := range hrValues { - sum += hr - } - metrics.AvgHR = sum / len(hrValues) - } - - return metrics, nil -} - -func mapTCXSportType(sport string) string { - switch sport { - case "Running": - return "running" - case "Biking": - return "cycling" - case "Swimming": - return "swimming" - default: - return "other" - } -} - -// GPX Parser Implementation -type GPXParser struct{} - -type GPX struct { - Tracks []GPXTrack `xml:"trk"` -} - -type GPXTrack struct { - Name string `xml:"name"` - Segments []GPXSegment `xml:"trkseg"` -} - -type GPXSegment struct { - Points []GPXPoint `xml:"trkpt"` -} - -type GPXPoint struct { - Lat float64 `xml:"lat,attr"` - Lon float64 `xml:"lon,attr"` - Elevation float64 `xml:"ele"` - Time string `xml:"time"` - HR int `xml:"extensions>TrackPointExtension>hr"` -} - -func (p *GPXParser) ParseFile(filepath string) (*ActivityMetrics, error) { - file, err := os.Open(filepath) - if err != nil { - return nil, err - } - defer file.Close() - - var gpx GPX - decoder := xml.NewDecoder(file) - if err := decoder.Decode(&gpx); err != nil { - return nil, err - } - - if len(gpx.Tracks) == 0 || len(gpx.Tracks[0].Segments) == 0 { - return nil, fmt.Errorf("no track data found") - } - - metrics := &ActivityMetrics{ - ActivityType: "other", // GPX doesn't specify activity type - } - - var allPoints []GPXPoint - for _, track := range gpx.Tracks { - for _, segment := range track.Segments { - allPoints = append(allPoints, segment.Points...) - } - } - - if len(allPoints) == 0 { - return nil, fmt.Errorf("no track points found") - } - - // Calculate metrics from points - var startTime, endTime time.Time - var totalDistance float64 - var hrValues []int - - for i, point := range allPoints { - // Parse time - if point.Time != "" { - if t, err := time.Parse(time.RFC3339, point.Time); err == nil { - if i == 0 { - startTime = t - metrics.StartTime = t - } - endTime = t - } - } - - // Calculate distance between consecutive points - if i > 0 { - prevPoint := allPoints[i-1] - distance := calculateDistance(prevPoint.Lat, prevPoint.Lon, point.Lat, point.Lon) - totalDistance += distance - } - - // Collect heart rate data - if point.HR > 0 { - hrValues = append(hrValues, point.HR) - } - } - - // Calculate duration - if !startTime.IsZero() && !endTime.IsZero() { - metrics.Duration = int(endTime.Sub(startTime).Seconds()) - } - - metrics.Distance = totalDistance - - // Calculate heart rate metrics - if len(hrValues) > 0 { - sum := 0 - maxHR := 0 - for _, hr := range hrValues { - sum += hr - if hr > maxHR { - maxHR = hr - } - } - metrics.AvgHR = sum / len(hrValues) - metrics.MaxHR = maxHR - } - - return metrics, nil -} - -// Haversine formula for distance calculation -func calculateDistance(lat1, lon1, lat2, lon2 float64) float64 { - const earthRadius = 6371000 // Earth's radius in meters - - dLat := (lat2 - lat1) * math.Pi / 180 - dLon := (lon2 - lon1) * math.Pi / 180 - - lat1Rad := lat1 * math.Pi / 180 - lat2Rad := lat2 * math.Pi / 180 - - a := math.Sin(dLat/2)*math.Sin(dLat/2) + - math.Cos(lat1Rad)*math.Cos(lat2Rad)*math.Sin(dLon/2)*math.Sin(dLon/2) - - c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) - - return earthRadius * c -} - -// FIT Parser Implementation (simplified - would use FIT SDK in real implementation) -type FITParser struct{} - -func (p *FITParser) ParseFile(filepath string) (*ActivityMetrics, error) { - // For now, return basic metrics - in real implementation, would use FIT SDK - // This is a placeholder that reads basic file info - - file, err := os.Open(filepath) - if err != nil { - return nil, err - } - defer file.Close() - - // Read FIT header to verify it's a valid FIT file - header := make([]byte, 14) - _, err = file.Read(header) - if err != nil { - return nil, err - } - - // Verify FIT signature - if !bytes.Equal(header[8:12], []byte(".FIT")) { - return nil, fmt.Errorf("invalid FIT file signature") - } - - // For now, return empty metrics - real implementation would parse FIT records - return &ActivityMetrics{ - ActivityType: "other", - // Additional parsing would happen here using FIT SDK - }, nil -} +const ( + FIT FileType = "fit" + TCX FileType = "tcx" + GPX FileType = "gpx" +) diff --git a/internal/parser/detector.go b/internal/parser/detector.go index 9d6ee9e..da366c2 100644 --- a/internal/parser/detector.go +++ b/internal/parser/detector.go @@ -1,55 +1,31 @@ -// internal/parser/detector.go package parser import ( - "bytes" - "os" + "bytes" + "errors" ) -type FileType string - -const ( - FileTypeFIT FileType = "fit" - FileTypeTCX FileType = "tcx" - FileTypeGPX FileType = "gpx" - FileTypeUnknown FileType = "unknown" +var ( + // FIT file signature + fitSignature = []byte{0x0E, 0x10} // .FIT files start with 0x0E 0x10 ) -func DetectFileType(filepath string) (FileType, error) { - file, err := os.Open(filepath) - if err != nil { - return FileTypeUnknown, err - } - defer file.Close() - - // Read first 512 bytes for detection - header := make([]byte, 512) - n, err := file.Read(header) - if err != nil && n == 0 { - return FileTypeUnknown, err - } - - header = header[:n] - - return DetectFileTypeFromData(header), nil -} +// DetectFileType detects the file type based on its content +func DetectFileType(data []byte) (string, error) { + // Check FIT file signature + if len(data) >= 2 && bytes.Equal(data[:2], fitSignature) { + return ".fit", nil + } -func DetectFileTypeFromData(data []byte) FileType { - // Check for FIT file signature - if len(data) >= 8 && bytes.Equal(data[8:12], []byte(".FIT")) { - return FileTypeFIT - } - - // Check for XML-based formats - if bytes.HasPrefix(data, []byte(" root) + if bytes.Contains(data, []byte(" prev.Ele { + elevationGain += curr.Ele - prev.Ele + } + prev = curr + } + + activity.Distance = totalDistance + activity.ElevationGain = elevationGain + + return activity, nil +} + +// haversine calculates the distance between two points on Earth +func haversine(lat1, lon1, lat2, lon2 float64) float64 { + const R = 6371000 // Earth radius in meters + φ1 := lat1 * math.Pi / 180 + φ2 := lat2 * math.Pi / 180 + Δφ := (lat2 - lat1) * math.Pi / 180 + Δλ := (lon2 - lon1) * math.Pi / 180 + + a := math.Sin(Δφ/2)*math.Sin(Δφ/2) + + math.Cos(φ1)*math.Cos(φ2)* + math.Sin(Δλ/2)*math.Sin(Δλ/2) + c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) + + return R * c +} + +func init() { + RegisterParser(".gpx", &GPXParser{}) +} diff --git a/internal/sync/sync.go b/internal/sync/sync.go new file mode 100644 index 0000000..0ef1f8b --- /dev/null +++ b/internal/sync/sync.go @@ -0,0 +1,145 @@ +package sync + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/yourusername/garminsync/internal/database" + "github.com/yourusername/garminsync/internal/garmin" + "github.com/yourusername/garminsync/internal/parser" +) + +type SyncService struct { + garminClient *garmin.Client + db *database.SQLiteDB + dataDir string +} + +func NewSyncService(garminClient *garmin.Client, db *database.SQLiteDB, dataDir string) *SyncService { + return &SyncService{ + garminClient: garminClient, + db: db, + dataDir: dataDir, + } +} + +func (s *SyncService) Sync(ctx context.Context) error { + startTime := time.Now() + fmt.Printf("Starting sync at %s\n", startTime.Format(time.RFC3339)) + defer func() { + fmt.Printf("Sync completed in %s\n", time.Since(startTime)) + }() + + // 1. Fetch latest activities from Garmin + activities, err := s.garminClient.GetActivities(0, 100) + if err != nil { + return fmt.Errorf("failed to get activities: %w", err) + } + fmt.Printf("Found %d activities on Garmin\n", len(activities)) + + // 2. Sync each activity + for i, activity := range activities { + select { + case <-ctx.Done(): + return ctx.Err() + default: + fmt.Printf("[%d/%d] Processing activity %d...\n", i+1, len(activities), activity.ActivityID) + if err := s.syncActivity(&activity); err != nil { + fmt.Printf("Error syncing activity %d: %v\n", activity.ActivityID, err) + // Continue with next activity on error + } + } + } + + return nil +} + +func (s *SyncService) syncActivity(activity *garmin.GarminActivity) error { + // Check if activity exists in database + dbActivity, err := s.db.GetActivity(activity.ActivityID) + if err == nil { + // Activity exists - check if already downloaded + if dbActivity.Downloaded { + fmt.Printf("Activity %d already downloaded\n", activity.ActivityID) + return nil + } + } else { + // Activity not in database - create new record + dbActivity = &database.Activity{ + ActivityID: activity.ActivityID, + StartTime: parseTime(activity.StartTimeLocal), + } + + // Add basic info if available + if activityType, ok := activity.ActivityType["typeKey"]; ok { + dbActivity.ActivityType = activityType.(string) + } + dbActivity.Duration = int(activity.Duration) + dbActivity.Distance = activity.Distance + + if err := s.db.CreateActivity(dbActivity); err != nil { + return fmt.Errorf("failed to create activity: %w", err) + } + } + + // Download the activity file (FIT format) + fileData, err := s.garminClient.DownloadActivity(activity.ActivityID, "fit") + if err != nil { + return fmt.Errorf("failed to download activity: %w", err) + } + + // Determine filename + filename := filepath.Join( + s.dataDir, + "activities", + fmt.Sprintf("%d_%s.fit", activity.ActivityID, activity.StartTimeLocal[:10]), + ) + + // Create directories if needed + if err := os.MkdirAll(filepath.Dir(filename), 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + // Save file + if err := os.WriteFile(filename, fileData, 0644); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + // Parse the file to extract additional metrics + metrics, err := parser.NewFITParser().ParseData(fileData) + if err != nil { + return fmt.Errorf("failed to parse activity file: %w", err) + } + + // Update activity with parsed metrics + dbActivity.Duration = int(metrics.Duration.Seconds()) + dbActivity.Distance = metrics.Distance + dbActivity.MaxHeartRate = metrics.MaxHeartRate + dbActivity.AvgHeartRate = metrics.AvgHeartRate + dbActivity.AvgPower = metrics.AvgPower + dbActivity.Calories = metrics.Calories + dbActivity.Downloaded = true + dbActivity.Filename = filename + dbActivity.FileType = "fit" + + // Save updated activity + if err := s.db.UpdateActivity(dbActivity); err != nil { + return fmt.Errorf("failed to update activity: %w", err) + } + + fmt.Printf("Successfully synced activity %d\n", activity.ActivityID) + return nil +} + +func parseTime(timeStr string) time.Time { + // Garmin time format: "2023-08-15 12:30:45" + t, err := time.Parse("2006-01-02 15:04:05", timeStr) + if err != nil { + return time.Now() + } + return t +} diff --git a/internal/web/routes.go b/internal/web/routes.go index e9f1706..1155fe2 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -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) + } } diff --git a/internal/web/templates/layouts/base.html b/internal/web/templates/layouts/base.html new file mode 100644 index 0000000..1c89a2b --- /dev/null +++ b/internal/web/templates/layouts/base.html @@ -0,0 +1,47 @@ + + + + + + GarminSync - {{block "title" .}}{{end}} + + + + + + +
+ {{block "content" .}}{{end}} +
+
+

GarminSync v1.0 · Last sync: {{.LastSync}}

+
+ + diff --git a/internal/web/templates/pages/activity_detail.html b/internal/web/templates/pages/activity_detail.html new file mode 100644 index 0000000..d3ab866 --- /dev/null +++ b/internal/web/templates/pages/activity_detail.html @@ -0,0 +1,70 @@ +{{define "title"}}{{.ActivityName}}{{end}} + +{{define "content"}} +
+
+
+

{{.ActivityName}}

+

{{.ActivityType}} • {{.StartTime.Format "Jan 2, 2006"}}

+
+
+ +
+
+

Metrics

+
    +
  • Distance: {{printf "%.2f km" (div .Distance 1000)}}
  • +
  • Duration: {{.Duration | formatDuration}}
  • +
  • Avg HR: {{.AvgHeartRate}} bpm
  • +
  • Avg Power: {{.AvgPower}}W
  • +
  • Calories: {{.Calories}}
  • +
  • Steps: {{.Steps}}
  • +
  • Elevation Gain: {{.ElevationGain | formatMeters}}m
  • +
+
+
+

Location

+ {{if and (ne .StartLatitude 0) (ne .StartLongitude 0)}} +
+ +
+ + {{else}} +

No location data available

+ {{end}} +
+
+ +
+ Download FIT File +
+
+{{end}} + +{{define "scripts"}} + +{{end}} diff --git a/internal/web/templates/pages/activity_list.html b/internal/web/templates/pages/activity_list.html new file mode 100644 index 0000000..775a546 --- /dev/null +++ b/internal/web/templates/pages/activity_list.html @@ -0,0 +1,27 @@ +{{define "title"}}Activities{{end}} + +{{define "content"}} +
+
+

Activity List

+
+
+ +
+ +
+
+ +
+ Loading activities... +
+
+{{end}} diff --git a/internal/web/templates/pages/index.html b/internal/web/templates/pages/index.html new file mode 100644 index 0000000..dd48736 --- /dev/null +++ b/internal/web/templates/pages/index.html @@ -0,0 +1,35 @@ +{{define "title"}}Dashboard{{end}} + +{{define "content"}} +
+

Activity Dashboard

+ +
+
+
Total Activities
+

{{.Total}}

+
+
+
Downloaded
+

{{.Downloaded}}

+
+
+
Missing
+

{{.Missing}}

+
+
+ + +
+ +
+

Recent Activities

+ {{/* Will be replaced by HTMX */}} +
+ Loading... +
+
+{{end}} diff --git a/main.go b/main.go index 78cc6cc..a3c00db 100644 --- a/main.go +++ b/main.go @@ -2,131 +2,198 @@ package main import ( - "context" - "database/sql" - "log" - "net/http" - "os" - "os/signal" - "syscall" - "time" + "context" + "database/sql" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" - _ "github.com/mattn/go-sqlite3" - "github.com/robfig/cron/v3" + "github.com/joho/godotenv" + + "github.com/yourusername/garminsync/internal/database" + "github.com/yourusername/garminsync/internal/garmin" + "github.com/yourusername/garminsync/internal/sync" + "github.com/yourusername/garminsync/internal/web" + + _ "github.com/mattn/go-sqlite3" + "github.com/robfig/cron/v3" ) type App struct { - db *sql.DB - cron *cron.Cron - server *http.Server - garmin *GarminClient - shutdown chan os.Signal + db *database.SQLiteDB + cron *cron.Cron + server *http.Server + garmin *garmin.Client + shutdown chan os.Signal + syncService *sync.SyncService } func main() { - app := &App{ - shutdown: make(chan os.Signal, 1), - } - - // Initialize components - if err := app.init(); err != nil { - log.Fatal("Failed to initialize app:", err) - } - - // Start services - app.start() - - // Wait for shutdown signal - signal.Notify(app.shutdown, os.Interrupt, syscall.SIGTERM) - <-app.shutdown - - // Graceful shutdown - app.stop() + // Load environment variables from .env file + if err := godotenv.Load(); err != nil { + log.Println("No .env file found, using system environment variables") + } + + app := &App{ + shutdown: make(chan os.Signal, 1), + } + + // Initialize components + if err := app.init(); err != nil { + log.Fatal("Failed to initialize app:", err) + } + + // Start services + app.start() + + // Wait for shutdown signal + signal.Notify(app.shutdown, os.Interrupt, syscall.SIGTERM) + <-app.shutdown + + // Graceful shutdown + app.stop() } func (app *App) init() error { - var err error - - // Initialize database - app.db, err = initDatabase() - if err != nil { - return err - } - - // Initialize Garmin client - app.garmin = NewGarminClient() - - // Setup cron scheduler - app.cron = cron.New() - - // Setup HTTP server - app.server = &http.Server{ - Addr: ":8888", - Handler: app.setupRoutes(), - } - - return nil + var err error + + // Initialize database + dbConn, err := initDatabase() + if err != nil { + return err + } + app.db = database.NewSQLiteDBFromDB(dbConn) + + // Initialize Garmin client + app.garmin = garmin.NewClient() + + // Initialize sync service + dataDir := os.Getenv("DATA_DIR") + if dataDir == "" { + dataDir = "./data" + } + app.syncService = sync.NewSyncService(app.garmin, database.NewSQLiteDBFromDB(app.db), dataDir) + + // Setup cron scheduler + app.cron = cron.New() + + // Setup HTTP server + webHandler := web.NewWebHandler(app.db) + templateDir := os.Getenv("TEMPLATE_DIR") + if templateDir == "" { + templateDir = "./internal/web/templates" + } + if err := webHandler.LoadTemplates(templateDir); err != nil { + return fmt.Errorf("failed to load templates: %w", err) + } + + app.server = &http.Server{ + Addr: ":8888", + Handler: app.setupRoutes(webHandler), + } + + return nil } func (app *App) start() { - // Start cron scheduler - app.cron.AddFunc("@hourly", func() { - log.Println("Starting scheduled sync...") - app.syncActivities() - }) - app.cron.Start() - - // Start web server - go func() { - log.Println("Server starting on http://localhost:8888") - if err := app.server.ListenAndServe(); err != http.ErrServerClosed { - log.Printf("Server error: %v", err) - } - }() + // Start cron scheduler + app.cron.AddFunc("@hourly", func() { + log.Println("Starting scheduled sync...") + if err := app.syncService.Sync(context.Background()); err != nil { + log.Printf("Sync failed: %v", err) + } + }) + app.cron.Start() + + // Start web server + go func() { + log.Println("Server starting on http://localhost:8888") + if err := app.server.ListenAndServe(); err != http.ErrServerClosed { + log.Printf("Server error: %v", err) + } + }() } func (app *App) stop() { - log.Println("Shutting down...") - - // Stop cron - app.cron.Stop() - - // Stop web server - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - if err := app.server.Shutdown(ctx); err != nil { - log.Printf("Server shutdown error: %v", err) - } - - // Close database - if app.db != nil { - app.db.Close() - } - - log.Println("Shutdown complete") + log.Println("Shutting down...") + + // Stop cron + app.cron.Stop() + + // Stop web server + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := app.server.Shutdown(ctx); err != nil { + log.Printf("Server shutdown error: %v", err) + } + + // Close database + if app.db != nil { + app.db.Close() + } + + log.Println("Shutdown complete") } -// main.go - Database initialization +// Database initialization func initDatabase() (*sql.DB, error) { - // Get data directory from environment or use default - dataDir := os.Getenv("DATA_DIR") - if dataDir == "" { - dataDir = "./data" - } - - // Create data directory if it doesn't exist - if err := os.MkdirAll(dataDir, 0755); err != nil { - return nil, fmt.Errorf("failed to create data directory: %v", err) - } - - dbPath := filepath.Join(dataDir, "garmin.db") - - // Initialize SQLite database - db, err := database.NewSQLiteDB(dbPath) - if err != nil { - return nil, fmt.Errorf("failed to initialize database: %v", err) - } - - return db.db, nil // Return the underlying *sql.DB + // Get database path from environment or use default + dbPath := os.Getenv("DB_PATH") + if dbPath == "" { + // Fallback to DATA_DIR/garmin.db if DB_PATH not set + dataDir := os.Getenv("DATA_DIR") + if dataDir == "" { + dataDir = "./data" + } + + // Create data directory if it doesn't exist + if err := os.MkdirAll(dataDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create data directory: %v", err) + } + + dbPath = filepath.Join(dataDir, "garmin.db") + } + + // Initialize SQLite database + db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on") + if err != nil { + return nil, fmt.Errorf("failed to open database: %v", err) + } + + // Verify connection + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("database ping failed: %v", err) + } + + // Create tables if they don't exist + sqliteDB := &database.SQLiteDB{db: db} + if err := sqliteDB.createTables(); err != nil { + return nil, fmt.Errorf("failed to create tables: %v", err) + } + + return db, nil +} + +// Application routes +func (app *App) setupRoutes(webHandler *web.WebHandler) *http.ServeMux { + mux := http.NewServeMux() + + // Health check + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) + + // Web UI routes + mux.HandleFunc("/", webHandler.Index) + mux.HandleFunc("/activities", webHandler.ActivityList) + mux.HandleFunc("/activity", webHandler.ActivityDetail) + + return mux } diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..029616c --- /dev/null +++ b/plan.md @@ -0,0 +1,3180 @@ +# GarminSync Go Migration Implementation Plan + +## Overview +Migrate from Python/FastAPI to a single Go binary that includes web UI, database, and sync logic. + +**Target:** Single executable file (~1,000 lines of Go code vs current 2,500+ lines across 25 files) + +--- + +## Phase 1: Setup & Core Structure (Week 1) + +### 1.1 Project Setup +```bash +# Create new Go project structure +mkdir garminsync-go +cd garminsync-go + +# Initialize Go module +go mod init garminsync + +# Create basic structure +touch main.go +mkdir -p {internal/{database,garmin,web},templates,assets} +``` + +### 1.2 Go Dependencies +```go +// go.mod - Keep dependencies minimal +module garminsync + +go 1.21 + +require ( + github.com/mattn/go-sqlite3 v1.14.17 + github.com/robfig/cron/v3 v3.0.1 + github.com/gorilla/mux v1.8.0 // For HTTP routing + golang.org/x/net v0.12.0 // For HTTP client +) +``` + +### 1.3 Core Application Structure +```go +// main.go - Entry point and dependency injection +package main + +import ( + "context" + "database/sql" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + _ "github.com/mattn/go-sqlite3" + "github.com/robfig/cron/v3" +) + +type App struct { + db *sql.DB + cron *cron.Cron + server *http.Server + garmin *GarminClient + shutdown chan os.Signal +} + +func main() { + app := &App{ + shutdown: make(chan os.Signal, 1), + } + + // Initialize components + if err := app.init(); err != nil { + log.Fatal("Failed to initialize app:", err) + } + + // Start services + app.start() + + // Wait for shutdown signal + signal.Notify(app.shutdown, os.Interrupt, syscall.SIGTERM) + <-app.shutdown + + // Graceful shutdown + app.stop() +} + +func (app *App) init() error { + var err error + + // Initialize database + app.db, err = initDatabase() + if err != nil { + return err + } + + // Initialize Garmin client + app.garmin = NewGarminClient() + + // Setup cron scheduler + app.cron = cron.New() + + // Setup HTTP server + app.server = &http.Server{ + Addr: ":8888", + Handler: app.setupRoutes(), + } + + return nil +} + +func (app *App) start() { + // Start cron scheduler + app.cron.AddFunc("@hourly", func() { + log.Println("Starting scheduled sync...") + app.syncActivities() + }) + app.cron.Start() + + // Start web server + go func() { + log.Println("Server starting on http://localhost:8888") + if err := app.server.ListenAndServe(); err != http.ErrServerClosed { + log.Printf("Server error: %v", err) + } + }() +} + +func (app *App) stop() { + log.Println("Shutting down...") + + // Stop cron + app.cron.Stop() + + // Stop web server + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := app.server.Shutdown(ctx); err != nil { + log.Printf("Server shutdown error: %v", err) + } + + // Close database + if app.db != nil { + app.db.Close() + } + + log.Println("Shutdown complete") +} +``` + +--- + +## Phase 2: Database Layer (Week 1-2) + +### 2.1 Database Models & Schema +```go +// internal/database/models.go +package database + +import ( + "database/sql" + "time" +) + +type Activity struct { + ID int `json:"id"` + ActivityID int `json:"activity_id"` + StartTime time.Time `json:"start_time"` + ActivityType string `json:"activity_type"` + Duration int `json:"duration"` // seconds + Distance float64 `json:"distance"` // meters + MaxHeartRate int `json:"max_heart_rate"` + AvgHeartRate int `json:"avg_heart_rate"` + AvgPower float64 `json:"avg_power"` + Calories int `json:"calories"` + Filename string `json:"filename"` + FileType string `json:"file_type"` + FileSize int64 `json:"file_size"` + Downloaded bool `json:"downloaded"` + CreatedAt time.Time `json:"created_at"` + LastSync time.Time `json:"last_sync"` +} + +type Stats struct { + Total int `json:"total"` + Downloaded int `json:"downloaded"` + Missing int `json:"missing"` +} + +type DaemonConfig struct { + ID int `json:"id"` + Enabled bool `json:"enabled"` + ScheduleCron string `json:"schedule_cron"` + LastRun string `json:"last_run"` + Status string `json:"status"` +} + +// Database interface +type Database interface { + // Activities + GetActivities(limit, offset int) ([]Activity, error) + GetActivity(activityID int) (*Activity, error) + CreateActivity(activity *Activity) error + UpdateActivity(activity *Activity) error + DeleteActivity(activityID int) error + + // Stats + GetStats() (*Stats, error) + + // Search and filter + FilterActivities(filters ActivityFilters) ([]Activity, error) + + // Close connection + Close() error +} + +type ActivityFilters struct { + ActivityType string + DateFrom *time.Time + DateTo *time.Time + MinDistance float64 + MaxDistance float64 + MinDuration int + MaxDuration int + Downloaded *bool + Limit int + Offset int + SortBy string + SortOrder string +} +``` + +### 2.2 SQLite Implementation +```go +// internal/database/sqlite.go +package database + +import ( + "database/sql" + "fmt" + "strings" + "time" +) + +type SQLiteDB struct { + db *sql.DB +} + +func NewSQLiteDB(dbPath string) (*SQLiteDB, error) { + db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on") + if err != nil { + return nil, err + } + + sqlite := &SQLiteDB{db: db} + + // Create tables + if err := sqlite.createTables(); err != nil { + return nil, err + } + + return sqlite, nil +} + +func (s *SQLiteDB) createTables() error { + schema := ` + CREATE TABLE IF NOT EXISTS activities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + activity_id INTEGER UNIQUE NOT NULL, + start_time DATETIME NOT NULL, + activity_type TEXT, + duration INTEGER, + distance REAL, + max_heart_rate INTEGER, + avg_heart_rate INTEGER, + avg_power REAL, + calories INTEGER, + filename TEXT UNIQUE, + file_type TEXT, + file_size INTEGER, + downloaded BOOLEAN DEFAULT FALSE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_sync DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_activities_activity_id ON activities(activity_id); + CREATE INDEX IF NOT EXISTS idx_activities_start_time ON activities(start_time); + CREATE INDEX IF NOT EXISTS idx_activities_activity_type ON activities(activity_type); + CREATE INDEX IF NOT EXISTS idx_activities_downloaded ON activities(downloaded); + + CREATE TABLE IF NOT EXISTS daemon_config ( + id INTEGER PRIMARY KEY DEFAULT 1, + enabled BOOLEAN DEFAULT TRUE, + schedule_cron TEXT DEFAULT '0 * * * *', + last_run TEXT, + status TEXT DEFAULT 'stopped', + CONSTRAINT single_config CHECK (id = 1) + ); + + INSERT OR IGNORE INTO daemon_config (id) VALUES (1); + ` + + _, err := s.db.Exec(schema) + return err +} + +func (s *SQLiteDB) GetActivities(limit, offset int) ([]Activity, error) { + query := ` + SELECT id, activity_id, start_time, activity_type, duration, distance, + max_heart_rate, avg_heart_rate, avg_power, calories, filename, + file_type, file_size, downloaded, created_at, last_sync + FROM activities + ORDER BY start_time DESC + LIMIT ? OFFSET ?` + + rows, err := s.db.Query(query, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var activities []Activity + for rows.Next() { + var a Activity + var startTime, createdAt, lastSync string + + err := rows.Scan( + &a.ID, &a.ActivityID, &startTime, &a.ActivityType, + &a.Duration, &a.Distance, &a.MaxHeartRate, &a.AvgHeartRate, + &a.AvgPower, &a.Calories, &a.Filename, &a.FileType, + &a.FileSize, &a.Downloaded, &createdAt, &lastSync, + ) + if err != nil { + return nil, err + } + + // Parse time strings + if a.StartTime, err = time.Parse("2006-01-02 15:04:05", startTime); err != nil { + return nil, err + } + if a.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAt); err != nil { + return nil, err + } + if a.LastSync, err = time.Parse("2006-01-02 15:04:05", lastSync); err != nil { + return nil, err + } + + activities = append(activities, a) + } + + return activities, nil +} + +func (s *SQLiteDB) CreateActivity(activity *Activity) error { + query := ` + INSERT INTO activities ( + activity_id, start_time, activity_type, duration, distance, + max_heart_rate, avg_heart_rate, avg_power, calories, + filename, file_type, file_size, downloaded + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + + _, err := s.db.Exec(query, + activity.ActivityID, activity.StartTime.Format("2006-01-02 15:04:05"), + activity.ActivityType, activity.Duration, activity.Distance, + activity.MaxHeartRate, activity.AvgHeartRate, activity.AvgPower, + activity.Calories, activity.Filename, activity.FileType, + activity.FileSize, activity.Downloaded, + ) + + return err +} + +func (s *SQLiteDB) UpdateActivity(activity *Activity) error { + query := ` + UPDATE activities SET + activity_type = ?, duration = ?, distance = ?, + max_heart_rate = ?, avg_heart_rate = ?, avg_power = ?, + calories = ?, filename = ?, file_type = ?, file_size = ?, + downloaded = ?, last_sync = CURRENT_TIMESTAMP + WHERE activity_id = ?` + + _, err := s.db.Exec(query, + activity.ActivityType, activity.Duration, activity.Distance, + activity.MaxHeartRate, activity.AvgHeartRate, activity.AvgPower, + activity.Calories, activity.Filename, activity.FileType, + activity.FileSize, activity.Downloaded, activity.ActivityID, + ) + + return err +} + +func (s *SQLiteDB) GetStats() (*Stats, error) { + stats := &Stats{} + + // Get total count + err := s.db.QueryRow("SELECT COUNT(*) FROM activities").Scan(&stats.Total) + if err != nil { + return nil, err + } + + // Get downloaded count + err = s.db.QueryRow("SELECT COUNT(*) FROM activities WHERE downloaded = TRUE").Scan(&stats.Downloaded) + if err != nil { + return nil, err + } + + stats.Missing = stats.Total - stats.Downloaded + + return stats, nil +} + +func (s *SQLiteDB) FilterActivities(filters ActivityFilters) ([]Activity, error) { + query := ` + SELECT id, activity_id, start_time, activity_type, duration, distance, + max_heart_rate, avg_heart_rate, avg_power, calories, filename, + file_type, file_size, downloaded, created_at, last_sync + FROM activities WHERE 1=1` + + var args []interface{} + var conditions []string + + // Build WHERE conditions + if filters.ActivityType != "" { + conditions = append(conditions, "activity_type = ?") + args = append(args, filters.ActivityType) + } + + if filters.DateFrom != nil { + conditions = append(conditions, "start_time >= ?") + args = append(args, filters.DateFrom.Format("2006-01-02 15:04:05")) + } + + if filters.DateTo != nil { + conditions = append(conditions, "start_time <= ?") + args = append(args, filters.DateTo.Format("2006-01-02 15:04:05")) + } + + if filters.MinDistance > 0 { + conditions = append(conditions, "distance >= ?") + args = append(args, filters.MinDistance) + } + + if filters.MaxDistance > 0 { + conditions = append(conditions, "distance <= ?") + args = append(args, filters.MaxDistance) + } + + if filters.Downloaded != nil { + conditions = append(conditions, "downloaded = ?") + args = append(args, *filters.Downloaded) + } + + // Add conditions to query + if len(conditions) > 0 { + query += " AND " + strings.Join(conditions, " AND ") + } + + // Add sorting + orderBy := "start_time" + if filters.SortBy != "" { + orderBy = filters.SortBy + } + + order := "DESC" + if filters.SortOrder == "asc" { + order = "ASC" + } + + query += fmt.Sprintf(" ORDER BY %s %s", orderBy, order) + + // Add pagination + if filters.Limit > 0 { + query += " LIMIT ?" + args = append(args, filters.Limit) + + if filters.Offset > 0 { + query += " OFFSET ?" + args = append(args, filters.Offset) + } + } + + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var activities []Activity + for rows.Next() { + var a Activity + var startTime, createdAt, lastSync string + + err := rows.Scan( + &a.ID, &a.ActivityID, &startTime, &a.ActivityType, + &a.Duration, &a.Distance, &a.MaxHeartRate, &a.AvgHeartRate, + &a.AvgPower, &a.Calories, &a.Filename, &a.FileType, + &a.FileSize, &a.Downloaded, &createdAt, &lastSync, + ) + if err != nil { + return nil, err + } + + // Parse times + a.StartTime, _ = time.Parse("2006-01-02 15:04:05", startTime) + a.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + a.LastSync, _ = time.Parse("2006-01-02 15:04:05", lastSync) + + activities = append(activities, a) + } + + return activities, nil +} + +func (s *SQLiteDB) Close() error { + return s.db.Close() +} +``` + +### 2.3 Initialize Database Function +```go +// main.go - Database initialization +func initDatabase() (*sql.DB, error) { + // Get data directory from environment or use default + dataDir := os.Getenv("DATA_DIR") + if dataDir == "" { + dataDir = "./data" + } + + // Create data directory if it doesn't exist + if err := os.MkdirAll(dataDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create data directory: %v", err) + } + + dbPath := filepath.Join(dataDir, "garmin.db") + + // Initialize SQLite database + db, err := database.NewSQLiteDB(dbPath) + if err != nil { + return nil, fmt.Errorf("failed to initialize database: %v", err) + } + + return db.db, nil // Return the underlying *sql.DB +} +``` + +--- + +## Phase 3: Garmin API Client (Week 2) + +### 3.1 Garmin Client Interface +```go +// internal/garmin/client.go +package garmin + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +type Client struct { + httpClient *http.Client + baseURL string + session *Session +} + +type Session struct { + Username string + Password string + Cookies []*http.Cookie + UserAgent string + Authenticated bool +} + +type GarminActivity struct { + ActivityID int `json:"activityId"` + ActivityName string `json:"activityName"` + StartTimeLocal string `json:"startTimeLocal"` + ActivityType map[string]interface{} `json:"activityType"` + Distance float64 `json:"distance"` + Duration float64 `json:"duration"` + MaxHR int `json:"maxHR"` + AvgHR int `json:"avgHR"` + AvgPower float64 `json:"avgPower"` + Calories int `json:"calories"` +} + +func NewClient() *Client { + return &Client{ + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + baseURL: "https://connect.garmin.com", + session: &Session{ + Username: os.Getenv("GARMIN_EMAIL"), + Password: os.Getenv("GARMIN_PASSWORD"), + UserAgent: "GarminSync/1.0", + }, + } +} + +func (c *Client) Login() error { + if c.session.Username == "" || c.session.Password == "" { + return fmt.Errorf("GARMIN_EMAIL and GARMIN_PASSWORD environment variables required") + } + + // Step 1: Get login form + loginURL := c.baseURL + "/signin" + req, err := http.NewRequest("GET", loginURL, nil) + if err != nil { + return err + } + + req.Header.Set("User-Agent", c.session.UserAgent) + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + // Extract cookies + c.session.Cookies = resp.Cookies() + + // Step 2: Submit login credentials + loginData := url.Values{} + loginData.Set("username", c.session.Username) + loginData.Set("password", c.session.Password) + loginData.Set("embed", "true") + + req, err = http.NewRequest("POST", loginURL, strings.NewReader(loginData.Encode())) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", c.session.UserAgent) + + // Add cookies + for _, cookie := range c.session.Cookies { + req.AddCookie(cookie) + } + + resp, err = c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + // Check if login was successful + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("login failed with status: %d", resp.StatusCode) + } + + // Update cookies + for _, cookie := range resp.Cookies() { + c.session.Cookies = append(c.session.Cookies, cookie) + } + + c.session.Authenticated = true + return nil +} + +func (c *Client) GetActivities(start, limit int) ([]GarminActivity, error) { + if !c.session.Authenticated { + if err := c.Login(); err != nil { + return nil, err + } + } + + url := fmt.Sprintf("%s/modern/proxy/activitylist-service/activities/search/activities?start=%d&limit=%d", + c.baseURL, start, limit) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("User-Agent", c.session.UserAgent) + req.Header.Set("Accept", "application/json") + + // Add cookies + for _, cookie := range c.session.Cookies { + req.AddCookie(cookie) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get activities: status %d", resp.StatusCode) + } + + var activities []GarminActivity + if err := json.NewDecoder(resp.Body).Decode(&activities); err != nil { + return nil, err + } + + // Rate limiting + time.Sleep(2 * time.Second) + + return activities, nil +} + +func (c *Client) DownloadActivity(activityID int, format string) ([]byte, error) { + if !c.session.Authenticated { + if err := c.Login(); err != nil { + return nil, err + } + } + + // Default to FIT format + if format == "" { + format = "fit" + } + + url := fmt.Sprintf("%s/modern/proxy/download-service/export/%s/activity/%d", + c.baseURL, format, activityID) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("User-Agent", c.session.UserAgent) + + // Add cookies + for _, cookie := range c.session.Cookies { + req.AddCookie(cookie) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to download activity %d: status %d", activityID, resp.StatusCode) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + // Rate limiting + time.Sleep(2 * time.Second) + + return data, nil +} + +func (c *Client) GetActivityDetails(activityID int) (*GarminActivity, error) { + if !c.session.Authenticated { + if err := c.Login(); err != nil { + return nil, err + } + } + + url := fmt.Sprintf("%s/modern/proxy/activity-service/activity/%d", + c.baseURL, activityID) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("User-Agent", c.session.UserAgent) + req.Header.Set("Accept", "application/json") + + // Add cookies + for _, cookie := range c.session.Cookies { + req.AddCookie(cookie) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get activity details: status %d", resp.StatusCode) + } + + var activity GarminActivity + if err := json.NewDecoder(resp.Body).Decode(&activity); err != nil { + return nil, err + } + + // Rate limiting + time.Sleep(2 * time.Second) + + return &activity, nil +} +``` + +--- + +## Phase 4: File Parsing (Week 2-3) + +### 4.1 File Type Detection +```go +// internal/parser/detector.go +package parser + +import ( + "bytes" + "os" +) + +type FileType string + +const ( + FileTypeFIT FileType = "fit" + FileTypeTCX FileType = "tcx" + FileTypeGPX FileType = "gpx" + FileTypeUnknown FileType = "unknown" +) + +func DetectFileType(filepath string) (FileType, error) { + file, err := os.Open(filepath) + if err != nil { + return FileTypeUnknown, err + } + defer file.Close() + + // Read first 512 bytes for detection + header := make([]byte, 512) + n, err := file.Read(header) + if err != nil && n == 0 { + return FileTypeUnknown, err + } + + header = header[:n] + + return DetectFileTypeFromData(header), nil +} + +func DetectFileTypeFromData(data []byte) FileType { + // Check for FIT file signature + if len(data) >= 8 && bytes.Equal(data[8:12], []byte(".FIT")) { + return FileTypeFIT + } + + // Check for XML-based formats + if bytes.HasPrefix(data, []byte(" maxHR { + maxHR = lap.MaximumHeartRate.Value + } + + if lap.AverageHeartRate.Value > 0 { + hrValues = append(hrValues, lap.AverageHeartRate.Value) + } + + // Collect HR data from trackpoints + for _, tp := range lap.Track.Trackpoints { + if tp.HeartRateBpm.Value > 0 { + hrValues = append(hrValues, tp.HeartRateBpm.Value) + } + } + } + + metrics.Duration = int(totalDuration) + metrics.Distance = totalDistance + metrics.MaxHR = maxHR + metrics.Calories = totalCalories + + // Calculate average HR + if len(hrValues) > 0 { + sum := 0 + for _, hr := range hrValues { + sum += hr + } + metrics.AvgHR = sum / len(hrValues) + } + + return metrics, nil +} + +func mapTCXSportType(sport string) string { + switch sport { + case "Running": + return "running" + case "Biking": + return "cycling" + case "Swimming": + return "swimming" + default: + return "other" + } +} + +// GPX Parser Implementation +type GPXParser struct{} + +type GPX struct { + Tracks []GPXTrack `xml:"trk"` +} + +type GPXTrack struct { + Name string `xml:"name"` + Segments []GPXSegment `xml:"trkseg"` +} + +type GPXSegment struct { + Points []GPXPoint `xml:"trkpt"` +} + +type GPXPoint struct { + Lat float64 `xml:"lat,attr"` + Lon float64 `xml:"lon,attr"` + Elevation float64 `xml:"ele"` + Time string `xml:"time"` + HR int `xml:"extensions>TrackPointExtension>hr"` +} + +func (p *GPXParser) ParseFile(filepath string) (*ActivityMetrics, error) { + file, err := os.Open(filepath) + if err != nil { + return nil, err + } + defer file.Close() + + var gpx GPX + decoder := xml.NewDecoder(file) + if err := decoder.Decode(&gpx); err != nil { + return nil, err + } + + if len(gpx.Tracks) == 0 || len(gpx.Tracks[0].Segments) == 0 { + return nil, fmt.Errorf("no track data found") + } + + metrics := &ActivityMetrics{ + ActivityType: "other", // GPX doesn't specify activity type + } + + var allPoints []GPXPoint + for _, track := range gpx.Tracks { + for _, segment := range track.Segments { + allPoints = append(allPoints, segment.Points...) + } + } + + if len(allPoints) == 0 { + return nil, fmt.Errorf("no track points found") + } + + // Calculate metrics from points + var startTime, endTime time.Time + var totalDistance float64 + var hrValues []int + + for i, point := range allPoints { + // Parse time + if point.Time != "" { + if t, err := time.Parse(time.RFC3339, point.Time); err == nil { + if i == 0 { + startTime = t + metrics.StartTime = t + } + endTime = t + } + } + + // Calculate distance between consecutive points + if i > 0 { + prevPoint := allPoints[i-1] + distance := calculateDistance(prevPoint.Lat, prevPoint.Lon, point.Lat, point.Lon) + totalDistance += distance + } + + // Collect heart rate data + if point.HR > 0 { + hrValues = append(hrValues, point.HR) + } + } + + // Calculate duration + if !startTime.IsZero() && !endTime.IsZero() { + metrics.Duration = int(endTime.Sub(startTime).Seconds()) + } + + metrics.Distance = totalDistance + + // Calculate heart rate metrics + if len(hrValues) > 0 { + sum := 0 + maxHR := 0 + for _, hr := range hrValues { + sum += hr + if hr > maxHR { + maxHR = hr + } + } + metrics.AvgHR = sum / len(hrValues) + metrics.MaxHR = maxHR + } + + return metrics, nil +} + +// Haversine formula for distance calculation +func calculateDistance(lat1, lon1, lat2, lon2 float64) float64 { + const earthRadius = 6371000 // Earth's radius in meters + + dLat := (lat2 - lat1) * math.Pi / 180 + dLon := (lon2 - lon1) * math.Pi / 180 + + lat1Rad := lat1 * math.Pi / 180 + lat2Rad := lat2 * math.Pi / 180 + + a := math.Sin(dLat/2)*math.Sin(dLat/2) + + math.Cos(lat1Rad)*math.Cos(lat2Rad)*math.Sin(dLon/2)*math.Sin(dLon/2) + + c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) + + return earthRadius * c +} + +// FIT Parser Implementation (simplified - would use FIT SDK in real implementation) +type FITParser struct{} + +func (p *FITParser) ParseFile(filepath string) (*ActivityMetrics, error) { + // For now, return basic metrics - in real implementation, would use FIT SDK + // This is a placeholder that reads basic file info + + file, err := os.Open(filepath) + if err != nil { + return nil, err + } + defer file.Close() + + // Read FIT header to verify it's a valid FIT file + header := make([]byte, 14) + _, err = file.Read(header) + if err != nil { + return nil, err + } + + // Verify FIT signature + if !bytes.Equal(header[8:12], []byte(".FIT")) { + return nil, fmt.Errorf("invalid FIT file signature") + } + + // For now, return empty metrics - real implementation would parse FIT records + return &ActivityMetrics{ + ActivityType: "other", + // Additional parsing would happen here using FIT SDK + }, nil +} +``` + +--- + +## Phase 5: Web Server & API (Week 3-4) + +### 5.1 HTTP Routes Setup +```go +// internal/web/routes.go +package web + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/gorilla/mux" + "garminsync/internal/database" +) + +type Server struct { + db database.Database + router *mux.Router +} + +func NewServer(db database.Database) *Server { + s := &Server{ + db: db, + router: mux.NewRouter(), + } + + s.setupRoutes() + return s +} + +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 (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.router.ServeHTTP(w, r) +} + +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 (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 (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 (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 +} +``` + +### 5.2 Embedded HTML Template +```go +// internal/web/templates.go +package web + +func getEmbeddedHTML() string { + return ` + + + + + GarminSync + + + +
+
+

GarminSync Dashboard

+

Sync and manage your Garmin Connect activities

+
+ +
+
+ - + Total Activities +
+
+ - + Downloaded +
+
+ - + Missing +
+
+ - + Sync Progress +
+
+ +
+ + +
+ +
+
+

Recent Activities

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + +
DateTypeDurationDistanceAvg HRMax HRCaloriesStatus
Loading activities...
+ + +
+
+ + + +` +} +``` + +--- + +## Phase 6: Sync Engine & Integration (Week 4-5) + +### 6.1 Sync Service +```go +// internal/sync/service.go +package sync + +import ( + "fmt" + "log" + "os" + "path/filepath" + "time" + + "garminsync/internal/database" + "garminsync/internal/garmin" + "garminsync/internal/parser" +) + +type Service struct { + db database.Database + garmin *garmin.Client + dataDir string + isRunning bool + lastSync time.Time +} + +type SyncResult struct { + TotalActivities int + NewActivities int + DownloadedFiles int + UpdatedActivities int + Errors []string + Duration time.Duration +} + +func NewService(db database.Database, garminClient *garmin.Client) *Service { + dataDir := os.Getenv("DATA_DIR") + if dataDir == "" { + dataDir = "./data" + } + + return &Service{ + db: db, + garmin: garminClient, + dataDir: dataDir, + } +} + +func (s *Service) IsRunning() bool { + return s.isRunning +} + +func (s *Service) LastSync() time.Time { + return s.lastSync +} + +func (s *Service) SyncActivities() (*SyncResult, error) { + if s.isRunning { + return nil, fmt.Errorf("sync already in progress") + } + + s.isRunning = true + defer func() { s.isRunning = false }() + + startTime := time.Now() + result := &SyncResult{} + + log.Println("Starting activity sync...") + + // Step 1: Get activities from Garmin Connect + activities, err := s.garmin.GetActivities(0, 1000) // Get last 1000 activities + if err != nil { + return nil, fmt.Errorf("failed to get activities from Garmin: %v", err) + } + + result.TotalActivities = len(activities) + log.Printf("Retrieved %d activities from Garmin Connect", len(activities)) + + // Step 2: Process each activity + for _, garminActivity := range activities { + if err := s.processActivity(garminActivity, result); err != nil { + result.Errors = append(result.Errors, + fmt.Sprintf("Activity %d: %v", garminActivity.ActivityID, err)) + } + } + + // Step 3: Download missing files + if err := s.downloadMissingFiles(result); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("Download phase: %v", err)) + } + + result.Duration = time.Since(startTime) + s.lastSync = time.Now() + + log.Printf("Sync completed in %v. New: %d, Downloaded: %d, Updated: %d, Errors: %d", + result.Duration, result.NewActivities, result.DownloadedFiles, + result.UpdatedActivities, len(result.Errors)) + + return result, nil +} + +func (s *Service) processActivity(garminActivity garmin.GarminActivity, result *SyncResult) error { + // Check if activity already exists + existing, err := s.db.GetActivity(garminActivity.ActivityID) + if err != nil && err.Error() != "activity not found" { // Assuming this error message + return err + } + + var dbActivity *database.Activity + + if existing == nil { + // Create new activity + startTime, err := time.Parse("2006-01-02 15:04:05", garminActivity.StartTimeLocal) + if err != nil { + startTime = time.Now() // Fallback + } + + dbActivity = &database.Activity{ + ActivityID: garminActivity.ActivityID, + StartTime: startTime, + ActivityType: s.mapActivityType(garminActivity.ActivityType), + Duration: int(garminActivity.Duration), + Distance: garminActivity.Distance, + MaxHeartRate: garminActivity.MaxHR, + AvgHeartRate: garminActivity.AvgHR, + AvgPower: garminActivity.AvgPower, + Calories: garminActivity.Calories, + Downloaded: false, + CreatedAt: time.Now(), + LastSync: time.Now(), + } + + if err := s.db.CreateActivity(dbActivity); err != nil { + return err + } + + result.NewActivities++ + + } else { + // Update existing activity if data has changed + dbActivity = existing + updated := false + + if dbActivity.ActivityType != s.mapActivityType(garminActivity.ActivityType) { + dbActivity.ActivityType = s.mapActivityType(garminActivity.ActivityType) + updated = true + } + + if dbActivity.Duration != int(garminActivity.Duration) { + dbActivity.Duration = int(garminActivity.Duration) + updated = true + } + + // Update other fields as needed... + + if updated { + dbActivity.LastSync = time.Now() + if err := s.db.UpdateActivity(dbActivity); err != nil { + return err + } + result.UpdatedActivities++ + } + } + + return nil +} + +func (s *Service) downloadMissingFiles(result *SyncResult) error { + // Get activities that haven't been downloaded + filters := database.ActivityFilters{ + Downloaded: boolPtr(false), + Limit: 100, // Process in batches + } + + missingActivities, err := s.db.FilterActivities(filters) + if err != nil { + return err + } + + log.Printf("Downloading %d missing activity files...", len(missingActivities)) + + for _, activity := range missingActivities { + if err := s.downloadActivityFile(&activity, result); err != nil { + result.Errors = append(result.Errors, + fmt.Sprintf("Download %d: %v", activity.ActivityID, err)) + continue + } + + result.DownloadedFiles++ + + // Rate limiting + time.Sleep(2 * time.Second) + } + + return nil +} + +func (s *Service) downloadActivityFile(activity *database.Activity, result *SyncResult) error { + // Try to download FIT file first + data, err := s.garmin.DownloadActivity(activity.ActivityID, "fit") + if err != nil { + return fmt.Errorf("failed to download FIT file: %v", err) + } + + // Detect actual file type + fileType := parser.DetectFileTypeFromData(data) + + // Create organized directory structure + activityDir := filepath.Join(s.dataDir, "activities", + activity.StartTime.Format("2006"), activity.StartTime.Format("01")) + + if err := os.MkdirAll(activityDir, 0755); err != nil { + return fmt.Errorf("failed to create directory: %v", err) + } + + // Generate filename + extension := string(fileType) + if extension == "unknown" { + extension = "fit" // Default to FIT + } + + filename := fmt.Sprintf("activity_%d_%s.%s", + activity.ActivityID, + activity.StartTime.Format("20060102_150405"), + extension) + + filepath := filepath.Join(activityDir, filename) + + // Save file + if err := os.WriteFile(filepath, data, 0644); err != nil { + return fmt.Errorf("failed to save file: %v", err) + } + + // Parse file to get additional metrics + if err := s.parseAndUpdateActivity(activity, filepath, fileType); err != nil { + log.Printf("Warning: failed to parse file for activity %d: %v", + activity.ActivityID, err) + // Don't return error - file was saved successfully + } + + // Update database + activity.Filename = filepath + activity.FileType = string(fileType) + activity.FileSize = int64(len(data)) + activity.Downloaded = true + activity.LastSync = time.Now() + + return s.db.UpdateActivity(activity) +} + +func (s *Service) parseAndUpdateActivity(activity *database.Activity, filepath string, fileType parser.FileType) error { + parser := parser.NewParser(fileType) + if parser == nil { + return fmt.Errorf("no parser available for file type: %s", fileType) + } + + metrics, err := parser.ParseFile(filepath) + if err != nil { + return err + } + + // Update activity with parsed metrics (only if not already set) + if activity.ActivityType == "" && metrics.ActivityType != "" { + activity.ActivityType = metrics.ActivityType + } + + if activity.Duration == 0 && metrics.Duration > 0 { + activity.Duration = metrics.Duration + } + + if activity.Distance == 0 && metrics.Distance > 0 { + activity.Distance = metrics.Distance + } + + if activity.MaxHeartRate == 0 && metrics.MaxHR > 0 { + activity.MaxHeartRate = metrics.MaxHR + } + + if activity.AvgHeartRate == 0 && metrics.AvgHR > 0 { + activity.AvgHeartRate = metrics.AvgHR + } + + if activity.AvgPower == 0 && metrics.AvgPower > 0 { + activity.AvgPower = metrics.AvgPower + } + + if activity.Calories == 0 && metrics.Calories > 0 { + activity.Calories = metrics.Calories + } + + return nil +} + +func (s *Service) mapActivityType(activityType map[string]interface{}) string { + if activityType == nil { + return "other" + } + + if typeKey, ok := activityType["typeKey"].(string); ok { + return typeKey + } + + return "other" +} + +// Utility function +func boolPtr(b bool) *bool { + return &b +} +``` + +### 6.2 Update Main Application +```go +// main.go - Complete main application with sync integration +package main + +import ( + "context" + "database/sql" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + _ "github.com/mattn/go-sqlite3" + "github.com/robfig/cron/v3" + + "garminsync/internal/database" + "garminsync/internal/garmin" + "garminsync/internal/sync" + "garminsync/internal/web" +) + +type App struct { + db database.Database + cron *cron.Cron + server *http.Server + garmin *garmin.Client + syncSvc *sync.Service + shutdown chan os.Signal +} + +func main() { + log.Println("Starting GarminSync...") + + app := &App{ + shutdown: make(chan os.Signal, 1), + } + + // Initialize components + if err := app.init(); err != nil { + log.Fatal("Failed to initialize app:", err) + } + + // Start services + app.start() + + log.Println("GarminSync is running...") + log.Println("Web interface: http://localhost:8888") + log.Println("Press Ctrl+C to shutdown") + + // Wait for shutdown signal + signal.Notify(app.shutdown, os.Interrupt, syscall.SIGTERM) + <-app.shutdown + + // Graceful shutdown + app.stop() +} + +func (app *App) init() error { + var err error + + // Initialize database + app.db, err = database.NewSQLiteDB("./data/garmin.db") + if err != nil { + return err + } + + // Initialize Garmin client + app.garmin = garmin.NewClient() + + // Initialize sync service + app.syncSvc = sync.NewService(app.db, app.garmin) + + // Setup cron scheduler + app.cron = cron.New() + + // Setup HTTP server + webServer := web.NewServer(app.db) + + // Add sync endpoint to web server + app.setupSyncEndpoints(webServer) + + app.server = &http.Server{ + Addr: ":8888", + Handler: webServer, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + return nil +} + +func (app *App) setupSyncEndpoints(webServer *web.Server) { + // This would extend the web server with sync-specific endpoints + // For now, we'll handle it in the main sync trigger +} + +func (app *App) start() { + // Schedule hourly sync + app.cron.AddFunc("@hourly", func() { + log.Println("Starting scheduled sync...") + if result, err := app.syncSvc.SyncActivities(); err != nil { + log.Printf("Scheduled sync failed: %v", err) + } else { + log.Printf("Scheduled sync completed: %+v", result) + } + }) + app.cron.Start() + + // Start web server + go func() { + log.Printf("Web server starting on %s", app.server.Addr) + if err := app.server.ListenAndServe(); err != http.ErrServerClosed { + log.Printf("Web server error: %v", err) + } + }() + + // Perform initial sync if no activities exist + go func() { + time.Sleep(2 * time.Second) // Wait for server to start + + stats, err := app.db.GetStats() + if err != nil { + log.Printf("Failed to get stats: %v", err) + return + } + + if stats.Total == 0 { + log.Println("No activities found, performing initial sync...") + if result, err := app.syncSvc.SyncActivities(); err != nil { + log.Printf("Initial sync failed: %v", err) + } else { + log.Printf("Initial sync completed: %+v", result) + } + } + }() +} + +func (app *App) stop() { + log.Println("Shutting down GarminSync...") + + // Stop cron scheduler + if app.cron != nil { + app.cron.Stop() + } + + // Stop web server + if app.server != nil { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := app.server.Shutdown(ctx); err != nil { + log.Printf("Server shutdown error: %v", err) + } + } + + // Close database + if app.db != nil { + app.db.Close() + } + + log.Println("Shutdown complete") +} +``` + +--- + +## Phase 7: Build & Deployment (Week 5) + +### 7.1 Build Script +```bash +#!/bin/bash +# build.sh - Cross-platform build script + +APP_NAME="garminsync" +VERSION="1.0.0" +BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") +GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") + +# Build flags +LDFLAGS="-X main.version=${VERSION} -X main.buildTime=${BUILD_TIME} -X main.gitCommit=${GIT_COMMIT}" + +echo "Building GarminSync v${VERSION}..." + +# Create build directory +mkdir -p dist + +# Build for different platforms +platforms=( + "linux/amd64" + "linux/arm64" + "darwin/amd64" + "darwin/arm64" + "windows/amd64" +) + +for platform in "${platforms[@]}" +do + platform_split=(${platform//\// }) + GOOS=${platform_split[0]} + GOARCH=${platform_split[1]} + + output_name="${APP_NAME}-${GOOS}-${GOARCH}" + if [ $GOOS = "windows" ]; then + output_name+='.exe' + fi + + echo "Building for $GOOS/$GOARCH..." + + env CGO_ENABLED=1 GOOS=$GOOS GOARCH=$GOARCH go build \ + -ldflags="${LDFLAGS}" \ + -o "dist/${output_name}" \ + . + + if [ $? -ne 0 ]; then + echo "Build failed for $GOOS/$GOARCH" + exit 1 + fi +done + +echo "Build completed successfully!" +ls -la dist/ +``` + +### 7.2 Docker Support +```dockerfile +# Dockerfile - Multi-stage build for minimal image +FROM golang:1.21-alpine AS builder + +# Install build dependencies +RUN apk add --no-cache gcc musl-dev sqlite-dev + +WORKDIR /app + +# Copy go mod files +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build the application +RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o garminsync . + +# Final stage +FROM alpine:latest + +# Install runtime dependencies +RUN apk --no-cache add ca-certificates sqlite + +WORKDIR /app + +# Copy binary from builder +COPY --from=builder /app/garminsync . + +# Create data directory +RUN mkdir -p /data + +# Set environment variables +ENV DATA_DIR=/data +ENV GIN_MODE=release + +# Expose port +EXPOSE 8888 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8888/health || exit 1 + +# Run the application +CMD ["./garminsync"] +``` + +### 7.3 Docker Compose +```yaml +# docker-compose.yml - Single service deployment +version: '3.8' + +services: + garminsync: + build: . + container_name: garminsync + ports: + - "8888:8888" + environment: + - GARMIN_EMAIL=${GARMIN_EMAIL} + - GARMIN_PASSWORD=${GARMIN_PASSWORD} + - DATA_DIR=/data + volumes: + - ./data:/data + - /etc/localtime:/etc/localtime:ro + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8888/health"] + interval: 30s + timeout: 10s + retries: 3 + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" +``` + +### 7.4 Installation Script +```bash +#!/bin/bash +# install.sh - Simple installation script + +set -e + +APP_NAME="garminsync" +INSTALL_DIR="/usr/local/bin" +SERVICE_DIR="/etc/systemd/system" + +# Detect architecture +ARCH=$(uname -m) +case $ARCH in + x86_64) + ARCH="amd64" + ;; + aarch64|arm64) + ARCH="arm64" + ;; + *) + echo "Unsupported architecture: $ARCH" + exit 1 + ;; +esac + +# Detect OS +OS=$(uname -s | tr '[:upper:]' '[:lower:]') + +BINARY_NAME="${APP_NAME}-${OS}-${ARCH}" +DOWNLOAD_URL="https://github.com/yourusername/garminsync/releases/latest/download/${BINARY_NAME}" + +echo "Installing GarminSync for ${OS}/${ARCH}..." + +# Download binary +echo "Downloading ${BINARY_NAME}..." +curl -L -o "/tmp/${BINARY_NAME}" "$DOWNLOAD_URL" +chmod +x "/tmp/${BINARY_NAME}" + +# Install binary +echo "Installing to ${INSTALL_DIR}..." +sudo mv "/tmp/${BINARY_NAME}" "${INSTALL_DIR}/${APP_NAME}" + +# Create data directory +sudo mkdir -p /var/lib/garminsync +sudo chown $USER:$USER /var/lib/garminsync + +# Create systemd service (Linux only) +if [ "$OS" = "linux" ] && [ -d "$SERVICE_DIR" ]; then + echo "Creating systemd service..." + + sudo tee "${SERVICE_DIR}/garminsync.service" > /dev/null <