From c322527dfa694f06e7a233a5f41b58e691311763 Mon Sep 17 00:00:00 2001 From: frag223 Date: Wed, 17 Jun 2026 21:37:54 +1000 Subject: [PATCH 01/98] adding init --- .covignore | 1 + .gitignore | 3 +++ .golangci.yaml | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ Taskfile.yml | 45 +++++++++++++++++++++++++++++++++++++++++++++ cmd/rush/main.go | 8 ++++++++ go.mod | 3 +++ sqlc.yaml | 13 +++++++++++++ 7 files changed, 121 insertions(+) create mode 100644 .covignore create mode 100644 .golangci.yaml create mode 100644 Taskfile.yml create mode 100644 cmd/rush/main.go create mode 100644 go.mod create mode 100644 sqlc.yaml diff --git a/.covignore b/.covignore new file mode 100644 index 0000000..87c6170 --- /dev/null +++ b/.covignore @@ -0,0 +1 @@ +.gen.go \ No newline at end of file diff --git a/.gitignore b/.gitignore index aaadf73..5b9295d 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ go.work.sum # env file .env +.env.local +.idea +.junie # Editor/IDE # .idea/ diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..b438533 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,48 @@ +version: "2" + +linters: + enable: + - cyclop + - nestif + - bodyclose + - iface + - gosec + - errcheck + - errchkjson + - errname + - errorlint + - exptostd + - fatcontext + - forbidigo + - forcetypeassert + - govet + - importas + - ireturn + - perfsprint + - recvcheck + - sloglint + - staticcheck + - unparam + - unused + - wastedassign + exclusions: + generated: lax + + rules: + # Exclude some linters from running on tests files. + - path: _test\.go + linters: + - gocyclo + - cyclop + - dupl + - gosec + - forbidigo + - exhaustruct + + settings: + depguard: + rules: + main: + list-mode: lax + + diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..5ebeb14 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,45 @@ +version: '3' + +tasks: + lint: + desc: Run golangci-lint + cmds: + - golangci-lint run ./... + + format: + desc: Format Go code and tidy modules + cmds: + - go fmt ./... + - go mod tidy + + sqlc: + desc: Generate Go code from SQL queries using sqlc + sources: + - sqlc.yaml + - "**/*.sql" + generates: + - "**/*.gen.go" + cmds: + - sqlc generate + + # Placeholder for migrations as requested "sqlc for sql lite migrations" + # This task assumes you might use a tool like goose or atlas for migrations. + # sqlc itself is used to generate Go code from the migration schema/queries. + migrate: + desc: Run SQLite migrations (adjust command to your migration tool) + cmds: + - echo "Running migrations..." + # Example with goose: + # - goose -dir internal/db/migrations sqlite3 ./rush.db up + + generate: + desc: Run all code generation + cmds: + - task: sqlc + + ci: + desc: Run ci tasks + cmds: + - task: generate + - task: format + - task: lint diff --git a/cmd/rush/main.go b/cmd/rush/main.go new file mode 100644 index 0000000..d37e834 --- /dev/null +++ b/cmd/rush/main.go @@ -0,0 +1,8 @@ +package main + +import "log/slog" + +func main() { + + slog.Info("Hello, world!") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..84ddb57 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/code-gorilla-au/rush + +go 1.26.3 diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..2503b60 --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,13 @@ +version: "2" +sql: + - schema: "internal/database/schema" + queries: "internal/database/queries" + engine: "sqlite" + gen: + go: + package: "database" + out: "internal/database" + output_db_file_name: "db.gen.go" + output_models_file_name: "models.gen.go" + output_querier_file_name: "querier.gen.go" + output_files_suffix: ".gen" From fd60bfe1fc3cef77734aaf2d303cb16b1a274d63 Mon Sep 17 00:00:00 2001 From: frag223 Date: Wed, 17 Jun 2026 21:48:00 +1000 Subject: [PATCH 02/98] bare min model for boilerplate --- .gitignore | 2 + internal/database/db.gen.go | 31 ++++++++ internal/database/models.gen.go | 26 +++++++ internal/database/queries/team.sql | 15 ++++ internal/database/schema/team.sql | 16 ++++ internal/database/team.sql.gen.go | 114 +++++++++++++++++++++++++++++ 6 files changed, 204 insertions(+) create mode 100644 internal/database/db.gen.go create mode 100644 internal/database/models.gen.go create mode 100644 internal/database/queries/team.sql create mode 100644 internal/database/schema/team.sql create mode 100644 internal/database/team.sql.gen.go diff --git a/.gitignore b/.gitignore index 5b9295d..24889b4 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,8 @@ go.work.sum .env.local .idea .junie +.db +.task # Editor/IDE # .idea/ diff --git a/internal/database/db.gen.go b/internal/database/db.gen.go new file mode 100644 index 0000000..85d4b8c --- /dev/null +++ b/internal/database/db.gen.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package database + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/database/models.gen.go b/internal/database/models.gen.go new file mode 100644 index 0000000..9e65e75 --- /dev/null +++ b/internal/database/models.gen.go @@ -0,0 +1,26 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package database + +import ( + "database/sql" +) + +type Coach struct { + ID interface{} + Name string +} + +type Player struct { + ID interface{} + Name string + TeamID sql.NullInt64 +} + +type Team struct { + ID interface{} + Name string + CoachID sql.NullInt64 +} diff --git a/internal/database/queries/team.sql b/internal/database/queries/team.sql new file mode 100644 index 0000000..d4ae40c --- /dev/null +++ b/internal/database/queries/team.sql @@ -0,0 +1,15 @@ +-- name: GetCoach :one +SELECT * FROM teams WHERE id = ?; + +-- name: GetCoaches :many +SELECT * FROM coaches; + +-- name: GetTeams :many +SELECT * FROM teams; + +-- name: GetTeam :one +SELECT * FROM teams WHERE id = ?; + +-- name: GetTeamMembers :many +SELECT * FROM players WHERE team_id = ?; + diff --git a/internal/database/schema/team.sql b/internal/database/schema/team.sql new file mode 100644 index 0000000..21bf9d1 --- /dev/null +++ b/internal/database/schema/team.sql @@ -0,0 +1,16 @@ +create table if not exists coaches ( + id serial primary key, + name varchar(255) not null +); + +create table if not exists teams ( + id serial primary key, + name varchar(255) not null, + coach_id integer references coach(id) +) + +create table if not exists players ( + id serial primary key, + name varchar(255) not null, + team_id integer references team(id) +); \ No newline at end of file diff --git a/internal/database/team.sql.gen.go b/internal/database/team.sql.gen.go new file mode 100644 index 0000000..3193dac --- /dev/null +++ b/internal/database/team.sql.gen.go @@ -0,0 +1,114 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: team.sql + +package database + +import ( + "context" + "database/sql" +) + +const getCoach = `-- name: GetCoach :one +SELECT id, name, coach_id FROM teams WHERE id = ? +` + +func (q *Queries) GetCoach(ctx context.Context, id interface{}) (Team, error) { + row := q.db.QueryRowContext(ctx, getCoach, id) + var i Team + err := row.Scan(&i.ID, &i.Name, &i.CoachID) + return i, err +} + +const getCoaches = `-- name: GetCoaches :many +SELECT id, name FROM coaches +` + +func (q *Queries) GetCoaches(ctx context.Context) ([]Coach, error) { + rows, err := q.db.QueryContext(ctx, getCoaches) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Coach + for rows.Next() { + var i Coach + if err := rows.Scan(&i.ID, &i.Name); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getTeam = `-- name: GetTeam :one +SELECT id, name, coach_id FROM teams WHERE id = ? +` + +func (q *Queries) GetTeam(ctx context.Context, id interface{}) (Team, error) { + row := q.db.QueryRowContext(ctx, getTeam, id) + var i Team + err := row.Scan(&i.ID, &i.Name, &i.CoachID) + return i, err +} + +const getTeamMembers = `-- name: GetTeamMembers :many +SELECT id, name, team_id FROM players WHERE team_id = ? +` + +func (q *Queries) GetTeamMembers(ctx context.Context, teamID sql.NullInt64) ([]Player, error) { + rows, err := q.db.QueryContext(ctx, getTeamMembers, teamID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Player + for rows.Next() { + var i Player + if err := rows.Scan(&i.ID, &i.Name, &i.TeamID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getTeams = `-- name: GetTeams :many +SELECT id, name, coach_id FROM teams +` + +func (q *Queries) GetTeams(ctx context.Context) ([]Team, error) { + rows, err := q.db.QueryContext(ctx, getTeams) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Team + for rows.Next() { + var i Team + if err := rows.Scan(&i.ID, &i.Name, &i.CoachID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} From da771d2dc67ffbd237cf33074cafa8b52992625f Mon Sep 17 00:00:00 2001 From: frag223 Date: Wed, 17 Jun 2026 22:03:08 +1000 Subject: [PATCH 03/98] adding more queries --- internal/database/models.gen.go | 22 ++++-- internal/database/queries/team.sql | 17 +++++ internal/database/schema/team.sql | 12 ++- internal/database/team.sql.gen.go | 113 ++++++++++++++++++++++++++--- 4 files changed, 143 insertions(+), 21 deletions(-) diff --git a/internal/database/models.gen.go b/internal/database/models.gen.go index 9e65e75..4047e9f 100644 --- a/internal/database/models.gen.go +++ b/internal/database/models.gen.go @@ -9,18 +9,24 @@ import ( ) type Coach struct { - ID interface{} - Name string + ID interface{} + Name string + CreatedAt sql.NullTime + UpdatedAt sql.NullTime } type Player struct { - ID interface{} - Name string - TeamID sql.NullInt64 + ID interface{} + Name string + TeamID sql.NullInt64 + CreatedAt sql.NullTime + UpdatedAt sql.NullTime } type Team struct { - ID interface{} - Name string - CoachID sql.NullInt64 + ID interface{} + Name string + CoachID sql.NullInt64 + CreatedAt sql.NullTime + UpdatedAt sql.NullTime } diff --git a/internal/database/queries/team.sql b/internal/database/queries/team.sql index d4ae40c..1ef1322 100644 --- a/internal/database/queries/team.sql +++ b/internal/database/queries/team.sql @@ -4,12 +4,29 @@ SELECT * FROM teams WHERE id = ?; -- name: GetCoaches :many SELECT * FROM coaches; +-- name: CreateCoach :exec +INSERT INTO coaches (name) VALUES (?) RETURNING *; + +-- name: DeleteCoach :exec +DELETE FROM coaches WHERE id = ?; + -- name: GetTeams :many SELECT * FROM teams; -- name: GetTeam :one SELECT * FROM teams WHERE id = ?; +-- name: CreateTeam :exec +INSERT INTO teams (name, coach_id) VALUES (?, ?) RETURNING *; + +-- name: DeleteTeam :exec +DELETE FROM teams WHERE id = ?; + -- name: GetTeamMembers :many SELECT * FROM players WHERE team_id = ?; +-- name: CreatePlayer :exec +INSERT INTO players (name, team_id) VALUES (?,?) RETURNING *; + +-- name: DeletePlayer :exec +DELETE FROM players WHERE id = ?; \ No newline at end of file diff --git a/internal/database/schema/team.sql b/internal/database/schema/team.sql index 21bf9d1..6ce8bbe 100644 --- a/internal/database/schema/team.sql +++ b/internal/database/schema/team.sql @@ -1,16 +1,22 @@ create table if not exists coaches ( id serial primary key, - name varchar(255) not null + name varchar(255) not null, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); create table if not exists teams ( id serial primary key, name varchar(255) not null, - coach_id integer references coach(id) + coach_id integer references coach(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) create table if not exists players ( id serial primary key, name varchar(255) not null, - team_id integer references team(id) + team_id integer references team(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); \ No newline at end of file diff --git a/internal/database/team.sql.gen.go b/internal/database/team.sql.gen.go index 3193dac..815e72c 100644 --- a/internal/database/team.sql.gen.go +++ b/internal/database/team.sql.gen.go @@ -10,19 +10,89 @@ import ( "database/sql" ) +const createCoach = `-- name: CreateCoach :exec +INSERT INTO coaches (name) VALUES (?) RETURNING id, name, created_at, updated_at +` + +func (q *Queries) CreateCoach(ctx context.Context, name string) error { + _, err := q.db.ExecContext(ctx, createCoach, name) + return err +} + +const createPlayer = `-- name: CreatePlayer :exec +INSERT INTO players (name, team_id) VALUES (?,?) RETURNING id, name, team_id, created_at, updated_at +` + +type CreatePlayerParams struct { + Name string + TeamID sql.NullInt64 +} + +func (q *Queries) CreatePlayer(ctx context.Context, arg CreatePlayerParams) error { + _, err := q.db.ExecContext(ctx, createPlayer, arg.Name, arg.TeamID) + return err +} + +const createTeam = `-- name: CreateTeam :exec +INSERT INTO teams (name, coach_id) VALUES (?, ?) RETURNING id, name, coach_id, created_at, updated_at +` + +type CreateTeamParams struct { + Name string + CoachID sql.NullInt64 +} + +func (q *Queries) CreateTeam(ctx context.Context, arg CreateTeamParams) error { + _, err := q.db.ExecContext(ctx, createTeam, arg.Name, arg.CoachID) + return err +} + +const deleteCoach = `-- name: DeleteCoach :exec +DELETE FROM coaches WHERE id = ? +` + +func (q *Queries) DeleteCoach(ctx context.Context, id interface{}) error { + _, err := q.db.ExecContext(ctx, deleteCoach, id) + return err +} + +const deletePlayer = `-- name: DeletePlayer :exec +DELETE FROM players WHERE id = ? +` + +func (q *Queries) DeletePlayer(ctx context.Context, id interface{}) error { + _, err := q.db.ExecContext(ctx, deletePlayer, id) + return err +} + +const deleteTeam = `-- name: DeleteTeam :exec +DELETE FROM teams WHERE id = ? +` + +func (q *Queries) DeleteTeam(ctx context.Context, id interface{}) error { + _, err := q.db.ExecContext(ctx, deleteTeam, id) + return err +} + const getCoach = `-- name: GetCoach :one -SELECT id, name, coach_id FROM teams WHERE id = ? +SELECT id, name, coach_id, created_at, updated_at FROM teams WHERE id = ? ` func (q *Queries) GetCoach(ctx context.Context, id interface{}) (Team, error) { row := q.db.QueryRowContext(ctx, getCoach, id) var i Team - err := row.Scan(&i.ID, &i.Name, &i.CoachID) + err := row.Scan( + &i.ID, + &i.Name, + &i.CoachID, + &i.CreatedAt, + &i.UpdatedAt, + ) return i, err } const getCoaches = `-- name: GetCoaches :many -SELECT id, name FROM coaches +SELECT id, name, created_at, updated_at FROM coaches ` func (q *Queries) GetCoaches(ctx context.Context) ([]Coach, error) { @@ -34,7 +104,12 @@ func (q *Queries) GetCoaches(ctx context.Context) ([]Coach, error) { var items []Coach for rows.Next() { var i Coach - if err := rows.Scan(&i.ID, &i.Name); err != nil { + if err := rows.Scan( + &i.ID, + &i.Name, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { return nil, err } items = append(items, i) @@ -49,18 +124,24 @@ func (q *Queries) GetCoaches(ctx context.Context) ([]Coach, error) { } const getTeam = `-- name: GetTeam :one -SELECT id, name, coach_id FROM teams WHERE id = ? +SELECT id, name, coach_id, created_at, updated_at FROM teams WHERE id = ? ` func (q *Queries) GetTeam(ctx context.Context, id interface{}) (Team, error) { row := q.db.QueryRowContext(ctx, getTeam, id) var i Team - err := row.Scan(&i.ID, &i.Name, &i.CoachID) + err := row.Scan( + &i.ID, + &i.Name, + &i.CoachID, + &i.CreatedAt, + &i.UpdatedAt, + ) return i, err } const getTeamMembers = `-- name: GetTeamMembers :many -SELECT id, name, team_id FROM players WHERE team_id = ? +SELECT id, name, team_id, created_at, updated_at FROM players WHERE team_id = ? ` func (q *Queries) GetTeamMembers(ctx context.Context, teamID sql.NullInt64) ([]Player, error) { @@ -72,7 +153,13 @@ func (q *Queries) GetTeamMembers(ctx context.Context, teamID sql.NullInt64) ([]P var items []Player for rows.Next() { var i Player - if err := rows.Scan(&i.ID, &i.Name, &i.TeamID); err != nil { + if err := rows.Scan( + &i.ID, + &i.Name, + &i.TeamID, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { return nil, err } items = append(items, i) @@ -87,7 +174,7 @@ func (q *Queries) GetTeamMembers(ctx context.Context, teamID sql.NullInt64) ([]P } const getTeams = `-- name: GetTeams :many -SELECT id, name, coach_id FROM teams +SELECT id, name, coach_id, created_at, updated_at FROM teams ` func (q *Queries) GetTeams(ctx context.Context) ([]Team, error) { @@ -99,7 +186,13 @@ func (q *Queries) GetTeams(ctx context.Context) ([]Team, error) { var items []Team for rows.Next() { var i Team - if err := rows.Scan(&i.ID, &i.Name, &i.CoachID); err != nil { + if err := rows.Scan( + &i.ID, + &i.Name, + &i.CoachID, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { return nil, err } items = append(items, i) From a3e9e87dffee72d33fa8ce3f8b7f7151e5b8e264 Mon Sep 17 00:00:00 2001 From: frag223 Date: Wed, 17 Jun 2026 22:04:37 +1000 Subject: [PATCH 04/98] update taskfile --- Taskfile.yml | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index 5ebeb14..303114d 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -5,6 +5,7 @@ tasks: desc: Run golangci-lint cmds: - golangci-lint run ./... + - sqlc vet format: desc: Format Go code and tidy modules @@ -22,16 +23,6 @@ tasks: cmds: - sqlc generate - # Placeholder for migrations as requested "sqlc for sql lite migrations" - # This task assumes you might use a tool like goose or atlas for migrations. - # sqlc itself is used to generate Go code from the migration schema/queries. - migrate: - desc: Run SQLite migrations (adjust command to your migration tool) - cmds: - - echo "Running migrations..." - # Example with goose: - # - goose -dir internal/db/migrations sqlite3 ./rush.db up - generate: desc: Run all code generation cmds: From 0d0602796e9cfabf7e89656083662f2f49bcb8dc Mon Sep 17 00:00:00 2001 From: frag223 Date: Wed, 17 Jun 2026 22:19:17 +1000 Subject: [PATCH 05/98] adding migrator --- Taskfile.yml | 35 ++++++++++++++++ go.mod | 14 +++++++ go.sum | 51 ++++++++++++++++++++++++ internal/database/migrator.go | 64 ++++++++++++++++++++++++++++++ internal/database/migrator_test.go | 45 +++++++++++++++++++++ internal/database/models.gen.go | 6 +-- internal/database/schema/team.sql | 12 +++--- internal/database/team.sql.gen.go | 10 ++--- 8 files changed, 223 insertions(+), 14 deletions(-) create mode 100644 go.sum create mode 100644 internal/database/migrator.go create mode 100644 internal/database/migrator_test.go diff --git a/Taskfile.yml b/Taskfile.yml index 303114d..573e7dc 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -1,5 +1,9 @@ version: '3' +vars: + GOLANG_WORKSPACES: ./... + GOLANG_COVERAGE: coverage.out + tasks: lint: desc: Run golangci-lint @@ -34,3 +38,34 @@ tasks: - task: generate - task: format - task: lint + + + test: + desc: Run go tests. + deps: [generate] + cmds: + - go test -cover -coverprofile={{ .GOLANG_COVERAGE }} -failfast {{ .GOLANG_WORKSPACES }} + + watch: + desc: Run go tests in watch mode. + cmds: + - gow test -cover -coverprofile={{ .GOLANG_COVERAGE }} -failfast {{ .GOLANG_WORKSPACES }} + + gen-coverage: + desc: Generate go coverage. + deps: [test] + cmds: + - grep -v -E -f ${PWD}/.covignore {{ .GOLANG_COVERAGE }} > coverage.filtered.out + - mv coverage.filtered.out {{ .GOLANG_COVERAGE }} + + cover: + desc: Run go coverage. + deps: [gen-coverage] + cmds: + - go tool cover -func={{ .GOLANG_COVERAGE }} | grep total + + cover-html: + desc: Run go coverage html. + deps: [gen-coverage] + cmds: + - go tool cover -html={{ .GOLANG_COVERAGE }} \ No newline at end of file diff --git a/go.mod b/go.mod index 84ddb57..00a1129 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,17 @@ module github.com/code-gorilla-au/rush go 1.26.3 + +require modernc.org/sqlite v1.52.0 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.72.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0391e71 --- /dev/null +++ b/go.sum @@ -0,0 +1,51 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo= +modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/database/migrator.go b/internal/database/migrator.go new file mode 100644 index 0000000..40df3d5 --- /dev/null +++ b/internal/database/migrator.go @@ -0,0 +1,64 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + "io/fs" + "slices" + "strings" +) + +import "embed" + +//go:embed schema/*.sql +var SchemaFS embed.FS + +// Service defines the interface for database migrations. +type Service interface { + Migrate(ctx context.Context) error +} + +// Migrator handles database schema migrations. +type Migrator struct { + db *sql.DB + fs fs.FS +} + +// NewMigrator creates a new Migrator instance that implements the Service interface. +func NewMigrator(db *sql.DB, fsys fs.FS) Service { + return &Migrator{ + db: db, + fs: fsys, + } +} + +// Migrate executes all SQL files found in the schema directory. +func (m *Migrator) Migrate(ctx context.Context) error { + entries, err := fs.ReadDir(m.fs, "schema") + if err != nil { + return fmt.Errorf("reading schema directory: %w", err) + } + + var sqlFiles []string + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") { + sqlFiles = append(sqlFiles, entry.Name()) + } + } + + slices.Sort(sqlFiles) + + for _, fileName := range sqlFiles { + content, err := fs.ReadFile(m.fs, "schema/"+fileName) + if err != nil { + return fmt.Errorf("reading schema file %s: %w", fileName, err) + } + + if _, err := m.db.ExecContext(ctx, string(content)); err != nil { + return fmt.Errorf("executing schema file %s: %w", fileName, err) + } + } + + return nil +} diff --git a/internal/database/migrator_test.go b/internal/database/migrator_test.go new file mode 100644 index 0000000..2ec77d4 --- /dev/null +++ b/internal/database/migrator_test.go @@ -0,0 +1,45 @@ +package database + +import ( + "database/sql" + "testing" + + _ "modernc.org/sqlite" +) + +func TestMigrator_Migrate(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("failed to open database: %v", err) + } + defer db.Close() + + migrator := NewMigrator(db, SchemaFS) + + if err := migrator.Migrate(t.Context()); err != nil { + t.Fatalf("failed to migrate: %v", err) + } + + // Verify that tables were created + rows, err := db.Query("SELECT name FROM sqlite_master WHERE type='table'") + if err != nil { + t.Fatalf("failed to query tables: %v", err) + } + defer rows.Close() + + tables := make(map[string]bool) + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + t.Fatalf("failed to scan table name: %v", err) + } + tables[name] = true + } + + expectedTables := []string{"coaches", "teams", "players"} + for _, table := range expectedTables { + if !tables[table] { + t.Errorf("expected table %s not found", table) + } + } +} diff --git a/internal/database/models.gen.go b/internal/database/models.gen.go index 4047e9f..0016ad6 100644 --- a/internal/database/models.gen.go +++ b/internal/database/models.gen.go @@ -9,14 +9,14 @@ import ( ) type Coach struct { - ID interface{} + ID int64 Name string CreatedAt sql.NullTime UpdatedAt sql.NullTime } type Player struct { - ID interface{} + ID int64 Name string TeamID sql.NullInt64 CreatedAt sql.NullTime @@ -24,7 +24,7 @@ type Player struct { } type Team struct { - ID interface{} + ID int64 Name string CoachID sql.NullInt64 CreatedAt sql.NullTime diff --git a/internal/database/schema/team.sql b/internal/database/schema/team.sql index 6ce8bbe..94a585e 100644 --- a/internal/database/schema/team.sql +++ b/internal/database/schema/team.sql @@ -1,22 +1,22 @@ create table if not exists coaches ( - id serial primary key, + id integer primary key autoincrement, name varchar(255) not null, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); create table if not exists teams ( - id serial primary key, + id integer primary key autoincrement, name varchar(255) not null, - coach_id integer references coach(id), + coach_id integer references coaches(id), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -) +); create table if not exists players ( - id serial primary key, + id integer primary key autoincrement, name varchar(255) not null, - team_id integer references team(id), + team_id integer references teams(id), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); \ No newline at end of file diff --git a/internal/database/team.sql.gen.go b/internal/database/team.sql.gen.go index 815e72c..f7c05de 100644 --- a/internal/database/team.sql.gen.go +++ b/internal/database/team.sql.gen.go @@ -51,7 +51,7 @@ const deleteCoach = `-- name: DeleteCoach :exec DELETE FROM coaches WHERE id = ? ` -func (q *Queries) DeleteCoach(ctx context.Context, id interface{}) error { +func (q *Queries) DeleteCoach(ctx context.Context, id int64) error { _, err := q.db.ExecContext(ctx, deleteCoach, id) return err } @@ -60,7 +60,7 @@ const deletePlayer = `-- name: DeletePlayer :exec DELETE FROM players WHERE id = ? ` -func (q *Queries) DeletePlayer(ctx context.Context, id interface{}) error { +func (q *Queries) DeletePlayer(ctx context.Context, id int64) error { _, err := q.db.ExecContext(ctx, deletePlayer, id) return err } @@ -69,7 +69,7 @@ const deleteTeam = `-- name: DeleteTeam :exec DELETE FROM teams WHERE id = ? ` -func (q *Queries) DeleteTeam(ctx context.Context, id interface{}) error { +func (q *Queries) DeleteTeam(ctx context.Context, id int64) error { _, err := q.db.ExecContext(ctx, deleteTeam, id) return err } @@ -78,7 +78,7 @@ const getCoach = `-- name: GetCoach :one SELECT id, name, coach_id, created_at, updated_at FROM teams WHERE id = ? ` -func (q *Queries) GetCoach(ctx context.Context, id interface{}) (Team, error) { +func (q *Queries) GetCoach(ctx context.Context, id int64) (Team, error) { row := q.db.QueryRowContext(ctx, getCoach, id) var i Team err := row.Scan( @@ -127,7 +127,7 @@ const getTeam = `-- name: GetTeam :one SELECT id, name, coach_id, created_at, updated_at FROM teams WHERE id = ? ` -func (q *Queries) GetTeam(ctx context.Context, id interface{}) (Team, error) { +func (q *Queries) GetTeam(ctx context.Context, id int64) (Team, error) { row := q.db.QueryRowContext(ctx, getTeam, id) var i Team err := row.Scan( From c35468b804953121aadc4be16e4217e06b141be7 Mon Sep 17 00:00:00 2001 From: frag223 Date: Wed, 17 Jun 2026 22:34:35 +1000 Subject: [PATCH 06/98] min working db --- cmd/rush/main.go | 33 ++++++++++++++++++++++++++++++++- go.mod | 2 ++ go.sum | 4 ++++ internal/database/db.go | 17 +++++++++++++++++ 4 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 internal/database/db.go diff --git a/cmd/rush/main.go b/cmd/rush/main.go index d37e834..439b7a1 100644 --- a/cmd/rush/main.go +++ b/cmd/rush/main.go @@ -1,8 +1,39 @@ package main -import "log/slog" +import ( + "context" + "log/slog" + "os" + + "github.com/code-gorilla-au/env" + "github.com/code-gorilla-au/rush/internal/database" +) func main() { + ctx := context.Background() + + env.LoadEnvFile(".env.local") + enUrl := env.GetAsString("DATABASE_URL") + + db, err := database.NewSqLiteProvider(enUrl) + if err != nil { + slog.Error("Failed to create database provider", "error", err) + os.Exit(1) + + } + + defer func() { + dbErr := db.Close() + if dbErr != nil { + slog.Error("Failed to close database", "error", err) + } + }() + + migrator := database.NewMigrator(db, database.SchemaFS) + if mErr := migrator.Migrate(ctx); mErr != nil { + slog.Error("Failed to migrate database", "error", mErr) + os.Exit(1) + } slog.Info("Hello, world!") } diff --git a/go.mod b/go.mod index 00a1129..df3d6ea 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,10 @@ go 1.26.3 require modernc.org/sqlite v1.52.0 require ( + github.com/code-gorilla-au/env v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/joho/godotenv v1.5.1 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect diff --git a/go.sum b/go.sum index 0391e71..8dc40b9 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/code-gorilla-au/env v1.1.1 h1:4rkSwCnyymKh+KGAOPx3fEg9v2ZV5i9r92bSf7xvnCE= +github.com/code-gorilla-au/env v1.1.1/go.mod h1:KE4Ymfz5MhMi7SX3ZKH4iMFAHsDCvwOV8WTzgpwzzE4= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= @@ -6,6 +8,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..36936fd --- /dev/null +++ b/internal/database/db.go @@ -0,0 +1,17 @@ +package database + +import ( + "database/sql" + + _ "modernc.org/sqlite" +) + +func NewSqLiteProvider(dbPath string) (*sql.DB, error) { + + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, err + } + + return db, nil +} From 39d0b816d53865ea7e2f614fe2b4f4cddf6b4bf0 Mon Sep 17 00:00:00 2001 From: frag223 Date: Wed, 17 Jun 2026 22:37:03 +1000 Subject: [PATCH 07/98] adding config --- cmd/rush/config.go | 13 +++++++++++++ cmd/rush/main.go | 5 ++--- 2 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 cmd/rush/config.go diff --git a/cmd/rush/config.go b/cmd/rush/config.go new file mode 100644 index 0000000..350d0eb --- /dev/null +++ b/cmd/rush/config.go @@ -0,0 +1,13 @@ +package main + +import "github.com/code-gorilla-au/env" + +type Config struct { + DatabaseUrl string +} + +func NewConfig() *Config { + return &Config{ + DatabaseUrl: env.GetAsString("DATABASE_URL"), + } +} diff --git a/cmd/rush/main.go b/cmd/rush/main.go index 439b7a1..478d68b 100644 --- a/cmd/rush/main.go +++ b/cmd/rush/main.go @@ -11,11 +11,10 @@ import ( func main() { ctx := context.Background() - env.LoadEnvFile(".env.local") - enUrl := env.GetAsString("DATABASE_URL") + config := NewConfig() - db, err := database.NewSqLiteProvider(enUrl) + db, err := database.NewSqLiteProvider(config.DatabaseUrl) if err != nil { slog.Error("Failed to create database provider", "error", err) os.Exit(1) From 9e4035c94f854e6b65914247ab2d64448d05ffb3 Mon Sep 17 00:00:00 2001 From: frag223 Date: Wed, 17 Jun 2026 22:38:42 +1000 Subject: [PATCH 08/98] adding task --- Taskfile.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Taskfile.yml b/Taskfile.yml index 573e7dc..ce138e3 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -5,6 +5,11 @@ vars: GOLANG_COVERAGE: coverage.out tasks: + dev: + desc: Run dev + cmds: + - go run ./cmd/rush/... + lint: desc: Run golangci-lint cmds: From 06df70e060161eab181dee72493c18ffd6aafbb6 Mon Sep 17 00:00:00 2001 From: frag223 Date: Wed, 17 Jun 2026 22:49:00 +1000 Subject: [PATCH 09/98] wip adding ui --- cmd/rush/main.go | 9 ++++- go.mod | 17 +++++++- go.sum | 32 +++++++++++++++ internal/ui/app.go | 98 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 internal/ui/app.go diff --git a/cmd/rush/main.go b/cmd/rush/main.go index 478d68b..5a0a89e 100644 --- a/cmd/rush/main.go +++ b/cmd/rush/main.go @@ -5,8 +5,10 @@ import ( "log/slog" "os" + tea "charm.land/bubbletea/v2" "github.com/code-gorilla-au/env" "github.com/code-gorilla-au/rush/internal/database" + "github.com/code-gorilla-au/rush/internal/ui" ) func main() { @@ -18,7 +20,6 @@ func main() { if err != nil { slog.Error("Failed to create database provider", "error", err) os.Exit(1) - } defer func() { @@ -34,5 +35,9 @@ func main() { os.Exit(1) } - slog.Info("Hello, world!") + p := tea.NewProgram(ui.New()) + if _, err := p.Run(); err != nil { + slog.Error("Failed to run program", "error", err) + os.Exit(1) + } } diff --git a/go.mod b/go.mod index df3d6ea..e759a36 100644 --- a/go.mod +++ b/go.mod @@ -5,14 +5,29 @@ go 1.26.3 require modernc.org/sqlite v1.52.0 require ( + charm.land/bubbletea/v2 v2.0.7 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/code-gorilla-au/env v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/joho/godotenv v1.5.1 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/sys v0.42.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 8dc40b9..1430ef0 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,21 @@ +charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0= +charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 h1:4+r3uOJ69ueRBt4okgEfWZeXs3BD36HcDBmOIAUlETk= +github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/code-gorilla-au/env v1.1.1 h1:4rkSwCnyymKh+KGAOPx3fEg9v2ZV5i9r92bSf7xvnCE= github.com/code-gorilla-au/env v1.1.1/go.mod h1:KE4Ymfz5MhMi7SX3ZKH4iMFAHsDCvwOV8WTzgpwzzE4= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -10,19 +28,33 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= diff --git a/internal/ui/app.go b/internal/ui/app.go new file mode 100644 index 0000000..4892a4d --- /dev/null +++ b/internal/ui/app.go @@ -0,0 +1,98 @@ +package ui + +import ( + "strings" + + tea "charm.land/bubbletea/v2" +) + +type model struct { + width int + height int +} + +// New returns a new UI model. +func New() model { + return model{} +} + +func (m model) Init() tea.Cmd { + return nil +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + } + return m, nil +} + +const logo = ` + ____ _ _ ____ _ _ + | _ \| | | / ___|| | | | + | |_) | | | \___ \| |_| | + | _ <| |_| |___) | _ | + |_| \_\\___/|____/|_| |_| +` + +func (m model) View() tea.View { + if m.width == 0 || m.height == 0 { + return tea.NewView("Initializing...") + } + + logoLines := strings.Split(strings.Trim(logo, "\n"), "\n") + logoWidth := 0 + for _, line := range logoLines { + if len(line) > logoWidth { + logoWidth = len(line) + } + } + + leftPadding := (m.width - logoWidth) / 2 + topPadding := (m.height - len(logoLines) - 2) / 2 // -2 for spacing and footer + + if leftPadding < 0 { + leftPadding = 0 + } + if topPadding < 0 { + topPadding = 0 + } + + var b strings.Builder + for range topPadding { + b.WriteString("\n") + } + + pad := strings.Repeat(" ", leftPadding) + for _, line := range logoLines { + b.WriteString(pad) + b.WriteString(line) + b.WriteString("\n") + } + + footer := "Press 'q' to quit" + footerPadding := (m.width - len(footer)) / 2 + if footerPadding < 0 { + footerPadding = 0 + } + b.WriteString("\n") + b.WriteString(strings.Repeat(" ", footerPadding)) + b.WriteString(footer) + + // Fill the rest of the height to prevent jumping if terminal is small + remainingLines := m.height - topPadding - len(logoLines) - 2 + for range remainingLines { + b.WriteString("\n") + } + + view := tea.NewView(b.String()) + view.AltScreen = true + return view +} From 2b7622e93914607f7671e581c2513c0ef61a21ce Mon Sep 17 00:00:00 2001 From: frag223 Date: Wed, 17 Jun 2026 22:56:13 +1000 Subject: [PATCH 10/98] adding theme --- go.mod | 10 +++++-- go.sum | 14 ++++++--- internal/ui/app.go | 64 ++++++++++++++--------------------------- internal/ui/app_test.go | 58 +++++++++++++++++++++++++++++++++++++ internal/ui/theme.go | 31 ++++++++++++++++++++ 5 files changed, 127 insertions(+), 50 deletions(-) create mode 100644 internal/ui/app_test.go create mode 100644 internal/ui/theme.go diff --git a/go.mod b/go.mod index e759a36..ee6999b 100644 --- a/go.mod +++ b/go.mod @@ -2,10 +2,15 @@ module github.com/code-gorilla-au/rush go 1.26.3 -require modernc.org/sqlite v1.52.0 +require ( + charm.land/bubbletea/v2 v2.0.7 + charm.land/lipgloss/v2 v2.0.4 + github.com/code-gorilla-au/env v1.1.1 + github.com/code-gorilla-au/odize v1.3.5 + modernc.org/sqlite v1.52.0 +) require ( - charm.land/bubbletea/v2 v2.0.7 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 // indirect github.com/charmbracelet/x/ansi v0.11.7 // indirect @@ -14,7 +19,6 @@ require ( github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/code-gorilla-au/env v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/joho/godotenv v1.5.1 // indirect diff --git a/go.sum b/go.sum index 1430ef0..1d7bb54 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,17 @@ charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0= charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs= +charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q= +charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 h1:4+r3uOJ69ueRBt4okgEfWZeXs3BD36HcDBmOIAUlETk= github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= @@ -18,6 +24,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/code-gorilla-au/env v1.1.1 h1:4rkSwCnyymKh+KGAOPx3fEg9v2ZV5i9r92bSf7xvnCE= github.com/code-gorilla-au/env v1.1.1/go.mod h1:KE4Ymfz5MhMi7SX3ZKH4iMFAHsDCvwOV8WTzgpwzzE4= +github.com/code-gorilla-au/odize v1.3.5 h1:Bjb0c1NXRkbEppsCs2PSN4DHWy3yWIggTXdroibWF54= +github.com/code-gorilla-au/odize v1.3.5/go.mod h1:+PtShsIEca9bAfxltU00OVD75aR5NvtkpOW/HGHdi9w= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= @@ -44,15 +52,13 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= diff --git a/internal/ui/app.go b/internal/ui/app.go index 4892a4d..fb01fa8 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -4,16 +4,20 @@ import ( "strings" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" ) type model struct { width int height int + theme IceTheme } // New returns a new UI model. func New() model { - return model{} + return model{ + theme: NewIceTheme(), + } } func (m model) Init() tea.Cmd { @@ -47,52 +51,26 @@ func (m model) View() tea.View { return tea.NewView("Initializing...") } - logoLines := strings.Split(strings.Trim(logo, "\n"), "\n") - logoWidth := 0 - for _, line := range logoLines { - if len(line) > logoWidth { - logoWidth = len(line) - } - } - - leftPadding := (m.width - logoWidth) / 2 - topPadding := (m.height - len(logoLines) - 2) / 2 // -2 for spacing and footer - - if leftPadding < 0 { - leftPadding = 0 - } - if topPadding < 0 { - topPadding = 0 - } - - var b strings.Builder - for range topPadding { - b.WriteString("\n") - } + styledLogo := m.theme.Logo.Render(strings.Trim(logo, "\n")) + footer := "Press 'q' to quit" + styledFooter := m.theme.Footer.Render(footer) - pad := strings.Repeat(" ", leftPadding) - for _, line := range logoLines { - b.WriteString(pad) - b.WriteString(line) - b.WriteString("\n") - } + content := styledLogo + "\n\n" + styledFooter - footer := "Press 'q' to quit" - footerPadding := (m.width - len(footer)) / 2 - if footerPadding < 0 { - footerPadding = 0 - } - b.WriteString("\n") - b.WriteString(strings.Repeat(" ", footerPadding)) - b.WriteString(footer) + // Center the content in the terminal + centeredContent := lipgloss.Place( + m.width, m.height, + lipgloss.Center, lipgloss.Center, + content, + ) - // Fill the rest of the height to prevent jumping if terminal is small - remainingLines := m.height - topPadding - len(logoLines) - 2 - for range remainingLines { - b.WriteString("\n") - } + // Apply base theme (black background) to the whole view + finalView := m.theme.Base. + Width(m.width). + Height(m.height). + Render(centeredContent) - view := tea.NewView(b.String()) + view := tea.NewView(finalView) view.AltScreen = true return view } diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go new file mode 100644 index 0000000..e03525e --- /dev/null +++ b/internal/ui/app_test.go @@ -0,0 +1,58 @@ +package ui + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/code-gorilla-au/odize" +) + +func TestTheme(t *testing.T) { + group := odize.NewGroup(t, nil) + + err := group. + Test("NewIceTheme should return a theme with correct colors", func(t *testing.T) { + theme := NewIceTheme() + // We can't easily check the color values from the Style object in Lipgloss v2 + // without deep inspection, but we can check if they are not empty. + odize.AssertTrue(t, theme.Logo.GetForeground() != nil) + odize.AssertTrue(t, theme.Footer.GetForeground() != nil) + odize.AssertTrue(t, theme.Base.GetBackground() != nil) + }). + Run() + + odize.AssertNoError(t, err) +} + +func TestNew(t *testing.T) { + group := odize.NewGroup(t, nil) + + err := group. + Test("New should initialize model with IceTheme", func(t *testing.T) { + m := New() + odize.AssertTrue(t, m.theme.Logo.GetForeground() != nil) + }). + Test("Init should return nil", func(t *testing.T) { + m := New() + cmd := m.Init() + odize.AssertTrue(t, cmd == nil) + }). + Test("Update should handle Quit keys", func(t *testing.T) { + m := New() + _, cmd := m.Update(tea.KeyPressMsg{Text: "q"}) + odize.AssertTrue(t, cmd != nil) + + _, cmd = m.Update(tea.KeyPressMsg{Text: "ctrl+c"}) + odize.AssertTrue(t, cmd != nil) + }). + Test("Update should handle WindowSizeMsg", func(t *testing.T) { + m := New() + newModel, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 50}) + updatedModel := newModel.(model) + odize.AssertTrue(t, updatedModel.width == 100) + odize.AssertTrue(t, updatedModel.height == 50) + }). + Run() + + odize.AssertNoError(t, err) +} diff --git a/internal/ui/theme.go b/internal/ui/theme.go new file mode 100644 index 0000000..3a067c3 --- /dev/null +++ b/internal/ui/theme.go @@ -0,0 +1,31 @@ +package ui + +import "charm.land/lipgloss/v2" + +// IceTheme defines the "ice" color palette and styles. +type IceTheme struct { + Logo lipgloss.Style + Footer lipgloss.Style + Base lipgloss.Style +} + +// NewIceTheme returns a new IceTheme with the "ice" palette. +func NewIceTheme() IceTheme { + // Colors + iceBlue := lipgloss.Color("#A5F2F3") + skyBlue := lipgloss.Color("#87CEEB") + white := lipgloss.Color("#FFFFFF") + black := lipgloss.Color("#000000") + + return IceTheme{ + Logo: lipgloss.NewStyle(). + Foreground(iceBlue). + Bold(true), + Footer: lipgloss.NewStyle(). + Foreground(skyBlue). + Italic(true), + Base: lipgloss.NewStyle(). + Background(black). + Foreground(white), + } +} From 05d4ad01fc4158052cbc30b1891b441a56dbfa74 Mon Sep 17 00:00:00 2001 From: frag223 Date: Wed, 17 Jun 2026 23:03:43 +1000 Subject: [PATCH 11/98] removing shadowing --- cmd/rush/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/rush/main.go b/cmd/rush/main.go index 5a0a89e..d9a893c 100644 --- a/cmd/rush/main.go +++ b/cmd/rush/main.go @@ -36,7 +36,7 @@ func main() { } p := tea.NewProgram(ui.New()) - if _, err := p.Run(); err != nil { + if _, err = p.Run(); err != nil { slog.Error("Failed to run program", "error", err) os.Exit(1) } From 7f836584147503fe5b4f7cdf079d0139ea43d7df Mon Sep 17 00:00:00 2001 From: frag223 Date: Wed, 17 Jun 2026 23:04:49 +1000 Subject: [PATCH 12/98] adding junie --- .gitignore | 1 - .junie/guidelines.md | 249 +++++++++++++++++++++++++++++++++++ .junie/memory/errors.md | 0 .junie/memory/feedback.md | 0 .junie/memory/language.json | 1 + .junie/memory/memory.version | 1 + .junie/memory/tasks.md | 0 7 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 .junie/guidelines.md create mode 100644 .junie/memory/errors.md create mode 100644 .junie/memory/feedback.md create mode 100644 .junie/memory/language.json create mode 100644 .junie/memory/memory.version create mode 100644 .junie/memory/tasks.md diff --git a/.gitignore b/.gitignore index 24889b4..9620ac9 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,6 @@ go.work.sum .env .env.local .idea -.junie .db .task diff --git a/.junie/guidelines.md b/.junie/guidelines.md new file mode 100644 index 0000000..74217a7 --- /dev/null +++ b/.junie/guidelines.md @@ -0,0 +1,249 @@ + +## Global rules +- You must always ask before creating mocks + +## SQLite queries + +- When writing sqlite queries use the tool [sqlc](https://sqlc.dev/) +- When writing SQL queries ensure you annotate your queries +- Following are examples of correct annotations +- after you finished writing the queries, use the command `task gen` to generate the boilerplate go code + +```sql +-- name: GetAuthor :one +SELECT * FROM authors +WHERE id = ? LIMIT 1; + +-- name: ListAuthors :many +SELECT * FROM authors +ORDER BY name; + +-- name: CreateAuthor :one +INSERT INTO authors ( + name, bio +) VALUES ( + ?, ? +) +RETURNING *; + +-- name: UpdateAuthor :exec +UPDATE authors +set name = ?, +bio = ? +WHERE id = ?; + +-- name: DeleteAuthor :exec +DELETE FROM authors +WHERE id = ?; +``` + +## Frontend + +## Golang + +You are an expert in Go, microservices architecture, and clean backend development practices. Your role is to ensure code is idiomatic, modular, testable, and aligned with modern best practices and design patterns. + +review `taskfile.yaml` for list of commands available in repo. + +### General Responsibilities: +- Do not add useless comments +- Guide the development of idiomatic, maintainable, and high-performance Go code. +- Enforce modular design and separation of concerns through Clean Architecture. +- Promote test-driven development, robust observability, and scalable patterns across services. + +### Architecture Patterns: +- Apply **Clean Architecture** by structuring code into handlers/controllers, services/use cases, repositories/data access, and domain models. +- Use **domain-driven design** principles where applicable. +- Prioritise **interface-driven development** with explicit dependency injection. +- Prefer **composition over inheritance**; favour small, purpose-specific interfaces. +- Ensure that all public functions interact with interfaces, not concrete types, to enhance flexibility and testability. + +### Project Structure Guidelines: +- Use a consistent project layout: + - cmd/: application entrypoint + - internal/: core application logic (not exposed externally) +- Group code by feature when it improves clarity and cohesion. +- Keep logic decoupled from framework-specific code. + +### Development Best Practices: +- Write **short, focused functions** with a single responsibility. +- Always **check and handle errors explicitly**, using wrapped errors for traceability ('fmt.Errorf("context: %w", err)'). +- Avoid **global state**; use constructor functions to inject dependencies. +- Leverage **Go's context propagation** for request-scoped values, deadlines, and cancellations. +- Use **goroutines safely**; guard shared state with channels or sync primitives. +- **Defer closing resources** and handle them carefully to avoid leaks. + +### Security and Resilience: +- Apply **input validation and sanitisation** rigorously, especially on inputs from external sources. +- Use secure defaults for **JWT, cookies**, and configuration settings. +- Isolate sensitive operations with clear **permission boundaries**. +- Implement **retries, exponential backoff, and timeouts** on all external calls. +- Use **circuit breakers and rate limiting** for service protection. +- Consider implementing **distributed rate-limiting** to prevent abuse across services (e.g., using Redis). + +### Testing: +- Write **unit tests** using use [odize](https://github.com/code-gorilla-au/odize) as the test framework and parallel execution. +- Do not mock out the database, we're using sqlite and embedded db for tests. +- Think about edge cases, within reason. +- **Mock external interfaces** cleanly using generated ([Moq](https://github.com/matryer/moq)) or handwritten mocks. +- Separate **fast unit tests** from slower integration and E2E tests. +- Ensure **test coverage** for every exported function, with behavioural checks. +- Test command with coverage is: `task go-cover`. + + +#### Example odize framework +```golang +func TestQueries(t *testing.T) { + group := odize.NewGroup(t, nil) + + owner := "acme" + repo := "super-repo" + topic := "tooling" + + err := group. + Test("queryReposByTopic builds expected search query", func(t *testing.T) { + q := queryReposByTopic(owner, topic) + + odize.AssertTrue(t, containsAll(q, []string{ + "search(", + "type: REPOSITORY", + "first: 100", + "pageInfo", + "hasNextPage", + "endCursor", + "repositoryCount", + "edges", + "... on Repository", + "name", + "url", + "owner", + "login", + })) + + odize.AssertTrue(t, containsAll(q, []string{ + "owner:" + owner, + "topic:" + topic, + })) + }). + Test("queryGetRepoDetails builds expected repository details query", func(t *testing.T) { + q := queryGetRepoDetails(owner, repo) + + odize.AssertTrue(t, containsAll(q, []string{ + "repository(owner: \"" + owner + "\"", + ", name: \"" + repo + "\")", + })) + + odize.AssertTrue(t, containsAll(q, []string{ + "name", + "url", + "owner", + "login", + })) + + odize.AssertTrue(t, containsAll(q, []string{ + "pullRequests", + "orderBy: {field: CREATED_AT, direction: ASC}", + "totalCount", + "nodes", + "id", + "state", + "title", + "createdAt", + "mergedAt", + "permalink", + "author", + "login", + })) + + odize.AssertTrue(t, containsAll(q, []string{ + "vulnerabilityAlerts", + "pageInfo", + "hasNextPage", + "endCursor", + "nodes", + "securityVulnerability", + "package", + "name", + "advisory", + "severity", + "firstPatchedVersion", + "identifier", + "updatedAt", + })) + }). + Run() + + odize.AssertNoError(t, err) +} + + +func TestService_GetAllOrganisations(t *testing.T) { + group := odize.NewGroup(t, nil) + + var s *Service + + ctx := context.Background() + + group.BeforeEach(func() { + s = NewService(ctx, _testDB, _testTxnDB) + }) + + err := group. + Test("should return all existing organisations", func(t *testing.T) { + initialOrgs, err := s.GetAllOrganisations() + odize.AssertNoError(t, err) + initialCount := len(initialOrgs) + odize.AssertTrue(t, initialCount > 0) + }). + Run() + odize.AssertNoError(t, err) +} +``` + +### Documentation and Standards: +- Document public functions and packages with **GoDoc-style comments**. +- Provide concise **READMEs** for services and libraries. +- Maintain a 'CONTRIBUTING.md' and 'ARCHITECTURE.md' to guide team practices. +- Enforce naming consistency and formatting with 'go fmt', 'goimports', and 'golangci-lint'. + +### Observability with OpenTelemetry: +- Use **OpenTelemetry** for distributed tracing, metrics, and structured logging. +- Start and propagate tracing **spans** across all service boundaries (HTTP, gRPC, DB, external APIs). +- Always attach 'context.Context' to spans, logs, and metric exports. +- Use **otel.Tracer** for creating spans and **otel.Meter** for collecting metrics. +- Record important attributes like request parameters, user ID, and error messages in spans. +- Use **log correlation** by injecting trace IDs into structured logs. +- Export data to **OpenTelemetry Collector**, **Jaeger**, or **Prometheus**. + +### Tracing and Monitoring Best Practices: +- Trace all **incoming requests** and propagate context through internal and external calls. +- Use **middleware** to instrument HTTP and gRPC endpoints automatically. +- Annotate slow, critical, or error-prone paths with **custom spans**. +- Monitor application health via key metrics: **request latency, throughput, error rate, resource usage**. +- Define **SLIs** (e.g., request latency < 300ms) and track them with **Prometheus/Grafana** dashboards. +- Alert on key conditions (e.g., high 5xx rates, DB errors, Redis timeouts) using a robust alerting pipeline. +- Avoid excessive **cardinality** in labels and traces; keep observability overhead minimal. +- Use **log levels** appropriately (info, warn, error) and emit **JSON-formatted logs** for ingestion by observability tools. +- Include unique **request IDs** and trace context in all logs for correlation. + +### Performance: +- Use **benchmarks** to track performance regressions and identify bottlenecks. +- Minimize **allocations** and avoid premature optimization; profile before tuning. +- Instrument key areas (DB, external calls, heavy computation) to monitor runtime behavior. + +### Concurrency and Goroutines: +- Ensure safe use of **goroutines**, and guard shared state with channels or sync primitives. +- Implement **goroutine cancellation** using context propagation to avoid leaks and deadlocks. + +### Tooling and Dependencies: +- Rely on **stable, minimal third-party libraries**; prefer the standard library where possible. +- Use **Go modules** for dependency management and reproducibility. +- Version-lock dependencies for deterministic builds. +- Integrate **linting, testing, and security checks** in CI pipelines. + +### Key Conventions: +1. Prioritise **readability, simplicity, and maintainability**. +2. Design for **change**: isolate business logic and minimise framework lock-in. +3. Emphasise clear **boundaries** and **dependency inversion**. +4. Ensure all behaviour is **observable, testable, and documented**. +5. **Automate workflows** for testing, building, and deployment. \ No newline at end of file diff --git a/.junie/memory/errors.md b/.junie/memory/errors.md new file mode 100644 index 0000000..e69de29 diff --git a/.junie/memory/feedback.md b/.junie/memory/feedback.md new file mode 100644 index 0000000..e69de29 diff --git a/.junie/memory/language.json b/.junie/memory/language.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/.junie/memory/language.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/.junie/memory/memory.version b/.junie/memory/memory.version new file mode 100644 index 0000000..f398a20 --- /dev/null +++ b/.junie/memory/memory.version @@ -0,0 +1 @@ +3.0 \ No newline at end of file diff --git a/.junie/memory/tasks.md b/.junie/memory/tasks.md new file mode 100644 index 0000000..e69de29 From 7bade8742a306f1ceb9c580041120ac101ec367e Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 18 Jun 2026 20:11:06 +1000 Subject: [PATCH 13/98] adding router and pages --- internal/ui/app.go | 96 ++++++++++++++++++-------------- internal/ui/app_test.go | 2 +- internal/ui/page_create_coach.go | 31 +++++++++++ internal/ui/page_locker_room.go | 25 +++++++++ internal/ui/page_title.go | 71 +++++++++++++++++++++++ 5 files changed, 182 insertions(+), 43 deletions(-) create mode 100644 internal/ui/page_create_coach.go create mode 100644 internal/ui/page_locker_room.go create mode 100644 internal/ui/page_title.go diff --git a/internal/ui/app.go b/internal/ui/app.go index fb01fa8..c1383e2 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -1,76 +1,88 @@ package ui import ( - "strings" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" ) -type model struct { - width int - height int - theme IceTheme +type MsgSwitchPage struct { + NewPage Page +} + +type Page int + +const ( + PageTitle Page = iota + 1 + PageCreateCoach + PageLockerRoom +) + +type RootModel struct { + width int + height int + theme IceTheme + currentPage Page + pageTitle tea.Model + pageCreateCoach tea.Model + pageLockerRoom tea.Model } // New returns a new UI model. -func New() model { - return model{ - theme: NewIceTheme(), +func New() RootModel { + return RootModel{ + theme: NewIceTheme(), + currentPage: PageTitle, + pageTitle: NewModelTitle(), + pageCreateCoach: NewModelCreateCoach(), + pageLockerRoom: NewModelLockerRoom(), } } -func (m model) Init() tea.Cmd { +func (m RootModel) Init() tea.Cmd { return nil } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "q", "ctrl+c": return m, tea.Quit } + case MsgSwitchPage: + m.currentPage = msg.NewPage case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + m.pageTitle, _ = m.pageTitle.Update(msg) + m.pageCreateCoach, _ = m.pageCreateCoach.Update(msg) + m.pageLockerRoom, _ = m.pageLockerRoom.Update(msg) + } + + switch m.currentPage { + case PageTitle: + m.pageTitle, _ = m.pageTitle.Update(msg) + case PageCreateCoach: + m.pageCreateCoach, _ = m.pageCreateCoach.Update(msg) + case PageLockerRoom: + m.pageLockerRoom, _ = m.pageLockerRoom.Update(msg) } + return m, nil } -const logo = ` - ____ _ _ ____ _ _ - | _ \| | | / ___|| | | | - | |_) | | | \___ \| |_| | - | _ <| |_| |___) | _ | - |_| \_\\___/|____/|_| |_| -` - -func (m model) View() tea.View { +func (m RootModel) View() tea.View { if m.width == 0 || m.height == 0 { return tea.NewView("Initializing...") } - styledLogo := m.theme.Logo.Render(strings.Trim(logo, "\n")) - footer := "Press 'q' to quit" - styledFooter := m.theme.Footer.Render(footer) - - content := styledLogo + "\n\n" + styledFooter - - // Center the content in the terminal - centeredContent := lipgloss.Place( - m.width, m.height, - lipgloss.Center, lipgloss.Center, - content, - ) - - // Apply base theme (black background) to the whole view - finalView := m.theme.Base. - Width(m.width). - Height(m.height). - Render(centeredContent) + switch m.currentPage { + case PageTitle: + return m.pageTitle.View() + case PageCreateCoach: + return m.pageCreateCoach.View() + case PageLockerRoom: + return m.pageLockerRoom.View() + } - view := tea.NewView(finalView) - view.AltScreen = true - return view + return tea.NewView("unknown page") } diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index e03525e..eeae3ab 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -48,7 +48,7 @@ func TestNew(t *testing.T) { Test("Update should handle WindowSizeMsg", func(t *testing.T) { m := New() newModel, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 50}) - updatedModel := newModel.(model) + updatedModel := newModel.(RootModel) odize.AssertTrue(t, updatedModel.width == 100) odize.AssertTrue(t, updatedModel.height == 50) }). diff --git a/internal/ui/page_create_coach.go b/internal/ui/page_create_coach.go new file mode 100644 index 0000000..280471e --- /dev/null +++ b/internal/ui/page_create_coach.go @@ -0,0 +1,31 @@ +package ui + +import ( + tea "charm.land/bubbletea/v2" +) + +type ModelCreateCoach struct { + width int + height int + theme IceTheme +} + +func NewModelCreateCoach() *ModelCreateCoach { + return &ModelCreateCoach{} +} + +func (m ModelCreateCoach) Init() tea.Cmd { + return nil +} + +func (m ModelCreateCoach) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + + return m, nil +} + +func (m ModelCreateCoach) View() tea.View { + + view := tea.NewView("create coach") + view.AltScreen = true + return view +} diff --git a/internal/ui/page_locker_room.go b/internal/ui/page_locker_room.go new file mode 100644 index 0000000..e92b287 --- /dev/null +++ b/internal/ui/page_locker_room.go @@ -0,0 +1,25 @@ +package ui + +import tea "charm.land/bubbletea/v2" + +type ModelLockerRoom struct { + width int + height int + theme IceTheme +} + +func NewModelLockerRoom() *ModelLockerRoom { + return &ModelLockerRoom{} +} + +func (m ModelLockerRoom) Init() tea.Cmd { + return nil +} + +func (m ModelLockerRoom) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return m, nil +} + +func (m ModelLockerRoom) View() tea.View { + return tea.NewView("locker room") +} diff --git a/internal/ui/page_title.go b/internal/ui/page_title.go new file mode 100644 index 0000000..9e024a4 --- /dev/null +++ b/internal/ui/page_title.go @@ -0,0 +1,71 @@ +package ui + +import ( + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +type ModelTitle struct { + width int + height int + theme IceTheme +} + +func NewModelTitle() *ModelTitle { + return &ModelTitle{} +} + +func (m ModelTitle) Init() tea.Cmd { + return nil +} + +func (m ModelTitle) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + } + return m, nil +} + +const logo = ` + ____ _ _ ____ _ _ + | _ \| | | / ___|| | | | + | |_) | | | \___ \| |_| | + | _ <| |_| |___) | _ | + |_| \_\\___/|____/|_| |_| +` + +func (m ModelTitle) View() tea.View { + if m.width == 0 || m.height == 0 { + return tea.NewView("Initializing...") + } + + styledLogo := m.theme.Logo.Render(strings.Trim(logo, "\n")) + footer := "Press 'q' to quit" + styledFooter := m.theme.Footer.Render(footer) + + content := styledLogo + "\n\n" + styledFooter + + centeredContent := lipgloss.Place( + m.width, m.height, + lipgloss.Center, lipgloss.Center, + content, + ) + + finalView := m.theme.Base. + Width(m.width). + Height(m.height). + Render(centeredContent) + + view := tea.NewView(finalView) + view.AltScreen = true + return view +} From d30c4aedb4422d43f0199410683cbe189ddd94fa Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 18 Jun 2026 20:19:34 +1000 Subject: [PATCH 14/98] adding query --- internal/database/models.gen.go | 1 + internal/database/queries/team.sql | 8 ++++++- internal/database/schema/team.sql | 1 + internal/database/team.sql.gen.go | 36 ++++++++++++++++++++++++------ 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/internal/database/models.gen.go b/internal/database/models.gen.go index 0016ad6..2c7dabd 100644 --- a/internal/database/models.gen.go +++ b/internal/database/models.gen.go @@ -26,6 +26,7 @@ type Player struct { type Team struct { ID int64 Name string + IsDefault sql.NullBool CoachID sql.NullInt64 CreatedAt sql.NullTime UpdatedAt sql.NullTime diff --git a/internal/database/queries/team.sql b/internal/database/queries/team.sql index 1ef1322..b4caf4e 100644 --- a/internal/database/queries/team.sql +++ b/internal/database/queries/team.sql @@ -17,7 +17,13 @@ SELECT * FROM teams; SELECT * FROM teams WHERE id = ?; -- name: CreateTeam :exec -INSERT INTO teams (name, coach_id) VALUES (?, ?) RETURNING *; +INSERT INTO teams (name, is_default, coach_id) VALUES (?, ?, ?) RETURNING *; + +-- name: SetDefaultTeam :exec +UPDATE teams SET is_default = false WHERE id = ?; + +-- name: ClearDefaultTeam :exec +UPDATE teams SET is_default = false WHERE is_default = true; -- name: DeleteTeam :exec DELETE FROM teams WHERE id = ?; diff --git a/internal/database/schema/team.sql b/internal/database/schema/team.sql index 94a585e..be51c92 100644 --- a/internal/database/schema/team.sql +++ b/internal/database/schema/team.sql @@ -8,6 +8,7 @@ create table if not exists coaches ( create table if not exists teams ( id integer primary key autoincrement, name varchar(255) not null, + is_default BOOLEAN DEFAULT 0, coach_id integer references coaches(id), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP diff --git a/internal/database/team.sql.gen.go b/internal/database/team.sql.gen.go index f7c05de..76b45f4 100644 --- a/internal/database/team.sql.gen.go +++ b/internal/database/team.sql.gen.go @@ -10,6 +10,15 @@ import ( "database/sql" ) +const clearDefaultTeam = `-- name: ClearDefaultTeam :exec +UPDATE teams SET is_default = false WHERE is_default = true +` + +func (q *Queries) ClearDefaultTeam(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, clearDefaultTeam) + return err +} + const createCoach = `-- name: CreateCoach :exec INSERT INTO coaches (name) VALUES (?) RETURNING id, name, created_at, updated_at ` @@ -34,16 +43,17 @@ func (q *Queries) CreatePlayer(ctx context.Context, arg CreatePlayerParams) erro } const createTeam = `-- name: CreateTeam :exec -INSERT INTO teams (name, coach_id) VALUES (?, ?) RETURNING id, name, coach_id, created_at, updated_at +INSERT INTO teams (name, is_default, coach_id) VALUES (?, ?, ?) RETURNING id, name, is_default, coach_id, created_at, updated_at ` type CreateTeamParams struct { - Name string - CoachID sql.NullInt64 + Name string + IsDefault sql.NullBool + CoachID sql.NullInt64 } func (q *Queries) CreateTeam(ctx context.Context, arg CreateTeamParams) error { - _, err := q.db.ExecContext(ctx, createTeam, arg.Name, arg.CoachID) + _, err := q.db.ExecContext(ctx, createTeam, arg.Name, arg.IsDefault, arg.CoachID) return err } @@ -75,7 +85,7 @@ func (q *Queries) DeleteTeam(ctx context.Context, id int64) error { } const getCoach = `-- name: GetCoach :one -SELECT id, name, coach_id, created_at, updated_at FROM teams WHERE id = ? +SELECT id, name, is_default, coach_id, created_at, updated_at FROM teams WHERE id = ? ` func (q *Queries) GetCoach(ctx context.Context, id int64) (Team, error) { @@ -84,6 +94,7 @@ func (q *Queries) GetCoach(ctx context.Context, id int64) (Team, error) { err := row.Scan( &i.ID, &i.Name, + &i.IsDefault, &i.CoachID, &i.CreatedAt, &i.UpdatedAt, @@ -124,7 +135,7 @@ func (q *Queries) GetCoaches(ctx context.Context) ([]Coach, error) { } const getTeam = `-- name: GetTeam :one -SELECT id, name, coach_id, created_at, updated_at FROM teams WHERE id = ? +SELECT id, name, is_default, coach_id, created_at, updated_at FROM teams WHERE id = ? ` func (q *Queries) GetTeam(ctx context.Context, id int64) (Team, error) { @@ -133,6 +144,7 @@ func (q *Queries) GetTeam(ctx context.Context, id int64) (Team, error) { err := row.Scan( &i.ID, &i.Name, + &i.IsDefault, &i.CoachID, &i.CreatedAt, &i.UpdatedAt, @@ -174,7 +186,7 @@ func (q *Queries) GetTeamMembers(ctx context.Context, teamID sql.NullInt64) ([]P } const getTeams = `-- name: GetTeams :many -SELECT id, name, coach_id, created_at, updated_at FROM teams +SELECT id, name, is_default, coach_id, created_at, updated_at FROM teams ` func (q *Queries) GetTeams(ctx context.Context) ([]Team, error) { @@ -189,6 +201,7 @@ func (q *Queries) GetTeams(ctx context.Context) ([]Team, error) { if err := rows.Scan( &i.ID, &i.Name, + &i.IsDefault, &i.CoachID, &i.CreatedAt, &i.UpdatedAt, @@ -205,3 +218,12 @@ func (q *Queries) GetTeams(ctx context.Context) ([]Team, error) { } return items, nil } + +const setDefaultTeam = `-- name: SetDefaultTeam :exec +UPDATE teams SET is_default = false WHERE id = ? +` + +func (q *Queries) SetDefaultTeam(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, setDefaultTeam, id) + return err +} From d72b6143b0784e64e044b29f9a18ba2762eb474e Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 18 Jun 2026 20:53:01 +1000 Subject: [PATCH 15/98] adding interface --- internal/database/models.gen.go | 1 + internal/database/queries/team.sql | 12 +++++++ internal/database/schema/team.sql | 1 + internal/database/team.sql.gen.go | 58 ++++++++++++++++++++++++++++-- internal/teams/interfaces.go | 37 +++++++++++++++++++ internal/teams/service.go | 5 +++ internal/teams/types.go | 29 +++++++++++++++ 7 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 internal/teams/interfaces.go create mode 100644 internal/teams/service.go create mode 100644 internal/teams/types.go diff --git a/internal/database/models.gen.go b/internal/database/models.gen.go index 2c7dabd..e0ab7aa 100644 --- a/internal/database/models.gen.go +++ b/internal/database/models.gen.go @@ -11,6 +11,7 @@ import ( type Coach struct { ID int64 Name string + IsDefault sql.NullBool CreatedAt sql.NullTime UpdatedAt sql.NullTime } diff --git a/internal/database/queries/team.sql b/internal/database/queries/team.sql index b4caf4e..da66332 100644 --- a/internal/database/queries/team.sql +++ b/internal/database/queries/team.sql @@ -1,6 +1,15 @@ -- name: GetCoach :one SELECT * FROM teams WHERE id = ?; +-- name: GetDefaultCoach :one +SELECT * FROM coaches WHERE is_default = true LIMIT 1; + +-- name: SetDefaultCoach :exec +UPDATE coaches SET is_default = true WHERE id = ?; + +-- name: ClearDefaultCoach :exec +UPDATE coaches SET is_default = false WHERE is_default = true; + -- name: GetCoaches :many SELECT * FROM coaches; @@ -16,6 +25,9 @@ SELECT * FROM teams; -- name: GetTeam :one SELECT * FROM teams WHERE id = ?; +-- name: GetTeamByCoachID :one +SELECT * FROM teams WHERE coach_id = ? AND is_default = true LIMIT 1; + -- name: CreateTeam :exec INSERT INTO teams (name, is_default, coach_id) VALUES (?, ?, ?) RETURNING *; diff --git a/internal/database/schema/team.sql b/internal/database/schema/team.sql index be51c92..b6dbdc3 100644 --- a/internal/database/schema/team.sql +++ b/internal/database/schema/team.sql @@ -1,6 +1,7 @@ create table if not exists coaches ( id integer primary key autoincrement, name varchar(255) not null, + is_default BOOLEAN DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); diff --git a/internal/database/team.sql.gen.go b/internal/database/team.sql.gen.go index 76b45f4..fe527e0 100644 --- a/internal/database/team.sql.gen.go +++ b/internal/database/team.sql.gen.go @@ -10,6 +10,15 @@ import ( "database/sql" ) +const clearDefaultCoach = `-- name: ClearDefaultCoach :exec +UPDATE coaches SET is_default = false WHERE is_default = true +` + +func (q *Queries) ClearDefaultCoach(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, clearDefaultCoach) + return err +} + const clearDefaultTeam = `-- name: ClearDefaultTeam :exec UPDATE teams SET is_default = false WHERE is_default = true ` @@ -20,7 +29,7 @@ func (q *Queries) ClearDefaultTeam(ctx context.Context) error { } const createCoach = `-- name: CreateCoach :exec -INSERT INTO coaches (name) VALUES (?) RETURNING id, name, created_at, updated_at +INSERT INTO coaches (name) VALUES (?) RETURNING id, name, is_default, created_at, updated_at ` func (q *Queries) CreateCoach(ctx context.Context, name string) error { @@ -103,7 +112,7 @@ func (q *Queries) GetCoach(ctx context.Context, id int64) (Team, error) { } const getCoaches = `-- name: GetCoaches :many -SELECT id, name, created_at, updated_at FROM coaches +SELECT id, name, is_default, created_at, updated_at FROM coaches ` func (q *Queries) GetCoaches(ctx context.Context) ([]Coach, error) { @@ -118,6 +127,7 @@ func (q *Queries) GetCoaches(ctx context.Context) ([]Coach, error) { if err := rows.Scan( &i.ID, &i.Name, + &i.IsDefault, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -134,6 +144,23 @@ func (q *Queries) GetCoaches(ctx context.Context) ([]Coach, error) { return items, nil } +const getDefaultCoach = `-- name: GetDefaultCoach :one +SELECT id, name, is_default, created_at, updated_at FROM coaches WHERE is_default = true LIMIT 1 +` + +func (q *Queries) GetDefaultCoach(ctx context.Context) (Coach, error) { + row := q.db.QueryRowContext(ctx, getDefaultCoach) + var i Coach + err := row.Scan( + &i.ID, + &i.Name, + &i.IsDefault, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const getTeam = `-- name: GetTeam :one SELECT id, name, is_default, coach_id, created_at, updated_at FROM teams WHERE id = ? ` @@ -152,6 +179,24 @@ func (q *Queries) GetTeam(ctx context.Context, id int64) (Team, error) { return i, err } +const getTeamByCoachID = `-- name: GetTeamByCoachID :one +SELECT id, name, is_default, coach_id, created_at, updated_at FROM teams WHERE coach_id = ? AND is_default = true LIMIT 1 +` + +func (q *Queries) GetTeamByCoachID(ctx context.Context, coachID sql.NullInt64) (Team, error) { + row := q.db.QueryRowContext(ctx, getTeamByCoachID, coachID) + var i Team + err := row.Scan( + &i.ID, + &i.Name, + &i.IsDefault, + &i.CoachID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const getTeamMembers = `-- name: GetTeamMembers :many SELECT id, name, team_id, created_at, updated_at FROM players WHERE team_id = ? ` @@ -219,6 +264,15 @@ func (q *Queries) GetTeams(ctx context.Context) ([]Team, error) { return items, nil } +const setDefaultCoach = `-- name: SetDefaultCoach :exec +UPDATE coaches SET is_default = true WHERE id = ? +` + +func (q *Queries) SetDefaultCoach(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, setDefaultCoach, id) + return err +} + const setDefaultTeam = `-- name: SetDefaultTeam :exec UPDATE teams SET is_default = false WHERE id = ? ` diff --git a/internal/teams/interfaces.go b/internal/teams/interfaces.go new file mode 100644 index 0000000..33e7e01 --- /dev/null +++ b/internal/teams/interfaces.go @@ -0,0 +1,37 @@ +package teams + +import ( + "context" + "database/sql" + + "github.com/code-gorilla-au/rush/internal/database" +) + +type Store interface { + PlayerStore + TeamStore + CoachStore +} + +type CoachStore interface { + GetDefaultCoach(ctx context.Context) (database.Coach, error) + ClearDefaultCoach(ctx context.Context) error + CreateCoach(ctx context.Context, name string) error + GetCoaches(ctx context.Context) ([]database.Coach, error) + SetDefaultCoach(ctx context.Context, id int64) error + SetDefaultTeam(ctx context.Context, id int64) error +} + +type PlayerStore interface { + CreatePlayer(ctx context.Context, arg database.CreatePlayerParams) error +} + +type TeamStore interface { + CreateTeam(ctx context.Context, arg database.CreateTeamParams) error + GetTeamByCoachID(ctx context.Context, coachID sql.NullInt64) (database.Team, error) + DeleteTeam(ctx context.Context, id int64) error + SetDefaultTeam(ctx context.Context, id int64) error + ClearDefaultTeam(ctx context.Context) error +} + +var _ Store = (*database.Queries)(nil) diff --git a/internal/teams/service.go b/internal/teams/service.go new file mode 100644 index 0000000..7140bcf --- /dev/null +++ b/internal/teams/service.go @@ -0,0 +1,5 @@ +package teams + +type Service struct { + store Store +} diff --git a/internal/teams/types.go b/internal/teams/types.go new file mode 100644 index 0000000..9ce4a05 --- /dev/null +++ b/internal/teams/types.go @@ -0,0 +1,29 @@ +package teams + +import ( + "time" +) + +type Team struct { + ID int `json:"id,omitempty"` + Name string `json:"name,omitempty"` + CoachID int `json:"coach_id,omitempty"` + Players []Player `json:"players,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Coach struct { + ID int `json:"id,omitempty"` + Name string `json:"name,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Player struct { + ID int `json:"id,omitempty"` + Name string `json:"name,omitempty"` + TeamID int `json:"team_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} From 0f2e0bd94c1a1c0a55d5bc99a137e9e4e226d045 Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 18 Jun 2026 20:56:54 +1000 Subject: [PATCH 16/98] adding service --- internal/teams/service.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/internal/teams/service.go b/internal/teams/service.go index 7140bcf..7552df7 100644 --- a/internal/teams/service.go +++ b/internal/teams/service.go @@ -1,5 +1,31 @@ package teams +import "context" + type Service struct { store Store } + +func NewTeamsService(store Store) *Service { + return &Service{store: store} +} + +func (s *Service) CreateCoach(ctx context.Context, name string) error { + return s.store.CreateCoach(ctx, name) +} + +func (s *Service) SetDefaultTeam(ctx context.Context, id int64) error { + return s.store.SetDefaultTeam(ctx, id) +} + +func (s *Service) ClearDefaultTeam(ctx context.Context) error { + return s.store.ClearDefaultTeam(ctx) +} + +func (s *Service) SetDefaultCoach(ctx context.Context, id int64) error { + return s.store.SetDefaultCoach(ctx, id) +} + +func (s *Service) ClearDefaultCoach(ctx context.Context) error { + return s.store.ClearDefaultCoach(ctx) +} From 06c60fd25f221f6b9eec1e36de2867aaca0938db Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 18 Jun 2026 21:30:07 +1000 Subject: [PATCH 17/98] adding error handling --- internal/database/queries/team.sql | 6 +- internal/database/team.sql.gen.go | 25 +++-- internal/teams/interfaces.go | 2 +- internal/teams/service.go | 37 +++++++- internal/teams/service_test.go | 147 +++++++++++++++++++++++++++++ internal/teams/transforms.go | 14 +++ internal/teams/types.go | 35 ++++--- 7 files changed, 238 insertions(+), 28 deletions(-) create mode 100644 internal/teams/service_test.go create mode 100644 internal/teams/transforms.go diff --git a/internal/database/queries/team.sql b/internal/database/queries/team.sql index da66332..b0127b3 100644 --- a/internal/database/queries/team.sql +++ b/internal/database/queries/team.sql @@ -13,8 +13,8 @@ UPDATE coaches SET is_default = false WHERE is_default = true; -- name: GetCoaches :many SELECT * FROM coaches; --- name: CreateCoach :exec -INSERT INTO coaches (name) VALUES (?) RETURNING *; +-- name: CreateCoach :one +INSERT INTO coaches (name, is_default) VALUES (?, ?) RETURNING *; -- name: DeleteCoach :exec DELETE FROM coaches WHERE id = ?; @@ -32,7 +32,7 @@ SELECT * FROM teams WHERE coach_id = ? AND is_default = true LIMIT 1; INSERT INTO teams (name, is_default, coach_id) VALUES (?, ?, ?) RETURNING *; -- name: SetDefaultTeam :exec -UPDATE teams SET is_default = false WHERE id = ?; +UPDATE teams SET is_default = true WHERE id = ?; -- name: ClearDefaultTeam :exec UPDATE teams SET is_default = false WHERE is_default = true; diff --git a/internal/database/team.sql.gen.go b/internal/database/team.sql.gen.go index fe527e0..e75e9e1 100644 --- a/internal/database/team.sql.gen.go +++ b/internal/database/team.sql.gen.go @@ -28,13 +28,26 @@ func (q *Queries) ClearDefaultTeam(ctx context.Context) error { return err } -const createCoach = `-- name: CreateCoach :exec -INSERT INTO coaches (name) VALUES (?) RETURNING id, name, is_default, created_at, updated_at +const createCoach = `-- name: CreateCoach :one +INSERT INTO coaches (name, is_default) VALUES (?, ?) RETURNING id, name, is_default, created_at, updated_at ` -func (q *Queries) CreateCoach(ctx context.Context, name string) error { - _, err := q.db.ExecContext(ctx, createCoach, name) - return err +type CreateCoachParams struct { + Name string + IsDefault sql.NullBool +} + +func (q *Queries) CreateCoach(ctx context.Context, arg CreateCoachParams) (Coach, error) { + row := q.db.QueryRowContext(ctx, createCoach, arg.Name, arg.IsDefault) + var i Coach + err := row.Scan( + &i.ID, + &i.Name, + &i.IsDefault, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err } const createPlayer = `-- name: CreatePlayer :exec @@ -274,7 +287,7 @@ func (q *Queries) SetDefaultCoach(ctx context.Context, id int64) error { } const setDefaultTeam = `-- name: SetDefaultTeam :exec -UPDATE teams SET is_default = false WHERE id = ? +UPDATE teams SET is_default = true WHERE id = ? ` func (q *Queries) SetDefaultTeam(ctx context.Context, id int64) error { diff --git a/internal/teams/interfaces.go b/internal/teams/interfaces.go index 33e7e01..2b131b9 100644 --- a/internal/teams/interfaces.go +++ b/internal/teams/interfaces.go @@ -16,7 +16,7 @@ type Store interface { type CoachStore interface { GetDefaultCoach(ctx context.Context) (database.Coach, error) ClearDefaultCoach(ctx context.Context) error - CreateCoach(ctx context.Context, name string) error + CreateCoach(ctx context.Context, arg database.CreateCoachParams) (database.Coach, error) GetCoaches(ctx context.Context) ([]database.Coach, error) SetDefaultCoach(ctx context.Context, id int64) error SetDefaultTeam(ctx context.Context, id int64) error diff --git a/internal/teams/service.go b/internal/teams/service.go index 7552df7..c7f46af 100644 --- a/internal/teams/service.go +++ b/internal/teams/service.go @@ -1,6 +1,13 @@ package teams -import "context" +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/code-gorilla-au/rush/internal/database" +) type Service struct { store Store @@ -10,8 +17,32 @@ func NewTeamsService(store Store) *Service { return &Service{store: store} } -func (s *Service) CreateCoach(ctx context.Context, name string) error { - return s.store.CreateCoach(ctx, name) +func (s *Service) CreateCoach(ctx context.Context, name string, isDefault bool) (Coach, error) { + model, err := s.store.CreateCoach(ctx, database.CreateCoachParams{ + Name: name, + IsDefault: sql.NullBool{ + Bool: isDefault, + Valid: true, + }, + }) + if err != nil { + return Coach{}, fmt.Errorf("creating coach: %w", err) + } + + return fromCoachModel(model), nil +} + +func (s *Service) GetDefaultCoach(ctx context.Context) (Coach, error) { + model, err := s.store.GetDefaultCoach(ctx) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Coach{}, ErrCoachNotFound + } + + return Coach{}, fmt.Errorf("getting default coach: %w", err) + } + + return fromCoachModel(model), nil } func (s *Service) SetDefaultTeam(ctx context.Context, id int64) error { diff --git a/internal/teams/service_test.go b/internal/teams/service_test.go new file mode 100644 index 0000000..3889bd8 --- /dev/null +++ b/internal/teams/service_test.go @@ -0,0 +1,147 @@ +package teams + +import ( + "database/sql" + "testing" + + "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/database" + _ "modernc.org/sqlite" +) + +func setupTestDB(t *testing.T) *sql.DB { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("failed to open database: %v", err) + } + + migrator := database.NewMigrator(db, database.SchemaFS) + if err := migrator.Migrate(t.Context()); err != nil { + t.Fatalf("failed to migrate database: %v", err) + } + + return db +} + +func TestService(t *testing.T) { + group := odize.NewGroup(t, nil) + + var db *sql.DB + var queries *database.Queries + var s *Service + + group.BeforeEach(func() { + db = setupTestDB(t) + queries = database.New(db) + s = NewTeamsService(queries) + }) + + group.AfterEach(func() { + if db != nil { + db.Close() + } + }) + + err := group. + Test("CreateCoach should create a coach and return it", func(t *testing.T) { + ctx := t.Context() + name := "Coach Carter" + + coach, err := s.CreateCoach(ctx, name, false) + odize.AssertNoError(t, err) + odize.AssertTrue(t, coach.ID > 0) + odize.AssertEqual(t, name, coach.Name) + odize.AssertFalse(t, coach.CreatedAt.IsZero()) + odize.AssertFalse(t, coach.UpdatedAt.IsZero()) + }). + Test("SetDefaultCoach should set the default coach", func(t *testing.T) { + ctx := t.Context() + name := "Coach Carter" + + coach, err := s.CreateCoach(ctx, name, false) + odize.AssertNoError(t, err) + + err = s.SetDefaultCoach(ctx, coach.ID) + odize.AssertNoError(t, err) + + // Verify it's set + queries := database.New(db) + model, err := queries.GetDefaultCoach(ctx) + odize.AssertNoError(t, err) + odize.AssertEqual(t, coach.ID, model.ID) + odize.AssertTrue(t, model.IsDefault.Bool) + }). + Test("ClearDefaultCoach should clear the default coach", func(t *testing.T) { + ctx := t.Context() + name := "Coach Carter" + + _, err := s.CreateCoach(ctx, name, true) + odize.AssertNoError(t, err) + + err = s.ClearDefaultCoach(ctx) + odize.AssertNoError(t, err) + + // Verify it's cleared + queries := database.New(db) + _, err = queries.GetDefaultCoach(ctx) + odize.AssertError(t, err) + odize.AssertTrue(t, err == sql.ErrNoRows) + }). + Test("SetDefaultTeam should set the default team", func(t *testing.T) { + ctx := t.Context() + queries := database.New(db) + err := queries.CreateTeam(ctx, database.CreateTeamParams{ + Name: "The Bulls", + }) + odize.AssertNoError(t, err) + + var teamID int64 + err = db.QueryRowContext(ctx, "SELECT id FROM teams WHERE name = ?", "The Bulls").Scan(&teamID) + odize.AssertNoError(t, err) + + err = s.SetDefaultTeam(ctx, teamID) + odize.AssertNoError(t, err) + + // Verify it's set + var isDefault bool + err = db.QueryRowContext(ctx, "SELECT is_default FROM teams WHERE id = ?", teamID).Scan(&isDefault) + odize.AssertNoError(t, err) + odize.AssertTrue(t, isDefault) + }). + Test("ClearDefaultTeam should clear the default team", func(t *testing.T) { + err := queries.CreateTeam(t.Context(), database.CreateTeamParams{ + Name: "The Bulls", + IsDefault: sql.NullBool{Bool: true, Valid: true}, + }) + odize.AssertNoError(t, err) + + var teamID int64 + err = db.QueryRowContext(t.Context(), "SELECT id FROM teams WHERE name = ?", "The Bulls").Scan(&teamID) + odize.AssertNoError(t, err) + + err = s.ClearDefaultTeam(t.Context()) + odize.AssertNoError(t, err) + + // Verify it's cleared + var isDefault bool + err = db.QueryRowContext(t.Context(), "SELECT is_default FROM teams WHERE id = ?", teamID).Scan(&isDefault) + odize.AssertNoError(t, err) + odize.AssertFalse(t, isDefault) + }). + Test("GetCoach should return error if no default coach", func(t *testing.T) { + _, err := s.GetDefaultCoach(t.Context()) + odize.AssertError(t, err) + + }). + Test("GetTeam should return error if no default coach", func(t *testing.T) { + _, err := s.CreateCoach(t.Context(), "Coach Carter", true) + odize.AssertNoError(t, err) + + coach, sErr := s.GetDefaultCoach(t.Context()) + odize.AssertNoError(t, sErr) + odize.AssertTrue(t, coach.ID > 0) + }). + Run() + + odize.AssertNoError(t, err) +} diff --git a/internal/teams/transforms.go b/internal/teams/transforms.go new file mode 100644 index 0000000..6a82cb8 --- /dev/null +++ b/internal/teams/transforms.go @@ -0,0 +1,14 @@ +package teams + +import ( + "github.com/code-gorilla-au/rush/internal/database" +) + +func fromCoachModel(m database.Coach) Coach { + return Coach{ + ID: m.ID, + Name: m.Name, + CreatedAt: m.CreatedAt.Time, + UpdatedAt: m.UpdatedAt.Time, + } +} diff --git a/internal/teams/types.go b/internal/teams/types.go index 9ce4a05..aabd327 100644 --- a/internal/teams/types.go +++ b/internal/teams/types.go @@ -1,29 +1,34 @@ package teams import ( + "errors" "time" ) type Team struct { - ID int `json:"id,omitempty"` - Name string `json:"name,omitempty"` - CoachID int `json:"coach_id,omitempty"` - Players []Player `json:"players,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id,omitzero"` + Name string `json:"name,omitzero"` + CoachID int `json:"coach_id,omitzero"` + Players []Player `json:"players,omitzero"` + CreatedAt time.Time `json:"created_at,omitzero"` + UpdatedAt time.Time `json:"updated_at,omitzero"` } type Coach struct { - ID int `json:"id,omitempty"` - Name string `json:"name,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id,omitzero"` + Name string `json:"name,omitzero"` + CreatedAt time.Time `json:"created_at,omitzero"` + UpdatedAt time.Time `json:"updated_at,omitzero"` } type Player struct { - ID int `json:"id,omitempty"` - Name string `json:"name,omitempty"` - TeamID int `json:"team_id,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id,omitzero"` + Name string `json:"name,omitzero"` + TeamID int `json:"team_id,omitzero"` + CreatedAt time.Time `json:"created_at,omitzero"` + UpdatedAt time.Time `json:"updated_at,omitzero"` } + +var ( + ErrCoachNotFound = errors.New("coach not found") +) From f0d348d0b0eea41bf90cdbf650c5d7e7cb554a92 Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 18 Jun 2026 21:45:22 +1000 Subject: [PATCH 18/98] adding state --- cmd/rush/main.go | 7 ++++++- internal/ui/app.go | 24 ++++++++++++++++++++---- internal/ui/page_create_coach.go | 13 ++++++++----- internal/ui/page_locker_room.go | 13 ++++++++----- internal/ui/page_title.go | 13 ++++++++----- 5 files changed, 50 insertions(+), 20 deletions(-) diff --git a/cmd/rush/main.go b/cmd/rush/main.go index d9a893c..ff03153 100644 --- a/cmd/rush/main.go +++ b/cmd/rush/main.go @@ -8,6 +8,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/code-gorilla-au/env" "github.com/code-gorilla-au/rush/internal/database" + "github.com/code-gorilla-au/rush/internal/teams" "github.com/code-gorilla-au/rush/internal/ui" ) @@ -35,7 +36,11 @@ func main() { os.Exit(1) } - p := tea.NewProgram(ui.New()) + queries := database.New(db) + + teamsSvc := teams.NewTeamsService(queries) + + p := tea.NewProgram(ui.New(teamsSvc)) if _, err = p.Run(); err != nil { slog.Error("Failed to run program", "error", err) os.Exit(1) diff --git a/internal/ui/app.go b/internal/ui/app.go index c1383e2..938bdad 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -1,7 +1,10 @@ package ui import ( + "context" + tea "charm.land/bubbletea/v2" + "github.com/code-gorilla-au/rush/internal/teams" ) type MsgSwitchPage struct { @@ -16,6 +19,10 @@ const ( PageLockerRoom ) +type GlobalState struct { + Coach *teams.Coach +} + type RootModel struct { width int height int @@ -24,20 +31,29 @@ type RootModel struct { pageTitle tea.Model pageCreateCoach tea.Model pageLockerRoom tea.Model + globalState GlobalState + teamsSvc *teams.Service } // New returns a new UI model. -func New() RootModel { +func New(teamsService *teams.Service) RootModel { + state := GlobalState{} + return RootModel{ theme: NewIceTheme(), currentPage: PageTitle, - pageTitle: NewModelTitle(), - pageCreateCoach: NewModelCreateCoach(), - pageLockerRoom: NewModelLockerRoom(), + pageTitle: NewModelTitle(state), + pageCreateCoach: NewModelCreateCoach(state), + pageLockerRoom: NewModelLockerRoom(state), + globalState: state, + teamsSvc: teamsService, } } func (m RootModel) Init() tea.Cmd { + coach, _ := m.teamsSvc.GetDefaultCoach(context.Background()) + m.globalState.Coach = &coach + return nil } diff --git a/internal/ui/page_create_coach.go b/internal/ui/page_create_coach.go index 280471e..d063e38 100644 --- a/internal/ui/page_create_coach.go +++ b/internal/ui/page_create_coach.go @@ -5,13 +5,16 @@ import ( ) type ModelCreateCoach struct { - width int - height int - theme IceTheme + width int + height int + theme IceTheme + globalState GlobalState } -func NewModelCreateCoach() *ModelCreateCoach { - return &ModelCreateCoach{} +func NewModelCreateCoach(state GlobalState) *ModelCreateCoach { + return &ModelCreateCoach{ + globalState: state, + } } func (m ModelCreateCoach) Init() tea.Cmd { diff --git a/internal/ui/page_locker_room.go b/internal/ui/page_locker_room.go index e92b287..6ff2d6c 100644 --- a/internal/ui/page_locker_room.go +++ b/internal/ui/page_locker_room.go @@ -3,13 +3,16 @@ package ui import tea "charm.land/bubbletea/v2" type ModelLockerRoom struct { - width int - height int - theme IceTheme + width int + height int + theme IceTheme + globalState GlobalState } -func NewModelLockerRoom() *ModelLockerRoom { - return &ModelLockerRoom{} +func NewModelLockerRoom(globalState GlobalState) *ModelLockerRoom { + return &ModelLockerRoom{ + globalState: globalState, + } } func (m ModelLockerRoom) Init() tea.Cmd { diff --git a/internal/ui/page_title.go b/internal/ui/page_title.go index 9e024a4..d791452 100644 --- a/internal/ui/page_title.go +++ b/internal/ui/page_title.go @@ -8,13 +8,16 @@ import ( ) type ModelTitle struct { - width int - height int - theme IceTheme + width int + height int + theme IceTheme + globalState GlobalState } -func NewModelTitle() *ModelTitle { - return &ModelTitle{} +func NewModelTitle(globalState GlobalState) *ModelTitle { + return &ModelTitle{ + globalState: globalState, + } } func (m ModelTitle) Init() tea.Cmd { From 71192051e5fdb5f32cbfdf4a36075ea676f974d1 Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 18 Jun 2026 22:09:38 +1000 Subject: [PATCH 19/98] adding ui for routing --- internal/ui/app.go | 43 ++++++++++++++++++--------- internal/ui/app_test.go | 33 +++++++++++++++++---- internal/ui/page_create_coach.go | 4 +-- internal/ui/page_locker_room.go | 4 +-- internal/ui/page_title.go | 26 +++++++++++++++-- internal/ui/page_title_test.go | 50 ++++++++++++++++++++++++++++++++ internal/ui/theme.go | 11 +++++++ 7 files changed, 145 insertions(+), 26 deletions(-) create mode 100644 internal/ui/page_title_test.go diff --git a/internal/ui/app.go b/internal/ui/app.go index 938bdad..2a868ab 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -7,6 +7,10 @@ import ( "github.com/code-gorilla-au/rush/internal/teams" ) +type MsgCoachLoaded struct { + Coach *teams.Coach +} + type MsgSwitchPage struct { NewPage Page } @@ -31,13 +35,13 @@ type RootModel struct { pageTitle tea.Model pageCreateCoach tea.Model pageLockerRoom tea.Model - globalState GlobalState + globalState *GlobalState teamsSvc *teams.Service } // New returns a new UI model. func New(teamsService *teams.Service) RootModel { - state := GlobalState{} + state := &GlobalState{} return RootModel{ theme: NewIceTheme(), @@ -51,14 +55,21 @@ func New(teamsService *teams.Service) RootModel { } func (m RootModel) Init() tea.Cmd { - coach, _ := m.teamsSvc.GetDefaultCoach(context.Background()) - m.globalState.Coach = &coach - - return nil + return func() tea.Msg { + coach, err := m.teamsSvc.GetDefaultCoach(context.Background()) + if err != nil { + return MsgCoachLoaded{Coach: nil} + } + return MsgCoachLoaded{Coach: &coach} + } } func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + switch msg := msg.(type) { + case MsgCoachLoaded: + m.globalState.Coach = msg.Coach case tea.KeyMsg: switch msg.String() { case "q", "ctrl+c": @@ -69,21 +80,27 @@ func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - m.pageTitle, _ = m.pageTitle.Update(msg) - m.pageCreateCoach, _ = m.pageCreateCoach.Update(msg) - m.pageLockerRoom, _ = m.pageLockerRoom.Update(msg) + var cmd tea.Cmd + m.pageTitle, cmd = m.pageTitle.Update(msg) + cmds = append(cmds, cmd) + m.pageCreateCoach, cmd = m.pageCreateCoach.Update(msg) + cmds = append(cmds, cmd) + m.pageLockerRoom, cmd = m.pageLockerRoom.Update(msg) + cmds = append(cmds, cmd) } + var cmd tea.Cmd switch m.currentPage { case PageTitle: - m.pageTitle, _ = m.pageTitle.Update(msg) + m.pageTitle, cmd = m.pageTitle.Update(msg) case PageCreateCoach: - m.pageCreateCoach, _ = m.pageCreateCoach.Update(msg) + m.pageCreateCoach, cmd = m.pageCreateCoach.Update(msg) case PageLockerRoom: - m.pageLockerRoom, _ = m.pageLockerRoom.Update(msg) + m.pageLockerRoom, cmd = m.pageLockerRoom.Update(msg) } + cmds = append(cmds, cmd) - return m, nil + return m, tea.Batch(cmds...) } func (m RootModel) View() tea.View { diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index eeae3ab..dc78dd9 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -1,12 +1,29 @@ package ui import ( + "database/sql" "testing" tea "charm.land/bubbletea/v2" "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/database" + "github.com/code-gorilla-au/rush/internal/teams" ) +func setupTeamsService(t *testing.T) *teams.Service { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("failed to open database: %v", err) + } + + migrator := database.NewMigrator(db, database.SchemaFS) + if err := migrator.Migrate(t.Context()); err != nil { + t.Fatalf("failed to migrate database: %v", err) + } + + return teams.NewTeamsService(database.New(db)) +} + func TestTheme(t *testing.T) { group := odize.NewGroup(t, nil) @@ -29,16 +46,19 @@ func TestNew(t *testing.T) { err := group. Test("New should initialize model with IceTheme", func(t *testing.T) { - m := New() + s := setupTeamsService(t) + m := New(s) odize.AssertTrue(t, m.theme.Logo.GetForeground() != nil) }). - Test("Init should return nil", func(t *testing.T) { - m := New() + Test("Init should return a command", func(t *testing.T) { + s := setupTeamsService(t) + m := New(s) cmd := m.Init() - odize.AssertTrue(t, cmd == nil) + odize.AssertTrue(t, cmd != nil) }). Test("Update should handle Quit keys", func(t *testing.T) { - m := New() + s := setupTeamsService(t) + m := New(s) _, cmd := m.Update(tea.KeyPressMsg{Text: "q"}) odize.AssertTrue(t, cmd != nil) @@ -46,7 +66,8 @@ func TestNew(t *testing.T) { odize.AssertTrue(t, cmd != nil) }). Test("Update should handle WindowSizeMsg", func(t *testing.T) { - m := New() + s := setupTeamsService(t) + m := New(s) newModel, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 50}) updatedModel := newModel.(RootModel) odize.AssertTrue(t, updatedModel.width == 100) diff --git a/internal/ui/page_create_coach.go b/internal/ui/page_create_coach.go index d063e38..f990620 100644 --- a/internal/ui/page_create_coach.go +++ b/internal/ui/page_create_coach.go @@ -8,10 +8,10 @@ type ModelCreateCoach struct { width int height int theme IceTheme - globalState GlobalState + globalState *GlobalState } -func NewModelCreateCoach(state GlobalState) *ModelCreateCoach { +func NewModelCreateCoach(state *GlobalState) *ModelCreateCoach { return &ModelCreateCoach{ globalState: state, } diff --git a/internal/ui/page_locker_room.go b/internal/ui/page_locker_room.go index 6ff2d6c..267fae3 100644 --- a/internal/ui/page_locker_room.go +++ b/internal/ui/page_locker_room.go @@ -6,10 +6,10 @@ type ModelLockerRoom struct { width int height int theme IceTheme - globalState GlobalState + globalState *GlobalState } -func NewModelLockerRoom(globalState GlobalState) *ModelLockerRoom { +func NewModelLockerRoom(globalState *GlobalState) *ModelLockerRoom { return &ModelLockerRoom{ globalState: globalState, } diff --git a/internal/ui/page_title.go b/internal/ui/page_title.go index d791452..5ac124d 100644 --- a/internal/ui/page_title.go +++ b/internal/ui/page_title.go @@ -11,10 +11,10 @@ type ModelTitle struct { width int height int theme IceTheme - globalState GlobalState + globalState *GlobalState } -func NewModelTitle(globalState GlobalState) *ModelTitle { +func NewModelTitle(globalState *GlobalState) *ModelTitle { return &ModelTitle{ globalState: globalState, } @@ -30,6 +30,18 @@ func (m ModelTitle) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "q", "ctrl+c": return m, tea.Quit + case "c": + if m.globalState.Coach == nil { + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageCreateCoach} + } + } + case "l": + if m.globalState.Coach != nil { + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageLockerRoom} + } + } } case tea.WindowSizeMsg: m.width = msg.Width @@ -52,10 +64,18 @@ func (m ModelTitle) View() tea.View { } styledLogo := m.theme.Logo.Render(strings.Trim(logo, "\n")) + + var navigation string + if m.globalState.Coach == nil { + navigation = "Press " + m.theme.Hotkey.Render("c") + " to create a coach" + } else { + navigation = m.theme.Button.Render("Locker Room (l)") + } + footer := "Press 'q' to quit" styledFooter := m.theme.Footer.Render(footer) - content := styledLogo + "\n\n" + styledFooter + content := styledLogo + "\n\n" + navigation + "\n\n" + styledFooter centeredContent := lipgloss.Place( m.width, m.height, diff --git a/internal/ui/page_title_test.go b/internal/ui/page_title_test.go new file mode 100644 index 0000000..6f51995 --- /dev/null +++ b/internal/ui/page_title_test.go @@ -0,0 +1,50 @@ +package ui + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/teams" +) + +func TestModelTitle(t *testing.T) { + group := odize.NewGroup(t, nil) + + err := group. + Test("should route to create coach when coach is nil and 'c' is pressed", func(t *testing.T) { + m := NewModelTitle(&GlobalState{Coach: nil}) + m.width = 100 + m.height = 50 + + _, cmd := m.Update(tea.KeyPressMsg{Text: "c"}) + odize.AssertTrue(t, cmd != nil) + + msg := cmd() + switch switchMsg := msg.(type) { + case MsgSwitchPage: + odize.AssertEqual(t, PageCreateCoach, switchMsg.NewPage) + default: + t.Fatalf("expected MsgSwitchPage, got %T", msg) + } + }). + Test("should route to locker room when coach is not nil and 'l' is pressed", func(t *testing.T) { + m := NewModelTitle(&GlobalState{Coach: &teams.Coach{Name: "Coach Carter"}}) + m.width = 100 + m.height = 50 + + _, cmd := m.Update(tea.KeyPressMsg{Text: "l"}) + odize.AssertTrue(t, cmd != nil) + + msg := cmd() + switch switchMsg := msg.(type) { + case MsgSwitchPage: + odize.AssertEqual(t, PageLockerRoom, switchMsg.NewPage) + default: + t.Fatalf("expected MsgSwitchPage, got %T", msg) + } + }). + Run() + + odize.AssertNoError(t, err) +} diff --git a/internal/ui/theme.go b/internal/ui/theme.go index 3a067c3..b63fb72 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -7,6 +7,8 @@ type IceTheme struct { Logo lipgloss.Style Footer lipgloss.Style Base lipgloss.Style + Button lipgloss.Style + Hotkey lipgloss.Style } // NewIceTheme returns a new IceTheme with the "ice" palette. @@ -27,5 +29,14 @@ func NewIceTheme() IceTheme { Base: lipgloss.NewStyle(). Background(black). Foreground(white), + Button: lipgloss.NewStyle(). + Foreground(black). + Background(iceBlue). + Padding(0, 3). + MarginTop(1). + Bold(true), + Hotkey: lipgloss.NewStyle(). + Foreground(iceBlue). + Bold(true), } } From d7f3103dab78fecabd11e411866daca07cb03c06 Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 19 Jun 2026 07:04:33 +1000 Subject: [PATCH 20/98] adding ui guidelines --- .junie/guidelines.md | 30 +++++------------------------- 1 file changed, 5 insertions(+), 25 deletions(-) diff --git a/.junie/guidelines.md b/.junie/guidelines.md index 74217a7..469f069 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -45,6 +45,11 @@ You are an expert in Go, microservices architecture, and clean backend developme review `taskfile.yaml` for list of commands available in repo. +### UI +- UI is https://github.com/charmbracelet/bubbletea +- Use components: https://github.com/charmbracelet/bubbles +- Use styling: https://github.com/charmbracelet/lipgloss + ### General Responsibilities: - Do not add useless comments - Guide the development of idiomatic, maintainable, and high-performance Go code. @@ -206,31 +211,6 @@ func TestService_GetAllOrganisations(t *testing.T) { - Maintain a 'CONTRIBUTING.md' and 'ARCHITECTURE.md' to guide team practices. - Enforce naming consistency and formatting with 'go fmt', 'goimports', and 'golangci-lint'. -### Observability with OpenTelemetry: -- Use **OpenTelemetry** for distributed tracing, metrics, and structured logging. -- Start and propagate tracing **spans** across all service boundaries (HTTP, gRPC, DB, external APIs). -- Always attach 'context.Context' to spans, logs, and metric exports. -- Use **otel.Tracer** for creating spans and **otel.Meter** for collecting metrics. -- Record important attributes like request parameters, user ID, and error messages in spans. -- Use **log correlation** by injecting trace IDs into structured logs. -- Export data to **OpenTelemetry Collector**, **Jaeger**, or **Prometheus**. - -### Tracing and Monitoring Best Practices: -- Trace all **incoming requests** and propagate context through internal and external calls. -- Use **middleware** to instrument HTTP and gRPC endpoints automatically. -- Annotate slow, critical, or error-prone paths with **custom spans**. -- Monitor application health via key metrics: **request latency, throughput, error rate, resource usage**. -- Define **SLIs** (e.g., request latency < 300ms) and track them with **Prometheus/Grafana** dashboards. -- Alert on key conditions (e.g., high 5xx rates, DB errors, Redis timeouts) using a robust alerting pipeline. -- Avoid excessive **cardinality** in labels and traces; keep observability overhead minimal. -- Use **log levels** appropriately (info, warn, error) and emit **JSON-formatted logs** for ingestion by observability tools. -- Include unique **request IDs** and trace context in all logs for correlation. - -### Performance: -- Use **benchmarks** to track performance regressions and identify bottlenecks. -- Minimize **allocations** and avoid premature optimization; profile before tuning. -- Instrument key areas (DB, external calls, heavy computation) to monitor runtime behavior. - ### Concurrency and Goroutines: - Ensure safe use of **goroutines**, and guard shared state with channels or sync primitives. - Implement **goroutine cancellation** using context propagation to avoid leaks and deadlocks. From 585ae47fb8612f3a00b03146ab859a743581beb3 Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 19 Jun 2026 12:34:02 +1000 Subject: [PATCH 21/98] adding team state management --- internal/teams/interfaces.go | 1 + internal/teams/service.go | 24 ++++++++++++++++++++++++ internal/teams/transforms.go | 25 +++++++++++++++++++++++++ internal/teams/types.go | 1 + internal/ui/app.go | 21 ++++++++++++++++----- 5 files changed, 67 insertions(+), 5 deletions(-) diff --git a/internal/teams/interfaces.go b/internal/teams/interfaces.go index 2b131b9..331d5aa 100644 --- a/internal/teams/interfaces.go +++ b/internal/teams/interfaces.go @@ -24,6 +24,7 @@ type CoachStore interface { type PlayerStore interface { CreatePlayer(ctx context.Context, arg database.CreatePlayerParams) error + GetTeamMembers(ctx context.Context, teamID sql.NullInt64) ([]database.Player, error) } type TeamStore interface { diff --git a/internal/teams/service.go b/internal/teams/service.go index c7f46af..21b1aeb 100644 --- a/internal/teams/service.go +++ b/internal/teams/service.go @@ -45,6 +45,30 @@ func (s *Service) GetDefaultCoach(ctx context.Context) (Coach, error) { return fromCoachModel(model), nil } +func (s *Service) GetTeamByCoachID(ctx context.Context, id int64) (Team, error) { + model, err := s.store.GetTeamByCoachID(ctx, sql.NullInt64{ + Valid: true, + Int64: id, + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Team{}, ErrTeamNotFound + } + + return Team{}, fmt.Errorf("getting team: %w", err) + } + + pModel, err := s.store.GetTeamMembers(ctx, sql.NullInt64{ + Int64: model.ID, + Valid: true, + }) + if err != nil { + return Team{}, fmt.Errorf("getting team members: %w", err) + } + + return fromTeamModel(model, pModel), nil +} + func (s *Service) SetDefaultTeam(ctx context.Context, id int64) error { return s.store.SetDefaultTeam(ctx, id) } diff --git a/internal/teams/transforms.go b/internal/teams/transforms.go index 6a82cb8..ef09544 100644 --- a/internal/teams/transforms.go +++ b/internal/teams/transforms.go @@ -12,3 +12,28 @@ func fromCoachModel(m database.Coach) Coach { UpdatedAt: m.UpdatedAt.Time, } } + +func fromTeamModel(m database.Team, p []database.Player) Team { + players := make([]Player, len(p)) + + for i, player := range p { + players[i] = fromPlayerModel(player) + } + + return Team{ + ID: m.ID, + Name: m.Name, + Players: players, + CreatedAt: m.CreatedAt.Time, + UpdatedAt: m.UpdatedAt.Time, + } +} + +func fromPlayerModel(m database.Player) Player { + return Player{ + ID: m.ID, + Name: m.Name, + CreatedAt: m.CreatedAt.Time, + UpdatedAt: m.UpdatedAt.Time, + } +} diff --git a/internal/teams/types.go b/internal/teams/types.go index aabd327..3d63343 100644 --- a/internal/teams/types.go +++ b/internal/teams/types.go @@ -31,4 +31,5 @@ type Player struct { var ( ErrCoachNotFound = errors.New("coach not found") + ErrTeamNotFound = errors.New("team not found") ) diff --git a/internal/ui/app.go b/internal/ui/app.go index 2a868ab..d20fcde 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -7,8 +7,9 @@ import ( "github.com/code-gorilla-au/rush/internal/teams" ) -type MsgCoachLoaded struct { +type MsgStateLoaded struct { Coach *teams.Coach + Team *teams.Team } type MsgSwitchPage struct { @@ -25,9 +26,11 @@ const ( type GlobalState struct { Coach *teams.Coach + Team *teams.Team } type RootModel struct { + ctx context.Context width int height int theme IceTheme @@ -44,6 +47,7 @@ func New(teamsService *teams.Service) RootModel { state := &GlobalState{} return RootModel{ + ctx: context.Background(), theme: NewIceTheme(), currentPage: PageTitle, pageTitle: NewModelTitle(state), @@ -56,11 +60,17 @@ func New(teamsService *teams.Service) RootModel { func (m RootModel) Init() tea.Cmd { return func() tea.Msg { - coach, err := m.teamsSvc.GetDefaultCoach(context.Background()) + coach, err := m.teamsSvc.GetDefaultCoach(m.ctx) if err != nil { - return MsgCoachLoaded{Coach: nil} + return MsgStateLoaded{Coach: nil} } - return MsgCoachLoaded{Coach: &coach} + + team, err := m.teamsSvc.GetTeamByCoachID(m.ctx, coach.ID) + if err != nil { + return MsgStateLoaded{Coach: nil} + } + + return MsgStateLoaded{Coach: &coach, Team: &team} } } @@ -68,8 +78,9 @@ func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { - case MsgCoachLoaded: + case MsgStateLoaded: m.globalState.Coach = msg.Coach + m.globalState.Team = msg.Team case tea.KeyMsg: switch msg.String() { case "q", "ctrl+c": From 8b1d7c59f9f7ea39fec9285ac844a97a78915ba0 Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 19 Jun 2026 12:39:31 +1000 Subject: [PATCH 22/98] passing state down --- internal/ui/page_locker_room.go | 14 ++++++++++++++ internal/ui/page_title.go | 4 ++++ 2 files changed, 18 insertions(+) diff --git a/internal/ui/page_locker_room.go b/internal/ui/page_locker_room.go index 267fae3..cb91480 100644 --- a/internal/ui/page_locker_room.go +++ b/internal/ui/page_locker_room.go @@ -20,6 +20,20 @@ func (m ModelLockerRoom) Init() tea.Cmd { } func (m ModelLockerRoom) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case MsgStateLoaded: + m.globalState.Coach = msg.Coach + m.globalState.Team = msg.Team + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + } + return m, nil } diff --git a/internal/ui/page_title.go b/internal/ui/page_title.go index 5ac124d..d2e3c07 100644 --- a/internal/ui/page_title.go +++ b/internal/ui/page_title.go @@ -26,6 +26,9 @@ func (m ModelTitle) Init() tea.Cmd { func (m ModelTitle) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case MsgStateLoaded: + m.globalState.Coach = msg.Coach + m.globalState.Team = msg.Team case tea.KeyMsg: switch msg.String() { case "q", "ctrl+c": @@ -47,6 +50,7 @@ func (m ModelTitle) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width = msg.Width m.height = msg.Height } + return m, nil } From f5061626b68620fe3289f30f06cff89b1596c150 Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 19 Jun 2026 12:56:09 +1000 Subject: [PATCH 23/98] wip --- go.mod | 2 + go.sum | 4 + internal/teams/service.go | 27 ++++++ internal/ui/app.go | 6 +- internal/ui/page_create_coach.go | 145 +++++++++++++++++++++++++++++-- 5 files changed, 176 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index ee6999b..ff69f67 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/code-gorilla-au/rush go 1.26.3 require ( + charm.land/bubbles/v2 v2.1.0 charm.land/bubbletea/v2 v2.0.7 charm.land/lipgloss/v2 v2.0.4 github.com/code-gorilla-au/env v1.1.1 @@ -11,6 +12,7 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 // indirect github.com/charmbracelet/x/ansi v0.11.7 // indirect diff --git a/go.sum b/go.sum index 1d7bb54..8047529 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,11 @@ +charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= +charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0= charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs= charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q= charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= diff --git a/internal/teams/service.go b/internal/teams/service.go index 21b1aeb..181b86f 100644 --- a/internal/teams/service.go +++ b/internal/teams/service.go @@ -69,6 +69,33 @@ func (s *Service) GetTeamByCoachID(ctx context.Context, id int64) (Team, error) return fromTeamModel(model, pModel), nil } +func (s *Service) CreateTeam(ctx context.Context, name string, coachID int64, isDefault bool) (Team, error) { + err := s.store.CreateTeam(ctx, database.CreateTeamParams{ + Name: name, + IsDefault: sql.NullBool{ + Bool: isDefault, + Valid: true, + }, + CoachID: sql.NullInt64{ + Int64: coachID, + Valid: true, + }, + }) + if err != nil { + return Team{}, fmt.Errorf("creating team: %w", err) + } + + model, err := s.store.GetTeamByCoachID(ctx, sql.NullInt64{ + Int64: coachID, + Valid: true, + }) + if err != nil { + return Team{}, fmt.Errorf("getting created team: %w", err) + } + + return fromTeamModel(model, nil), nil +} + func (s *Service) SetDefaultTeam(ctx context.Context, id int64) error { return s.store.SetDefaultTeam(ctx, id) } diff --git a/internal/ui/app.go b/internal/ui/app.go index d20fcde..a434602 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -29,6 +29,10 @@ type GlobalState struct { Team *teams.Team } +func (m *GlobalState) Context() context.Context { + return context.Background() +} + type RootModel struct { ctx context.Context width int @@ -51,7 +55,7 @@ func New(teamsService *teams.Service) RootModel { theme: NewIceTheme(), currentPage: PageTitle, pageTitle: NewModelTitle(state), - pageCreateCoach: NewModelCreateCoach(state), + pageCreateCoach: NewModelCreateCoach(state, teamsService), pageLockerRoom: NewModelLockerRoom(state), globalState: state, teamsSvc: teamsService, diff --git a/internal/ui/page_create_coach.go b/internal/ui/page_create_coach.go index f990620..b7d2283 100644 --- a/internal/ui/page_create_coach.go +++ b/internal/ui/page_create_coach.go @@ -1,34 +1,165 @@ package ui import ( + "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/code-gorilla-au/rush/internal/teams" ) +type MsgCoachCreated struct { + Coach teams.Coach + Team teams.Team +} + type ModelCreateCoach struct { width int height int theme IceTheme globalState *GlobalState + teamsSvc *teams.Service + + coachInput textinput.Model + teamInput textinput.Model + focusIndex int + err error } -func NewModelCreateCoach(state *GlobalState) *ModelCreateCoach { +func NewModelCreateCoach(state *GlobalState, teamsSvc *teams.Service) *ModelCreateCoach { + c := textinput.New() + c.Placeholder = "Coach Name" + c.Focus() + c.CharLimit = 156 + c.SetWidth(20) + + t := textinput.New() + t.Placeholder = "Team Name" + t.CharLimit = 156 + c.SetWidth(20) + return &ModelCreateCoach{ globalState: state, + teamsSvc: teamsSvc, + coachInput: c, + teamInput: t, + theme: NewIceTheme(), } } -func (m ModelCreateCoach) Init() tea.Cmd { - return nil +func (m *ModelCreateCoach) Init() tea.Cmd { + return func() tea.Msg { return textinput.Blink() } } -func (m ModelCreateCoach) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *ModelCreateCoach) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + case tea.KeyMsg: + switch msg.String() { + case "tab", "shift+tab", "up", "down": + s := msg.String() + + if s == "up" || s == "shift+tab" { + m.focusIndex-- + } else { + m.focusIndex++ + } + + if m.focusIndex > 1 { + m.focusIndex = 0 + } else if m.focusIndex < 0 { + m.focusIndex = 1 + } + + m.updateFocus() + + case "enter": + if m.focusIndex == 1 { + return m, m.submit() + } + m.focusIndex++ + m.updateFocus() + + case "esc": + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageTitle} + } + } + + case MsgCoachCreated: + m.globalState.Coach = &msg.Coach + m.globalState.Team = &msg.Team + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageLockerRoom} + } + } + + var cmd tea.Cmd + m.coachInput, cmd = m.coachInput.Update(msg) + cmds = append(cmds, cmd) - return m, nil + m.teamInput, cmd = m.teamInput.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) } -func (m ModelCreateCoach) View() tea.View { +func (m *ModelCreateCoach) updateFocus() { + if m.focusIndex == 0 { + m.coachInput.Focus() + m.teamInput.Blur() + } else { + m.coachInput.Blur() + m.teamInput.Focus() + } +} + +func (m *ModelCreateCoach) submit() tea.Cmd { + return func() tea.Msg { + ctx := m.globalState.Context() + coach, err := m.teamsSvc.CreateCoach(ctx, m.coachInput.Value(), true) + if err != nil { + return err + } + + team, err := m.teamsSvc.CreateTeam(ctx, m.teamInput.Value(), coach.ID, true) + if err != nil { + return err + } - view := tea.NewView("create coach") + return MsgCoachCreated{ + Coach: coach, + Team: team, + } + } +} + +func (m *ModelCreateCoach) View() tea.View { + view := tea.NewView("") view.AltScreen = true + + s := lipgloss.NewStyle(). + Width(m.width). + Height(m.height). + Align(lipgloss.Center, lipgloss.Center) + + form := lipgloss.JoinVertical( + lipgloss.Left, + m.theme.Logo.Render("RUSH - NEW CAREER"), + "", + "Coach Details", + m.coachInput.View(), + "", + "Team Details", + m.teamInput.View(), + "", + m.theme.Hotkey.Render("enter")+" continue • "+m.theme.Hotkey.Render("esc")+" back", + ) + + view.Content = s.Render(form) return view } From 2f225c8dd5e2cf7a01a1307d68c1591c1c42b26b Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 19 Jun 2026 19:59:20 +1000 Subject: [PATCH 24/98] adding new coach form --- internal/ui/app.go | 10 +++++----- internal/ui/page_create_coach.go | 18 +++++++----------- internal/ui/page_locker_room.go | 8 ++++---- internal/ui/page_title.go | 2 +- 4 files changed, 17 insertions(+), 21 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index a434602..971fd70 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -7,7 +7,7 @@ import ( "github.com/code-gorilla-au/rush/internal/teams" ) -type MsgStateLoaded struct { +type MsgStateUpdated struct { Coach *teams.Coach Team *teams.Team } @@ -66,15 +66,15 @@ func (m RootModel) Init() tea.Cmd { return func() tea.Msg { coach, err := m.teamsSvc.GetDefaultCoach(m.ctx) if err != nil { - return MsgStateLoaded{Coach: nil} + return MsgStateUpdated{Coach: nil} } team, err := m.teamsSvc.GetTeamByCoachID(m.ctx, coach.ID) if err != nil { - return MsgStateLoaded{Coach: nil} + return MsgStateUpdated{Coach: nil} } - return MsgStateLoaded{Coach: &coach, Team: &team} + return MsgStateUpdated{Coach: &coach, Team: &team} } } @@ -82,7 +82,7 @@ func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { - case MsgStateLoaded: + case MsgStateUpdated: m.globalState.Coach = msg.Coach m.globalState.Team = msg.Team case tea.KeyMsg: diff --git a/internal/ui/page_create_coach.go b/internal/ui/page_create_coach.go index b7d2283..9bb99eb 100644 --- a/internal/ui/page_create_coach.go +++ b/internal/ui/page_create_coach.go @@ -7,11 +7,6 @@ import ( "github.com/code-gorilla-au/rush/internal/teams" ) -type MsgCoachCreated struct { - Coach teams.Coach - Team teams.Team -} - type ModelCreateCoach struct { width int height int @@ -90,9 +85,10 @@ func (m *ModelCreateCoach) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - case MsgCoachCreated: - m.globalState.Coach = &msg.Coach - m.globalState.Team = &msg.Team + case MsgStateUpdated: + m.globalState.Coach = msg.Coach + m.globalState.Team = msg.Team + return m, func() tea.Msg { return MsgSwitchPage{NewPage: PageLockerRoom} } @@ -131,9 +127,9 @@ func (m *ModelCreateCoach) submit() tea.Cmd { return err } - return MsgCoachCreated{ - Coach: coach, - Team: team, + return MsgStateUpdated{ + Coach: &coach, + Team: &team, } } } diff --git a/internal/ui/page_locker_room.go b/internal/ui/page_locker_room.go index cb91480..0118c9f 100644 --- a/internal/ui/page_locker_room.go +++ b/internal/ui/page_locker_room.go @@ -15,13 +15,13 @@ func NewModelLockerRoom(globalState *GlobalState) *ModelLockerRoom { } } -func (m ModelLockerRoom) Init() tea.Cmd { +func (m *ModelLockerRoom) Init() tea.Cmd { return nil } -func (m ModelLockerRoom) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *ModelLockerRoom) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case MsgStateLoaded: + case MsgStateUpdated: m.globalState.Coach = msg.Coach m.globalState.Team = msg.Team case tea.KeyMsg: @@ -37,6 +37,6 @@ func (m ModelLockerRoom) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -func (m ModelLockerRoom) View() tea.View { +func (m *ModelLockerRoom) View() tea.View { return tea.NewView("locker room") } diff --git a/internal/ui/page_title.go b/internal/ui/page_title.go index d2e3c07..50ce47f 100644 --- a/internal/ui/page_title.go +++ b/internal/ui/page_title.go @@ -26,7 +26,7 @@ func (m ModelTitle) Init() tea.Cmd { func (m ModelTitle) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case MsgStateLoaded: + case MsgStateUpdated: m.globalState.Coach = msg.Coach m.globalState.Team = msg.Team case tea.KeyMsg: From a7395dfc4b8d18ff9c2d5ed8522cf3a157e7762b Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 19 Jun 2026 20:14:11 +1000 Subject: [PATCH 25/98] adding footer helper --- internal/ui/components/footer.go | 62 +++++++++++++++ internal/ui/components/footer_test.go | 67 ++++++++++++++++ internal/ui/page_create_coach.go | 106 +++++++++++++++++++++----- internal/ui/page_locker_room.go | 73 +++++++++++++++++- internal/ui/page_title.go | 59 ++++++++++++-- internal/ui/page_title_test.go | 4 +- 6 files changed, 337 insertions(+), 34 deletions(-) create mode 100644 internal/ui/components/footer.go create mode 100644 internal/ui/components/footer_test.go diff --git a/internal/ui/components/footer.go b/internal/ui/components/footer.go new file mode 100644 index 0000000..d760113 --- /dev/null +++ b/internal/ui/components/footer.go @@ -0,0 +1,62 @@ +package components + +import ( + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// Footer is a reusable component for displaying help information. +type Footer struct { + Help help.Model + KeyMap help.KeyMap + Width int +} + +// NewFooter creates a new Footer component. +func NewFooter(keyMap help.KeyMap) Footer { + return Footer{ + Help: help.New(), + KeyMap: keyMap, + } +} + +// Update updates the footer component. +func (f *Footer) Update(msg tea.Msg) { + if msg, ok := msg.(tea.WindowSizeMsg); ok { + f.Width = msg.Width + } +} + +// View renders the footer component. +func (f Footer) View(style lipgloss.Style) string { + return style.Render(f.Help.View(f.KeyMap)) +} + +// CommonKeys defines keys that are shared across many pages. +type CommonKeys struct { + Quit key.Binding +} + +// ShortHelp returns keybindings to be shown in the mini help view. +func (k CommonKeys) ShortHelp() []key.Binding { + return []key.Binding{k.Quit} +} + +// FullHelp returns keybindings for the expanded help view. +func (k CommonKeys) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Quit}, + } +} + +// NewCommonKeys returns a default set of common keys. +func NewCommonKeys() CommonKeys { + return CommonKeys{ + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), + ), + } +} diff --git a/internal/ui/components/footer_test.go b/internal/ui/components/footer_test.go new file mode 100644 index 0000000..fbef367 --- /dev/null +++ b/internal/ui/components/footer_test.go @@ -0,0 +1,67 @@ +package components + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/code-gorilla-au/odize" +) + +func TestFooter(t *testing.T) { + group := odize.NewGroup(t, nil) + + err := group. + Test("NewFooter should initialize with given KeyMap", func(t *testing.T) { + keys := NewCommonKeys() + footer := NewFooter(keys) + odize.AssertEqual(t, keys, footer.KeyMap) + }). + Test("Update should set width from WindowSizeMsg", func(t *testing.T) { + footer := NewFooter(NewCommonKeys()) + msg := tea.WindowSizeMsg{Width: 100, Height: 50} + footer.Update(msg) + odize.AssertEqual(t, 100, footer.Width) + }). + Test("View should render help text", func(t *testing.T) { + footer := NewFooter(NewCommonKeys()) + style := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) + + rendered := footer.View(style) + + odize.AssertTrue(t, len(rendered) > 0) + odize.AssertTrue(t, strings.Contains(rendered, "q")) + odize.AssertTrue(t, strings.Contains(rendered, "quit")) + }). + Run() + + odize.AssertNoError(t, err) +} + +func TestCommonKeys(t *testing.T) { + group := odize.NewGroup(t, nil) + + err := group. + Test("NewCommonKeys should have Quit binding", func(t *testing.T) { + keys := NewCommonKeys() + odize.AssertTrue(t, keys.Quit.Enabled()) + odize.AssertEqual(t, "q", keys.Quit.Keys()[0]) + }). + Test("ShortHelp should contain Quit", func(t *testing.T) { + keys := NewCommonKeys() + shortHelp := keys.ShortHelp() + odize.AssertEqual(t, 1, len(shortHelp)) + odize.AssertEqual(t, keys.Quit, shortHelp[0]) + }). + Test("FullHelp should contain Quit in the first row", func(t *testing.T) { + keys := NewCommonKeys() + fullHelp := keys.FullHelp() + odize.AssertEqual(t, 1, len(fullHelp)) + odize.AssertEqual(t, 1, len(fullHelp[0])) + odize.AssertEqual(t, keys.Quit, fullHelp[0][0]) + }). + Run() + + odize.AssertNoError(t, err) +} diff --git a/internal/ui/page_create_coach.go b/internal/ui/page_create_coach.go index 9bb99eb..9d9b92c 100644 --- a/internal/ui/page_create_coach.go +++ b/internal/ui/page_create_coach.go @@ -1,12 +1,66 @@ package ui import ( + "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/code-gorilla-au/rush/internal/teams" + "github.com/code-gorilla-au/rush/internal/ui/components" ) +type createCoachKeyMap struct { + components.CommonKeys + Back key.Binding + Enter key.Binding + Up key.Binding + Down key.Binding + Tab key.Binding + ShiftTab key.Binding +} + +func (k createCoachKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Enter, k.Back, k.Quit} +} + +func (k createCoachKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Enter, k.Back}, + {k.Up, k.Down, k.Tab, k.ShiftTab}, + {k.Quit}, + } +} + +func newCreateCoachKeyMap() createCoachKeyMap { + return createCoachKeyMap{ + CommonKeys: components.NewCommonKeys(), + Back: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "continue"), + ), + Up: key.NewBinding( + key.WithKeys("up"), + key.WithHelp("↑", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down"), + key.WithHelp("↓", "down"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "next"), + ), + ShiftTab: key.NewBinding( + key.WithKeys("shift+tab"), + key.WithHelp("shift+tab", "prev"), + ), + } +} + type ModelCreateCoach struct { width int height int @@ -18,6 +72,8 @@ type ModelCreateCoach struct { teamInput textinput.Model focusIndex int err error + keys createCoachKeyMap + footer components.Footer } func NewModelCreateCoach(state *GlobalState, teamsSvc *teams.Service) *ModelCreateCoach { @@ -32,12 +88,16 @@ func NewModelCreateCoach(state *GlobalState, teamsSvc *teams.Service) *ModelCrea t.CharLimit = 156 c.SetWidth(20) + keys := newCreateCoachKeyMap() + return &ModelCreateCoach{ globalState: state, teamsSvc: teamsSvc, coachInput: c, teamInput: t, theme: NewIceTheme(), + keys: keys, + footer: components.NewFooter(keys), } } @@ -52,34 +112,35 @@ func (m *ModelCreateCoach) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + m.footer.Update(msg) case tea.KeyMsg: - switch msg.String() { - case "tab", "shift+tab", "up", "down": - s := msg.String() - - if s == "up" || s == "shift+tab" { - m.focusIndex-- - } else { - m.focusIndex++ + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + + case key.Matches(msg, m.keys.Up), key.Matches(msg, m.keys.ShiftTab): + m.focusIndex-- + if m.focusIndex < 0 { + m.focusIndex = 1 } + m.updateFocus() + case key.Matches(msg, m.keys.Down), key.Matches(msg, m.keys.Tab): + m.focusIndex++ if m.focusIndex > 1 { m.focusIndex = 0 - } else if m.focusIndex < 0 { - m.focusIndex = 1 } - m.updateFocus() - case "enter": + case key.Matches(msg, m.keys.Enter): if m.focusIndex == 1 { return m, m.submit() } m.focusIndex++ m.updateFocus() - case "esc": + case key.Matches(msg, m.keys.Back): return m, func() tea.Msg { return MsgSwitchPage{NewPage: PageTitle} } @@ -138,11 +199,6 @@ func (m *ModelCreateCoach) View() tea.View { view := tea.NewView("") view.AltScreen = true - s := lipgloss.NewStyle(). - Width(m.width). - Height(m.height). - Align(lipgloss.Center, lipgloss.Center) - form := lipgloss.JoinVertical( lipgloss.Left, m.theme.Logo.Render("RUSH - NEW CAREER"), @@ -153,9 +209,19 @@ func (m *ModelCreateCoach) View() tea.View { "Team Details", m.teamInput.View(), "", - m.theme.Hotkey.Render("enter")+" continue • "+m.theme.Hotkey.Render("esc")+" back", + m.footer.View(m.theme.Footer), + ) + + centeredContent := lipgloss.Place( + m.width, m.height, + lipgloss.Center, lipgloss.Center, + form, ) - view.Content = s.Render(form) + view.Content = m.theme.Base. + Width(m.width). + Height(m.height). + Render(centeredContent) + return view } diff --git a/internal/ui/page_locker_room.go b/internal/ui/page_locker_room.go index 0118c9f..a4985e0 100644 --- a/internal/ui/page_locker_room.go +++ b/internal/ui/page_locker_room.go @@ -1,17 +1,54 @@ package ui -import tea "charm.land/bubbletea/v2" +import ( + "github.com/code-gorilla-au/rush/internal/ui/components" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +type lockerRoomKeyMap struct { + components.CommonKeys + Back key.Binding +} + +func (k lockerRoomKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Back, k.Quit} +} + +func (k lockerRoomKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Back, k.Quit}, + } +} + +func newLockerRoomKeyMap() lockerRoomKeyMap { + return lockerRoomKeyMap{ + CommonKeys: components.NewCommonKeys(), + Back: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back to title"), + ), + } +} type ModelLockerRoom struct { width int height int theme IceTheme globalState *GlobalState + keys lockerRoomKeyMap + footer components.Footer } func NewModelLockerRoom(globalState *GlobalState) *ModelLockerRoom { + keys := newLockerRoomKeyMap() return &ModelLockerRoom{ globalState: globalState, + keys: keys, + footer: components.NewFooter(keys), + theme: NewIceTheme(), } } @@ -25,18 +62,46 @@ func (m *ModelLockerRoom) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.globalState.Coach = msg.Coach m.globalState.Team = msg.Team case tea.KeyMsg: - switch msg.String() { - case "q", "ctrl+c": + switch { + case key.Matches(msg, m.keys.Quit): return m, tea.Quit + case key.Matches(msg, m.keys.Back): + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageTitle} + } } case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + m.footer.Update(msg) } return m, nil } func (m *ModelLockerRoom) View() tea.View { - return tea.NewView("locker room") + view := tea.NewView("") + view.AltScreen = true + + content := lipgloss.JoinVertical( + lipgloss.Center, + m.theme.Logo.Render("LOCKER ROOM"), + "", + "Welcome to the locker room!", + "", + m.footer.View(m.theme.Footer), + ) + + centeredContent := lipgloss.Place( + m.width, m.height, + lipgloss.Center, lipgloss.Center, + content, + ) + + view.Content = m.theme.Base. + Width(m.width). + Height(m.height). + Render(centeredContent) + + return view } diff --git a/internal/ui/page_title.go b/internal/ui/page_title.go index 50ce47f..1347d24 100644 --- a/internal/ui/page_title.go +++ b/internal/ui/page_title.go @@ -3,20 +3,58 @@ package ui import ( "strings" + "github.com/code-gorilla-au/rush/internal/ui/components" + + "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) +type titleKeyMap struct { + components.CommonKeys + CreateCoach key.Binding + LockerRoom key.Binding +} + +func (k titleKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.CreateCoach, k.LockerRoom, k.Quit} +} + +func (k titleKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.CreateCoach, k.LockerRoom, k.Quit}, + } +} + +func newTitleKeyMap() titleKeyMap { + return titleKeyMap{ + CommonKeys: components.NewCommonKeys(), + CreateCoach: key.NewBinding( + key.WithKeys("c"), + key.WithHelp("c", "create coach"), + ), + LockerRoom: key.NewBinding( + key.WithKeys("l"), + key.WithHelp("l", "locker room"), + ), + } +} + type ModelTitle struct { width int height int theme IceTheme globalState *GlobalState + keys titleKeyMap + footer components.Footer } func NewModelTitle(globalState *GlobalState) *ModelTitle { + keys := newTitleKeyMap() return &ModelTitle{ globalState: globalState, + keys: keys, + footer: components.NewFooter(keys), } } @@ -30,16 +68,16 @@ func (m ModelTitle) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.globalState.Coach = msg.Coach m.globalState.Team = msg.Team case tea.KeyMsg: - switch msg.String() { - case "q", "ctrl+c": + switch { + case key.Matches(msg, m.keys.Quit): return m, tea.Quit - case "c": + case key.Matches(msg, m.keys.CreateCoach): if m.globalState.Coach == nil { return m, func() tea.Msg { return MsgSwitchPage{NewPage: PageCreateCoach} } } - case "l": + case key.Matches(msg, m.keys.LockerRoom): if m.globalState.Coach != nil { return m, func() tea.Msg { return MsgSwitchPage{NewPage: PageLockerRoom} @@ -49,6 +87,7 @@ func (m ModelTitle) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + m.footer.Update(msg) } return m, nil @@ -76,10 +115,14 @@ func (m ModelTitle) View() tea.View { navigation = m.theme.Button.Render("Locker Room (l)") } - footer := "Press 'q' to quit" - styledFooter := m.theme.Footer.Render(footer) - - content := styledLogo + "\n\n" + navigation + "\n\n" + styledFooter + content := lipgloss.JoinVertical( + lipgloss.Center, + styledLogo, + "", + navigation, + "", + m.footer.View(m.theme.Footer), + ) centeredContent := lipgloss.Place( m.width, m.height, diff --git a/internal/ui/page_title_test.go b/internal/ui/page_title_test.go index 6f51995..81d55f3 100644 --- a/internal/ui/page_title_test.go +++ b/internal/ui/page_title_test.go @@ -17,7 +17,7 @@ func TestModelTitle(t *testing.T) { m.width = 100 m.height = 50 - _, cmd := m.Update(tea.KeyPressMsg{Text: "c"}) + _, cmd := m.Update(tea.KeyPressMsg{Text: m.keys.CreateCoach.Keys()[0]}) odize.AssertTrue(t, cmd != nil) msg := cmd() @@ -33,7 +33,7 @@ func TestModelTitle(t *testing.T) { m.width = 100 m.height = 50 - _, cmd := m.Update(tea.KeyPressMsg{Text: "l"}) + _, cmd := m.Update(tea.KeyPressMsg{Text: m.keys.LockerRoom.Keys()[0]}) odize.AssertTrue(t, cmd != nil) msg := cmd() From ea593eb3565a72b55a9cab9901c2ddbc6dee9388 Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 19 Jun 2026 20:26:39 +1000 Subject: [PATCH 26/98] wip --- internal/ui/page_create_coach.go | 89 +++++++++++++++++++------------- 1 file changed, 54 insertions(+), 35 deletions(-) diff --git a/internal/ui/page_create_coach.go b/internal/ui/page_create_coach.go index 9d9b92c..269be98 100644 --- a/internal/ui/page_create_coach.go +++ b/internal/ui/page_create_coach.go @@ -106,8 +106,6 @@ func (m *ModelCreateCoach) Init() tea.Cmd { } func (m *ModelCreateCoach) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width @@ -115,47 +113,68 @@ func (m *ModelCreateCoach) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.footer.Update(msg) case tea.KeyMsg: - switch { - case key.Matches(msg, m.keys.Quit): - return m, tea.Quit - - case key.Matches(msg, m.keys.Up), key.Matches(msg, m.keys.ShiftTab): - m.focusIndex-- - if m.focusIndex < 0 { - m.focusIndex = 1 - } - m.updateFocus() - - case key.Matches(msg, m.keys.Down), key.Matches(msg, m.keys.Tab): - m.focusIndex++ - if m.focusIndex > 1 { - m.focusIndex = 0 - } - m.updateFocus() - - case key.Matches(msg, m.keys.Enter): - if m.focusIndex == 1 { - return m, m.submit() - } - m.focusIndex++ - m.updateFocus() - - case key.Matches(msg, m.keys.Back): - return m, func() tea.Msg { - return MsgSwitchPage{NewPage: PageTitle} - } + if cmd, done := m.handleKeyMsg(msg); done { + return m, cmd } case MsgStateUpdated: - m.globalState.Coach = msg.Coach - m.globalState.Team = msg.Team + return m.handleStateUpdated(msg) + } + + return m.updateInputs(msg) +} + +func (m *ModelCreateCoach) handleKeyMsg(msg tea.KeyMsg) (tea.Cmd, bool) { + switch { + case key.Matches(msg, m.keys.Quit): + return tea.Quit, true + + case key.Matches(msg, m.keys.Up), key.Matches(msg, m.keys.ShiftTab): + m.focusIndex-- + if m.focusIndex < 0 { + m.focusIndex = 1 + } + m.updateFocus() + return nil, false + + case key.Matches(msg, m.keys.Down), key.Matches(msg, m.keys.Tab): + m.focusIndex++ + if m.focusIndex > 1 { + m.focusIndex = 0 + } + m.updateFocus() + return nil, false - return m, func() tea.Msg { - return MsgSwitchPage{NewPage: PageLockerRoom} + case key.Matches(msg, m.keys.Enter): + if m.focusIndex == 1 { + return m.submit(), true } + m.focusIndex++ + m.updateFocus() + return nil, false + + case key.Matches(msg, m.keys.Back): + return func() tea.Msg { + return MsgSwitchPage{NewPage: PageTitle} + }, true } + return nil, false +} + +func (m *ModelCreateCoach) handleStateUpdated(msg MsgStateUpdated) (tea.Model, tea.Cmd) { + m.globalState.Coach = msg.Coach + m.globalState.Team = msg.Team + + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageLockerRoom} + } +} + +func (m *ModelCreateCoach) updateInputs(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd var cmd tea.Cmd + m.coachInput, cmd = m.coachInput.Update(msg) cmds = append(cmds, cmd) From 3ecebea7ba4aac03080523c2ad19c40e3c2cdbac Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 19 Jun 2026 20:37:49 +1000 Subject: [PATCH 27/98] wip --- internal/ui/components/locker_room_list.go | 72 +++++++++++++++++++ .../ui/components/locker_room_list_test.go | 52 ++++++++++++++ internal/ui/page_locker_room.go | 6 +- internal/ui/theme.go | 14 ++-- 4 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 internal/ui/components/locker_room_list.go create mode 100644 internal/ui/components/locker_room_list_test.go diff --git a/internal/ui/components/locker_room_list.go b/internal/ui/components/locker_room_list.go new file mode 100644 index 0000000..6b8c8ba --- /dev/null +++ b/internal/ui/components/locker_room_list.go @@ -0,0 +1,72 @@ +package components + +import ( + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +type LockerRoomItem int + +const ( + ItemPlayers LockerRoomItem = iota + ItemPlaybooks + ItemSettings +) + +func (i LockerRoomItem) String() string { + switch i { + case ItemPlayers: + return "Players" + case ItemPlaybooks: + return "Playbooks" + case ItemSettings: + return "Settings" + } + return "" +} + +type LockerRoomList struct { + cursor int + items []LockerRoomItem +} + +func NewLockerRoomList() LockerRoomList { + return LockerRoomList{ + items: []LockerRoomItem{ItemPlayers, ItemPlaybooks, ItemSettings}, + } +} + +func (l *LockerRoomList) Update(msg tea.Msg) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "up", "k": + if l.cursor > 0 { + l.cursor-- + } + case "down", "j": + if l.cursor < len(l.items)-1 { + l.cursor++ + } + } + } +} + +func (l *LockerRoomList) View(itemStyle lipgloss.Style, selectedStyle lipgloss.Style) string { + var s string + for i, item := range l.items { + if i == l.cursor { + s += selectedStyle.Render("> " + item.String()) + } else { + s += itemStyle.Render(" " + item.String()) + } + if i < len(l.items)-1 { + s += "\n" + } + } + return s +} + +func (l *LockerRoomList) SelectedItem() LockerRoomItem { + return l.items[l.cursor] +} diff --git a/internal/ui/components/locker_room_list_test.go b/internal/ui/components/locker_room_list_test.go new file mode 100644 index 0000000..10a2abf --- /dev/null +++ b/internal/ui/components/locker_room_list_test.go @@ -0,0 +1,52 @@ +package components + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/code-gorilla-au/odize" +) + +func TestLockerRoomList(t *testing.T) { + group := odize.NewGroup(t, nil) + + group.Test("NewLockerRoomList should have 3 items", func(t *testing.T) { + l := NewLockerRoomList() + odize.AssertEqual(t, 3, len(l.items)) + odize.AssertEqual(t, ItemPlayers, l.items[0]) + odize.AssertEqual(t, ItemPlaybooks, l.items[1]) + odize.AssertEqual(t, ItemSettings, l.items[2]) + }) + + group.Test("Update should move cursor down", func(t *testing.T) { + l := NewLockerRoomList() + odize.AssertEqual(t, 0, l.cursor) + + l.Update(tea.KeyPressMsg{Text: "down"}) + odize.AssertEqual(t, 1, l.cursor) + odize.AssertEqual(t, ItemPlaybooks, l.SelectedItem()) + }) + + group.Test("Update should move cursor up", func(t *testing.T) { + l := NewLockerRoomList() + l.cursor = 1 + + l.Update(tea.KeyPressMsg{Text: "up"}) + odize.AssertEqual(t, 0, l.cursor) + odize.AssertEqual(t, ItemPlayers, l.SelectedItem()) + }) + + group.Test("Update should not move cursor out of bounds", func(t *testing.T) { + l := NewLockerRoomList() + + l.Update(tea.KeyPressMsg{Text: "up"}) + odize.AssertEqual(t, 0, l.cursor) + + l.cursor = 2 + l.Update(tea.KeyPressMsg{Text: "down"}) + odize.AssertEqual(t, 2, l.cursor) + }) + + err := group.Run() + odize.AssertNoError(t, err) +} diff --git a/internal/ui/page_locker_room.go b/internal/ui/page_locker_room.go index a4985e0..c40fa33 100644 --- a/internal/ui/page_locker_room.go +++ b/internal/ui/page_locker_room.go @@ -40,6 +40,7 @@ type ModelLockerRoom struct { globalState *GlobalState keys lockerRoomKeyMap footer components.Footer + list components.LockerRoomList } func NewModelLockerRoom(globalState *GlobalState) *ModelLockerRoom { @@ -49,6 +50,7 @@ func NewModelLockerRoom(globalState *GlobalState) *ModelLockerRoom { keys: keys, footer: components.NewFooter(keys), theme: NewIceTheme(), + list: components.NewLockerRoomList(), } } @@ -57,6 +59,8 @@ func (m *ModelLockerRoom) Init() tea.Cmd { } func (m *ModelLockerRoom) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + m.list.Update(msg) + switch msg := msg.(type) { case MsgStateUpdated: m.globalState.Coach = msg.Coach @@ -87,7 +91,7 @@ func (m *ModelLockerRoom) View() tea.View { lipgloss.Center, m.theme.Logo.Render("LOCKER ROOM"), "", - "Welcome to the locker room!", + m.list.View(m.theme.Base, m.theme.ListSelected), "", m.footer.View(m.theme.Footer), ) diff --git a/internal/ui/theme.go b/internal/ui/theme.go index b63fb72..e5bb6d5 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -4,11 +4,12 @@ import "charm.land/lipgloss/v2" // IceTheme defines the "ice" color palette and styles. type IceTheme struct { - Logo lipgloss.Style - Footer lipgloss.Style - Base lipgloss.Style - Button lipgloss.Style - Hotkey lipgloss.Style + Logo lipgloss.Style + Footer lipgloss.Style + Base lipgloss.Style + Button lipgloss.Style + Hotkey lipgloss.Style + ListSelected lipgloss.Style } // NewIceTheme returns a new IceTheme with the "ice" palette. @@ -38,5 +39,8 @@ func NewIceTheme() IceTheme { Hotkey: lipgloss.NewStyle(). Foreground(iceBlue). Bold(true), + ListSelected: lipgloss.NewStyle(). + Foreground(iceBlue). + Bold(true), } } From 0b462325417afeba6b14d112937701350eeb7036 Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 19 Jun 2026 20:57:37 +1000 Subject: [PATCH 28/98] adding players --- internal/database/queries/team.sql | 2 +- internal/database/team.sql.gen.go | 16 ++++++++++++---- internal/teams/interfaces.go | 2 +- internal/teams/service.go | 28 +++++++++++++++++++++++++++- 4 files changed, 41 insertions(+), 7 deletions(-) diff --git a/internal/database/queries/team.sql b/internal/database/queries/team.sql index b0127b3..01facf6 100644 --- a/internal/database/queries/team.sql +++ b/internal/database/queries/team.sql @@ -43,7 +43,7 @@ DELETE FROM teams WHERE id = ?; -- name: GetTeamMembers :many SELECT * FROM players WHERE team_id = ?; --- name: CreatePlayer :exec +-- name: CreatePlayer :one INSERT INTO players (name, team_id) VALUES (?,?) RETURNING *; -- name: DeletePlayer :exec diff --git a/internal/database/team.sql.gen.go b/internal/database/team.sql.gen.go index e75e9e1..a1c4052 100644 --- a/internal/database/team.sql.gen.go +++ b/internal/database/team.sql.gen.go @@ -50,7 +50,7 @@ func (q *Queries) CreateCoach(ctx context.Context, arg CreateCoachParams) (Coach return i, err } -const createPlayer = `-- name: CreatePlayer :exec +const createPlayer = `-- name: CreatePlayer :one INSERT INTO players (name, team_id) VALUES (?,?) RETURNING id, name, team_id, created_at, updated_at ` @@ -59,9 +59,17 @@ type CreatePlayerParams struct { TeamID sql.NullInt64 } -func (q *Queries) CreatePlayer(ctx context.Context, arg CreatePlayerParams) error { - _, err := q.db.ExecContext(ctx, createPlayer, arg.Name, arg.TeamID) - return err +func (q *Queries) CreatePlayer(ctx context.Context, arg CreatePlayerParams) (Player, error) { + row := q.db.QueryRowContext(ctx, createPlayer, arg.Name, arg.TeamID) + var i Player + err := row.Scan( + &i.ID, + &i.Name, + &i.TeamID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err } const createTeam = `-- name: CreateTeam :exec diff --git a/internal/teams/interfaces.go b/internal/teams/interfaces.go index 331d5aa..edabf09 100644 --- a/internal/teams/interfaces.go +++ b/internal/teams/interfaces.go @@ -23,7 +23,7 @@ type CoachStore interface { } type PlayerStore interface { - CreatePlayer(ctx context.Context, arg database.CreatePlayerParams) error + CreatePlayer(ctx context.Context, arg database.CreatePlayerParams) (database.Player, error) GetTeamMembers(ctx context.Context, teamID sql.NullInt64) ([]database.Player, error) } diff --git a/internal/teams/service.go b/internal/teams/service.go index 181b86f..d211608 100644 --- a/internal/teams/service.go +++ b/internal/teams/service.go @@ -93,7 +93,33 @@ func (s *Service) CreateTeam(ctx context.Context, name string, coachID int64, is return Team{}, fmt.Errorf("getting created team: %w", err) } - return fromTeamModel(model, nil), nil + playersModel, err := s.createPlayers(ctx, model.ID) + if err != nil { + return Team{}, fmt.Errorf("creating players: %w", err) + } + + return fromTeamModel(model, playersModel), nil +} + +func (s *Service) createPlayers(ctx context.Context, teamID int64) ([]database.Player, error) { + modelPlayers := make([]database.Player, 5) + + for i := 0; i < 5; i++ { + model, err := s.store.CreatePlayer(ctx, database.CreatePlayerParams{ + Name: "Player " + fmt.Sprint(i+1), + TeamID: sql.NullInt64{ + Int64: teamID, + Valid: true, + }, + }) + if err != nil { + return modelPlayers, fmt.Errorf("creating player: %w", err) + } + + modelPlayers[i] = model + } + + return modelPlayers, nil } func (s *Service) SetDefaultTeam(ctx context.Context, id int64) error { From 04068949bc0145d34638241f529983b53289d77a Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 19 Jun 2026 21:09:07 +1000 Subject: [PATCH 29/98] adding page --- internal/ui/app.go | 39 +++++++++++++++-------------- internal/ui/page_locker_players.go | 40 ++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 18 deletions(-) create mode 100644 internal/ui/page_locker_players.go diff --git a/internal/ui/app.go b/internal/ui/app.go index 971fd70..4091f92 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -22,6 +22,7 @@ const ( PageTitle Page = iota + 1 PageCreateCoach PageLockerRoom + PageLockerPlayers ) type GlobalState struct { @@ -34,16 +35,17 @@ func (m *GlobalState) Context() context.Context { } type RootModel struct { - ctx context.Context - width int - height int - theme IceTheme - currentPage Page - pageTitle tea.Model - pageCreateCoach tea.Model - pageLockerRoom tea.Model - globalState *GlobalState - teamsSvc *teams.Service + ctx context.Context + width int + height int + theme IceTheme + currentPage Page + pageTitle tea.Model + pageCreateCoach tea.Model + pageLockerRoom tea.Model + pageLockerPlayers tea.Model + globalState *GlobalState + teamsSvc *teams.Service } // New returns a new UI model. @@ -51,14 +53,15 @@ func New(teamsService *teams.Service) RootModel { state := &GlobalState{} return RootModel{ - ctx: context.Background(), - theme: NewIceTheme(), - currentPage: PageTitle, - pageTitle: NewModelTitle(state), - pageCreateCoach: NewModelCreateCoach(state, teamsService), - pageLockerRoom: NewModelLockerRoom(state), - globalState: state, - teamsSvc: teamsService, + ctx: context.Background(), + theme: NewIceTheme(), + currentPage: PageTitle, + pageTitle: NewModelTitle(state), + pageCreateCoach: NewModelCreateCoach(state, teamsService), + pageLockerRoom: NewModelLockerRoom(state), + pageLockerPlayers: NewModelLockerPlayers(state, teamsService), + globalState: state, + teamsSvc: teamsService, } } diff --git a/internal/ui/page_locker_players.go b/internal/ui/page_locker_players.go new file mode 100644 index 0000000..6ece585 --- /dev/null +++ b/internal/ui/page_locker_players.go @@ -0,0 +1,40 @@ +package ui + +import ( + tea "charm.land/bubbletea/v2" + "github.com/code-gorilla-au/rush/internal/teams" +) + +type ModelLockerPlayers struct { + width int + height int + theme IceTheme + globalState *GlobalState + teamsSvc *teams.Service +} + +func NewModelLockerPlayers(state *GlobalState, teamsSvc *teams.Service) *ModelLockerPlayers { + return &ModelLockerPlayers{ + theme: NewIceTheme(), + globalState: state, + teamsSvc: teamsSvc, + } +} + +func (m *ModelLockerPlayers) Init() tea.Cmd { + return nil +} + +func (m *ModelLockerPlayers) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + } + + return m, nil +} + +func (m *ModelLockerPlayers) View() tea.View { + return tea.NewView("locker players") +} From 234d3e83c05077c0e3588f1f2dc2cc2d1ca02b44 Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 19 Jun 2026 21:16:51 +1000 Subject: [PATCH 30/98] added locker room and players --- internal/ui/app.go | 6 +++ internal/ui/page_locker_players.go | 71 +++++++++++++++++++++++++++- internal/ui/page_locker_room.go | 17 +++++-- internal/ui/page_locker_room_test.go | 36 ++++++++++++++ 4 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 internal/ui/page_locker_room_test.go diff --git a/internal/ui/app.go b/internal/ui/app.go index 4091f92..a513212 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -105,6 +105,8 @@ func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) m.pageLockerRoom, cmd = m.pageLockerRoom.Update(msg) cmds = append(cmds, cmd) + m.pageLockerPlayers, cmd = m.pageLockerPlayers.Update(msg) + cmds = append(cmds, cmd) } var cmd tea.Cmd @@ -115,6 +117,8 @@ func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.pageCreateCoach, cmd = m.pageCreateCoach.Update(msg) case PageLockerRoom: m.pageLockerRoom, cmd = m.pageLockerRoom.Update(msg) + case PageLockerPlayers: + m.pageLockerPlayers, cmd = m.pageLockerPlayers.Update(msg) } cmds = append(cmds, cmd) @@ -133,6 +137,8 @@ func (m RootModel) View() tea.View { return m.pageCreateCoach.View() case PageLockerRoom: return m.pageLockerRoom.View() + case PageLockerPlayers: + return m.pageLockerPlayers.View() } return tea.NewView("unknown page") diff --git a/internal/ui/page_locker_players.go b/internal/ui/page_locker_players.go index 6ece585..5acfa85 100644 --- a/internal/ui/page_locker_players.go +++ b/internal/ui/page_locker_players.go @@ -1,23 +1,57 @@ package ui import ( - tea "charm.land/bubbletea/v2" "github.com/code-gorilla-au/rush/internal/teams" + "github.com/code-gorilla-au/rush/internal/ui/components" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" ) +type lockerPlayersKeyMap struct { + components.CommonKeys + Back key.Binding +} + +func (k lockerPlayersKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Back, k.Quit} +} + +func (k lockerPlayersKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Back, k.Quit}, + } +} + +func newLockerPlayersKeyMap() lockerPlayersKeyMap { + return lockerPlayersKeyMap{ + CommonKeys: components.NewCommonKeys(), + Back: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back to locker room"), + ), + } +} + type ModelLockerPlayers struct { width int height int theme IceTheme globalState *GlobalState teamsSvc *teams.Service + keys lockerPlayersKeyMap + footer components.Footer } func NewModelLockerPlayers(state *GlobalState, teamsSvc *teams.Service) *ModelLockerPlayers { + keys := newLockerPlayersKeyMap() return &ModelLockerPlayers{ theme: NewIceTheme(), globalState: state, teamsSvc: teamsSvc, + keys: keys, + footer: components.NewFooter(keys), } } @@ -27,14 +61,47 @@ func (m *ModelLockerPlayers) Init() tea.Cmd { func (m *ModelLockerPlayers) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + case key.Matches(msg, m.keys.Back): + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageLockerRoom} + } + } case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + m.footer.Update(msg) } return m, nil } func (m *ModelLockerPlayers) View() tea.View { - return tea.NewView("locker players") + view := tea.NewView("") + view.AltScreen = true + + content := lipgloss.JoinVertical( + lipgloss.Center, + m.theme.Logo.Render("PLAYERS"), + "", + "Locker Room Players", + "", + m.footer.View(m.theme.Footer), + ) + + centeredContent := lipgloss.Place( + m.width, m.height, + lipgloss.Center, lipgloss.Center, + content, + ) + + view.Content = m.theme.Base. + Width(m.width). + Height(m.height). + Render(centeredContent) + + return view } diff --git a/internal/ui/page_locker_room.go b/internal/ui/page_locker_room.go index c40fa33..518ea5a 100644 --- a/internal/ui/page_locker_room.go +++ b/internal/ui/page_locker_room.go @@ -10,16 +10,17 @@ import ( type lockerRoomKeyMap struct { components.CommonKeys - Back key.Binding + Back key.Binding + Select key.Binding } func (k lockerRoomKeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Back, k.Quit} + return []key.Binding{k.Select, k.Back, k.Quit} } func (k lockerRoomKeyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ - {k.Back, k.Quit}, + {k.Select, k.Back, k.Quit}, } } @@ -30,6 +31,10 @@ func newLockerRoomKeyMap() lockerRoomKeyMap { key.WithKeys("esc"), key.WithHelp("esc", "back to title"), ), + Select: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select"), + ), } } @@ -73,6 +78,12 @@ func (m *ModelLockerRoom) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, func() tea.Msg { return MsgSwitchPage{NewPage: PageTitle} } + case key.Matches(msg, m.keys.Select): + if m.list.SelectedItem() == components.ItemPlayers { + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageLockerPlayers} + } + } } case tea.WindowSizeMsg: m.width = msg.Width diff --git a/internal/ui/page_locker_room_test.go b/internal/ui/page_locker_room_test.go new file mode 100644 index 0000000..ba92493 --- /dev/null +++ b/internal/ui/page_locker_room_test.go @@ -0,0 +1,36 @@ +package ui + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/ui/components" +) + +func TestModelLockerRoom_Selection(t *testing.T) { + group := odize.NewGroup(t, nil) + + group.Test("should route to locker players when players item is selected", func(t *testing.T) { + state := &GlobalState{} + m := NewModelLockerRoom(state) + + // Ensure ItemPlayers is selected (it is by default) + odize.AssertEqual(t, components.ItemPlayers, m.list.SelectedItem()) + + // Simulate Enter key press + _, cmd := m.Update(tea.KeyPressMsg{Text: "enter"}) + + odize.AssertTrue(t, cmd != nil) + msg := cmd() + switch v := msg.(type) { + case MsgSwitchPage: + odize.AssertEqual(t, PageLockerPlayers, v.NewPage) + default: + t.Fatalf("expected MsgSwitchPage, got %T", msg) + } + }) + + err := group.Run() + odize.AssertNoError(t, err) +} From ad01d60d9b708ec5454d188383caf5b48bfe8cee Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 19 Jun 2026 21:26:14 +1000 Subject: [PATCH 31/98] adding ability to update player names --- internal/database/queries/team.sql | 3 + internal/database/team.sql.gen.go | 14 ++++ internal/teams/interfaces.go | 1 + internal/teams/service.go | 12 +++ internal/teams/service_test.go | 29 +++++++ internal/ui/components/player_list.go | 111 ++++++++++++++++++++++++++ internal/ui/page_locker_players.go | 67 ++++++++++++++-- 7 files changed, 232 insertions(+), 5 deletions(-) create mode 100644 internal/ui/components/player_list.go diff --git a/internal/database/queries/team.sql b/internal/database/queries/team.sql index 01facf6..0171996 100644 --- a/internal/database/queries/team.sql +++ b/internal/database/queries/team.sql @@ -46,5 +46,8 @@ SELECT * FROM players WHERE team_id = ?; -- name: CreatePlayer :one INSERT INTO players (name, team_id) VALUES (?,?) RETURNING *; +-- name: UpdatePlayer :exec +UPDATE players SET name = ? WHERE id = ?; + -- name: DeletePlayer :exec DELETE FROM players WHERE id = ?; \ No newline at end of file diff --git a/internal/database/team.sql.gen.go b/internal/database/team.sql.gen.go index a1c4052..511035c 100644 --- a/internal/database/team.sql.gen.go +++ b/internal/database/team.sql.gen.go @@ -302,3 +302,17 @@ func (q *Queries) SetDefaultTeam(ctx context.Context, id int64) error { _, err := q.db.ExecContext(ctx, setDefaultTeam, id) return err } + +const updatePlayer = `-- name: UpdatePlayer :exec +UPDATE players SET name = ? WHERE id = ? +` + +type UpdatePlayerParams struct { + Name string + ID int64 +} + +func (q *Queries) UpdatePlayer(ctx context.Context, arg UpdatePlayerParams) error { + _, err := q.db.ExecContext(ctx, updatePlayer, arg.Name, arg.ID) + return err +} diff --git a/internal/teams/interfaces.go b/internal/teams/interfaces.go index edabf09..b914e80 100644 --- a/internal/teams/interfaces.go +++ b/internal/teams/interfaces.go @@ -25,6 +25,7 @@ type CoachStore interface { type PlayerStore interface { CreatePlayer(ctx context.Context, arg database.CreatePlayerParams) (database.Player, error) GetTeamMembers(ctx context.Context, teamID sql.NullInt64) ([]database.Player, error) + UpdatePlayer(ctx context.Context, arg database.UpdatePlayerParams) error } type TeamStore interface { diff --git a/internal/teams/service.go b/internal/teams/service.go index d211608..03e94e0 100644 --- a/internal/teams/service.go +++ b/internal/teams/service.go @@ -126,6 +126,18 @@ func (s *Service) SetDefaultTeam(ctx context.Context, id int64) error { return s.store.SetDefaultTeam(ctx, id) } +func (s *Service) UpdatePlayer(ctx context.Context, id int64, name string) error { + err := s.store.UpdatePlayer(ctx, database.UpdatePlayerParams{ + ID: id, + Name: name, + }) + if err != nil { + return fmt.Errorf("updating player: %w", err) + } + + return nil +} + func (s *Service) ClearDefaultTeam(ctx context.Context) error { return s.store.ClearDefaultTeam(ctx) } diff --git a/internal/teams/service_test.go b/internal/teams/service_test.go index 3889bd8..ad19468 100644 --- a/internal/teams/service_test.go +++ b/internal/teams/service_test.go @@ -141,6 +141,35 @@ func TestService(t *testing.T) { odize.AssertNoError(t, sErr) odize.AssertTrue(t, coach.ID > 0) }). + Test("UpdatePlayer should update player name", func(t *testing.T) { + ctx := t.Context() + coach, err := s.CreateCoach(ctx, "Coach", true) + odize.AssertNoError(t, err) + + team, err := s.CreateTeam(ctx, "Team", coach.ID, true) + odize.AssertNoError(t, err) + odize.AssertTrue(t, len(team.Players) > 0) + + player := team.Players[0] + newName := "Updated Name" + + err = s.UpdatePlayer(ctx, player.ID, newName) + odize.AssertNoError(t, err) + + // Verify + members, err := queries.GetTeamMembers(ctx, sql.NullInt64{Int64: team.ID, Valid: true}) + odize.AssertNoError(t, err) + + found := false + for _, m := range members { + if m.ID == player.ID { + odize.AssertEqual(t, newName, m.Name) + found = true + break + } + } + odize.AssertTrue(t, found) + }). Run() odize.AssertNoError(t, err) diff --git a/internal/ui/components/player_list.go b/internal/ui/components/player_list.go new file mode 100644 index 0000000..4a64449 --- /dev/null +++ b/internal/ui/components/player_list.go @@ -0,0 +1,111 @@ +package components + +import ( + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/code-gorilla-au/rush/internal/teams" +) + +type PlayerItem struct { + Player teams.Player + Input textinput.Model + IsEditing bool +} + +type PlayerList struct { + cursor int + Items []PlayerItem +} + +func NewPlayerList(players []teams.Player) PlayerList { + items := make([]PlayerItem, len(players)) + for i, p := range players { + ti := textinput.New() + ti.SetValue(p.Name) + ti.CharLimit = 50 + ti.SetWidth(20) + items[i] = PlayerItem{ + Player: p, + Input: ti, + } + } + return PlayerList{ + Items: items, + } +} + +func (l *PlayerList) Update(msg tea.Msg) tea.Cmd { + if len(l.Items) == 0 { + return nil + } + + item := &l.Items[l.cursor] + + if item.IsEditing { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "enter": + item.IsEditing = false + item.Input.Blur() + item.Player.Name = item.Input.Value() + return func() tea.Msg { + return MsgPlayerUpdated{Player: item.Player} + } + case "esc": + item.IsEditing = false + item.Input.Blur() + item.Input.SetValue(item.Player.Name) + return nil + } + } + var cmd tea.Cmd + item.Input, cmd = item.Input.Update(msg) + return cmd + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "up", "k": + if l.cursor > 0 { + l.cursor-- + } + case "down", "j": + if l.cursor < len(l.Items)-1 { + l.cursor++ + } + case "enter": + item.IsEditing = true + return item.Input.Focus() + } + } + return nil +} + +func (l *PlayerList) View(itemStyle lipgloss.Style, selectedStyle lipgloss.Style) string { + var s string + for i, item := range l.Items { + var content string + if item.IsEditing { + content = item.Input.View() + } else { + content = item.Player.Name + } + + if i == l.cursor { + s += selectedStyle.Render("> " + content) + } else { + s += itemStyle.Render(" " + content) + } + if i < len(l.Items)-1 { + s += "\n" + } + } + return s +} + +type MsgPlayerUpdated struct { + Player teams.Player +} diff --git a/internal/ui/page_locker_players.go b/internal/ui/page_locker_players.go index 5acfa85..276ced6 100644 --- a/internal/ui/page_locker_players.go +++ b/internal/ui/page_locker_players.go @@ -11,16 +11,20 @@ import ( type lockerPlayersKeyMap struct { components.CommonKeys - Back key.Binding + Back key.Binding + Enter key.Binding + Up key.Binding + Down key.Binding } func (k lockerPlayersKeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Back, k.Quit} + return []key.Binding{k.Enter, k.Back, k.Quit} } func (k lockerPlayersKeyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ - {k.Back, k.Quit}, + {k.Enter, k.Back, k.Quit}, + {k.Up, k.Down}, } } @@ -31,6 +35,18 @@ func newLockerPlayersKeyMap() lockerPlayersKeyMap { key.WithKeys("esc"), key.WithHelp("esc", "back to locker room"), ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "edit/save"), + ), + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), } } @@ -42,6 +58,7 @@ type ModelLockerPlayers struct { teamsSvc *teams.Service keys lockerPlayersKeyMap footer components.Footer + playerList components.PlayerList } func NewModelLockerPlayers(state *GlobalState, teamsSvc *teams.Service) *ModelLockerPlayers { @@ -60,7 +77,19 @@ func (m *ModelLockerPlayers) Init() tea.Cmd { } func (m *ModelLockerPlayers) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + switch msg := msg.(type) { + case MsgStateUpdated: + if m.globalState.Team != nil { + m.playerList = components.NewPlayerList(m.globalState.Team.Players) + } + case MsgSwitchPage: + if msg.NewPage == PageLockerPlayers && m.globalState.Team != nil { + m.playerList = components.NewPlayerList(m.globalState.Team.Players) + } + case components.MsgPlayerUpdated: + cmds = append(cmds, m.handlePlayerUpdated(msg)) case tea.KeyMsg: switch { case key.Matches(msg, m.keys.Quit): @@ -76,18 +105,46 @@ func (m *ModelLockerPlayers) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.footer.Update(msg) } - return m, nil + cmd := m.playerList.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m *ModelLockerPlayers) handlePlayerUpdated(msg components.MsgPlayerUpdated) tea.Cmd { + return func() tea.Msg { + err := m.teamsSvc.UpdatePlayer(m.globalState.Context(), msg.Player.ID, msg.Player.Name) + if err != nil { + return err // Should probably handle this better + } + + // Update global state + if m.globalState.Team != nil { + for i, p := range m.globalState.Team.Players { + if p.ID == msg.Player.ID { + m.globalState.Team.Players[i] = msg.Player + break + } + } + } + return nil + } } func (m *ModelLockerPlayers) View() tea.View { view := tea.NewView("") view.AltScreen = true + playersView := "No players found" + if len(m.playerList.Items) > 0 { + playersView = m.playerList.View(lipgloss.NewStyle(), m.theme.ListSelected) + } + content := lipgloss.JoinVertical( lipgloss.Center, m.theme.Logo.Render("PLAYERS"), "", - "Locker Room Players", + playersView, "", m.footer.View(m.theme.Footer), ) From 294c3c312c5f5521f1b7215f9c2865c0813e443c Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 19 Jun 2026 21:43:31 +1000 Subject: [PATCH 32/98] adding avatar --- internal/ui/components/coach_avatar.go | 34 ++++++++++++++++ internal/ui/components/coach_avatar_test.go | 43 +++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 internal/ui/components/coach_avatar.go create mode 100644 internal/ui/components/coach_avatar_test.go diff --git a/internal/ui/components/coach_avatar.go b/internal/ui/components/coach_avatar.go new file mode 100644 index 0000000..5820584 --- /dev/null +++ b/internal/ui/components/coach_avatar.go @@ -0,0 +1,34 @@ +package components + +import ( + "fmt" + + "charm.land/lipgloss/v2" + "github.com/code-gorilla-au/rush/internal/teams" +) + +// CoachAvatar component displays team name and coach name. +type CoachAvatar struct { + Coach *teams.Coach + Team *teams.Team +} + +// NewCoachAvatar creates a new CoachAvatar component. +func NewCoachAvatar(coach *teams.Coach, team *teams.Team) CoachAvatar { + return CoachAvatar{ + Coach: coach, + Team: team, + } +} + +// View renders the CoachAvatar component. +func (c CoachAvatar) View(teamStyle lipgloss.Style, coachStyle lipgloss.Style) string { + if c.Coach == nil || c.Team == nil { + return "" + } + + teamName := teamStyle.Render(c.Team.Name) + coachName := coachStyle.Render(fmt.Sprintf("Coach: %s", c.Coach.Name)) + + return lipgloss.JoinVertical(lipgloss.Left, teamName, coachName) +} diff --git a/internal/ui/components/coach_avatar_test.go b/internal/ui/components/coach_avatar_test.go new file mode 100644 index 0000000..7d6af4e --- /dev/null +++ b/internal/ui/components/coach_avatar_test.go @@ -0,0 +1,43 @@ +package components + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/teams" +) + +func TestCoachAvatar(t *testing.T) { + group := odize.NewGroup(t, nil) + + coach := &teams.Coach{Name: "Ted Lasso"} + team := &teams.Team{Name: "AFC Richmond"} + teamStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#0000FF")) + coachStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + + err := group. + Test("NewCoachAvatar should initialize with given coach and team", func(t *testing.T) { + avatar := NewCoachAvatar(coach, team) + odize.AssertEqual(t, coach, avatar.Coach) + odize.AssertEqual(t, team, avatar.Team) + }). + Test("View should render team and coach names when both are present", func(t *testing.T) { + avatar := NewCoachAvatar(coach, team) + rendered := avatar.View(teamStyle, coachStyle) + + odize.AssertTrue(t, strings.Contains(rendered, "AFC Richmond")) + odize.AssertTrue(t, strings.Contains(rendered, "Coach: Ted Lasso")) + }). + Test("View should return empty string if coach or team is nil", func(t *testing.T) { + avatarNoCoach := NewCoachAvatar(nil, team) + odize.AssertEqual(t, "", avatarNoCoach.View(teamStyle, coachStyle)) + + avatarNoTeam := NewCoachAvatar(coach, nil) + odize.AssertEqual(t, "", avatarNoTeam.View(teamStyle, coachStyle)) + }). + Run() + + odize.AssertNoError(t, err) +} From 04f889652dce7151efed6351304cf3e274377df9 Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 19 Jun 2026 21:43:41 +1000 Subject: [PATCH 33/98] theme --- internal/ui/theme.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/ui/theme.go b/internal/ui/theme.go index e5bb6d5..1dfbd67 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -10,6 +10,8 @@ type IceTheme struct { Button lipgloss.Style Hotkey lipgloss.Style ListSelected lipgloss.Style + CoachTeam lipgloss.Style + CoachName lipgloss.Style } // NewIceTheme returns a new IceTheme with the "ice" palette. @@ -42,5 +44,12 @@ func NewIceTheme() IceTheme { ListSelected: lipgloss.NewStyle(). Foreground(iceBlue). Bold(true), + CoachTeam: lipgloss.NewStyle(). + Foreground(iceBlue). + Bold(true), + CoachName: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#555555")). + Italic(true). + Faint(true), } } From 677204b3d97b1197dd86a30780703275ba5bd132 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 20 Jun 2026 21:02:26 +1000 Subject: [PATCH 34/98] adding local migrator --- Taskfile.yml | 12 +++++++- cmd/local/config.go | 13 +++++++++ cmd/local/main.go | 39 ++++++++++++++++++++++++++ internal/database/queries/playbook.sql | 17 +++++++++++ internal/database/schema/playbook.sql | 9 ++++++ internal/database/schema/team.sql | 1 + 6 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 cmd/local/config.go create mode 100644 cmd/local/main.go create mode 100644 internal/database/queries/playbook.sql create mode 100644 internal/database/schema/playbook.sql diff --git a/Taskfile.yml b/Taskfile.yml index ce138e3..7e06f33 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -73,4 +73,14 @@ tasks: desc: Run go coverage html. deps: [gen-coverage] cmds: - - go tool cover -html={{ .GOLANG_COVERAGE }} \ No newline at end of file + - go tool cover -html={{ .GOLANG_COVERAGE }} + + db-clean: + desc: Run db clean + cmds: + - rm -f ./db/rush.db + db-reset: + desc: Run db clean and migrate + deps: [db-clean] + cmds: + - go run ./cmd/local/... \ No newline at end of file diff --git a/cmd/local/config.go b/cmd/local/config.go new file mode 100644 index 0000000..350d0eb --- /dev/null +++ b/cmd/local/config.go @@ -0,0 +1,13 @@ +package main + +import "github.com/code-gorilla-au/env" + +type Config struct { + DatabaseUrl string +} + +func NewConfig() *Config { + return &Config{ + DatabaseUrl: env.GetAsString("DATABASE_URL"), + } +} diff --git a/cmd/local/main.go b/cmd/local/main.go new file mode 100644 index 0000000..fd258dc --- /dev/null +++ b/cmd/local/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "context" + "log/slog" + "os" + + "github.com/code-gorilla-au/env" + "github.com/code-gorilla-au/rush/internal/database" +) + +func main() { + ctx := context.Background() + env.LoadEnvFile(".env.local") + config := NewConfig() + + slog.Info("Starting local migration") + + db, err := database.NewSqLiteProvider(config.DatabaseUrl) + if err != nil { + slog.Error("Failed to create database provider", "error", err) + os.Exit(1) + } + + defer func() { + slog.Info("Closing local database") + dbErr := db.Close() + if dbErr != nil { + slog.Error("Failed to close database", "error", err) + } + }() + + migrator := database.NewMigrator(db, database.SchemaFS) + if mErr := migrator.Migrate(ctx); mErr != nil { + slog.Error("Failed to migrate database", "error", mErr) + os.Exit(1) + } + slog.Info("Migrate complete") +} diff --git a/internal/database/queries/playbook.sql b/internal/database/queries/playbook.sql new file mode 100644 index 0000000..958daa9 --- /dev/null +++ b/internal/database/queries/playbook.sql @@ -0,0 +1,17 @@ +-- name: CreatePlaybook :one +insert into playbooks (name, + description, + formations) +VALUES (?, + ?, + ?) +RETURNING *; + +-- name: UpdatePlaybookFormations :one +update playbooks +set formations = ? +where id = ? +returning *; + +-- name: GetPlaybooksByTeam :many +select * from playbooks where team_id = ?; diff --git a/internal/database/schema/playbook.sql b/internal/database/schema/playbook.sql new file mode 100644 index 0000000..e80d39c --- /dev/null +++ b/internal/database/schema/playbook.sql @@ -0,0 +1,9 @@ +create table if not exists playbooks ( + id serial primary key, + name varchar(255) not null, + team_id integer references teams(id), + description text, + formations string not null, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/internal/database/schema/team.sql b/internal/database/schema/team.sql index b6dbdc3..bb97104 100644 --- a/internal/database/schema/team.sql +++ b/internal/database/schema/team.sql @@ -2,6 +2,7 @@ create table if not exists coaches ( id integer primary key autoincrement, name varchar(255) not null, is_default BOOLEAN DEFAULT 0, + is_human BOOLEAN DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); From fc1e2e3433ea9dcd455fc29e03c17206845005d1 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 20 Jun 2026 21:30:26 +1000 Subject: [PATCH 35/98] adding playbook service --- internal/database/models.gen.go | 11 +++ internal/database/playbook.sql.gen.go | 113 +++++++++++++++++++++++++ internal/database/queries/playbook.sql | 3 + internal/database/schema/playbook.sql | 2 +- internal/database/team.sql.gen.go | 9 +- internal/playbooks/interfaces.go | 15 ++++ internal/playbooks/service.go | 58 +++++++++++++ internal/playbooks/transforms.go | 45 ++++++++++ internal/playbooks/types.go | 21 +++++ 9 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 internal/database/playbook.sql.gen.go create mode 100644 internal/playbooks/interfaces.go create mode 100644 internal/playbooks/service.go create mode 100644 internal/playbooks/transforms.go create mode 100644 internal/playbooks/types.go diff --git a/internal/database/models.gen.go b/internal/database/models.gen.go index e0ab7aa..d48bf9d 100644 --- a/internal/database/models.gen.go +++ b/internal/database/models.gen.go @@ -12,10 +12,21 @@ type Coach struct { ID int64 Name string IsDefault sql.NullBool + IsHuman sql.NullBool CreatedAt sql.NullTime UpdatedAt sql.NullTime } +type Playbook struct { + ID int64 + Name string + TeamID sql.NullInt64 + Description sql.NullString + Formations interface{} + CreatedAt sql.NullTime + UpdatedAt sql.NullTime +} + type Player struct { ID int64 Name string diff --git a/internal/database/playbook.sql.gen.go b/internal/database/playbook.sql.gen.go new file mode 100644 index 0000000..d8aff0e --- /dev/null +++ b/internal/database/playbook.sql.gen.go @@ -0,0 +1,113 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: playbook.sql + +package database + +import ( + "context" + "database/sql" +) + +const createPlaybook = `-- name: CreatePlaybook :one +insert into playbooks (name, + description, + formations) +VALUES (?, + ?, + ?) +RETURNING id, name, team_id, description, formations, created_at, updated_at +` + +type CreatePlaybookParams struct { + Name string + Description sql.NullString + Formations interface{} +} + +func (q *Queries) CreatePlaybook(ctx context.Context, arg CreatePlaybookParams) (Playbook, error) { + row := q.db.QueryRowContext(ctx, createPlaybook, arg.Name, arg.Description, arg.Formations) + var i Playbook + err := row.Scan( + &i.ID, + &i.Name, + &i.TeamID, + &i.Description, + &i.Formations, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deletePlaybook = `-- name: DeletePlaybook :exec +delete from playbooks where id = ? +` + +func (q *Queries) DeletePlaybook(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deletePlaybook, id) + return err +} + +const getPlaybooksByTeam = `-- name: GetPlaybooksByTeam :many +select id, name, team_id, description, formations, created_at, updated_at from playbooks where team_id = ? +` + +func (q *Queries) GetPlaybooksByTeam(ctx context.Context, teamID sql.NullInt64) ([]Playbook, error) { + rows, err := q.db.QueryContext(ctx, getPlaybooksByTeam, teamID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Playbook + for rows.Next() { + var i Playbook + if err := rows.Scan( + &i.ID, + &i.Name, + &i.TeamID, + &i.Description, + &i.Formations, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updatePlaybookFormations = `-- name: UpdatePlaybookFormations :one +update playbooks +set formations = ? +where id = ? +returning id, name, team_id, description, formations, created_at, updated_at +` + +type UpdatePlaybookFormationsParams struct { + Formations interface{} + ID int64 +} + +func (q *Queries) UpdatePlaybookFormations(ctx context.Context, arg UpdatePlaybookFormationsParams) (Playbook, error) { + row := q.db.QueryRowContext(ctx, updatePlaybookFormations, arg.Formations, arg.ID) + var i Playbook + err := row.Scan( + &i.ID, + &i.Name, + &i.TeamID, + &i.Description, + &i.Formations, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/database/queries/playbook.sql b/internal/database/queries/playbook.sql index 958daa9..879b754 100644 --- a/internal/database/queries/playbook.sql +++ b/internal/database/queries/playbook.sql @@ -13,5 +13,8 @@ set formations = ? where id = ? returning *; +-- name: DeletePlaybook :exec +delete from playbooks where id = ?; + -- name: GetPlaybooksByTeam :many select * from playbooks where team_id = ?; diff --git a/internal/database/schema/playbook.sql b/internal/database/schema/playbook.sql index e80d39c..42a031f 100644 --- a/internal/database/schema/playbook.sql +++ b/internal/database/schema/playbook.sql @@ -1,5 +1,5 @@ create table if not exists playbooks ( - id serial primary key, + id integer primary key autoincrement, name varchar(255) not null, team_id integer references teams(id), description text, diff --git a/internal/database/team.sql.gen.go b/internal/database/team.sql.gen.go index 511035c..a07934b 100644 --- a/internal/database/team.sql.gen.go +++ b/internal/database/team.sql.gen.go @@ -29,7 +29,7 @@ func (q *Queries) ClearDefaultTeam(ctx context.Context) error { } const createCoach = `-- name: CreateCoach :one -INSERT INTO coaches (name, is_default) VALUES (?, ?) RETURNING id, name, is_default, created_at, updated_at +INSERT INTO coaches (name, is_default) VALUES (?, ?) RETURNING id, name, is_default, is_human, created_at, updated_at ` type CreateCoachParams struct { @@ -44,6 +44,7 @@ func (q *Queries) CreateCoach(ctx context.Context, arg CreateCoachParams) (Coach &i.ID, &i.Name, &i.IsDefault, + &i.IsHuman, &i.CreatedAt, &i.UpdatedAt, ) @@ -133,7 +134,7 @@ func (q *Queries) GetCoach(ctx context.Context, id int64) (Team, error) { } const getCoaches = `-- name: GetCoaches :many -SELECT id, name, is_default, created_at, updated_at FROM coaches +SELECT id, name, is_default, is_human, created_at, updated_at FROM coaches ` func (q *Queries) GetCoaches(ctx context.Context) ([]Coach, error) { @@ -149,6 +150,7 @@ func (q *Queries) GetCoaches(ctx context.Context) ([]Coach, error) { &i.ID, &i.Name, &i.IsDefault, + &i.IsHuman, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -166,7 +168,7 @@ func (q *Queries) GetCoaches(ctx context.Context) ([]Coach, error) { } const getDefaultCoach = `-- name: GetDefaultCoach :one -SELECT id, name, is_default, created_at, updated_at FROM coaches WHERE is_default = true LIMIT 1 +SELECT id, name, is_default, is_human, created_at, updated_at FROM coaches WHERE is_default = true LIMIT 1 ` func (q *Queries) GetDefaultCoach(ctx context.Context) (Coach, error) { @@ -176,6 +178,7 @@ func (q *Queries) GetDefaultCoach(ctx context.Context) (Coach, error) { &i.ID, &i.Name, &i.IsDefault, + &i.IsHuman, &i.CreatedAt, &i.UpdatedAt, ) diff --git a/internal/playbooks/interfaces.go b/internal/playbooks/interfaces.go new file mode 100644 index 0000000..4032cdb --- /dev/null +++ b/internal/playbooks/interfaces.go @@ -0,0 +1,15 @@ +package playbooks + +import ( + "context" + "database/sql" + + "github.com/code-gorilla-au/rush/internal/database" +) + +type Store interface { + CreatePlaybook(ctx context.Context, arg database.CreatePlaybookParams) (database.Playbook, error) + DeletePlaybook(ctx context.Context, id interface{}) error + GetPlaybooksByTeam(ctx context.Context, teamID sql.NullInt64) ([]database.Playbook, error) + UpdatePlaybookFormations(ctx context.Context, arg database.UpdatePlaybookFormationsParams) (database.Playbook, error) +} diff --git a/internal/playbooks/service.go b/internal/playbooks/service.go new file mode 100644 index 0000000..a5d66b2 --- /dev/null +++ b/internal/playbooks/service.go @@ -0,0 +1,58 @@ +package playbooks + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + + "github.com/code-gorilla-au/rush/internal/database" +) + +type Service struct { + store Store +} + +func NewPlaybooksService(store Store) *Service { + return &Service{store: store} +} + +type PlaybookParams struct { + Name string + Description string + Formations []Formation +} + +func (s *Service) CreatePlaybook(ctx context.Context, params PlaybookParams) (Playbook, error) { + + data, err := json.Marshal(params.Formations) + if err != nil { + return Playbook{}, fmt.Errorf("failed to marshal formations: %w", err) + } + + model, err := s.store.CreatePlaybook(ctx, database.CreatePlaybookParams{ + Name: params.Name, + Description: sql.NullString{ + String: params.Description, + Valid: true, + }, + Formations: data, + }) + if err != nil { + return Playbook{}, err + } + + return fromPlaybookModel(model) +} + +func (s *Service) GetTeamPlaybooks(ctx context.Context, teamID int64) ([]Playbook, error) { + models, err := s.store.GetPlaybooksByTeam(ctx, sql.NullInt64{ + Valid: true, + Int64: teamID, + }) + if err != nil { + return nil, err + } + + return fromPlaybookModels(models) +} diff --git a/internal/playbooks/transforms.go b/internal/playbooks/transforms.go new file mode 100644 index 0000000..0d45b26 --- /dev/null +++ b/internal/playbooks/transforms.go @@ -0,0 +1,45 @@ +package playbooks + +import ( + "encoding/json" + "fmt" + + "github.com/code-gorilla-au/rush/internal/database" +) + +func fromFormationJSON(j interface{}) ([]Formation, error) { + var f []Formation + if err := json.Unmarshal(j.([]byte), &f); err != nil { + return []Formation{}, fmt.Errorf("failed to unmarshal formation: %w", err) + } + + return f, nil +} + +func fromPlaybookModel(model database.Playbook) (Playbook, error) { + formations, err := fromFormationJSON(model.Formations) + if err != nil { + return Playbook{}, fmt.Errorf("failed to unmarshal formations: %w", err) + } + + return Playbook{ + ID: model.ID, + Name: model.Name, + Description: model.Description.String, + Formations: formations, + }, nil +} + +func fromPlaybookModels(models []database.Playbook) ([]Playbook, error) { + playbooks := make([]Playbook, len(models)) + + for i, model := range models { + playbook, err := fromPlaybookModel(model) + if err != nil { + return nil, err + } + playbooks[i] = playbook + } + + return playbooks, nil +} diff --git a/internal/playbooks/types.go b/internal/playbooks/types.go new file mode 100644 index 0000000..87fb20d --- /dev/null +++ b/internal/playbooks/types.go @@ -0,0 +1,21 @@ +package playbooks + +import "time" + +type Playbook struct { + ID int64 + Name string + TeamID int64 + Description string + Formations []Formation + CreatedAt time.Time + UpdatedAt time.Time +} + +type Formation struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Lane1 int `json:"lane_1,omitempty"` + Lane2 int `json:"lane_2,omitempty"` + Lane3 int `json:"lane_3,omitempty"` +} From 3074def2db066ac3a077648ddb20fec6a8edf18e Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 20 Jun 2026 21:33:52 +1000 Subject: [PATCH 36/98] adding playbook service --- .junie/guidelines.md | 2 +- internal/database/playbook.sql.gen.go | 12 +++- internal/database/queries/playbook.sql | 4 +- internal/playbooks/interfaces.go | 2 +- internal/playbooks/service.go | 5 ++ internal/playbooks/service_test.go | 94 ++++++++++++++++++++++++++ internal/teams/service_test.go | 52 +++++++++++--- 7 files changed, 156 insertions(+), 15 deletions(-) create mode 100644 internal/playbooks/service_test.go diff --git a/.junie/guidelines.md b/.junie/guidelines.md index 469f069..159050b 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -93,7 +93,7 @@ review `taskfile.yaml` for list of commands available in repo. - **Mock external interfaces** cleanly using generated ([Moq](https://github.com/matryer/moq)) or handwritten mocks. - Separate **fast unit tests** from slower integration and E2E tests. - Ensure **test coverage** for every exported function, with behavioural checks. -- Test command with coverage is: `task go-cover`. +- Test command with coverage is: `task cover`. #### Example odize framework diff --git a/internal/database/playbook.sql.gen.go b/internal/database/playbook.sql.gen.go index d8aff0e..f8126d0 100644 --- a/internal/database/playbook.sql.gen.go +++ b/internal/database/playbook.sql.gen.go @@ -13,8 +13,10 @@ import ( const createPlaybook = `-- name: CreatePlaybook :one insert into playbooks (name, description, - formations) + formations, + team_id) VALUES (?, + ?, ?, ?) RETURNING id, name, team_id, description, formations, created_at, updated_at @@ -24,10 +26,16 @@ type CreatePlaybookParams struct { Name string Description sql.NullString Formations interface{} + TeamID sql.NullInt64 } func (q *Queries) CreatePlaybook(ctx context.Context, arg CreatePlaybookParams) (Playbook, error) { - row := q.db.QueryRowContext(ctx, createPlaybook, arg.Name, arg.Description, arg.Formations) + row := q.db.QueryRowContext(ctx, createPlaybook, + arg.Name, + arg.Description, + arg.Formations, + arg.TeamID, + ) var i Playbook err := row.Scan( &i.ID, diff --git a/internal/database/queries/playbook.sql b/internal/database/queries/playbook.sql index 879b754..5ce8164 100644 --- a/internal/database/queries/playbook.sql +++ b/internal/database/queries/playbook.sql @@ -1,8 +1,10 @@ -- name: CreatePlaybook :one insert into playbooks (name, description, - formations) + formations, + team_id) VALUES (?, + ?, ?, ?) RETURNING *; diff --git a/internal/playbooks/interfaces.go b/internal/playbooks/interfaces.go index 4032cdb..862b3c2 100644 --- a/internal/playbooks/interfaces.go +++ b/internal/playbooks/interfaces.go @@ -9,7 +9,7 @@ import ( type Store interface { CreatePlaybook(ctx context.Context, arg database.CreatePlaybookParams) (database.Playbook, error) - DeletePlaybook(ctx context.Context, id interface{}) error + DeletePlaybook(ctx context.Context, id int64) error GetPlaybooksByTeam(ctx context.Context, teamID sql.NullInt64) ([]database.Playbook, error) UpdatePlaybookFormations(ctx context.Context, arg database.UpdatePlaybookFormationsParams) (database.Playbook, error) } diff --git a/internal/playbooks/service.go b/internal/playbooks/service.go index a5d66b2..4c62c92 100644 --- a/internal/playbooks/service.go +++ b/internal/playbooks/service.go @@ -18,6 +18,7 @@ func NewPlaybooksService(store Store) *Service { } type PlaybookParams struct { + TeamID int64 Name string Description string Formations []Formation @@ -37,6 +38,10 @@ func (s *Service) CreatePlaybook(ctx context.Context, params PlaybookParams) (Pl Valid: true, }, Formations: data, + TeamID: sql.NullInt64{ + Int64: params.TeamID, + Valid: true, + }, }) if err != nil { return Playbook{}, err diff --git a/internal/playbooks/service_test.go b/internal/playbooks/service_test.go new file mode 100644 index 0000000..d6817b6 --- /dev/null +++ b/internal/playbooks/service_test.go @@ -0,0 +1,94 @@ +package playbooks + +import ( + "database/sql" + "testing" + + "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/database" + _ "modernc.org/sqlite" +) + +func setupTestDB(t *testing.T) *sql.DB { + db, err := sql.Open("sqlite", ":memory:") + odize.AssertNoError(t, err) + + migrator := database.NewMigrator(db, database.SchemaFS) + err = migrator.Migrate(t.Context()) + odize.AssertNoError(t, err) + + return db +} + +func TestService(t *testing.T) { + group := odize.NewGroup(t, nil) + + var db *sql.DB + var queries *database.Queries + var s *Service + + group.BeforeEach(func() { + db = setupTestDB(t) + queries = database.New(db) + s = NewPlaybooksService(queries) + }) + + group.AfterEach(func() { + if db != nil { + db.Close() + } + }) + + err := group. + Test("CreatePlaybook should create a playbook and return it", func(t *testing.T) { + ctx := t.Context() + params := PlaybookParams{ + TeamID: 1, + Name: "Test Playbook", + Description: "Test Description", + Formations: []Formation{ + { + Name: "Formation 1", + Lane1: 1, + Lane2: 2, + Lane3: 3, + }, + }, + } + + pb, err := s.CreatePlaybook(ctx, params) + odize.AssertNoError(t, err) + odize.AssertTrue(t, pb.ID > 0) + odize.AssertEqual(t, params.Name, pb.Name) + odize.AssertEqual(t, params.Description, pb.Description) + odize.AssertEqual(t, 1, len(pb.Formations)) + odize.AssertEqual(t, "Formation 1", pb.Formations[0].Name) + }). + Test("GetTeamPlaybooks should return playbooks for a team", func(t *testing.T) { + ctx := t.Context() + + // Create a team first (using queries directly to avoid dependency on teams package) + res, err := db.ExecContext(ctx, "INSERT INTO teams (name, is_default) VALUES (?, ?)", "Team 1", true) + odize.AssertNoError(t, err) + teamID, err := res.LastInsertId() + odize.AssertNoError(t, err) + + // Create a playbook for this team + params := PlaybookParams{ + TeamID: teamID, + Name: "Team Playbook", + Formations: []Formation{{Name: "F1"}}, + } + + _, err = s.CreatePlaybook(ctx, params) + odize.AssertNoError(t, err) + + playbooks, err := s.GetTeamPlaybooks(ctx, teamID) + odize.AssertNoError(t, err) + odize.AssertEqual(t, 1, len(playbooks)) + odize.AssertEqual(t, "Team Playbook", playbooks[0].Name) + }). + Run() + + odize.AssertNoError(t, err) +} diff --git a/internal/teams/service_test.go b/internal/teams/service_test.go index ad19468..eac2c29 100644 --- a/internal/teams/service_test.go +++ b/internal/teams/service_test.go @@ -2,6 +2,7 @@ package teams import ( "database/sql" + "errors" "testing" "github.com/code-gorilla-au/odize" @@ -128,18 +129,20 @@ func TestService(t *testing.T) { odize.AssertNoError(t, err) odize.AssertFalse(t, isDefault) }). - Test("GetCoach should return error if no default coach", func(t *testing.T) { - _, err := s.GetDefaultCoach(t.Context()) - odize.AssertError(t, err) - - }). - Test("GetTeam should return error if no default coach", func(t *testing.T) { - _, err := s.CreateCoach(t.Context(), "Coach Carter", true) + Test("GetDefaultCoach should return the default coach", func(t *testing.T) { + ctx := t.Context() + name := "Coach Carter" + _, err := s.CreateCoach(ctx, name, true) odize.AssertNoError(t, err) - coach, sErr := s.GetDefaultCoach(t.Context()) - odize.AssertNoError(t, sErr) - odize.AssertTrue(t, coach.ID > 0) + coach, err := s.GetDefaultCoach(ctx) + odize.AssertNoError(t, err) + odize.AssertEqual(t, name, coach.Name) + }). + Test("GetDefaultCoach should return error if no default coach", func(t *testing.T) { + _, err := s.GetDefaultCoach(t.Context()) + odize.AssertError(t, err) + odize.AssertTrue(t, errors.Is(err, ErrCoachNotFound)) }). Test("UpdatePlayer should update player name", func(t *testing.T) { ctx := t.Context() @@ -170,6 +173,35 @@ func TestService(t *testing.T) { } odize.AssertTrue(t, found) }). + Test("GetTeamByCoachID should return team and players", func(t *testing.T) { + ctx := t.Context() + coach, err := s.CreateCoach(ctx, "Coach", true) + odize.AssertNoError(t, err) + + _, err = s.CreateTeam(ctx, "The Bulls", coach.ID, true) + odize.AssertNoError(t, err) + + team, err := s.GetTeamByCoachID(ctx, coach.ID) + odize.AssertNoError(t, err) + odize.AssertEqual(t, "The Bulls", team.Name) + odize.AssertEqual(t, 5, len(team.Players)) + }). + Test("GetTeamByCoachID should return error if team not found", func(t *testing.T) { + _, err := s.GetTeamByCoachID(t.Context(), 999) + odize.AssertError(t, err) + odize.AssertTrue(t, errors.Is(err, ErrTeamNotFound)) + }). + Test("CreateTeam should create a team with default players", func(t *testing.T) { + ctx := t.Context() + coach, err := s.CreateCoach(ctx, "Coach", false) + odize.AssertNoError(t, err) + + team, err := s.CreateTeam(ctx, "Lakers", coach.ID, true) + odize.AssertNoError(t, err) + odize.AssertEqual(t, "Lakers", team.Name) + odize.AssertEqual(t, 5, len(team.Players)) + odize.AssertEqual(t, "Player 1", team.Players[0].Name) + }). Run() odize.AssertNoError(t, err) From 70ac656d63f2b69e29e3f579b24f4aa6b4dc5be2 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 20 Jun 2026 21:37:53 +1000 Subject: [PATCH 37/98] adding methods --- internal/playbooks/service.go | 21 ++++++++++++ internal/playbooks/service_test.go | 52 ++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/internal/playbooks/service.go b/internal/playbooks/service.go index 4c62c92..73a6e35 100644 --- a/internal/playbooks/service.go +++ b/internal/playbooks/service.go @@ -61,3 +61,24 @@ func (s *Service) GetTeamPlaybooks(ctx context.Context, teamID int64) ([]Playboo return fromPlaybookModels(models) } + +func (s *Service) DeletePlaybook(ctx context.Context, id int64) error { + return s.store.DeletePlaybook(ctx, id) +} + +func (s *Service) UpdatePlaybookFormations(ctx context.Context, id int64, formations []Formation) (Playbook, error) { + data, err := json.Marshal(formations) + if err != nil { + return Playbook{}, fmt.Errorf("failed to marshal formations: %w", err) + } + + model, err := s.store.UpdatePlaybookFormations(ctx, database.UpdatePlaybookFormationsParams{ + ID: id, + Formations: data, + }) + if err != nil { + return Playbook{}, err + } + + return fromPlaybookModel(model) +} diff --git a/internal/playbooks/service_test.go b/internal/playbooks/service_test.go index d6817b6..a2834c4 100644 --- a/internal/playbooks/service_test.go +++ b/internal/playbooks/service_test.go @@ -88,6 +88,58 @@ func TestService(t *testing.T) { odize.AssertEqual(t, 1, len(playbooks)) odize.AssertEqual(t, "Team Playbook", playbooks[0].Name) }). + Test("DeletePlaybook should delete a playbook", func(t *testing.T) { + ctx := t.Context() + params := PlaybookParams{ + TeamID: 1, + Name: "To Be Deleted", + Formations: []Formation{{Name: "F1"}}, + } + + pb, err := s.CreatePlaybook(ctx, params) + odize.AssertNoError(t, err) + + err = s.DeletePlaybook(ctx, pb.ID) + odize.AssertNoError(t, err) + + // Verify it is deleted + playbooks, err := s.GetTeamPlaybooks(ctx, 1) + odize.AssertNoError(t, err) + + found := false + for _, p := range playbooks { + if p.ID == pb.ID { + found = true + break + } + } + odize.AssertFalse(t, found) + }). + Test("UpdatePlaybookFormations should update formations", func(t *testing.T) { + ctx := t.Context() + params := PlaybookParams{ + TeamID: 1, + Name: "To Be Updated", + Formations: []Formation{{Name: "F1"}}, + } + + pb, err := s.CreatePlaybook(ctx, params) + odize.AssertNoError(t, err) + + newFormations := []Formation{ + { + Name: "New Formation", + Lane1: 5, + }, + } + + updatedPb, err := s.UpdatePlaybookFormations(ctx, pb.ID, newFormations) + odize.AssertNoError(t, err) + odize.AssertEqual(t, pb.ID, updatedPb.ID) + odize.AssertEqual(t, 1, len(updatedPb.Formations)) + odize.AssertEqual(t, "New Formation", updatedPb.Formations[0].Name) + odize.AssertEqual(t, 5, updatedPb.Formations[0].Lane1) + }). Run() odize.AssertNoError(t, err) From da56d621bcfccee8d77ed8b9b09679b26a426419 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 20 Jun 2026 22:05:11 +1000 Subject: [PATCH 38/98] documenting formations --- docs/formations.md | 157 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 docs/formations.md diff --git a/docs/formations.md b/docs/formations.md new file mode 100644 index 0000000..6f287c1 --- /dev/null +++ b/docs/formations.md @@ -0,0 +1,157 @@ +# Formations + +Formations for a team of 5. + +## Combinations + +### Balanced + +#### Balanced Right + +name: balanced-right +description: each lane has at least one player, with centre and right having two players in each lane. + +```markdown +|---|---|---| +| x | x | x | +| | x | x | +| | | | + +``` + +#### Balanced Left + +name: balanced-left +description: each lane has at least one player, with centre and left having two players in each lane. + +```markdown +|---|---|---| +| x | x | x | +| x | x | | +| | | | + +``` + +### Strong + +#### Strong Right + +name: strong-right +Description: A strong formation with more players on the right side. + +```markdown +|---|---|---| +| x | x | x | +| | | x | +| | | x | + +``` + +#### Strong Left + +name: strong-left +Description: A strong formation with more players on the left side. + +```markdown +|---|---|---| +| x | x | x | +| x | | | +| x | | | + +``` + +#### Strong centre + +name: strong-centre +Description: Concentrated strength in the middle lane. + +```markdown +|---|---|---| +| x | x | x | +| | x | | +| | x | | + +``` + +### Split + +#### Split Balanced + +name: split-balanced +Description: Players are split to stack team members on the outside lanes. + +```markdown +|---|---|---| +| x | x | x | +| x | | x | +| | | | + +``` + + +### Overload + +#### Overload Right + +name: overload-right +Description: Maximum pressure on the right flank, leaving the left open. + +```markdown +|---|---|---| +| | x | x | +| | x | x | +| | | x | + +``` + +#### Overload Left + +name: overload-left +Description: Maximum pressure on the left flank, leaving the right open. + +```markdown +|---|---|---| +| x | x | | +| x | x | | +| x | | | + +``` + +#### Overload Center Left + +name: overload-centre-left +Description: Heavy presence in the centre and left, leaving the right open. + +```markdown +|---|---|---| +| x | x | | +| x | x | | +| | x | | + +``` + +#### Overload Center Right + +name: overload-centre-right +Description: Heavy presence in the centre and right, leaving the left open. + +```markdown +|---|---|---| +| | x | x | +| | x | x | +| | x | | + +``` + +#### Overload Wide + +name: overload-wide +Description: Maximum pressure on the outside lanes, leaving the centre open. + +```markdown +|---|---|---| +| x | | x | +| x | | x | +| x | | | + +``` \ No newline at end of file From 4b47c472c22493f6720e51ac9b1acf39ca64d4d0 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 20 Jun 2026 22:08:36 +1000 Subject: [PATCH 39/98] adding formations --- docs/formations.md | 39 ++++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/docs/formations.md b/docs/formations.md index 6f287c1..49a17f0 100644 --- a/docs/formations.md +++ b/docs/formations.md @@ -88,6 +88,32 @@ Description: Players are split to stack team members on the outside lanes. ``` +#### Split right + +name: split-right +Description: Players are stacked on the outside lanes, leaving the centre open, with a strong presence on the right flank. + +```markdown +|---|---|---| +| x | | x | +| x | | x | +| x | | | + +``` + +#### Split left + +name: split-left +Description: Players are stacked on the outside lanes, leaving the centre open, with a strong presence on the left flank. + +```markdown +|---|---|---| +| x | | x | +| x | | x | +| | | x | + +``` + ### Overload @@ -142,16 +168,3 @@ Description: Heavy presence in the centre and right, leaving the left open. | | x | | ``` - -#### Overload Wide - -name: overload-wide -Description: Maximum pressure on the outside lanes, leaving the centre open. - -```markdown -|---|---|---| -| x | | x | -| x | | x | -| x | | | - -``` \ No newline at end of file From 7e6642632b400b27de95fb87f6bc5cb92188626e Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 20 Jun 2026 22:11:04 +1000 Subject: [PATCH 40/98] adding formations --- docs/formations.md | 51 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/docs/formations.md b/docs/formations.md index 49a17f0..226b59e 100644 --- a/docs/formations.md +++ b/docs/formations.md @@ -97,7 +97,7 @@ Description: Players are stacked on the outside lanes, leaving the centre open, |---|---|---| | x | | x | | x | | x | -| x | | | +| | | x | ``` @@ -110,7 +110,7 @@ Description: Players are stacked on the outside lanes, leaving the centre open, |---|---|---| | x | | x | | x | | x | -| | | x | +| x | | | ``` @@ -168,3 +168,50 @@ Description: Heavy presence in the centre and right, leaving the left open. | | x | | ``` + +### Single Lane + +#### Single Lane Left + +name: single-lane-left +Description: All players are concentrated in the left lane. + +```markdown +|---|---|---| +| x | | | +| x | | | +| x | | | +| x | | | +| x | | | + +``` + +#### Single Lane Center + +name: single-lane-centre +Description: All players are concentrated in the center lane. + +```markdown +|---|---|---| +| | x | | +| | x | | +| | x | | +| | x | | +| | x | | + +``` + +#### Single Lane Right + +name: single-lane-right +Description: All players are concentrated in the right lane. + +```markdown +|---|---|---| +| | | x | +| | | x | +| | | x | +| | | x | +| | | x | + +``` From c0047f3bcc856e43e68e6babad0c4031dc6d0e51 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 20 Jun 2026 22:17:45 +1000 Subject: [PATCH 41/98] adding formations --- internal/playbooks/formations.go | 116 +++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 internal/playbooks/formations.go diff --git a/internal/playbooks/formations.go b/internal/playbooks/formations.go new file mode 100644 index 0000000..1274f13 --- /dev/null +++ b/internal/playbooks/formations.go @@ -0,0 +1,116 @@ +package playbooks + +import "slices" + +var formations = []Formation{ + { + Name: "balanced-right", + Description: "each lane has at least one player, with centre and right having two players in each lane.", + Lane1: 1, + Lane2: 2, + Lane3: 2, + }, + { + Name: "balanced-left", + Description: "each lane has at least one player, with centre and left having two players in each lane.", + Lane1: 2, + Lane2: 2, + Lane3: 1, + }, + { + Name: "strong-right", + Description: "A strong formation with more players on the right side.", + Lane1: 1, + Lane2: 1, + Lane3: 3, + }, + { + Name: "strong-left", + Description: "A strong formation with more players on the left side.", + Lane1: 3, + Lane2: 1, + Lane3: 1, + }, + { + Name: "strong-centre", + Description: "Concentrated strength in the middle lane.", + Lane1: 1, + Lane2: 3, + Lane3: 1, + }, + { + Name: "split-balanced", + Description: "Players are split to stack team members on the outside lanes.", + Lane1: 2, + Lane2: 1, + Lane3: 2, + }, + { + Name: "split-right", + Description: "Players are stacked on the outside lanes, leaving the centre open, with a strong presence on the right flank.", + Lane1: 2, + Lane2: 0, + Lane3: 3, + }, + { + Name: "split-left", + Description: "Players are stacked on the outside lanes, leaving the centre open, with a strong presence on the left flank.", + Lane1: 3, + Lane2: 0, + Lane3: 2, + }, + { + Name: "overload-right", + Description: "Maximum pressure on the right flank, leaving the left open.", + Lane1: 0, + Lane2: 2, + Lane3: 3, + }, + { + Name: "overload-left", + Description: "Maximum pressure on the left flank, leaving the right open.", + Lane1: 3, + Lane2: 2, + Lane3: 0, + }, + { + Name: "overload-centre-left", + Description: "Heavy presence in the centre and left, leaving the right open.", + Lane1: 2, + Lane2: 3, + Lane3: 0, + }, + { + Name: "overload-centre-right", + Description: "Heavy presence in the centre and right, leaving the left open.", + Lane1: 0, + Lane2: 3, + Lane3: 2, + }, + { + Name: "single-lane-left", + Description: "All players are concentrated in the left lane.", + Lane1: 5, + Lane2: 0, + Lane3: 0, + }, + { + Name: "single-lane-centre", + Description: "All players are concentrated in the center lane.", + Lane1: 0, + Lane2: 5, + Lane3: 0, + }, + { + Name: "single-lane-right", + Description: "All players are concentrated in the right lane.", + Lane1: 0, + Lane2: 0, + Lane3: 5, + }, +} + +func Formations() []Formation { + cloned := slices.Clone(formations) + return cloned +} From c436e71703f4ad0d6bf1db27655faab58821c8e4 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 20 Jun 2026 22:31:01 +1000 Subject: [PATCH 42/98] adding page --- internal/ui/app.go | 53 +++++++++++++++++----------- internal/ui/page_locker_playbooks.go | 34 ++++++++++++++++++ 2 files changed, 66 insertions(+), 21 deletions(-) create mode 100644 internal/ui/page_locker_playbooks.go diff --git a/internal/ui/app.go b/internal/ui/app.go index a513212..570519c 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -4,6 +4,7 @@ import ( "context" tea "charm.land/bubbletea/v2" + "github.com/code-gorilla-au/rush/internal/playbooks" "github.com/code-gorilla-au/rush/internal/teams" ) @@ -23,6 +24,7 @@ const ( PageCreateCoach PageLockerRoom PageLockerPlayers + PageLockerPlaybooks ) type GlobalState struct { @@ -35,33 +37,37 @@ func (m *GlobalState) Context() context.Context { } type RootModel struct { - ctx context.Context - width int - height int - theme IceTheme - currentPage Page - pageTitle tea.Model - pageCreateCoach tea.Model - pageLockerRoom tea.Model - pageLockerPlayers tea.Model - globalState *GlobalState - teamsSvc *teams.Service + ctx context.Context + width int + height int + theme IceTheme + currentPage Page + pageTitle tea.Model + pageCreateCoach tea.Model + pageLockerRoom tea.Model + pageLockerPlayers tea.Model + pageLockerPlaybooks tea.Model + globalState *GlobalState + teamsSvc *teams.Service + playbookSvc *playbooks.Service } // New returns a new UI model. -func New(teamsService *teams.Service) RootModel { +func New(teamsService *teams.Service, playbookService *playbooks.Service) RootModel { state := &GlobalState{} return RootModel{ - ctx: context.Background(), - theme: NewIceTheme(), - currentPage: PageTitle, - pageTitle: NewModelTitle(state), - pageCreateCoach: NewModelCreateCoach(state, teamsService), - pageLockerRoom: NewModelLockerRoom(state), - pageLockerPlayers: NewModelLockerPlayers(state, teamsService), - globalState: state, - teamsSvc: teamsService, + ctx: context.Background(), + theme: NewIceTheme(), + currentPage: PageTitle, + pageTitle: NewModelTitle(state), + pageCreateCoach: NewModelCreateCoach(state, teamsService), + pageLockerRoom: NewModelLockerRoom(state), + pageLockerPlayers: NewModelLockerPlayers(state, teamsService), + pageLockerPlaybooks: NewModelLockerPlaybooks(state, playbookService), + globalState: state, + teamsSvc: teamsService, + playbookSvc: playbookService, } } @@ -107,6 +113,7 @@ func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) m.pageLockerPlayers, cmd = m.pageLockerPlayers.Update(msg) cmds = append(cmds, cmd) + m.pageLockerPlaybooks, cmd = m.pageLockerPlaybooks.Update(msg) } var cmd tea.Cmd @@ -119,6 +126,8 @@ func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.pageLockerRoom, cmd = m.pageLockerRoom.Update(msg) case PageLockerPlayers: m.pageLockerPlayers, cmd = m.pageLockerPlayers.Update(msg) + case PageLockerPlaybooks: + m.pageLockerPlaybooks, cmd = m.pageLockerPlaybooks.Update(msg) } cmds = append(cmds, cmd) @@ -139,6 +148,8 @@ func (m RootModel) View() tea.View { return m.pageLockerRoom.View() case PageLockerPlayers: return m.pageLockerPlayers.View() + case PageLockerPlaybooks: + return m.pageLockerPlaybooks.View() } return tea.NewView("unknown page") diff --git a/internal/ui/page_locker_playbooks.go b/internal/ui/page_locker_playbooks.go new file mode 100644 index 0000000..f1f8967 --- /dev/null +++ b/internal/ui/page_locker_playbooks.go @@ -0,0 +1,34 @@ +package ui + +import ( + tea "charm.land/bubbletea/v2" + "github.com/code-gorilla-au/rush/internal/playbooks" +) + +type ModelLockerPlaybooks struct { + width int + height int + theme IceTheme + globalState *GlobalState + playbookSvc *playbooks.Service +} + +func NewModelLockerPlaybooks(state *GlobalState, playbookSvc *playbooks.Service) *ModelLockerPlaybooks { + return &ModelLockerPlaybooks{ + theme: NewIceTheme(), + globalState: state, + playbookSvc: playbookSvc, + } +} + +func (m *ModelLockerPlaybooks) Init() tea.Cmd { + return nil +} + +func (m *ModelLockerPlaybooks) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return m, nil +} + +func (m *ModelLockerPlaybooks) View() tea.View { + return tea.NewView("playbooks") +} From 6c98d4b70e02a90d8b5edf65d7a3087b8d861142 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 20 Jun 2026 22:38:11 +1000 Subject: [PATCH 43/98] wip --- internal/ui/components/formation_list.go | 57 +++++ internal/ui/components/playbook_list.go | 65 ++++++ internal/ui/page_locker_playbooks.go | 262 ++++++++++++++++++++++- internal/ui/page_locker_room.go | 7 +- internal/ui/page_locker_room_test.go | 21 ++ 5 files changed, 400 insertions(+), 12 deletions(-) create mode 100644 internal/ui/components/formation_list.go create mode 100644 internal/ui/components/playbook_list.go diff --git a/internal/ui/components/formation_list.go b/internal/ui/components/formation_list.go new file mode 100644 index 0000000..ff6d84a --- /dev/null +++ b/internal/ui/components/formation_list.go @@ -0,0 +1,57 @@ +package components + +import ( + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/code-gorilla-au/rush/internal/playbooks" +) + +type FormationList struct { + cursor int + items []playbooks.Formation +} + +func NewFormationList() FormationList { + return FormationList{ + items: playbooks.Formations(), + } +} + +func (l *FormationList) Update(msg tea.Msg) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "up", "k": + if l.cursor > 0 { + l.cursor-- + } + case "down", "j": + if l.cursor < len(l.items)-1 { + l.cursor++ + } + } + } +} + +func (l *FormationList) View(itemStyle lipgloss.Style, selectedStyle lipgloss.Style) string { + var s string + for i, item := range l.items { + content := item.Name + if i == l.cursor { + s += selectedStyle.Render("> " + content) + } else { + s += itemStyle.Render(" " + content) + } + if i < len(l.items)-1 { + s += "\n" + } + } + return s +} + +func (l *FormationList) SelectedItem() playbooks.Formation { + if len(l.items) == 0 { + return playbooks.Formation{} + } + return l.items[l.cursor] +} diff --git a/internal/ui/components/playbook_list.go b/internal/ui/components/playbook_list.go new file mode 100644 index 0000000..e619d80 --- /dev/null +++ b/internal/ui/components/playbook_list.go @@ -0,0 +1,65 @@ +package components + +import ( + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/code-gorilla-au/rush/internal/playbooks" +) + +type PlaybookList struct { + cursor int + items []playbooks.Playbook +} + +func NewPlaybookList(items []playbooks.Playbook) PlaybookList { + return PlaybookList{ + items: items, + } +} + +func (l *PlaybookList) Update(msg tea.Msg) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "up", "k": + if l.cursor > 0 { + l.cursor-- + } + case "down", "j": + if l.cursor < len(l.items)-1 { + l.cursor++ + } + } + } +} + +func (l *PlaybookList) View(itemStyle lipgloss.Style, selectedStyle lipgloss.Style) string { + if len(l.items) == 0 { + return "No playbooks found." + } + + var s string + for i, item := range l.items { + content := item.Name + if i == l.cursor { + s += selectedStyle.Render("> " + content) + } else { + s += itemStyle.Render(" " + content) + } + if i < len(l.items)-1 { + s += "\n" + } + } + return s +} + +func (l *PlaybookList) SelectedItem() *playbooks.Playbook { + if len(l.items) == 0 { + return nil + } + return &l.items[l.cursor] +} + +func (l *PlaybookList) Len() int { + return len(l.items) +} diff --git a/internal/ui/page_locker_playbooks.go b/internal/ui/page_locker_playbooks.go index f1f8967..835abec 100644 --- a/internal/ui/page_locker_playbooks.go +++ b/internal/ui/page_locker_playbooks.go @@ -1,34 +1,274 @@ package ui import ( + "fmt" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/code-gorilla-au/rush/internal/playbooks" + "github.com/code-gorilla-au/rush/internal/ui/components" +) + +type playbooksViewMode int + +const ( + modeList playbooksViewMode = iota + modeCreateName + modeAddFormations ) +type lockerPlaybooksKeyMap struct { + components.CommonKeys + Back key.Binding + Enter key.Binding + Select key.Binding +} + +func (k lockerPlaybooksKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Enter, k.Back, k.Quit} +} + +func (k lockerPlaybooksKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Enter, k.Select, k.Back, k.Quit}, + } +} + +func newLockerPlaybooksKeyMap() lockerPlaybooksKeyMap { + return lockerPlaybooksKeyMap{ + CommonKeys: components.NewCommonKeys(), + Back: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "confirm"), + ), + Select: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select"), + ), + } +} + type ModelLockerPlaybooks struct { - width int - height int - theme IceTheme - globalState *GlobalState - playbookSvc *playbooks.Service + width int + height int + theme IceTheme + globalState *GlobalState + playbookSvc *playbooks.Service + keys lockerPlaybooksKeyMap + footer components.Footer + playbookList components.PlaybookList + formationList components.FormationList + mode playbooksViewMode + // Create flow state + newPlaybookName textinput.Model + newFormations []playbooks.Formation + playbooksLoaded bool + err error } func NewModelLockerPlaybooks(state *GlobalState, playbookSvc *playbooks.Service) *ModelLockerPlaybooks { + ti := textinput.New() + ti.Placeholder = "Playbook Name" + ti.Focus() + return &ModelLockerPlaybooks{ - theme: NewIceTheme(), - globalState: state, - playbookSvc: playbookSvc, + theme: NewIceTheme(), + globalState: state, + playbookSvc: playbookSvc, + keys: newLockerPlaybooksKeyMap(), + footer: components.NewFooter(newLockerPlaybooksKeyMap()), + newPlaybookName: ti, + formationList: components.NewFormationList(), } } func (m *ModelLockerPlaybooks) Init() tea.Cmd { - return nil + return m.loadPlaybooks +} + +func (m *ModelLockerPlaybooks) loadPlaybooks() tea.Msg { + if m.globalState.Team == nil { + return nil + } + items, err := m.playbookSvc.GetTeamPlaybooks(m.globalState.Context(), m.globalState.Team.ID) + if err != nil { + return err + } + return MsgPlaybooksLoaded{Playbooks: items} +} + +type MsgPlaybooksLoaded struct { + Playbooks []playbooks.Playbook } func (m *ModelLockerPlaybooks) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - return m, nil + var cmds []tea.Cmd + + switch msg := msg.(type) { + case MsgPlaybooksLoaded: + m.playbooksLoaded = true + m.playbookList = components.NewPlaybookList(msg.Playbooks) + m.mode = modeList + case MsgSwitchPage: + if msg.NewPage == PageLockerPlaybooks { + cmds = append(cmds, m.loadPlaybooks) + } + case error: + m.err = msg + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + case key.Matches(msg, m.keys.Back): + if m.mode == modeList { + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageLockerRoom} + } + } + m.mode = modeList + return m, nil + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.footer.Update(msg) + } + + switch m.mode { + case modeList: + cmds = append(cmds, m.updateList(msg)) + case modeCreateName: + cmds = append(cmds, m.updateCreateName(msg)) + case modeAddFormations: + cmds = append(cmds, m.updateAddFormations(msg)) + } + + return m, tea.Batch(cmds...) +} + +func (m *ModelLockerPlaybooks) updateList(msg tea.Msg) tea.Cmd { + if !m.playbooksLoaded { + return nil + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "n": // New playbook + m.mode = modeCreateName + m.newPlaybookName.Reset() + m.newPlaybookName.Focus() + m.newFormations = nil + return nil + } + } + + m.playbookList.Update(msg) + return nil +} + +func (m *ModelLockerPlaybooks) updateCreateName(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "enter" && m.newPlaybookName.Value() != "" { + m.mode = modeAddFormations + return nil + } + } + var cmd tea.Cmd + m.newPlaybookName, cmd = m.newPlaybookName.Update(msg) + return cmd +} + +func (m *ModelLockerPlaybooks) updateAddFormations(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "enter" { + if len(m.newFormations) < 10 { + m.newFormations = append(m.newFormations, m.formationList.SelectedItem()) + } + } else if msg.String() == "s" { // Save + if len(m.newFormations) > 0 { + return m.savePlaybook + } + } + } + m.formationList.Update(msg) + return nil +} + +func (m *ModelLockerPlaybooks) savePlaybook() tea.Msg { + _, err := m.playbookSvc.CreatePlaybook(m.globalState.Context(), playbooks.PlaybookParams{ + TeamID: m.globalState.Team.ID, + Name: m.newPlaybookName.Value(), + Formations: m.newFormations, + }) + if err != nil { + return err + } + return m.loadPlaybooks() } func (m *ModelLockerPlaybooks) View() tea.View { - return tea.NewView("playbooks") + view := tea.NewView("") + view.AltScreen = true + + var content string + title := "PLAYBOOKS" + + if m.err != nil { + content = m.theme.Logo.Render(fmt.Sprintf("Error: %v", m.err)) + } else if !m.playbooksLoaded { + content = "Loading..." + } else { + switch m.mode { + case modeList: + if m.playbookList.Len() == 0 { + content = "No playbooks yet. Press 'n' to create one." + } else { + content = m.playbookList.View(lipgloss.NewStyle(), m.theme.ListSelected) + content += "\n\nPress 'n' to create new playbook" + } + case modeCreateName: + title = "CREATE PLAYBOOK" + content = "Enter playbook name:\n\n" + m.newPlaybookName.View() + case modeAddFormations: + title = "ADD FORMATIONS" + content = fmt.Sprintf("Formations (%d/10):\n", len(m.newFormations)) + for _, f := range m.newFormations { + content += m.theme.ListSelected.Render(" + "+f.Name) + "\n" + } + content += "\nAvailable Formations:\n" + content += m.formationList.View(lipgloss.NewStyle(), m.theme.ListSelected) + content += "\n\nPress 'enter' to add, 's' to save" + } + } + + mainContent := lipgloss.JoinVertical( + lipgloss.Center, + m.theme.Logo.Render(title), + "", + content, + "", + m.footer.View(m.theme.Footer), + ) + + centeredContent := lipgloss.Place( + m.width, m.height, + lipgloss.Center, lipgloss.Center, + mainContent, + ) + + view.Content = m.theme.Base. + Width(m.width). + Height(m.height). + Render(centeredContent) + + return view } diff --git a/internal/ui/page_locker_room.go b/internal/ui/page_locker_room.go index 518ea5a..adcbc85 100644 --- a/internal/ui/page_locker_room.go +++ b/internal/ui/page_locker_room.go @@ -79,10 +79,15 @@ func (m *ModelLockerRoom) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return MsgSwitchPage{NewPage: PageTitle} } case key.Matches(msg, m.keys.Select): - if m.list.SelectedItem() == components.ItemPlayers { + switch m.list.SelectedItem() { + case components.ItemPlayers: return m, func() tea.Msg { return MsgSwitchPage{NewPage: PageLockerPlayers} } + case components.ItemPlaybooks: + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageLockerPlaybooks} + } } } case tea.WindowSizeMsg: diff --git a/internal/ui/page_locker_room_test.go b/internal/ui/page_locker_room_test.go index ba92493..18daed9 100644 --- a/internal/ui/page_locker_room_test.go +++ b/internal/ui/page_locker_room_test.go @@ -31,6 +31,27 @@ func TestModelLockerRoom_Selection(t *testing.T) { } }) + group.Test("should route to locker playbooks when playbooks item is selected", func(t *testing.T) { + state := &GlobalState{} + m := NewModelLockerRoom(state) + + // Select Playbooks (it's the second item) + m.Update(tea.KeyPressMsg{Text: "down"}) + odize.AssertEqual(t, components.ItemPlaybooks, m.list.SelectedItem()) + + // Simulate Enter key press + _, cmd := m.Update(tea.KeyPressMsg{Text: "enter"}) + + odize.AssertTrue(t, cmd != nil) + msg := cmd() + switch v := msg.(type) { + case MsgSwitchPage: + odize.AssertEqual(t, PageLockerPlaybooks, v.NewPage) + default: + t.Fatalf("expected MsgSwitchPage, got %T", msg) + } + }) + err := group.Run() odize.AssertNoError(t, err) } From 40bb26bce6e392b5803f2020c1ce0b2d6762256c Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 20 Jun 2026 22:40:35 +1000 Subject: [PATCH 44/98] fixing --- cmd/rush/main.go | 5 +++-- internal/ui/app_test.go | 22 ++++++++++++---------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/cmd/rush/main.go b/cmd/rush/main.go index ff03153..e80a21a 100644 --- a/cmd/rush/main.go +++ b/cmd/rush/main.go @@ -8,6 +8,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/code-gorilla-au/env" "github.com/code-gorilla-au/rush/internal/database" + "github.com/code-gorilla-au/rush/internal/playbooks" "github.com/code-gorilla-au/rush/internal/teams" "github.com/code-gorilla-au/rush/internal/ui" ) @@ -37,10 +38,10 @@ func main() { } queries := database.New(db) - teamsSvc := teams.NewTeamsService(queries) + playbooksSvc := playbooks.NewPlaybooksService(queries) - p := tea.NewProgram(ui.New(teamsSvc)) + p := tea.NewProgram(ui.New(teamsSvc, playbooksSvc)) if _, err = p.Run(); err != nil { slog.Error("Failed to run program", "error", err) os.Exit(1) diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index dc78dd9..b6c4235 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -7,10 +7,11 @@ import ( tea "charm.land/bubbletea/v2" "github.com/code-gorilla-au/odize" "github.com/code-gorilla-au/rush/internal/database" + "github.com/code-gorilla-au/rush/internal/playbooks" "github.com/code-gorilla-au/rush/internal/teams" ) -func setupTeamsService(t *testing.T) *teams.Service { +func setupServices(t *testing.T) (*teams.Service, *playbooks.Service) { db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatalf("failed to open database: %v", err) @@ -21,7 +22,8 @@ func setupTeamsService(t *testing.T) *teams.Service { t.Fatalf("failed to migrate database: %v", err) } - return teams.NewTeamsService(database.New(db)) + queries := database.New(db) + return teams.NewTeamsService(queries), playbooks.NewPlaybooksService(queries) } func TestTheme(t *testing.T) { @@ -46,19 +48,19 @@ func TestNew(t *testing.T) { err := group. Test("New should initialize model with IceTheme", func(t *testing.T) { - s := setupTeamsService(t) - m := New(s) + s, ps := setupServices(t) + m := New(s, ps) odize.AssertTrue(t, m.theme.Logo.GetForeground() != nil) }). Test("Init should return a command", func(t *testing.T) { - s := setupTeamsService(t) - m := New(s) + s, ps := setupServices(t) + m := New(s, ps) cmd := m.Init() odize.AssertTrue(t, cmd != nil) }). Test("Update should handle Quit keys", func(t *testing.T) { - s := setupTeamsService(t) - m := New(s) + s, ps := setupServices(t) + m := New(s, ps) _, cmd := m.Update(tea.KeyPressMsg{Text: "q"}) odize.AssertTrue(t, cmd != nil) @@ -66,8 +68,8 @@ func TestNew(t *testing.T) { odize.AssertTrue(t, cmd != nil) }). Test("Update should handle WindowSizeMsg", func(t *testing.T) { - s := setupTeamsService(t) - m := New(s) + s, ps := setupServices(t) + m := New(s, ps) newModel, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 50}) updatedModel := newModel.(RootModel) odize.AssertTrue(t, updatedModel.width == 100) From 58250b0f1c6ee3d26f81a959067f498495a7df59 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 20 Jun 2026 22:58:34 +1000 Subject: [PATCH 45/98] adding ability to add playbooks --- Taskfile.yml | 3 ++- internal/ui/page_locker_playbooks.go | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index 7e06f33..28d9164 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -78,7 +78,8 @@ tasks: db-clean: desc: Run db clean cmds: - - rm -f ./db/rush.db + - rm -rf ./.db + - mkdir ./.db db-reset: desc: Run db clean and migrate deps: [db-clean] diff --git a/internal/ui/page_locker_playbooks.go b/internal/ui/page_locker_playbooks.go index 835abec..bf02275 100644 --- a/internal/ui/page_locker_playbooks.go +++ b/internal/ui/page_locker_playbooks.go @@ -111,6 +111,9 @@ func (m *ModelLockerPlaybooks) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { + case MsgStateUpdated: + m.globalState.Coach = msg.Coach + m.globalState.Team = msg.Team case MsgPlaybooksLoaded: m.playbooksLoaded = true m.playbookList = components.NewPlaybookList(msg.Playbooks) @@ -189,7 +192,7 @@ func (m *ModelLockerPlaybooks) updateCreateName(msg tea.Msg) tea.Cmd { func (m *ModelLockerPlaybooks) updateAddFormations(msg tea.Msg) tea.Cmd { switch msg := msg.(type) { case tea.KeyMsg: - if msg.String() == "enter" { + if key.Matches(msg, m.keys.Enter) { if len(m.newFormations) < 10 { m.newFormations = append(m.newFormations, m.formationList.SelectedItem()) } From c21c1dbaf96b9eea6021b2a05ce398a637b37dd8 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sun, 21 Jun 2026 14:13:48 +1000 Subject: [PATCH 46/98] adding ability to add playbooks --- go.mod | 1 + go.sum | 4 + internal/ui/components/playbook_list.go | 89 +++++++++------- internal/ui/page_locker_playbooks.go | 128 +++++++++++++++++------- 4 files changed, 150 insertions(+), 72 deletions(-) diff --git a/go.mod b/go.mod index ff69f67..2118b98 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect diff --git a/go.sum b/go.sum index 8047529..3a5814b 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -54,6 +56,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= diff --git a/internal/ui/components/playbook_list.go b/internal/ui/components/playbook_list.go index e619d80..5036f2e 100644 --- a/internal/ui/components/playbook_list.go +++ b/internal/ui/components/playbook_list.go @@ -1,65 +1,78 @@ package components import ( + "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/code-gorilla-au/rush/internal/playbooks" ) +type PlaybookItem struct { + playbook playbooks.Playbook +} + +func (i PlaybookItem) Title() string { return i.playbook.Name } +func (i PlaybookItem) Description() string { return i.playbook.Description } +func (i PlaybookItem) FilterValue() string { return i.playbook.Name } + type PlaybookList struct { - cursor int - items []playbooks.Playbook + list list.Model } func NewPlaybookList(items []playbooks.Playbook) PlaybookList { + listItems := make([]list.Item, len(items)) + for i, item := range items { + listItems[i] = PlaybookItem{playbook: item} + } + + delegate := list.NewDefaultDelegate() + delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle.Foreground(lipgloss.Color("#A5F2F3")).BorderForeground(lipgloss.Color("#A5F2F3")) + delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc.Foreground(lipgloss.Color("#87CEEB")).BorderForeground(lipgloss.Color("#A5F2F3")) + + l := list.New(listItems, delegate, 0, 0) + l.Title = "Playbooks" + l.SetShowStatusBar(false) + l.SetFilteringEnabled(true) + l.Styles.Title = lipgloss.NewStyle().MarginLeft(2).Foreground(lipgloss.Color("#A5F2F3")).Bold(true) + return PlaybookList{ - items: items, + list: l, } } -func (l *PlaybookList) Update(msg tea.Msg) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "up", "k": - if l.cursor > 0 { - l.cursor-- - } - case "down", "j": - if l.cursor < len(l.items)-1 { - l.cursor++ - } - } - } +func (l *PlaybookList) Update(msg tea.Msg) (PlaybookList, tea.Cmd) { + var cmd tea.Cmd + l.list, cmd = l.list.Update(msg) + return *l, cmd } -func (l *PlaybookList) View(itemStyle lipgloss.Style, selectedStyle lipgloss.Style) string { - if len(l.items) == 0 { - return "No playbooks found." - } +func (l *PlaybookList) View() string { + return l.list.View() +} - var s string - for i, item := range l.items { - content := item.Name - if i == l.cursor { - s += selectedStyle.Render("> " + content) - } else { - s += itemStyle.Render(" " + content) - } - if i < len(l.items)-1 { - s += "\n" - } +func (l *PlaybookList) SelectedItem() *playbooks.Playbook { + if item, ok := l.list.SelectedItem().(PlaybookItem); ok { + return &item.playbook } - return s + return nil } -func (l *PlaybookList) SelectedItem() *playbooks.Playbook { - if len(l.items) == 0 { - return nil +func (l *PlaybookList) SetSize(width, height int) { + l.list.SetSize(width, height) +} + +func (l *PlaybookList) SetItems(items []playbooks.Playbook) tea.Cmd { + listItems := make([]list.Item, len(items)) + for i, item := range items { + listItems[i] = PlaybookItem{playbook: item} } - return &l.items[l.cursor] + return l.list.SetItems(listItems) } func (l *PlaybookList) Len() int { - return len(l.items) + return len(l.list.Items()) +} + +func (l *PlaybookList) IsFiltering() bool { + return l.list.FilterState() == list.Filtering } diff --git a/internal/ui/page_locker_playbooks.go b/internal/ui/page_locker_playbooks.go index bf02275..4a0bbc1 100644 --- a/internal/ui/page_locker_playbooks.go +++ b/internal/ui/page_locker_playbooks.go @@ -55,16 +55,18 @@ func newLockerPlaybooksKeyMap() lockerPlaybooksKeyMap { } type ModelLockerPlaybooks struct { - width int - height int - theme IceTheme - globalState *GlobalState - playbookSvc *playbooks.Service - keys lockerPlaybooksKeyMap - footer components.Footer - playbookList components.PlaybookList - formationList components.FormationList - mode playbooksViewMode + width int + height int + theme IceTheme + globalState *GlobalState + playbookSvc *playbooks.Service + keys lockerPlaybooksKeyMap + footer components.Footer + playbookList components.PlaybookList + formationList components.FormationList + selectedFormationList components.SelectedFormationList + mode playbooksViewMode + activeList int // 0 for formationList, 1 for selectedFormationList // Create flow state newPlaybookName textinput.Model newFormations []playbooks.Formation @@ -78,13 +80,14 @@ func NewModelLockerPlaybooks(state *GlobalState, playbookSvc *playbooks.Service) ti.Focus() return &ModelLockerPlaybooks{ - theme: NewIceTheme(), - globalState: state, - playbookSvc: playbookSvc, - keys: newLockerPlaybooksKeyMap(), - footer: components.NewFooter(newLockerPlaybooksKeyMap()), - newPlaybookName: ti, - formationList: components.NewFormationList(), + theme: NewIceTheme(), + globalState: state, + playbookSvc: playbookSvc, + keys: newLockerPlaybooksKeyMap(), + footer: components.NewFooter(newLockerPlaybooksKeyMap()), + newPlaybookName: ti, + formationList: components.NewFormationList(), + selectedFormationList: components.NewSelectedFormationList(), } } @@ -117,6 +120,7 @@ func (m *ModelLockerPlaybooks) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case MsgPlaybooksLoaded: m.playbooksLoaded = true m.playbookList = components.NewPlaybookList(msg.Playbooks) + m.playbookList.SetSize(m.width, m.height-10) // Set initial size m.mode = modeList case MsgSwitchPage: if msg.NewPage == PageLockerPlaybooks { @@ -130,16 +134,29 @@ func (m *ModelLockerPlaybooks) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case key.Matches(msg, m.keys.Back): if m.mode == modeList { + if m.playbookList.IsFiltering() { + break + } return m, func() tea.Msg { return MsgSwitchPage{NewPage: PageLockerRoom} } } + if m.mode == modeAddFormations { + if m.formationList.IsFiltering() { + break + } + } m.mode = modeList return m, nil } case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + if m.playbooksLoaded { + m.playbookList.SetSize(msg.Width, msg.Height-10) + } + m.formationList.SetSize(msg.Width/2-4, msg.Height-15) + m.selectedFormationList.SetSize(msg.Width/2-4, msg.Height-15) m.footer.Update(msg) } @@ -162,6 +179,9 @@ func (m *ModelLockerPlaybooks) updateList(msg tea.Msg) tea.Cmd { switch msg := msg.(type) { case tea.KeyMsg: + if m.playbookList.IsFiltering() { + break + } switch msg.String() { case "n": // New playbook m.mode = modeCreateName @@ -172,8 +192,9 @@ func (m *ModelLockerPlaybooks) updateList(msg tea.Msg) tea.Cmd { } } - m.playbookList.Update(msg) - return nil + var cmd tea.Cmd + m.playbookList, cmd = m.playbookList.Update(msg) + return cmd } func (m *ModelLockerPlaybooks) updateCreateName(msg tea.Msg) tea.Cmd { @@ -190,20 +211,54 @@ func (m *ModelLockerPlaybooks) updateCreateName(msg tea.Msg) tea.Cmd { } func (m *ModelLockerPlaybooks) updateAddFormations(msg tea.Msg) tea.Cmd { + var cmds []tea.Cmd + switch msg := msg.(type) { case tea.KeyMsg: - if key.Matches(msg, m.keys.Enter) { - if len(m.newFormations) < 10 { - m.newFormations = append(m.newFormations, m.formationList.SelectedItem()) + if m.formationList.IsFiltering() { + break + } + switch msg.String() { + case "tab": + m.activeList = (m.activeList + 1) % 2 + return nil + case "enter": + if m.activeList == 0 { + if len(m.newFormations) < 10 { + f := m.formationList.SelectedItem() + m.newFormations = append(m.newFormations, f) + cmds = append(cmds, m.selectedFormationList.SetItems(m.newFormations)) + } + } else { + // Remove from selected + if len(m.newFormations) > 0 { + idx := m.selectedFormationList.SelectedIndex() + if idx >= 0 && idx < len(m.newFormations) { + m.newFormations = append(m.newFormations[:idx], m.newFormations[idx+1:]...) + cmds = append(cmds, m.selectedFormationList.SetItems(m.newFormations)) + } + } } - } else if msg.String() == "s" { // Save + return tea.Batch(cmds...) + case "s": // Save if len(m.newFormations) > 0 { return m.savePlaybook } } } - m.formationList.Update(msg) - return nil + + m.formationList.SetActive(m.activeList == 0) + m.selectedFormationList.SetActive(m.activeList == 1) + + var cmd tea.Cmd + if m.activeList == 0 { + m.formationList, cmd = m.formationList.Update(msg) + } else { + m.selectedFormationList, cmd = m.selectedFormationList.Update(msg) + } + cmds = append(cmds, cmd) + + return tea.Batch(cmds...) } func (m *ModelLockerPlaybooks) savePlaybook() tea.Msg { @@ -235,21 +290,26 @@ func (m *ModelLockerPlaybooks) View() tea.View { if m.playbookList.Len() == 0 { content = "No playbooks yet. Press 'n' to create one." } else { - content = m.playbookList.View(lipgloss.NewStyle(), m.theme.ListSelected) - content += "\n\nPress 'n' to create new playbook" + content = m.playbookList.View() + if !m.playbookList.IsFiltering() { + content += "\n\nPress 'n' to create new playbook" + } } case modeCreateName: title = "CREATE PLAYBOOK" content = "Enter playbook name:\n\n" + m.newPlaybookName.View() case modeAddFormations: title = "ADD FORMATIONS" - content = fmt.Sprintf("Formations (%d/10):\n", len(m.newFormations)) - for _, f := range m.newFormations { - content += m.theme.ListSelected.Render(" + "+f.Name) + "\n" - } - content += "\nAvailable Formations:\n" - content += m.formationList.View(lipgloss.NewStyle(), m.theme.ListSelected) - content += "\n\nPress 'enter' to add, 's' to save" + m.formationList.SetSize(m.width/2-4, m.height-15) + m.selectedFormationList.SetSize(m.width/2-4, m.height-15) + + content = lipgloss.JoinHorizontal( + lipgloss.Top, + m.formationList.View(), + lipgloss.NewStyle().Width(2).Render(""), + m.selectedFormationList.View(), + ) + content += "\n\n" + m.theme.Footer.Render("Tab to switch lists • Enter to add/remove • 's' to save") } } From 3002ecca612856eb06c3b286c414376195947092 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sun, 21 Jun 2026 14:33:54 +1000 Subject: [PATCH 47/98] components added --- internal/ui/components/formation_list.go | 91 ++++++++++++------- .../ui/components/selected_formation_list.go | 82 +++++++++++++++++ internal/ui/page_locker_playbooks.go | 20 ++-- 3 files changed, 153 insertions(+), 40 deletions(-) create mode 100644 internal/ui/components/selected_formation_list.go diff --git a/internal/ui/components/formation_list.go b/internal/ui/components/formation_list.go index ff6d84a..c515e97 100644 --- a/internal/ui/components/formation_list.go +++ b/internal/ui/components/formation_list.go @@ -1,57 +1,84 @@ package components import ( + "fmt" + + "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/code-gorilla-au/rush/internal/playbooks" ) +type FormationItem struct { + formation playbooks.Formation +} + +func (i FormationItem) Title() string { + return fmt.Sprintf("%s (%d-%d-%d)", i.formation.Name, i.formation.Lane1, i.formation.Lane2, i.formation.Lane3) +} +func (i FormationItem) Description() string { return i.formation.Description } +func (i FormationItem) FilterValue() string { return i.formation.Name } + type FormationList struct { - cursor int - items []playbooks.Formation + list list.Model + active bool } func NewFormationList() FormationList { + formations := playbooks.Formations() + items := make([]list.Item, len(formations)) + for i, f := range formations { + items[i] = FormationItem{formation: f} + } + + delegate := list.NewDefaultDelegate() + // Match PlaybookList styling + delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle.Foreground(lipgloss.Color("#A5F2F3")).BorderForeground(lipgloss.Color("#A5F2F3")) + delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc.Foreground(lipgloss.Color("#87CEEB")).BorderForeground(lipgloss.Color("#A5F2F3")) + + l := list.New(items, delegate, 0, 0) + l.Title = "Available Formations" + l.SetShowStatusBar(false) + l.SetFilteringEnabled(true) + l.Styles.Title = lipgloss.NewStyle().MarginLeft(2).Foreground(lipgloss.Color("#A5F2F3")).Bold(true) + return FormationList{ - items: playbooks.Formations(), + list: l, } } -func (l *FormationList) Update(msg tea.Msg) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "up", "k": - if l.cursor > 0 { - l.cursor-- - } - case "down", "j": - if l.cursor < len(l.items)-1 { - l.cursor++ - } - } +func (l *FormationList) Update(msg tea.Msg) (FormationList, tea.Cmd) { + if !l.active { + return *l, nil } + var cmd tea.Cmd + l.list, cmd = l.list.Update(msg) + return *l, cmd } -func (l *FormationList) View(itemStyle lipgloss.Style, selectedStyle lipgloss.Style) string { - var s string - for i, item := range l.items { - content := item.Name - if i == l.cursor { - s += selectedStyle.Render("> " + content) - } else { - s += itemStyle.Render(" " + content) - } - if i < len(l.items)-1 { - s += "\n" - } +func (l *FormationList) View() string { + style := lipgloss.NewStyle().Padding(1).Border(lipgloss.RoundedBorder()) + if l.active { + style = style.BorderForeground(lipgloss.Color("#A5F2F3")) } - return s + return style.Render(l.list.View()) } func (l *FormationList) SelectedItem() playbooks.Formation { - if len(l.items) == 0 { - return playbooks.Formation{} + if item, ok := l.list.SelectedItem().(FormationItem); ok { + return item.formation } - return l.items[l.cursor] + return playbooks.Formation{} +} + +func (l *FormationList) SetSize(width, height int) { + l.list.SetSize(width, height) +} + +func (l *FormationList) SetActive(active bool) { + l.active = active +} + +func (l *FormationList) IsFiltering() bool { + return l.list.FilterState() == list.Filtering } diff --git a/internal/ui/components/selected_formation_list.go b/internal/ui/components/selected_formation_list.go new file mode 100644 index 0000000..6ce9ad6 --- /dev/null +++ b/internal/ui/components/selected_formation_list.go @@ -0,0 +1,82 @@ +package components + +import ( + "fmt" + + "charm.land/bubbles/v2/list" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/code-gorilla-au/rush/internal/playbooks" +) + +type SelectedFormationItem struct { + formation playbooks.Formation +} + +func (i SelectedFormationItem) Title() string { + return fmt.Sprintf("%s (%d-%d-%d)", i.formation.Name, i.formation.Lane1, i.formation.Lane2, i.formation.Lane3) +} +func (i SelectedFormationItem) Description() string { return "" } +func (i SelectedFormationItem) FilterValue() string { return i.formation.Name } + +type SelectedFormationList struct { + list list.Model + active bool +} + +func NewSelectedFormationList() SelectedFormationList { + delegate := list.NewDefaultDelegate() + delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle.Foreground(lipgloss.Color("#A5F2F3")).BorderForeground(lipgloss.Color("#A5F2F3")) + delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc.Foreground(lipgloss.Color("#87CEEB")).BorderForeground(lipgloss.Color("#A5F2F3")) + + l := list.New([]list.Item{}, delegate, 0, 0) + l.Title = "Selected Formations (Max 10)" + l.SetShowStatusBar(false) + l.SetFilteringEnabled(false) + l.Styles.Title = lipgloss.NewStyle().MarginLeft(2).Foreground(lipgloss.Color("#A5F2F3")).Bold(true) + + return SelectedFormationList{ + list: l, + } +} + +func (l *SelectedFormationList) Update(msg tea.Msg) (SelectedFormationList, tea.Cmd) { + if !l.active { + return *l, nil + } + var cmd tea.Cmd + l.list, cmd = l.list.Update(msg) + return *l, cmd +} + +func (l *SelectedFormationList) View() string { + style := lipgloss.NewStyle().Padding(1).Border(lipgloss.RoundedBorder()) + if l.active { + style = style.BorderForeground(lipgloss.Color("#A5F2F3")) + } + return style.Render(l.list.View()) +} + +func (l *SelectedFormationList) SetItems(formations []playbooks.Formation) tea.Cmd { + items := make([]list.Item, len(formations)) + for i, f := range formations { + items[i] = SelectedFormationItem{formation: f} + } + return l.list.SetItems(items) +} + +func (l *SelectedFormationList) SelectedIndex() int { + return l.list.Index() +} + +func (l *SelectedFormationList) SetSize(width, height int) { + l.list.SetSize(width, height) +} + +func (l *SelectedFormationList) SetActive(active bool) { + l.active = active +} + +func (l *SelectedFormationList) Len() int { + return len(l.list.Items()) +} diff --git a/internal/ui/page_locker_playbooks.go b/internal/ui/page_locker_playbooks.go index 4a0bbc1..a5621cd 100644 --- a/internal/ui/page_locker_playbooks.go +++ b/internal/ui/page_locker_playbooks.go @@ -221,13 +221,17 @@ func (m *ModelLockerPlaybooks) updateAddFormations(msg tea.Msg) tea.Cmd { switch msg.String() { case "tab": m.activeList = (m.activeList + 1) % 2 + m.formationList.SetActive(m.activeList == 0) + m.selectedFormationList.SetActive(m.activeList == 1) return nil case "enter": if m.activeList == 0 { if len(m.newFormations) < 10 { f := m.formationList.SelectedItem() - m.newFormations = append(m.newFormations, f) - cmds = append(cmds, m.selectedFormationList.SetItems(m.newFormations)) + if f.Name != "" { + m.newFormations = append(m.newFormations, f) + cmds = append(cmds, m.selectedFormationList.SetItems(m.newFormations)) + } } } else { // Remove from selected @@ -251,11 +255,10 @@ func (m *ModelLockerPlaybooks) updateAddFormations(msg tea.Msg) tea.Cmd { m.selectedFormationList.SetActive(m.activeList == 1) var cmd tea.Cmd - if m.activeList == 0 { - m.formationList, cmd = m.formationList.Update(msg) - } else { - m.selectedFormationList, cmd = m.selectedFormationList.Update(msg) - } + m.formationList, cmd = m.formationList.Update(msg) + cmds = append(cmds, cmd) + + m.selectedFormationList, cmd = m.selectedFormationList.Update(msg) cmds = append(cmds, cmd) return tea.Batch(cmds...) @@ -300,6 +303,7 @@ func (m *ModelLockerPlaybooks) View() tea.View { content = "Enter playbook name:\n\n" + m.newPlaybookName.View() case modeAddFormations: title = "ADD FORMATIONS" + // The SetSize will be handled by WindowSizeMsg, but we ensure it here too for the split m.formationList.SetSize(m.width/2-4, m.height-15) m.selectedFormationList.SetSize(m.width/2-4, m.height-15) @@ -309,7 +313,7 @@ func (m *ModelLockerPlaybooks) View() tea.View { lipgloss.NewStyle().Width(2).Render(""), m.selectedFormationList.View(), ) - content += "\n\n" + m.theme.Footer.Render("Tab to switch lists • Enter to add/remove • 's' to save") + content += "\n\n" + m.theme.Footer.Render(fmt.Sprintf("%d/10 formations • Tab: switch • Enter: add/remove • 's': save", len(m.newFormations))) } } From 0eb2fdba9692340ec3e715709c3a71fec5d7dfe5 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sun, 21 Jun 2026 20:04:49 +1000 Subject: [PATCH 48/98] clean up --- internal/ui/components/formation_list.go | 60 ++++++++++++-- .../ui/components/selected_formation_list.go | 82 ------------------- internal/ui/page_locker_playbooks.go | 28 +++++-- 3 files changed, 70 insertions(+), 100 deletions(-) delete mode 100644 internal/ui/components/selected_formation_list.go diff --git a/internal/ui/components/formation_list.go b/internal/ui/components/formation_list.go index c515e97..6c91942 100644 --- a/internal/ui/components/formation_list.go +++ b/internal/ui/components/formation_list.go @@ -10,13 +10,19 @@ import ( ) type FormationItem struct { - formation playbooks.Formation + formation playbooks.Formation + showDescription bool } func (i FormationItem) Title() string { return fmt.Sprintf("%s (%d-%d-%d)", i.formation.Name, i.formation.Lane1, i.formation.Lane2, i.formation.Lane3) } -func (i FormationItem) Description() string { return i.formation.Description } +func (i FormationItem) Description() string { + if i.showDescription { + return i.formation.Description + } + return "" +} func (i FormationItem) FilterValue() string { return i.formation.Name } type FormationList struct { @@ -24,11 +30,20 @@ type FormationList struct { active bool } -func NewFormationList() FormationList { - formations := playbooks.Formations() - items := make([]list.Item, len(formations)) - for i, f := range formations { - items[i] = FormationItem{formation: f} +type FormationListConfig struct { + Title string + Items []playbooks.Formation + EnableFiltering bool + ShowDescription bool +} + +func NewFormationList(config FormationListConfig) FormationList { + items := make([]list.Item, len(config.Items)) + for i, f := range config.Items { + items[i] = FormationItem{ + formation: f, + showDescription: config.ShowDescription, + } } delegate := list.NewDefaultDelegate() @@ -37,9 +52,9 @@ func NewFormationList() FormationList { delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc.Foreground(lipgloss.Color("#87CEEB")).BorderForeground(lipgloss.Color("#A5F2F3")) l := list.New(items, delegate, 0, 0) - l.Title = "Available Formations" + l.Title = config.Title l.SetShowStatusBar(false) - l.SetFilteringEnabled(true) + l.SetFilteringEnabled(config.EnableFiltering) l.Styles.Title = lipgloss.NewStyle().MarginLeft(2).Foreground(lipgloss.Color("#A5F2F3")).Bold(true) return FormationList{ @@ -79,6 +94,33 @@ func (l *FormationList) SetActive(active bool) { l.active = active } +func (l *FormationList) SetItems(formations []playbooks.Formation) tea.Cmd { + items := make([]list.Item, len(formations)) + + showDescription := false + if len(l.list.Items()) > 0 { + if first, ok := l.list.Items()[0].(FormationItem); ok { + showDescription = first.showDescription + } + } + + for i, f := range formations { + items[i] = FormationItem{ + formation: f, + showDescription: showDescription, + } + } + return l.list.SetItems(items) +} + +func (l *FormationList) SelectedIndex() int { + return l.list.Index() +} + +func (l *FormationList) Len() int { + return len(l.list.Items()) +} + func (l *FormationList) IsFiltering() bool { return l.list.FilterState() == list.Filtering } diff --git a/internal/ui/components/selected_formation_list.go b/internal/ui/components/selected_formation_list.go deleted file mode 100644 index 6ce9ad6..0000000 --- a/internal/ui/components/selected_formation_list.go +++ /dev/null @@ -1,82 +0,0 @@ -package components - -import ( - "fmt" - - "charm.land/bubbles/v2/list" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/code-gorilla-au/rush/internal/playbooks" -) - -type SelectedFormationItem struct { - formation playbooks.Formation -} - -func (i SelectedFormationItem) Title() string { - return fmt.Sprintf("%s (%d-%d-%d)", i.formation.Name, i.formation.Lane1, i.formation.Lane2, i.formation.Lane3) -} -func (i SelectedFormationItem) Description() string { return "" } -func (i SelectedFormationItem) FilterValue() string { return i.formation.Name } - -type SelectedFormationList struct { - list list.Model - active bool -} - -func NewSelectedFormationList() SelectedFormationList { - delegate := list.NewDefaultDelegate() - delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle.Foreground(lipgloss.Color("#A5F2F3")).BorderForeground(lipgloss.Color("#A5F2F3")) - delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc.Foreground(lipgloss.Color("#87CEEB")).BorderForeground(lipgloss.Color("#A5F2F3")) - - l := list.New([]list.Item{}, delegate, 0, 0) - l.Title = "Selected Formations (Max 10)" - l.SetShowStatusBar(false) - l.SetFilteringEnabled(false) - l.Styles.Title = lipgloss.NewStyle().MarginLeft(2).Foreground(lipgloss.Color("#A5F2F3")).Bold(true) - - return SelectedFormationList{ - list: l, - } -} - -func (l *SelectedFormationList) Update(msg tea.Msg) (SelectedFormationList, tea.Cmd) { - if !l.active { - return *l, nil - } - var cmd tea.Cmd - l.list, cmd = l.list.Update(msg) - return *l, cmd -} - -func (l *SelectedFormationList) View() string { - style := lipgloss.NewStyle().Padding(1).Border(lipgloss.RoundedBorder()) - if l.active { - style = style.BorderForeground(lipgloss.Color("#A5F2F3")) - } - return style.Render(l.list.View()) -} - -func (l *SelectedFormationList) SetItems(formations []playbooks.Formation) tea.Cmd { - items := make([]list.Item, len(formations)) - for i, f := range formations { - items[i] = SelectedFormationItem{formation: f} - } - return l.list.SetItems(items) -} - -func (l *SelectedFormationList) SelectedIndex() int { - return l.list.Index() -} - -func (l *SelectedFormationList) SetSize(width, height int) { - l.list.SetSize(width, height) -} - -func (l *SelectedFormationList) SetActive(active bool) { - l.active = active -} - -func (l *SelectedFormationList) Len() int { - return len(l.list.Items()) -} diff --git a/internal/ui/page_locker_playbooks.go b/internal/ui/page_locker_playbooks.go index a5621cd..b11203c 100644 --- a/internal/ui/page_locker_playbooks.go +++ b/internal/ui/page_locker_playbooks.go @@ -64,7 +64,7 @@ type ModelLockerPlaybooks struct { footer components.Footer playbookList components.PlaybookList formationList components.FormationList - selectedFormationList components.SelectedFormationList + selectedFormationList components.FormationList mode playbooksViewMode activeList int // 0 for formationList, 1 for selectedFormationList // Create flow state @@ -80,14 +80,24 @@ func NewModelLockerPlaybooks(state *GlobalState, playbookSvc *playbooks.Service) ti.Focus() return &ModelLockerPlaybooks{ - theme: NewIceTheme(), - globalState: state, - playbookSvc: playbookSvc, - keys: newLockerPlaybooksKeyMap(), - footer: components.NewFooter(newLockerPlaybooksKeyMap()), - newPlaybookName: ti, - formationList: components.NewFormationList(), - selectedFormationList: components.NewSelectedFormationList(), + theme: NewIceTheme(), + globalState: state, + playbookSvc: playbookSvc, + keys: newLockerPlaybooksKeyMap(), + footer: components.NewFooter(newLockerPlaybooksKeyMap()), + newPlaybookName: ti, + formationList: components.NewFormationList(components.FormationListConfig{ + Title: "Available Formations", + Items: playbooks.Formations(), + EnableFiltering: true, + ShowDescription: true, + }), + selectedFormationList: components.NewFormationList(components.FormationListConfig{ + Title: "Selected Formations (Max 10)", + Items: []playbooks.Formation{}, + EnableFiltering: false, + ShowDescription: false, + }), } } From a1c1cc8bd760cbcdb8e377d73a4c03fdd977a774 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sun, 21 Jun 2026 20:13:01 +1000 Subject: [PATCH 49/98] splitting out pages --- internal/ui/app.go | 69 ++++--- ...books.go => page_locker_playbooks_edit.go} | 137 ++++--------- internal/ui/page_locker_playbooks_list.go | 186 ++++++++++++++++++ internal/ui/page_locker_room.go | 2 +- internal/ui/page_locker_room_test.go | 2 +- 5 files changed, 265 insertions(+), 131 deletions(-) rename internal/ui/{page_locker_playbooks.go => page_locker_playbooks_edit.go} (64%) create mode 100644 internal/ui/page_locker_playbooks_list.go diff --git a/internal/ui/app.go b/internal/ui/app.go index 570519c..c8973a5 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -24,7 +24,8 @@ const ( PageCreateCoach PageLockerRoom PageLockerPlayers - PageLockerPlaybooks + PageLockerPlaybooksList + PageLockerPlaybooksEdit ) type GlobalState struct { @@ -37,19 +38,20 @@ func (m *GlobalState) Context() context.Context { } type RootModel struct { - ctx context.Context - width int - height int - theme IceTheme - currentPage Page - pageTitle tea.Model - pageCreateCoach tea.Model - pageLockerRoom tea.Model - pageLockerPlayers tea.Model - pageLockerPlaybooks tea.Model - globalState *GlobalState - teamsSvc *teams.Service - playbookSvc *playbooks.Service + ctx context.Context + width int + height int + theme IceTheme + currentPage Page + pageTitle tea.Model + pageCreateCoach tea.Model + pageLockerRoom tea.Model + pageLockerPlayers tea.Model + pageLockerPlaybooksList tea.Model + pageLockerPlaybooksEdit tea.Model + globalState *GlobalState + teamsSvc *teams.Service + playbookSvc *playbooks.Service } // New returns a new UI model. @@ -57,17 +59,18 @@ func New(teamsService *teams.Service, playbookService *playbooks.Service) RootMo state := &GlobalState{} return RootModel{ - ctx: context.Background(), - theme: NewIceTheme(), - currentPage: PageTitle, - pageTitle: NewModelTitle(state), - pageCreateCoach: NewModelCreateCoach(state, teamsService), - pageLockerRoom: NewModelLockerRoom(state), - pageLockerPlayers: NewModelLockerPlayers(state, teamsService), - pageLockerPlaybooks: NewModelLockerPlaybooks(state, playbookService), - globalState: state, - teamsSvc: teamsService, - playbookSvc: playbookService, + ctx: context.Background(), + theme: NewIceTheme(), + currentPage: PageTitle, + pageTitle: NewModelTitle(state), + pageCreateCoach: NewModelCreateCoach(state, teamsService), + pageLockerRoom: NewModelLockerRoom(state), + pageLockerPlayers: NewModelLockerPlayers(state, teamsService), + pageLockerPlaybooksList: NewModelLockerPlaybooksList(state, playbookService), + pageLockerPlaybooksEdit: NewModelLockerPlaybooksEdit(state, playbookService), + globalState: state, + teamsSvc: teamsService, + playbookSvc: playbookService, } } @@ -113,7 +116,9 @@ func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) m.pageLockerPlayers, cmd = m.pageLockerPlayers.Update(msg) cmds = append(cmds, cmd) - m.pageLockerPlaybooks, cmd = m.pageLockerPlaybooks.Update(msg) + m.pageLockerPlaybooksList, cmd = m.pageLockerPlaybooksList.Update(msg) + cmds = append(cmds, cmd) + m.pageLockerPlaybooksEdit, cmd = m.pageLockerPlaybooksEdit.Update(msg) } var cmd tea.Cmd @@ -126,8 +131,10 @@ func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.pageLockerRoom, cmd = m.pageLockerRoom.Update(msg) case PageLockerPlayers: m.pageLockerPlayers, cmd = m.pageLockerPlayers.Update(msg) - case PageLockerPlaybooks: - m.pageLockerPlaybooks, cmd = m.pageLockerPlaybooks.Update(msg) + case PageLockerPlaybooksList: + m.pageLockerPlaybooksList, cmd = m.pageLockerPlaybooksList.Update(msg) + case PageLockerPlaybooksEdit: + m.pageLockerPlaybooksEdit, cmd = m.pageLockerPlaybooksEdit.Update(msg) } cmds = append(cmds, cmd) @@ -148,8 +155,10 @@ func (m RootModel) View() tea.View { return m.pageLockerRoom.View() case PageLockerPlayers: return m.pageLockerPlayers.View() - case PageLockerPlaybooks: - return m.pageLockerPlaybooks.View() + case PageLockerPlaybooksList: + return m.pageLockerPlaybooksList.View() + case PageLockerPlaybooksEdit: + return m.pageLockerPlaybooksEdit.View() } return tea.NewView("unknown page") diff --git a/internal/ui/page_locker_playbooks.go b/internal/ui/page_locker_playbooks_edit.go similarity index 64% rename from internal/ui/page_locker_playbooks.go rename to internal/ui/page_locker_playbooks_edit.go index b11203c..80319f0 100644 --- a/internal/ui/page_locker_playbooks.go +++ b/internal/ui/page_locker_playbooks_edit.go @@ -11,33 +11,32 @@ import ( "github.com/code-gorilla-au/rush/internal/ui/components" ) -type playbooksViewMode int +type editPlaybookMode int const ( - modeList playbooksViewMode = iota - modeCreateName + modeCreateName editPlaybookMode = iota modeAddFormations ) -type lockerPlaybooksKeyMap struct { +type lockerPlaybooksEditKeyMap struct { components.CommonKeys Back key.Binding Enter key.Binding Select key.Binding } -func (k lockerPlaybooksKeyMap) ShortHelp() []key.Binding { +func (k lockerPlaybooksEditKeyMap) ShortHelp() []key.Binding { return []key.Binding{k.Enter, k.Back, k.Quit} } -func (k lockerPlaybooksKeyMap) FullHelp() [][]key.Binding { +func (k lockerPlaybooksEditKeyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ {k.Enter, k.Select, k.Back, k.Quit}, } } -func newLockerPlaybooksKeyMap() lockerPlaybooksKeyMap { - return lockerPlaybooksKeyMap{ +func newLockerPlaybooksEditKeyMap() lockerPlaybooksEditKeyMap { + return lockerPlaybooksEditKeyMap{ CommonKeys: components.NewCommonKeys(), Back: key.NewBinding( key.WithKeys("esc"), @@ -54,37 +53,34 @@ func newLockerPlaybooksKeyMap() lockerPlaybooksKeyMap { } } -type ModelLockerPlaybooks struct { +type ModelLockerPlaybooksEdit struct { width int height int theme IceTheme globalState *GlobalState playbookSvc *playbooks.Service - keys lockerPlaybooksKeyMap + keys lockerPlaybooksEditKeyMap footer components.Footer - playbookList components.PlaybookList formationList components.FormationList selectedFormationList components.FormationList - mode playbooksViewMode + mode editPlaybookMode activeList int // 0 for formationList, 1 for selectedFormationList - // Create flow state - newPlaybookName textinput.Model - newFormations []playbooks.Formation - playbooksLoaded bool - err error + newPlaybookName textinput.Model + newFormations []playbooks.Formation + err error } -func NewModelLockerPlaybooks(state *GlobalState, playbookSvc *playbooks.Service) *ModelLockerPlaybooks { +func NewModelLockerPlaybooksEdit(state *GlobalState, playbookSvc *playbooks.Service) *ModelLockerPlaybooksEdit { ti := textinput.New() ti.Placeholder = "Playbook Name" ti.Focus() - return &ModelLockerPlaybooks{ + return &ModelLockerPlaybooksEdit{ theme: NewIceTheme(), globalState: state, playbookSvc: playbookSvc, - keys: newLockerPlaybooksKeyMap(), - footer: components.NewFooter(newLockerPlaybooksKeyMap()), + keys: newLockerPlaybooksEditKeyMap(), + footer: components.NewFooter(newLockerPlaybooksEditKeyMap()), newPlaybookName: ti, formationList: components.NewFormationList(components.FormationListConfig{ Title: "Available Formations", @@ -101,40 +97,20 @@ func NewModelLockerPlaybooks(state *GlobalState, playbookSvc *playbooks.Service) } } -func (m *ModelLockerPlaybooks) Init() tea.Cmd { - return m.loadPlaybooks +func (m *ModelLockerPlaybooksEdit) Init() tea.Cmd { + return nil } -func (m *ModelLockerPlaybooks) loadPlaybooks() tea.Msg { - if m.globalState.Team == nil { - return nil - } - items, err := m.playbookSvc.GetTeamPlaybooks(m.globalState.Context(), m.globalState.Team.ID) - if err != nil { - return err - } - return MsgPlaybooksLoaded{Playbooks: items} -} - -type MsgPlaybooksLoaded struct { - Playbooks []playbooks.Playbook -} - -func (m *ModelLockerPlaybooks) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *ModelLockerPlaybooksEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case MsgStateUpdated: m.globalState.Coach = msg.Coach m.globalState.Team = msg.Team - case MsgPlaybooksLoaded: - m.playbooksLoaded = true - m.playbookList = components.NewPlaybookList(msg.Playbooks) - m.playbookList.SetSize(m.width, m.height-10) // Set initial size - m.mode = modeList case MsgSwitchPage: - if msg.NewPage == PageLockerPlaybooks { - cmds = append(cmds, m.loadPlaybooks) + if msg.NewPage == PageLockerPlaybooksEdit { + m.reset() } case error: m.err = msg @@ -143,36 +119,28 @@ func (m *ModelLockerPlaybooks) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.Quit): return m, tea.Quit case key.Matches(msg, m.keys.Back): - if m.mode == modeList { - if m.playbookList.IsFiltering() { - break - } + if m.mode == modeCreateName { return m, func() tea.Msg { - return MsgSwitchPage{NewPage: PageLockerRoom} + return MsgSwitchPage{NewPage: PageLockerPlaybooksList} } } if m.mode == modeAddFormations { if m.formationList.IsFiltering() { break } + m.mode = modeCreateName + return m, nil } - m.mode = modeList - return m, nil } case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - if m.playbooksLoaded { - m.playbookList.SetSize(msg.Width, msg.Height-10) - } m.formationList.SetSize(msg.Width/2-4, msg.Height-15) m.selectedFormationList.SetSize(msg.Width/2-4, msg.Height-15) m.footer.Update(msg) } switch m.mode { - case modeList: - cmds = append(cmds, m.updateList(msg)) case modeCreateName: cmds = append(cmds, m.updateCreateName(msg)) case modeAddFormations: @@ -182,32 +150,16 @@ func (m *ModelLockerPlaybooks) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m *ModelLockerPlaybooks) updateList(msg tea.Msg) tea.Cmd { - if !m.playbooksLoaded { - return nil - } - - switch msg := msg.(type) { - case tea.KeyMsg: - if m.playbookList.IsFiltering() { - break - } - switch msg.String() { - case "n": // New playbook - m.mode = modeCreateName - m.newPlaybookName.Reset() - m.newPlaybookName.Focus() - m.newFormations = nil - return nil - } - } - - var cmd tea.Cmd - m.playbookList, cmd = m.playbookList.Update(msg) - return cmd +func (m *ModelLockerPlaybooksEdit) reset() { + m.newPlaybookName.Reset() + m.newPlaybookName.Focus() + m.newFormations = nil + m.mode = modeCreateName + m.selectedFormationList.SetItems(nil) + m.err = nil } -func (m *ModelLockerPlaybooks) updateCreateName(msg tea.Msg) tea.Cmd { +func (m *ModelLockerPlaybooksEdit) updateCreateName(msg tea.Msg) tea.Cmd { switch msg := msg.(type) { case tea.KeyMsg: if msg.String() == "enter" && m.newPlaybookName.Value() != "" { @@ -220,7 +172,7 @@ func (m *ModelLockerPlaybooks) updateCreateName(msg tea.Msg) tea.Cmd { return cmd } -func (m *ModelLockerPlaybooks) updateAddFormations(msg tea.Msg) tea.Cmd { +func (m *ModelLockerPlaybooksEdit) updateAddFormations(msg tea.Msg) tea.Cmd { var cmds []tea.Cmd switch msg := msg.(type) { @@ -244,7 +196,6 @@ func (m *ModelLockerPlaybooks) updateAddFormations(msg tea.Msg) tea.Cmd { } } } else { - // Remove from selected if len(m.newFormations) > 0 { idx := m.selectedFormationList.SelectedIndex() if idx >= 0 && idx < len(m.newFormations) { @@ -274,7 +225,7 @@ func (m *ModelLockerPlaybooks) updateAddFormations(msg tea.Msg) tea.Cmd { return tea.Batch(cmds...) } -func (m *ModelLockerPlaybooks) savePlaybook() tea.Msg { +func (m *ModelLockerPlaybooksEdit) savePlaybook() tea.Msg { _, err := m.playbookSvc.CreatePlaybook(m.globalState.Context(), playbooks.PlaybookParams{ TeamID: m.globalState.Team.ID, Name: m.newPlaybookName.Value(), @@ -283,10 +234,10 @@ func (m *ModelLockerPlaybooks) savePlaybook() tea.Msg { if err != nil { return err } - return m.loadPlaybooks() + return MsgSwitchPage{NewPage: PageLockerPlaybooksList} } -func (m *ModelLockerPlaybooks) View() tea.View { +func (m *ModelLockerPlaybooksEdit) View() tea.View { view := tea.NewView("") view.AltScreen = true @@ -295,25 +246,13 @@ func (m *ModelLockerPlaybooks) View() tea.View { if m.err != nil { content = m.theme.Logo.Render(fmt.Sprintf("Error: %v", m.err)) - } else if !m.playbooksLoaded { - content = "Loading..." } else { switch m.mode { - case modeList: - if m.playbookList.Len() == 0 { - content = "No playbooks yet. Press 'n' to create one." - } else { - content = m.playbookList.View() - if !m.playbookList.IsFiltering() { - content += "\n\nPress 'n' to create new playbook" - } - } case modeCreateName: title = "CREATE PLAYBOOK" content = "Enter playbook name:\n\n" + m.newPlaybookName.View() case modeAddFormations: title = "ADD FORMATIONS" - // The SetSize will be handled by WindowSizeMsg, but we ensure it here too for the split m.formationList.SetSize(m.width/2-4, m.height-15) m.selectedFormationList.SetSize(m.width/2-4, m.height-15) diff --git a/internal/ui/page_locker_playbooks_list.go b/internal/ui/page_locker_playbooks_list.go new file mode 100644 index 0000000..fe4fad0 --- /dev/null +++ b/internal/ui/page_locker_playbooks_list.go @@ -0,0 +1,186 @@ +package ui + +import ( + "fmt" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/code-gorilla-au/rush/internal/playbooks" + "github.com/code-gorilla-au/rush/internal/ui/components" +) + +type lockerPlaybooksListKeyMap struct { + components.CommonKeys + Back key.Binding + Enter key.Binding + New key.Binding +} + +func (k lockerPlaybooksListKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Enter, k.New, k.Back, k.Quit} +} + +func (k lockerPlaybooksListKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Enter, k.New, k.Back, k.Quit}, + } +} + +func newLockerPlaybooksListKeyMap() lockerPlaybooksListKeyMap { + return lockerPlaybooksListKeyMap{ + CommonKeys: components.NewCommonKeys(), + Back: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select"), + ), + New: key.NewBinding( + key.WithKeys("n"), + key.WithHelp("n", "new playbook"), + ), + } +} + +type ModelLockerPlaybooksList struct { + width int + height int + theme IceTheme + globalState *GlobalState + playbookSvc *playbooks.Service + keys lockerPlaybooksListKeyMap + footer components.Footer + playbookList components.PlaybookList + playbooksLoaded bool + err error +} + +func NewModelLockerPlaybooksList(state *GlobalState, playbookSvc *playbooks.Service) *ModelLockerPlaybooksList { + return &ModelLockerPlaybooksList{ + theme: NewIceTheme(), + globalState: state, + playbookSvc: playbookSvc, + keys: newLockerPlaybooksListKeyMap(), + footer: components.NewFooter(newLockerPlaybooksListKeyMap()), + } +} + +func (m *ModelLockerPlaybooksList) Init() tea.Cmd { + return m.loadPlaybooks +} + +func (m *ModelLockerPlaybooksList) loadPlaybooks() tea.Msg { + if m.globalState.Team == nil { + return nil + } + items, err := m.playbookSvc.GetTeamPlaybooks(m.globalState.Context(), m.globalState.Team.ID) + if err != nil { + return err + } + return MsgPlaybooksLoaded{Playbooks: items} +} + +type MsgPlaybooksLoaded struct { + Playbooks []playbooks.Playbook +} + +func (m *ModelLockerPlaybooksList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case MsgStateUpdated: + m.globalState.Coach = msg.Coach + m.globalState.Team = msg.Team + case MsgPlaybooksLoaded: + m.playbooksLoaded = true + m.playbookList = components.NewPlaybookList(msg.Playbooks) + m.playbookList.SetSize(m.width, m.height-10) + case MsgSwitchPage: + if msg.NewPage == PageLockerPlaybooksList { + cmds = append(cmds, m.loadPlaybooks) + } + case error: + m.err = msg + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + case key.Matches(msg, m.keys.Back): + if m.playbookList.IsFiltering() { + break + } + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageLockerRoom} + } + case key.Matches(msg, m.keys.New): + if !m.playbookList.IsFiltering() { + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageLockerPlaybooksEdit} + } + } + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + if m.playbooksLoaded { + m.playbookList.SetSize(msg.Width, msg.Height-10) + } + m.footer.Update(msg) + } + + if m.playbooksLoaded { + var cmd tea.Cmd + m.playbookList, cmd = m.playbookList.Update(msg) + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +func (m *ModelLockerPlaybooksList) View() tea.View { + view := tea.NewView("") + view.AltScreen = true + + var content string + title := "PLAYBOOKS" + + if m.err != nil { + content = m.theme.Logo.Render(fmt.Sprintf("Error: %v", m.err)) + } else if !m.playbooksLoaded { + content = "Loading..." + } else { + if m.playbookList.Len() == 0 { + content = "No playbooks yet. Press 'n' to create one." + } else { + content = m.playbookList.View() + if !m.playbookList.IsFiltering() { + content += "\n\nPress 'n' to create new playbook" + } + } + } + + mainContent := lipgloss.JoinVertical( + lipgloss.Center, + m.theme.Logo.Render(title), + "", + content, + "", + m.footer.View(m.theme.Footer), + ) + + centeredContent := lipgloss.Place( + m.width, m.height, + lipgloss.Center, lipgloss.Center, + mainContent, + ) + + view.Content = m.theme.Base. + Width(m.width). + Height(m.height). + Render(centeredContent) + + return view +} diff --git a/internal/ui/page_locker_room.go b/internal/ui/page_locker_room.go index adcbc85..f13b081 100644 --- a/internal/ui/page_locker_room.go +++ b/internal/ui/page_locker_room.go @@ -86,7 +86,7 @@ func (m *ModelLockerRoom) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case components.ItemPlaybooks: return m, func() tea.Msg { - return MsgSwitchPage{NewPage: PageLockerPlaybooks} + return MsgSwitchPage{NewPage: PageLockerPlaybooksList} } } } diff --git a/internal/ui/page_locker_room_test.go b/internal/ui/page_locker_room_test.go index 18daed9..edd95d5 100644 --- a/internal/ui/page_locker_room_test.go +++ b/internal/ui/page_locker_room_test.go @@ -46,7 +46,7 @@ func TestModelLockerRoom_Selection(t *testing.T) { msg := cmd() switch v := msg.(type) { case MsgSwitchPage: - odize.AssertEqual(t, PageLockerPlaybooks, v.NewPage) + odize.AssertEqual(t, PageLockerPlaybooksList, v.NewPage) default: t.Fatalf("expected MsgSwitchPage, got %T", msg) } From 94b9ec32cdfc85b056e9d81c98421fa070f1eac7 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sun, 21 Jun 2026 20:29:41 +1000 Subject: [PATCH 50/98] update --- internal/database/playbook.sql.gen.go | 23 ++++++++---- internal/database/queries/playbook.sql | 6 ++-- internal/playbooks/interfaces.go | 2 +- internal/playbooks/service.go | 15 ++++---- internal/ui/app.go | 3 +- internal/ui/page_locker_playbooks_edit.go | 43 +++++++++++++++++++---- internal/ui/page_locker_playbooks_list.go | 13 +++++++ 7 files changed, 81 insertions(+), 24 deletions(-) diff --git a/internal/database/playbook.sql.gen.go b/internal/database/playbook.sql.gen.go index f8126d0..d1f45ae 100644 --- a/internal/database/playbook.sql.gen.go +++ b/internal/database/playbook.sql.gen.go @@ -93,20 +93,29 @@ func (q *Queries) GetPlaybooksByTeam(ctx context.Context, teamID sql.NullInt64) return items, nil } -const updatePlaybookFormations = `-- name: UpdatePlaybookFormations :one +const updatePlaybook = `-- name: UpdatePlaybook :one update playbooks -set formations = ? +set name = ?, + description = ?, + formations = ? where id = ? returning id, name, team_id, description, formations, created_at, updated_at ` -type UpdatePlaybookFormationsParams struct { - Formations interface{} - ID int64 +type UpdatePlaybookParams struct { + Name string + Description sql.NullString + Formations interface{} + ID int64 } -func (q *Queries) UpdatePlaybookFormations(ctx context.Context, arg UpdatePlaybookFormationsParams) (Playbook, error) { - row := q.db.QueryRowContext(ctx, updatePlaybookFormations, arg.Formations, arg.ID) +func (q *Queries) UpdatePlaybook(ctx context.Context, arg UpdatePlaybookParams) (Playbook, error) { + row := q.db.QueryRowContext(ctx, updatePlaybook, + arg.Name, + arg.Description, + arg.Formations, + arg.ID, + ) var i Playbook err := row.Scan( &i.ID, diff --git a/internal/database/queries/playbook.sql b/internal/database/queries/playbook.sql index 5ce8164..ca63db1 100644 --- a/internal/database/queries/playbook.sql +++ b/internal/database/queries/playbook.sql @@ -9,9 +9,11 @@ VALUES (?, ?) RETURNING *; --- name: UpdatePlaybookFormations :one +-- name: UpdatePlaybook :one update playbooks -set formations = ? +set name = ?, + description = ?, + formations = ? where id = ? returning *; diff --git a/internal/playbooks/interfaces.go b/internal/playbooks/interfaces.go index 862b3c2..2c80399 100644 --- a/internal/playbooks/interfaces.go +++ b/internal/playbooks/interfaces.go @@ -11,5 +11,5 @@ type Store interface { CreatePlaybook(ctx context.Context, arg database.CreatePlaybookParams) (database.Playbook, error) DeletePlaybook(ctx context.Context, id int64) error GetPlaybooksByTeam(ctx context.Context, teamID sql.NullInt64) ([]database.Playbook, error) - UpdatePlaybookFormations(ctx context.Context, arg database.UpdatePlaybookFormationsParams) (database.Playbook, error) + UpdatePlaybook(ctx context.Context, arg database.UpdatePlaybookParams) (database.Playbook, error) } diff --git a/internal/playbooks/service.go b/internal/playbooks/service.go index 73a6e35..89bdcbc 100644 --- a/internal/playbooks/service.go +++ b/internal/playbooks/service.go @@ -66,19 +66,22 @@ func (s *Service) DeletePlaybook(ctx context.Context, id int64) error { return s.store.DeletePlaybook(ctx, id) } -func (s *Service) UpdatePlaybookFormations(ctx context.Context, id int64, formations []Formation) (Playbook, error) { - data, err := json.Marshal(formations) +func (s *Service) UpdatePlaybook(ctx context.Context, id int64, params PlaybookParams) (Playbook, error) { + data, err := json.Marshal(params.Formations) if err != nil { return Playbook{}, fmt.Errorf("failed to marshal formations: %w", err) } - - model, err := s.store.UpdatePlaybookFormations(ctx, database.UpdatePlaybookFormationsParams{ - ID: id, + model, err := s.store.UpdatePlaybook(ctx, database.UpdatePlaybookParams{ + ID: id, + Name: params.Name, + Description: sql.NullString{ + String: params.Description, + Valid: true, + }, Formations: data, }) if err != nil { return Playbook{}, err } - return fromPlaybookModel(model) } diff --git a/internal/ui/app.go b/internal/ui/app.go index c8973a5..c7e5093 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -14,7 +14,8 @@ type MsgStateUpdated struct { } type MsgSwitchPage struct { - NewPage Page + NewPage Page + Playbook *playbooks.Playbook } type Page int diff --git a/internal/ui/page_locker_playbooks_edit.go b/internal/ui/page_locker_playbooks_edit.go index 80319f0..65882a1 100644 --- a/internal/ui/page_locker_playbooks_edit.go +++ b/internal/ui/page_locker_playbooks_edit.go @@ -65,6 +65,7 @@ type ModelLockerPlaybooksEdit struct { selectedFormationList components.FormationList mode editPlaybookMode activeList int // 0 for formationList, 1 for selectedFormationList + playbookID int64 newPlaybookName textinput.Model newFormations []playbooks.Formation err error @@ -110,7 +111,11 @@ func (m *ModelLockerPlaybooksEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.globalState.Team = msg.Team case MsgSwitchPage: if msg.NewPage == PageLockerPlaybooksEdit { - m.reset() + if msg.Playbook != nil { + m.load(msg.Playbook) + } else { + m.reset() + } } case error: m.err = msg @@ -154,11 +159,22 @@ func (m *ModelLockerPlaybooksEdit) reset() { m.newPlaybookName.Reset() m.newPlaybookName.Focus() m.newFormations = nil + m.playbookID = 0 m.mode = modeCreateName m.selectedFormationList.SetItems(nil) m.err = nil } +func (m *ModelLockerPlaybooksEdit) load(p *playbooks.Playbook) { + m.newPlaybookName.SetValue(p.Name) + m.newPlaybookName.Focus() + m.newFormations = p.Formations + m.playbookID = p.ID + m.mode = modeCreateName + m.selectedFormationList.SetItems(m.newFormations) + m.err = nil +} + func (m *ModelLockerPlaybooksEdit) updateCreateName(msg tea.Msg) tea.Cmd { switch msg := msg.(type) { case tea.KeyMsg: @@ -226,11 +242,20 @@ func (m *ModelLockerPlaybooksEdit) updateAddFormations(msg tea.Msg) tea.Cmd { } func (m *ModelLockerPlaybooksEdit) savePlaybook() tea.Msg { - _, err := m.playbookSvc.CreatePlaybook(m.globalState.Context(), playbooks.PlaybookParams{ - TeamID: m.globalState.Team.ID, - Name: m.newPlaybookName.Value(), - Formations: m.newFormations, - }) + var err error + if m.playbookID != 0 { + _, err = m.playbookSvc.UpdatePlaybook(m.globalState.Context(), m.playbookID, playbooks.PlaybookParams{ + TeamID: m.globalState.Team.ID, + Name: m.newPlaybookName.Value(), + Formations: m.newFormations, + }) + } else { + _, err = m.playbookSvc.CreatePlaybook(m.globalState.Context(), playbooks.PlaybookParams{ + TeamID: m.globalState.Team.ID, + Name: m.newPlaybookName.Value(), + Formations: m.newFormations, + }) + } if err != nil { return err } @@ -249,7 +274,11 @@ func (m *ModelLockerPlaybooksEdit) View() tea.View { } else { switch m.mode { case modeCreateName: - title = "CREATE PLAYBOOK" + if m.playbookID != 0 { + title = "EDIT PLAYBOOK" + } else { + title = "CREATE PLAYBOOK" + } content = "Enter playbook name:\n\n" + m.newPlaybookName.View() case modeAddFormations: title = "ADD FORMATIONS" diff --git a/internal/ui/page_locker_playbooks_list.go b/internal/ui/page_locker_playbooks_list.go index fe4fad0..3dfb764 100644 --- a/internal/ui/page_locker_playbooks_list.go +++ b/internal/ui/page_locker_playbooks_list.go @@ -115,6 +115,19 @@ func (m *ModelLockerPlaybooksList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, func() tea.Msg { return MsgSwitchPage{NewPage: PageLockerRoom} } + case key.Matches(msg, m.keys.Enter): + if m.playbookList.IsFiltering() { + break + } + selected := m.playbookList.SelectedItem() + if selected != nil { + return m, func() tea.Msg { + return MsgSwitchPage{ + NewPage: PageLockerPlaybooksEdit, + Playbook: selected, + } + } + } case key.Matches(msg, m.keys.New): if !m.playbookList.IsFiltering() { return m, func() tea.Msg { From 24d8beff8ddbdccaa814a716889fe90235eef292 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sun, 21 Jun 2026 20:44:05 +1000 Subject: [PATCH 51/98] wip adding --- internal/ui/page_locker_playbooks_list.go | 57 +++++++++++++++++------ 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/internal/ui/page_locker_playbooks_list.go b/internal/ui/page_locker_playbooks_list.go index 3dfb764..38df5b4 100644 --- a/internal/ui/page_locker_playbooks_list.go +++ b/internal/ui/page_locker_playbooks_list.go @@ -12,9 +12,10 @@ import ( type lockerPlaybooksListKeyMap struct { components.CommonKeys - Back key.Binding - Enter key.Binding - New key.Binding + Back key.Binding + Enter key.Binding + New key.Binding + Delete key.Binding } func (k lockerPlaybooksListKeyMap) ShortHelp() []key.Binding { @@ -23,7 +24,7 @@ func (k lockerPlaybooksListKeyMap) ShortHelp() []key.Binding { func (k lockerPlaybooksListKeyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ - {k.Enter, k.New, k.Back, k.Quit}, + {k.Enter, k.New, k.Delete, k.Back, k.Quit}, } } @@ -42,6 +43,9 @@ func newLockerPlaybooksListKeyMap() lockerPlaybooksListKeyMap { key.WithKeys("n"), key.WithHelp("n", "new playbook"), ), + Delete: key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "delete")), } } @@ -116,17 +120,9 @@ func (m *ModelLockerPlaybooksList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return MsgSwitchPage{NewPage: PageLockerRoom} } case key.Matches(msg, m.keys.Enter): - if m.playbookList.IsFiltering() { - break - } - selected := m.playbookList.SelectedItem() - if selected != nil { - return m, func() tea.Msg { - return MsgSwitchPage{ - NewPage: PageLockerPlaybooksEdit, - Playbook: selected, - } - } + model, cmd, done := m.handleRouteEditPlaybook() + if done { + return model, cmd } case key.Matches(msg, m.keys.New): if !m.playbookList.IsFiltering() { @@ -134,6 +130,8 @@ func (m *ModelLockerPlaybooksList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return MsgSwitchPage{NewPage: PageLockerPlaybooksEdit} } } + case key.Matches(msg, m.keys.Delete): + m.handleDeletePlaybook() } case tea.WindowSizeMsg: m.width = msg.Width @@ -153,6 +151,35 @@ func (m *ModelLockerPlaybooksList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } +func (m *ModelLockerPlaybooksList) handleRouteEditPlaybook() (tea.Model, tea.Cmd, bool) { + if m.playbookList.IsFiltering() { + return nil, nil, false + } + + selected := m.playbookList.SelectedItem() + if selected != nil { + return m, func() tea.Msg { + return MsgSwitchPage{ + NewPage: PageLockerPlaybooksEdit, + Playbook: selected, + } + }, true + } + + return nil, nil, false +} + +func (m *ModelLockerPlaybooksList) handleDeletePlaybook() { + if m.playbookList.IsFiltering() { + return + } + + selected := m.playbookList.SelectedItem() + if err := m.playbookSvc.DeletePlaybook(m.globalState.Context(), selected.ID); err != nil { + m.err = err + } +} + func (m *ModelLockerPlaybooksList) View() tea.View { view := tea.NewView("") view.AltScreen = true From 7f11ac95bd80aa6e09b8e38ee1ab79e2fc408afe Mon Sep 17 00:00:00 2001 From: frag223 Date: Sun, 21 Jun 2026 20:52:34 +1000 Subject: [PATCH 52/98] wip working playbooks --- internal/ui/page_locker_playbooks_list.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/internal/ui/page_locker_playbooks_list.go b/internal/ui/page_locker_playbooks_list.go index 38df5b4..6fd7905 100644 --- a/internal/ui/page_locker_playbooks_list.go +++ b/internal/ui/page_locker_playbooks_list.go @@ -131,7 +131,10 @@ func (m *ModelLockerPlaybooksList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } case key.Matches(msg, m.keys.Delete): - m.handleDeletePlaybook() + model, cmd, done := m.handleDeletePlaybook() + if done { + return model, cmd + } } case tea.WindowSizeMsg: m.width = msg.Width @@ -169,15 +172,21 @@ func (m *ModelLockerPlaybooksList) handleRouteEditPlaybook() (tea.Model, tea.Cmd return nil, nil, false } -func (m *ModelLockerPlaybooksList) handleDeletePlaybook() { +func (m *ModelLockerPlaybooksList) handleDeletePlaybook() (tea.Model, tea.Cmd, bool) { if m.playbookList.IsFiltering() { - return + return nil, nil, false } selected := m.playbookList.SelectedItem() + if selected == nil { + return nil, nil, false + } + if err := m.playbookSvc.DeletePlaybook(m.globalState.Context(), selected.ID); err != nil { m.err = err } + + return m, m.loadPlaybooks, true } func (m *ModelLockerPlaybooksList) View() tea.View { From da0cceb091779d2e182418f81a3995b37bf969ac Mon Sep 17 00:00:00 2001 From: frag223 Date: Sun, 21 Jun 2026 20:58:30 +1000 Subject: [PATCH 53/98] wip working playbooks --- docs/rules.md | 8 ++++++++ internal/ui/app.go | 1 + 2 files changed, 9 insertions(+) create mode 100644 docs/rules.md diff --git a/docs/rules.md b/docs/rules.md new file mode 100644 index 0000000..0a46354 --- /dev/null +++ b/docs/rules.md @@ -0,0 +1,8 @@ +# Rules + +Game rules. + +## Game + +- 10 rounds +- Player chosen at random to go first, at round 5, players swap over \ No newline at end of file diff --git a/internal/ui/app.go b/internal/ui/app.go index c7e5093..bebfeca 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -120,6 +120,7 @@ func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.pageLockerPlaybooksList, cmd = m.pageLockerPlaybooksList.Update(msg) cmds = append(cmds, cmd) m.pageLockerPlaybooksEdit, cmd = m.pageLockerPlaybooksEdit.Update(msg) + cmds = append(cmds, cmd) } var cmd tea.Cmd From 8501d97e7dcdfd07567381a8daf20a92244e9b57 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sun, 21 Jun 2026 21:12:25 +1000 Subject: [PATCH 54/98] update rules --- docs/rules.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/rules.md b/docs/rules.md index 0a46354..1b0fd05 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -4,5 +4,10 @@ Game rules. ## Game -- 10 rounds -- Player chosen at random to go first, at round 5, players swap over \ No newline at end of file +- The game is ten rounds. +- Coaches chosen at random to go first; at round 5, coaches swap over. +- Players in the same lane dual and roll a 1d6 die. Highest score wins. +- Loosing player is eliminated. Lane duals continue until no opposing players remain in the lane. +- Round is considered when there are no opposing players remaining in all three lanes. +- Players remaining in all three lanes score 1 point for the round. +- After ten rounds, the coach with the most points wins. \ No newline at end of file From 064e4585e1a012787d32d553a43bf4574a577a8d Mon Sep 17 00:00:00 2001 From: frag223 Date: Sun, 21 Jun 2026 21:53:29 +1000 Subject: [PATCH 55/98] wip generating AI service --- go.mod | 2 + go.sum | 6 +++ internal/tournament/teams.go | 76 ++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 internal/tournament/teams.go diff --git a/go.mod b/go.mod index 2118b98..b8afe88 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-faker/faker/v4 v4.8.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/joho/godotenv v1.5.1 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect @@ -35,6 +36,7 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 3a5814b..6099983 100644 --- a/go.sum +++ b/go.sum @@ -32,6 +32,8 @@ github.com/code-gorilla-au/odize v1.3.5 h1:Bjb0c1NXRkbEppsCs2PSN4DHWy3yWIggTXdro github.com/code-gorilla-au/odize v1.3.5/go.mod h1:+PtShsIEca9bAfxltU00OVD75aR5NvtkpOW/HGHdi9w= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-faker/faker/v4 v4.8.0 h1:QAKmb4TyAMxIgf3a8fXubQXwFFJviBGL2/IDa34N6JQ= +github.com/go-faker/faker/v4 v4.8.0/go.mod h1:u1dIRP5neLB6kTzgyVjdBOV5R1uP7BdxkcWk7tiKQXk= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -64,13 +66,17 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= diff --git a/internal/tournament/teams.go b/internal/tournament/teams.go new file mode 100644 index 0000000..756c6d2 --- /dev/null +++ b/internal/tournament/teams.go @@ -0,0 +1,76 @@ +package tournament + +import ( + "context" + "fmt" + + "github.com/code-gorilla-au/rush/internal/playbooks" + "github.com/code-gorilla-au/rush/internal/teams" + "github.com/go-faker/faker/v4" +) + +const totalTeams = 12 + +type AITeam struct { + CoachName string `faker:"name"` + TeamName string `faker:"username"` +} + +type AITeamService struct { + teamsSvc *teams.Service + playbooksSvc *playbooks.Service +} + +func (s *AITeamService) GenerateTeams(ctx context.Context) error { + aiTeams, err := generateAITeams() + if err != nil { + return fmt.Errorf("generating AI teams: %w", err) + } + + for _, team := range aiTeams { + if err = s.generateTeam(ctx, team); err != nil { + return fmt.Errorf("generating team: %w", err) + } + } + + return nil +} + +func (s *AITeamService) generateTeam(ctx context.Context, team AITeam) error { + coach, err := s.teamsSvc.CreateCoach(ctx, team.CoachName, false) + if err != nil { + return fmt.Errorf("creating AI coach: %w", err) + } + + aiTeam, err := s.teamsSvc.CreateTeam(nil, team.TeamName, coach.ID, false) + if err != nil { + return fmt.Errorf("creating team: %w", err) + } + + if _, err = s.playbooksSvc.CreatePlaybook(ctx, playbooks.PlaybookParams{ + TeamID: aiTeam.ID, + Name: fmt.Sprintf("%s Playbook", aiTeam.Name), + Description: "AI generated playbook", + Formations: []playbooks.Formation{}, + }); err != nil { + return fmt.Errorf("creating AI playbook: %w", err) + } + + return nil +} + +func generateAITeams() ([]AITeam, error) { + aiTeams := make([]AITeam, totalTeams) + + for i := 0; i < totalTeams; i++ { + tmpTeam := AITeam{} + + if err := faker.FakeData(&tmpTeam); err != nil { + return nil, fmt.Errorf("generating team: %w", err) + } + + aiTeams[i] = tmpTeam + } + + return aiTeams, nil +} From 4cda7c132b541d014c0775fd4ffda09478fed462 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sun, 21 Jun 2026 21:59:25 +1000 Subject: [PATCH 56/98] fixing + refactoring --- internal/database/queries/team.sql | 2 +- internal/database/team.sql.gen.go | 5 +++-- internal/playbooks/service_test.go | 5 ++++- internal/teams/service.go | 16 +++++++++++--- internal/teams/service_test.go | 35 ++++++++++++++++++++++++------ internal/tournament/teams.go | 6 ++++- internal/ui/page_create_coach.go | 6 ++++- 7 files changed, 59 insertions(+), 16 deletions(-) diff --git a/internal/database/queries/team.sql b/internal/database/queries/team.sql index 0171996..bc338a2 100644 --- a/internal/database/queries/team.sql +++ b/internal/database/queries/team.sql @@ -14,7 +14,7 @@ UPDATE coaches SET is_default = false WHERE is_default = true; SELECT * FROM coaches; -- name: CreateCoach :one -INSERT INTO coaches (name, is_default) VALUES (?, ?) RETURNING *; +INSERT INTO coaches (name, is_human, is_default) VALUES (?, ?, ?) RETURNING *; -- name: DeleteCoach :exec DELETE FROM coaches WHERE id = ?; diff --git a/internal/database/team.sql.gen.go b/internal/database/team.sql.gen.go index a07934b..558a4aa 100644 --- a/internal/database/team.sql.gen.go +++ b/internal/database/team.sql.gen.go @@ -29,16 +29,17 @@ func (q *Queries) ClearDefaultTeam(ctx context.Context) error { } const createCoach = `-- name: CreateCoach :one -INSERT INTO coaches (name, is_default) VALUES (?, ?) RETURNING id, name, is_default, is_human, created_at, updated_at +INSERT INTO coaches (name, is_human, is_default) VALUES (?, ?, ?) RETURNING id, name, is_default, is_human, created_at, updated_at ` type CreateCoachParams struct { Name string + IsHuman sql.NullBool IsDefault sql.NullBool } func (q *Queries) CreateCoach(ctx context.Context, arg CreateCoachParams) (Coach, error) { - row := q.db.QueryRowContext(ctx, createCoach, arg.Name, arg.IsDefault) + row := q.db.QueryRowContext(ctx, createCoach, arg.Name, arg.IsHuman, arg.IsDefault) var i Coach err := row.Scan( &i.ID, diff --git a/internal/playbooks/service_test.go b/internal/playbooks/service_test.go index a2834c4..9c73ad2 100644 --- a/internal/playbooks/service_test.go +++ b/internal/playbooks/service_test.go @@ -133,7 +133,10 @@ func TestService(t *testing.T) { }, } - updatedPb, err := s.UpdatePlaybookFormations(ctx, pb.ID, newFormations) + updatedPb, err := s.UpdatePlaybook(ctx, pb.ID, PlaybookParams{ + Name: pb.Name, + Formations: newFormations, + }) odize.AssertNoError(t, err) odize.AssertEqual(t, pb.ID, updatedPb.ID) odize.AssertEqual(t, 1, len(updatedPb.Formations)) diff --git a/internal/teams/service.go b/internal/teams/service.go index 03e94e0..4d768bf 100644 --- a/internal/teams/service.go +++ b/internal/teams/service.go @@ -17,11 +17,21 @@ func NewTeamsService(store Store) *Service { return &Service{store: store} } -func (s *Service) CreateCoach(ctx context.Context, name string, isDefault bool) (Coach, error) { +type CreateCoachParams struct { + Name string + IsHuman bool + IsDefault bool +} + +func (s *Service) CreateCoach(ctx context.Context, params CreateCoachParams) (Coach, error) { model, err := s.store.CreateCoach(ctx, database.CreateCoachParams{ - Name: name, + Name: params.Name, + IsHuman: sql.NullBool{ + Bool: params.IsHuman, + Valid: true, + }, IsDefault: sql.NullBool{ - Bool: isDefault, + Bool: params.IsDefault, Valid: true, }, }) diff --git a/internal/teams/service_test.go b/internal/teams/service_test.go index eac2c29..8316bd4 100644 --- a/internal/teams/service_test.go +++ b/internal/teams/service_test.go @@ -48,7 +48,10 @@ func TestService(t *testing.T) { ctx := t.Context() name := "Coach Carter" - coach, err := s.CreateCoach(ctx, name, false) + coach, err := s.CreateCoach(ctx, CreateCoachParams{ + Name: name, + IsHuman: false, + }) odize.AssertNoError(t, err) odize.AssertTrue(t, coach.ID > 0) odize.AssertEqual(t, name, coach.Name) @@ -59,7 +62,10 @@ func TestService(t *testing.T) { ctx := t.Context() name := "Coach Carter" - coach, err := s.CreateCoach(ctx, name, false) + coach, err := s.CreateCoach(ctx, CreateCoachParams{ + Name: name, + IsHuman: false, + }) odize.AssertNoError(t, err) err = s.SetDefaultCoach(ctx, coach.ID) @@ -76,7 +82,10 @@ func TestService(t *testing.T) { ctx := t.Context() name := "Coach Carter" - _, err := s.CreateCoach(ctx, name, true) + _, err := s.CreateCoach(ctx, CreateCoachParams{ + Name: name, + IsDefault: true, + }) odize.AssertNoError(t, err) err = s.ClearDefaultCoach(ctx) @@ -132,7 +141,10 @@ func TestService(t *testing.T) { Test("GetDefaultCoach should return the default coach", func(t *testing.T) { ctx := t.Context() name := "Coach Carter" - _, err := s.CreateCoach(ctx, name, true) + _, err := s.CreateCoach(ctx, CreateCoachParams{ + Name: name, + IsDefault: true, + }) odize.AssertNoError(t, err) coach, err := s.GetDefaultCoach(ctx) @@ -146,7 +158,10 @@ func TestService(t *testing.T) { }). Test("UpdatePlayer should update player name", func(t *testing.T) { ctx := t.Context() - coach, err := s.CreateCoach(ctx, "Coach", true) + coach, err := s.CreateCoach(ctx, CreateCoachParams{ + Name: "Coach", + IsDefault: true, + }) odize.AssertNoError(t, err) team, err := s.CreateTeam(ctx, "Team", coach.ID, true) @@ -175,7 +190,10 @@ func TestService(t *testing.T) { }). Test("GetTeamByCoachID should return team and players", func(t *testing.T) { ctx := t.Context() - coach, err := s.CreateCoach(ctx, "Coach", true) + coach, err := s.CreateCoach(ctx, CreateCoachParams{ + Name: "Coach", + IsDefault: true, + }) odize.AssertNoError(t, err) _, err = s.CreateTeam(ctx, "The Bulls", coach.ID, true) @@ -193,7 +211,10 @@ func TestService(t *testing.T) { }). Test("CreateTeam should create a team with default players", func(t *testing.T) { ctx := t.Context() - coach, err := s.CreateCoach(ctx, "Coach", false) + coach, err := s.CreateCoach(ctx, CreateCoachParams{ + Name: "Coach", + IsHuman: false, + }) odize.AssertNoError(t, err) team, err := s.CreateTeam(ctx, "Lakers", coach.ID, true) diff --git a/internal/tournament/teams.go b/internal/tournament/teams.go index 756c6d2..ec7a1a4 100644 --- a/internal/tournament/teams.go +++ b/internal/tournament/teams.go @@ -37,7 +37,11 @@ func (s *AITeamService) GenerateTeams(ctx context.Context) error { } func (s *AITeamService) generateTeam(ctx context.Context, team AITeam) error { - coach, err := s.teamsSvc.CreateCoach(ctx, team.CoachName, false) + coach, err := s.teamsSvc.CreateCoach(ctx, teams.CreateCoachParams{ + Name: team.CoachName, + IsHuman: false, + IsDefault: false, + }) if err != nil { return fmt.Errorf("creating AI coach: %w", err) } diff --git a/internal/ui/page_create_coach.go b/internal/ui/page_create_coach.go index 269be98..5c6e9bd 100644 --- a/internal/ui/page_create_coach.go +++ b/internal/ui/page_create_coach.go @@ -197,7 +197,11 @@ func (m *ModelCreateCoach) updateFocus() { func (m *ModelCreateCoach) submit() tea.Cmd { return func() tea.Msg { ctx := m.globalState.Context() - coach, err := m.teamsSvc.CreateCoach(ctx, m.coachInput.Value(), true) + coach, err := m.teamsSvc.CreateCoach(ctx, teams.CreateCoachParams{ + Name: m.coachInput.Value(), + IsHuman: true, + IsDefault: true, + }) if err != nil { return err } From c4ecfe1dd9e9461f2f131db79239dbbd53a7ddbf Mon Sep 17 00:00:00 2001 From: frag223 Date: Sun, 21 Jun 2026 22:10:55 +1000 Subject: [PATCH 57/98] clean up --- internal/playbooks/service.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/playbooks/service.go b/internal/playbooks/service.go index 89bdcbc..9067b45 100644 --- a/internal/playbooks/service.go +++ b/internal/playbooks/service.go @@ -83,5 +83,6 @@ func (s *Service) UpdatePlaybook(ctx context.Context, id int64, params PlaybookP if err != nil { return Playbook{}, err } + return fromPlaybookModel(model) } From 4add609dce17a877f773978da1c4d4af81856533 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sun, 21 Jun 2026 22:24:16 +1000 Subject: [PATCH 58/98] playbooks generated --- internal/tournament/teams.go | 128 +++++++++++++++++++++++++++++- internal/tournament/teams_test.go | 30 +++++++ 2 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 internal/tournament/teams_test.go diff --git a/internal/tournament/teams.go b/internal/tournament/teams.go index ec7a1a4..c7a7991 100644 --- a/internal/tournament/teams.go +++ b/internal/tournament/teams.go @@ -12,8 +12,112 @@ import ( const totalTeams = 12 type AITeam struct { - CoachName string `faker:"name"` - TeamName string `faker:"username"` + CoachName string `faker:"name"` + TeamName string `faker:"username"` + Persona string + Formations []playbooks.Formation +} + +var personas = []struct { + Name string + FormationNames []string +}{ + { + Name: "The Wall - A defensive specialist that focuses on protecting all lanes equally.", + FormationNames: []string{ + "balanced-right", "balanced-left", "split-balanced", + "strong-centre", "strong-right", "strong-left", + "overload-centre-left", "overload-centre-right", "balanced-right", "balanced-left", + }, + }, + { + Name: "Blitzkrieg - Aggressive tactics focusing on overwhelming the opponent through the center.", + FormationNames: []string{ + "strong-centre", "single-lane-centre", "overload-centre-left", + "overload-centre-right", "balanced-left", "balanced-right", + "strong-centre", "single-lane-centre", "strong-centre", "split-balanced", + }, + }, + { + Name: "Flank Specialist - Prefers to attack from the edges, leaving the middle open.", + FormationNames: []string{ + "split-right", "split-left", "overload-right", + "overload-left", "split-balanced", "balanced-right", + "balanced-left", "split-right", "split-left", "overload-right", + }, + }, + { + Name: "Right Side Powerhouse - Concentrates most of the strength on the right flank.", + FormationNames: []string{ + "strong-right", "overload-right", "single-lane-right", + "balanced-right", "overload-centre-right", "split-right", + "strong-right", "overload-right", "single-lane-right", "balanced-right", + }, + }, + { + Name: "Left Side Powerhouse - Concentrates most of the strength on the left flank.", + FormationNames: []string{ + "strong-left", "overload-left", "single-lane-left", + "balanced-left", "overload-centre-left", "split-left", + "strong-left", "overload-left", "single-lane-left", "balanced-left", + }, + }, + { + Name: "The Juggernaut - Heavy focus on one single lane to break through.", + FormationNames: []string{ + "single-lane-left", "single-lane-centre", "single-lane-right", + "overload-left", "overload-right", "strong-centre", + "single-lane-left", "single-lane-centre", "single-lane-right", "split-balanced", + }, + }, + { + Name: "Balanced Strategist - Adapts to the game with a mix of balanced formations.", + FormationNames: []string{ + "balanced-right", "balanced-left", "split-balanced", + "strong-centre", "strong-right", "strong-left", + "split-right", "split-left", "balanced-right", "balanced-left", + }, + }, + { + Name: "Chaos Theory - Uses unpredictable and highly skewed formations.", + FormationNames: []string{ + "overload-centre-left", "overload-centre-right", "split-right", + "split-left", "overload-left", "overload-right", + "single-lane-left", "single-lane-right", "overload-centre-left", "overload-centre-right", + }, + }, + { + Name: "The Shield - Focuses on heavy central defense and slight flank pressure.", + FormationNames: []string{ + "strong-centre", "overload-centre-left", "overload-centre-right", + "balanced-right", "balanced-left", "split-balanced", + "strong-centre", "overload-centre-left", "overload-centre-right", "strong-centre", + }, + }, + { + Name: "Centrist - Always stays in the middle, daring the opponent to go around.", + FormationNames: []string{ + "strong-centre", "single-lane-centre", "balanced-right", + "balanced-left", "overload-centre-left", "overload-centre-right", + "strong-centre", "single-lane-centre", "strong-centre", "single-lane-centre", + }, + }, + { + Name: "Dual Flanker - Strong presence on both left and right, ignoring the center.", + FormationNames: []string{ + "split-balanced", "split-right", "split-left", + "overload-right", "overload-left", "balanced-right", + "balanced-left", "split-balanced", "split-right", "split-left", + }, + }, + { + Name: "Overload Master - Specializes in shifting maximum weight to one side or the other.", + FormationNames: []string{ + "overload-right", "overload-left", "overload-centre-left", + "overload-centre-right", "single-lane-left", "single-lane-right", + "overload-right", "overload-left", "overload-centre-left", "overload-centre-right", + }, + }, } type AITeamService struct { @@ -54,8 +158,8 @@ func (s *AITeamService) generateTeam(ctx context.Context, team AITeam) error { if _, err = s.playbooksSvc.CreatePlaybook(ctx, playbooks.PlaybookParams{ TeamID: aiTeam.ID, Name: fmt.Sprintf("%s Playbook", aiTeam.Name), - Description: "AI generated playbook", - Formations: []playbooks.Formation{}, + Description: team.Persona, + Formations: team.Formations, }); err != nil { return fmt.Errorf("creating AI playbook: %w", err) } @@ -65,6 +169,11 @@ func (s *AITeamService) generateTeam(ctx context.Context, team AITeam) error { func generateAITeams() ([]AITeam, error) { aiTeams := make([]AITeam, totalTeams) + allFormations := playbooks.Formations() + formationMap := make(map[string]playbooks.Formation) + for _, f := range allFormations { + formationMap[f.Name] = f + } for i := 0; i < totalTeams; i++ { tmpTeam := AITeam{} @@ -73,6 +182,17 @@ func generateAITeams() ([]AITeam, error) { return nil, fmt.Errorf("generating team: %w", err) } + persona := personas[i%len(personas)] + tmpTeam.Persona = persona.Name + + teamFormations := make([]playbooks.Formation, 0, len(persona.FormationNames)) + for _, name := range persona.FormationNames { + if f, ok := formationMap[name]; ok { + teamFormations = append(teamFormations, f) + } + } + tmpTeam.Formations = teamFormations + aiTeams[i] = tmpTeam } diff --git a/internal/tournament/teams_test.go b/internal/tournament/teams_test.go new file mode 100644 index 0000000..9c34cee --- /dev/null +++ b/internal/tournament/teams_test.go @@ -0,0 +1,30 @@ +package tournament + +import ( + "testing" + + "github.com/code-gorilla-au/odize" +) + +func TestGenerateAITeams(t *testing.T) { + group := odize.NewGroup(t, nil) + + err := group. + Test("should generate exactly 12 teams with personas and formations", func(t *testing.T) { + teams, err := generateAITeams() + odize.AssertNoError(t, err) + odize.AssertTrue(t, len(teams) == 12) + + for i, team := range teams { + odize.AssertTrue(t, team.Persona != "") + odize.AssertTrue(t, len(team.Formations) == 10) + + // Verify personas are assigned in order + expectedPersona := personas[i%len(personas)].Name + odize.AssertTrue(t, team.Persona == expectedPersona) + } + }). + Run() + + odize.AssertNoError(t, err) +} From 0d74bf49042c9c3aafb7b677a6105323cfcb4359 Mon Sep 17 00:00:00 2001 From: frag223 Date: Mon, 22 Jun 2026 20:07:03 +1000 Subject: [PATCH 59/98] adding interface --- internal/tournament/interfaces.go | 17 +++++++++++++++++ internal/tournament/teams.go | 5 +++-- 2 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 internal/tournament/interfaces.go diff --git a/internal/tournament/interfaces.go b/internal/tournament/interfaces.go new file mode 100644 index 0000000..941051a --- /dev/null +++ b/internal/tournament/interfaces.go @@ -0,0 +1,17 @@ +package tournament + +import ( + "context" + + "github.com/code-gorilla-au/rush/internal/playbooks" + "github.com/code-gorilla-au/rush/internal/teams" +) + +type TeamCreator interface { + CreateCoach(ctx context.Context, params teams.CreateCoachParams) (teams.Coach, error) + CreateTeam(ctx context.Context, name string, coachID int64, isDefault bool) (teams.Team, error) +} + +type PlaybookCreator interface { + CreatePlaybook(ctx context.Context, params playbooks.PlaybookParams) (playbooks.Playbook, error) +} diff --git a/internal/tournament/teams.go b/internal/tournament/teams.go index c7a7991..59da245 100644 --- a/internal/tournament/teams.go +++ b/internal/tournament/teams.go @@ -121,8 +121,8 @@ var personas = []struct { } type AITeamService struct { - teamsSvc *teams.Service - playbooksSvc *playbooks.Service + teamsSvc TeamCreator + playbooksSvc PlaybookCreator } func (s *AITeamService) GenerateTeams(ctx context.Context) error { @@ -191,6 +191,7 @@ func generateAITeams() ([]AITeam, error) { teamFormations = append(teamFormations, f) } } + tmpTeam.Formations = teamFormations aiTeams[i] = tmpTeam From 106825d2fb48ad07b032ea13f37c32c8844b9e77 Mon Sep 17 00:00:00 2001 From: frag223 Date: Mon, 22 Jun 2026 20:12:23 +1000 Subject: [PATCH 60/98] update tests --- internal/tournament/teams.go | 2 +- internal/tournament/teams_test.go | 114 +++++++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 4 deletions(-) diff --git a/internal/tournament/teams.go b/internal/tournament/teams.go index 59da245..c6d21f6 100644 --- a/internal/tournament/teams.go +++ b/internal/tournament/teams.go @@ -150,7 +150,7 @@ func (s *AITeamService) generateTeam(ctx context.Context, team AITeam) error { return fmt.Errorf("creating AI coach: %w", err) } - aiTeam, err := s.teamsSvc.CreateTeam(nil, team.TeamName, coach.ID, false) + aiTeam, err := s.teamsSvc.CreateTeam(ctx, team.TeamName, coach.ID, false) if err != nil { return fmt.Errorf("creating team: %w", err) } diff --git a/internal/tournament/teams_test.go b/internal/tournament/teams_test.go index 9c34cee..bcb02ae 100644 --- a/internal/tournament/teams_test.go +++ b/internal/tournament/teams_test.go @@ -1,21 +1,46 @@ package tournament import ( + "context" + "errors" "testing" "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/playbooks" + "github.com/code-gorilla-au/rush/internal/teams" ) +type mockTeamCreator struct { + createCoachFunc func(ctx context.Context, params teams.CreateCoachParams) (teams.Coach, error) + createTeamFunc func(ctx context.Context, name string, coachID int64, isDefault bool) (teams.Team, error) +} + +func (m *mockTeamCreator) CreateCoach(ctx context.Context, params teams.CreateCoachParams) (teams.Coach, error) { + return m.createCoachFunc(ctx, params) +} + +func (m *mockTeamCreator) CreateTeam(ctx context.Context, name string, coachID int64, isDefault bool) (teams.Team, error) { + return m.createTeamFunc(ctx, name, coachID, isDefault) +} + +type mockPlaybookCreator struct { + createPlaybookFunc func(ctx context.Context, params playbooks.PlaybookParams) (playbooks.Playbook, error) +} + +func (m *mockPlaybookCreator) CreatePlaybook(ctx context.Context, params playbooks.PlaybookParams) (playbooks.Playbook, error) { + return m.createPlaybookFunc(ctx, params) +} + func TestGenerateAITeams(t *testing.T) { group := odize.NewGroup(t, nil) err := group. Test("should generate exactly 12 teams with personas and formations", func(t *testing.T) { - teams, err := generateAITeams() + _teams, err := generateAITeams() odize.AssertNoError(t, err) - odize.AssertTrue(t, len(teams) == 12) + odize.AssertTrue(t, len(_teams) == 12) - for i, team := range teams { + for i, team := range _teams { odize.AssertTrue(t, team.Persona != "") odize.AssertTrue(t, len(team.Formations) == 10) @@ -28,3 +53,86 @@ func TestGenerateAITeams(t *testing.T) { odize.AssertNoError(t, err) } + +func TestAITeamService_GenerateTeams(t *testing.T) { + group := odize.NewGroup(t, nil) + + var mockTeams *mockTeamCreator + var mockPlaybooks *mockPlaybookCreator + var svc *AITeamService + + group.BeforeEach(func() { + mockTeams = &mockTeamCreator{} + mockPlaybooks = &mockPlaybookCreator{} + svc = &AITeamService{ + teamsSvc: mockTeams, + playbooksSvc: mockPlaybooks, + } + }) + + err := group. + Test("should successfully generate all teams", func(t *testing.T) { + var coachCount, teamCount, playbookCount int + + mockTeams.createCoachFunc = func(ctx context.Context, params teams.CreateCoachParams) (teams.Coach, error) { + coachCount++ + return teams.Coach{ID: int64(coachCount), Name: params.Name}, nil + } + mockTeams.createTeamFunc = func(ctx context.Context, name string, coachID int64, isDefault bool) (teams.Team, error) { + teamCount++ + return teams.Team{ID: int64(teamCount), Name: name}, nil + } + mockPlaybooks.createPlaybookFunc = func(ctx context.Context, params playbooks.PlaybookParams) (playbooks.Playbook, error) { + playbookCount++ + return playbooks.Playbook{ID: int64(playbookCount), Name: params.Name}, nil + } + + err := svc.GenerateTeams(t.Context()) + odize.AssertNoError(t, err) + odize.AssertEqual(t, totalTeams, coachCount) + odize.AssertEqual(t, totalTeams, teamCount) + odize.AssertEqual(t, totalTeams, playbookCount) + }). + Test("should return error when CreateCoach fails", func(t *testing.T) { + expectedErr := errors.New("coach error") + mockTeams.createCoachFunc = func(ctx context.Context, params teams.CreateCoachParams) (teams.Coach, error) { + return teams.Coach{}, expectedErr + } + + err := svc.GenerateTeams(t.Context()) + odize.AssertError(t, err) + odize.AssertTrue(t, errors.Is(err, expectedErr)) + }). + Test("should return error when CreateTeam fails", func(t *testing.T) { + expectedErr := errors.New("team error") + mockTeams.createCoachFunc = func(ctx context.Context, params teams.CreateCoachParams) (teams.Coach, error) { + return teams.Coach{ID: 1}, nil + } + mockTeams.createTeamFunc = func(ctx context.Context, name string, coachID int64, isDefault bool) (teams.Team, error) { + return teams.Team{}, expectedErr + } + + err := svc.GenerateTeams(t.Context()) + odize.AssertError(t, err) + odize.AssertTrue(t, errors.Is(err, expectedErr)) + }). + Test("should return error when CreatePlaybook fails", func(t *testing.T) { + expectedErr := errors.New("playbook error") + mockTeams.createCoachFunc = func(ctx context.Context, params teams.CreateCoachParams) (teams.Coach, error) { + return teams.Coach{ID: 1}, nil + } + mockTeams.createTeamFunc = func(ctx context.Context, name string, coachID int64, isDefault bool) (teams.Team, error) { + return teams.Team{ID: 1, Name: name}, nil + } + mockPlaybooks.createPlaybookFunc = func(ctx context.Context, params playbooks.PlaybookParams) (playbooks.Playbook, error) { + return playbooks.Playbook{}, expectedErr + } + + err := svc.GenerateTeams(t.Context()) + odize.AssertError(t, err) + odize.AssertTrue(t, errors.Is(err, expectedErr)) + }). + Run() + + odize.AssertNoError(t, err) +} From 0206e42f51eb2312245896b0996aaef8ce2ae71c Mon Sep 17 00:00:00 2001 From: frag223 Date: Mon, 22 Jun 2026 20:49:24 +1000 Subject: [PATCH 61/98] adding ai generation step --- cmd/rush/main.go | 18 ++++++++++++++++++ internal/database/queries/team.sql | 2 +- internal/database/team.sql.gen.go | 17 +++++++++++++---- internal/teams/interfaces.go | 2 +- internal/teams/service.go | 21 +++++++++++---------- internal/teams/service_test.go | 4 ++-- internal/teams/transforms.go | 10 ++++++++++ internal/teams/types.go | 2 ++ internal/tournament/interfaces.go | 1 + internal/tournament/teams.go | 21 +++++++++++++++++++++ 10 files changed, 80 insertions(+), 18 deletions(-) diff --git a/cmd/rush/main.go b/cmd/rush/main.go index e80a21a..9f66b7c 100644 --- a/cmd/rush/main.go +++ b/cmd/rush/main.go @@ -10,6 +10,7 @@ import ( "github.com/code-gorilla-au/rush/internal/database" "github.com/code-gorilla-au/rush/internal/playbooks" "github.com/code-gorilla-au/rush/internal/teams" + "github.com/code-gorilla-au/rush/internal/tournament" "github.com/code-gorilla-au/rush/internal/ui" ) @@ -40,6 +41,23 @@ func main() { queries := database.New(db) teamsSvc := teams.NewTeamsService(queries) playbooksSvc := playbooks.NewPlaybooksService(queries) + tournamentSvc := tournament.NewAITeamService(teamsSvc, playbooksSvc) + + go func() { + hasAICoaches, tErr := tournamentSvc.HasAICoaches(ctx) + if tErr != nil { + slog.Error("Failed to check for AI coaches", "error", err) + return + } + + if hasAICoaches { + return + } + + if tErr = tournamentSvc.GenerateTeams(ctx); err != nil { + slog.Error("Failed to generate teams", "error", err) + } + }() p := tea.NewProgram(ui.New(teamsSvc, playbooksSvc)) if _, err = p.Run(); err != nil { diff --git a/internal/database/queries/team.sql b/internal/database/queries/team.sql index bc338a2..25cb92f 100644 --- a/internal/database/queries/team.sql +++ b/internal/database/queries/team.sql @@ -28,7 +28,7 @@ SELECT * FROM teams WHERE id = ?; -- name: GetTeamByCoachID :one SELECT * FROM teams WHERE coach_id = ? AND is_default = true LIMIT 1; --- name: CreateTeam :exec +-- name: CreateTeam :one INSERT INTO teams (name, is_default, coach_id) VALUES (?, ?, ?) RETURNING *; -- name: SetDefaultTeam :exec diff --git a/internal/database/team.sql.gen.go b/internal/database/team.sql.gen.go index 558a4aa..e81144e 100644 --- a/internal/database/team.sql.gen.go +++ b/internal/database/team.sql.gen.go @@ -74,7 +74,7 @@ func (q *Queries) CreatePlayer(ctx context.Context, arg CreatePlayerParams) (Pla return i, err } -const createTeam = `-- name: CreateTeam :exec +const createTeam = `-- name: CreateTeam :one INSERT INTO teams (name, is_default, coach_id) VALUES (?, ?, ?) RETURNING id, name, is_default, coach_id, created_at, updated_at ` @@ -84,9 +84,18 @@ type CreateTeamParams struct { CoachID sql.NullInt64 } -func (q *Queries) CreateTeam(ctx context.Context, arg CreateTeamParams) error { - _, err := q.db.ExecContext(ctx, createTeam, arg.Name, arg.IsDefault, arg.CoachID) - return err +func (q *Queries) CreateTeam(ctx context.Context, arg CreateTeamParams) (Team, error) { + row := q.db.QueryRowContext(ctx, createTeam, arg.Name, arg.IsDefault, arg.CoachID) + var i Team + err := row.Scan( + &i.ID, + &i.Name, + &i.IsDefault, + &i.CoachID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err } const deleteCoach = `-- name: DeleteCoach :exec diff --git a/internal/teams/interfaces.go b/internal/teams/interfaces.go index b914e80..9c8720f 100644 --- a/internal/teams/interfaces.go +++ b/internal/teams/interfaces.go @@ -29,7 +29,7 @@ type PlayerStore interface { } type TeamStore interface { - CreateTeam(ctx context.Context, arg database.CreateTeamParams) error + CreateTeam(ctx context.Context, arg database.CreateTeamParams) (database.Team, error) GetTeamByCoachID(ctx context.Context, coachID sql.NullInt64) (database.Team, error) DeleteTeam(ctx context.Context, id int64) error SetDefaultTeam(ctx context.Context, id int64) error diff --git a/internal/teams/service.go b/internal/teams/service.go index 4d768bf..1fd79db 100644 --- a/internal/teams/service.go +++ b/internal/teams/service.go @@ -42,6 +42,15 @@ func (s *Service) CreateCoach(ctx context.Context, params CreateCoachParams) (Co return fromCoachModel(model), nil } +func (s *Service) ListCoaches(ctx context.Context) ([]Coach, error) { + models, err := s.store.GetCoaches(ctx) + if err != nil { + return nil, fmt.Errorf("listing coaches: %w", err) + } + + return fromCoachesModel(models), nil +} + func (s *Service) GetDefaultCoach(ctx context.Context) (Coach, error) { model, err := s.store.GetDefaultCoach(ctx) if err != nil { @@ -80,7 +89,7 @@ func (s *Service) GetTeamByCoachID(ctx context.Context, id int64) (Team, error) } func (s *Service) CreateTeam(ctx context.Context, name string, coachID int64, isDefault bool) (Team, error) { - err := s.store.CreateTeam(ctx, database.CreateTeamParams{ + model, err := s.store.CreateTeam(ctx, database.CreateTeamParams{ Name: name, IsDefault: sql.NullBool{ Bool: isDefault, @@ -92,15 +101,7 @@ func (s *Service) CreateTeam(ctx context.Context, name string, coachID int64, is }, }) if err != nil { - return Team{}, fmt.Errorf("creating team: %w", err) - } - - model, err := s.store.GetTeamByCoachID(ctx, sql.NullInt64{ - Int64: coachID, - Valid: true, - }) - if err != nil { - return Team{}, fmt.Errorf("getting created team: %w", err) + return Team{}, fmt.Errorf("creating team %s: %w", name, err) } playersModel, err := s.createPlayers(ctx, model.ID) diff --git a/internal/teams/service_test.go b/internal/teams/service_test.go index 8316bd4..c2bb463 100644 --- a/internal/teams/service_test.go +++ b/internal/teams/service_test.go @@ -100,7 +100,7 @@ func TestService(t *testing.T) { Test("SetDefaultTeam should set the default team", func(t *testing.T) { ctx := t.Context() queries := database.New(db) - err := queries.CreateTeam(ctx, database.CreateTeamParams{ + _, err := queries.CreateTeam(ctx, database.CreateTeamParams{ Name: "The Bulls", }) odize.AssertNoError(t, err) @@ -119,7 +119,7 @@ func TestService(t *testing.T) { odize.AssertTrue(t, isDefault) }). Test("ClearDefaultTeam should clear the default team", func(t *testing.T) { - err := queries.CreateTeam(t.Context(), database.CreateTeamParams{ + _, err := queries.CreateTeam(t.Context(), database.CreateTeamParams{ Name: "The Bulls", IsDefault: sql.NullBool{Bool: true, Valid: true}, }) diff --git a/internal/teams/transforms.go b/internal/teams/transforms.go index ef09544..d32469d 100644 --- a/internal/teams/transforms.go +++ b/internal/teams/transforms.go @@ -13,6 +13,16 @@ func fromCoachModel(m database.Coach) Coach { } } +func fromCoachesModel(m []database.Coach) []Coach { + coaches := make([]Coach, len(m)) + + for i, coach := range m { + coaches[i] = fromCoachModel(coach) + } + + return coaches +} + func fromTeamModel(m database.Team, p []database.Player) Team { players := make([]Player, len(p)) diff --git a/internal/teams/types.go b/internal/teams/types.go index 3d63343..f050b80 100644 --- a/internal/teams/types.go +++ b/internal/teams/types.go @@ -17,6 +17,8 @@ type Team struct { type Coach struct { ID int64 `json:"id,omitzero"` Name string `json:"name,omitzero"` + IsDefault bool `json:"is_default,omitzero"` + IsHuman bool `json:"is_human,omitzero"` CreatedAt time.Time `json:"created_at,omitzero"` UpdatedAt time.Time `json:"updated_at,omitzero"` } diff --git a/internal/tournament/interfaces.go b/internal/tournament/interfaces.go index 941051a..56aa057 100644 --- a/internal/tournament/interfaces.go +++ b/internal/tournament/interfaces.go @@ -9,6 +9,7 @@ import ( type TeamCreator interface { CreateCoach(ctx context.Context, params teams.CreateCoachParams) (teams.Coach, error) + ListCoaches(ctx context.Context) ([]teams.Coach, error) CreateTeam(ctx context.Context, name string, coachID int64, isDefault bool) (teams.Team, error) } diff --git a/internal/tournament/teams.go b/internal/tournament/teams.go index c6d21f6..5c51463 100644 --- a/internal/tournament/teams.go +++ b/internal/tournament/teams.go @@ -3,6 +3,7 @@ package tournament import ( "context" "fmt" + "slices" "github.com/code-gorilla-au/rush/internal/playbooks" "github.com/code-gorilla-au/rush/internal/teams" @@ -125,6 +126,26 @@ type AITeamService struct { playbooksSvc PlaybookCreator } +func NewAITeamService(teamsSvc TeamCreator, playbooksSvc PlaybookCreator) *AITeamService { + return &AITeamService{ + teamsSvc: teamsSvc, + playbooksSvc: playbooksSvc, + } +} + +func (s *AITeamService) HasAICoaches(ctx context.Context) (bool, error) { + coaches, err := s.teamsSvc.ListCoaches(ctx) + if err != nil { + return false, fmt.Errorf("listing coaches: %w", err) + } + + aiCoaches := slices.DeleteFunc(coaches, func(c teams.Coach) bool { + return c.IsHuman == false + }) + + return len(aiCoaches) > 0, nil +} + func (s *AITeamService) GenerateTeams(ctx context.Context) error { aiTeams, err := generateAITeams() if err != nil { From 3b71612721755e0362119ff9b6217944a14f03ac Mon Sep 17 00:00:00 2001 From: frag223 Date: Mon, 22 Jun 2026 20:51:57 +1000 Subject: [PATCH 62/98] fixing err naming --- cmd/rush/main.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/rush/main.go b/cmd/rush/main.go index 9f66b7c..01dfd1e 100644 --- a/cmd/rush/main.go +++ b/cmd/rush/main.go @@ -46,7 +46,7 @@ func main() { go func() { hasAICoaches, tErr := tournamentSvc.HasAICoaches(ctx) if tErr != nil { - slog.Error("Failed to check for AI coaches", "error", err) + slog.Error("Failed to check for AI coaches", "error", tErr) return } @@ -54,8 +54,8 @@ func main() { return } - if tErr = tournamentSvc.GenerateTeams(ctx); err != nil { - slog.Error("Failed to generate teams", "error", err) + if tErr = tournamentSvc.GenerateTeams(ctx); tErr != nil { + slog.Error("Failed to generate teams", "error", tErr) } }() From 9fc3cd1156bfbe6f5aa833aa469823f79c9602e5 Mon Sep 17 00:00:00 2001 From: frag223 Date: Mon, 22 Jun 2026 21:07:33 +1000 Subject: [PATCH 63/98] update --- internal/playbooks/service.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/playbooks/service.go b/internal/playbooks/service.go index 9067b45..45ff9b1 100644 --- a/internal/playbooks/service.go +++ b/internal/playbooks/service.go @@ -71,6 +71,7 @@ func (s *Service) UpdatePlaybook(ctx context.Context, id int64, params PlaybookP if err != nil { return Playbook{}, fmt.Errorf("failed to marshal formations: %w", err) } + model, err := s.store.UpdatePlaybook(ctx, database.UpdatePlaybookParams{ ID: id, Name: params.Name, From c9a0ce391fd67cdf336b459160228712be702755 Mon Sep 17 00:00:00 2001 From: frag223 Date: Mon, 22 Jun 2026 22:19:31 +1000 Subject: [PATCH 64/98] resolving a lane --- internal/game/dual.go | 64 +++++++++++++++++++ internal/game/dual_test.go | 125 +++++++++++++++++++++++++++++++++++++ internal/game/game.go | 1 + internal/game/types.go | 26 ++++++++ 4 files changed, 216 insertions(+) create mode 100644 internal/game/dual.go create mode 100644 internal/game/dual_test.go create mode 100644 internal/game/game.go create mode 100644 internal/game/types.go diff --git a/internal/game/dual.go b/internal/game/dual.go new file mode 100644 index 0000000..2c39c45 --- /dev/null +++ b/internal/game/dual.go @@ -0,0 +1,64 @@ +package game + +import "errors" + +func (r *Round) ResolveLane(lane int, rollFn func() int) Result { + for r.TeamA.LaneHasPlayers(lane) && r.TeamB.LaneHasPlayers(lane) { + aRoll := rollFn() + bRoll := rollFn() + + if aRoll > bRoll { + _, err := r.TeamB.LanePop(lane) + if errors.Is(err, ErrNoPlayer) { + break + } + + } else if bRoll > aRoll { + _, err := r.TeamA.LanePop(lane) + if errors.Is(err, ErrNoPlayer) { + break + } + } + + } + + if r.TeamA.LaneHasPlayers(lane) { + return Result{ + TeamA: true, + TeamB: false, + RemainingPlayers: r.TeamA.LaneCount(lane), + } + } + + return Result{ + TeamA: false, + TeamB: true, + RemainingPlayers: r.TeamB.LaneCount(lane), + } + +} + +func (s *Squad) LaneCount(lane int) int { + return len(s.Lanes[lane]) +} + +func (s *Squad) LaneHasPlayers(lane int) bool { + return len(s.Lanes[lane]) > 0 +} + +func (s *Squad) LanePop(lane int) (int, error) { + tmpLane := s.Lanes[lane] + if len(tmpLane) == 0 { + return 0, ErrNoPlayer + } + + s.Lanes[lane] = tmpLane[:len(tmpLane)-1] + + return 1, nil +} + +func (s *Squad) LaneFill(lane int, players int) { + for i := 0; i < players; i++ { + s.Lanes[lane] = append(s.Lanes[lane], i) + } +} diff --git a/internal/game/dual_test.go b/internal/game/dual_test.go new file mode 100644 index 0000000..bdaa3d6 --- /dev/null +++ b/internal/game/dual_test.go @@ -0,0 +1,125 @@ +package game + +import ( + "testing" + + "github.com/code-gorilla-au/odize" +) + +func TestResolveLane(t *testing.T) { + group := odize.NewGroup(t, nil) + + group.Test("Team A should win when Team B runs out of players", func(t *testing.T) { + r := &Round{ + TeamA: Squad{}, + TeamB: Squad{}, + } + lane := 0 + r.TeamA.LaneFill(lane, 2) + r.TeamB.LaneFill(lane, 1) + + // Team A wins if aRoll > bRoll + rolls := []int{6, 1} // aRoll=6, bRoll=1 -> Team B pops + idx := 0 + rollFn := func() int { + val := rolls[idx] + idx++ + return val + } + + res := r.ResolveLane(lane, rollFn) + + odize.AssertTrue(t, res.TeamA) + odize.AssertFalse(t, res.TeamB) + odize.AssertEqual(t, 2, res.RemainingPlayers) + odize.AssertEqual(t, 0, r.TeamB.LaneCount(lane)) + }) + + group.Test("Team B should win when Team A runs out of players", func(t *testing.T) { + r := &Round{ + TeamA: Squad{}, + TeamB: Squad{}, + } + lane := 0 + r.TeamA.LaneFill(lane, 1) + r.TeamB.LaneFill(lane, 2) + + // Team B wins if bRoll > aRoll + rolls := []int{1, 6} // aRoll=1, bRoll=6 -> Team A pops + idx := 0 + rollFn := func() int { + val := rolls[idx] + idx++ + return val + } + + res := r.ResolveLane(lane, rollFn) + + odize.AssertFalse(t, res.TeamA) + odize.AssertTrue(t, res.TeamB) + odize.AssertEqual(t, 2, res.RemainingPlayers) + odize.AssertEqual(t, 0, r.TeamA.LaneCount(lane)) + }) + + group.Test("Draw should not result in any player being eliminated", func(t *testing.T) { + r := &Round{ + TeamA: Squad{}, + TeamB: Squad{}, + } + lane := 0 + r.TeamA.LaneFill(lane, 1) + r.TeamB.LaneFill(lane, 1) + + // Draw + rolls := []int{3, 3, 6, 1} // aRoll=3, bRoll=3 -> nothing; then aRoll=6, bRoll=1 -> Team B pops + idx := 0 + rollFn := func() int { + val := rolls[idx] + idx++ + return val + } + + res := r.ResolveLane(lane, rollFn) + + odize.AssertTrue(t, res.TeamA) + odize.AssertFalse(t, res.TeamB) + odize.AssertEqual(t, 1, res.RemainingPlayers) + odize.AssertEqual(t, 0, r.TeamB.LaneCount(lane)) + odize.AssertEqual(t, 1, r.TeamA.LaneCount(lane)) + }) + + group.Test("Team A starts with 0 players should lose immediately", func(t *testing.T) { + r := &Round{ + TeamA: Squad{}, + TeamB: Squad{}, + } + lane := 0 + r.TeamA.LaneFill(lane, 0) + r.TeamB.LaneFill(lane, 3) + + res := r.ResolveLane(lane, func() int { return 1 }) + + odize.AssertFalse(t, res.TeamA) + odize.AssertTrue(t, res.TeamB) + odize.AssertEqual(t, 3, res.RemainingPlayers) + }) + + group.Test("Team B starts with 0 players should lose immediately", func(t *testing.T) { + r := &Round{ + TeamA: Squad{}, + TeamB: Squad{}, + } + lane := 0 + r.TeamA.LaneFill(lane, 3) + r.TeamB.LaneFill(lane, 0) + + res := r.ResolveLane(lane, func() int { return 1 }) + + odize.AssertTrue(t, res.TeamA) + odize.AssertFalse(t, res.TeamB) + odize.AssertEqual(t, 3, res.RemainingPlayers) + }) + + err := group.Run() + odize.AssertNoError(t, err) +} diff --git a/internal/game/game.go b/internal/game/game.go new file mode 100644 index 0000000..cde26fe --- /dev/null +++ b/internal/game/game.go @@ -0,0 +1 @@ +package game diff --git a/internal/game/types.go b/internal/game/types.go new file mode 100644 index 0000000..8cf77d3 --- /dev/null +++ b/internal/game/types.go @@ -0,0 +1,26 @@ +package game + +import "errors" + +type Game struct { + Rounds [10]Round +} + +type Round struct { + TeamA Squad + TeamB Squad +} + +type Squad struct { + Lanes [3][]int +} + +type Result struct { + TeamA bool + TeamB bool + RemainingPlayers int +} + +var ( + ErrNoPlayer = errors.New("no player left in lane") +) From 2a637020fae8b90bfab04ae3d9ba78f02f2e7c0e Mon Sep 17 00:00:00 2001 From: frag223 Date: Mon, 22 Jun 2026 22:29:00 +1000 Subject: [PATCH 65/98] adding game mechanics --- internal/game/dual.go | 40 ++++++++++++++ internal/game/dual_test.go | 108 +++++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) diff --git a/internal/game/dual.go b/internal/game/dual.go index 2c39c45..11079dd 100644 --- a/internal/game/dual.go +++ b/internal/game/dual.go @@ -2,6 +2,46 @@ package game import "errors" +func (r *Round) ResolveLanes(rollFn func() int) Result { + var result []Result + + for lane := 0; lane < len(r.TeamA.Lanes); lane++ { + laneResult := r.ResolveLane(lane, rollFn) + result = append(result, laneResult) + } + + teamA := 0 + teamAPlayers := 0 + + teamB := 0 + teamBPlayers := 0 + + for _, laneResult := range result { + if laneResult.TeamA { + teamA++ + teamAPlayers += laneResult.RemainingPlayers + } else { + teamB++ + teamBPlayers += laneResult.RemainingPlayers + } + } + + if teamAPlayers > teamBPlayers { + return Result{ + TeamA: true, + TeamB: false, + RemainingPlayers: teamAPlayers, + } + } + + return Result{ + TeamA: false, + TeamB: true, + RemainingPlayers: teamBPlayers, + } + +} + func (r *Round) ResolveLane(lane int, rollFn func() int) Result { for r.TeamA.LaneHasPlayers(lane) && r.TeamB.LaneHasPlayers(lane) { aRoll := rollFn() diff --git a/internal/game/dual_test.go b/internal/game/dual_test.go index bdaa3d6..11d49e9 100644 --- a/internal/game/dual_test.go +++ b/internal/game/dual_test.go @@ -123,3 +123,111 @@ func TestResolveLane(t *testing.T) { err := group.Run() odize.AssertNoError(t, err) } + +func TestResolveLanes(t *testing.T) { + group := odize.NewGroup(t, nil) + + group.Test("Team A should win the round if they have more remaining players across all lanes", func(t *testing.T) { + r := &Round{ + TeamA: Squad{}, + TeamB: Squad{}, + } + // Lane 0: Team A wins (2 remaining) + r.TeamA.LaneFill(0, 2) + r.TeamB.LaneFill(0, 1) + + // Lane 1: Team B wins (1 remaining) + r.TeamA.LaneFill(1, 1) + r.TeamB.LaneFill(1, 2) + + // Lane 2: Team A wins (3 remaining) + r.TeamA.LaneFill(2, 3) + r.TeamB.LaneFill(2, 0) + + // Rolls for Lane 0: A(6), B(1) -> B loses 1 + // Rolls for Lane 1: A(1), B(6) -> A loses 1 + // Lane 2: No rolls needed as B has 0 players + rolls := []int{6, 1, 1, 6} + idx := 0 + rollFn := func() int { + val := rolls[idx] + idx++ + return val + } + + res := r.ResolveLanes(rollFn) + + // Team A players: Lane 0 (2), Lane 1 (0), Lane 2 (3) = 5 + // Team B players: Lane 0 (0), Lane 1 (2), Lane 2 (0) = 2 + // Total A (5) > Total B (2) -> Team A wins + odize.AssertTrue(t, res.TeamA) + odize.AssertFalse(t, res.TeamB) + odize.AssertEqual(t, 5, res.RemainingPlayers) + }) + + group.Test("Team B should win the round if they have more remaining players across all lanes", func(t *testing.T) { + r := &Round{ + TeamA: Squad{}, + TeamB: Squad{}, + } + // Lane 0: Team B wins (3 remaining) + r.TeamA.LaneFill(0, 0) + r.TeamB.LaneFill(0, 3) + + // Lane 1: Team B wins (2 remaining) + r.TeamA.LaneFill(1, 1) + r.TeamB.LaneFill(1, 2) + + // Lane 2: Team A wins (1 remaining) + r.TeamA.LaneFill(2, 1) + r.TeamB.LaneFill(2, 0) + + // Rolls for Lane 1: A(1), B(6) -> A loses 1 + rolls := []int{1, 6} + idx := 0 + rollFn := func() int { + val := rolls[idx] + idx++ + return val + } + + res := r.ResolveLanes(rollFn) + + // Team A players: Lane 0 (0), Lane 1 (0), Lane 2 (1) = 1 + // Team B players: Lane 0 (3), Lane 1 (2), Lane 2 (0) = 5 + // Total B (5) > Total A (1) -> Team B wins + odize.AssertFalse(t, res.TeamA) + odize.AssertTrue(t, res.TeamB) + odize.AssertEqual(t, 5, res.RemainingPlayers) + }) + + group.Test("A tie in total remaining players should default to Team B win (or current logic)", func(t *testing.T) { + r := &Round{ + TeamA: Squad{}, + TeamB: Squad{}, + } + // Lane 0: Team A wins (1 remaining) + r.TeamA.LaneFill(0, 1) + r.TeamB.LaneFill(0, 0) + + // Lane 1: Team B wins (1 remaining) + r.TeamA.LaneFill(1, 0) + r.TeamB.LaneFill(1, 1) + + // Lane 2: Both empty + r.TeamA.LaneFill(2, 0) + r.TeamB.LaneFill(2, 0) + + res := r.ResolveLanes(func() int { return 1 }) + + // Total A (1) == Total B (1) + // Current logic: if teamAPlayers > teamBPlayers { A wins } else { B wins } + // So it should be Team B win. + odize.AssertFalse(t, res.TeamA) + odize.AssertTrue(t, res.TeamB) + odize.AssertEqual(t, 1, res.RemainingPlayers) + }) + + err := group.Run() + odize.AssertNoError(t, err) +} From bf7797eafcf58b41d85a02ac2419f5e834558eb3 Mon Sep 17 00:00:00 2001 From: frag223 Date: Mon, 22 Jun 2026 22:44:47 +1000 Subject: [PATCH 66/98] adding game mechanics --- internal/game/dual.go | 18 ++++++++++++++++++ internal/game/game.go | 29 +++++++++++++++++++++++++++++ internal/game/types.go | 6 ++++++ 3 files changed, 53 insertions(+) diff --git a/internal/game/dual.go b/internal/game/dual.go index 11079dd..7834874 100644 --- a/internal/game/dual.go +++ b/internal/game/dual.go @@ -2,6 +2,18 @@ package game import "errors" +func NewRound() Round { + return Round{ + TeamA: Squad{Lanes: [3][]int{}}, + TeamB: Squad{Lanes: [3][]int{}}, + } +} + +func (r *Round) FillTeams(a SquadLanes, b SquadLanes) { + r.TeamA.FillSquad(a) + r.TeamB.FillSquad(b) +} + func (r *Round) ResolveLanes(rollFn func() int) Result { var result []Result @@ -97,6 +109,12 @@ func (s *Squad) LanePop(lane int) (int, error) { return 1, nil } +func (s *Squad) FillSquad(f SquadLanes) { + s.LaneFill(0, f.Lane1) + s.LaneFill(1, f.Lane2) + s.LaneFill(2, f.Lane3) +} + func (s *Squad) LaneFill(lane int, players int) { for i := 0; i < players; i++ { s.Lanes[lane] = append(s.Lanes[lane], i) diff --git a/internal/game/game.go b/internal/game/game.go index cde26fe..48ef0bb 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -1 +1,30 @@ package game + +import "github.com/code-gorilla-au/rush/internal/playbooks" + +func NewGame(teamAPlaybook playbooks.Playbook, teamBPlaybook playbooks.Playbook) Game { + rounds := [10]Round{} + + for i := range rounds { + r := NewRound() + + r.FillTeams( + SquadLanes{ + Lane1: teamAPlaybook.Formations[i].Lane1, + Lane2: teamAPlaybook.Formations[i].Lane2, + Lane3: teamAPlaybook.Formations[i].Lane3, + }, + SquadLanes{ + Lane1: teamBPlaybook.Formations[i].Lane1, + Lane2: teamBPlaybook.Formations[i].Lane2, + Lane3: teamBPlaybook.Formations[i].Lane3, + }, + ) + + rounds[i] = r + } + + return Game{ + Rounds: rounds, + } +} diff --git a/internal/game/types.go b/internal/game/types.go index 8cf77d3..f8e689e 100644 --- a/internal/game/types.go +++ b/internal/game/types.go @@ -15,6 +15,12 @@ type Squad struct { Lanes [3][]int } +type SquadLanes struct { + Lane1 int + Lane2 int + Lane3 int +} + type Result struct { TeamA bool TeamB bool From b58e5e3a88254a3f1509cd59be419f992b02c5ea Mon Sep 17 00:00:00 2001 From: frag223 Date: Tue, 23 Jun 2026 07:20:37 +1000 Subject: [PATCH 67/98] wip, adding round resolver --- internal/game/game.go | 16 +++++++++++++++- internal/game/{dual.go => rounds.go} | 4 ++-- internal/game/{dual_test.go => rounds_test.go} | 0 internal/game/types.go | 11 ++++++++++- 4 files changed, 27 insertions(+), 4 deletions(-) rename internal/game/{dual.go => rounds.go} (94%) rename internal/game/{dual_test.go => rounds_test.go} (100%) diff --git a/internal/game/game.go b/internal/game/game.go index 48ef0bb..23700b9 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -25,6 +25,20 @@ func NewGame(teamAPlaybook playbooks.Playbook, teamBPlaybook playbooks.Playbook) } return Game{ - Rounds: rounds, + rounds: rounds, + currentRound: 0, + results: []Result{}, } } + +func (g *Game) ResolveRound(roll RollFn) (Result, error) { + if g.currentRound >= len(g.rounds) { + return Result{}, ErrNoRounds + } + + round := g.rounds[g.currentRound] + result := round.ResolveLanes(roll) + g.results = append(g.results, result) + g.currentRound++ + return result, nil +} diff --git a/internal/game/dual.go b/internal/game/rounds.go similarity index 94% rename from internal/game/dual.go rename to internal/game/rounds.go index 7834874..984bbca 100644 --- a/internal/game/dual.go +++ b/internal/game/rounds.go @@ -14,7 +14,7 @@ func (r *Round) FillTeams(a SquadLanes, b SquadLanes) { r.TeamB.FillSquad(b) } -func (r *Round) ResolveLanes(rollFn func() int) Result { +func (r *Round) ResolveLanes(rollFn RollFn) Result { var result []Result for lane := 0; lane < len(r.TeamA.Lanes); lane++ { @@ -54,7 +54,7 @@ func (r *Round) ResolveLanes(rollFn func() int) Result { } -func (r *Round) ResolveLane(lane int, rollFn func() int) Result { +func (r *Round) ResolveLane(lane int, rollFn RollFn) Result { for r.TeamA.LaneHasPlayers(lane) && r.TeamB.LaneHasPlayers(lane) { aRoll := rollFn() bRoll := rollFn() diff --git a/internal/game/dual_test.go b/internal/game/rounds_test.go similarity index 100% rename from internal/game/dual_test.go rename to internal/game/rounds_test.go diff --git a/internal/game/types.go b/internal/game/types.go index f8e689e..3377a76 100644 --- a/internal/game/types.go +++ b/internal/game/types.go @@ -2,8 +2,14 @@ package game import "errors" +const ( + MaxRounds = 10 +) + type Game struct { - Rounds [10]Round + rounds [10]Round + currentRound int + results []Result } type Round struct { @@ -27,6 +33,9 @@ type Result struct { RemainingPlayers int } +type RollFn func() int + var ( ErrNoPlayer = errors.New("no player left in lane") + ErrNoRounds = errors.New("no rounds left") ) From f3b192508849447017888990defa029c5362a758 Mon Sep 17 00:00:00 2001 From: frag223 Date: Tue, 23 Jun 2026 08:38:03 +1000 Subject: [PATCH 68/98] wip designing game loop --- internal/database/game.sql.gen.go | 132 ++++++++++++++++++++++++ internal/database/models.gen.go | 20 ++++ internal/database/queries/game.sql | 29 ++++++ internal/database/schema/game.sql | 21 ++++ internal/{game => games}/game.go | 24 +++-- internal/games/interfaces.go | 13 +++ internal/{game => games}/rounds.go | 8 +- internal/{game => games}/rounds_test.go | 2 +- internal/games/service.go | 7 ++ internal/{game => games}/types.go | 19 ++-- 10 files changed, 255 insertions(+), 20 deletions(-) create mode 100644 internal/database/game.sql.gen.go create mode 100644 internal/database/queries/game.sql create mode 100644 internal/database/schema/game.sql rename internal/{game => games}/game.go (60%) create mode 100644 internal/games/interfaces.go rename internal/{game => games}/rounds.go (93%) rename internal/{game => games}/rounds_test.go (99%) create mode 100644 internal/games/service.go rename internal/{game => games}/types.go (70%) diff --git a/internal/database/game.sql.gen.go b/internal/database/game.sql.gen.go new file mode 100644 index 0000000..605d7f2 --- /dev/null +++ b/internal/database/game.sql.gen.go @@ -0,0 +1,132 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: game.sql + +package database + +import ( + "context" + "database/sql" +) + +const createGame = `-- name: CreateGame :one +insert into games (name, + team_a, + team_b, + tournament_id, + results_log) +values (?, + ?, + ?, + ?, + ?) +returning id, name, tournament_id, team_a, team_b, winner, status, results_log, created_at, updated_at +` + +type CreateGameParams struct { + Name string + TeamA sql.NullInt64 + TeamB sql.NullInt64 + TournamentID sql.NullInt64 + ResultsLog interface{} +} + +func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, error) { + row := q.db.QueryRowContext(ctx, createGame, + arg.Name, + arg.TeamA, + arg.TeamB, + arg.TournamentID, + arg.ResultsLog, + ) + var i Game + err := row.Scan( + &i.ID, + &i.Name, + &i.TournamentID, + &i.TeamA, + &i.TeamB, + &i.Winner, + &i.Status, + &i.ResultsLog, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getGameByID = `-- name: GetGameByID :one +select id, name, tournament_id, team_a, team_b, winner, status, results_log, created_at, updated_at +from games +where id = ? +` + +func (q *Queries) GetGameByID(ctx context.Context, id int64) (Game, error) { + row := q.db.QueryRowContext(ctx, getGameByID, id) + var i Game + err := row.Scan( + &i.ID, + &i.Name, + &i.TournamentID, + &i.TeamA, + &i.TeamB, + &i.Winner, + &i.Status, + &i.ResultsLog, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateGame = `-- name: UpdateGame :one +update games +set name = ?, + team_a = ?, + team_b = ?, + winner = ?, + status = ?, + results_log = ?, + tournament_id = ? +where id = ? +returning id, name, tournament_id, team_a, team_b, winner, status, results_log, created_at, updated_at +` + +type UpdateGameParams struct { + Name string + TeamA sql.NullInt64 + TeamB sql.NullInt64 + Winner sql.NullInt64 + Status sql.NullString + ResultsLog interface{} + TournamentID sql.NullInt64 + ID int64 +} + +func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) (Game, error) { + row := q.db.QueryRowContext(ctx, updateGame, + arg.Name, + arg.TeamA, + arg.TeamB, + arg.Winner, + arg.Status, + arg.ResultsLog, + arg.TournamentID, + arg.ID, + ) + var i Game + err := row.Scan( + &i.ID, + &i.Name, + &i.TournamentID, + &i.TeamA, + &i.TeamB, + &i.Winner, + &i.Status, + &i.ResultsLog, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/database/models.gen.go b/internal/database/models.gen.go index d48bf9d..dfd1f17 100644 --- a/internal/database/models.gen.go +++ b/internal/database/models.gen.go @@ -17,6 +17,19 @@ type Coach struct { UpdatedAt sql.NullTime } +type Game struct { + ID int64 + Name string + TournamentID sql.NullInt64 + TeamA sql.NullInt64 + TeamB sql.NullInt64 + Winner sql.NullInt64 + Status sql.NullString + ResultsLog interface{} + CreatedAt sql.NullTime + UpdatedAt sql.NullTime +} + type Playbook struct { ID int64 Name string @@ -43,3 +56,10 @@ type Team struct { CreatedAt sql.NullTime UpdatedAt sql.NullTime } + +type Tournament struct { + ID int64 + Name string + CreatedAt sql.NullTime + UpdatedAt sql.NullTime +} diff --git a/internal/database/queries/game.sql b/internal/database/queries/game.sql new file mode 100644 index 0000000..7870133 --- /dev/null +++ b/internal/database/queries/game.sql @@ -0,0 +1,29 @@ +-- name: CreateGame :one +insert into games (name, + team_a, + team_b, + tournament_id, + results_log) +values (?, + ?, + ?, + ?, + ?) +returning *; + +-- name: GetGameByID :one +select * +from games +where id = ?; + +-- name: UpdateGame :one +update games +set name = ?, + team_a = ?, + team_b = ?, + winner = ?, + status = ?, + results_log = ?, + tournament_id = ? +where id = ? +returning *; \ No newline at end of file diff --git a/internal/database/schema/game.sql b/internal/database/schema/game.sql new file mode 100644 index 0000000..afe3efd --- /dev/null +++ b/internal/database/schema/game.sql @@ -0,0 +1,21 @@ + +create table if not exists tournaments ( + id integer primary key autoincrement, + name text not null, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +create table if not exists games ( + id integer primary key autoincrement, + name text not null, + tournament_id integer references tournaments(id), + team_a integer references teams(id), + team_b integer references teams(id), + winner integer references teams(id), + status varchar(255), + results_log string not null, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + diff --git a/internal/game/game.go b/internal/games/game.go similarity index 60% rename from internal/game/game.go rename to internal/games/game.go index 23700b9..33633c2 100644 --- a/internal/game/game.go +++ b/internal/games/game.go @@ -1,4 +1,4 @@ -package game +package games import "github.com/code-gorilla-au/rush/internal/playbooks" @@ -8,16 +8,18 @@ func NewGame(teamAPlaybook playbooks.Playbook, teamBPlaybook playbooks.Playbook) for i := range rounds { r := NewRound() - r.FillTeams( - SquadLanes{ - Lane1: teamAPlaybook.Formations[i].Lane1, - Lane2: teamAPlaybook.Formations[i].Lane2, - Lane3: teamAPlaybook.Formations[i].Lane3, + r.FillSquad( + SquadConfig{ + TeamID: teamAPlaybook.TeamID, + Lane1: teamAPlaybook.Formations[i].Lane1, + Lane2: teamAPlaybook.Formations[i].Lane2, + Lane3: teamAPlaybook.Formations[i].Lane3, }, - SquadLanes{ - Lane1: teamBPlaybook.Formations[i].Lane1, - Lane2: teamBPlaybook.Formations[i].Lane2, - Lane3: teamBPlaybook.Formations[i].Lane3, + SquadConfig{ + TeamID: teamBPlaybook.TeamID, + Lane1: teamBPlaybook.Formations[i].Lane1, + Lane2: teamBPlaybook.Formations[i].Lane2, + Lane3: teamBPlaybook.Formations[i].Lane3, }, ) @@ -38,7 +40,9 @@ func (g *Game) ResolveRound(roll RollFn) (Result, error) { round := g.rounds[g.currentRound] result := round.ResolveLanes(roll) + g.results = append(g.results, result) g.currentRound++ + return result, nil } diff --git a/internal/games/interfaces.go b/internal/games/interfaces.go new file mode 100644 index 0000000..13d4887 --- /dev/null +++ b/internal/games/interfaces.go @@ -0,0 +1,13 @@ +package games + +import ( + "context" + + "github.com/code-gorilla-au/rush/internal/database" +) + +type Store interface { + CreateGame(ctx context.Context, arg database.CreateGameParams) (database.Game, error) + GetGameByID(ctx context.Context, id int64) (database.Game, error) + UpdateGame(ctx context.Context, arg database.UpdateGameParams) (database.Game, error) +} diff --git a/internal/game/rounds.go b/internal/games/rounds.go similarity index 93% rename from internal/game/rounds.go rename to internal/games/rounds.go index 984bbca..5adb215 100644 --- a/internal/game/rounds.go +++ b/internal/games/rounds.go @@ -1,4 +1,4 @@ -package game +package games import "errors" @@ -9,7 +9,7 @@ func NewRound() Round { } } -func (r *Round) FillTeams(a SquadLanes, b SquadLanes) { +func (r *Round) FillSquad(a SquadConfig, b SquadConfig) { r.TeamA.FillSquad(a) r.TeamB.FillSquad(b) } @@ -109,7 +109,9 @@ func (s *Squad) LanePop(lane int) (int, error) { return 1, nil } -func (s *Squad) FillSquad(f SquadLanes) { +func (s *Squad) FillSquad(f SquadConfig) { + s.TeamID = f.TeamID + s.LaneFill(0, f.Lane1) s.LaneFill(1, f.Lane2) s.LaneFill(2, f.Lane3) diff --git a/internal/game/rounds_test.go b/internal/games/rounds_test.go similarity index 99% rename from internal/game/rounds_test.go rename to internal/games/rounds_test.go index 11d49e9..727cf9a 100644 --- a/internal/game/rounds_test.go +++ b/internal/games/rounds_test.go @@ -1,4 +1,4 @@ -package game +package games import ( "testing" diff --git a/internal/games/service.go b/internal/games/service.go new file mode 100644 index 0000000..45d9ec2 --- /dev/null +++ b/internal/games/service.go @@ -0,0 +1,7 @@ +package games + +func NewService(store Store) Service { + return Service{ + Store: store, + } +} diff --git a/internal/game/types.go b/internal/games/types.go similarity index 70% rename from internal/game/types.go rename to internal/games/types.go index 3377a76..29d0c14 100644 --- a/internal/game/types.go +++ b/internal/games/types.go @@ -1,4 +1,4 @@ -package game +package games import "errors" @@ -6,7 +6,12 @@ const ( MaxRounds = 10 ) +type Service struct { + Store Store +} + type Game struct { + id int64 rounds [10]Round currentRound int results []Result @@ -18,13 +23,15 @@ type Round struct { } type Squad struct { - Lanes [3][]int + TeamID int64 + Lanes [3][]int } -type SquadLanes struct { - Lane1 int - Lane2 int - Lane3 int +type SquadConfig struct { + TeamID int64 + Lane1 int + Lane2 int + Lane3 int } type Result struct { From 6b721844616d0534ae80b9351214b3a1a4b7be52 Mon Sep 17 00:00:00 2001 From: frag223 Date: Wed, 24 Jun 2026 20:24:45 +1000 Subject: [PATCH 69/98] wip designing game loop --- internal/games/game.go | 27 ++++++++++++++------------ internal/games/rounds.go | 20 +++++++++---------- internal/games/rounds_test.go | 32 +++++++++++++++---------------- internal/games/types.go | 20 ++++++++++++++----- internal/tournament/teams_test.go | 5 +++++ 5 files changed, 61 insertions(+), 43 deletions(-) diff --git a/internal/games/game.go b/internal/games/game.go index 33633c2..4e0bd71 100644 --- a/internal/games/game.go +++ b/internal/games/game.go @@ -1,25 +1,28 @@ package games -import "github.com/code-gorilla-au/rush/internal/playbooks" +type NewGameParams struct { + TeamA TeamConfig + TeamB TeamConfig +} -func NewGame(teamAPlaybook playbooks.Playbook, teamBPlaybook playbooks.Playbook) Game { +func NewGame(params NewGameParams) Game { rounds := [10]Round{} for i := range rounds { r := NewRound() r.FillSquad( - SquadConfig{ - TeamID: teamAPlaybook.TeamID, - Lane1: teamAPlaybook.Formations[i].Lane1, - Lane2: teamAPlaybook.Formations[i].Lane2, - Lane3: teamAPlaybook.Formations[i].Lane3, + LanesConfig{ + TeamID: params.TeamA.TeamID, + Lane1: params.TeamA.Formations[i].Lane1, + Lane2: params.TeamA.Formations[i].Lane2, + Lane3: params.TeamA.Formations[i].Lane3, }, - SquadConfig{ - TeamID: teamBPlaybook.TeamID, - Lane1: teamBPlaybook.Formations[i].Lane1, - Lane2: teamBPlaybook.Formations[i].Lane2, - Lane3: teamBPlaybook.Formations[i].Lane3, + LanesConfig{ + TeamID: params.TeamB.TeamID, + Lane1: params.TeamB.Formations[i].Lane1, + Lane2: params.TeamB.Formations[i].Lane2, + Lane3: params.TeamB.Formations[i].Lane3, }, ) diff --git a/internal/games/rounds.go b/internal/games/rounds.go index 5adb215..3845b09 100644 --- a/internal/games/rounds.go +++ b/internal/games/rounds.go @@ -4,14 +4,14 @@ import "errors" func NewRound() Round { return Round{ - TeamA: Squad{Lanes: [3][]int{}}, - TeamB: Squad{Lanes: [3][]int{}}, + TeamA: TeamFormation{Lanes: [3][]int{}}, + TeamB: TeamFormation{Lanes: [3][]int{}}, } } -func (r *Round) FillSquad(a SquadConfig, b SquadConfig) { - r.TeamA.FillSquad(a) - r.TeamB.FillSquad(b) +func (r *Round) FillSquad(a LanesConfig, b LanesConfig) { + r.TeamA.FillLanes(a) + r.TeamB.FillLanes(b) } func (r *Round) ResolveLanes(rollFn RollFn) Result { @@ -90,15 +90,15 @@ func (r *Round) ResolveLane(lane int, rollFn RollFn) Result { } -func (s *Squad) LaneCount(lane int) int { +func (s *TeamFormation) LaneCount(lane int) int { return len(s.Lanes[lane]) } -func (s *Squad) LaneHasPlayers(lane int) bool { +func (s *TeamFormation) LaneHasPlayers(lane int) bool { return len(s.Lanes[lane]) > 0 } -func (s *Squad) LanePop(lane int) (int, error) { +func (s *TeamFormation) LanePop(lane int) (int, error) { tmpLane := s.Lanes[lane] if len(tmpLane) == 0 { return 0, ErrNoPlayer @@ -109,7 +109,7 @@ func (s *Squad) LanePop(lane int) (int, error) { return 1, nil } -func (s *Squad) FillSquad(f SquadConfig) { +func (s *TeamFormation) FillLanes(f LanesConfig) { s.TeamID = f.TeamID s.LaneFill(0, f.Lane1) @@ -117,7 +117,7 @@ func (s *Squad) FillSquad(f SquadConfig) { s.LaneFill(2, f.Lane3) } -func (s *Squad) LaneFill(lane int, players int) { +func (s *TeamFormation) LaneFill(lane int, players int) { for i := 0; i < players; i++ { s.Lanes[lane] = append(s.Lanes[lane], i) } diff --git a/internal/games/rounds_test.go b/internal/games/rounds_test.go index 727cf9a..de8d8a6 100644 --- a/internal/games/rounds_test.go +++ b/internal/games/rounds_test.go @@ -11,8 +11,8 @@ func TestResolveLane(t *testing.T) { group.Test("Team A should win when Team B runs out of players", func(t *testing.T) { r := &Round{ - TeamA: Squad{}, - TeamB: Squad{}, + TeamA: TeamFormation{}, + TeamB: TeamFormation{}, } lane := 0 r.TeamA.LaneFill(lane, 2) @@ -37,8 +37,8 @@ func TestResolveLane(t *testing.T) { group.Test("Team B should win when Team A runs out of players", func(t *testing.T) { r := &Round{ - TeamA: Squad{}, - TeamB: Squad{}, + TeamA: TeamFormation{}, + TeamB: TeamFormation{}, } lane := 0 r.TeamA.LaneFill(lane, 1) @@ -63,8 +63,8 @@ func TestResolveLane(t *testing.T) { group.Test("Draw should not result in any player being eliminated", func(t *testing.T) { r := &Round{ - TeamA: Squad{}, - TeamB: Squad{}, + TeamA: TeamFormation{}, + TeamB: TeamFormation{}, } lane := 0 r.TeamA.LaneFill(lane, 1) @@ -90,8 +90,8 @@ func TestResolveLane(t *testing.T) { group.Test("Team A starts with 0 players should lose immediately", func(t *testing.T) { r := &Round{ - TeamA: Squad{}, - TeamB: Squad{}, + TeamA: TeamFormation{}, + TeamB: TeamFormation{}, } lane := 0 r.TeamA.LaneFill(lane, 0) @@ -106,8 +106,8 @@ func TestResolveLane(t *testing.T) { group.Test("Team B starts with 0 players should lose immediately", func(t *testing.T) { r := &Round{ - TeamA: Squad{}, - TeamB: Squad{}, + TeamA: TeamFormation{}, + TeamB: TeamFormation{}, } lane := 0 r.TeamA.LaneFill(lane, 3) @@ -129,8 +129,8 @@ func TestResolveLanes(t *testing.T) { group.Test("Team A should win the round if they have more remaining players across all lanes", func(t *testing.T) { r := &Round{ - TeamA: Squad{}, - TeamB: Squad{}, + TeamA: TeamFormation{}, + TeamB: TeamFormation{}, } // Lane 0: Team A wins (2 remaining) r.TeamA.LaneFill(0, 2) @@ -167,8 +167,8 @@ func TestResolveLanes(t *testing.T) { group.Test("Team B should win the round if they have more remaining players across all lanes", func(t *testing.T) { r := &Round{ - TeamA: Squad{}, - TeamB: Squad{}, + TeamA: TeamFormation{}, + TeamB: TeamFormation{}, } // Lane 0: Team B wins (3 remaining) r.TeamA.LaneFill(0, 0) @@ -203,8 +203,8 @@ func TestResolveLanes(t *testing.T) { group.Test("A tie in total remaining players should default to Team B win (or current logic)", func(t *testing.T) { r := &Round{ - TeamA: Squad{}, - TeamB: Squad{}, + TeamA: TeamFormation{}, + TeamB: TeamFormation{}, } // Lane 0: Team A wins (1 remaining) r.TeamA.LaneFill(0, 1) diff --git a/internal/games/types.go b/internal/games/types.go index 29d0c14..55fadca 100644 --- a/internal/games/types.go +++ b/internal/games/types.go @@ -1,6 +1,10 @@ package games -import "errors" +import ( + "errors" + + "github.com/code-gorilla-au/rush/internal/playbooks" +) const ( MaxRounds = 10 @@ -18,16 +22,22 @@ type Game struct { } type Round struct { - TeamA Squad - TeamB Squad + TeamA TeamFormation + TeamB TeamFormation +} + +type TeamConfig struct { + TeamID int64 + TeamName string + Formations []playbooks.Formation } -type Squad struct { +type TeamFormation struct { TeamID int64 Lanes [3][]int } -type SquadConfig struct { +type LanesConfig struct { TeamID int64 Lane1 int Lane2 int diff --git a/internal/tournament/teams_test.go b/internal/tournament/teams_test.go index bcb02ae..c594e0e 100644 --- a/internal/tournament/teams_test.go +++ b/internal/tournament/teams_test.go @@ -12,6 +12,7 @@ import ( type mockTeamCreator struct { createCoachFunc func(ctx context.Context, params teams.CreateCoachParams) (teams.Coach, error) + listCoachesFunc func(ctx context.Context) ([]teams.Coach, error) createTeamFunc func(ctx context.Context, name string, coachID int64, isDefault bool) (teams.Team, error) } @@ -19,6 +20,10 @@ func (m *mockTeamCreator) CreateCoach(ctx context.Context, params teams.CreateCo return m.createCoachFunc(ctx, params) } +func (m *mockTeamCreator) ListCoaches(ctx context.Context) ([]teams.Coach, error) { + return m.listCoachesFunc(ctx) +} + func (m *mockTeamCreator) CreateTeam(ctx context.Context, name string, coachID int64, isDefault bool) (teams.Team, error) { return m.createTeamFunc(ctx, name, coachID, isDefault) } From 7515c4b1c84c0bbad7b8f6adf7e5734d5b4ef3e6 Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 25 Jun 2026 21:25:33 +1000 Subject: [PATCH 70/98] adding create + update service --- internal/database/game.sql.gen.go | 49 +++++++++--- internal/database/models.gen.go | 7 +- internal/database/queries/game.sql | 22 ++++-- internal/database/schema/game.sql | 6 +- internal/games/game.go | 123 ++++++++++++++++++++++++----- internal/games/service.go | 76 ++++++++++++++++++ internal/games/types.go | 23 ++++-- sqlc.yaml | 5 ++ 8 files changed, 262 insertions(+), 49 deletions(-) diff --git a/internal/database/game.sql.gen.go b/internal/database/game.sql.gen.go index 605d7f2..f5a6cb9 100644 --- a/internal/database/game.sql.gen.go +++ b/internal/database/game.sql.gen.go @@ -8,6 +8,7 @@ package database import ( "context" "database/sql" + "encoding/json" ) const createGame = `-- name: CreateGame :one @@ -15,13 +16,19 @@ insert into games (name, team_a, team_b, tournament_id, - results_log) + results_log, + status, + rounds, + current_round) values (?, ?, ?, ?, + ?, + 'pending', + ?, ?) -returning id, name, tournament_id, team_a, team_b, winner, status, results_log, created_at, updated_at +returning id, name, tournament_id, team_a, team_b, winner, status, rounds, current_round, results_log, created_at, updated_at ` type CreateGameParams struct { @@ -29,7 +36,9 @@ type CreateGameParams struct { TeamA sql.NullInt64 TeamB sql.NullInt64 TournamentID sql.NullInt64 - ResultsLog interface{} + ResultsLog json.RawMessage + Rounds json.RawMessage + CurrentRound int64 } func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, error) { @@ -39,6 +48,8 @@ func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, e arg.TeamB, arg.TournamentID, arg.ResultsLog, + arg.Rounds, + arg.CurrentRound, ) var i Game err := row.Scan( @@ -49,6 +60,8 @@ func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, e &i.TeamB, &i.Winner, &i.Status, + &i.Rounds, + &i.CurrentRound, &i.ResultsLog, &i.CreatedAt, &i.UpdatedAt, @@ -57,7 +70,7 @@ func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, e } const getGameByID = `-- name: GetGameByID :one -select id, name, tournament_id, team_a, team_b, winner, status, results_log, created_at, updated_at +select id, name, tournament_id, team_a, team_b, winner, status, rounds, current_round, results_log, created_at, updated_at from games where id = ? ` @@ -73,6 +86,8 @@ func (q *Queries) GetGameByID(ctx context.Context, id int64) (Game, error) { &i.TeamB, &i.Winner, &i.Status, + &i.Rounds, + &i.CurrentRound, &i.ResultsLog, &i.CreatedAt, &i.UpdatedAt, @@ -82,15 +97,17 @@ func (q *Queries) GetGameByID(ctx context.Context, id int64) (Game, error) { const updateGame = `-- name: UpdateGame :one update games -set name = ?, - team_a = ?, - team_b = ?, - winner = ?, - status = ?, - results_log = ?, +set name = ?, + team_a = ?, + team_b = ?, + winner = ?, + status = ?, + results_log = ?, + rounds = ?, + current_round = ?, tournament_id = ? where id = ? -returning id, name, tournament_id, team_a, team_b, winner, status, results_log, created_at, updated_at +returning id, name, tournament_id, team_a, team_b, winner, status, rounds, current_round, results_log, created_at, updated_at ` type UpdateGameParams struct { @@ -98,8 +115,10 @@ type UpdateGameParams struct { TeamA sql.NullInt64 TeamB sql.NullInt64 Winner sql.NullInt64 - Status sql.NullString - ResultsLog interface{} + Status string + ResultsLog json.RawMessage + Rounds json.RawMessage + CurrentRound int64 TournamentID sql.NullInt64 ID int64 } @@ -112,6 +131,8 @@ func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) (Game, e arg.Winner, arg.Status, arg.ResultsLog, + arg.Rounds, + arg.CurrentRound, arg.TournamentID, arg.ID, ) @@ -124,6 +145,8 @@ func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) (Game, e &i.TeamB, &i.Winner, &i.Status, + &i.Rounds, + &i.CurrentRound, &i.ResultsLog, &i.CreatedAt, &i.UpdatedAt, diff --git a/internal/database/models.gen.go b/internal/database/models.gen.go index dfd1f17..9a605c0 100644 --- a/internal/database/models.gen.go +++ b/internal/database/models.gen.go @@ -6,6 +6,7 @@ package database import ( "database/sql" + "encoding/json" ) type Coach struct { @@ -24,8 +25,10 @@ type Game struct { TeamA sql.NullInt64 TeamB sql.NullInt64 Winner sql.NullInt64 - Status sql.NullString - ResultsLog interface{} + Status string + Rounds json.RawMessage + CurrentRound int64 + ResultsLog json.RawMessage CreatedAt sql.NullTime UpdatedAt sql.NullTime } diff --git a/internal/database/queries/game.sql b/internal/database/queries/game.sql index 7870133..746866b 100644 --- a/internal/database/queries/game.sql +++ b/internal/database/queries/game.sql @@ -3,11 +3,17 @@ insert into games (name, team_a, team_b, tournament_id, - results_log) + results_log, + status, + rounds, + current_round) values (?, ?, ?, ?, + ?, + 'pending', + ?, ?) returning *; @@ -18,12 +24,14 @@ where id = ?; -- name: UpdateGame :one update games -set name = ?, - team_a = ?, - team_b = ?, - winner = ?, - status = ?, - results_log = ?, +set name = ?, + team_a = ?, + team_b = ?, + winner = ?, + status = ?, + results_log = ?, + rounds = ?, + current_round = ?, tournament_id = ? where id = ? returning *; \ No newline at end of file diff --git a/internal/database/schema/game.sql b/internal/database/schema/game.sql index afe3efd..4a23c88 100644 --- a/internal/database/schema/game.sql +++ b/internal/database/schema/game.sql @@ -13,8 +13,10 @@ create table if not exists games ( team_a integer references teams(id), team_b integer references teams(id), winner integer references teams(id), - status varchar(255), - results_log string not null, + status varchar(255) not null, + rounds text not null, + current_round integer not null default 0, + results_log text not null, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); diff --git a/internal/games/game.go b/internal/games/game.go index 4e0bd71..42006d6 100644 --- a/internal/games/game.go +++ b/internal/games/game.go @@ -1,11 +1,14 @@ package games -type NewGameParams struct { - TeamA TeamConfig - TeamB TeamConfig -} +import ( + "database/sql" + "encoding/json" + "fmt" + + "github.com/code-gorilla-au/rush/internal/database" +) -func NewGame(params NewGameParams) Game { +func generateRounds(teamA TeamConfig, teamB TeamConfig) [10]Round { rounds := [10]Round{} for i := range rounds { @@ -13,35 +16,31 @@ func NewGame(params NewGameParams) Game { r.FillSquad( LanesConfig{ - TeamID: params.TeamA.TeamID, - Lane1: params.TeamA.Formations[i].Lane1, - Lane2: params.TeamA.Formations[i].Lane2, - Lane3: params.TeamA.Formations[i].Lane3, + TeamID: teamA.TeamID, + Lane1: teamA.Formations[i].Lane1, + Lane2: teamA.Formations[i].Lane2, + Lane3: teamA.Formations[i].Lane3, }, LanesConfig{ - TeamID: params.TeamB.TeamID, - Lane1: params.TeamB.Formations[i].Lane1, - Lane2: params.TeamB.Formations[i].Lane2, - Lane3: params.TeamB.Formations[i].Lane3, + TeamID: teamB.TeamID, + Lane1: teamB.Formations[i].Lane1, + Lane2: teamB.Formations[i].Lane2, + Lane3: teamB.Formations[i].Lane3, }, ) rounds[i] = r } - return Game{ - rounds: rounds, - currentRound: 0, - results: []Result{}, - } + return rounds } func (g *Game) ResolveRound(roll RollFn) (Result, error) { - if g.currentRound >= len(g.rounds) { + if g.currentRound <= 0 || g.currentRound >= int64(len(g.rounds)) { return Result{}, ErrNoRounds } - round := g.rounds[g.currentRound] + round := g.rounds[int(g.currentRound)] result := round.ResolveLanes(roll) g.results = append(g.results, result) @@ -49,3 +48,87 @@ func (g *Game) ResolveRound(roll RollFn) (Result, error) { return result, nil } + +func fromGameModel(m database.Game) (Game, error) { + + var rounds [10]Round + if err := json.Unmarshal(m.Rounds, &rounds); err != nil { + return Game{}, fmt.Errorf("failed to unmarshal game model: %w", err) + } + + var results []Result + if err := json.Unmarshal(m.ResultsLog, &results); err != nil { + return Game{}, fmt.Errorf("failed to unmarshal results log: %w", err) + } + + return Game{ + id: m.ID, + name: m.Name, + tournamentID: &m.TournamentID.Int64, + teamA: m.TeamA.Int64, + teamB: m.TeamB.Int64, + winner: &m.Winner.Int64, + status: GameStatus(m.Status), + rounds: rounds, + currentRound: m.CurrentRound, + results: results, + createdAt: m.CreatedAt.Time, + updatedAt: m.UpdatedAt.Time, + }, nil +} + +func toGameModel(g Game) (database.Game, error) { + resolvedTournamentID := sql.NullInt64{} + if g.tournamentID != nil { + resolvedTournamentID = sql.NullInt64{ + Int64: *g.tournamentID, + Valid: true, + } + } + + resolvedWinner := sql.NullInt64{} + if g.winner != nil { + resolvedWinner = sql.NullInt64{ + Int64: *g.winner, + Valid: true, + } + } + + roundData, err := json.Marshal(g.results) + if err != nil { + return database.Game{}, fmt.Errorf("failed to marshal game model: %w", err) + } + + resultsData, err := json.Marshal(g.results) + if err != nil { + return database.Game{}, fmt.Errorf("failed to marshal results model: %w", err) + } + + return database.Game{ + ID: g.id, + Name: g.name, + TournamentID: resolvedTournamentID, + TeamA: sql.NullInt64{ + Int64: g.teamA, + Valid: true, + }, + TeamB: sql.NullInt64{ + Int64: g.teamB, + Valid: true, + }, + Winner: resolvedWinner, + Status: string(g.status), + Rounds: roundData, + CurrentRound: g.currentRound, + ResultsLog: resultsData, + CreatedAt: sql.NullTime{ + Time: g.createdAt, + Valid: true, + }, + UpdatedAt: sql.NullTime{ + Time: g.updatedAt, + Valid: true, + }, + }, nil + +} diff --git a/internal/games/service.go b/internal/games/service.go index 45d9ec2..54b5666 100644 --- a/internal/games/service.go +++ b/internal/games/service.go @@ -1,7 +1,83 @@ package games +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + + "github.com/code-gorilla-au/rush/internal/database" +) + func NewService(store Store) Service { return Service{ Store: store, } } + +type NewGameParams struct { + TeamA TeamConfig + TeamB TeamConfig + TournamentID *int64 +} + +func (s *Service) NewGame(ctx context.Context, params NewGameParams) (Game, error) { + resolvedTournamentID := sql.NullInt64{} + if params.TournamentID != nil { + resolvedTournamentID = sql.NullInt64{ + Int64: *params.TournamentID, + Valid: true, + } + } + + roundsJsonData, rErr := json.Marshal(generateRounds(params.TeamA, params.TeamB)) + if rErr != nil { + return Game{}, fmt.Errorf("failed to marshal rounds json data: %w", rErr) + } + + model, err := s.Store.CreateGame(ctx, database.CreateGameParams{ + Name: fmt.Sprintf("%s VS %s", params.TeamA.TeamName, params.TeamB.TeamName), + TeamA: sql.NullInt64{ + Int64: params.TeamA.TeamID, + Valid: true, + }, + TeamB: sql.NullInt64{ + Int64: params.TeamB.TeamID, + Valid: true, + }, + TournamentID: resolvedTournamentID, + ResultsLog: nil, + Rounds: roundsJsonData, + CurrentRound: 0, + }) + if err != nil { + return Game{}, fmt.Errorf("creating game: %w", err) + } + + return fromGameModel(model) +} + +func (s *Service) UpdateGame(ctx context.Context, game Game) (Game, error) { + model, err := toGameModel(game) + if err != nil { + return Game{}, fmt.Errorf("failed to convert game to model: %w", err) + } + + updated, err := s.Store.UpdateGame(ctx, database.UpdateGameParams{ + Name: model.Name, + TeamA: model.TeamA, + TeamB: model.TeamB, + Winner: model.Winner, + Status: model.Status, + ResultsLog: model.ResultsLog, + Rounds: model.Rounds, + CurrentRound: model.CurrentRound, + TournamentID: model.TournamentID, + ID: model.ID, + }) + if err != nil { + return Game{}, fmt.Errorf("updating game: %w", err) + } + + return fromGameModel(updated) +} diff --git a/internal/games/types.go b/internal/games/types.go index 55fadca..58853ff 100644 --- a/internal/games/types.go +++ b/internal/games/types.go @@ -2,23 +2,36 @@ package games import ( "errors" + "time" "github.com/code-gorilla-au/rush/internal/playbooks" ) -const ( - MaxRounds = 10 -) - type Service struct { Store Store } +type GameStatus string + +const ( + StatusPending GameStatus = "pending" + StatusRunning GameStatus = "running" + StatusComplete GameStatus = "complete" +) + type Game struct { id int64 + name string + tournamentID *int64 + teamA int64 + teamB int64 + winner *int64 + status GameStatus rounds [10]Round - currentRound int + currentRound int64 results []Result + createdAt time.Time + updatedAt time.Time } type Round struct { diff --git a/sqlc.yaml b/sqlc.yaml index 2503b60..c54d8dc 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -11,3 +11,8 @@ sql: output_models_file_name: "models.gen.go" output_querier_file_name: "querier.gen.go" output_files_suffix: ".gen" + overrides: + - column: "games.rounds" + go_type: "encoding/json.RawMessage" + - column: "games.results_log" + go_type: "encoding/json.RawMessage" From 5aae5183777be564b6a3737c29934ab76658be58 Mon Sep 17 00:00:00 2001 From: frag223 Date: Thu, 25 Jun 2026 21:30:21 +1000 Subject: [PATCH 71/98] update --- cmd/rush/main.go | 4 +++- internal/games/service.go | 4 ++-- internal/ui/app.go | 5 ++++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/cmd/rush/main.go b/cmd/rush/main.go index 01dfd1e..2af7fa5 100644 --- a/cmd/rush/main.go +++ b/cmd/rush/main.go @@ -8,6 +8,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/code-gorilla-au/env" "github.com/code-gorilla-au/rush/internal/database" + "github.com/code-gorilla-au/rush/internal/games" "github.com/code-gorilla-au/rush/internal/playbooks" "github.com/code-gorilla-au/rush/internal/teams" "github.com/code-gorilla-au/rush/internal/tournament" @@ -41,6 +42,7 @@ func main() { queries := database.New(db) teamsSvc := teams.NewTeamsService(queries) playbooksSvc := playbooks.NewPlaybooksService(queries) + gameSvc := games.NewService(queries) tournamentSvc := tournament.NewAITeamService(teamsSvc, playbooksSvc) go func() { @@ -59,7 +61,7 @@ func main() { } }() - p := tea.NewProgram(ui.New(teamsSvc, playbooksSvc)) + p := tea.NewProgram(ui.New(teamsSvc, playbooksSvc, gameSvc)) if _, err = p.Run(); err != nil { slog.Error("Failed to run program", "error", err) os.Exit(1) diff --git a/internal/games/service.go b/internal/games/service.go index 54b5666..b6d1941 100644 --- a/internal/games/service.go +++ b/internal/games/service.go @@ -9,8 +9,8 @@ import ( "github.com/code-gorilla-au/rush/internal/database" ) -func NewService(store Store) Service { - return Service{ +func NewService(store Store) *Service { + return &Service{ Store: store, } } diff --git a/internal/ui/app.go b/internal/ui/app.go index bebfeca..7c9b377 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -4,6 +4,7 @@ import ( "context" tea "charm.land/bubbletea/v2" + "github.com/code-gorilla-au/rush/internal/games" "github.com/code-gorilla-au/rush/internal/playbooks" "github.com/code-gorilla-au/rush/internal/teams" ) @@ -53,10 +54,11 @@ type RootModel struct { globalState *GlobalState teamsSvc *teams.Service playbookSvc *playbooks.Service + gameSvc *games.Service } // New returns a new UI model. -func New(teamsService *teams.Service, playbookService *playbooks.Service) RootModel { +func New(teamsService *teams.Service, playbookService *playbooks.Service, gameService *games.Service) RootModel { state := &GlobalState{} return RootModel{ @@ -72,6 +74,7 @@ func New(teamsService *teams.Service, playbookService *playbooks.Service) RootMo globalState: state, teamsSvc: teamsService, playbookSvc: playbookService, + gameSvc: gameService, } } From ba3aefd2bda9e645a3396ca9ded9809ebc49c059 Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 26 Jun 2026 06:56:02 +1000 Subject: [PATCH 72/98] update docs --- docs/features.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 docs/features.md diff --git a/docs/features.md b/docs/features.md new file mode 100644 index 0000000..809c1c2 --- /dev/null +++ b/docs/features.md @@ -0,0 +1,8 @@ +# Features + +List of features intended for the project. + +- [ ] Single battle +- [ ] Tournament mode +- [ ] Record battles +- [ ] Team statistics \ No newline at end of file From 766e5985f4ebcf64039b0b8bbb8c2d087cfd53eb Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 26 Jun 2026 07:09:20 +1000 Subject: [PATCH 73/98] update test --- internal/ui/app_test.go | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index b6c4235..f17c0f2 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -7,11 +7,12 @@ import ( tea "charm.land/bubbletea/v2" "github.com/code-gorilla-au/odize" "github.com/code-gorilla-au/rush/internal/database" + "github.com/code-gorilla-au/rush/internal/games" "github.com/code-gorilla-au/rush/internal/playbooks" "github.com/code-gorilla-au/rush/internal/teams" ) -func setupServices(t *testing.T) (*teams.Service, *playbooks.Service) { +func setupServices(t *testing.T) (*teams.Service, *playbooks.Service, *games.Service) { db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatalf("failed to open database: %v", err) @@ -23,7 +24,7 @@ func setupServices(t *testing.T) (*teams.Service, *playbooks.Service) { } queries := database.New(db) - return teams.NewTeamsService(queries), playbooks.NewPlaybooksService(queries) + return teams.NewTeamsService(queries), playbooks.NewPlaybooksService(queries), games.NewService(queries) } func TestTheme(t *testing.T) { @@ -48,19 +49,19 @@ func TestNew(t *testing.T) { err := group. Test("New should initialize model with IceTheme", func(t *testing.T) { - s, ps := setupServices(t) - m := New(s, ps) + s, ps, gs := setupServices(t) + m := New(s, ps, gs) odize.AssertTrue(t, m.theme.Logo.GetForeground() != nil) }). Test("Init should return a command", func(t *testing.T) { - s, ps := setupServices(t) - m := New(s, ps) + s, ps, gs := setupServices(t) + m := New(s, ps, gs) cmd := m.Init() odize.AssertTrue(t, cmd != nil) }). Test("Update should handle Quit keys", func(t *testing.T) { - s, ps := setupServices(t) - m := New(s, ps) + s, ps, gs := setupServices(t) + m := New(s, ps, gs) _, cmd := m.Update(tea.KeyPressMsg{Text: "q"}) odize.AssertTrue(t, cmd != nil) @@ -68,8 +69,8 @@ func TestNew(t *testing.T) { odize.AssertTrue(t, cmd != nil) }). Test("Update should handle WindowSizeMsg", func(t *testing.T) { - s, ps := setupServices(t) - m := New(s, ps) + s, ps, gs := setupServices(t) + m := New(s, ps, gs) newModel, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 50}) updatedModel := newModel.(RootModel) odize.AssertTrue(t, updatedModel.width == 100) From 5796e967074315cc8db058de8e24738409f0dda7 Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 26 Jun 2026 20:48:09 +1000 Subject: [PATCH 74/98] adding create page --- internal/ui/app.go | 65 ++++--- internal/ui/components/playbook_form.go | 98 +++++++++++ internal/ui/page_locker_playbooks_create.go | 179 ++++++++++++++++++++ internal/ui/page_locker_playbooks_edit.go | 124 +++++--------- internal/ui/page_locker_playbooks_list.go | 4 +- 5 files changed, 358 insertions(+), 112 deletions(-) create mode 100644 internal/ui/components/playbook_form.go create mode 100644 internal/ui/page_locker_playbooks_create.go diff --git a/internal/ui/app.go b/internal/ui/app.go index 7c9b377..e199316 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -27,6 +27,7 @@ const ( PageLockerRoom PageLockerPlayers PageLockerPlaybooksList + PageLockerPlaybooksCreate PageLockerPlaybooksEdit ) @@ -40,21 +41,22 @@ func (m *GlobalState) Context() context.Context { } type RootModel struct { - ctx context.Context - width int - height int - theme IceTheme - currentPage Page - pageTitle tea.Model - pageCreateCoach tea.Model - pageLockerRoom tea.Model - pageLockerPlayers tea.Model - pageLockerPlaybooksList tea.Model - pageLockerPlaybooksEdit tea.Model - globalState *GlobalState - teamsSvc *teams.Service - playbookSvc *playbooks.Service - gameSvc *games.Service + ctx context.Context + width int + height int + theme IceTheme + currentPage Page + pageTitle tea.Model + pageCreateCoach tea.Model + pageLockerRoom tea.Model + pageLockerPlayers tea.Model + pageLockerPlaybooksList tea.Model + pageLockerPlaybooksCreate tea.Model + pageLockerPlaybooksEdit tea.Model + globalState *GlobalState + teamsSvc *teams.Service + playbookSvc *playbooks.Service + gameSvc *games.Service } // New returns a new UI model. @@ -62,19 +64,20 @@ func New(teamsService *teams.Service, playbookService *playbooks.Service, gameSe state := &GlobalState{} return RootModel{ - ctx: context.Background(), - theme: NewIceTheme(), - currentPage: PageTitle, - pageTitle: NewModelTitle(state), - pageCreateCoach: NewModelCreateCoach(state, teamsService), - pageLockerRoom: NewModelLockerRoom(state), - pageLockerPlayers: NewModelLockerPlayers(state, teamsService), - pageLockerPlaybooksList: NewModelLockerPlaybooksList(state, playbookService), - pageLockerPlaybooksEdit: NewModelLockerPlaybooksEdit(state, playbookService), - globalState: state, - teamsSvc: teamsService, - playbookSvc: playbookService, - gameSvc: gameService, + ctx: context.Background(), + theme: NewIceTheme(), + currentPage: PageTitle, + pageTitle: NewModelTitle(state), + pageCreateCoach: NewModelCreateCoach(state, teamsService), + pageLockerRoom: NewModelLockerRoom(state), + pageLockerPlayers: NewModelLockerPlayers(state, teamsService), + pageLockerPlaybooksList: NewModelLockerPlaybooksList(state, playbookService), + pageLockerPlaybooksCreate: NewModelLockerPlaybooksCreate(state, playbookService), + pageLockerPlaybooksEdit: NewModelLockerPlaybooksEdit(state, playbookService), + globalState: state, + teamsSvc: teamsService, + playbookSvc: playbookService, + gameSvc: gameService, } } @@ -122,6 +125,8 @@ func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) m.pageLockerPlaybooksList, cmd = m.pageLockerPlaybooksList.Update(msg) cmds = append(cmds, cmd) + m.pageLockerPlaybooksCreate, cmd = m.pageLockerPlaybooksCreate.Update(msg) + cmds = append(cmds, cmd) m.pageLockerPlaybooksEdit, cmd = m.pageLockerPlaybooksEdit.Update(msg) cmds = append(cmds, cmd) } @@ -138,6 +143,8 @@ func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.pageLockerPlayers, cmd = m.pageLockerPlayers.Update(msg) case PageLockerPlaybooksList: m.pageLockerPlaybooksList, cmd = m.pageLockerPlaybooksList.Update(msg) + case PageLockerPlaybooksCreate: + m.pageLockerPlaybooksCreate, cmd = m.pageLockerPlaybooksCreate.Update(msg) case PageLockerPlaybooksEdit: m.pageLockerPlaybooksEdit, cmd = m.pageLockerPlaybooksEdit.Update(msg) } @@ -162,6 +169,8 @@ func (m RootModel) View() tea.View { return m.pageLockerPlayers.View() case PageLockerPlaybooksList: return m.pageLockerPlaybooksList.View() + case PageLockerPlaybooksCreate: + return m.pageLockerPlaybooksCreate.View() case PageLockerPlaybooksEdit: return m.pageLockerPlaybooksEdit.View() } diff --git a/internal/ui/components/playbook_form.go b/internal/ui/components/playbook_form.go new file mode 100644 index 0000000..34a80d5 --- /dev/null +++ b/internal/ui/components/playbook_form.go @@ -0,0 +1,98 @@ +package components + +import ( + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +type PlaybookForm struct { + NameInput textinput.Model + DescriptionInput textinput.Model + focusIndex int +} + +func NewPlaybookForm() PlaybookForm { + name := textinput.New() + name.Placeholder = "Playbook Name" + name.Focus() + + description := textinput.New() + description.Placeholder = "Description" + + return PlaybookForm{ + NameInput: name, + DescriptionInput: description, + focusIndex: 0, + } +} + +func (f *PlaybookForm) Update(msg tea.Msg) (PlaybookForm, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "tab", "shift+tab", "up", "down": + s := msg.String() + if s == "up" || s == "shift+tab" { + f.focusIndex-- + } else { + f.focusIndex++ + } + + if f.focusIndex > 1 { + f.focusIndex = 0 + } else if f.focusIndex < 0 { + f.focusIndex = 1 + } + + var cmd tea.Cmd + if f.focusIndex == 0 { + cmd = f.NameInput.Focus() + f.DescriptionInput.Blur() + } else { + f.NameInput.Blur() + cmd = f.DescriptionInput.Focus() + } + cmds = append(cmds, cmd) + } + } + + var cmd tea.Cmd + f.NameInput, cmd = f.NameInput.Update(msg) + cmds = append(cmds, cmd) + + f.DescriptionInput, cmd = f.DescriptionInput.Update(msg) + cmds = append(cmds, cmd) + + return *f, tea.Batch(cmds...) +} + +func (f *PlaybookForm) View() string { + return lipgloss.JoinVertical( + lipgloss.Left, + "Name:", + f.NameInput.View(), + "", + "Description:", + f.DescriptionInput.View(), + ) +} + +func (f *PlaybookForm) SetValues(name, description string) { + f.NameInput.SetValue(name) + f.DescriptionInput.SetValue(description) +} + +func (f *PlaybookForm) Values() (string, string) { + return f.NameInput.Value(), f.DescriptionInput.Value() +} + +func (f *PlaybookForm) Reset() { + f.NameInput.Reset() + f.DescriptionInput.Reset() + f.focusIndex = 0 + f.NameInput.Focus() + f.DescriptionInput.Blur() +} diff --git a/internal/ui/page_locker_playbooks_create.go b/internal/ui/page_locker_playbooks_create.go new file mode 100644 index 0000000..aed4a28 --- /dev/null +++ b/internal/ui/page_locker_playbooks_create.go @@ -0,0 +1,179 @@ +package ui + +import ( + "fmt" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/code-gorilla-au/rush/internal/playbooks" + "github.com/code-gorilla-au/rush/internal/ui/components" +) + +type lockerPlaybooksCreateKeyMap struct { + components.CommonKeys + Back key.Binding + Enter key.Binding +} + +func (k lockerPlaybooksCreateKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Enter, k.Back, k.Quit} +} + +func (k lockerPlaybooksCreateKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Enter, k.Back, k.Quit}, + } +} + +func newLockerPlaybooksCreateKeyMap() lockerPlaybooksCreateKeyMap { + return lockerPlaybooksCreateKeyMap{ + CommonKeys: components.NewCommonKeys(), + Back: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "confirm"), + ), + } +} + +type ModelLockerPlaybooksCreate struct { + width int + height int + theme IceTheme + globalState *GlobalState + playbookSvc *playbooks.Service + keys lockerPlaybooksCreateKeyMap + footer components.Footer + playbookID int64 + playbookForm components.PlaybookForm + formations []playbooks.Formation + err error +} + +func NewModelLockerPlaybooksCreate(state *GlobalState, playbookSvc *playbooks.Service) *ModelLockerPlaybooksCreate { + return &ModelLockerPlaybooksCreate{ + theme: NewIceTheme(), + globalState: state, + playbookSvc: playbookSvc, + keys: newLockerPlaybooksCreateKeyMap(), + footer: components.NewFooter(newLockerPlaybooksCreateKeyMap()), + playbookForm: components.NewPlaybookForm(), + } +} + +func (m *ModelLockerPlaybooksCreate) Init() tea.Cmd { + return nil +} + +func (m *ModelLockerPlaybooksCreate) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case MsgStateUpdated: + m.globalState.Coach = msg.Coach + m.globalState.Team = msg.Team + case MsgSwitchPage: + if msg.NewPage == PageLockerPlaybooksCreate { + if msg.Playbook != nil { + m.load(msg.Playbook) + } else { + m.reset() + } + } + case error: + m.err = msg + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + case key.Matches(msg, m.keys.Back): + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageLockerPlaybooksList} + } + case key.Matches(msg, m.keys.Enter): + name, description := m.playbookForm.Values() + if name != "" { + return m, func() tea.Msg { + return MsgSwitchPage{ + NewPage: PageLockerPlaybooksEdit, + Playbook: &playbooks.Playbook{ + ID: m.playbookID, + Name: name, + Description: description, + Formations: m.formations, + }, + } + } + } + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.footer.Update(msg) + } + + var cmd tea.Cmd + m.playbookForm, cmd = m.playbookForm.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m *ModelLockerPlaybooksCreate) reset() { + m.playbookForm.Reset() + m.formations = nil + m.playbookID = 0 + m.err = nil +} + +func (m *ModelLockerPlaybooksCreate) load(p *playbooks.Playbook) { + m.playbookForm.SetValues(p.Name, p.Description) + m.formations = p.Formations + m.playbookID = p.ID + m.err = nil +} + +func (m *ModelLockerPlaybooksCreate) View() tea.View { + view := tea.NewView("") + view.AltScreen = true + + var content string + title := "PLAYBOOK NAME" + + if m.err != nil { + content = m.theme.Logo.Render(fmt.Sprintf("Error: %v", m.err)) + } else { + if m.playbookID != 0 { + title = "EDIT PLAYBOOK" + } else { + title = "CREATE PLAYBOOK" + } + content = "Enter playbook details:\n\n" + m.playbookForm.View() + } + + mainContent := lipgloss.JoinVertical( + lipgloss.Center, + m.theme.Logo.Render(title), + "", + content, + "", + m.footer.View(m.theme.Footer), + ) + + centeredContent := lipgloss.Place( + m.width, m.height, + lipgloss.Center, lipgloss.Center, + mainContent, + ) + + view.Content = m.theme.Base. + Width(m.width). + Height(m.height). + Render(centeredContent) + + return view +} diff --git a/internal/ui/page_locker_playbooks_edit.go b/internal/ui/page_locker_playbooks_edit.go index 65882a1..234b958 100644 --- a/internal/ui/page_locker_playbooks_edit.go +++ b/internal/ui/page_locker_playbooks_edit.go @@ -4,20 +4,12 @@ import ( "fmt" "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/code-gorilla-au/rush/internal/playbooks" "github.com/code-gorilla-au/rush/internal/ui/components" ) -type editPlaybookMode int - -const ( - modeCreateName editPlaybookMode = iota - modeAddFormations -) - type lockerPlaybooksEditKeyMap struct { components.CommonKeys Back key.Binding @@ -63,26 +55,21 @@ type ModelLockerPlaybooksEdit struct { footer components.Footer formationList components.FormationList selectedFormationList components.FormationList - mode editPlaybookMode activeList int // 0 for formationList, 1 for selectedFormationList playbookID int64 - newPlaybookName textinput.Model + playbookName string + playbookDescription string newFormations []playbooks.Formation err error } func NewModelLockerPlaybooksEdit(state *GlobalState, playbookSvc *playbooks.Service) *ModelLockerPlaybooksEdit { - ti := textinput.New() - ti.Placeholder = "Playbook Name" - ti.Focus() - return &ModelLockerPlaybooksEdit{ - theme: NewIceTheme(), - globalState: state, - playbookSvc: playbookSvc, - keys: newLockerPlaybooksEditKeyMap(), - footer: components.NewFooter(newLockerPlaybooksEditKeyMap()), - newPlaybookName: ti, + theme: NewIceTheme(), + globalState: state, + playbookSvc: playbookSvc, + keys: newLockerPlaybooksEditKeyMap(), + footer: components.NewFooter(newLockerPlaybooksEditKeyMap()), formationList: components.NewFormationList(components.FormationListConfig{ Title: "Available Formations", Items: playbooks.Formations(), @@ -124,17 +111,19 @@ func (m *ModelLockerPlaybooksEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.Quit): return m, tea.Quit case key.Matches(msg, m.keys.Back): - if m.mode == modeCreateName { - return m, func() tea.Msg { - return MsgSwitchPage{NewPage: PageLockerPlaybooksList} - } + if m.formationList.IsFiltering() { + break } - if m.mode == modeAddFormations { - if m.formationList.IsFiltering() { - break + return m, func() tea.Msg { + return MsgSwitchPage{ + NewPage: PageLockerPlaybooksCreate, + Playbook: &playbooks.Playbook{ + ID: m.playbookID, + Name: m.playbookName, + Description: m.playbookDescription, + Formations: m.newFormations, + }, } - m.mode = modeCreateName - return m, nil } } case tea.WindowSizeMsg: @@ -145,49 +134,29 @@ func (m *ModelLockerPlaybooksEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.footer.Update(msg) } - switch m.mode { - case modeCreateName: - cmds = append(cmds, m.updateCreateName(msg)) - case modeAddFormations: - cmds = append(cmds, m.updateAddFormations(msg)) - } + cmds = append(cmds, m.updateAddFormations(msg)) return m, tea.Batch(cmds...) } func (m *ModelLockerPlaybooksEdit) reset() { - m.newPlaybookName.Reset() - m.newPlaybookName.Focus() m.newFormations = nil m.playbookID = 0 - m.mode = modeCreateName + m.playbookName = "" + m.playbookDescription = "" m.selectedFormationList.SetItems(nil) m.err = nil } func (m *ModelLockerPlaybooksEdit) load(p *playbooks.Playbook) { - m.newPlaybookName.SetValue(p.Name) - m.newPlaybookName.Focus() m.newFormations = p.Formations m.playbookID = p.ID - m.mode = modeCreateName + m.playbookName = p.Name + m.playbookDescription = p.Description m.selectedFormationList.SetItems(m.newFormations) m.err = nil } -func (m *ModelLockerPlaybooksEdit) updateCreateName(msg tea.Msg) tea.Cmd { - switch msg := msg.(type) { - case tea.KeyMsg: - if msg.String() == "enter" && m.newPlaybookName.Value() != "" { - m.mode = modeAddFormations - return nil - } - } - var cmd tea.Cmd - m.newPlaybookName, cmd = m.newPlaybookName.Update(msg) - return cmd -} - func (m *ModelLockerPlaybooksEdit) updateAddFormations(msg tea.Msg) tea.Cmd { var cmds []tea.Cmd @@ -245,15 +214,17 @@ func (m *ModelLockerPlaybooksEdit) savePlaybook() tea.Msg { var err error if m.playbookID != 0 { _, err = m.playbookSvc.UpdatePlaybook(m.globalState.Context(), m.playbookID, playbooks.PlaybookParams{ - TeamID: m.globalState.Team.ID, - Name: m.newPlaybookName.Value(), - Formations: m.newFormations, + TeamID: m.globalState.Team.ID, + Name: m.playbookName, + Description: m.playbookDescription, + Formations: m.newFormations, }) } else { _, err = m.playbookSvc.CreatePlaybook(m.globalState.Context(), playbooks.PlaybookParams{ - TeamID: m.globalState.Team.ID, - Name: m.newPlaybookName.Value(), - Formations: m.newFormations, + TeamID: m.globalState.Team.ID, + Name: m.playbookName, + Description: m.playbookDescription, + Formations: m.newFormations, }) } if err != nil { @@ -267,32 +238,21 @@ func (m *ModelLockerPlaybooksEdit) View() tea.View { view.AltScreen = true var content string - title := "PLAYBOOKS" + title := "ALLOCATE FORMATIONS" if m.err != nil { content = m.theme.Logo.Render(fmt.Sprintf("Error: %v", m.err)) } else { - switch m.mode { - case modeCreateName: - if m.playbookID != 0 { - title = "EDIT PLAYBOOK" - } else { - title = "CREATE PLAYBOOK" - } - content = "Enter playbook name:\n\n" + m.newPlaybookName.View() - case modeAddFormations: - title = "ADD FORMATIONS" - m.formationList.SetSize(m.width/2-4, m.height-15) - m.selectedFormationList.SetSize(m.width/2-4, m.height-15) - - content = lipgloss.JoinHorizontal( - lipgloss.Top, - m.formationList.View(), - lipgloss.NewStyle().Width(2).Render(""), - m.selectedFormationList.View(), - ) - content += "\n\n" + m.theme.Footer.Render(fmt.Sprintf("%d/10 formations • Tab: switch • Enter: add/remove • 's': save", len(m.newFormations))) - } + m.formationList.SetSize(m.width/2-4, m.height-15) + m.selectedFormationList.SetSize(m.width/2-4, m.height-15) + + content = lipgloss.JoinHorizontal( + lipgloss.Top, + m.formationList.View(), + lipgloss.NewStyle().Width(2).Render(""), + m.selectedFormationList.View(), + ) + content += "\n\n" + m.theme.Footer.Render(fmt.Sprintf("%d/10 formations • Tab: switch • Enter: add/remove • 's': save", len(m.newFormations))) } mainContent := lipgloss.JoinVertical( diff --git a/internal/ui/page_locker_playbooks_list.go b/internal/ui/page_locker_playbooks_list.go index 6fd7905..8a2056a 100644 --- a/internal/ui/page_locker_playbooks_list.go +++ b/internal/ui/page_locker_playbooks_list.go @@ -127,7 +127,7 @@ func (m *ModelLockerPlaybooksList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.New): if !m.playbookList.IsFiltering() { return m, func() tea.Msg { - return MsgSwitchPage{NewPage: PageLockerPlaybooksEdit} + return MsgSwitchPage{NewPage: PageLockerPlaybooksCreate} } } case key.Matches(msg, m.keys.Delete): @@ -163,7 +163,7 @@ func (m *ModelLockerPlaybooksList) handleRouteEditPlaybook() (tea.Model, tea.Cmd if selected != nil { return m, func() tea.Msg { return MsgSwitchPage{ - NewPage: PageLockerPlaybooksEdit, + NewPage: PageLockerPlaybooksCreate, Playbook: selected, } }, true From 428d5cc84823994f33a7ce79ec4cbd7e774b193c Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 26 Jun 2026 20:57:56 +1000 Subject: [PATCH 75/98] adding method --- internal/games/game.go | 4 ++++ internal/games/service.go | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/internal/games/game.go b/internal/games/game.go index 42006d6..2ff84cf 100644 --- a/internal/games/game.go +++ b/internal/games/game.go @@ -49,6 +49,10 @@ func (g *Game) ResolveRound(roll RollFn) (Result, error) { return result, nil } +func (g *Game) IsGameComplete() bool { + return g.currentRound >= int64(len(g.rounds)) +} + func fromGameModel(m database.Game) (Game, error) { var rounds [10]Round diff --git a/internal/games/service.go b/internal/games/service.go index b6d1941..f523b32 100644 --- a/internal/games/service.go +++ b/internal/games/service.go @@ -81,3 +81,14 @@ func (s *Service) UpdateGame(ctx context.Context, game Game) (Game, error) { return fromGameModel(updated) } + +func (s *Service) CompleteGame(ctx context.Context, game Game) (Game, error) { + game.status = StatusComplete + + updatedGame, err := s.UpdateGame(ctx, game) + if err != nil { + return Game{}, fmt.Errorf("completing game: %w", err) + } + + return updatedGame, nil +} From 0544778a7ca70aff9094abda9c1d8741801a2772 Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 26 Jun 2026 21:09:24 +1000 Subject: [PATCH 76/98] refactoring title menu --- internal/ui/components/title_menu.go | 100 +++++++++++++++++++++++++++ internal/ui/page_title.go | 38 +++++----- 2 files changed, 116 insertions(+), 22 deletions(-) create mode 100644 internal/ui/components/title_menu.go diff --git a/internal/ui/components/title_menu.go b/internal/ui/components/title_menu.go new file mode 100644 index 0000000..02fc0a8 --- /dev/null +++ b/internal/ui/components/title_menu.go @@ -0,0 +1,100 @@ +package components + +import ( + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +type TitleItem int + +const ( + ItemCreateCoach TitleItem = iota + ItemLockerRoom + ItemNewTournament + ItemNewBattle + ItemNewSettings +) + +func (i TitleItem) String() string { + switch i { + case ItemCreateCoach: + return "Create Coach" + case ItemLockerRoom: + return "Locker Room" + case ItemNewTournament: + return "New Tournament" + case ItemNewBattle: + return "New Battle" + case ItemNewSettings: + return "Settings" + } + return "" +} + +type TitleMenu struct { + cursor int + items []TitleItem +} + +func NewTitleMenu(hasCoach bool) TitleMenu { + var items []TitleItem + if !hasCoach { + items = []TitleItem{ItemCreateCoach} + } else { + items = []TitleItem{ItemLockerRoom, ItemNewTournament, ItemNewBattle, ItemNewSettings} + } + return TitleMenu{ + items: items, + } +} + +func (m *TitleMenu) Update(msg tea.Msg) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.items)-1 { + m.cursor++ + } + } + } +} + +func (m *TitleMenu) View(itemStyle lipgloss.Style, selectedStyle lipgloss.Style) string { + var s string + for i, item := range m.items { + if i == m.cursor { + s += selectedStyle.Render("> " + item.String()) + } else { + s += itemStyle.Render(" " + item.String()) + } + if i < len(m.items)-1 { + s += "\n" + } + } + return s +} + +func (m *TitleMenu) SelectedItem() TitleItem { + if m.cursor < 0 || m.cursor >= len(m.items) { + return -1 + } + return m.items[m.cursor] +} + +func (m *TitleMenu) SetHasCoach(hasCoach bool) { + var items []TitleItem + if !hasCoach { + items = []TitleItem{ItemCreateCoach} + } else { + items = []TitleItem{ItemLockerRoom, ItemNewTournament, ItemNewBattle, ItemNewSettings} + } + m.items = items + if m.cursor >= len(m.items) { + m.cursor = 0 + } +} diff --git a/internal/ui/page_title.go b/internal/ui/page_title.go index 1347d24..5e19018 100644 --- a/internal/ui/page_title.go +++ b/internal/ui/page_title.go @@ -12,30 +12,25 @@ import ( type titleKeyMap struct { components.CommonKeys - CreateCoach key.Binding - LockerRoom key.Binding + Enter key.Binding } func (k titleKeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.CreateCoach, k.LockerRoom, k.Quit} + return []key.Binding{k.Enter, k.Quit} } func (k titleKeyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ - {k.CreateCoach, k.LockerRoom, k.Quit}, + {k.Enter, k.Quit}, } } func newTitleKeyMap() titleKeyMap { return titleKeyMap{ CommonKeys: components.NewCommonKeys(), - CreateCoach: key.NewBinding( - key.WithKeys("c"), - key.WithHelp("c", "create coach"), - ), - LockerRoom: key.NewBinding( - key.WithKeys("l"), - key.WithHelp("l", "locker room"), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select"), ), } } @@ -47,6 +42,7 @@ type ModelTitle struct { globalState *GlobalState keys titleKeyMap footer components.Footer + menu components.TitleMenu } func NewModelTitle(globalState *GlobalState) *ModelTitle { @@ -55,6 +51,7 @@ func NewModelTitle(globalState *GlobalState) *ModelTitle { globalState: globalState, keys: keys, footer: components.NewFooter(keys), + menu: components.NewTitleMenu(globalState.Coach != nil), } } @@ -67,23 +64,25 @@ func (m ModelTitle) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case MsgStateUpdated: m.globalState.Coach = msg.Coach m.globalState.Team = msg.Team + m.menu.SetHasCoach(m.globalState.Coach != nil) case tea.KeyMsg: switch { case key.Matches(msg, m.keys.Quit): return m, tea.Quit - case key.Matches(msg, m.keys.CreateCoach): - if m.globalState.Coach == nil { + case key.Matches(msg, m.keys.Enter): + selected := m.menu.SelectedItem() + switch selected { + case components.ItemCreateCoach: return m, func() tea.Msg { return MsgSwitchPage{NewPage: PageCreateCoach} } - } - case key.Matches(msg, m.keys.LockerRoom): - if m.globalState.Coach != nil { + case components.ItemLockerRoom: return m, func() tea.Msg { return MsgSwitchPage{NewPage: PageLockerRoom} } } } + m.menu.Update(msg) case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height @@ -108,12 +107,7 @@ func (m ModelTitle) View() tea.View { styledLogo := m.theme.Logo.Render(strings.Trim(logo, "\n")) - var navigation string - if m.globalState.Coach == nil { - navigation = "Press " + m.theme.Hotkey.Render("c") + " to create a coach" - } else { - navigation = m.theme.Button.Render("Locker Room (l)") - } + navigation := m.menu.View(m.theme.Base, m.theme.Hotkey) content := lipgloss.JoinVertical( lipgloss.Center, From 1dc27af4d026e2622a11342ba59fb8e400509ade Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 26 Jun 2026 21:11:02 +1000 Subject: [PATCH 77/98] fixing var --- internal/ui/page_title.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/ui/page_title.go b/internal/ui/page_title.go index 5e19018..3a5b664 100644 --- a/internal/ui/page_title.go +++ b/internal/ui/page_title.go @@ -60,16 +60,16 @@ func (m ModelTitle) Init() tea.Cmd { } func (m ModelTitle) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { + switch vMsg := msg.(type) { case MsgStateUpdated: - m.globalState.Coach = msg.Coach - m.globalState.Team = msg.Team + m.globalState.Coach = vMsg.Coach + m.globalState.Team = vMsg.Team m.menu.SetHasCoach(m.globalState.Coach != nil) case tea.KeyMsg: switch { - case key.Matches(msg, m.keys.Quit): + case key.Matches(vMsg, m.keys.Quit): return m, tea.Quit - case key.Matches(msg, m.keys.Enter): + case key.Matches(vMsg, m.keys.Enter): selected := m.menu.SelectedItem() switch selected { case components.ItemCreateCoach: @@ -82,11 +82,11 @@ func (m ModelTitle) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } - m.menu.Update(msg) + m.menu.Update(vMsg) case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - m.footer.Update(msg) + m.width = vMsg.Width + m.height = vMsg.Height + m.footer.Update(vMsg) } return m, nil From a59410a2d92f88f00986d82adcebd34f73e57433 Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 26 Jun 2026 21:37:07 +1000 Subject: [PATCH 78/98] adding pages --- internal/ui/app.go | 27 ++++++++++++++ .../ui/components/locker_room_list_test.go | 2 +- internal/ui/components/title_menu.go | 28 +++++++------- internal/ui/page_new_battle.go | 37 +++++++++++++++++++ internal/ui/page_new_tournament.go | 36 ++++++++++++++++++ internal/ui/page_title.go | 16 +++++++- internal/ui/page_title_settings.go | 37 +++++++++++++++++++ 7 files changed, 166 insertions(+), 17 deletions(-) create mode 100644 internal/ui/page_new_battle.go create mode 100644 internal/ui/page_new_tournament.go create mode 100644 internal/ui/page_title_settings.go diff --git a/internal/ui/app.go b/internal/ui/app.go index e199316..aed7ba8 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -29,6 +29,9 @@ const ( PageLockerPlaybooksList PageLockerPlaybooksCreate PageLockerPlaybooksEdit + PageNewTournament + PageNewBattle + PageTitleSettings ) type GlobalState struct { @@ -53,6 +56,9 @@ type RootModel struct { pageLockerPlaybooksList tea.Model pageLockerPlaybooksCreate tea.Model pageLockerPlaybooksEdit tea.Model + pageNewTournament tea.Model + pageNewBattle tea.Model + pageTitleSettings tea.Model globalState *GlobalState teamsSvc *teams.Service playbookSvc *playbooks.Service @@ -74,6 +80,9 @@ func New(teamsService *teams.Service, playbookService *playbooks.Service, gameSe pageLockerPlaybooksList: NewModelLockerPlaybooksList(state, playbookService), pageLockerPlaybooksCreate: NewModelLockerPlaybooksCreate(state, playbookService), pageLockerPlaybooksEdit: NewModelLockerPlaybooksEdit(state, playbookService), + pageNewTournament: NewModelNewTournament(state), + pageNewBattle: NewModelNewBattle(state), + pageTitleSettings: NewModelTitleSettings(state), globalState: state, teamsSvc: teamsService, playbookSvc: playbookService, @@ -129,6 +138,12 @@ func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) m.pageLockerPlaybooksEdit, cmd = m.pageLockerPlaybooksEdit.Update(msg) cmds = append(cmds, cmd) + m.pageNewTournament, cmd = m.pageNewTournament.Update(msg) + cmds = append(cmds, cmd) + m.pageNewBattle, cmd = m.pageNewBattle.Update(msg) + cmds = append(cmds, cmd) + m.pageTitleSettings, cmd = m.pageTitleSettings.Update(msg) + cmds = append(cmds, cmd) } var cmd tea.Cmd @@ -147,6 +162,12 @@ func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.pageLockerPlaybooksCreate, cmd = m.pageLockerPlaybooksCreate.Update(msg) case PageLockerPlaybooksEdit: m.pageLockerPlaybooksEdit, cmd = m.pageLockerPlaybooksEdit.Update(msg) + case PageNewTournament: + m.pageNewTournament, cmd = m.pageNewTournament.Update(msg) + case PageNewBattle: + m.pageNewBattle, cmd = m.pageNewBattle.Update(msg) + case PageTitleSettings: + m.pageTitleSettings, cmd = m.pageTitleSettings.Update(msg) } cmds = append(cmds, cmd) @@ -173,6 +194,12 @@ func (m RootModel) View() tea.View { return m.pageLockerPlaybooksCreate.View() case PageLockerPlaybooksEdit: return m.pageLockerPlaybooksEdit.View() + case PageNewTournament: + return m.pageNewTournament.View() + case PageNewBattle: + return m.pageNewBattle.View() + case PageTitleSettings: + return m.pageTitleSettings.View() } return tea.NewView("unknown page") diff --git a/internal/ui/components/locker_room_list_test.go b/internal/ui/components/locker_room_list_test.go index 10a2abf..3d1edba 100644 --- a/internal/ui/components/locker_room_list_test.go +++ b/internal/ui/components/locker_room_list_test.go @@ -15,7 +15,7 @@ func TestLockerRoomList(t *testing.T) { odize.AssertEqual(t, 3, len(l.items)) odize.AssertEqual(t, ItemPlayers, l.items[0]) odize.AssertEqual(t, ItemPlaybooks, l.items[1]) - odize.AssertEqual(t, ItemSettings, l.items[2]) + odize.AssertEqual(t, TitleItemSettings, l.items[2]) }) group.Test("Update should move cursor down", func(t *testing.T) { diff --git a/internal/ui/components/title_menu.go b/internal/ui/components/title_menu.go index 02fc0a8..73138b1 100644 --- a/internal/ui/components/title_menu.go +++ b/internal/ui/components/title_menu.go @@ -8,24 +8,24 @@ import ( type TitleItem int const ( - ItemCreateCoach TitleItem = iota - ItemLockerRoom - ItemNewTournament - ItemNewBattle - ItemNewSettings + TitleItemCreateCoach TitleItem = iota + TitleItemLockerRoom + TitleItemNewTournament + TitleItemNewBattle + TitleItemSettings ) func (i TitleItem) String() string { switch i { - case ItemCreateCoach: + case TitleItemCreateCoach: return "Create Coach" - case ItemLockerRoom: + case TitleItemLockerRoom: return "Locker Room" - case ItemNewTournament: + case TitleItemNewTournament: return "New Tournament" - case ItemNewBattle: + case TitleItemNewBattle: return "New Battle" - case ItemNewSettings: + case TitleItemSettings: return "Settings" } return "" @@ -39,9 +39,9 @@ type TitleMenu struct { func NewTitleMenu(hasCoach bool) TitleMenu { var items []TitleItem if !hasCoach { - items = []TitleItem{ItemCreateCoach} + items = []TitleItem{TitleItemCreateCoach} } else { - items = []TitleItem{ItemLockerRoom, ItemNewTournament, ItemNewBattle, ItemNewSettings} + items = []TitleItem{TitleItemLockerRoom, TitleItemNewTournament, TitleItemNewBattle, TitleItemSettings} } return TitleMenu{ items: items, @@ -89,9 +89,9 @@ func (m *TitleMenu) SelectedItem() TitleItem { func (m *TitleMenu) SetHasCoach(hasCoach bool) { var items []TitleItem if !hasCoach { - items = []TitleItem{ItemCreateCoach} + items = []TitleItem{TitleItemCreateCoach} } else { - items = []TitleItem{ItemLockerRoom, ItemNewTournament, ItemNewBattle, ItemNewSettings} + items = []TitleItem{TitleItemLockerRoom, TitleItemNewTournament, TitleItemNewBattle, TitleItemSettings} } m.items = items if m.cursor >= len(m.items) { diff --git a/internal/ui/page_new_battle.go b/internal/ui/page_new_battle.go new file mode 100644 index 0000000..1522ac7 --- /dev/null +++ b/internal/ui/page_new_battle.go @@ -0,0 +1,37 @@ +package ui + +import tea "charm.land/bubbletea/v2" + +type ModelNewBattle struct { + width int + height int + theme IceTheme + globalState *GlobalState +} + +func NewModelNewBattle(globalState *GlobalState) *ModelNewBattle { + return &ModelNewBattle{ + globalState: globalState, + } +} + +func (m *ModelNewBattle) Init() tea.Cmd { + return nil +} + +func (m *ModelNewBattle) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch vMsg := msg.(type) { + case tea.WindowSizeMsg: + m.width = vMsg.Width + m.height = vMsg.Height + case MsgStateUpdated: + m.globalState.Coach = vMsg.Coach + m.globalState.Team = vMsg.Team + } + + return m, nil +} + +func (m *ModelNewBattle) View() tea.View { + return tea.NewView("new battle") +} diff --git a/internal/ui/page_new_tournament.go b/internal/ui/page_new_tournament.go new file mode 100644 index 0000000..4589a5c --- /dev/null +++ b/internal/ui/page_new_tournament.go @@ -0,0 +1,36 @@ +package ui + +import tea "charm.land/bubbletea/v2" + +type ModelNewTournament struct { + width int + height int + theme IceTheme + globalState *GlobalState +} + +func NewModelNewTournament(globalState *GlobalState) *ModelNewTournament { + return &ModelNewTournament{ + globalState: globalState, + } +} + +func (m *ModelNewTournament) Init() tea.Cmd { + return nil +} + +func (m *ModelNewTournament) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch vMsg := msg.(type) { + case tea.WindowSizeMsg: + m.width = vMsg.Width + m.height = vMsg.Height + case MsgStateUpdated: + m.globalState.Coach = vMsg.Coach + m.globalState.Team = vMsg.Team + } + return m, nil +} + +func (m *ModelNewTournament) View() tea.View { + return tea.NewView("new tournament") +} diff --git a/internal/ui/page_title.go b/internal/ui/page_title.go index 3a5b664..881ba1e 100644 --- a/internal/ui/page_title.go +++ b/internal/ui/page_title.go @@ -72,14 +72,26 @@ func (m ModelTitle) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(vMsg, m.keys.Enter): selected := m.menu.SelectedItem() switch selected { - case components.ItemCreateCoach: + case components.TitleItemCreateCoach: return m, func() tea.Msg { return MsgSwitchPage{NewPage: PageCreateCoach} } - case components.ItemLockerRoom: + case components.TitleItemLockerRoom: return m, func() tea.Msg { return MsgSwitchPage{NewPage: PageLockerRoom} } + case components.TitleItemNewTournament: + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageNewTournament} + } + case components.TitleItemNewBattle: + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageNewBattle} + } + case components.TitleItemSettings: + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageTitleSettings} + } } } m.menu.Update(vMsg) diff --git a/internal/ui/page_title_settings.go b/internal/ui/page_title_settings.go new file mode 100644 index 0000000..9a162a6 --- /dev/null +++ b/internal/ui/page_title_settings.go @@ -0,0 +1,37 @@ +package ui + +import tea "charm.land/bubbletea/v2" + +type ModelTitleSettings struct { + width int + height int + theme IceTheme + globalState *GlobalState +} + +func NewModelTitleSettings(globalState *GlobalState) *ModelTitleSettings { + return &ModelTitleSettings{ + globalState: globalState, + } +} + +func (m *ModelTitleSettings) Init() tea.Cmd { + return nil +} + +func (m *ModelTitleSettings) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch vMsg := msg.(type) { + case tea.WindowSizeMsg: + m.width = vMsg.Width + m.height = vMsg.Height + case MsgStateUpdated: + m.globalState.Coach = vMsg.Coach + m.globalState.Team = vMsg.Team + } + + return m, nil +} + +func (m *ModelTitleSettings) View() tea.View { + return tea.NewView("Settings") +} From 7114a3a81a4759794ef91be060f8b1c4eda4a46f Mon Sep 17 00:00:00 2001 From: frag223 Date: Fri, 26 Jun 2026 22:21:24 +1000 Subject: [PATCH 79/98] adding battle selection. WIP not working --- internal/database/queries/team.sql | 3 + internal/database/team.sql.gen.go | 34 +++ internal/teams/interfaces.go | 1 + internal/teams/service.go | 9 + internal/teams/transforms.go | 4 + internal/tournament/interfaces.go | 2 +- internal/tournament/teams.go | 19 +- internal/ui/app.go | 16 +- internal/ui/components/title_menu.go | 8 +- internal/ui/page_new_battle.go | 37 ---- internal/ui/page_new_battle_selection.go | 252 +++++++++++++++++++++++ internal/ui/page_title.go | 4 +- 12 files changed, 325 insertions(+), 64 deletions(-) delete mode 100644 internal/ui/page_new_battle.go create mode 100644 internal/ui/page_new_battle_selection.go diff --git a/internal/database/queries/team.sql b/internal/database/queries/team.sql index 25cb92f..d70f8d1 100644 --- a/internal/database/queries/team.sql +++ b/internal/database/queries/team.sql @@ -13,6 +13,9 @@ UPDATE coaches SET is_default = false WHERE is_default = true; -- name: GetCoaches :many SELECT * FROM coaches; +-- name: GetAICoaches :many +SELECT * FROM coaches WHERE is_human = false; + -- name: CreateCoach :one INSERT INTO coaches (name, is_human, is_default) VALUES (?, ?, ?) RETURNING *; diff --git a/internal/database/team.sql.gen.go b/internal/database/team.sql.gen.go index e81144e..34717fe 100644 --- a/internal/database/team.sql.gen.go +++ b/internal/database/team.sql.gen.go @@ -125,6 +125,40 @@ func (q *Queries) DeleteTeam(ctx context.Context, id int64) error { return err } +const getAICoaches = `-- name: GetAICoaches :many +SELECT id, name, is_default, is_human, created_at, updated_at FROM coaches WHERE is_human = false +` + +func (q *Queries) GetAICoaches(ctx context.Context) ([]Coach, error) { + rows, err := q.db.QueryContext(ctx, getAICoaches) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Coach + for rows.Next() { + var i Coach + if err := rows.Scan( + &i.ID, + &i.Name, + &i.IsDefault, + &i.IsHuman, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getCoach = `-- name: GetCoach :one SELECT id, name, is_default, coach_id, created_at, updated_at FROM teams WHERE id = ? ` diff --git a/internal/teams/interfaces.go b/internal/teams/interfaces.go index 9c8720f..dee6859 100644 --- a/internal/teams/interfaces.go +++ b/internal/teams/interfaces.go @@ -18,6 +18,7 @@ type CoachStore interface { ClearDefaultCoach(ctx context.Context) error CreateCoach(ctx context.Context, arg database.CreateCoachParams) (database.Coach, error) GetCoaches(ctx context.Context) ([]database.Coach, error) + GetAICoaches(ctx context.Context) ([]database.Coach, error) SetDefaultCoach(ctx context.Context, id int64) error SetDefaultTeam(ctx context.Context, id int64) error } diff --git a/internal/teams/service.go b/internal/teams/service.go index 1fd79db..95a8659 100644 --- a/internal/teams/service.go +++ b/internal/teams/service.go @@ -51,6 +51,15 @@ func (s *Service) ListCoaches(ctx context.Context) ([]Coach, error) { return fromCoachesModel(models), nil } +func (s *Service) ListAICoaches(ctx context.Context) ([]Coach, error) { + models, err := s.store.GetAICoaches(ctx) + if err != nil { + return nil, fmt.Errorf("listing ai coaches: %w", err) + } + + return fromCoachesModel(models), nil +} + func (s *Service) GetDefaultCoach(ctx context.Context) (Coach, error) { model, err := s.store.GetDefaultCoach(ctx) if err != nil { diff --git a/internal/teams/transforms.go b/internal/teams/transforms.go index d32469d..aa1aa86 100644 --- a/internal/teams/transforms.go +++ b/internal/teams/transforms.go @@ -8,6 +8,8 @@ func fromCoachModel(m database.Coach) Coach { return Coach{ ID: m.ID, Name: m.Name, + IsDefault: m.IsDefault.Bool, + IsHuman: m.IsHuman.Bool, CreatedAt: m.CreatedAt.Time, UpdatedAt: m.UpdatedAt.Time, } @@ -33,6 +35,7 @@ func fromTeamModel(m database.Team, p []database.Player) Team { return Team{ ID: m.ID, Name: m.Name, + CoachID: int(m.CoachID.Int64), Players: players, CreatedAt: m.CreatedAt.Time, UpdatedAt: m.UpdatedAt.Time, @@ -43,6 +46,7 @@ func fromPlayerModel(m database.Player) Player { return Player{ ID: m.ID, Name: m.Name, + TeamID: int(m.TeamID.Int64), CreatedAt: m.CreatedAt.Time, UpdatedAt: m.UpdatedAt.Time, } diff --git a/internal/tournament/interfaces.go b/internal/tournament/interfaces.go index 56aa057..8747757 100644 --- a/internal/tournament/interfaces.go +++ b/internal/tournament/interfaces.go @@ -9,7 +9,7 @@ import ( type TeamCreator interface { CreateCoach(ctx context.Context, params teams.CreateCoachParams) (teams.Coach, error) - ListCoaches(ctx context.Context) ([]teams.Coach, error) + ListAICoaches(ctx context.Context) ([]teams.Coach, error) CreateTeam(ctx context.Context, name string, coachID int64, isDefault bool) (teams.Team, error) } diff --git a/internal/tournament/teams.go b/internal/tournament/teams.go index 5c51463..77b446e 100644 --- a/internal/tournament/teams.go +++ b/internal/tournament/teams.go @@ -3,7 +3,6 @@ package tournament import ( "context" "fmt" - "slices" "github.com/code-gorilla-au/rush/internal/playbooks" "github.com/code-gorilla-au/rush/internal/teams" @@ -12,7 +11,7 @@ import ( const totalTeams = 12 -type AITeam struct { +type AIGenerationParams struct { CoachName string `faker:"name"` TeamName string `faker:"username"` Persona string @@ -134,15 +133,11 @@ func NewAITeamService(teamsSvc TeamCreator, playbooksSvc PlaybookCreator) *AITea } func (s *AITeamService) HasAICoaches(ctx context.Context) (bool, error) { - coaches, err := s.teamsSvc.ListCoaches(ctx) + aiCoaches, err := s.teamsSvc.ListAICoaches(ctx) if err != nil { return false, fmt.Errorf("listing coaches: %w", err) } - aiCoaches := slices.DeleteFunc(coaches, func(c teams.Coach) bool { - return c.IsHuman == false - }) - return len(aiCoaches) > 0, nil } @@ -161,7 +156,7 @@ func (s *AITeamService) GenerateTeams(ctx context.Context) error { return nil } -func (s *AITeamService) generateTeam(ctx context.Context, team AITeam) error { +func (s *AITeamService) generateTeam(ctx context.Context, team AIGenerationParams) error { coach, err := s.teamsSvc.CreateCoach(ctx, teams.CreateCoachParams{ Name: team.CoachName, IsHuman: false, @@ -171,7 +166,7 @@ func (s *AITeamService) generateTeam(ctx context.Context, team AITeam) error { return fmt.Errorf("creating AI coach: %w", err) } - aiTeam, err := s.teamsSvc.CreateTeam(ctx, team.TeamName, coach.ID, false) + aiTeam, err := s.teamsSvc.CreateTeam(ctx, team.TeamName, coach.ID, true) if err != nil { return fmt.Errorf("creating team: %w", err) } @@ -188,8 +183,8 @@ func (s *AITeamService) generateTeam(ctx context.Context, team AITeam) error { return nil } -func generateAITeams() ([]AITeam, error) { - aiTeams := make([]AITeam, totalTeams) +func generateAITeams() ([]AIGenerationParams, error) { + aiTeams := make([]AIGenerationParams, totalTeams) allFormations := playbooks.Formations() formationMap := make(map[string]playbooks.Formation) for _, f := range allFormations { @@ -197,7 +192,7 @@ func generateAITeams() ([]AITeam, error) { } for i := 0; i < totalTeams; i++ { - tmpTeam := AITeam{} + tmpTeam := AIGenerationParams{} if err := faker.FakeData(&tmpTeam); err != nil { return nil, fmt.Errorf("generating team: %w", err) diff --git a/internal/ui/app.go b/internal/ui/app.go index aed7ba8..3523346 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -30,7 +30,7 @@ const ( PageLockerPlaybooksCreate PageLockerPlaybooksEdit PageNewTournament - PageNewBattle + PageNewBattleSelection PageTitleSettings ) @@ -57,7 +57,7 @@ type RootModel struct { pageLockerPlaybooksCreate tea.Model pageLockerPlaybooksEdit tea.Model pageNewTournament tea.Model - pageNewBattle tea.Model + pageNewBattleSelection tea.Model pageTitleSettings tea.Model globalState *GlobalState teamsSvc *teams.Service @@ -81,7 +81,7 @@ func New(teamsService *teams.Service, playbookService *playbooks.Service, gameSe pageLockerPlaybooksCreate: NewModelLockerPlaybooksCreate(state, playbookService), pageLockerPlaybooksEdit: NewModelLockerPlaybooksEdit(state, playbookService), pageNewTournament: NewModelNewTournament(state), - pageNewBattle: NewModelNewBattle(state), + pageNewBattleSelection: NewModelNewBattleSelection(state, teamsService, playbookService), pageTitleSettings: NewModelTitleSettings(state), globalState: state, teamsSvc: teamsService, @@ -140,7 +140,7 @@ func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) m.pageNewTournament, cmd = m.pageNewTournament.Update(msg) cmds = append(cmds, cmd) - m.pageNewBattle, cmd = m.pageNewBattle.Update(msg) + m.pageNewBattleSelection, cmd = m.pageNewBattleSelection.Update(msg) cmds = append(cmds, cmd) m.pageTitleSettings, cmd = m.pageTitleSettings.Update(msg) cmds = append(cmds, cmd) @@ -164,8 +164,8 @@ func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.pageLockerPlaybooksEdit, cmd = m.pageLockerPlaybooksEdit.Update(msg) case PageNewTournament: m.pageNewTournament, cmd = m.pageNewTournament.Update(msg) - case PageNewBattle: - m.pageNewBattle, cmd = m.pageNewBattle.Update(msg) + case PageNewBattleSelection: + m.pageNewBattleSelection, cmd = m.pageNewBattleSelection.Update(msg) case PageTitleSettings: m.pageTitleSettings, cmd = m.pageTitleSettings.Update(msg) } @@ -196,8 +196,8 @@ func (m RootModel) View() tea.View { return m.pageLockerPlaybooksEdit.View() case PageNewTournament: return m.pageNewTournament.View() - case PageNewBattle: - return m.pageNewBattle.View() + case PageNewBattleSelection: + return m.pageNewBattleSelection.View() case PageTitleSettings: return m.pageTitleSettings.View() } diff --git a/internal/ui/components/title_menu.go b/internal/ui/components/title_menu.go index 73138b1..a51d1a1 100644 --- a/internal/ui/components/title_menu.go +++ b/internal/ui/components/title_menu.go @@ -11,7 +11,7 @@ const ( TitleItemCreateCoach TitleItem = iota TitleItemLockerRoom TitleItemNewTournament - TitleItemNewBattle + TitleItemNewBattleSelection TitleItemSettings ) @@ -23,7 +23,7 @@ func (i TitleItem) String() string { return "Locker Room" case TitleItemNewTournament: return "New Tournament" - case TitleItemNewBattle: + case TitleItemNewBattleSelection: return "New Battle" case TitleItemSettings: return "Settings" @@ -41,7 +41,7 @@ func NewTitleMenu(hasCoach bool) TitleMenu { if !hasCoach { items = []TitleItem{TitleItemCreateCoach} } else { - items = []TitleItem{TitleItemLockerRoom, TitleItemNewTournament, TitleItemNewBattle, TitleItemSettings} + items = []TitleItem{TitleItemLockerRoom, TitleItemNewTournament, TitleItemNewBattleSelection, TitleItemSettings} } return TitleMenu{ items: items, @@ -91,7 +91,7 @@ func (m *TitleMenu) SetHasCoach(hasCoach bool) { if !hasCoach { items = []TitleItem{TitleItemCreateCoach} } else { - items = []TitleItem{TitleItemLockerRoom, TitleItemNewTournament, TitleItemNewBattle, TitleItemSettings} + items = []TitleItem{TitleItemLockerRoom, TitleItemNewTournament, TitleItemNewBattleSelection, TitleItemSettings} } m.items = items if m.cursor >= len(m.items) { diff --git a/internal/ui/page_new_battle.go b/internal/ui/page_new_battle.go deleted file mode 100644 index 1522ac7..0000000 --- a/internal/ui/page_new_battle.go +++ /dev/null @@ -1,37 +0,0 @@ -package ui - -import tea "charm.land/bubbletea/v2" - -type ModelNewBattle struct { - width int - height int - theme IceTheme - globalState *GlobalState -} - -func NewModelNewBattle(globalState *GlobalState) *ModelNewBattle { - return &ModelNewBattle{ - globalState: globalState, - } -} - -func (m *ModelNewBattle) Init() tea.Cmd { - return nil -} - -func (m *ModelNewBattle) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch vMsg := msg.(type) { - case tea.WindowSizeMsg: - m.width = vMsg.Width - m.height = vMsg.Height - case MsgStateUpdated: - m.globalState.Coach = vMsg.Coach - m.globalState.Team = vMsg.Team - } - - return m, nil -} - -func (m *ModelNewBattle) View() tea.View { - return tea.NewView("new battle") -} diff --git a/internal/ui/page_new_battle_selection.go b/internal/ui/page_new_battle_selection.go new file mode 100644 index 0000000..c838b92 --- /dev/null +++ b/internal/ui/page_new_battle_selection.go @@ -0,0 +1,252 @@ +package ui + +import ( + "fmt" + + "github.com/code-gorilla-au/rush/internal/playbooks" + "github.com/code-gorilla-au/rush/internal/teams" + "github.com/code-gorilla-au/rush/internal/ui/components" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +type selectionState int + +const ( + stateSelectingCoach selectionState = iota + stateSelectingPlaybook +) + +type battleSelectionKeyMap struct { + components.CommonKeys + Back key.Binding + Select key.Binding +} + +func (k battleSelectionKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Select, k.Back, k.Quit} +} + +func (k battleSelectionKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Select, k.Back, k.Quit}, + } +} + +func newBattleSelectionKeyMap() battleSelectionKeyMap { + return battleSelectionKeyMap{ + CommonKeys: components.NewCommonKeys(), + Back: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back"), + ), + Select: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select"), + ), + } +} + +type AICoachItem struct { + coach teams.Coach + team teams.Team +} + +type ModelNewBattleSelection struct { + width int + height int + theme IceTheme + globalState *GlobalState + teamsSvc *teams.Service + playbookSvc *playbooks.Service + state selectionState + aiCoaches []AICoachItem + selectedCoachIdx int + playbooks []playbooks.Playbook + playbookList components.PlaybookList + keys battleSelectionKeyMap + footer components.Footer + err error +} + +func NewModelNewBattleSelection(globalState *GlobalState, teamsSvc *teams.Service, playbookSvc *playbooks.Service) *ModelNewBattleSelection { + keys := newBattleSelectionKeyMap() + return &ModelNewBattleSelection{ + globalState: globalState, + teamsSvc: teamsSvc, + playbookSvc: playbookSvc, + theme: NewIceTheme(), + keys: keys, + footer: components.NewFooter(keys), + playbookList: components.NewPlaybookList(nil), + } +} + +func (m *ModelNewBattleSelection) Init() tea.Cmd { + return m.loadAICoaches +} + +func (m *ModelNewBattleSelection) loadAICoaches() tea.Msg { + coaches, err := m.teamsSvc.ListAICoaches(m.globalState.Context()) + if err != nil { + return err + } + + items := make([]AICoachItem, 0, len(coaches)) + for _, coach := range coaches { + team, err := m.teamsSvc.GetTeamByCoachID(m.globalState.Context(), coach.ID) + if err != nil { + continue + } + items = append(items, AICoachItem{coach: coach, team: team}) + } + return msgAICoachesLoaded{coaches: items} +} + +type msgAICoachesLoaded struct { + coaches []AICoachItem +} + +type msgPlaybooksLoaded struct { + playbooks []playbooks.Playbook +} + +func (m *ModelNewBattleSelection) loadPlaybooks(teamID int64) tea.Cmd { + return func() tea.Msg { + pbks, err := m.playbookSvc.GetTeamPlaybooks(m.globalState.Context(), teamID) + if err != nil { + return err + } + return msgPlaybooksLoaded{playbooks: pbks} + } +} + +func (m *ModelNewBattleSelection) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.footer.Update(msg) + m.playbookList.SetSize(m.width, m.height-10) + case msgAICoachesLoaded: + m.aiCoaches = msg.coaches + case msgPlaybooksLoaded: + m.playbooks = msg.playbooks + cmds = append(cmds, m.playbookList.SetItems(msg.playbooks)) + case error: + m.err = msg + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + case key.Matches(msg, m.keys.Back): + if m.state == stateSelectingPlaybook { + m.state = stateSelectingCoach + return m, nil + } + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageTitle} + } + case key.Matches(msg, m.keys.Select): + if m.state == stateSelectingCoach && len(m.aiCoaches) > 0 { + m.state = stateSelectingPlaybook + cmds = append(cmds, m.loadPlaybooks(m.aiCoaches[m.selectedCoachIdx].team.ID)) + } else if m.state == stateSelectingPlaybook { + selectedPlaybook := m.playbookList.SelectedItem() + if selectedPlaybook != nil { + // TODO: Start battle with selected coach and playbook + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageTitle} + } + } + } + case msg.String() == "up", msg.String() == "k": + if m.state == stateSelectingCoach && m.selectedCoachIdx > 0 { + m.selectedCoachIdx-- + } + case msg.String() == "down", msg.String() == "j": + if m.state == stateSelectingCoach && m.selectedCoachIdx < len(m.aiCoaches)-1 { + m.selectedCoachIdx++ + } + } + } + + if m.state == stateSelectingPlaybook { + var cmd tea.Cmd + m.playbookList, cmd = m.playbookList.Update(msg) + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +func (m *ModelNewBattleSelection) View() tea.View { + view := tea.NewView("") + view.AltScreen = true + + var content string + if m.err != nil { + content = m.theme.Logo.Render(fmt.Sprintf("Error: %v", m.err)) + } else if m.state == stateSelectingCoach { + content = m.viewCoaches() + } else { + content = m.viewPlaybooks() + } + + centeredContent := lipgloss.Place( + m.width, m.height, + lipgloss.Center, lipgloss.Center, + lipgloss.JoinVertical(lipgloss.Center, + m.theme.Logo.Render("NEW BATTLE"), + "", + content, + "", + m.footer.View(m.theme.Footer), + ), + ) + + view.Content = m.theme.Base. + Width(m.width). + Height(m.height). + Render(centeredContent) + + return view +} + +func (m *ModelNewBattleSelection) viewCoaches() string { + if len(m.aiCoaches) == 0 { + return "No AI coaches available" + } + + var s string + s += m.theme.Logo.Render("Select your opponent:") + "\n\n" + + for i, item := range m.aiCoaches { + avatar := components.NewCoachAvatar(&item.coach, &item.team) + avatarView := avatar.View(m.theme.CoachTeam, m.theme.CoachName) + if i == m.selectedCoachIdx { + s += m.theme.ListSelected.Render("> " + avatarView) + } else { + s += " " + avatarView + } + s += "\n\n" + } + + return s +} + +func (m *ModelNewBattleSelection) viewPlaybooks() string { + if len(m.playbooks) == 0 { + return "This coach has no playbooks" + } + + return lipgloss.JoinVertical(lipgloss.Left, + m.theme.Logo.Render(fmt.Sprintf("Select %s's playbook:", m.aiCoaches[m.selectedCoachIdx].coach.Name)), + "", + m.playbookList.View(), + ) +} diff --git a/internal/ui/page_title.go b/internal/ui/page_title.go index 881ba1e..63733cc 100644 --- a/internal/ui/page_title.go +++ b/internal/ui/page_title.go @@ -84,9 +84,9 @@ func (m ModelTitle) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, func() tea.Msg { return MsgSwitchPage{NewPage: PageNewTournament} } - case components.TitleItemNewBattle: + case components.TitleItemNewBattleSelection: return m, func() tea.Msg { - return MsgSwitchPage{NewPage: PageNewBattle} + return MsgSwitchPage{NewPage: PageNewBattleSelection} } case components.TitleItemSettings: return m, func() tea.Msg { From adbc8d341e0dca7fd8d07af11811145fcd3fc723 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 27 Jun 2026 07:05:21 +1000 Subject: [PATCH 80/98] wip passing deps --- internal/tournament/interfaces.go | 4 +- internal/tournament/teams.go | 47 +++++++++++++++++++++++- internal/ui/app.go | 30 ++++++++++----- internal/ui/page_new_battle_selection.go | 7 ++-- 4 files changed, 72 insertions(+), 16 deletions(-) diff --git a/internal/tournament/interfaces.go b/internal/tournament/interfaces.go index 8747757..7c7aacd 100644 --- a/internal/tournament/interfaces.go +++ b/internal/tournament/interfaces.go @@ -7,12 +7,14 @@ import ( "github.com/code-gorilla-au/rush/internal/teams" ) -type TeamCreator interface { +type TeamCreatLister interface { CreateCoach(ctx context.Context, params teams.CreateCoachParams) (teams.Coach, error) ListAICoaches(ctx context.Context) ([]teams.Coach, error) + GetTeamByCoachID(ctx context.Context, id int64) (teams.Team, error) CreateTeam(ctx context.Context, name string, coachID int64, isDefault bool) (teams.Team, error) } type PlaybookCreator interface { CreatePlaybook(ctx context.Context, params playbooks.PlaybookParams) (playbooks.Playbook, error) + GetTeamPlaybooks(ctx context.Context, teamID int64) ([]playbooks.Playbook, error) } diff --git a/internal/tournament/teams.go b/internal/tournament/teams.go index 77b446e..414a22b 100644 --- a/internal/tournament/teams.go +++ b/internal/tournament/teams.go @@ -121,11 +121,17 @@ var personas = []struct { } type AITeamService struct { - teamsSvc TeamCreator + teamsSvc TeamCreatLister playbooksSvc PlaybookCreator } -func NewAITeamService(teamsSvc TeamCreator, playbooksSvc PlaybookCreator) *AITeamService { +type AITeam struct { + Coach teams.Coach + Team teams.Team + Playbook playbooks.Playbook +} + +func NewAITeamService(teamsSvc TeamCreatLister, playbooksSvc PlaybookCreator) *AITeamService { return &AITeamService{ teamsSvc: teamsSvc, playbooksSvc: playbooksSvc, @@ -141,6 +147,43 @@ func (s *AITeamService) HasAICoaches(ctx context.Context) (bool, error) { return len(aiCoaches) > 0, nil } +func (s *AITeamService) ListAITeams(ctx context.Context) ([]AITeam, error) { + aiCoaches, err := s.teamsSvc.ListAICoaches(ctx) + if err != nil { + return nil, fmt.Errorf("listing coaches: %w", err) + } + + aiTeams := make([]AITeam, len(aiCoaches)) + for i, coach := range aiCoaches { + aiTeam, tErr := s.GetAITeam(ctx, coach) + if tErr != nil { + return nil, fmt.Errorf("getting team: %w", tErr) + } + + aiTeams[i] = aiTeam + } + + return aiTeams, nil +} + +func (s *AITeamService) GetAITeam(ctx context.Context, coach teams.Coach) (AITeam, error) { + aiTeam, tErr := s.teamsSvc.GetTeamByCoachID(ctx, coach.ID) + if tErr != nil { + return AITeam{}, fmt.Errorf("getting team: %w", tErr) + } + + aiPlaybook, pErr := s.playbooksSvc.GetTeamPlaybooks(ctx, aiTeam.ID) + if pErr != nil { + return AITeam{}, fmt.Errorf("getting team playbooks: %w", pErr) + } + + return AITeam{ + Coach: coach, + Team: aiTeam, + Playbook: aiPlaybook[0], + }, nil +} + func (s *AITeamService) GenerateTeams(ctx context.Context) error { aiTeams, err := generateAITeams() if err != nil { diff --git a/internal/ui/app.go b/internal/ui/app.go index 3523346..2ff38fa 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -7,6 +7,7 @@ import ( "github.com/code-gorilla-au/rush/internal/games" "github.com/code-gorilla-au/rush/internal/playbooks" "github.com/code-gorilla-au/rush/internal/teams" + "github.com/code-gorilla-au/rush/internal/tournament" ) type MsgStateUpdated struct { @@ -63,10 +64,18 @@ type RootModel struct { teamsSvc *teams.Service playbookSvc *playbooks.Service gameSvc *games.Service + aiTeamsSvc *tournament.AITeamService +} + +type Dependencies struct { + teamsSvc *teams.Service + playbookSvc *playbooks.Service + gameSvc *games.Service + aiTeamsSvc *tournament.AITeamService } // New returns a new UI model. -func New(teamsService *teams.Service, playbookService *playbooks.Service, gameService *games.Service) RootModel { +func New(deps Dependencies) RootModel { state := &GlobalState{} return RootModel{ @@ -74,19 +83,20 @@ func New(teamsService *teams.Service, playbookService *playbooks.Service, gameSe theme: NewIceTheme(), currentPage: PageTitle, pageTitle: NewModelTitle(state), - pageCreateCoach: NewModelCreateCoach(state, teamsService), + pageCreateCoach: NewModelCreateCoach(state, deps.teamsSvc), pageLockerRoom: NewModelLockerRoom(state), - pageLockerPlayers: NewModelLockerPlayers(state, teamsService), - pageLockerPlaybooksList: NewModelLockerPlaybooksList(state, playbookService), - pageLockerPlaybooksCreate: NewModelLockerPlaybooksCreate(state, playbookService), - pageLockerPlaybooksEdit: NewModelLockerPlaybooksEdit(state, playbookService), + pageLockerPlayers: NewModelLockerPlayers(state, deps.teamsSvc), + pageLockerPlaybooksList: NewModelLockerPlaybooksList(state, deps.playbookSvc), + pageLockerPlaybooksCreate: NewModelLockerPlaybooksCreate(state, deps.playbookSvc), + pageLockerPlaybooksEdit: NewModelLockerPlaybooksEdit(state, deps.playbookSvc), pageNewTournament: NewModelNewTournament(state), - pageNewBattleSelection: NewModelNewBattleSelection(state, teamsService, playbookService), + pageNewBattleSelection: NewModelNewBattleSelection(state, deps.teamsSvc, deps.playbookSvc), pageTitleSettings: NewModelTitleSettings(state), globalState: state, - teamsSvc: teamsService, - playbookSvc: playbookService, - gameSvc: gameService, + teamsSvc: deps.teamsSvc, + playbookSvc: deps.playbookSvc, + gameSvc: deps.gameSvc, + aiTeamsSvc: deps.aiTeamsSvc, } } diff --git a/internal/ui/page_new_battle_selection.go b/internal/ui/page_new_battle_selection.go index c838b92..d2dc907 100644 --- a/internal/ui/page_new_battle_selection.go +++ b/internal/ui/page_new_battle_selection.go @@ -5,6 +5,7 @@ import ( "github.com/code-gorilla-au/rush/internal/playbooks" "github.com/code-gorilla-au/rush/internal/teams" + "github.com/code-gorilla-au/rush/internal/tournament" "github.com/code-gorilla-au/rush/internal/ui/components" "charm.land/bubbles/v2/key" @@ -61,6 +62,7 @@ type ModelNewBattleSelection struct { globalState *GlobalState teamsSvc *teams.Service playbookSvc *playbooks.Service + aiTeamsSvc *tournament.AITeamService state selectionState aiCoaches []AICoachItem selectedCoachIdx int @@ -71,12 +73,11 @@ type ModelNewBattleSelection struct { err error } -func NewModelNewBattleSelection(globalState *GlobalState, teamsSvc *teams.Service, playbookSvc *playbooks.Service) *ModelNewBattleSelection { +func NewModelNewBattleSelection(globalState *GlobalState, aiTeamsSvc *tournament.AITeamService) *ModelNewBattleSelection { keys := newBattleSelectionKeyMap() return &ModelNewBattleSelection{ globalState: globalState, - teamsSvc: teamsSvc, - playbookSvc: playbookSvc, + aiTeamsSvc: aiTeamsSvc, theme: NewIceTheme(), keys: keys, footer: components.NewFooter(keys), From b827de4cde8d4802d787d99772cba2696b232dfc Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 27 Jun 2026 07:08:12 +1000 Subject: [PATCH 81/98] fixing tests --- cmd/rush/main.go | 7 ++- internal/tournament/teams_test.go | 22 ++++++--- internal/ui/app.go | 28 ++++++------ internal/ui/app_test.go | 45 ++++++++++++++----- .../ui/components/locker_room_list_test.go | 2 +- internal/ui/page_title_test.go | 8 ++-- 6 files changed, 76 insertions(+), 36 deletions(-) diff --git a/cmd/rush/main.go b/cmd/rush/main.go index 2af7fa5..fc5688b 100644 --- a/cmd/rush/main.go +++ b/cmd/rush/main.go @@ -61,7 +61,12 @@ func main() { } }() - p := tea.NewProgram(ui.New(teamsSvc, playbooksSvc, gameSvc)) + p := tea.NewProgram(ui.New(ui.Dependencies{ + TeamsSvc: teamsSvc, + PlaybookSvc: playbooksSvc, + GameSvc: gameSvc, + AiTeamsSvc: tournamentSvc, + })) if _, err = p.Run(); err != nil { slog.Error("Failed to run program", "error", err) os.Exit(1) diff --git a/internal/tournament/teams_test.go b/internal/tournament/teams_test.go index c594e0e..1e268f1 100644 --- a/internal/tournament/teams_test.go +++ b/internal/tournament/teams_test.go @@ -11,17 +11,22 @@ import ( ) type mockTeamCreator struct { - createCoachFunc func(ctx context.Context, params teams.CreateCoachParams) (teams.Coach, error) - listCoachesFunc func(ctx context.Context) ([]teams.Coach, error) - createTeamFunc func(ctx context.Context, name string, coachID int64, isDefault bool) (teams.Team, error) + createCoachFunc func(ctx context.Context, params teams.CreateCoachParams) (teams.Coach, error) + listAICoachesFunc func(ctx context.Context) ([]teams.Coach, error) + getTeamByCoachIDFunc func(ctx context.Context, id int64) (teams.Team, error) + createTeamFunc func(ctx context.Context, name string, coachID int64, isDefault bool) (teams.Team, error) } func (m *mockTeamCreator) CreateCoach(ctx context.Context, params teams.CreateCoachParams) (teams.Coach, error) { return m.createCoachFunc(ctx, params) } -func (m *mockTeamCreator) ListCoaches(ctx context.Context) ([]teams.Coach, error) { - return m.listCoachesFunc(ctx) +func (m *mockTeamCreator) ListAICoaches(ctx context.Context) ([]teams.Coach, error) { + return m.listAICoachesFunc(ctx) +} + +func (m *mockTeamCreator) GetTeamByCoachID(ctx context.Context, id int64) (teams.Team, error) { + return m.getTeamByCoachIDFunc(ctx, id) } func (m *mockTeamCreator) CreateTeam(ctx context.Context, name string, coachID int64, isDefault bool) (teams.Team, error) { @@ -29,13 +34,18 @@ func (m *mockTeamCreator) CreateTeam(ctx context.Context, name string, coachID i } type mockPlaybookCreator struct { - createPlaybookFunc func(ctx context.Context, params playbooks.PlaybookParams) (playbooks.Playbook, error) + createPlaybookFunc func(ctx context.Context, params playbooks.PlaybookParams) (playbooks.Playbook, error) + getTeamPlaybooksFunc func(ctx context.Context, teamID int64) ([]playbooks.Playbook, error) } func (m *mockPlaybookCreator) CreatePlaybook(ctx context.Context, params playbooks.PlaybookParams) (playbooks.Playbook, error) { return m.createPlaybookFunc(ctx, params) } +func (m *mockPlaybookCreator) GetTeamPlaybooks(ctx context.Context, teamID int64) ([]playbooks.Playbook, error) { + return m.getTeamPlaybooksFunc(ctx, teamID) +} + func TestGenerateAITeams(t *testing.T) { group := odize.NewGroup(t, nil) diff --git a/internal/ui/app.go b/internal/ui/app.go index 2ff38fa..abb2501 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -68,10 +68,10 @@ type RootModel struct { } type Dependencies struct { - teamsSvc *teams.Service - playbookSvc *playbooks.Service - gameSvc *games.Service - aiTeamsSvc *tournament.AITeamService + TeamsSvc *teams.Service + PlaybookSvc *playbooks.Service + GameSvc *games.Service + AiTeamsSvc *tournament.AITeamService } // New returns a new UI model. @@ -83,20 +83,20 @@ func New(deps Dependencies) RootModel { theme: NewIceTheme(), currentPage: PageTitle, pageTitle: NewModelTitle(state), - pageCreateCoach: NewModelCreateCoach(state, deps.teamsSvc), + pageCreateCoach: NewModelCreateCoach(state, deps.TeamsSvc), pageLockerRoom: NewModelLockerRoom(state), - pageLockerPlayers: NewModelLockerPlayers(state, deps.teamsSvc), - pageLockerPlaybooksList: NewModelLockerPlaybooksList(state, deps.playbookSvc), - pageLockerPlaybooksCreate: NewModelLockerPlaybooksCreate(state, deps.playbookSvc), - pageLockerPlaybooksEdit: NewModelLockerPlaybooksEdit(state, deps.playbookSvc), + pageLockerPlayers: NewModelLockerPlayers(state, deps.TeamsSvc), + pageLockerPlaybooksList: NewModelLockerPlaybooksList(state, deps.PlaybookSvc), + pageLockerPlaybooksCreate: NewModelLockerPlaybooksCreate(state, deps.PlaybookSvc), + pageLockerPlaybooksEdit: NewModelLockerPlaybooksEdit(state, deps.PlaybookSvc), pageNewTournament: NewModelNewTournament(state), - pageNewBattleSelection: NewModelNewBattleSelection(state, deps.teamsSvc, deps.playbookSvc), + pageNewBattleSelection: NewModelNewBattleSelection(state, deps.AiTeamsSvc), pageTitleSettings: NewModelTitleSettings(state), globalState: state, - teamsSvc: deps.teamsSvc, - playbookSvc: deps.playbookSvc, - gameSvc: deps.gameSvc, - aiTeamsSvc: deps.aiTeamsSvc, + teamsSvc: deps.TeamsSvc, + playbookSvc: deps.PlaybookSvc, + gameSvc: deps.GameSvc, + aiTeamsSvc: deps.AiTeamsSvc, } } diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index f17c0f2..e9ffdd7 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -10,9 +10,10 @@ import ( "github.com/code-gorilla-au/rush/internal/games" "github.com/code-gorilla-au/rush/internal/playbooks" "github.com/code-gorilla-au/rush/internal/teams" + "github.com/code-gorilla-au/rush/internal/tournament" ) -func setupServices(t *testing.T) (*teams.Service, *playbooks.Service, *games.Service) { +func setupServices(t *testing.T) (*teams.Service, *playbooks.Service, *games.Service, *tournament.AITeamService) { db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatalf("failed to open database: %v", err) @@ -24,7 +25,11 @@ func setupServices(t *testing.T) (*teams.Service, *playbooks.Service, *games.Ser } queries := database.New(db) - return teams.NewTeamsService(queries), playbooks.NewPlaybooksService(queries), games.NewService(queries) + ts := teams.NewTeamsService(queries) + ps := playbooks.NewPlaybooksService(queries) + gs := games.NewService(queries) + as := tournament.NewAITeamService(ts, ps) + return ts, ps, gs, as } func TestTheme(t *testing.T) { @@ -49,19 +54,34 @@ func TestNew(t *testing.T) { err := group. Test("New should initialize model with IceTheme", func(t *testing.T) { - s, ps, gs := setupServices(t) - m := New(s, ps, gs) + s, ps, gs, as := setupServices(t) + m := New(Dependencies{ + TeamsSvc: s, + PlaybookSvc: ps, + GameSvc: gs, + AiTeamsSvc: as, + }) odize.AssertTrue(t, m.theme.Logo.GetForeground() != nil) }). Test("Init should return a command", func(t *testing.T) { - s, ps, gs := setupServices(t) - m := New(s, ps, gs) + s, ps, gs, as := setupServices(t) + m := New(Dependencies{ + TeamsSvc: s, + PlaybookSvc: ps, + GameSvc: gs, + AiTeamsSvc: as, + }) cmd := m.Init() odize.AssertTrue(t, cmd != nil) }). Test("Update should handle Quit keys", func(t *testing.T) { - s, ps, gs := setupServices(t) - m := New(s, ps, gs) + s, ps, gs, as := setupServices(t) + m := New(Dependencies{ + TeamsSvc: s, + PlaybookSvc: ps, + GameSvc: gs, + AiTeamsSvc: as, + }) _, cmd := m.Update(tea.KeyPressMsg{Text: "q"}) odize.AssertTrue(t, cmd != nil) @@ -69,8 +89,13 @@ func TestNew(t *testing.T) { odize.AssertTrue(t, cmd != nil) }). Test("Update should handle WindowSizeMsg", func(t *testing.T) { - s, ps, gs := setupServices(t) - m := New(s, ps, gs) + s, ps, gs, as := setupServices(t) + m := New(Dependencies{ + TeamsSvc: s, + PlaybookSvc: ps, + GameSvc: gs, + AiTeamsSvc: as, + }) newModel, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 50}) updatedModel := newModel.(RootModel) odize.AssertTrue(t, updatedModel.width == 100) diff --git a/internal/ui/components/locker_room_list_test.go b/internal/ui/components/locker_room_list_test.go index 3d1edba..10a2abf 100644 --- a/internal/ui/components/locker_room_list_test.go +++ b/internal/ui/components/locker_room_list_test.go @@ -15,7 +15,7 @@ func TestLockerRoomList(t *testing.T) { odize.AssertEqual(t, 3, len(l.items)) odize.AssertEqual(t, ItemPlayers, l.items[0]) odize.AssertEqual(t, ItemPlaybooks, l.items[1]) - odize.AssertEqual(t, TitleItemSettings, l.items[2]) + odize.AssertEqual(t, ItemSettings, l.items[2]) }) group.Test("Update should move cursor down", func(t *testing.T) { diff --git a/internal/ui/page_title_test.go b/internal/ui/page_title_test.go index 81d55f3..4d7a84a 100644 --- a/internal/ui/page_title_test.go +++ b/internal/ui/page_title_test.go @@ -12,12 +12,12 @@ func TestModelTitle(t *testing.T) { group := odize.NewGroup(t, nil) err := group. - Test("should route to create coach when coach is nil and 'c' is pressed", func(t *testing.T) { + Test("should route to create coach when coach is nil and enter is pressed", func(t *testing.T) { m := NewModelTitle(&GlobalState{Coach: nil}) m.width = 100 m.height = 50 - _, cmd := m.Update(tea.KeyPressMsg{Text: m.keys.CreateCoach.Keys()[0]}) + _, cmd := m.Update(tea.KeyPressMsg{Text: "enter"}) odize.AssertTrue(t, cmd != nil) msg := cmd() @@ -28,12 +28,12 @@ func TestModelTitle(t *testing.T) { t.Fatalf("expected MsgSwitchPage, got %T", msg) } }). - Test("should route to locker room when coach is not nil and 'l' is pressed", func(t *testing.T) { + Test("should route to locker room when coach is not nil and enter is pressed", func(t *testing.T) { m := NewModelTitle(&GlobalState{Coach: &teams.Coach{Name: "Coach Carter"}}) m.width = 100 m.height = 50 - _, cmd := m.Update(tea.KeyPressMsg{Text: m.keys.LockerRoom.Keys()[0]}) + _, cmd := m.Update(tea.KeyPressMsg{Text: "enter"}) odize.AssertTrue(t, cmd != nil) msg := cmd() From 6bb3d4f8202943fcc8e37c677253e135bf014641 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 27 Jun 2026 07:19:02 +1000 Subject: [PATCH 82/98] refactoring --- cmd/rush/main.go | 2 +- .../teams.go => teams/ai_teams.go} | 45 ++++++--------- .../teams_test.go => teams/ai_teams_test.go} | 55 +++++++++---------- internal/teams/interfaces.go | 6 ++ internal/teams/service.go | 3 +- internal/tournament/interfaces.go | 20 ------- 6 files changed, 52 insertions(+), 79 deletions(-) rename internal/{tournament/teams.go => teams/ai_teams.go} (83%) rename internal/{tournament/teams_test.go => teams/ai_teams_test.go} (75%) delete mode 100644 internal/tournament/interfaces.go diff --git a/cmd/rush/main.go b/cmd/rush/main.go index fc5688b..748914a 100644 --- a/cmd/rush/main.go +++ b/cmd/rush/main.go @@ -56,7 +56,7 @@ func main() { return } - if tErr = tournamentSvc.GenerateTeams(ctx); tErr != nil { + if tErr = tournamentSvc.GenerateAITeams(ctx); tErr != nil { slog.Error("Failed to generate teams", "error", tErr) } }() diff --git a/internal/tournament/teams.go b/internal/teams/ai_teams.go similarity index 83% rename from internal/tournament/teams.go rename to internal/teams/ai_teams.go index 414a22b..a36b286 100644 --- a/internal/tournament/teams.go +++ b/internal/teams/ai_teams.go @@ -1,11 +1,10 @@ -package tournament +package teams import ( "context" "fmt" "github.com/code-gorilla-au/rush/internal/playbooks" - "github.com/code-gorilla-au/rush/internal/teams" "github.com/go-faker/faker/v4" ) @@ -120,26 +119,14 @@ var personas = []struct { }, } -type AITeamService struct { - teamsSvc TeamCreatLister - playbooksSvc PlaybookCreator -} - type AITeam struct { - Coach teams.Coach - Team teams.Team + Coach Coach + Team Team Playbook playbooks.Playbook } -func NewAITeamService(teamsSvc TeamCreatLister, playbooksSvc PlaybookCreator) *AITeamService { - return &AITeamService{ - teamsSvc: teamsSvc, - playbooksSvc: playbooksSvc, - } -} - -func (s *AITeamService) HasAICoaches(ctx context.Context) (bool, error) { - aiCoaches, err := s.teamsSvc.ListAICoaches(ctx) +func (s *Service) HasAICoaches(ctx context.Context) (bool, error) { + aiCoaches, err := s.store.GetAICoaches(ctx) if err != nil { return false, fmt.Errorf("listing coaches: %w", err) } @@ -147,15 +134,15 @@ func (s *AITeamService) HasAICoaches(ctx context.Context) (bool, error) { return len(aiCoaches) > 0, nil } -func (s *AITeamService) ListAITeams(ctx context.Context) ([]AITeam, error) { - aiCoaches, err := s.teamsSvc.ListAICoaches(ctx) +func (s *Service) ListAITeams(ctx context.Context) ([]AITeam, error) { + aiCoaches, err := s.store.GetAICoaches(ctx) if err != nil { return nil, fmt.Errorf("listing coaches: %w", err) } aiTeams := make([]AITeam, len(aiCoaches)) for i, coach := range aiCoaches { - aiTeam, tErr := s.GetAITeam(ctx, coach) + aiTeam, tErr := s.GetAITeam(ctx, fromCoachModel(coach)) if tErr != nil { return nil, fmt.Errorf("getting team: %w", tErr) } @@ -166,13 +153,13 @@ func (s *AITeamService) ListAITeams(ctx context.Context) ([]AITeam, error) { return aiTeams, nil } -func (s *AITeamService) GetAITeam(ctx context.Context, coach teams.Coach) (AITeam, error) { - aiTeam, tErr := s.teamsSvc.GetTeamByCoachID(ctx, coach.ID) +func (s *Service) GetAITeam(ctx context.Context, coach Coach) (AITeam, error) { + aiTeam, tErr := s.GetTeamByCoachID(ctx, coach.ID) if tErr != nil { return AITeam{}, fmt.Errorf("getting team: %w", tErr) } - aiPlaybook, pErr := s.playbooksSvc.GetTeamPlaybooks(ctx, aiTeam.ID) + aiPlaybook, pErr := s.playbookSvc.GetTeamPlaybooks(ctx, aiTeam.ID) if pErr != nil { return AITeam{}, fmt.Errorf("getting team playbooks: %w", pErr) } @@ -184,7 +171,7 @@ func (s *AITeamService) GetAITeam(ctx context.Context, coach teams.Coach) (AITea }, nil } -func (s *AITeamService) GenerateTeams(ctx context.Context) error { +func (s *Service) GenerateAITeams(ctx context.Context) error { aiTeams, err := generateAITeams() if err != nil { return fmt.Errorf("generating AI teams: %w", err) @@ -199,8 +186,8 @@ func (s *AITeamService) GenerateTeams(ctx context.Context) error { return nil } -func (s *AITeamService) generateTeam(ctx context.Context, team AIGenerationParams) error { - coach, err := s.teamsSvc.CreateCoach(ctx, teams.CreateCoachParams{ +func (s *Service) generateTeam(ctx context.Context, team AIGenerationParams) error { + coach, err := s.CreateCoach(ctx, CreateCoachParams{ Name: team.CoachName, IsHuman: false, IsDefault: false, @@ -209,12 +196,12 @@ func (s *AITeamService) generateTeam(ctx context.Context, team AIGenerationParam return fmt.Errorf("creating AI coach: %w", err) } - aiTeam, err := s.teamsSvc.CreateTeam(ctx, team.TeamName, coach.ID, true) + aiTeam, err := s.CreateTeam(ctx, team.TeamName, coach.ID, true) if err != nil { return fmt.Errorf("creating team: %w", err) } - if _, err = s.playbooksSvc.CreatePlaybook(ctx, playbooks.PlaybookParams{ + if _, err = s.playbookSvc.CreatePlaybook(ctx, playbooks.PlaybookParams{ TeamID: aiTeam.ID, Name: fmt.Sprintf("%s Playbook", aiTeam.Name), Description: team.Persona, diff --git a/internal/tournament/teams_test.go b/internal/teams/ai_teams_test.go similarity index 75% rename from internal/tournament/teams_test.go rename to internal/teams/ai_teams_test.go index 1e268f1..0855c30 100644 --- a/internal/tournament/teams_test.go +++ b/internal/teams/ai_teams_test.go @@ -1,4 +1,4 @@ -package tournament +package teams import ( "context" @@ -7,29 +7,28 @@ import ( "github.com/code-gorilla-au/odize" "github.com/code-gorilla-au/rush/internal/playbooks" - "github.com/code-gorilla-au/rush/internal/teams" ) type mockTeamCreator struct { - createCoachFunc func(ctx context.Context, params teams.CreateCoachParams) (teams.Coach, error) - listAICoachesFunc func(ctx context.Context) ([]teams.Coach, error) - getTeamByCoachIDFunc func(ctx context.Context, id int64) (teams.Team, error) - createTeamFunc func(ctx context.Context, name string, coachID int64, isDefault bool) (teams.Team, error) + createCoachFunc func(ctx context.Context, params CreateCoachParams) (Coach, error) + listAICoachesFunc func(ctx context.Context) ([]Coach, error) + getTeamByCoachIDFunc func(ctx context.Context, id int64) (Team, error) + createTeamFunc func(ctx context.Context, name string, coachID int64, isDefault bool) (Team, error) } -func (m *mockTeamCreator) CreateCoach(ctx context.Context, params teams.CreateCoachParams) (teams.Coach, error) { +func (m *mockTeamCreator) CreateCoach(ctx context.Context, params CreateCoachParams) (Coach, error) { return m.createCoachFunc(ctx, params) } -func (m *mockTeamCreator) ListAICoaches(ctx context.Context) ([]teams.Coach, error) { +func (m *mockTeamCreator) ListAICoaches(ctx context.Context) ([]Coach, error) { return m.listAICoachesFunc(ctx) } -func (m *mockTeamCreator) GetTeamByCoachID(ctx context.Context, id int64) (teams.Team, error) { +func (m *mockTeamCreator) GetTeamByCoachID(ctx context.Context, id int64) (Team, error) { return m.getTeamByCoachIDFunc(ctx, id) } -func (m *mockTeamCreator) CreateTeam(ctx context.Context, name string, coachID int64, isDefault bool) (teams.Team, error) { +func (m *mockTeamCreator) CreateTeam(ctx context.Context, name string, coachID int64, isDefault bool) (Team, error) { return m.createTeamFunc(ctx, name, coachID, isDefault) } @@ -89,20 +88,20 @@ func TestAITeamService_GenerateTeams(t *testing.T) { Test("should successfully generate all teams", func(t *testing.T) { var coachCount, teamCount, playbookCount int - mockTeams.createCoachFunc = func(ctx context.Context, params teams.CreateCoachParams) (teams.Coach, error) { + mockTeams.createCoachFunc = func(ctx context.Context, params CreateCoachParams) (Coach, error) { coachCount++ - return teams.Coach{ID: int64(coachCount), Name: params.Name}, nil + return Coach{ID: int64(coachCount), Name: params.Name}, nil } - mockTeams.createTeamFunc = func(ctx context.Context, name string, coachID int64, isDefault bool) (teams.Team, error) { + mockTeams.createTeamFunc = func(ctx context.Context, name string, coachID int64, isDefault bool) (Team, error) { teamCount++ - return teams.Team{ID: int64(teamCount), Name: name}, nil + return Team{ID: int64(teamCount), Name: name}, nil } mockPlaybooks.createPlaybookFunc = func(ctx context.Context, params playbooks.PlaybookParams) (playbooks.Playbook, error) { playbookCount++ return playbooks.Playbook{ID: int64(playbookCount), Name: params.Name}, nil } - err := svc.GenerateTeams(t.Context()) + err := svc.GenerateAITeams(t.Context()) odize.AssertNoError(t, err) odize.AssertEqual(t, totalTeams, coachCount) odize.AssertEqual(t, totalTeams, teamCount) @@ -110,40 +109,40 @@ func TestAITeamService_GenerateTeams(t *testing.T) { }). Test("should return error when CreateCoach fails", func(t *testing.T) { expectedErr := errors.New("coach error") - mockTeams.createCoachFunc = func(ctx context.Context, params teams.CreateCoachParams) (teams.Coach, error) { - return teams.Coach{}, expectedErr + mockTeams.createCoachFunc = func(ctx context.Context, params CreateCoachParams) (Coach, error) { + return Coach{}, expectedErr } - err := svc.GenerateTeams(t.Context()) + err := svc.GenerateAITeams(t.Context()) odize.AssertError(t, err) odize.AssertTrue(t, errors.Is(err, expectedErr)) }). Test("should return error when CreateTeam fails", func(t *testing.T) { expectedErr := errors.New("team error") - mockTeams.createCoachFunc = func(ctx context.Context, params teams.CreateCoachParams) (teams.Coach, error) { - return teams.Coach{ID: 1}, nil + mockTeams.createCoachFunc = func(ctx context.Context, params CreateCoachParams) (Coach, error) { + return Coach{ID: 1}, nil } - mockTeams.createTeamFunc = func(ctx context.Context, name string, coachID int64, isDefault bool) (teams.Team, error) { - return teams.Team{}, expectedErr + mockTeams.createTeamFunc = func(ctx context.Context, name string, coachID int64, isDefault bool) (Team, error) { + return Team{}, expectedErr } - err := svc.GenerateTeams(t.Context()) + err := svc.GenerateAITeams(t.Context()) odize.AssertError(t, err) odize.AssertTrue(t, errors.Is(err, expectedErr)) }). Test("should return error when CreatePlaybook fails", func(t *testing.T) { expectedErr := errors.New("playbook error") - mockTeams.createCoachFunc = func(ctx context.Context, params teams.CreateCoachParams) (teams.Coach, error) { - return teams.Coach{ID: 1}, nil + mockTeams.createCoachFunc = func(ctx context.Context, params CreateCoachParams) (Coach, error) { + return Coach{ID: 1}, nil } - mockTeams.createTeamFunc = func(ctx context.Context, name string, coachID int64, isDefault bool) (teams.Team, error) { - return teams.Team{ID: 1, Name: name}, nil + mockTeams.createTeamFunc = func(ctx context.Context, name string, coachID int64, isDefault bool) (Team, error) { + return Team{ID: 1, Name: name}, nil } mockPlaybooks.createPlaybookFunc = func(ctx context.Context, params playbooks.PlaybookParams) (playbooks.Playbook, error) { return playbooks.Playbook{}, expectedErr } - err := svc.GenerateTeams(t.Context()) + err := svc.GenerateAITeams(t.Context()) odize.AssertError(t, err) odize.AssertTrue(t, errors.Is(err, expectedErr)) }). diff --git a/internal/teams/interfaces.go b/internal/teams/interfaces.go index dee6859..fd83d30 100644 --- a/internal/teams/interfaces.go +++ b/internal/teams/interfaces.go @@ -5,6 +5,7 @@ import ( "database/sql" "github.com/code-gorilla-au/rush/internal/database" + "github.com/code-gorilla-au/rush/internal/playbooks" ) type Store interface { @@ -38,3 +39,8 @@ type TeamStore interface { } var _ Store = (*database.Queries)(nil) + +type PlaybookCreator interface { + CreatePlaybook(ctx context.Context, params playbooks.PlaybookParams) (playbooks.Playbook, error) + GetTeamPlaybooks(ctx context.Context, teamID int64) ([]playbooks.Playbook, error) +} diff --git a/internal/teams/service.go b/internal/teams/service.go index 95a8659..29650ca 100644 --- a/internal/teams/service.go +++ b/internal/teams/service.go @@ -10,7 +10,8 @@ import ( ) type Service struct { - store Store + store Store + playbookSvc PlaybookCreator } func NewTeamsService(store Store) *Service { diff --git a/internal/tournament/interfaces.go b/internal/tournament/interfaces.go deleted file mode 100644 index 7c7aacd..0000000 --- a/internal/tournament/interfaces.go +++ /dev/null @@ -1,20 +0,0 @@ -package tournament - -import ( - "context" - - "github.com/code-gorilla-au/rush/internal/playbooks" - "github.com/code-gorilla-au/rush/internal/teams" -) - -type TeamCreatLister interface { - CreateCoach(ctx context.Context, params teams.CreateCoachParams) (teams.Coach, error) - ListAICoaches(ctx context.Context) ([]teams.Coach, error) - GetTeamByCoachID(ctx context.Context, id int64) (teams.Team, error) - CreateTeam(ctx context.Context, name string, coachID int64, isDefault bool) (teams.Team, error) -} - -type PlaybookCreator interface { - CreatePlaybook(ctx context.Context, params playbooks.PlaybookParams) (playbooks.Playbook, error) - GetTeamPlaybooks(ctx context.Context, teamID int64) ([]playbooks.Playbook, error) -} From f4827137cf1d3763e95d1e40be82a06e13ee9a1e Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 27 Jun 2026 07:24:25 +1000 Subject: [PATCH 83/98] moving for teams to be an orchestrator --- cmd/rush/main.go | 9 +-- go.mod | 2 +- go.sum | 6 +- internal/teams/ai_teams_test.go | 77 +++++++++++++++++------- internal/teams/service.go | 7 ++- internal/teams/service_test.go | 3 +- internal/ui/app.go | 6 +- internal/ui/app_test.go | 20 +++--- internal/ui/page_new_battle_selection.go | 6 +- 9 files changed, 78 insertions(+), 58 deletions(-) diff --git a/cmd/rush/main.go b/cmd/rush/main.go index 748914a..a0515fe 100644 --- a/cmd/rush/main.go +++ b/cmd/rush/main.go @@ -11,7 +11,6 @@ import ( "github.com/code-gorilla-au/rush/internal/games" "github.com/code-gorilla-au/rush/internal/playbooks" "github.com/code-gorilla-au/rush/internal/teams" - "github.com/code-gorilla-au/rush/internal/tournament" "github.com/code-gorilla-au/rush/internal/ui" ) @@ -40,13 +39,12 @@ func main() { } queries := database.New(db) - teamsSvc := teams.NewTeamsService(queries) playbooksSvc := playbooks.NewPlaybooksService(queries) + teamsSvc := teams.NewTeamsService(queries, playbooksSvc) gameSvc := games.NewService(queries) - tournamentSvc := tournament.NewAITeamService(teamsSvc, playbooksSvc) go func() { - hasAICoaches, tErr := tournamentSvc.HasAICoaches(ctx) + hasAICoaches, tErr := teamsSvc.HasAICoaches(ctx) if tErr != nil { slog.Error("Failed to check for AI coaches", "error", tErr) return @@ -56,7 +54,7 @@ func main() { return } - if tErr = tournamentSvc.GenerateAITeams(ctx); tErr != nil { + if tErr = teamsSvc.GenerateAITeams(ctx); tErr != nil { slog.Error("Failed to generate teams", "error", tErr) } }() @@ -65,7 +63,6 @@ func main() { TeamsSvc: teamsSvc, PlaybookSvc: playbooksSvc, GameSvc: gameSvc, - AiTeamsSvc: tournamentSvc, })) if _, err = p.Run(); err != nil { slog.Error("Failed to run program", "error", err) diff --git a/go.mod b/go.mod index b8afe88..bb7095e 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( charm.land/lipgloss/v2 v2.0.4 github.com/code-gorilla-au/env v1.1.1 github.com/code-gorilla-au/odize v1.3.5 + github.com/go-faker/faker/v4 v4.8.0 modernc.org/sqlite v1.52.0 ) @@ -22,7 +23,6 @@ require ( github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/go-faker/faker/v4 v4.8.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/joho/godotenv v1.5.1 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect diff --git a/go.sum b/go.sum index 6099983..43f692f 100644 --- a/go.sum +++ b/go.sum @@ -64,9 +64,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -74,9 +73,8 @@ golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= diff --git a/internal/teams/ai_teams_test.go b/internal/teams/ai_teams_test.go index 0855c30..691008c 100644 --- a/internal/teams/ai_teams_test.go +++ b/internal/teams/ai_teams_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/database" "github.com/code-gorilla-au/rush/internal/playbooks" ) @@ -32,6 +33,30 @@ func (m *mockTeamCreator) CreateTeam(ctx context.Context, name string, coachID i return m.createTeamFunc(ctx, name, coachID, isDefault) } +type mockStore struct { + Store + createCoachFunc func(ctx context.Context, arg database.CreateCoachParams) (database.Coach, error) + createTeamFunc func(ctx context.Context, arg database.CreateTeamParams) (database.Team, error) + createPlayerFunc func(ctx context.Context, arg database.CreatePlayerParams) (database.Player, error) + getAICoachesFunc func(ctx context.Context) ([]database.Coach, error) +} + +func (m *mockStore) CreateCoach(ctx context.Context, arg database.CreateCoachParams) (database.Coach, error) { + return m.createCoachFunc(ctx, arg) +} + +func (m *mockStore) CreateTeam(ctx context.Context, arg database.CreateTeamParams) (database.Team, error) { + return m.createTeamFunc(ctx, arg) +} + +func (m *mockStore) CreatePlayer(ctx context.Context, arg database.CreatePlayerParams) (database.Player, error) { + return m.createPlayerFunc(ctx, arg) +} + +func (m *mockStore) GetAICoaches(ctx context.Context) ([]database.Coach, error) { + return m.getAICoachesFunc(ctx) +} + type mockPlaybookCreator struct { createPlaybookFunc func(ctx context.Context, params playbooks.PlaybookParams) (playbooks.Playbook, error) getTeamPlaybooksFunc func(ctx context.Context, teamID int64) ([]playbooks.Playbook, error) @@ -68,33 +93,37 @@ func TestGenerateAITeams(t *testing.T) { odize.AssertNoError(t, err) } -func TestAITeamService_GenerateTeams(t *testing.T) { +func TestTeamsService_GenerateTeams(t *testing.T) { group := odize.NewGroup(t, nil) - var mockTeams *mockTeamCreator + var mStore *mockStore var mockPlaybooks *mockPlaybookCreator - var svc *AITeamService + var svc *Service group.BeforeEach(func() { - mockTeams = &mockTeamCreator{} + mStore = &mockStore{} mockPlaybooks = &mockPlaybookCreator{} - svc = &AITeamService{ - teamsSvc: mockTeams, - playbooksSvc: mockPlaybooks, + svc = &Service{ + store: mStore, + playbookSvc: mockPlaybooks, } }) err := group. Test("should successfully generate all teams", func(t *testing.T) { - var coachCount, teamCount, playbookCount int + var coachCount, teamCount, playbookCount, playerCount int - mockTeams.createCoachFunc = func(ctx context.Context, params CreateCoachParams) (Coach, error) { + mStore.createCoachFunc = func(ctx context.Context, arg database.CreateCoachParams) (database.Coach, error) { coachCount++ - return Coach{ID: int64(coachCount), Name: params.Name}, nil + return database.Coach{ID: int64(coachCount), Name: arg.Name}, nil } - mockTeams.createTeamFunc = func(ctx context.Context, name string, coachID int64, isDefault bool) (Team, error) { + mStore.createTeamFunc = func(ctx context.Context, arg database.CreateTeamParams) (database.Team, error) { teamCount++ - return Team{ID: int64(teamCount), Name: name}, nil + return database.Team{ID: int64(teamCount), Name: arg.Name}, nil + } + mStore.createPlayerFunc = func(ctx context.Context, arg database.CreatePlayerParams) (database.Player, error) { + playerCount++ + return database.Player{ID: int64(playerCount), Name: arg.Name}, nil } mockPlaybooks.createPlaybookFunc = func(ctx context.Context, params playbooks.PlaybookParams) (playbooks.Playbook, error) { playbookCount++ @@ -106,11 +135,12 @@ func TestAITeamService_GenerateTeams(t *testing.T) { odize.AssertEqual(t, totalTeams, coachCount) odize.AssertEqual(t, totalTeams, teamCount) odize.AssertEqual(t, totalTeams, playbookCount) + odize.AssertEqual(t, totalTeams*5, playerCount) }). Test("should return error when CreateCoach fails", func(t *testing.T) { expectedErr := errors.New("coach error") - mockTeams.createCoachFunc = func(ctx context.Context, params CreateCoachParams) (Coach, error) { - return Coach{}, expectedErr + mStore.createCoachFunc = func(ctx context.Context, arg database.CreateCoachParams) (database.Coach, error) { + return database.Coach{}, expectedErr } err := svc.GenerateAITeams(t.Context()) @@ -119,11 +149,11 @@ func TestAITeamService_GenerateTeams(t *testing.T) { }). Test("should return error when CreateTeam fails", func(t *testing.T) { expectedErr := errors.New("team error") - mockTeams.createCoachFunc = func(ctx context.Context, params CreateCoachParams) (Coach, error) { - return Coach{ID: 1}, nil + mStore.createCoachFunc = func(ctx context.Context, arg database.CreateCoachParams) (database.Coach, error) { + return database.Coach{ID: 1}, nil } - mockTeams.createTeamFunc = func(ctx context.Context, name string, coachID int64, isDefault bool) (Team, error) { - return Team{}, expectedErr + mStore.createTeamFunc = func(ctx context.Context, arg database.CreateTeamParams) (database.Team, error) { + return database.Team{}, expectedErr } err := svc.GenerateAITeams(t.Context()) @@ -132,11 +162,14 @@ func TestAITeamService_GenerateTeams(t *testing.T) { }). Test("should return error when CreatePlaybook fails", func(t *testing.T) { expectedErr := errors.New("playbook error") - mockTeams.createCoachFunc = func(ctx context.Context, params CreateCoachParams) (Coach, error) { - return Coach{ID: 1}, nil + mStore.createCoachFunc = func(ctx context.Context, arg database.CreateCoachParams) (database.Coach, error) { + return database.Coach{ID: 1}, nil + } + mStore.createTeamFunc = func(ctx context.Context, arg database.CreateTeamParams) (database.Team, error) { + return database.Team{ID: 1, Name: arg.Name}, nil } - mockTeams.createTeamFunc = func(ctx context.Context, name string, coachID int64, isDefault bool) (Team, error) { - return Team{ID: 1, Name: name}, nil + mStore.createPlayerFunc = func(ctx context.Context, arg database.CreatePlayerParams) (database.Player, error) { + return database.Player{}, nil } mockPlaybooks.createPlaybookFunc = func(ctx context.Context, params playbooks.PlaybookParams) (playbooks.Playbook, error) { return playbooks.Playbook{}, expectedErr diff --git a/internal/teams/service.go b/internal/teams/service.go index 29650ca..0ca412a 100644 --- a/internal/teams/service.go +++ b/internal/teams/service.go @@ -14,8 +14,11 @@ type Service struct { playbookSvc PlaybookCreator } -func NewTeamsService(store Store) *Service { - return &Service{store: store} +func NewTeamsService(store Store, playbookSvc PlaybookCreator) *Service { + return &Service{ + store: store, + playbookSvc: playbookSvc, + } } type CreateCoachParams struct { diff --git a/internal/teams/service_test.go b/internal/teams/service_test.go index c2bb463..9f90343 100644 --- a/internal/teams/service_test.go +++ b/internal/teams/service_test.go @@ -7,6 +7,7 @@ import ( "github.com/code-gorilla-au/odize" "github.com/code-gorilla-au/rush/internal/database" + "github.com/code-gorilla-au/rush/internal/playbooks" _ "modernc.org/sqlite" ) @@ -34,7 +35,7 @@ func TestService(t *testing.T) { group.BeforeEach(func() { db = setupTestDB(t) queries = database.New(db) - s = NewTeamsService(queries) + s = NewTeamsService(queries, playbooks.NewPlaybooksService(queries)) }) group.AfterEach(func() { diff --git a/internal/ui/app.go b/internal/ui/app.go index abb2501..f8d4da9 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -7,7 +7,6 @@ import ( "github.com/code-gorilla-au/rush/internal/games" "github.com/code-gorilla-au/rush/internal/playbooks" "github.com/code-gorilla-au/rush/internal/teams" - "github.com/code-gorilla-au/rush/internal/tournament" ) type MsgStateUpdated struct { @@ -64,14 +63,12 @@ type RootModel struct { teamsSvc *teams.Service playbookSvc *playbooks.Service gameSvc *games.Service - aiTeamsSvc *tournament.AITeamService } type Dependencies struct { TeamsSvc *teams.Service PlaybookSvc *playbooks.Service GameSvc *games.Service - AiTeamsSvc *tournament.AITeamService } // New returns a new UI model. @@ -90,13 +87,12 @@ func New(deps Dependencies) RootModel { pageLockerPlaybooksCreate: NewModelLockerPlaybooksCreate(state, deps.PlaybookSvc), pageLockerPlaybooksEdit: NewModelLockerPlaybooksEdit(state, deps.PlaybookSvc), pageNewTournament: NewModelNewTournament(state), - pageNewBattleSelection: NewModelNewBattleSelection(state, deps.AiTeamsSvc), + pageNewBattleSelection: NewModelNewBattleSelection(state, deps.TeamsSvc), pageTitleSettings: NewModelTitleSettings(state), globalState: state, teamsSvc: deps.TeamsSvc, playbookSvc: deps.PlaybookSvc, gameSvc: deps.GameSvc, - aiTeamsSvc: deps.AiTeamsSvc, } } diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index e9ffdd7..b7159bc 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -10,10 +10,9 @@ import ( "github.com/code-gorilla-au/rush/internal/games" "github.com/code-gorilla-au/rush/internal/playbooks" "github.com/code-gorilla-au/rush/internal/teams" - "github.com/code-gorilla-au/rush/internal/tournament" ) -func setupServices(t *testing.T) (*teams.Service, *playbooks.Service, *games.Service, *tournament.AITeamService) { +func setupServices(t *testing.T) (*teams.Service, *playbooks.Service, *games.Service) { db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatalf("failed to open database: %v", err) @@ -25,11 +24,10 @@ func setupServices(t *testing.T) (*teams.Service, *playbooks.Service, *games.Ser } queries := database.New(db) - ts := teams.NewTeamsService(queries) ps := playbooks.NewPlaybooksService(queries) + ts := teams.NewTeamsService(queries, ps) gs := games.NewService(queries) - as := tournament.NewAITeamService(ts, ps) - return ts, ps, gs, as + return ts, ps, gs } func TestTheme(t *testing.T) { @@ -54,33 +52,30 @@ func TestNew(t *testing.T) { err := group. Test("New should initialize model with IceTheme", func(t *testing.T) { - s, ps, gs, as := setupServices(t) + s, ps, gs := setupServices(t) m := New(Dependencies{ TeamsSvc: s, PlaybookSvc: ps, GameSvc: gs, - AiTeamsSvc: as, }) odize.AssertTrue(t, m.theme.Logo.GetForeground() != nil) }). Test("Init should return a command", func(t *testing.T) { - s, ps, gs, as := setupServices(t) + s, ps, gs := setupServices(t) m := New(Dependencies{ TeamsSvc: s, PlaybookSvc: ps, GameSvc: gs, - AiTeamsSvc: as, }) cmd := m.Init() odize.AssertTrue(t, cmd != nil) }). Test("Update should handle Quit keys", func(t *testing.T) { - s, ps, gs, as := setupServices(t) + s, ps, gs := setupServices(t) m := New(Dependencies{ TeamsSvc: s, PlaybookSvc: ps, GameSvc: gs, - AiTeamsSvc: as, }) _, cmd := m.Update(tea.KeyPressMsg{Text: "q"}) odize.AssertTrue(t, cmd != nil) @@ -89,12 +84,11 @@ func TestNew(t *testing.T) { odize.AssertTrue(t, cmd != nil) }). Test("Update should handle WindowSizeMsg", func(t *testing.T) { - s, ps, gs, as := setupServices(t) + s, ps, gs := setupServices(t) m := New(Dependencies{ TeamsSvc: s, PlaybookSvc: ps, GameSvc: gs, - AiTeamsSvc: as, }) newModel, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 50}) updatedModel := newModel.(RootModel) diff --git a/internal/ui/page_new_battle_selection.go b/internal/ui/page_new_battle_selection.go index d2dc907..ceebdf7 100644 --- a/internal/ui/page_new_battle_selection.go +++ b/internal/ui/page_new_battle_selection.go @@ -5,7 +5,6 @@ import ( "github.com/code-gorilla-au/rush/internal/playbooks" "github.com/code-gorilla-au/rush/internal/teams" - "github.com/code-gorilla-au/rush/internal/tournament" "github.com/code-gorilla-au/rush/internal/ui/components" "charm.land/bubbles/v2/key" @@ -62,7 +61,6 @@ type ModelNewBattleSelection struct { globalState *GlobalState teamsSvc *teams.Service playbookSvc *playbooks.Service - aiTeamsSvc *tournament.AITeamService state selectionState aiCoaches []AICoachItem selectedCoachIdx int @@ -73,11 +71,11 @@ type ModelNewBattleSelection struct { err error } -func NewModelNewBattleSelection(globalState *GlobalState, aiTeamsSvc *tournament.AITeamService) *ModelNewBattleSelection { +func NewModelNewBattleSelection(globalState *GlobalState, teamsSvc *teams.Service) *ModelNewBattleSelection { keys := newBattleSelectionKeyMap() return &ModelNewBattleSelection{ globalState: globalState, - aiTeamsSvc: aiTeamsSvc, + teamsSvc: teamsSvc, theme: NewIceTheme(), keys: keys, footer: components.NewFooter(keys), From 0188b09820de78adb865d26e61ae05d6a0f3ff8b Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 27 Jun 2026 07:28:54 +1000 Subject: [PATCH 84/98] moving function around --- internal/teams/ai_teams.go | 9 +++++++++ internal/teams/service.go | 18 ------------------ internal/teams/service_test.go | 2 +- 3 files changed, 10 insertions(+), 19 deletions(-) diff --git a/internal/teams/ai_teams.go b/internal/teams/ai_teams.go index a36b286..764fad3 100644 --- a/internal/teams/ai_teams.go +++ b/internal/teams/ai_teams.go @@ -171,6 +171,15 @@ func (s *Service) GetAITeam(ctx context.Context, coach Coach) (AITeam, error) { }, nil } +func (s *Service) ListAICoaches(ctx context.Context) ([]Coach, error) { + models, err := s.store.GetAICoaches(ctx) + if err != nil { + return nil, fmt.Errorf("listing ai coaches: %w", err) + } + + return fromCoachesModel(models), nil +} + func (s *Service) GenerateAITeams(ctx context.Context) error { aiTeams, err := generateAITeams() if err != nil { diff --git a/internal/teams/service.go b/internal/teams/service.go index 0ca412a..710cb42 100644 --- a/internal/teams/service.go +++ b/internal/teams/service.go @@ -46,24 +46,6 @@ func (s *Service) CreateCoach(ctx context.Context, params CreateCoachParams) (Co return fromCoachModel(model), nil } -func (s *Service) ListCoaches(ctx context.Context) ([]Coach, error) { - models, err := s.store.GetCoaches(ctx) - if err != nil { - return nil, fmt.Errorf("listing coaches: %w", err) - } - - return fromCoachesModel(models), nil -} - -func (s *Service) ListAICoaches(ctx context.Context) ([]Coach, error) { - models, err := s.store.GetAICoaches(ctx) - if err != nil { - return nil, fmt.Errorf("listing ai coaches: %w", err) - } - - return fromCoachesModel(models), nil -} - func (s *Service) GetDefaultCoach(ctx context.Context) (Coach, error) { model, err := s.store.GetDefaultCoach(ctx) if err != nil { diff --git a/internal/teams/service_test.go b/internal/teams/service_test.go index 9f90343..70362df 100644 --- a/internal/teams/service_test.go +++ b/internal/teams/service_test.go @@ -96,7 +96,7 @@ func TestService(t *testing.T) { queries := database.New(db) _, err = queries.GetDefaultCoach(ctx) odize.AssertError(t, err) - odize.AssertTrue(t, err == sql.ErrNoRows) + odize.AssertTrue(t, errors.Is(err, sql.ErrNoRows)) }). Test("SetDefaultTeam should set the default team", func(t *testing.T) { ctx := t.Context() From d499b75755b46a08de46a11c0855f27f8cdcbe5a Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 27 Jun 2026 20:40:18 +1000 Subject: [PATCH 85/98] refactoring --- internal/ui/page_new_battle_selection.go | 30 ++++++++++++------------ 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/internal/ui/page_new_battle_selection.go b/internal/ui/page_new_battle_selection.go index ceebdf7..d62e2a9 100644 --- a/internal/ui/page_new_battle_selection.go +++ b/internal/ui/page_new_battle_selection.go @@ -19,6 +19,10 @@ const ( stateSelectingPlaybook ) +type msgAICoachesLoaded struct { + aiTeams []AITeamItem +} + type battleSelectionKeyMap struct { components.CommonKeys Back key.Binding @@ -49,7 +53,7 @@ func newBattleSelectionKeyMap() battleSelectionKeyMap { } } -type AICoachItem struct { +type AITeamItem struct { coach teams.Coach team teams.Team } @@ -62,7 +66,7 @@ type ModelNewBattleSelection struct { teamsSvc *teams.Service playbookSvc *playbooks.Service state selectionState - aiCoaches []AICoachItem + aiCoaches []AITeamItem selectedCoachIdx int playbooks []playbooks.Playbook playbookList components.PlaybookList @@ -88,24 +92,20 @@ func (m *ModelNewBattleSelection) Init() tea.Cmd { } func (m *ModelNewBattleSelection) loadAICoaches() tea.Msg { - coaches, err := m.teamsSvc.ListAICoaches(m.globalState.Context()) + aiTeams, err := m.teamsSvc.ListAITeams(m.globalState.Context()) if err != nil { return err } - items := make([]AICoachItem, 0, len(coaches)) - for _, coach := range coaches { - team, err := m.teamsSvc.GetTeamByCoachID(m.globalState.Context(), coach.ID) - if err != nil { - continue - } - items = append(items, AICoachItem{coach: coach, team: team}) + items := make([]AITeamItem, 0, len(aiTeams)) + for _, aiTeam := range aiTeams { + items = append(items, AITeamItem{ + coach: aiTeam.Coach, + team: aiTeam.Team, + }) } - return msgAICoachesLoaded{coaches: items} -} -type msgAICoachesLoaded struct { - coaches []AICoachItem + return msgAICoachesLoaded{aiTeams: items} } type msgPlaybooksLoaded struct { @@ -132,7 +132,7 @@ func (m *ModelNewBattleSelection) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.footer.Update(msg) m.playbookList.SetSize(m.width, m.height-10) case msgAICoachesLoaded: - m.aiCoaches = msg.coaches + m.aiCoaches = msg.aiTeams case msgPlaybooksLoaded: m.playbooks = msg.playbooks cmds = append(cmds, m.playbookList.SetItems(msg.playbooks)) From 394e0f295c53239b5f19db5ea987e5b61542a981 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 27 Jun 2026 20:46:25 +1000 Subject: [PATCH 86/98] refactoring --- internal/ui/app.go | 2 +- internal/ui/page_new_battle_selection.go | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index f8d4da9..31914e4 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -87,7 +87,7 @@ func New(deps Dependencies) RootModel { pageLockerPlaybooksCreate: NewModelLockerPlaybooksCreate(state, deps.PlaybookSvc), pageLockerPlaybooksEdit: NewModelLockerPlaybooksEdit(state, deps.PlaybookSvc), pageNewTournament: NewModelNewTournament(state), - pageNewBattleSelection: NewModelNewBattleSelection(state, deps.TeamsSvc), + pageNewBattleSelection: NewModelNewBattleSelection(state, deps.TeamsSvc, deps.PlaybookSvc), pageTitleSettings: NewModelTitleSettings(state), globalState: state, teamsSvc: deps.TeamsSvc, diff --git a/internal/ui/page_new_battle_selection.go b/internal/ui/page_new_battle_selection.go index d62e2a9..05d148b 100644 --- a/internal/ui/page_new_battle_selection.go +++ b/internal/ui/page_new_battle_selection.go @@ -75,11 +75,12 @@ type ModelNewBattleSelection struct { err error } -func NewModelNewBattleSelection(globalState *GlobalState, teamsSvc *teams.Service) *ModelNewBattleSelection { +func NewModelNewBattleSelection(globalState *GlobalState, teamsSvc *teams.Service, playbookSvc *playbooks.Service) *ModelNewBattleSelection { keys := newBattleSelectionKeyMap() return &ModelNewBattleSelection{ globalState: globalState, teamsSvc: teamsSvc, + playbookSvc: playbookSvc, theme: NewIceTheme(), keys: keys, footer: components.NewFooter(keys), @@ -124,15 +125,18 @@ func (m *ModelNewBattleSelection) loadPlaybooks(teamID int64) tea.Cmd { func (m *ModelNewBattleSelection) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd + m.footer.Update(msg) switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - m.footer.Update(msg) m.playbookList.SetSize(m.width, m.height-10) case msgAICoachesLoaded: m.aiCoaches = msg.aiTeams + if len(m.aiCoaches) > 0 { + m.selectedCoachIdx = 0 + } case msgPlaybooksLoaded: m.playbooks = msg.playbooks cmds = append(cmds, m.playbookList.SetItems(msg.playbooks)) @@ -184,6 +188,10 @@ func (m *ModelNewBattleSelection) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *ModelNewBattleSelection) View() tea.View { + if m.width == 0 || m.height == 0 { + return tea.NewView("Initializing...") + } + view := tea.NewView("") view.AltScreen = true From ba9e44cb45fce554b04f2cd106cb752cbb330435 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 27 Jun 2026 21:15:32 +1000 Subject: [PATCH 87/98] wip kinda working --- internal/database/queries/team.sql | 2 +- internal/database/team.sql.gen.go | 2 +- internal/teams/ai_teams.go | 4 +- internal/teams/service.go | 21 ++++- internal/teams/types.go | 7 ++ internal/ui/app.go | 6 ++ internal/ui/page_new_battle_selection.go | 8 +- internal/ui/page_new_battle_selection_test.go | 92 +++++++++++++++++++ 8 files changed, 132 insertions(+), 10 deletions(-) create mode 100644 internal/ui/page_new_battle_selection_test.go diff --git a/internal/database/queries/team.sql b/internal/database/queries/team.sql index d70f8d1..bda4394 100644 --- a/internal/database/queries/team.sql +++ b/internal/database/queries/team.sql @@ -29,7 +29,7 @@ SELECT * FROM teams; SELECT * FROM teams WHERE id = ?; -- name: GetTeamByCoachID :one -SELECT * FROM teams WHERE coach_id = ? AND is_default = true LIMIT 1; +SELECT * FROM teams WHERE coach_id = ? LIMIT 1; -- name: CreateTeam :one INSERT INTO teams (name, is_default, coach_id) VALUES (?, ?, ?) RETURNING *; diff --git a/internal/database/team.sql.gen.go b/internal/database/team.sql.gen.go index 34717fe..d9f28d3 100644 --- a/internal/database/team.sql.gen.go +++ b/internal/database/team.sql.gen.go @@ -248,7 +248,7 @@ func (q *Queries) GetTeam(ctx context.Context, id int64) (Team, error) { } const getTeamByCoachID = `-- name: GetTeamByCoachID :one -SELECT id, name, is_default, coach_id, created_at, updated_at FROM teams WHERE coach_id = ? AND is_default = true LIMIT 1 +SELECT id, name, is_default, coach_id, created_at, updated_at FROM teams WHERE coach_id = ? LIMIT 1 ` func (q *Queries) GetTeamByCoachID(ctx context.Context, coachID sql.NullInt64) (Team, error) { diff --git a/internal/teams/ai_teams.go b/internal/teams/ai_teams.go index 764fad3..85e1723 100644 --- a/internal/teams/ai_teams.go +++ b/internal/teams/ai_teams.go @@ -156,12 +156,12 @@ func (s *Service) ListAITeams(ctx context.Context) ([]AITeam, error) { func (s *Service) GetAITeam(ctx context.Context, coach Coach) (AITeam, error) { aiTeam, tErr := s.GetTeamByCoachID(ctx, coach.ID) if tErr != nil { - return AITeam{}, fmt.Errorf("getting team: %w", tErr) + return AITeam{}, fmt.Errorf("GetAITeam: %w", tErr) } aiPlaybook, pErr := s.playbookSvc.GetTeamPlaybooks(ctx, aiTeam.ID) if pErr != nil { - return AITeam{}, fmt.Errorf("getting team playbooks: %w", pErr) + return AITeam{}, fmt.Errorf("GetAITeam playbooks: %w", pErr) } return AITeam{ diff --git a/internal/teams/service.go b/internal/teams/service.go index 710cb42..365bc4e 100644 --- a/internal/teams/service.go +++ b/internal/teams/service.go @@ -59,6 +59,23 @@ func (s *Service) GetDefaultCoach(ctx context.Context) (Coach, error) { return fromCoachModel(model), nil } +func (s *Service) GetTeamAndPlaybooksByCoachID(ctx context.Context, id int64) (TeamWithPlaybooks, error) { + team, err := s.GetTeamByCoachID(ctx, id) + if err != nil { + return TeamWithPlaybooks{}, err + } + + playbooks, err := s.playbookSvc.GetTeamPlaybooks(ctx, team.ID) + if err != nil { + return TeamWithPlaybooks{}, err + } + + return TeamWithPlaybooks{ + Playbooks: playbooks, + Team: team, + }, nil +} + func (s *Service) GetTeamByCoachID(ctx context.Context, id int64) (Team, error) { model, err := s.store.GetTeamByCoachID(ctx, sql.NullInt64{ Valid: true, @@ -69,7 +86,7 @@ func (s *Service) GetTeamByCoachID(ctx context.Context, id int64) (Team, error) return Team{}, ErrTeamNotFound } - return Team{}, fmt.Errorf("getting team: %w", err) + return Team{}, fmt.Errorf("GetTeamByCoachID: %w", err) } pModel, err := s.store.GetTeamMembers(ctx, sql.NullInt64{ @@ -77,7 +94,7 @@ func (s *Service) GetTeamByCoachID(ctx context.Context, id int64) (Team, error) Valid: true, }) if err != nil { - return Team{}, fmt.Errorf("getting team members: %w", err) + return Team{}, fmt.Errorf("GetTeamByCoachID team members: %w", err) } return fromTeamModel(model, pModel), nil diff --git a/internal/teams/types.go b/internal/teams/types.go index f050b80..818e3ed 100644 --- a/internal/teams/types.go +++ b/internal/teams/types.go @@ -3,8 +3,15 @@ package teams import ( "errors" "time" + + "github.com/code-gorilla-au/rush/internal/playbooks" ) +type TeamWithPlaybooks struct { + Playbooks []playbooks.Playbook `json:"playbooks,omitzero"` + Team Team `json:"team,omitzero"` +} + type Team struct { ID int64 `json:"id,omitzero"` Name string `json:"name,omitzero"` diff --git a/internal/ui/app.go b/internal/ui/app.go index 31914e4..006ee99 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -126,6 +126,12 @@ func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case MsgSwitchPage: m.currentPage = msg.NewPage + var cmd tea.Cmd + switch m.currentPage { + case PageNewBattleSelection: + cmd = m.pageNewBattleSelection.Init() + } + cmds = append(cmds, cmd) case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height diff --git a/internal/ui/page_new_battle_selection.go b/internal/ui/page_new_battle_selection.go index 05d148b..bca1f35 100644 --- a/internal/ui/page_new_battle_selection.go +++ b/internal/ui/page_new_battle_selection.go @@ -113,13 +113,13 @@ type msgPlaybooksLoaded struct { playbooks []playbooks.Playbook } -func (m *ModelNewBattleSelection) loadPlaybooks(teamID int64) tea.Cmd { +func (m *ModelNewBattleSelection) loadPlaybooks() tea.Cmd { return func() tea.Msg { - pbks, err := m.playbookSvc.GetTeamPlaybooks(m.globalState.Context(), teamID) + team, err := m.teamsSvc.GetTeamAndPlaybooksByCoachID(m.globalState.Context(), m.globalState.Coach.ID) if err != nil { return err } - return msgPlaybooksLoaded{playbooks: pbks} + return msgPlaybooksLoaded{playbooks: team.Playbooks} } } @@ -157,7 +157,7 @@ func (m *ModelNewBattleSelection) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.Select): if m.state == stateSelectingCoach && len(m.aiCoaches) > 0 { m.state = stateSelectingPlaybook - cmds = append(cmds, m.loadPlaybooks(m.aiCoaches[m.selectedCoachIdx].team.ID)) + cmds = append(cmds, m.loadPlaybooks()) } else if m.state == stateSelectingPlaybook { selectedPlaybook := m.playbookList.SelectedItem() if selectedPlaybook != nil { diff --git a/internal/ui/page_new_battle_selection_test.go b/internal/ui/page_new_battle_selection_test.go new file mode 100644 index 0000000..6c9d0fb --- /dev/null +++ b/internal/ui/page_new_battle_selection_test.go @@ -0,0 +1,92 @@ +package ui + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/teams" +) + +func TestModelNewBattleSelection_Rendering(t *testing.T) { + group := odize.NewGroup(t, nil) + + group.Test("should load AI coaches and render them", func(t *testing.T) { + state := &GlobalState{} + + m := NewModelNewBattleSelection(state, nil, nil) + m.width = 100 + m.height = 40 + + aiTeams := []AITeamItem{ + { + coach: teams.Coach{Name: "Coach A"}, + team: teams.Team{Name: "Team A"}, + }, + } + + msg := msgAICoachesLoaded{aiTeams: aiTeams} + m.Update(msg) + + odize.AssertEqual(t, 1, len(m.aiCoaches)) + + view := m.View() + content := view.Content + + odize.AssertTrue(t, strings.Contains(content, "Select your opponent")) + odize.AssertTrue(t, !strings.Contains(content, "No AI coaches available")) + }) + + group.Test("should transition to playbook selection and load playbooks", func(t *testing.T) { + state := &GlobalState{} + m := NewModelNewBattleSelection(state, nil, nil) + m.width = 100 + m.height = 40 + m.aiCoaches = []AITeamItem{ + { + coach: teams.Coach{ID: 1, Name: "Coach A"}, + team: teams.Team{ID: 1, Name: "Team A"}, + }, + } + m.selectedCoachIdx = 0 + + // Simulate Enter key on stateSelectingCoach + m.state = stateSelectingCoach + _, cmd := m.Update(tea.KeyPressMsg{Text: "enter"}) + + odize.AssertEqual(t, stateSelectingPlaybook, m.state) + odize.AssertTrue(t, cmd != nil) + }) + + group.Test("should handle back navigation from coach selection", func(t *testing.T) { + state := &GlobalState{} + m := NewModelNewBattleSelection(state, nil, nil) + m.state = stateSelectingCoach + + _, cmd := m.Update(tea.KeyPressMsg{Text: "esc"}) + + odize.AssertTrue(t, cmd != nil) + msg := cmd() + switch v := msg.(type) { + case MsgSwitchPage: + odize.AssertEqual(t, PageTitle, v.NewPage) + default: + t.Fatalf("expected MsgSwitchPage, got %T", msg) + } + }) + + group.Test("should handle back navigation from playbook selection", func(t *testing.T) { + state := &GlobalState{} + m := NewModelNewBattleSelection(state, nil, nil) + m.state = stateSelectingPlaybook + + _, cmd := m.Update(tea.KeyPressMsg{Text: "esc"}) + + odize.AssertEqual(t, stateSelectingCoach, m.state) + odize.AssertTrue(t, cmd == nil) + }) + + err := group.Run() + odize.AssertNoError(t, err) +} From 4247ded8416f522deee291c5f6aac00fec089272 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 27 Jun 2026 21:19:50 +1000 Subject: [PATCH 88/98] kinda fixed --- internal/ui/app.go | 2 +- internal/ui/page_new_battle_selection.go | 81 +++---------------- internal/ui/page_new_battle_selection_test.go | 31 +++---- 3 files changed, 26 insertions(+), 88 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index 006ee99..1ee41a2 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -87,7 +87,7 @@ func New(deps Dependencies) RootModel { pageLockerPlaybooksCreate: NewModelLockerPlaybooksCreate(state, deps.PlaybookSvc), pageLockerPlaybooksEdit: NewModelLockerPlaybooksEdit(state, deps.PlaybookSvc), pageNewTournament: NewModelNewTournament(state), - pageNewBattleSelection: NewModelNewBattleSelection(state, deps.TeamsSvc, deps.PlaybookSvc), + pageNewBattleSelection: NewModelNewBattleSelection(state, deps.TeamsSvc), pageTitleSettings: NewModelTitleSettings(state), globalState: state, teamsSvc: deps.TeamsSvc, diff --git a/internal/ui/page_new_battle_selection.go b/internal/ui/page_new_battle_selection.go index bca1f35..a4a9275 100644 --- a/internal/ui/page_new_battle_selection.go +++ b/internal/ui/page_new_battle_selection.go @@ -3,7 +3,6 @@ package ui import ( "fmt" - "github.com/code-gorilla-au/rush/internal/playbooks" "github.com/code-gorilla-au/rush/internal/teams" "github.com/code-gorilla-au/rush/internal/ui/components" @@ -16,7 +15,6 @@ type selectionState int const ( stateSelectingCoach selectionState = iota - stateSelectingPlaybook ) type msgAICoachesLoaded struct { @@ -64,27 +62,22 @@ type ModelNewBattleSelection struct { theme IceTheme globalState *GlobalState teamsSvc *teams.Service - playbookSvc *playbooks.Service state selectionState aiCoaches []AITeamItem selectedCoachIdx int - playbooks []playbooks.Playbook - playbookList components.PlaybookList keys battleSelectionKeyMap footer components.Footer err error } -func NewModelNewBattleSelection(globalState *GlobalState, teamsSvc *teams.Service, playbookSvc *playbooks.Service) *ModelNewBattleSelection { +func NewModelNewBattleSelection(globalState *GlobalState, teamsSvc *teams.Service) *ModelNewBattleSelection { keys := newBattleSelectionKeyMap() return &ModelNewBattleSelection{ - globalState: globalState, - teamsSvc: teamsSvc, - playbookSvc: playbookSvc, - theme: NewIceTheme(), - keys: keys, - footer: components.NewFooter(keys), - playbookList: components.NewPlaybookList(nil), + globalState: globalState, + teamsSvc: teamsSvc, + theme: NewIceTheme(), + keys: keys, + footer: components.NewFooter(keys), } } @@ -109,20 +102,6 @@ func (m *ModelNewBattleSelection) loadAICoaches() tea.Msg { return msgAICoachesLoaded{aiTeams: items} } -type msgPlaybooksLoaded struct { - playbooks []playbooks.Playbook -} - -func (m *ModelNewBattleSelection) loadPlaybooks() tea.Cmd { - return func() tea.Msg { - team, err := m.teamsSvc.GetTeamAndPlaybooksByCoachID(m.globalState.Context(), m.globalState.Coach.ID) - if err != nil { - return err - } - return msgPlaybooksLoaded{playbooks: team.Playbooks} - } -} - func (m *ModelNewBattleSelection) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd m.footer.Update(msg) @@ -131,15 +110,11 @@ func (m *ModelNewBattleSelection) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - m.playbookList.SetSize(m.width, m.height-10) case msgAICoachesLoaded: m.aiCoaches = msg.aiTeams if len(m.aiCoaches) > 0 { m.selectedCoachIdx = 0 } - case msgPlaybooksLoaded: - m.playbooks = msg.playbooks - cmds = append(cmds, m.playbookList.SetItems(msg.playbooks)) case error: m.err = msg case tea.KeyMsg: @@ -147,43 +122,27 @@ func (m *ModelNewBattleSelection) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.Quit): return m, tea.Quit case key.Matches(msg, m.keys.Back): - if m.state == stateSelectingPlaybook { - m.state = stateSelectingCoach - return m, nil - } return m, func() tea.Msg { return MsgSwitchPage{NewPage: PageTitle} } case key.Matches(msg, m.keys.Select): - if m.state == stateSelectingCoach && len(m.aiCoaches) > 0 { - m.state = stateSelectingPlaybook - cmds = append(cmds, m.loadPlaybooks()) - } else if m.state == stateSelectingPlaybook { - selectedPlaybook := m.playbookList.SelectedItem() - if selectedPlaybook != nil { - // TODO: Start battle with selected coach and playbook - return m, func() tea.Msg { - return MsgSwitchPage{NewPage: PageTitle} - } + if len(m.aiCoaches) > 0 { + // TODO: Start battle with selected coach + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageTitle} } } case msg.String() == "up", msg.String() == "k": - if m.state == stateSelectingCoach && m.selectedCoachIdx > 0 { + if m.selectedCoachIdx > 0 { m.selectedCoachIdx-- } case msg.String() == "down", msg.String() == "j": - if m.state == stateSelectingCoach && m.selectedCoachIdx < len(m.aiCoaches)-1 { + if m.selectedCoachIdx < len(m.aiCoaches)-1 { m.selectedCoachIdx++ } } } - if m.state == stateSelectingPlaybook { - var cmd tea.Cmd - m.playbookList, cmd = m.playbookList.Update(msg) - cmds = append(cmds, cmd) - } - return m, tea.Batch(cmds...) } @@ -198,10 +157,8 @@ func (m *ModelNewBattleSelection) View() tea.View { var content string if m.err != nil { content = m.theme.Logo.Render(fmt.Sprintf("Error: %v", m.err)) - } else if m.state == stateSelectingCoach { - content = m.viewCoaches() } else { - content = m.viewPlaybooks() + content = m.viewCoaches() } centeredContent := lipgloss.Place( @@ -245,15 +202,3 @@ func (m *ModelNewBattleSelection) viewCoaches() string { return s } - -func (m *ModelNewBattleSelection) viewPlaybooks() string { - if len(m.playbooks) == 0 { - return "This coach has no playbooks" - } - - return lipgloss.JoinVertical(lipgloss.Left, - m.theme.Logo.Render(fmt.Sprintf("Select %s's playbook:", m.aiCoaches[m.selectedCoachIdx].coach.Name)), - "", - m.playbookList.View(), - ) -} diff --git a/internal/ui/page_new_battle_selection_test.go b/internal/ui/page_new_battle_selection_test.go index 6c9d0fb..e241474 100644 --- a/internal/ui/page_new_battle_selection_test.go +++ b/internal/ui/page_new_battle_selection_test.go @@ -15,7 +15,7 @@ func TestModelNewBattleSelection_Rendering(t *testing.T) { group.Test("should load AI coaches and render them", func(t *testing.T) { state := &GlobalState{} - m := NewModelNewBattleSelection(state, nil, nil) + m := NewModelNewBattleSelection(state, nil) m.width = 100 m.height = 40 @@ -38,9 +38,9 @@ func TestModelNewBattleSelection_Rendering(t *testing.T) { odize.AssertTrue(t, !strings.Contains(content, "No AI coaches available")) }) - group.Test("should transition to playbook selection and load playbooks", func(t *testing.T) { + group.Test("should handle select coach and return to title", func(t *testing.T) { state := &GlobalState{} - m := NewModelNewBattleSelection(state, nil, nil) + m := NewModelNewBattleSelection(state, nil) m.width = 100 m.height = 40 m.aiCoaches = []AITeamItem{ @@ -51,18 +51,22 @@ func TestModelNewBattleSelection_Rendering(t *testing.T) { } m.selectedCoachIdx = 0 - // Simulate Enter key on stateSelectingCoach - m.state = stateSelectingCoach + // Simulate Enter key _, cmd := m.Update(tea.KeyPressMsg{Text: "enter"}) - odize.AssertEqual(t, stateSelectingPlaybook, m.state) odize.AssertTrue(t, cmd != nil) + msg := cmd() + switch v := msg.(type) { + case MsgSwitchPage: + odize.AssertEqual(t, PageTitle, v.NewPage) + default: + t.Fatalf("expected MsgSwitchPage, got %T", msg) + } }) group.Test("should handle back navigation from coach selection", func(t *testing.T) { state := &GlobalState{} - m := NewModelNewBattleSelection(state, nil, nil) - m.state = stateSelectingCoach + m := NewModelNewBattleSelection(state, nil) _, cmd := m.Update(tea.KeyPressMsg{Text: "esc"}) @@ -76,17 +80,6 @@ func TestModelNewBattleSelection_Rendering(t *testing.T) { } }) - group.Test("should handle back navigation from playbook selection", func(t *testing.T) { - state := &GlobalState{} - m := NewModelNewBattleSelection(state, nil, nil) - m.state = stateSelectingPlaybook - - _, cmd := m.Update(tea.KeyPressMsg{Text: "esc"}) - - odize.AssertEqual(t, stateSelectingCoach, m.state) - odize.AssertTrue(t, cmd == nil) - }) - err := group.Run() odize.AssertNoError(t, err) } From f7ec6976b9b4e7ebd925aee4201eb4751a117c29 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 27 Jun 2026 21:30:29 +1000 Subject: [PATCH 89/98] wip --- internal/ui/page_new_battle_selection.go | 55 +++++++++---------- internal/ui/page_new_battle_selection_test.go | 35 ++++++++++++ 2 files changed, 61 insertions(+), 29 deletions(-) diff --git a/internal/ui/page_new_battle_selection.go b/internal/ui/page_new_battle_selection.go index a4a9275..1ba8d03 100644 --- a/internal/ui/page_new_battle_selection.go +++ b/internal/ui/page_new_battle_selection.go @@ -11,12 +11,6 @@ import ( "charm.land/lipgloss/v2" ) -type selectionState int - -const ( - stateSelectingCoach selectionState = iota -) - type msgAICoachesLoaded struct { aiTeams []AITeamItem } @@ -62,7 +56,6 @@ type ModelNewBattleSelection struct { theme IceTheme globalState *GlobalState teamsSvc *teams.Service - state selectionState aiCoaches []AITeamItem selectedCoachIdx int keys battleSelectionKeyMap @@ -103,7 +96,6 @@ func (m *ModelNewBattleSelection) loadAICoaches() tea.Msg { } func (m *ModelNewBattleSelection) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd m.footer.Update(msg) switch msg := msg.(type) { @@ -118,32 +110,37 @@ func (m *ModelNewBattleSelection) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case error: m.err = msg case tea.KeyMsg: - switch { - case key.Matches(msg, m.keys.Quit): - return m, tea.Quit - case key.Matches(msg, m.keys.Back): + return m.handleKey(msg) + } + + return m, nil +} + +func (m *ModelNewBattleSelection) handleKey(msg tea.KeyMsg) (*ModelNewBattleSelection, tea.Cmd) { + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + case key.Matches(msg, m.keys.Back): + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageTitle} + } + case key.Matches(msg, m.keys.Select): + if len(m.aiCoaches) > 0 { + // TODO: Start battle with selected coach return m, func() tea.Msg { return MsgSwitchPage{NewPage: PageTitle} } - case key.Matches(msg, m.keys.Select): - if len(m.aiCoaches) > 0 { - // TODO: Start battle with selected coach - return m, func() tea.Msg { - return MsgSwitchPage{NewPage: PageTitle} - } - } - case msg.String() == "up", msg.String() == "k": - if m.selectedCoachIdx > 0 { - m.selectedCoachIdx-- - } - case msg.String() == "down", msg.String() == "j": - if m.selectedCoachIdx < len(m.aiCoaches)-1 { - m.selectedCoachIdx++ - } + } + case msg.String() == "up", msg.String() == "k": + if m.selectedCoachIdx > 0 { + m.selectedCoachIdx-- + } + case msg.String() == "down", msg.String() == "j": + if m.selectedCoachIdx < len(m.aiCoaches)-1 { + m.selectedCoachIdx++ } } - - return m, tea.Batch(cmds...) + return m, nil } func (m *ModelNewBattleSelection) View() tea.View { diff --git a/internal/ui/page_new_battle_selection_test.go b/internal/ui/page_new_battle_selection_test.go index e241474..2f6e0b0 100644 --- a/internal/ui/page_new_battle_selection_test.go +++ b/internal/ui/page_new_battle_selection_test.go @@ -80,6 +80,41 @@ func TestModelNewBattleSelection_Rendering(t *testing.T) { } }) + group.Test("should handle navigation between coaches", func(t *testing.T) { + state := &GlobalState{} + m := NewModelNewBattleSelection(state, nil) + m.aiCoaches = []AITeamItem{ + {coach: teams.Coach{Name: "Coach A"}}, + {coach: teams.Coach{Name: "Coach B"}}, + {coach: teams.Coach{Name: "Coach C"}}, + } + m.selectedCoachIdx = 0 + + // Move down + m.Update(tea.KeyPressMsg{Text: "down"}) + odize.AssertEqual(t, 1, m.selectedCoachIdx) + + // Move down again + m.Update(tea.KeyPressMsg{Text: "j"}) + odize.AssertEqual(t, 2, m.selectedCoachIdx) + + // Move down at the end (should stay) + m.Update(tea.KeyPressMsg{Text: "down"}) + odize.AssertEqual(t, 2, m.selectedCoachIdx) + + // Move up + m.Update(tea.KeyPressMsg{Text: "up"}) + odize.AssertEqual(t, 1, m.selectedCoachIdx) + + // Move up again + m.Update(tea.KeyPressMsg{Text: "k"}) + odize.AssertEqual(t, 0, m.selectedCoachIdx) + + // Move up at the beginning (should stay) + m.Update(tea.KeyPressMsg{Text: "up"}) + odize.AssertEqual(t, 0, m.selectedCoachIdx) + }) + err := group.Run() odize.AssertNoError(t, err) } From 98cf1ca39ea5ae08210d25cdf4e2ba79c7e42dc8 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 27 Jun 2026 21:44:13 +1000 Subject: [PATCH 90/98] wip --- internal/ui/app.go | 11 ++++++----- internal/ui/app_test.go | 2 +- internal/ui/page_title.go | 6 +++--- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index 1ee41a2..223288d 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -72,10 +72,10 @@ type Dependencies struct { } // New returns a new UI model. -func New(deps Dependencies) RootModel { +func New(deps Dependencies) *RootModel { state := &GlobalState{} - return RootModel{ + return &RootModel{ ctx: context.Background(), theme: NewIceTheme(), currentPage: PageTitle, @@ -96,7 +96,7 @@ func New(deps Dependencies) RootModel { } } -func (m RootModel) Init() tea.Cmd { +func (m *RootModel) Init() tea.Cmd { return func() tea.Msg { coach, err := m.teamsSvc.GetDefaultCoach(m.ctx) if err != nil { @@ -112,7 +112,7 @@ func (m RootModel) Init() tea.Cmd { } } -func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { @@ -156,6 +156,7 @@ func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) m.pageTitleSettings, cmd = m.pageTitleSettings.Update(msg) cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) } var cmd tea.Cmd @@ -186,7 +187,7 @@ func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m RootModel) View() tea.View { +func (m *RootModel) View() tea.View { if m.width == 0 || m.height == 0 { return tea.NewView("Initializing...") } diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index b7159bc..57adced 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -91,7 +91,7 @@ func TestNew(t *testing.T) { GameSvc: gs, }) newModel, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 50}) - updatedModel := newModel.(RootModel) + updatedModel := newModel.(*RootModel) odize.AssertTrue(t, updatedModel.width == 100) odize.AssertTrue(t, updatedModel.height == 50) }). diff --git a/internal/ui/page_title.go b/internal/ui/page_title.go index 63733cc..ff329be 100644 --- a/internal/ui/page_title.go +++ b/internal/ui/page_title.go @@ -55,11 +55,11 @@ func NewModelTitle(globalState *GlobalState) *ModelTitle { } } -func (m ModelTitle) Init() tea.Cmd { +func (m *ModelTitle) Init() tea.Cmd { return nil } -func (m ModelTitle) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *ModelTitle) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch vMsg := msg.(type) { case MsgStateUpdated: m.globalState.Coach = vMsg.Coach @@ -112,7 +112,7 @@ const logo = ` |_| \_\\___/|____/|_| |_| ` -func (m ModelTitle) View() tea.View { +func (m *ModelTitle) View() tea.View { if m.width == 0 || m.height == 0 { return tea.NewView("Initializing...") } From bde6c60a58d6af159f88046ecfb801fb0e93ef41 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 27 Jun 2026 21:54:58 +1000 Subject: [PATCH 91/98] wip --- .github/CODEOWNERS | 1 + .github/workflows/ci.yml | 47 ++++++++++++++++++++++++++++++++++++++++ Taskfile.yml | 1 + 3 files changed, 49 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/ci.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..191ba7d --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @frag223 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8af2c49 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + pull_request: + branches: [ main, master ] + push: + branches: [ main, master ] + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install sqlc + uses: sqlc-dev/setup-sqlc@v4 + with: + sqlc-version: '1.27.0' + + - name: Run generation + run: task generate + + - name: Run format + run: task format + + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: v1.64 + + - name: Run sqlc vet + run: sqlc vet + + - name: Run tests + run: task test diff --git a/Taskfile.yml b/Taskfile.yml index 28d9164..c539307 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -43,6 +43,7 @@ tasks: - task: generate - task: format - task: lint + - task: cover test: From 838f350122f4326b26379b8440dc396e1ac8cfe3 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 27 Jun 2026 21:56:41 +1000 Subject: [PATCH 92/98] wip --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8af2c49..087bda4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,4 +44,4 @@ jobs: run: sqlc vet - name: Run tests - run: task test + run: task cover From ae2e95e0ea66a979af5fb8d9109c5a68e44e2ec6 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 27 Jun 2026 22:03:37 +1000 Subject: [PATCH 93/98] fixing issue --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 087bda4..d330474 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,8 +37,6 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@v6 - with: - version: v1.64 - name: Run sqlc vet run: sqlc vet From 437ab57456fc423f60e11804ea13140ead362d1a Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 27 Jun 2026 22:05:37 +1000 Subject: [PATCH 94/98] fixing issue --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d330474..b8bf90f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: CI +name: app ci on: pull_request: From c298969c9f74b88cc1fb5a4ea83b0f9766bbeb28 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 27 Jun 2026 22:08:19 +1000 Subject: [PATCH 95/98] forcing version --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8bf90f..cfe3320 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,8 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@v6 + with: + version: v2.12 - name: Run sqlc vet run: sqlc vet From 2b1f336ce793c796c8a10a4df3b23e5224447a80 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 27 Jun 2026 22:12:48 +1000 Subject: [PATCH 96/98] forcing version --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfe3320..adf67e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: run: task format - name: golangci-lint - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v9 with: version: v2.12 From 00e021b1f151b28433a34a0e9452298bd8b7ed52 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 27 Jun 2026 22:14:43 +1000 Subject: [PATCH 97/98] removing lint for now --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adf67e9..0203d56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,10 +35,10 @@ jobs: - name: Run format run: task format - - name: golangci-lint - uses: golangci/golangci-lint-action@v9 - with: - version: v2.12 +# - name: golangci-lint +# uses: golangci/golangci-lint-action@v9 +# with: +# version: v2.12 - name: Run sqlc vet run: sqlc vet From 413ea5524726a1142b86a94597c600d449964792 Mon Sep 17 00:00:00 2001 From: frag223 Date: Sat, 27 Jun 2026 22:18:48 +1000 Subject: [PATCH 98/98] adding format checker --- .github/workflows/ci.yml | 4 ++-- Taskfile.yml | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0203d56..fb37759 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,8 +32,8 @@ jobs: - name: Run generation run: task generate - - name: Run format - run: task format + - name: Run format checker + run: task format-check # - name: golangci-lint # uses: golangci/golangci-lint-action@v9 diff --git a/Taskfile.yml b/Taskfile.yml index c539307..2f76f2c 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -15,6 +15,15 @@ tasks: cmds: - golangci-lint run ./... - sqlc vet + - task: format-check + + format-check: + desc: Check if Go code is formatted + cmds: + - cmd: test -z "$(gofmt -l .)" + silent: true + status: + - test -z "$(gofmt -l .)" format: desc: Format Go code and tidy modules