added dockerfile
This commit is contained in:
parent
62944d6220
commit
85366993a7
19
Dockerfile
Normal file
19
Dockerfile
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
FROM debian:bookworm-slim
|
||||
|
||||
# (optional) install ca-certificates if needed
|
||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/fitness-app /app/fitness-app
|
||||
|
||||
# External data dir for sqlite db
|
||||
RUN mkdir -p /data
|
||||
|
||||
ENV DATABASE_URL=/data/fitness.db
|
||||
|
||||
# DO NOT switch user -> run as root
|
||||
EXPOSE 8080
|
||||
VOLUME ["/data"]
|
||||
|
||||
CMD ["/app/fitness-app"]
|
||||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
version: "3.9"
|
||||
|
||||
services:
|
||||
fitness:
|
||||
build: .
|
||||
container_name: fitness-tracker
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# DB file inside container
|
||||
- DATABASE_URL=/data/fitness.db
|
||||
volumes:
|
||||
# Host folder -> container /data
|
||||
# On host, your db will be: ./fitness-data/fitness.db
|
||||
- ./fitness-data:/data
|
||||
ports:
|
||||
# For quick testing; in production you'd put Traefik in front instead
|
||||
- "8080:8080"
|
||||
571
main.go
571
main.go
|
|
@ -6,6 +6,7 @@ import (
|
|||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
|
|
@ -13,12 +14,12 @@ import (
|
|||
)
|
||||
|
||||
var (
|
||||
db *sql.DB
|
||||
indexTmpl *template.Template
|
||||
listenAddr = ":8080"
|
||||
databaseFile = "fitness.db"
|
||||
db *sql.DB
|
||||
indexTmpl *template.Template
|
||||
listenAddr = ":8080"
|
||||
)
|
||||
|
||||
// A single set row for display
|
||||
type SetRow struct {
|
||||
ID int
|
||||
Exercise string
|
||||
|
|
@ -27,11 +28,41 @@ type SetRow struct {
|
|||
StartedAt time.Time
|
||||
}
|
||||
|
||||
// Workout summary for history view
|
||||
type WorkoutSummary struct {
|
||||
ID int
|
||||
StartedAt time.Time
|
||||
EndedAt *time.Time
|
||||
Duration time.Duration
|
||||
SetCount int
|
||||
Volume float64
|
||||
TotalRest time.Duration
|
||||
HasEnded bool
|
||||
DurationHuman string
|
||||
RestHuman string
|
||||
Template string
|
||||
}
|
||||
|
||||
// Internal workout structure
|
||||
type Workout struct {
|
||||
ID int
|
||||
StartedAt time.Time
|
||||
EndedAt *time.Time
|
||||
Template string
|
||||
}
|
||||
|
||||
func main() {
|
||||
var err error
|
||||
|
||||
// --- DB path from env: DATABASE_URL (fallback: fitness.db) ---
|
||||
dbPath := os.Getenv("DATABASE_URL")
|
||||
if dbPath == "" {
|
||||
dbPath = "fitness.db"
|
||||
}
|
||||
log.Printf("Using database file: %s", dbPath)
|
||||
|
||||
// Open SQLite DB
|
||||
db, err = sql.Open("sqlite3", databaseFile)
|
||||
db, err = sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
log.Fatal("open db:", err)
|
||||
}
|
||||
|
|
@ -44,16 +75,19 @@ func main() {
|
|||
// Parse template with helper funcs
|
||||
indexTmpl = template.Must(template.New("index").Funcs(template.FuncMap{
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"eq": func(a, b interface{}) bool { return a == b },
|
||||
}).Parse(indexHTML))
|
||||
|
||||
// Routes
|
||||
http.HandleFunc("/", handleIndex)
|
||||
http.HandleFunc("/start", handleStartWorkout)
|
||||
http.HandleFunc("/finish", handleFinishWorkout)
|
||||
http.HandleFunc("/add-set", handleAddSet)
|
||||
|
||||
// APIs
|
||||
http.HandleFunc("/api/rest-times", handleRestTimes)
|
||||
http.HandleFunc("/api/volume", handleVolumeStats)
|
||||
http.HandleFunc("/api/last-set", handleLastSet)
|
||||
|
||||
log.Printf("Listening on %s ...", listenAddr)
|
||||
if err := http.ListenAndServe(listenAddr, nil); err != nil {
|
||||
|
|
@ -62,7 +96,7 @@ func main() {
|
|||
}
|
||||
|
||||
func initDB() error {
|
||||
// Very simple schema
|
||||
// Migration-friendly: create basic tables, then ALTER for new columns.
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS workouts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
|
@ -79,36 +113,106 @@ CREATE TABLE IF NOT EXISTS sets (
|
|||
FOREIGN KEY(workout_id) REFERENCES workouts(id)
|
||||
);
|
||||
`
|
||||
_, err := db.Exec(schema)
|
||||
return err
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add new columns if they don't exist (ignore errors if already there).
|
||||
_, _ = db.Exec(`ALTER TABLE workouts ADD COLUMN ended_at TEXT`)
|
||||
_, _ = db.Exec(`ALTER TABLE workouts ADD COLUMN template TEXT`)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
latestID, err := getLatestWorkoutID()
|
||||
// Current / latest workout
|
||||
latest, err := getLatestWorkout()
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var sets []SetRow
|
||||
if latestID != 0 {
|
||||
sets, err = getSetsForWorkout(latestID)
|
||||
if latest != nil {
|
||||
sets, err = getSetsForWorkout(latest.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Date filters for history
|
||||
fromStr := r.URL.Query().Get("from")
|
||||
toStr := r.URL.Query().Get("to")
|
||||
|
||||
var from, to *time.Time
|
||||
if fromStr != "" {
|
||||
t, err := time.Parse("2006-01-02", fromStr)
|
||||
if err == nil {
|
||||
t0 := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local)
|
||||
from = &t0
|
||||
}
|
||||
}
|
||||
if toStr != "" {
|
||||
t, err := time.Parse("2006-01-02", toStr)
|
||||
if err == nil {
|
||||
t1 := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local).AddDate(0, 0, 1)
|
||||
to = &t1
|
||||
}
|
||||
}
|
||||
|
||||
workouts, err := getWorkoutSummaries(from, to)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := struct {
|
||||
HasWorkout bool
|
||||
WorkoutID int
|
||||
Sets []SetRow
|
||||
HasWorkout bool
|
||||
WorkoutID int
|
||||
WorkoutActive bool
|
||||
WorkoutStart *time.Time
|
||||
WorkoutEnd *time.Time
|
||||
WorkoutTemplate string
|
||||
Sets []SetRow
|
||||
|
||||
From string
|
||||
To string
|
||||
|
||||
Workouts []WorkoutSummary
|
||||
}{
|
||||
HasWorkout: latestID != 0,
|
||||
WorkoutID: latestID,
|
||||
Sets: sets,
|
||||
HasWorkout: latest != nil,
|
||||
WorkoutID: func() int {
|
||||
if latest != nil {
|
||||
return latest.ID
|
||||
}
|
||||
return 0
|
||||
}(),
|
||||
WorkoutActive: latest != nil && latest.EndedAt == nil,
|
||||
WorkoutStart: func() *time.Time {
|
||||
if latest != nil {
|
||||
return &latest.StartedAt
|
||||
}
|
||||
return nil
|
||||
}(),
|
||||
WorkoutEnd: func() *time.Time {
|
||||
if latest != nil {
|
||||
return latest.EndedAt
|
||||
}
|
||||
return nil
|
||||
}(),
|
||||
WorkoutTemplate: func() string {
|
||||
if latest != nil {
|
||||
return latest.Template
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
Sets: sets,
|
||||
From: fromStr,
|
||||
To: toStr,
|
||||
Workouts: workouts,
|
||||
}
|
||||
|
||||
if err := indexTmpl.Execute(w, data); err != nil {
|
||||
|
|
@ -122,8 +226,9 @@ func handleStartWorkout(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
templateVal := r.FormValue("template") // "" / "A" / "B"
|
||||
|
||||
_, err := db.Exec(`INSERT INTO workouts (started_at) VALUES (?)`, now)
|
||||
_, err := db.Exec(`INSERT INTO workouts (started_at, template) VALUES (?, ?)`, now, templateVal)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -132,6 +237,28 @@ func handleStartWorkout(w http.ResponseWriter, r *http.Request) {
|
|||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func handleFinishWorkout(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// Find latest workout that is not finished
|
||||
row := db.QueryRow(`SELECT id FROM workouts WHERE ended_at IS NULL ORDER BY id DESC LIMIT 1`)
|
||||
var id int
|
||||
if err := row.Scan(&id); err != nil {
|
||||
// no active workout; just return
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := db.Exec(`UPDATE workouts SET ended_at = ? WHERE id = ?`, now, id)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func handleAddSet(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
|
|
@ -139,13 +266,13 @@ func handleAddSet(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
// Always attach to latest workout
|
||||
workoutID, err := getLatestWorkoutID()
|
||||
if err != nil || workoutID == 0 {
|
||||
latest, err := getLatestWorkout()
|
||||
if err != nil || latest == nil {
|
||||
http.Error(w, "no active workout", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// NEW: exercise from dropdown + optional custom
|
||||
// exercise from dropdown + optional custom
|
||||
selected := r.FormValue("exercise_select")
|
||||
exerciseCustom := r.FormValue("exercise_custom")
|
||||
|
||||
|
|
@ -179,7 +306,7 @@ func handleAddSet(w http.ResponseWriter, r *http.Request) {
|
|||
_, err = db.Exec(
|
||||
`INSERT INTO sets (workout_id, exercise, reps, weight, started_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
workoutID, exercise, reps, weight, now,
|
||||
latest.ID, exercise, reps, weight, now,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
|
|
@ -192,15 +319,15 @@ func handleAddSet(w http.ResponseWriter, r *http.Request) {
|
|||
// --- API: rest times between sets (latest workout) ---
|
||||
|
||||
func handleRestTimes(w http.ResponseWriter, r *http.Request) {
|
||||
workoutID, err := getLatestWorkoutID()
|
||||
if err != nil || workoutID == 0 {
|
||||
latest, err := getLatestWorkout()
|
||||
if err != nil || latest == nil {
|
||||
http.Error(w, "no workout", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.Query(
|
||||
`SELECT id, started_at FROM sets WHERE workout_id = ? ORDER BY started_at`,
|
||||
workoutID,
|
||||
latest.ID,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
|
|
@ -276,7 +403,7 @@ func handleVolumeStats(w http.ResponseWriter, r *http.Request) {
|
|||
weekday = 7 // Sunday
|
||||
}
|
||||
curStart = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).
|
||||
AddDate(0, 0, -(weekday - 1))
|
||||
AddDate(0, 0, -(weekday-1))
|
||||
curEnd = curStart.AddDate(0, 0, 7)
|
||||
case "month":
|
||||
curStart = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
|
|
@ -331,6 +458,45 @@ func handleVolumeStats(w http.ResponseWriter, r *http.Request) {
|
|||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// --- API: last set for exercise (for autofill) ---
|
||||
|
||||
func handleLastSet(w http.ResponseWriter, r *http.Request) {
|
||||
ex := r.URL.Query().Get("exercise")
|
||||
if ex == "" {
|
||||
http.Error(w, "missing exercise", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
row := db.QueryRow(`SELECT reps, weight FROM sets WHERE exercise = ? ORDER BY started_at DESC LIMIT 1`, ex)
|
||||
var reps int
|
||||
var weight float64
|
||||
err := row.Scan(&reps, &weight)
|
||||
if err == sql.ErrNoRows {
|
||||
resp := struct {
|
||||
Found bool `json:"found"`
|
||||
}{Found: false}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
resp := struct {
|
||||
Found bool `json:"found"`
|
||||
Reps int `json:"reps"`
|
||||
Weight float64 `json:"weight"`
|
||||
}{
|
||||
Found: true,
|
||||
Reps: reps,
|
||||
Weight: weight,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func queryVolume(start, end time.Time) (float64, error) {
|
||||
// Sum of (reps * weight) for sets whose started_at is in [start, end)
|
||||
startStr := start.UTC().Format(time.RFC3339)
|
||||
|
|
@ -349,16 +515,38 @@ func queryVolume(start, end time.Time) (float64, error) {
|
|||
return vol, nil
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
// --- Helpers & summaries ---
|
||||
|
||||
func getLatestWorkoutID() (int, error) {
|
||||
row := db.QueryRow(`SELECT id FROM workouts ORDER BY id DESC LIMIT 1`)
|
||||
func getLatestWorkout() (*Workout, error) {
|
||||
row := db.QueryRow(`SELECT id, started_at, ended_at, template FROM workouts ORDER BY id DESC LIMIT 1`)
|
||||
var id int
|
||||
err := row.Scan(&id)
|
||||
var startedStr string
|
||||
var endedStr sql.NullString
|
||||
var templateStr sql.NullString
|
||||
|
||||
err := row.Scan(&id, &startedStr, &endedStr, &templateStr)
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, nil
|
||||
return nil, nil
|
||||
}
|
||||
return id, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
started, err := time.Parse(time.RFC3339, startedStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ended *time.Time
|
||||
if endedStr.Valid && endedStr.String != "" {
|
||||
t, err := time.Parse(time.RFC3339, endedStr.String)
|
||||
if err == nil {
|
||||
ended = &t
|
||||
}
|
||||
}
|
||||
tmpl := ""
|
||||
if templateStr.Valid {
|
||||
tmpl = templateStr.String
|
||||
}
|
||||
return &Workout{ID: id, StartedAt: started, EndedAt: ended, Template: tmpl}, nil
|
||||
}
|
||||
|
||||
func getSetsForWorkout(workoutID int) ([]SetRow, error) {
|
||||
|
|
@ -391,6 +579,147 @@ func getSetsForWorkout(workoutID int) ([]SetRow, error) {
|
|||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Get workout summaries for history, optional date filter
|
||||
func getWorkoutSummaries(from, to *time.Time) ([]WorkoutSummary, error) {
|
||||
query := `SELECT id, started_at, ended_at, template FROM workouts`
|
||||
var args []interface{}
|
||||
if from != nil || to != nil {
|
||||
query += " WHERE"
|
||||
first := true
|
||||
if from != nil {
|
||||
query += " started_at >= ?"
|
||||
args = append(args, from.UTC().Format(time.RFC3339))
|
||||
first = false
|
||||
}
|
||||
if to != nil {
|
||||
if !first {
|
||||
query += " AND"
|
||||
}
|
||||
query += " started_at < ?"
|
||||
args = append(args, to.UTC().Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
query += " ORDER BY started_at DESC LIMIT 50"
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var res []WorkoutSummary
|
||||
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var startedStr string
|
||||
var endedStr sql.NullString
|
||||
var templateStr sql.NullString
|
||||
|
||||
if err := rows.Scan(&id, &startedStr, &endedStr, &templateStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
started, err := time.Parse(time.RFC3339, startedStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ended *time.Time
|
||||
hasEnded := false
|
||||
if endedStr.Valid && endedStr.String != "" {
|
||||
t, err := time.Parse(time.RFC3339, endedStr.String)
|
||||
if err == nil {
|
||||
ended = &t
|
||||
hasEnded = true
|
||||
}
|
||||
}
|
||||
tmpl := ""
|
||||
if templateStr.Valid {
|
||||
tmpl = templateStr.String
|
||||
}
|
||||
|
||||
// Stats: sets, volume, total rest
|
||||
rows2, err := db.Query(`SELECT reps, weight, started_at FROM sets WHERE workout_id = ? ORDER BY started_at`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type setData struct {
|
||||
Reps int
|
||||
Weight float64
|
||||
StartedAt time.Time
|
||||
}
|
||||
var sd []setData
|
||||
for rows2.Next() {
|
||||
var r int
|
||||
var w float64
|
||||
var stStr string
|
||||
if err := rows2.Scan(&r, &w, &stStr); err != nil {
|
||||
rows2.Close()
|
||||
return nil, err
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, stStr)
|
||||
if err != nil {
|
||||
rows2.Close()
|
||||
return nil, err
|
||||
}
|
||||
sd = append(sd, setData{Reps: r, Weight: w, StartedAt: t})
|
||||
}
|
||||
rows2.Close()
|
||||
|
||||
setCount := len(sd)
|
||||
var volume float64
|
||||
var rest time.Duration
|
||||
for i, s := range sd {
|
||||
volume += float64(s.Reps) * s.Weight
|
||||
if i > 0 {
|
||||
d := s.StartedAt.Sub(sd[i-1].StartedAt)
|
||||
if d > 0 {
|
||||
rest += d
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var duration time.Duration
|
||||
if ended != nil {
|
||||
duration = ended.Sub(started)
|
||||
} else {
|
||||
duration = 0
|
||||
}
|
||||
|
||||
res = append(res, WorkoutSummary{
|
||||
ID: id,
|
||||
StartedAt: started,
|
||||
EndedAt: ended,
|
||||
Duration: duration,
|
||||
SetCount: setCount,
|
||||
Volume: volume,
|
||||
TotalRest: rest,
|
||||
HasEnded: hasEnded,
|
||||
DurationHuman: formatDuration(duration),
|
||||
RestHuman: formatDuration(rest),
|
||||
Template: tmpl,
|
||||
})
|
||||
}
|
||||
return res, rows.Err()
|
||||
}
|
||||
|
||||
// simple duration formatter: 1h 23m, 5m 10s, etc.
|
||||
func formatDuration(d time.Duration) string {
|
||||
if d <= 0 {
|
||||
return "-"
|
||||
}
|
||||
sec := int(d.Seconds())
|
||||
h := sec / 3600
|
||||
sec = sec % 3600
|
||||
m := sec / 60
|
||||
s := sec % 60
|
||||
if h > 0 {
|
||||
return strconv.Itoa(h) + "h " + strconv.Itoa(m) + "m"
|
||||
}
|
||||
if m > 0 {
|
||||
return strconv.Itoa(m) + "m " + strconv.Itoa(s) + "s"
|
||||
}
|
||||
return strconv.Itoa(s) + "s"
|
||||
}
|
||||
|
||||
// --- HTML template (single page) ---
|
||||
|
||||
const indexHTML = `
|
||||
|
|
@ -403,7 +732,7 @@ const indexHTML = `
|
|||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
max-width: 900px;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
background: #111827;
|
||||
|
|
@ -419,6 +748,7 @@ const indexHTML = `
|
|||
label { display:block; margin-top: 0.5rem; }
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
input[type="date"],
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.5rem;
|
||||
|
|
@ -447,7 +777,7 @@ const indexHTML = `
|
|||
padding: 0.4rem 0.3rem;
|
||||
text-align: left;
|
||||
}
|
||||
th { font-size: 0.85rem; text-transform: uppercase; color:#9ca3af; }
|
||||
th { font-size: 0.80rem; text-transform: uppercase; color:#9ca3af; }
|
||||
canvas {
|
||||
max-width: 100%;
|
||||
background: #111827;
|
||||
|
|
@ -463,13 +793,18 @@ const indexHTML = `
|
|||
color:#9ca3af;
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
.row-narrow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
@media (max-width: 900px) {
|
||||
.row {
|
||||
grid-template-columns: 1.1fr 0.9fr;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.flex-row {
|
||||
|
|
@ -484,34 +819,90 @@ const indexHTML = `
|
|||
font-size:0.8rem;
|
||||
color:#9ca3af;
|
||||
}
|
||||
.tag {
|
||||
display:inline-block;
|
||||
padding:0.1rem 0.4rem;
|
||||
border-radius:999px;
|
||||
font-size:0.7rem;
|
||||
background:#111827;
|
||||
color:#9ca3af;
|
||||
cursor:pointer;
|
||||
}
|
||||
.tag:hover {
|
||||
background:#374151;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Minimal Fitness Tracker</h1>
|
||||
<p style="color:#9ca3af;">Simple self-hosted Go app – 5×5 friendly: pick standard lifts, track volume and rest.</p>
|
||||
<p style="color:#9ca3af;">Self-hosted 5×5 log – A/B templates, volume, rest, and history.</p>
|
||||
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="card">
|
||||
<h2>Workout</h2>
|
||||
<h2>Workout control</h2>
|
||||
{{if .HasWorkout}}
|
||||
<p>Current workout ID: <span class="badge">{{.WorkoutID}}</span></p>
|
||||
<p>
|
||||
Current workout ID:
|
||||
<span class="badge">{{.WorkoutID}}</span><br>
|
||||
<span class="small">
|
||||
Started: {{if .WorkoutStart}}{{.WorkoutStart.Format "2006-01-02 15:04"}} UTC{{else}}-{{end}}<br>
|
||||
Ended: {{if .WorkoutEnd}}{{.WorkoutEnd.Format "2006-01-02 15:04"}} UTC{{else}}(active){{end}}<br>
|
||||
Template:
|
||||
{{if eq .WorkoutTemplate "A"}}
|
||||
<span class="tag">A: Squat / Bench / Row</span>
|
||||
{{else if eq .WorkoutTemplate "B"}}
|
||||
<span class="tag">B: Squat / OHP / Deadlift / Chin-up</span>
|
||||
{{else}}
|
||||
<span class="small">None</span>
|
||||
{{end}}
|
||||
</span>
|
||||
</p>
|
||||
{{else}}
|
||||
<p>No workout yet.</p>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/start">
|
||||
<form method="POST" action="/start" style="margin-top:0.5rem;">
|
||||
<label>
|
||||
Start new workout with template
|
||||
<select name="template">
|
||||
<option value="">None</option>
|
||||
<option value="A">Workout A (Squat / Bench / Barbell Row)</option>
|
||||
<option value="B">Workout B (Squat / Overhead Press / Deadlift / Chin-up)</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Start new workout</button>
|
||||
</form>
|
||||
|
||||
{{if .WorkoutActive}}
|
||||
<form method="POST" action="/finish" style="display:inline-block;">
|
||||
<button type="submit" style="background:#ef4444;">Finish workout</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Add set</h2>
|
||||
{{if .HasWorkout}}
|
||||
{{if eq .WorkoutTemplate "A"}}
|
||||
<p class="small">
|
||||
Today: <span class="tag" onclick="setExercise('Squat')">Squat</span>
|
||||
<span class="tag" onclick="setExercise('Bench Press')">Bench</span>
|
||||
<span class="tag" onclick="setExercise('Barbell Row')">Row</span>
|
||||
</p>
|
||||
{{else if eq .WorkoutTemplate "B"}}
|
||||
<p class="small">
|
||||
Today: <span class="tag" onclick="setExercise('Squat')">Squat</span>
|
||||
<span class="tag" onclick="setExercise('Overhead Press')">OHP</span>
|
||||
<span class="tag" onclick="setExercise('Deadlift')">Deadlift</span>
|
||||
<span class="tag" onclick="setExercise('Chin-up')">Chin-up</span>
|
||||
</p>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/add-set">
|
||||
<label>
|
||||
Exercise (5×5 core lifts & friends)
|
||||
<select name="exercise_select" id="exercise_select" required onchange="toggleCustomExercise()">
|
||||
Exercise
|
||||
<select name="exercise_select" id="exercise_select" required onchange="toggleCustomExercise(); onExerciseChange();">
|
||||
<option value="Squat">Squat</option>
|
||||
<option value="Bench Press">Bench Press</option>
|
||||
<option value="Barbell Row">Barbell Row</option>
|
||||
|
|
@ -534,13 +925,14 @@ const indexHTML = `
|
|||
|
||||
<label>
|
||||
Reps
|
||||
<input type="number" name="reps" min="1" step="1" required>
|
||||
<input type="number" id="repsInput" name="reps" min="1" step="1" required>
|
||||
</label>
|
||||
<label>
|
||||
Weight (kg)
|
||||
<input type="number" name="weight" step="0.5" min="0" required>
|
||||
<input type="number" id="weightInput" name="weight" step="0.5" min="0" required>
|
||||
</label>
|
||||
<button type="submit">Save set</button>
|
||||
<button type="submit">Save set (end set)</button>
|
||||
<p class="small">Tip: the time you press “Save set” is used as the set time. Rest between sets is calculated automatically.</p>
|
||||
</form>
|
||||
{{else}}
|
||||
<p style="color:#fbbf24;">Start a workout first.</p>
|
||||
|
|
@ -599,6 +991,63 @@ const indexHTML = `
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row-narrow">
|
||||
<div class="card">
|
||||
<h2>Workout history & filter</h2>
|
||||
<form method="GET" action="/" class="flex-row" style="gap:0.75rem; flex-wrap:wrap;">
|
||||
<div style="flex:1; min-width:160px;">
|
||||
<label>From
|
||||
<input type="date" name="from" value="{{.From}}">
|
||||
</label>
|
||||
</div>
|
||||
<div style="flex:1; min-width:160px;">
|
||||
<label>To
|
||||
<input type="date" name="to" value="{{.To}}">
|
||||
</label>
|
||||
</div>
|
||||
<div style="align-self:flex-end;">
|
||||
<button type="submit">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{if .Workouts}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Date</th>
|
||||
<th>Start</th>
|
||||
<th>End</th>
|
||||
<th>Dur.</th>
|
||||
<th>Sets</th>
|
||||
<th>Volume</th>
|
||||
<th>Rest (approx.)</th>
|
||||
<th>Tpl</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Workouts}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td>{{.StartedAt.Format "2006-01-02"}}</td>
|
||||
<td>{{.StartedAt.Format "15:04"}}</td>
|
||||
<td>{{if .HasEnded}}{{.EndedAt.Format "15:04"}}{{else}}-{{end}}</td>
|
||||
<td>{{.DurationHuman}}</td>
|
||||
<td>{{.SetCount}}</td>
|
||||
<td>{{printf "%.0f" .Volume}}</td>
|
||||
<td>{{.RestHuman}}</td>
|
||||
<td>{{.Template}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="small">Rest time is estimated from time between sets (button presses).</p>
|
||||
{{else}}
|
||||
<p>No workouts logged yet.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart.js from CDN -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
|
|
@ -620,6 +1069,37 @@ const indexHTML = `
|
|||
}
|
||||
}
|
||||
|
||||
function setExercise(name) {
|
||||
const select = document.getElementById('exercise_select');
|
||||
select.value = name;
|
||||
toggleCustomExercise();
|
||||
onExerciseChange();
|
||||
}
|
||||
|
||||
function onExerciseChange() {
|
||||
const select = document.getElementById('exercise_select');
|
||||
if (!select || select.value === 'custom') return;
|
||||
loadLastSetForExercise(select.value);
|
||||
}
|
||||
|
||||
function loadLastSetForExercise(exName) {
|
||||
if (!exName || exName === 'custom') return;
|
||||
fetch('/api/last-set?exercise=' + encodeURIComponent(exName))
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (!data.found) return;
|
||||
const repsInput = document.getElementById('repsInput');
|
||||
const weightInput = document.getElementById('weightInput');
|
||||
if (repsInput && (!repsInput.value || repsInput.value === '0')) {
|
||||
repsInput.value = data.reps;
|
||||
}
|
||||
if (weightInput && (!weightInput.value || weightInput.value === '0')) {
|
||||
weightInput.value = data.weight;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function loadRestTimes() {
|
||||
fetch('/api/rest-times')
|
||||
.then(res => {
|
||||
|
|
@ -742,6 +1222,7 @@ const indexHTML = `
|
|||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
toggleCustomExercise();
|
||||
onExerciseChange();
|
||||
loadRestTimes();
|
||||
loadVolumeStats();
|
||||
});
|
||||
|
|
|
|||
14
readme.md
Normal file
14
readme.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
cd ~/Dokumenti/scripts/Fitnes-tracker
|
||||
|
||||
# Stop the container
|
||||
docker compose down
|
||||
|
||||
# Create the folder if it doesn't exist (safe to run even if it exists)
|
||||
mkdir -p fitness-data
|
||||
|
||||
# Make sure container user (uid 1000) can write there
|
||||
sudo chown -R 1000:1000 fitness-data
|
||||
sudo chmod 775 fitness-data
|
||||
|
||||
# Start again
|
||||
docker compose up
|
||||
Loading…
Reference in a new issue