add countdown -> and API for phone accessing
This commit is contained in:
parent
c7c422c31f
commit
796660c3cb
358
main.go
358
main.go
|
|
@ -1,3 +1,4 @@
|
|||
// main.go
|
||||
package main
|
||||
|
||||
import (
|
||||
|
|
@ -7,18 +8,22 @@ import (
|
|||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
var (
|
||||
db *sql.DB
|
||||
indexTmpl *template.Template
|
||||
db *sql.DB
|
||||
indexTmpl *template.Template
|
||||
listenAddr = ":8080"
|
||||
)
|
||||
|
||||
// --- Types ---
|
||||
|
||||
// A single set row for display
|
||||
type SetRow struct {
|
||||
ID int
|
||||
|
|
@ -51,6 +56,25 @@ type Workout struct {
|
|||
Template string
|
||||
}
|
||||
|
||||
// DTOs for API
|
||||
type WorkoutDTO struct {
|
||||
ID int `json:"id"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
EndedAt *time.Time `json:"ended_at,omitempty"`
|
||||
Template string `json:"template"`
|
||||
SetCount int `json:"set_count"`
|
||||
Volume float64 `json:"volume"`
|
||||
}
|
||||
|
||||
type SetDTO struct {
|
||||
ID int `json:"id"`
|
||||
WorkoutID int `json:"workout_id"`
|
||||
Exercise string `json:"exercise"`
|
||||
Reps int `json:"reps"`
|
||||
Weight float64 `json:"weight"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
var err error
|
||||
|
||||
|
|
@ -61,6 +85,13 @@ func main() {
|
|||
}
|
||||
log.Printf("Using database file: %s", dbPath)
|
||||
|
||||
// Ensure directory exists if path includes folder
|
||||
if dir := path.Dir(dbPath); dir != "." {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
log.Fatalf("failed to create db directory %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Open SQLite DB
|
||||
db, err = sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
|
|
@ -78,17 +109,22 @@ func main() {
|
|||
"eq": func(a, b interface{}) bool { return a == b },
|
||||
}).Parse(indexHTML))
|
||||
|
||||
// Routes
|
||||
// Routes (HTML)
|
||||
http.HandleFunc("/", handleIndex)
|
||||
http.HandleFunc("/start", handleStartWorkout)
|
||||
http.HandleFunc("/finish", handleFinishWorkout)
|
||||
http.HandleFunc("/add-set", handleAddSet)
|
||||
|
||||
// APIs
|
||||
// Existing APIs
|
||||
http.HandleFunc("/api/rest-times", handleRestTimes)
|
||||
http.HandleFunc("/api/volume", handleVolumeStats)
|
||||
http.HandleFunc("/api/last-set", handleLastSet)
|
||||
|
||||
// New JSON APIs for mobile app
|
||||
http.HandleFunc("/api/workouts", handleAPIWorkouts)
|
||||
http.HandleFunc("/api/workouts/", handleAPIWorkoutDetail) // /api/workouts/{id}
|
||||
http.HandleFunc("/api/sets", handleAPIAddSetJSON)
|
||||
|
||||
log.Printf("Listening on %s ...", listenAddr)
|
||||
if err := http.ListenAndServe(listenAddr, nil); err != nil {
|
||||
log.Fatal(err)
|
||||
|
|
@ -124,7 +160,7 @@ CREATE TABLE IF NOT EXISTS sets (
|
|||
return nil
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
// --- HTML Handlers ---
|
||||
|
||||
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
// Current / latest workout
|
||||
|
|
@ -170,13 +206,13 @@ func handleIndex(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
data := struct {
|
||||
HasWorkout bool
|
||||
WorkoutID int
|
||||
WorkoutActive bool
|
||||
WorkoutStart *time.Time
|
||||
WorkoutEnd *time.Time
|
||||
HasWorkout bool
|
||||
WorkoutID int
|
||||
WorkoutActive bool
|
||||
WorkoutStart *time.Time
|
||||
WorkoutEnd *time.Time
|
||||
WorkoutTemplate string
|
||||
Sets []SetRow
|
||||
Sets []SetRow
|
||||
|
||||
From string
|
||||
To string
|
||||
|
|
@ -458,7 +494,25 @@ func handleVolumeStats(w http.ResponseWriter, r *http.Request) {
|
|||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// --- API: last set for exercise (for autofill) ---
|
||||
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
|
||||
}
|
||||
|
||||
// --- API: last set for exercise (autofill) ---
|
||||
|
||||
func handleLastSet(w http.ResponseWriter, r *http.Request) {
|
||||
ex := r.URL.Query().Get("exercise")
|
||||
|
|
@ -497,22 +551,201 @@ func handleLastSet(w http.ResponseWriter, r *http.Request) {
|
|||
_ = 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)
|
||||
// --- JSON APIs for mobile app ---
|
||||
|
||||
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
|
||||
// /api/workouts
|
||||
// GET -> list latest workouts
|
||||
// POST -> start new workout { "template": "A" }
|
||||
func handleAPIWorkouts(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
workouts, err := getWorkoutSummaries(nil, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// convert to DTOs
|
||||
var out []WorkoutDTO
|
||||
for _, wsum := range workouts {
|
||||
out = append(out, WorkoutDTO{
|
||||
ID: wsum.ID,
|
||||
StartedAt: wsum.StartedAt,
|
||||
EndedAt: wsum.EndedAt,
|
||||
Template: wsum.Template,
|
||||
SetCount: wsum.SetCount,
|
||||
Volume: wsum.Volume,
|
||||
})
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
case http.MethodPost:
|
||||
var req struct {
|
||||
Template string `json:"template"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
res, err := db.Exec(`INSERT INTO workouts (started_at, template) VALUES (?, ?)`, now, req.Template)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
id64, _ := res.LastInsertId()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
ID int64 `json:"id"`
|
||||
}{ID: id64})
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
return vol, nil
|
||||
}
|
||||
|
||||
// /api/workouts/{id}
|
||||
func handleAPIWorkoutDetail(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
// path: /api/workouts/{id}
|
||||
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
||||
if len(parts) != 3 {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
idStr := parts[2]
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// get workout
|
||||
row := db.QueryRow(`SELECT id, started_at, ended_at, template FROM workouts WHERE id = ?`, id)
|
||||
var wid int
|
||||
var startedStr string
|
||||
var endedStr sql.NullString
|
||||
var templateStr sql.NullString
|
||||
if err := row.Scan(&wid, &startedStr, &endedStr, &templateStr); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
started, err := time.Parse(time.RFC3339, startedStr)
|
||||
if err != nil {
|
||||
http.Error(w, "time parse error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// sets
|
||||
setsRows, err := getSetsForWorkout(id)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var sets []SetDTO
|
||||
for _, s := range setsRows {
|
||||
sets = append(sets, SetDTO{
|
||||
ID: s.ID,
|
||||
WorkoutID: id,
|
||||
Exercise: s.Exercise,
|
||||
Reps: s.Reps,
|
||||
Weight: s.Weight,
|
||||
StartedAt: s.StartedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// volume
|
||||
var volume float64
|
||||
for _, s := range setsRows {
|
||||
volume += float64(s.Reps) * s.Weight
|
||||
}
|
||||
|
||||
resp := struct {
|
||||
Workout WorkoutDTO `json:"workout"`
|
||||
Sets []SetDTO `json:"sets"`
|
||||
}{
|
||||
Workout: WorkoutDTO{
|
||||
ID: wid,
|
||||
StartedAt: started,
|
||||
EndedAt: ended,
|
||||
Template: tmpl,
|
||||
SetCount: len(setsRows),
|
||||
Volume: volume,
|
||||
},
|
||||
Sets: sets,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// /api/sets (POST JSON) -> create set
|
||||
func handleAPIAddSetJSON(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
WorkoutID int `json:"workout_id"`
|
||||
Exercise string `json:"exercise"`
|
||||
Reps int `json:"reps"`
|
||||
Weight float64 `json:"weight"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// choose workout id: if 0 -> latest
|
||||
var workoutID int
|
||||
if req.WorkoutID == 0 {
|
||||
latest, err := getLatestWorkout()
|
||||
if err != nil || latest == nil {
|
||||
http.Error(w, "no active workout", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
workoutID = latest.ID
|
||||
} else {
|
||||
workoutID = req.WorkoutID
|
||||
}
|
||||
|
||||
if req.Exercise == "" || req.Reps <= 0 || req.Weight < 0 {
|
||||
http.Error(w, "invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
res, err := db.Exec(
|
||||
`INSERT INTO sets (workout_id, exercise, reps, weight, started_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
workoutID, req.Exercise, req.Reps, req.Weight, now,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
id64, _ := res.LastInsertId()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
ID int64 `json:"id"`
|
||||
}{ID: id64})
|
||||
}
|
||||
|
||||
// --- Helpers & summaries ---
|
||||
|
|
@ -720,7 +953,7 @@ func formatDuration(d time.Duration) string {
|
|||
return strconv.Itoa(s) + "s"
|
||||
}
|
||||
|
||||
// --- HTML template (single page) ---
|
||||
// --- HTML template (includes rest timer card) ---
|
||||
|
||||
const indexHTML = `
|
||||
<!DOCTYPE html>
|
||||
|
|
@ -831,11 +1064,16 @@ const indexHTML = `
|
|||
.tag:hover {
|
||||
background:#374151;
|
||||
}
|
||||
.timer-display {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Minimal Fitness Tracker</h1>
|
||||
<p style="color:#9ca3af;">Self-hosted 5×5 log – A/B templates, volume, rest, and history.</p>
|
||||
<p style="color:#9ca3af;">Self-hosted 5×5 log – A/B templates, volume, rest, history & basic API.</p>
|
||||
|
||||
<div class="row">
|
||||
<div>
|
||||
|
|
@ -932,7 +1170,7 @@ const indexHTML = `
|
|||
<input type="number" id="weightInput" name="weight" step="0.5" min="0" required>
|
||||
</label>
|
||||
<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>
|
||||
<p class="small">Time you press “Save set” = set timestamp. Rest between sets is automatic.</p>
|
||||
</form>
|
||||
{{else}}
|
||||
<p style="color:#fbbf24;">Start a workout first.</p>
|
||||
|
|
@ -988,6 +1226,17 @@ const indexHTML = `
|
|||
<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 class="card">
|
||||
<h2>Rest timer (with notification)</h2>
|
||||
<label>
|
||||
Rest seconds
|
||||
<input type="number" id="restSeconds" min="10" step="5" value="90">
|
||||
</label>
|
||||
<button type="button" onclick="startRestTimer()">Start rest</button>
|
||||
<div id="restCountdown" class="timer-display"></div>
|
||||
<p class="small">When the timer ends, you’ll get a browser notification (if allowed).</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1053,6 +1302,8 @@ const indexHTML = `
|
|||
<script>
|
||||
let restChartInstance = null;
|
||||
let volumeChartInstance = null;
|
||||
let restTimerInterval = null;
|
||||
let restTimerEnd = null;
|
||||
|
||||
function toggleCustomExercise() {
|
||||
const select = document.getElementById('exercise_select');
|
||||
|
|
@ -1220,6 +1471,55 @@ const indexHTML = `
|
|||
});
|
||||
}
|
||||
|
||||
// --- Rest timer with notification ---
|
||||
|
||||
function startRestTimer() {
|
||||
const input = document.getElementById('restSeconds');
|
||||
const display = document.getElementById('restCountdown');
|
||||
let sec = parseInt(input.value, 10);
|
||||
if (isNaN(sec) || sec <= 0) {
|
||||
sec = 90;
|
||||
input.value = 90;
|
||||
}
|
||||
|
||||
// Ask for notification permission once
|
||||
if ('Notification' in window && Notification.permission === 'default') {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
|
||||
restTimerEnd = Date.now() + sec * 1000;
|
||||
updateRestCountdown(); // immediate
|
||||
|
||||
if (restTimerInterval) {
|
||||
clearInterval(restTimerInterval);
|
||||
}
|
||||
restTimerInterval = setInterval(updateRestCountdown, 250);
|
||||
}
|
||||
|
||||
function updateRestCountdown() {
|
||||
const display = document.getElementById('restCountdown');
|
||||
if (!restTimerEnd) {
|
||||
display.textContent = '';
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const remainingMs = restTimerEnd - now;
|
||||
if (remainingMs <= 0) {
|
||||
display.textContent = "0s";
|
||||
clearInterval(restTimerInterval);
|
||||
restTimerInterval = null;
|
||||
restTimerEnd = null;
|
||||
|
||||
// Fire notification if allowed
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
new Notification("Rest done – time to lift! 💪");
|
||||
}
|
||||
return;
|
||||
}
|
||||
const sec = Math.round(remainingMs / 1000);
|
||||
display.textContent = sec + "s";
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
toggleCustomExercise();
|
||||
onExerciseChange();
|
||||
|
|
|
|||
Loading…
Reference in a new issue