Fitnes-tracker/main.go

752 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"database/sql"
"encoding/json"
"html/template"
"log"
"net/http"
"strconv"
"time"
_ "github.com/mattn/go-sqlite3"
)
var (
db *sql.DB
indexTmpl *template.Template
listenAddr = ":8080"
databaseFile = "fitness.db"
)
type SetRow struct {
ID int
Exercise string
Reps int
Weight float64
StartedAt time.Time
}
func main() {
var err error
// Open SQLite DB
db, err = sql.Open("sqlite3", databaseFile)
if err != nil {
log.Fatal("open db:", err)
}
defer db.Close()
if err := initDB(); err != nil {
log.Fatal("init db:", err)
}
// Parse template with helper funcs
indexTmpl = template.Must(template.New("index").Funcs(template.FuncMap{
"add": func(a, b int) int { return a + b },
}).Parse(indexHTML))
// Routes
http.HandleFunc("/", handleIndex)
http.HandleFunc("/start", handleStartWorkout)
http.HandleFunc("/add-set", handleAddSet)
// APIs
http.HandleFunc("/api/rest-times", handleRestTimes)
http.HandleFunc("/api/volume", handleVolumeStats)
log.Printf("Listening on %s ...", listenAddr)
if err := http.ListenAndServe(listenAddr, nil); err != nil {
log.Fatal(err)
}
}
func initDB() error {
// Very simple schema
schema := `
CREATE TABLE IF NOT EXISTS workouts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workout_id INTEGER NOT NULL,
exercise TEXT NOT NULL,
reps INTEGER NOT NULL,
weight REAL NOT NULL,
started_at TEXT NOT NULL,
FOREIGN KEY(workout_id) REFERENCES workouts(id)
);
`
_, err := db.Exec(schema)
return err
}
// --- Handlers ---
func handleIndex(w http.ResponseWriter, r *http.Request) {
latestID, err := getLatestWorkoutID()
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
var sets []SetRow
if latestID != 0 {
sets, err = getSetsForWorkout(latestID)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
}
data := struct {
HasWorkout bool
WorkoutID int
Sets []SetRow
}{
HasWorkout: latestID != 0,
WorkoutID: latestID,
Sets: sets,
}
if err := indexTmpl.Execute(w, data); err != nil {
log.Println("template error:", err)
}
}
func handleStartWorkout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
now := time.Now().UTC().Format(time.RFC3339)
_, err := db.Exec(`INSERT INTO workouts (started_at) VALUES (?)`, now)
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)
return
}
// Always attach to latest workout
workoutID, err := getLatestWorkoutID()
if err != nil || workoutID == 0 {
http.Error(w, "no active workout", http.StatusBadRequest)
return
}
// NEW: exercise from dropdown + optional custom
selected := r.FormValue("exercise_select")
exerciseCustom := r.FormValue("exercise_custom")
exercise := selected
if selected == "custom" {
exercise = exerciseCustom
}
repsStr := r.FormValue("reps")
weightStr := r.FormValue("weight")
if exercise == "" || repsStr == "" || weightStr == "" {
http.Error(w, "missing fields", http.StatusBadRequest)
return
}
reps, err := strconv.Atoi(repsStr)
if err != nil {
http.Error(w, "invalid reps", http.StatusBadRequest)
return
}
weight, err := strconv.ParseFloat(weightStr, 64)
if err != nil {
http.Error(w, "invalid weight", http.StatusBadRequest)
return
}
now := time.Now().UTC().Format(time.RFC3339)
_, err = db.Exec(
`INSERT INTO sets (workout_id, exercise, reps, weight, started_at)
VALUES (?, ?, ?, ?, ?)`,
workoutID, exercise, reps, weight, now,
)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// --- API: rest times between sets (latest workout) ---
func handleRestTimes(w http.ResponseWriter, r *http.Request) {
workoutID, err := getLatestWorkoutID()
if err != nil || workoutID == 0 {
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,
)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
defer rows.Close()
type simpleSet struct {
ID int
StartedAt time.Time
}
var sets []simpleSet
for rows.Next() {
var id int
var startedStr string
if err := rows.Scan(&id, &startedStr); err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
t, err := time.Parse(time.RFC3339, startedStr)
if err != nil {
http.Error(w, "time parse error", http.StatusInternalServerError)
return
}
sets = append(sets, simpleSet{ID: id, StartedAt: t})
}
if err := rows.Err(); err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
// Compute rest times (seconds) between sets
var labels []string
var data []float64
for i := 1; i < len(sets); i++ {
delta := sets[i].StartedAt.Sub(sets[i-1].StartedAt).Seconds()
if delta < 0 {
delta = 0
}
labels = append(labels, "Set "+strconv.Itoa(i+1))
data = append(data, delta)
}
resp := struct {
Labels []string `json:"labels"`
Data []float64 `json:"data"`
}{
Labels: labels,
Data: data,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
// --- API: volume stats (day / week / month) ---
func handleVolumeStats(w http.ResponseWriter, r *http.Request) {
rangeParam := r.URL.Query().Get("range")
if rangeParam == "" {
rangeParam = "day"
}
now := time.Now()
var curStart, curEnd time.Time
switch rangeParam {
case "week":
// Start of ISO week (Monday)
weekday := int(now.Weekday())
if weekday == 0 {
weekday = 7 // Sunday
}
curStart = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).
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())
curEnd = curStart.AddDate(0, 1, 0)
default: // "day"
curStart = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
curEnd = curStart.AddDate(0, 0, 1)
rangeParam = "day"
}
// Previous period of same length
period := curEnd.Sub(curStart)
prevEnd := curStart
prevStart := prevEnd.Add(-period)
currentVol, err := queryVolume(curStart, curEnd)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
prevVol, err := queryVolume(prevStart, prevEnd)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
percentChange := 0.0
hasPrevious := prevVol > 0
if hasPrevious {
percentChange = (currentVol - prevVol) / prevVol * 100.0
}
resp := struct {
Range string `json:"range"`
CurrentVolume float64 `json:"current_volume"`
PreviousVolume float64 `json:"previous_volume"`
PercentChange float64 `json:"percent_change"`
HasPrevious bool `json:"has_previous"`
CurrentLabel string `json:"current_label"`
PreviousLabel string `json:"previous_label"`
}{
Range: rangeParam,
CurrentVolume: currentVol,
PreviousVolume: prevVol,
PercentChange: percentChange,
HasPrevious: hasPrevious,
CurrentLabel: curStart.Format("2006-01-02"),
PreviousLabel: prevStart.Format("2006-01-02"),
}
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)
endStr := end.UTC().Format(time.RFC3339)
row := db.QueryRow(`
SELECT COALESCE(SUM(reps * weight), 0)
FROM sets
WHERE started_at >= ? AND started_at < ?
`, startStr, endStr)
var vol float64
if err := row.Scan(&vol); err != nil {
return 0, err
}
return vol, nil
}
// --- Helpers ---
func getLatestWorkoutID() (int, error) {
row := db.QueryRow(`SELECT id FROM workouts ORDER BY id DESC LIMIT 1`)
var id int
err := row.Scan(&id)
if err == sql.ErrNoRows {
return 0, nil
}
return id, err
}
func getSetsForWorkout(workoutID int) ([]SetRow, error) {
rows, err := db.Query(
`SELECT id, exercise, reps, weight, started_at
FROM sets
WHERE workout_id = ?
ORDER BY started_at`,
workoutID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []SetRow
for rows.Next() {
var s SetRow
var startedStr string
if err := rows.Scan(&s.ID, &s.Exercise, &s.Reps, &s.Weight, &startedStr); err != nil {
return nil, err
}
t, err := time.Parse(time.RFC3339, startedStr)
if err != nil {
return nil, err
}
s.StartedAt = t
out = append(out, s)
}
return out, rows.Err()
}
// --- HTML template (single page) ---
const indexHTML = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Minimal Fitness Tracker</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
max-width: 900px;
margin: 0 auto;
padding: 1rem;
background: #111827;
color: #e5e7eb;
}
h1 { margin-bottom: 0.5rem; }
.card {
background: #1f2937;
border-radius: 0.75rem;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
}
label { display:block; margin-top: 0.5rem; }
input[type="text"],
input[type="number"],
select {
width: 100%;
padding: 0.4rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid #374151;
background: #111827;
color: #e5e7eb;
}
button {
margin-top: 0.75rem;
padding: 0.4rem 0.9rem;
border-radius: 0.375rem;
border: none;
background: #3b82f6;
color: white;
cursor: pointer;
}
button:hover { background: #2563eb; }
table {
width: 100%;
border-collapse: collapse;
margin-top: 0.5rem;
}
th, td {
border-bottom: 1px solid #374151;
padding: 0.4rem 0.3rem;
text-align: left;
}
th { font-size: 0.85rem; text-transform: uppercase; color:#9ca3af; }
canvas {
max-width: 100%;
background: #111827;
border-radius: 0.75rem;
padding: 0.5rem;
}
.badge {
display:inline-block;
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
background:#111827;
color:#9ca3af;
}
.row {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@media (min-width: 768px) {
.row {
grid-template-columns: 1.1fr 0.9fr;
}
}
.flex-row {
display:flex;
gap:0.5rem;
align-items:center;
}
.flex-row input[type="text"] {
flex:1;
}
.small {
font-size:0.8rem;
color:#9ca3af;
}
</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>
<div class="row">
<div>
<div class="card">
<h2>Workout</h2>
{{if .HasWorkout}}
<p>Current workout ID: <span class="badge">{{.WorkoutID}}</span></p>
{{else}}
<p>No workout yet.</p>
{{end}}
<form method="POST" action="/start">
<button type="submit">Start new workout</button>
</form>
</div>
<div class="card">
<h2>Add set</h2>
{{if .HasWorkout}}
<form method="POST" action="/add-set">
<label>
Exercise (5×5 core lifts & friends)
<select name="exercise_select" id="exercise_select" required onchange="toggleCustomExercise()">
<option value="Squat">Squat</option>
<option value="Bench Press">Bench Press</option>
<option value="Barbell Row">Barbell Row</option>
<option value="Overhead Press">Overhead Press</option>
<option value="Deadlift">Deadlift</option>
<option value="Pull-up">Pull-up</option>
<option value="Chin-up">Chin-up</option>
<option value="Dip">Dip</option>
<option value="Barbell Curl">Barbell Curl</option>
<option value="Tricep Extension">Tricep Extension</option>
<option value="Face Pull">Face Pull</option>
<option value="Lat Pulldown">Lat Pulldown</option>
<option value="custom">Custom…</option>
</select>
</label>
<div class="flex-row small" id="customExerciseRow" style="margin-top:0.4rem; display:none;">
<span>Custom name:</span>
<input type="text" name="exercise_custom" id="exercise_custom" placeholder="Front Squat, Incline Bench…">
</div>
<label>
Reps
<input type="number" name="reps" min="1" step="1" required>
</label>
<label>
Weight (kg)
<input type="number" name="weight" step="0.5" min="0" required>
</label>
<button type="submit">Save set</button>
</form>
{{else}}
<p style="color:#fbbf24;">Start a workout first.</p>
{{end}}
</div>
<div class="card">
<h2>Sets in latest workout</h2>
{{if .Sets}}
<table>
<thead>
<tr>
<th>#</th>
<th>Exercise</th>
<th>Reps</th>
<th>Weight</th>
<th>Time (UTC)</th>
</tr>
</thead>
<tbody>
{{range $i, $s := .Sets}}
<tr>
<td>{{add $i 1}}</td>
<td>{{$s.Exercise}}</td>
<td>{{$s.Reps}}</td>
<td>{{$s.Weight}}</td>
<td>{{$s.StartedAt.Format "15:04:05"}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p>No sets logged yet.</p>
{{end}}
</div>
</div>
<div>
<div class="card">
<h2>Rest time between sets (latest workout)</h2>
<canvas id="restChart" height="120"></canvas>
<p id="restInfo" style="font-size:0.85rem; color:#9ca3af; margin-top:0.3rem;"></p>
</div>
<div class="card">
<h2>Training volume (Σ weight × reps)</h2>
<label for="rangeSelect">View range</label>
<select id="rangeSelect" onchange="loadVolumeStats()">
<option value="day">Today vs yesterday</option>
<option value="week">This week vs last week</option>
<option value="month">This month vs last month</option>
</select>
<canvas id="volumeChart" height="120" style="margin-top:0.5rem;"></canvas>
<p id="volumeInfo" style="font-size:0.85rem; color:#9ca3af; margin-top:0.3rem;"></p>
</div>
</div>
</div>
<!-- Chart.js from CDN -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
let restChartInstance = null;
let volumeChartInstance = null;
function toggleCustomExercise() {
const select = document.getElementById('exercise_select');
const row = document.getElementById('customExerciseRow');
const input = document.getElementById('exercise_custom');
if (select.value === 'custom') {
row.style.display = 'flex';
input.required = true;
} else {
row.style.display = 'none';
input.required = false;
input.value = '';
}
}
function loadRestTimes() {
fetch('/api/rest-times')
.then(res => {
if (!res.ok) {
throw new Error("No rest data yet");
}
return res.json();
})
.then(data => {
const ctx = document.getElementById('restChart').getContext('2d');
if (!data || !data.labels || data.labels.length === 0) {
document.getElementById('restInfo').textContent =
'Log at least two sets to see rest times.';
return;
}
if (restChartInstance) {
restChartInstance.destroy();
}
restChartInstance = new Chart(ctx, {
type: 'bar',
data: {
labels: data.labels,
datasets: [{
label: 'Rest (seconds)',
data: data.data
}]
},
options: {
scales: {
y: {
beginAtZero: true
}
}
}
});
const avg = data.data.reduce((a,b)=>a+b,0) / data.data.length;
document.getElementById('restInfo').textContent =
'Average rest: ' + avg.toFixed(1) + ' s';
})
.catch(err => {
document.getElementById('restInfo').textContent =
'No rest data yet add some sets.';
});
}
function loadVolumeStats() {
const range = document.getElementById('rangeSelect').value;
fetch('/api/volume?range=' + encodeURIComponent(range))
.then(res => {
if (!res.ok) {
throw new Error("No volume data");
}
return res.json();
})
.then(data => {
const ctx = document.getElementById('volumeChart').getContext('2d');
if (volumeChartInstance) {
volumeChartInstance.destroy();
}
volumeChartInstance = new Chart(ctx, {
type: 'bar',
data: {
labels: [data.previous_label, data.current_label],
datasets: [{
label: 'Volume (kg × reps)',
data: [data.previous_volume, data.current_volume]
}]
},
options: {
scales: {
y: {
beginAtZero: true
}
}
}
});
let text = '';
const current = data.current_volume;
const previous = data.previous_volume;
const prettyRange =
data.range === 'day' ? 'today' :
data.range === 'week' ? 'this week' :
'this month';
if (!data.has_previous) {
text = 'Total volume ' + prettyRange + ': ' + current.toFixed(1) +
' (no previous period logged).';
} else {
const change = data.percent_change;
let dir = 'no change';
let arrow = '';
if (change > 1e-6) {
dir = 'up';
arrow = '▲';
} else if (change < -1e-6) {
dir = 'down';
arrow = '▼';
}
text = 'Total volume ' + prettyRange + ': ' + current.toFixed(1) +
' (previous: ' + previous.toFixed(1) +
'). Change: ' + dir + ' ' + arrow + ' ' +
change.toFixed(1) + '%.';
}
document.getElementById('volumeInfo').textContent = text;
})
.catch(err => {
document.getElementById('volumeInfo').textContent =
'No volume data yet log some sets.';
});
}
document.addEventListener('DOMContentLoaded', () => {
toggleCustomExercise();
loadRestTimes();
loadVolumeStats();
});
</script>
</body>
</html>
`