├── .gitignore ├── .nvmrc ├── Dockerfile ├── LICENSE ├── README.md ├── cmd └── server │ └── main.go ├── db └── sqldb │ ├── migrations │ ├── 20210629173004_initial_db.down.sql │ ├── 20210629173004_initial_db.up.sql │ ├── 20210808084114_smallest_denom.down.sql │ ├── 20210808084114_smallest_denom.up.sql │ ├── 20230609204223_add_to_failure.down.sql │ ├── 20230609204223_add_to_failure.up.sql │ ├── 20230610082149_skip_week.down.sql │ └── 20230610082149_skip_week.up.sql │ ├── sqldb.go │ └── util.go ├── frontend ├── .env ├── .eslintignore ├── .eslintrc.cjs ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── Dockerfile ├── README.md ├── package-lock.json ├── package.json ├── src │ ├── app.d.ts │ ├── app.html │ ├── lib │ │ ├── Modal.svelte │ │ ├── api │ │ │ └── index.ts │ │ └── apipath │ │ │ └── index.ts │ └── routes │ │ ├── +page.svelte │ │ ├── +page.ts │ │ └── training-maxes │ │ ├── +page.svelte │ │ └── +page.ts ├── static │ └── favicon.png ├── svelte.config.js ├── tsconfig.json └── vite.config.ts ├── go.mod ├── go.sum ├── routine.example.json ├── screenshots ├── lifts.png └── training-maxes.png ├── scripts └── new_migration.sh ├── server ├── server.go └── server_test.go ├── stronk.go ├── stronk_test.go └── testing └── testdb └── testdb.go /.gitignore: -------------------------------------------------------------------------------- 1 | routine.json 2 | hashkey.dat 3 | blockkey.dat 4 | fivethreeone.db 5 | fivethreeone.db.* 6 | stronk.db 7 | stronk.db.* 8 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.12.0 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # docker build -t 192.168.5.3:5000/stronk . 2 | FROM golang:1.21 as build 3 | 4 | WORKDIR /project 5 | 6 | COPY go.mod /project 7 | COPY go.sum /project 8 | COPY stronk.go /project/stronk.go 9 | COPY stronk_test.go /project/stronk_test.go 10 | COPY db/ /project/db 11 | COPY cmd/ /project/cmd 12 | COPY server/ /project/server 13 | COPY testing/ /project/testing 14 | # Needed for testing 15 | COPY routine.example.json /project 16 | 17 | RUN go test ./... && GOOS=linux go build -ldflags "-linkmode external -extldflags -static" -o stronk github.com/bcspragu/stronk/cmd/server 18 | 19 | FROM gcr.io/distroless/static-debian12 20 | COPY --from=build /project/stronk / 21 | COPY db/sqldb/migrations /migrations 22 | CMD ["/stronk"] 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Brandon Sprague 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # stronk 2 | 3 | Stronk is a simple web app for tracking your exercise. It was built with [Jim Wendler's 5/3/1](https://www.amazon.com/Simplest-Effective-Training-System-Strength/dp/B00686OYGQ) in mind, but is decently flexible (at least in terms of routines, the UI is pretty heavily based around the main four 5/3/1 lifts). 4 | 5 | Your exercises are configured via a `routine.json` file that specifies lifts in a rough hierarchy: 6 | 7 | * Week - A week of workouts. A week can be 'optional'. This is used for deload weeks. 8 | * Day - A day of workouts 9 | * Movement - A set of lifts, all having the same exercise (e.g. squat, bench) and set type (e.g. warmup, assistance, etc) 10 | * Set - A number of target reps at a target percentage of the training max for that movement's exercise. Can optionally be 'to failure', meaning the rep target is a minimum 11 | 12 | An example `routine.example.json` is included, which implements a fairly standard 5/3/1 using "Big but Boring" for the assistance work. It includes an optional deload week. 13 | 14 | ## Screenshots 15 | 16 | The training max page, where you enter your initial training maxes, which all subsequent sets will be based on. 17 | 18 | ![Screenshot of the training max page, showing four inputs corresponding to the four lifts of 5/3/1, along with a selector for the smallest plate you have available at your gym](/screenshots/training-maxes.png) 19 | 20 | The lift/main page, where you get an overview of the day's lifts, and record them as you do them, adding rep counts and notes as relevant. 21 | 22 | ![Screenshot of the lifts page, showing a series of sets broken into warmup, main, and assistance. The bottom half of the page shows buttons for recording lifts, adding notes, skipping, and more](/screenshots/lifts.png) 23 | 24 | 25 | ## Local Development 26 | 27 | To run locally, you'll need a recent version of Go + some version of NPM. Install frontend dependencies (namely Svelte) with `cd frontend && npm install`. 28 | 29 | Then, to run the infrastructure. 30 | 31 | ```bash 32 | # Run the backend 33 | go run ./cmd/server 34 | 35 | # In another terminal 36 | cd frontend 37 | npm run dev 38 | ``` 39 | 40 | The server stores lift info in a SQLite database, which will be created + migrated on the first boot. 41 | 42 | Frontend is available at `localhost:5173`, backend is `localhost:8080`. 43 | 44 | ## Deployment 45 | 46 | Note: the app has no authentication, make sure to introduce basic auth or deploy the app behind something like [Tailscale](https://tailscale.com/) 47 | 48 | The main way to deploy this is with two Docker containers `stronk` and `stronk-fe`, which run the backend and frontend respectively. I run this in a local K8s deployment, using a config like: 49 | 50 |
51 | 52 | stronk.yaml 53 | 54 | ```yaml 55 | apiVersion: apps/v1 56 | kind: Deployment 57 | metadata: 58 | name: stronk-deployment 59 | labels: 60 | app: stronk 61 | spec: 62 | selector: 63 | matchLabels: 64 | app: stronk 65 | strategy: 66 | type: Recreate 67 | template: 68 | metadata: 69 | labels: 70 | app: stronk 71 | spec: 72 | containers: 73 | - image: /stronk-fe 74 | name: frontend 75 | env: 76 | - name: PUBLIC_API_BASE_URL 77 | value: "http://localhost:8080" 78 | ports: 79 | - containerPort: 3000 80 | name: web 81 | - image: /stronk 82 | name: backend 83 | env: 84 | - name: ROUTINE_FILE 85 | value: /config/routine.json 86 | - name: DB_FILE 87 | value: /data/stronk.db 88 | - name: MIGRATION_DIR 89 | value: /migrations 90 | ports: 91 | - containerPort: 8080 92 | name: http-api 93 | volumeMounts: 94 | - name: site-data 95 | mountPath: "/data" 96 | subPath: stronk 97 | - name: config 98 | mountPath: "/config" 99 | readOnly: true 100 | volumes: 101 | - name: site-data 102 | # TODO: Some kind of mount for the SQLite database 103 | - name: config 104 | configMap: 105 | name: stronk-config 106 | # This contains the routine.json file for your specific program. 107 | --- 108 | apiVersion: v1 109 | kind: Service 110 | metadata: 111 | name: stronk 112 | spec: 113 | selector: 114 | app: stronk 115 | ports: 116 | - name: web 117 | protocol: TCP 118 | port: 3000 119 | targetPort: 3000 120 | - name: http-api 121 | protocol: TCP 122 | port: 8080 123 | targetPort: 8080 124 | ``` 125 | 126 |
127 | 128 | And then deploy it behind something like Caddy with: 129 | 130 |
131 | 132 | Caddyfile 133 | 134 | ```caddy 135 | https://stronk. { 136 | encode gzip 137 | 138 | handle /api/* { 139 | reverse_proxy stronk..svc.cluster.local:8080 140 | } 141 | 142 | handle { 143 | reverse_proxy stronk..svc.cluster.local:3000 144 | } 145 | } 146 | ``` 147 | 148 |
149 | -------------------------------------------------------------------------------- /cmd/server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | 11 | "github.com/bcspragu/stronk" 12 | "github.com/bcspragu/stronk/db/sqldb" 13 | "github.com/bcspragu/stronk/server" 14 | "github.com/namsral/flag" 15 | "github.com/rs/cors" 16 | ) 17 | 18 | func main() { 19 | if err := run(); err != nil { 20 | log.Fatal(err) 21 | } 22 | } 23 | 24 | func run() error { 25 | var ( 26 | routineFile = flag.String("routine_file", "routine.json", "Path to the JSON file containing a routine") 27 | 28 | dbFile = flag.String("db_file", "stronk.db", "Path to the SQLite database") 29 | migrationDir = flag.String("migration_dir", "db/sqldb/migrations", "Path to the directory containing our migration set files") 30 | 31 | addr = flag.String("addr", ":8080", "The address to run the HTTP server on") 32 | ) 33 | flag.Parse() 34 | 35 | routine, err := loadRoutine(*routineFile) 36 | if err != nil { 37 | return fmt.Errorf("failed to load users file: %v", err) 38 | } 39 | 40 | db, err := sqldb.New(*dbFile, *migrationDir) 41 | if err != nil { 42 | return fmt.Errorf("failed to load SQLite db: %v", err) 43 | } 44 | defer db.Close() 45 | 46 | srv := server.New(routine, db) 47 | 48 | errChan := make(chan error) 49 | go func() { 50 | c := make(chan os.Signal, 1) 51 | signal.Notify(c, os.Interrupt) 52 | 53 | s := <-c 54 | log.Printf("Received signal: %s", s) 55 | errChan <- nil 56 | }() 57 | 58 | go func() { 59 | log.Printf("Starting server on %q", *addr) 60 | errChan <- http.ListenAndServe(*addr, cors.Default().Handler(srv)) 61 | }() 62 | 63 | return <-errChan 64 | } 65 | 66 | func loadRoutine(usersFile string) (*stronk.Routine, error) { 67 | f, err := os.Open(usersFile) 68 | if err != nil { 69 | return nil, fmt.Errorf("failed to open routine file: %w", err) 70 | } 71 | defer f.Close() 72 | 73 | var routine *stronk.Routine 74 | if err := json.NewDecoder(f).Decode(&routine); err != nil { 75 | return nil, fmt.Errorf("failed to parse routine file as JSON: %w", err) 76 | } 77 | return routine, nil 78 | } 79 | -------------------------------------------------------------------------------- /db/sqldb/migrations/20210629173004_initial_db.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE training_maxes; 2 | DROP TABLE lifts; 3 | DROP TABLE exercises; 4 | -------------------------------------------------------------------------------- /db/sqldb/migrations/20210629173004_initial_db.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE exercises ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 3 | name TEXT UNIQUE NOT NULL 4 | ); 5 | 6 | CREATE TABLE lifts ( 7 | id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 8 | exercise_id NOT NULL, 9 | set_type TEXT CHECK( set_type IN ('WARMUP', 'MAIN', 'ASSISTANCE') ) NOT NULL, 10 | set_number INTEGER NOT NULL, 11 | reps INTEGER NOT NULL, 12 | weight TEXT NOT NULL, 13 | day_number INTEGER NOT NULL, 14 | week_number INTEGER NOT NULL, 15 | iteration_number INTEGER NOT NULL, 16 | lift_note TEXT, 17 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 18 | FOREIGN KEY (exercise_id) REFERENCES exercises (id) 19 | ); 20 | 21 | CREATE TABLE training_maxes ( 22 | id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 23 | exercise_id NOT NULL, 24 | training_max_weight TEXT NOT NULL, 25 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 26 | FOREIGN KEY (exercise_id) REFERENCES exercises (id) 27 | ); 28 | -------------------------------------------------------------------------------- /db/sqldb/migrations/20210808084114_smallest_denom.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE smallest_denom; 2 | -------------------------------------------------------------------------------- /db/sqldb/migrations/20210808084114_smallest_denom.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE smallest_denom ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 3 | smallest_denom TEXT NOT NULL, 4 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 5 | ); 6 | -------------------------------------------------------------------------------- /db/sqldb/migrations/20230609204223_add_to_failure.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE lifts DROP COLUMN to_failure; -------------------------------------------------------------------------------- /db/sqldb/migrations/20230609204223_add_to_failure.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE lifts 2 | ADD COLUMN to_failure INTEGER NOT NULL DEFAULT FALSE; -------------------------------------------------------------------------------- /db/sqldb/migrations/20230610082149_skip_week.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE skipped_weeks; -------------------------------------------------------------------------------- /db/sqldb/migrations/20230610082149_skip_week.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE skipped_weeks ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 3 | week_number INTEGER NOT NULL, 4 | iteration_number INTEGER NOT NULL, 5 | note TEXT NOT NULL, 6 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 7 | ); 8 | -------------------------------------------------------------------------------- /db/sqldb/sqldb.go: -------------------------------------------------------------------------------- 1 | // Package sqldb implements the server.DB interface, backed by a sqlite database. 2 | package sqldb 3 | 4 | import ( 5 | "database/sql" 6 | "errors" 7 | "fmt" 8 | "log" 9 | "path/filepath" 10 | "strings" 11 | "sync" 12 | 13 | "github.com/bcspragu/stronk" 14 | "github.com/golang-migrate/migrate/v4" 15 | "github.com/mattn/go-sqlite3" 16 | 17 | migratesqlite3 "github.com/golang-migrate/migrate/v4/database/sqlite3" 18 | _ "github.com/golang-migrate/migrate/v4/source/file" 19 | ) 20 | 21 | type DB struct { 22 | mu sync.Mutex 23 | sql *sql.DB 24 | mainLiftIDs map[stronk.Exercise]int 25 | } 26 | 27 | func (db *DB) Close() error { 28 | return db.sql.Close() 29 | } 30 | 31 | type scanner interface { 32 | Scan(dest ...interface{}) error 33 | } 34 | 35 | func (db *DB) EditLift(id stronk.LiftID, note string, reps int) error { 36 | return db.transact(func(tx *sql.Tx) error { 37 | q := ` 38 | UPDATE lifts 39 | SET reps = ?, lift_note = ? 40 | WHERE id = ? 41 | ` 42 | _, err := tx.Exec(q, reps, note, id) 43 | return err 44 | }) 45 | } 46 | 47 | func (db *DB) Lift(id stronk.LiftID) (*stronk.Lift, error) { 48 | var lift *stronk.Lift 49 | err := db.transact(func(tx *sql.Tx) error { 50 | q := ` 51 | SELECT lifts.id, exercises.name, lifts.set_type, lifts.weight, lifts.set_number, lifts.reps, lifts.lift_note, lifts.day_number, lifts.week_number, lifts.iteration_number, lifts.to_failure 52 | FROM lifts 53 | JOIN exercises 54 | ON lifts.exercise_id = exercises.id 55 | WHERE lifts.id = ?` 56 | 57 | rows, err := tx.Query(q, id) 58 | if err != nil { 59 | return fmt.Errorf("failed to query training_maxes: %w", err) 60 | } 61 | lfs, err := lifts(rows) 62 | if err != nil { 63 | return fmt.Errorf("failed to scan training_maxes: %w", err) 64 | } 65 | if n := len(lfs); n != 1 { 66 | return fmt.Errorf("unexpected number of lifts %d", n) 67 | } 68 | lift = lfs[0] 69 | return nil 70 | }) 71 | if err != nil { 72 | return nil, fmt.Errorf("failed to set lifts: %w", err) 73 | } 74 | return lift, nil 75 | } 76 | 77 | func (db *DB) RecordLift(ex stronk.Exercise, st stronk.SetType, weight stronk.Weight, set int, reps int, note string, day, week, iter int, toFailure bool) (stronk.LiftID, error) { 78 | var id stronk.LiftID 79 | err := db.transact(func(tx *sql.Tx) error { 80 | q := `INSERT INTO lifts 81 | (exercise_id, set_type, set_number, reps, weight, day_number, week_number, iteration_number, lift_note, to_failure) 82 | VALUES ((SELECT id FROM exercises WHERE name = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?) 83 | RETURNING lifts.id` 84 | if err := tx.QueryRow(q, ex, st, set, reps, &sqlWeight{&weight}, day, week, iter, nullString(note), toFailure).Scan(&id); err != nil { 85 | return fmt.Errorf("failed to insert lift: %w", err) 86 | } 87 | return nil 88 | }) 89 | if err != nil { 90 | return 0, err 91 | } 92 | return id, nil 93 | } 94 | 95 | func (db *DB) SkippedWeeks() ([]stronk.SkippedWeek, error) { 96 | var weeks []stronk.SkippedWeek 97 | err := db.transact(func(tx *sql.Tx) error { 98 | q := ` 99 | SELECT week_number, iteration_number, note 100 | FROM skipped_weeks 101 | ORDER BY iteration_number DESC, week_number DESC 102 | LIMIT 100` 103 | 104 | rows, err := tx.Query(q) 105 | if err != nil { 106 | return fmt.Errorf("failed to query skipped weeks: %w", err) 107 | } 108 | if weeks, err = skippedWeeks(rows); err != nil { 109 | return fmt.Errorf("failed to scan skipped weeks: %w", err) 110 | } 111 | return nil 112 | }) 113 | if err != nil { 114 | return nil, fmt.Errorf("failed to load skipped weeks: %w", err) 115 | } 116 | return weeks, nil 117 | } 118 | 119 | func (db *DB) SkipWeek(note string, week, iter int) error { 120 | return db.transact(func(tx *sql.Tx) error { 121 | q := `INSERT INTO skipped_weeks 122 | (week_number, iteration_number, note) 123 | VALUES (?, ?, ?)` 124 | if _, err := tx.Exec(q, week, iter, note); err != nil { 125 | return fmt.Errorf("failed to insert skipped week: %w", err) 126 | } 127 | return nil 128 | }) 129 | } 130 | 131 | func (db *DB) ComparableLifts(ex stronk.Exercise, weight stronk.Weight) (*stronk.ComparableLifts, error) { 132 | // We want to find two comparable lifts: 133 | // 1. The closest in weight, breaking ties by highest ORM equivalent ("Most Similar") 134 | // 2. The highest ORM equivalent reps, period. ("PR") 135 | var lfs []*stronk.Lift 136 | err := db.transact(func(tx *sql.Tx) error { 137 | q := ` 138 | SELECT lifts.id, exercises.name, lifts.set_type, lifts.weight, lifts.set_number, lifts.reps, lifts.lift_note, lifts.day_number, lifts.week_number, lifts.iteration_number, lifts.to_failure 139 | FROM lifts 140 | JOIN exercises 141 | ON lifts.exercise_id = exercises.id 142 | WHERE exercises.name = ? 143 | AND to_failure = TRUE 144 | ORDER BY iteration_number DESC, week_number DESC, day_number DESC, lifts.created_at DESC 145 | LIMIT 250` 146 | 147 | rows, err := tx.Query(q, ex) 148 | if err != nil { 149 | return fmt.Errorf("failed to query lifts: %w", err) 150 | } 151 | if lfs, err = lifts(rows); err != nil { 152 | return fmt.Errorf("failed to scan lifts: %w", err) 153 | } 154 | return nil 155 | }) 156 | if err != nil { 157 | return nil, fmt.Errorf("failed to load comparables: %w", err) 158 | } 159 | 160 | return stronk.CalcComparables(lfs, weight), nil 161 | } 162 | 163 | func (db *DB) RecentFailureSets() ([]*stronk.Lift, error) { 164 | var lfs []*stronk.Lift 165 | err := db.transact(func(tx *sql.Tx) error { 166 | q := ` 167 | SELECT lifts.id, exercises.name, lifts.set_type, lifts.weight, lifts.set_number, lifts.reps, lifts.lift_note, lifts.day_number, lifts.week_number, lifts.iteration_number, lifts.to_failure 168 | FROM lifts 169 | JOIN exercises 170 | ON lifts.exercise_id = exercises.id 171 | WHERE set_type = 'MAIN' 172 | AND to_failure = TRUE 173 | ORDER BY iteration_number DESC, week_number DESC, day_number DESC, lifts.created_at DESC 174 | LIMIT 250` 175 | 176 | rows, err := tx.Query(q) 177 | if err != nil { 178 | return fmt.Errorf("failed to query training_maxes: %w", err) 179 | } 180 | if lfs, err = lifts(rows); err != nil { 181 | return fmt.Errorf("failed to scan training_maxes: %w", err) 182 | } 183 | return nil 184 | }) 185 | if err != nil { 186 | return nil, fmt.Errorf("failed to set lifts: %w", err) 187 | } 188 | return lfs, nil 189 | } 190 | 191 | func (db *DB) RecentLifts() ([]*stronk.Lift, error) { 192 | var lfs []*stronk.Lift 193 | err := db.transact(func(tx *sql.Tx) error { 194 | q := ` 195 | SELECT lifts.id, exercises.name, lifts.set_type, lifts.weight, lifts.set_number, lifts.reps, lifts.lift_note, lifts.day_number, lifts.week_number, lifts.iteration_number, lifts.to_failure 196 | FROM lifts 197 | JOIN exercises 198 | ON lifts.exercise_id = exercises.id 199 | ORDER BY iteration_number DESC, week_number DESC, day_number DESC, lifts.created_at DESC 200 | LIMIT 100` 201 | 202 | rows, err := tx.Query(q) 203 | if err != nil { 204 | return fmt.Errorf("failed to query training_maxes: %w", err) 205 | } 206 | if lfs, err = lifts(rows); err != nil { 207 | return fmt.Errorf("failed to scan training_maxes: %w", err) 208 | } 209 | return nil 210 | }) 211 | if err != nil { 212 | return nil, fmt.Errorf("failed to set lifts: %w", err) 213 | } 214 | return lfs, nil 215 | } 216 | 217 | func (db *DB) transact(dbFn func(tx *sql.Tx) error) error { 218 | db.mu.Lock() 219 | defer db.mu.Unlock() 220 | 221 | tx, err := db.sql.Begin() 222 | if err != nil { 223 | return fmt.Errorf("failed to start transaction: %w", err) 224 | } 225 | defer tx.Rollback() 226 | 227 | if err := dbFn(tx); err != nil { 228 | return fmt.Errorf("failed to perform DB action: %w", err) 229 | } 230 | 231 | if err := tx.Commit(); err != nil { 232 | return fmt.Errorf("failed to commit transaction: %w", err) 233 | } 234 | 235 | return nil 236 | } 237 | 238 | func (db *DB) SetTrainingMaxes(press, squat, bench, deadlift stronk.Weight) error { 239 | err := db.transact(func(tx *sql.Tx) error { 240 | q := `INSERT INTO training_maxes 241 | (exercise_id, training_max_weight) VALUES 242 | (?, ?), (?, ?), (?, ?), (?, ?)` 243 | args := []interface{}{ 244 | db.mainLiftIDs[stronk.OverheadPress], &sqlWeight{&press}, 245 | db.mainLiftIDs[stronk.Squat], &sqlWeight{&squat}, 246 | db.mainLiftIDs[stronk.BenchPress], &sqlWeight{&bench}, 247 | db.mainLiftIDs[stronk.Deadlift], &sqlWeight{&deadlift}, 248 | } 249 | if _, err := tx.Exec(q, args...); err != nil { 250 | return fmt.Errorf("failed to insert to training_maxes: %w", err) 251 | } 252 | return nil 253 | }) 254 | if err != nil { 255 | return fmt.Errorf("failed to set training maxes: %w", err) 256 | } 257 | return nil 258 | } 259 | 260 | func (db *DB) TrainingMaxes() ([]*stronk.TrainingMax, error) { 261 | var tms []*stronk.TrainingMax 262 | err := db.transact(func(tx *sql.Tx) error { 263 | q := ` 264 | SELECT b.exname, a.training_max_weight 265 | FROM training_maxes a 266 | INNER JOIN 267 | ( 268 | SELECT exercises.id exid, exercises.name exname, MAX(created_at) latest 269 | FROM training_maxes 270 | JOIN exercises 271 | ON training_maxes.exercise_id = exercises.id 272 | GROUP BY exercises.id 273 | ) b 274 | ON a.exercise_id = b.exid 275 | AND a.created_at = b.latest` 276 | 277 | rows, err := tx.Query(q) 278 | if err != nil { 279 | return fmt.Errorf("failed to query training_maxes: %w", err) 280 | } 281 | if tms, err = trainingMaxes(rows); err != nil { 282 | return fmt.Errorf("failed to scan training_maxes: %w", err) 283 | } 284 | return nil 285 | }) 286 | if err != nil { 287 | return nil, fmt.Errorf("failed to set training maxes: %w", err) 288 | } 289 | return tms, nil 290 | } 291 | 292 | func trainingMaxes(rows *sql.Rows) ([]*stronk.TrainingMax, error) { 293 | defer rows.Close() 294 | 295 | var tms []*stronk.TrainingMax 296 | for rows.Next() { 297 | var tm stronk.TrainingMax 298 | if err := rows.Scan(&tm.Exercise, &sqlWeight{&tm.Max}); err != nil { 299 | return nil, fmt.Errorf("failed to scan training max: %w", err) 300 | } 301 | tms = append(tms, &tm) 302 | } 303 | 304 | if err := rows.Err(); err != nil { 305 | return nil, fmt.Errorf("failed to scan training maxes: %w", err) 306 | } 307 | return tms, nil 308 | } 309 | 310 | func (db *DB) SetSmallestDenom(small stronk.Weight) error { 311 | err := db.transact(func(tx *sql.Tx) error { 312 | q := `INSERT INTO smallest_denom (smallest_denom) VALUES (?)` 313 | if _, err := tx.Exec(q, &sqlWeight{&small}); err != nil { 314 | return fmt.Errorf("failed to insert to smallest_denom: %w", err) 315 | } 316 | return nil 317 | }) 318 | if err != nil { 319 | return fmt.Errorf("failed to set smallest denominator: %w", err) 320 | } 321 | return nil 322 | } 323 | 324 | func (db *DB) SmallestDenom() (stronk.Weight, error) { 325 | var small stronk.Weight 326 | err := db.transact(func(tx *sql.Tx) error { 327 | q := ` 328 | SELECT a.smallest_denom 329 | FROM smallest_denom a 330 | ORDER BY a.created_at DESC 331 | LIMIT 1` 332 | err := tx.QueryRow(q).Scan(&sqlWeight{&small}) 333 | if errors.Is(err, sql.ErrNoRows) { 334 | return stronk.ErrNoSmallestDenom 335 | } 336 | if err != nil { 337 | return fmt.Errorf("failed to scan smallest denominator: %w", err) 338 | } 339 | return nil 340 | }) 341 | if err != nil { 342 | return stronk.Weight{}, err 343 | } 344 | return small, nil 345 | } 346 | 347 | func lifts(rows *sql.Rows) ([]*stronk.Lift, error) { 348 | defer rows.Close() 349 | 350 | var lfs []*stronk.Lift 351 | for rows.Next() { 352 | var ( 353 | lf stronk.Lift 354 | note sql.NullString 355 | ) 356 | if err := rows.Scan( 357 | &lf.ID, 358 | &lf.Exercise, &lf.SetType, &sqlWeight{&lf.Weight}, 359 | &lf.SetNumber, &lf.Reps, ¬e, 360 | &lf.DayNumber, &lf.WeekNumber, &lf.IterationNumber, 361 | &lf.ToFailure); err != nil { 362 | return nil, fmt.Errorf("failed to scan lift: %w", err) 363 | } 364 | if note.Valid { 365 | lf.Note = note.String 366 | } 367 | lfs = append(lfs, &lf) 368 | } 369 | 370 | if err := rows.Err(); err != nil { 371 | return nil, fmt.Errorf("failed to scan lifts: %w", err) 372 | } 373 | return lfs, nil 374 | } 375 | 376 | func skippedWeeks(rows *sql.Rows) ([]stronk.SkippedWeek, error) { 377 | defer rows.Close() 378 | 379 | var wks []stronk.SkippedWeek 380 | for rows.Next() { 381 | var wk stronk.SkippedWeek 382 | if err := rows.Scan(&wk.Week, &wk.Iteration, &wk.Note); err != nil { 383 | return nil, fmt.Errorf("failed to scan skipped week: %w", err) 384 | } 385 | wks = append(wks, wk) 386 | } 387 | 388 | if err := rows.Err(); err != nil { 389 | return nil, fmt.Errorf("failed to scan lifts: %w", err) 390 | } 391 | return wks, nil 392 | } 393 | 394 | func New(dbPath, migrationsPath string) (*DB, error) { 395 | db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on&_loc=UTC") 396 | if err != nil { 397 | return nil, fmt.Errorf("failed to open SQLite DB: %w", err) 398 | } 399 | cleanupOnError := func(origErr error) error { 400 | if closeErr := db.Close(); closeErr != nil { 401 | return fmt.Errorf("error closing DB (%v) while handling original error: %w", closeErr, origErr) 402 | } 403 | return origErr 404 | } 405 | 406 | driver, err := migratesqlite3.WithInstance(db, &migratesqlite3.Config{ 407 | MigrationsTable: migratesqlite3.DefaultMigrationsTable, 408 | }) 409 | if err != nil { 410 | return nil, cleanupOnError(fmt.Errorf("failed to init go-migrate driver: %w", err)) 411 | } 412 | 413 | rootedMigrationsPath, err := filepath.Abs(migrationsPath) 414 | if err != nil { 415 | return nil, cleanupOnError(fmt.Errorf("failed to get a rooted migrations file path: %w", err)) 416 | } 417 | 418 | m, err := migrate.NewWithDatabaseInstance( 419 | "file://"+rootedMigrationsPath, 420 | "sqlite3", driver) 421 | if err != nil { 422 | return nil, cleanupOnError(fmt.Errorf("failed to create migrate instance: %w", err)) 423 | } 424 | 425 | prevV, dirty, err := m.Version() 426 | if err != nil && err != migrate.ErrNilVersion { 427 | return nil, cleanupOnError(fmt.Errorf("failed to load current DB version: %w", err)) 428 | } 429 | if dirty { 430 | return nil, cleanupOnError(errors.New("database was marked dirty")) 431 | } 432 | 433 | switch err := m.Up(); { 434 | case err == nil: 435 | // Fine, good. 436 | case errors.Is(err, migrate.ErrNoChange): 437 | log.Print("No new migrations to apply") 438 | default: 439 | return nil, cleanupOnError(fmt.Errorf("failed to migrate database up: %w", err)) 440 | } 441 | 442 | curV, dirty, err := m.Version() 443 | if err != nil && err != migrate.ErrNilVersion { 444 | return nil, cleanupOnError(fmt.Errorf("failed to load DB version post-migration: %w", err)) 445 | } 446 | if dirty { 447 | return nil, cleanupOnError(errors.New("database was marked dirty after migration")) 448 | } 449 | 450 | if prevV != curV { 451 | log.Printf("Migrated from version %d to version %d", prevV, curV) 452 | } 453 | 454 | sdb := &DB{sql: db} 455 | 456 | if err := sdb.initMainLifts(); err != nil { 457 | return nil, fmt.Errorf("failed to init main lifts: %w", err) 458 | } 459 | 460 | return sdb, nil 461 | } 462 | 463 | func (db *DB) CreateExercise(ex stronk.Exercise) error { 464 | return db.transact(func(tx *sql.Tx) error { 465 | q := `INSERT INTO exercises (name) VALUES (?)` 466 | _, err := tx.Exec(q, ex) 467 | sqlErr := sqlite3.Error{} 468 | if errors.As(err, &sqlErr) && sqlErr.ExtendedCode == sqlite3.ErrConstraintUnique { 469 | // An expected error if we've already inserted this, we don't need to let 470 | // callers know about this. 471 | return nil 472 | } 473 | if err != nil { 474 | return fmt.Errorf("failed to insert exercise: %w", err) 475 | } 476 | return nil 477 | }) 478 | } 479 | 480 | type exercise struct { 481 | ID int 482 | Exercise stronk.Exercise 483 | } 484 | 485 | func (db *DB) exercises(exs []stronk.Exercise) ([]exercise, error) { 486 | var out []exercise 487 | err := db.transact(func(tx *sql.Tx) error { 488 | q := fmt.Sprintf(` 489 | SELECT id, name 490 | FROM exercises 491 | WHERE name IN %s`, repeatedArgs(len(exs))) 492 | 493 | var args []interface{} 494 | for _, ex := range exs { 495 | args = append(args, ex) 496 | } 497 | 498 | rows, err := tx.Query(q, args...) 499 | if err != nil { 500 | return fmt.Errorf("failed to query exercises: %w", err) 501 | } 502 | defer rows.Close() 503 | 504 | for rows.Next() { 505 | var e exercise 506 | if err := rows.Scan(&e.ID, &e.Exercise); err != nil { 507 | return fmt.Errorf("failed to scan exercise: %w", err) 508 | } 509 | out = append(out, e) 510 | } 511 | 512 | if err := rows.Err(); err != nil { 513 | return fmt.Errorf("failed to scan exercises: %w", err) 514 | } 515 | return nil 516 | }) 517 | if err != nil { 518 | return nil, fmt.Errorf("failed to load exercises: %w", err) 519 | } 520 | return out, nil 521 | } 522 | 523 | func (db *DB) initMainLifts() error { 524 | // First, create all the main lifts. 525 | exs := stronk.MainExercises() 526 | for _, ex := range exs { 527 | if err := db.CreateExercise(ex); err != nil { 528 | return fmt.Errorf("failed to create exercise %q: %w", ex, err) 529 | } 530 | } 531 | 532 | // Now, load all of their IDs. 533 | mainLiftIDs := make(map[stronk.Exercise]int) 534 | exsWithIDs, err := db.exercises(exs) 535 | if err != nil { 536 | return err 537 | } 538 | for _, ex := range exsWithIDs { 539 | mainLiftIDs[ex.Exercise] = ex.ID 540 | } 541 | 542 | db.mainLiftIDs = mainLiftIDs 543 | 544 | return nil 545 | } 546 | 547 | func repeatedArgs(n int) string { 548 | if n < 1 { 549 | // Normally, you wouldn't want to panic in a production application, but 550 | // this is clearly a programmer error and it's a personal project so imma 551 | // just try to not make this particular error :shrug:. 552 | panic(fmt.Sprintf("repeatedArgs called with value less than one, %d", n)) 553 | } 554 | 555 | return "(" + strings.Repeat("?,", n-1) + "?)" 556 | } 557 | -------------------------------------------------------------------------------- /db/sqldb/util.go: -------------------------------------------------------------------------------- 1 | package sqldb 2 | 3 | import ( 4 | "database/sql" 5 | "database/sql/driver" 6 | "errors" 7 | "fmt" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/bcspragu/stronk" 12 | ) 13 | 14 | type sqlWeight struct { 15 | w *stronk.Weight 16 | } 17 | 18 | func (sw sqlWeight) Value() (driver.Value, error) { 19 | return fmt.Sprintf("%d:%s", sw.w.Value, sw.w.Unit), nil 20 | } 21 | 22 | func (sw *sqlWeight) Scan(val interface{}) error { 23 | if val == nil { 24 | return errors.New("weight should always be set") 25 | } 26 | 27 | // We probably don't need both []byte and string, but I swear []byte was 28 | // working, but it recently started failing and expected string instead. 29 | // :shrug: 30 | switch v := val.(type) { 31 | case []byte: 32 | w, err := parseWeight(string(v)) 33 | if err != nil { 34 | return fmt.Errorf("failed to parse weight: %w", err) 35 | } 36 | *sw.w = w 37 | return nil 38 | case string: 39 | w, err := parseWeight(v) 40 | if err != nil { 41 | return fmt.Errorf("failed to parse weight: %w", err) 42 | } 43 | *sw.w = w 44 | return nil 45 | default: 46 | return fmt.Errorf("unexpected type of val %T", val) 47 | } 48 | } 49 | 50 | func parseWeight(v string) (stronk.Weight, error) { 51 | ps := strings.Split(v, ":") 52 | if n := len(ps); n != 2 { 53 | return stronk.Weight{}, fmt.Errorf("malformed weight had %d parts", n) 54 | } 55 | 56 | val, err := strconv.Atoi(ps[0]) 57 | if err != nil { 58 | return stronk.Weight{}, fmt.Errorf("failed to parse weight %q: %w", ps[0], err) 59 | } 60 | 61 | var unit stronk.WeightUnit 62 | switch ps[1] { 63 | case "DECI_POUNDS": 64 | unit = stronk.DeciPounds 65 | default: 66 | return stronk.Weight{}, fmt.Errorf("unknown unit %q", ps[1]) 67 | } 68 | 69 | return stronk.Weight{ 70 | Unit: unit, 71 | Value: val, 72 | }, nil 73 | } 74 | 75 | func nullString(in string) sql.NullString { 76 | if in == "" { 77 | return sql.NullString{Valid: false} 78 | } 79 | return sql.NullString{Valid: true, String: in} 80 | } 81 | -------------------------------------------------------------------------------- /frontend/.env: -------------------------------------------------------------------------------- 1 | PUBLIC_API_BASE_URL=http://localhost:8080 2 | -------------------------------------------------------------------------------- /frontend/.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /frontend/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: [ 4 | 'eslint:recommended', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:svelte/recommended', 7 | 'prettier' 8 | ], 9 | parser: '@typescript-eslint/parser', 10 | plugins: ['@typescript-eslint'], 11 | parserOptions: { 12 | sourceType: 'module', 13 | ecmaVersion: 2020, 14 | extraFileExtensions: ['.svelte'] 15 | }, 16 | env: { 17 | browser: true, 18 | es2017: true, 19 | node: true 20 | }, 21 | overrides: [ 22 | { 23 | files: ['*.svelte'], 24 | parser: 'svelte-eslint-parser', 25 | parserOptions: { 26 | parser: '@typescript-eslint/parser' 27 | } 28 | } 29 | ] 30 | }; 31 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env.* 7 | !.env.example 8 | vite.config.js.timestamp-* 9 | vite.config.ts.timestamp-* 10 | -------------------------------------------------------------------------------- /frontend/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | resolution-mode=highest 3 | -------------------------------------------------------------------------------- /frontend/.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte"], 7 | "pluginSearchDirs": ["."], 8 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 9 | } 10 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # npm run build:cloud 2 | # docker build -t 192.168.5.3:5000/stronk-fe . 3 | FROM gcr.io/distroless/nodejs20-debian12 4 | COPY ./build/ /app 5 | COPY package.json /app 6 | WORKDIR /app 7 | CMD ["/app"] 8 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # stronk frontend 2 | 3 | A barebones SvelteKit/TypeScript frontend that allows viewing + recording lifts, and setting training maxes. 4 | 5 | ## Developing 6 | 7 | Once you've installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: 8 | 9 | ```bash 10 | npm run dev 11 | 12 | # or start the server and open the app in a new browser tab 13 | npm run dev -- --open 14 | ``` 15 | 16 | ## Building 17 | 18 | To create a more 'production' version of app: 19 | 20 | ```bash 21 | npm run build:dev 22 | ``` 23 | 24 | You can preview the production build with `npm run preview`. 25 | 26 | ## Deploying 27 | 28 | To build the actual production version of the app, run: 29 | 30 | ```bash 31 | npm run build:cloud 32 | ``` 33 | 34 | This is kind of a misnomer, as it uses the default Node adapter and can then be packaged up into a Docker image with: 35 | 36 | ```bash 37 | docker build -t /stronk-fe . 38 | ``` 39 | 40 | Personally, I deploy it on a homelab k8s cluster, but this same image should be fine to deploy on any cloud provider that can run Docker image (e.g. AWS Lambda or Fargate, GCP Cloud Run or Functions or App Engine Flex, Azure App Services, etc). See [the main README](/README.md) for more deployment details. 41 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stronk-frontend", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite dev", 7 | "build:dev": "vite build", 8 | "build:cloud": "vite build --mode cloud", 9 | "preview": "vite preview", 10 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 11 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 12 | "lint": "prettier --plugin-search-dir . --check . && eslint .", 13 | "format": "prettier --plugin-search-dir . --write ." 14 | }, 15 | "devDependencies": { 16 | "@sveltejs/adapter-node": "^4.0.1", 17 | "@sveltejs/kit": "^2.0.0", 18 | "@sveltejs/vite-plugin-svelte": "^3.0.0", 19 | "@typescript-eslint/eslint-plugin": "^5.45.0", 20 | "@typescript-eslint/parser": "^5.45.0", 21 | "eslint": "^8.28.0", 22 | "eslint-config-prettier": "^8.5.0", 23 | "eslint-plugin-svelte": "^2.30.0", 24 | "prettier": "^2.8.0", 25 | "prettier-plugin-svelte": "^2.10.1", 26 | "svelte": "^4.0.0", 27 | "svelte-check": "^3.4.3", 28 | "tslib": "^2.4.1", 29 | "typescript": "^5.0.0", 30 | "vite": "^5.0.0" 31 | }, 32 | "type": "module" 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface Platform {} 9 | } 10 | } 11 | 12 | export {}; 13 | -------------------------------------------------------------------------------- /frontend/src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /frontend/src/lib/Modal.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 21 | 22 | 69 | -------------------------------------------------------------------------------- /frontend/src/lib/api/index.ts: -------------------------------------------------------------------------------- 1 | export interface Weight { 2 | Unit: string; 3 | Value: number; 4 | } 5 | 6 | export type SetType = 'WARMUP' | 'MAIN' | 'ASSISTANCE'; 7 | export type Exercise = 'OVERHEAD_PRESS' | 'SQUAT' | 'BENCH_PRESS' | 'DEADLIFT'; 8 | 9 | export interface Set { 10 | RepTarget: number; 11 | ToFailure: boolean; 12 | TrainingMaxPercentage: number; 13 | WeightTarget: Weight; 14 | FailureComparables?: ComparableLifts; 15 | AssociatedLiftID?: number; 16 | } 17 | 18 | export interface Movement { 19 | Exercise: Exercise; 20 | SetType: SetType; 21 | Sets: Set[]; 22 | } 23 | 24 | export interface Lift { 25 | ID: number; 26 | Exercise: Exercise; 27 | SetType: SetType; 28 | Weight: Weight; 29 | SetNumber: number; 30 | Reps: number; 31 | Note: string; 32 | 33 | DayNumber: number; 34 | WeekNumber: number; 35 | IterationNumber: number; 36 | ToFailure: boolean; 37 | } 38 | 39 | export interface ComparableLifts { 40 | ClosestWeight?: Lift; 41 | PersonalRecord?: Lift; 42 | PREquivalentReps: number; 43 | } 44 | 45 | export interface RecordLiftResponse { 46 | LiftID: number; 47 | NextLift: NextLiftResponse; 48 | } 49 | 50 | export interface NextLiftResponse { 51 | DayNumber: number; 52 | WeekNumber: number; 53 | IterationNumber: number; 54 | DayName: string; 55 | WeekName: string; 56 | Workout: Movement[]; 57 | NextMovementIndex: number; 58 | NextSetIndex: number; 59 | OptionalWeek: boolean; 60 | } 61 | 62 | export interface TrainingMax { 63 | Max: Weight; 64 | Exercise: Exercise; 65 | } 66 | 67 | export interface TrainingMaxesResponse { 68 | TrainingMaxes: TrainingMax[]; 69 | SmallestDenom?: string; 70 | LatestFailureSets?: Lift[][]; 71 | } 72 | 73 | export interface SetTrainingMaxesRequest { 74 | OverheadPress: string; 75 | Squat: string; 76 | BenchPress: string; 77 | Deadlift: string; 78 | SmallestDenom: string; 79 | } 80 | 81 | export interface RecordLiftRequest { 82 | Exercise: Exercise; 83 | SetType: SetType; 84 | Weight: string; 85 | Set: number; 86 | Reps: number; 87 | Note: string; 88 | Day: number; 89 | Week: number; 90 | Iteration: number; 91 | ToFailure: boolean; 92 | } 93 | 94 | export interface SkipOptionalWeekRequest { 95 | Week: number; 96 | Iteration: number; 97 | Note: string; 98 | } 99 | -------------------------------------------------------------------------------- /frontend/src/lib/apipath/index.ts: -------------------------------------------------------------------------------- 1 | import { PUBLIC_API_BASE_URL } from '$env/static/public'; 2 | import { browser, dev } from '$app/environment'; 3 | 4 | const apipath = (path: string, params?: Record) => { 5 | const baseURL = browser && !dev ? '' : PUBLIC_API_BASE_URL; 6 | const query = params ? `?${new URLSearchParams(params)}` : ''; 7 | return `${baseURL}${path}${query}`; 8 | }; 9 | 10 | export default apipath; 11 | -------------------------------------------------------------------------------- /frontend/src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 158 | 159 |
160 | 161 | {#if editingLift} 162 |

Edit Lift ID #{editingLift.ID}

163 | {:else} 164 |

Edit Lift

165 | {/if} 166 |
167 | 168 | 169 | 170 |
171 |