From 81d4ceb354f31e5b3c1c38336a70cd9ec0b6261b Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sun, 1 Feb 2026 13:25:11 +1100 Subject: [PATCH] added seasons list --- IMPLEMENTATION_COMPLETE.md | 275 ------------- MIGRATION_IMPLEMENTATION_SUMMARY.md | 383 ------------------ cmd/oslstats/httpserver.go | 1 + cmd/oslstats/migrate.go | 19 +- cmd/oslstats/routes.go | 10 + go.sum | 2 - internal/db/paginate.go | 96 +++++ internal/db/season.go | 83 ++++ internal/db/user.go | 10 +- internal/handlers/seasons.go | 128 ++++++ internal/view/component/nav/navbar.templ | 7 +- .../component/pagination/pagination.templ | 101 +++++ internal/view/component/sort/dropdown.templ | 26 ++ internal/view/page/index.templ | 2 +- internal/view/page/seasons_list.templ | 85 ++++ pkg/embedfs/files/css/output.css | 66 ++- pkg/embedfs/files/js/pagination.js | 45 ++ 17 files changed, 660 insertions(+), 679 deletions(-) delete mode 100644 IMPLEMENTATION_COMPLETE.md delete mode 100644 MIGRATION_IMPLEMENTATION_SUMMARY.md create mode 100644 internal/db/paginate.go create mode 100644 internal/db/season.go create mode 100644 internal/handlers/seasons.go create mode 100644 internal/view/component/pagination/pagination.templ create mode 100644 internal/view/component/sort/dropdown.templ create mode 100644 internal/view/page/seasons_list.templ create mode 100644 pkg/embedfs/files/js/pagination.js diff --git a/IMPLEMENTATION_COMPLETE.md b/IMPLEMENTATION_COMPLETE.md deleted file mode 100644 index 13b6bbc..0000000 --- a/IMPLEMENTATION_COMPLETE.md +++ /dev/null @@ -1,275 +0,0 @@ -# 🎉 MIGRATION SYSTEM IMPLEMENTATION COMPLETE! 🎉 - -## ✅ All Features Implemented and Tested - -### What You Can Do Now: - -```bash -# 1. Check migration status -make migrate-status - -# 2. Preview pending migrations -make migrate-dry-run - -# 3. Run migrations (with automatic backup) -make migrate - -# 4. Create new migrations -make migrate-create NAME=my_new_feature - -# 5. Rollback if needed -make migrate-rollback - -# 6. Reset database (dev only) -make reset-db -``` - -## 📊 Implementation Statistics - -- **Files Created**: 5 -- **Files Modified**: 7 -- **Lines of Code**: ~800 -- **Features**: 8 major safety features -- **Commands**: 7 new make targets -- **Documentation**: Comprehensive AGENTS.md section - -## 🎯 Key Achievements - -✅ **Production-Safe Migrations** -- No more accidental data deletion -- Automatic compressed backups before every change -- 5-minute migration lock timeout -- Pre-migration validation - -✅ **Developer-Friendly** -- Simple `make migrate-create NAME=feature` workflow -- Dry-run mode to preview changes -- Pretty status output -- Clear error messages - -✅ **Enterprise Features** -- PostgreSQL advisory locks prevent concurrent migrations -- Backup retention policy (keeps last 10) -- Graceful degradation if pg_dump missing -- Comprehensive logging - -## 📁 Project Structure After Implementation - -``` -oslstats/ -├── cmd/oslstats/ -│ ├── main.go ✏️ MODIFIED - Added migration routing -│ ├── db.go ✏️ MODIFIED - Simplified loadModels -│ ├── migrate.go ✅ NEW - Migration runner (350 lines) -│ └── migrations/ -│ ├── migrations.go ✅ NEW - Migration collection -│ └── 20250124000001_initial_schema.go ✅ NEW - Initial migration -├── internal/ -│ ├── backup/ -│ │ └── backup.go ✅ NEW - Backup system (140 lines) -│ ├── config/ -│ │ └── flags.go ✏️ MODIFIED - Added 7 migration flags -│ └── db/ -│ └── config.go ✏️ MODIFIED - Added backup config -├── backups/ -│ └── .gitkeep ✅ NEW - Git-tracked directory -├── Makefile ✏️ MODIFIED - Added 7 migration targets -├── AGENTS.md ✏️ MODIFIED - Added migration guide -├── .gitignore ✏️ MODIFIED - Added backup exclusions -├── MIGRATION_IMPLEMENTATION_SUMMARY.md 📄 Documentation -└── IMPLEMENTATION_COMPLETE.md 📄 This file -``` - -## 🚀 Quick Start Guide - -### First Time Setup - -1. **Ensure PostgreSQL is running** - ```bash - # Your database should be configured in .env: - # DB_USER=... - # DB_PASSWORD=... - # DB_HOST=localhost - # DB_PORT=5432 - # DB_NAME=oslstats - # DB_SSL=disable - ``` - -2. **Run initial migration** - ```bash - make migrate - ``` - This will: - - Validate migrations compile - - Create initial schema (users, discord_tokens tables) - - Create migration tracking table (bun_migrations) - -3. **Verify** - ```bash - make migrate-status - ``` - Should show: - ``` - ✅ Applied 20250124000001_initial_schema 1 2026-01-24 15:XX:XX - ``` - -### Adding Your First Feature - -1. **Create a migration** - ```bash - make migrate-create NAME=add_games_table - ``` - -2. **Edit the generated file** - - Location: `cmd/oslstats/migrations/YYYYMMDDHHmmss_add_games_table.go` - - Implement UP function (apply changes) - - Implement DOWN function (rollback changes) - -3. **Preview** - ```bash - make migrate-dry-run - ``` - -4. **Apply** - ```bash - make migrate - ``` - -5. **Verify** - ```bash - make migrate-status - ``` - -## 🛡️ Safety Features in Action - -### 1. Automatic Backups -Every migration creates a compressed backup: -``` -backups/20250124_150530_pre_migration.sql.gz (2.5 MB) -``` - -### 2. Migration Locking -``` -[INFO] Migration lock acquired -[INFO] Applying migrations... -[INFO] Migration lock released -``` - -### 3. Validation -``` -[INFO] Step 1/5: Validating migrations... -[INFO] Migration validation passed ✓ -``` - -### 4. Progress Tracking -``` -[INFO] Step 1/5: Validating migrations... -[INFO] Step 2/5: Checking for pending migrations... -[INFO] Step 3/5: Creating backup... -[INFO] Step 4/5: Acquiring migration lock... -[INFO] Step 5/5: Applying migrations... -``` - -## 📝 Example: Adding Email Field to Users - -**Complete workflow:** - -```bash -# 1. Create migration -make migrate-create NAME=add_email_to_users - -# 2. Edit cmd/oslstats/migrations/YYYYMMDDHHmmss_add_email_to_users.go -# Add this code: - -package migrations - -import ( - "context" - "github.com/uptrace/bun" -) - -func init() { - Migrations.MustRegister( - // UP: Add email column - func(ctx context.Context, db *bun.DB) error { - _, err := db.ExecContext(ctx, - "ALTER TABLE users ADD COLUMN email VARCHAR(255)") - return err - }, - // DOWN: Remove email column - func(ctx context.Context, db *bun.DB) error { - _, err := db.ExecContext(ctx, - "ALTER TABLE users DROP COLUMN email") - return err - }, - ) -} - -# 3. Update internal/db/user.go model -# Add: Email string `bun:"email"` - -# 4. Preview -make migrate-dry-run - -# 5. Apply -make migrate - -# 6. Verify -make migrate-status -``` - -## 🔍 Troubleshooting - -### "pg_dump not found" -**Issue**: Backups are skipped -**Solution**: Install PostgreSQL client tools (optional, migrations still work) -```bash -# Ubuntu/Debian -sudo apt-get install postgresql-client - -# macOS -brew install postgresql - -# Arch -sudo pacman -S postgresql-libs -``` - -### "migration already in progress" -**Issue**: Another process is running migrations -**Solution**: Wait up to 5 minutes for it to complete, or check for hung connections - -### "migration build failed" -**Issue**: Syntax error in migration file -**Solution**: Fix the error shown in output, then try again - -## 📚 Resources - -- **Full Documentation**: See `AGENTS.md` "Database Migrations" section -- **Implementation Details**: See `MIGRATION_IMPLEMENTATION_SUMMARY.md` -- **Bun Docs**: https://bun.uptrace.dev/guide/migrations.html - -## 🎓 Best Practices - -1. ✅ Always use `make migrate-dry-run` before applying -2. ✅ Test rollbacks in development -3. ✅ Keep migrations small and focused -4. ✅ Commit migration files to git -5. ✅ Document complex migrations with comments -6. ✅ Test in staging before production - -## ✨ What's Next? - -Your migration system is ready to use! You can: - -1. **Start using it immediately** - Run `make migrate` to apply initial schema -2. **Add new features** - Use `make migrate-create NAME=...` workflow -3. **Deploy confidently** - Automatic backups and rollback support -4. **Scale safely** - Migration locking prevents conflicts - ---- - -**🎉 Congratulations! Your migration system is production-ready! 🎉** - -**Implementation Date**: January 24, 2026 -**Status**: ✅ Complete and Tested -**Next Action**: Run `make migrate` to apply initial schema diff --git a/MIGRATION_IMPLEMENTATION_SUMMARY.md b/MIGRATION_IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index a94e484..0000000 --- a/MIGRATION_IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -1,383 +0,0 @@ -# ✅ Migration System Implementation Complete! - -## 🎉 What Was Built - -A complete, production-ready database migration system for oslstats with: - -### Core Features -- ✅ **Go-based migrations** - Type-safe, compile-time checked -- ✅ **Automatic compressed backups** - gzip compression saves ~80% space -- ✅ **Backup retention** - Keeps last 10 backups (configurable) -- ✅ **Dry-run mode** - Preview migrations before applying -- ✅ **Migration locking** - PostgreSQL advisory locks with 5-minute timeout -- ✅ **Pre-migration validation** - `go build` check ensures migrations compile -- ✅ **Graceful pg_dump handling** - Warns if not available, doesn't block migrations -- ✅ **Interactive reset** - Requires 'yes' confirmation for destructive operations - -## 📁 Files Created (5 new files) - -1. **`cmd/oslstats/migrations/migrations.go`** - - Migration collection registry - - Exports `Migrations` variable for registration - -2. **`cmd/oslstats/migrations/20250124000001_initial_schema.go`** - - Initial migration for users and discord_tokens tables - - UP: Creates both tables - - DOWN: Drops both tables (rollback support) - -3. **`cmd/oslstats/migrate.go`** (~350 lines) - - Complete migration runner with all commands - - Functions: runMigrations, migrateUp, migrateRollback, migrateStatus, migrateDryRun - - Validation: validateMigrations (go build check) - - Locking: acquireMigrationLock, releaseMigrationLock (PostgreSQL advisory locks) - - Utilities: createMigration, resetDatabase - -4. **`internal/backup/backup.go`** (~140 lines) - - Backup system with gzip compression - - CreateBackup: pg_dump with compression, graceful error handling - - CleanOldBackups: Retention policy cleanup - - Handles missing pg_dump/gzip gracefully - -5. **`backups/.gitkeep`** - - Empty file to track backup directory in git - -## 📝 Files Modified (7 files) - -1. **`cmd/oslstats/main.go`** - - Added migration command routing before server startup - - Routes to: migrate-up, migrate-rollback, migrate-status, migrate-dry-run, reset-db, migrate-create - - Migration commands exit after execution (don't start server) - -2. **`cmd/oslstats/db.go`** - - Removed `resetDB` parameter from loadModels() - - Simplified to only create tables with IfNotExists() - - Reset logic moved to resetDatabase() in migrate.go - -3. **`internal/config/flags.go`** - - Added 7 new migration-related flags - - Renamed `--migrate` to `--reset-db` - - Added validation to prevent multiple migration commands simultaneously - - Updated SetupFlags() to return (Flags, error) - -4. **`internal/db/config.go`** - - Added BackupDir field (default: "backups") - - Added BackupRetention field (default: 10) - - Added validation for BackupRetention >= 1 - -5. **`Makefile`** - - Added 7 new migration targets: - - `make migrate` - Run pending migrations with backup - - `make migrate-no-backup` - Run without backup (dev) - - `make migrate-rollback` - Rollback last group - - `make migrate-status` - Show status - - `make migrate-dry-run` - Preview pending - - `make migrate-create NAME=...` - Create new migration - - `make reset-db` - Reset database (destructive) - -6. **`AGENTS.md`** - - Replaced "Database" section with comprehensive "Database Migrations" section - - Added migration creation guide with examples - - Added migration patterns (create table, add column, create index, data migration) - - Added safety features documentation - - Added troubleshooting guide - - Added best practices - -7. **`.gitignore`** - - Added backups/*.sql.gz - - Added backups/*.sql - - Excludes backups but keeps backups/.gitkeep - -## 🎯 Available Commands - -### Migration Commands -```bash -# Show what migrations have been applied -make migrate-status - -# Preview migrations without applying -make migrate-dry-run - -# Run pending migrations (with automatic backup) -make migrate - -# Rollback last migration group (with backup) -make migrate-rollback - -# Create new migration file -make migrate-create NAME=add_email_to_users - -# Skip backups (dev only - faster) -make migrate-no-backup - -# Reset database (destructive, requires confirmation) -make reset-db -``` - -### Direct Binary Flags -```bash -./bin/oslstats --migrate-up # Run migrations -./bin/oslstats --migrate-rollback # Rollback -./bin/oslstats --migrate-status # Show status -./bin/oslstats --migrate-dry-run # Preview -./bin/oslstats --migrate-create foo # Create migration -./bin/oslstats --no-backup # Skip backups -./bin/oslstats --reset-db # Reset database -``` - -## 🔧 Configuration - -### Environment Variables - -Add to your `.env` file: - -```bash -# Database Backup Configuration -DB_BACKUP_DIR=backups # Directory for backups (default: backups) -DB_BACKUP_RETENTION=10 # Number of backups to keep (default: 10) -``` - -### Requirements - -**Required:** -- PostgreSQL database -- Go 1.25.5+ - -**Optional (for backups):** -- `pg_dump` (PostgreSQL client tools) -- `gzip` (compression utility) - -If `pg_dump` or `gzip` are not available, the system will: -- Warn the user -- Skip backups -- Continue with migrations normally - -## 📋 Migration Workflow Example - -### Adding a New Table - -1. **Create the model** (`internal/db/game.go`): - ```go - package db - - import "github.com/uptrace/bun" - - type Game struct { - bun.BaseModel `bun:"table:games,alias:g"` - - ID int `bun:"id,pk,autoincrement"` - Name string `bun:"name"` - CreatedAt int64 `bun:"created_at"` - } - ``` - -2. **Generate migration:** - ```bash - make migrate-create NAME=add_games_table - ``` - -3. **Edit migration** (`cmd/oslstats/migrations/YYYYMMDDHHmmss_add_games_table.go`): - ```go - package migrations - - import ( - "context" - "git.haelnorr.com/h/oslstats/internal/db" - "github.com/uptrace/bun" - ) - - func init() { - Migrations.MustRegister( - // UP: Create games table - func(ctx context.Context, dbConn *bun.DB) error { - _, err := dbConn.NewCreateTable(). - Model((*db.Game)(nil)). - Exec(ctx) - return err - }, - // DOWN: Drop games table - func(ctx context.Context, dbConn *bun.DB) error { - _, err := dbConn.NewDropTable(). - Model((*db.Game)(nil)). - IfExists(). - Exec(ctx) - return err - }, - ) - } - ``` - -4. **Preview the migration:** - ```bash - make migrate-dry-run - ``` - Output: - ``` - [INFO] Pending migrations (dry-run): - 📋 YYYYMMDDHHmmss_add_games_table - [INFO] Would migrate to group 2 - ``` - -5. **Apply the migration:** - ```bash - make migrate - ``` - Output: - ``` - [INFO] Step 1/5: Validating migrations... - [INFO] Migration validation passed ✓ - [INFO] Step 2/5: Checking for pending migrations... - [INFO] Step 3/5: Creating backup... - [INFO] Backup created: backups/20250124_150530_pre_migration.sql.gz (2.5 MB) - [INFO] Step 4/5: Acquiring migration lock... - [INFO] Migration lock acquired - [INFO] Step 5/5: Applying migrations... - [INFO] Migrated to group 2 - ✅ YYYYMMDDHHmmss_add_games_table - [INFO] Migration lock released - ``` - -6. **Verify:** - ```bash - make migrate-status - ``` - -## 🛡️ Safety Features - -### 1. Automatic Backups -- Created before every migration and rollback -- Compressed with gzip (saves ~80% disk space) -- Includes `--clean` and `--if-exists` for safe restoration -- Retention policy automatically cleans old backups - -### 2. Migration Locking -- Uses PostgreSQL advisory locks (lock ID: 1234567890) -- Prevents concurrent migrations from different processes -- 5-minute timeout prevents hung locks -- Automatically released on completion or error - -### 3. Pre-Migration Validation -- Runs `go build ./cmd/oslstats/migrations` before applying -- Ensures all migrations compile -- Catches syntax errors before they affect the database - -### 4. Dry-Run Mode -- Preview exactly which migrations would run -- See group number before applying -- No database changes - -### 5. Status Tracking -- Shows which migrations are applied/pending -- Displays migration group and timestamp -- Summary counts - -### 6. Interactive Confirmation -- `--reset-db` requires typing 'yes' -- Prevents accidental data loss - -## 📊 Testing Checklist - -- [x] Migration file creation works -- [x] Build compiles without errors -- [x] Flags registered correctly -- [x] Help text displays all flags -- [x] Makefile targets work -- [x] Documentation updated -- [x] .gitignore configured - -### Manual Testing Required - -Connect to your database and test: - -1. **Fresh database migration:** - ```bash - make migrate-status # Should show initial_schema as pending - make migrate-dry-run # Preview - make migrate # Apply - make migrate-status # Should show initial_schema as applied - ``` - -2. **Rollback:** - ```bash - make migrate-rollback - make migrate-status # Should show as pending again - ``` - -3. **Create and apply new migration:** - ```bash - make migrate-create NAME=test_feature - # Edit the file - make migrate - ``` - -4. **Backup verification:** - ```bash - ls -lh backups/ - # Should see .sql.gz files - ``` - -5. **Concurrent migration prevention:** - ```bash - # Terminal 1: - make migrate & - # Terminal 2 (immediately): - make migrate - # Should fail with "migration already in progress" - ``` - -## 🎓 Developer Notes - -### Migration Naming Convention -``` -YYYYMMDDHHmmss_description.go - -Examples: -20250124120000_initial_schema.go -20250125103045_add_email_to_users.go -20250126144530_create_games_table.go -``` - -### Migration Best Practices - -1. **One migration = one logical change** - - Don't combine unrelated changes - - Keep migrations focused and simple - -2. **Always write DOWN functions** - - Every UP must have a corresponding DOWN - - Test rollbacks in development - -3. **Test before production** - - Use `make migrate-dry-run` to preview - - Test rollback works: `make migrate-rollback` - - Verify with `make migrate-status` - -4. **Commit migrations to git** - - Migration files are source code - - Include in pull requests - - Review carefully - -5. **Production deployment** - - Always have a rollback plan - - Test in staging first - - Communicate potential downtime - - Keep backups accessible - -## 🚀 Next Steps - -The migration system is now fully operational! You can: - -1. **Connect to your database** and run `make migrate` to apply the initial schema -2. **Create new migrations** as you add features -3. **Use dry-run mode** to preview changes safely -4. **Check migration status** anytime with `make migrate-status` - -## 📚 Additional Resources - -- **Bun Migration Docs**: https://bun.uptrace.dev/guide/migrations.html -- **PostgreSQL Advisory Locks**: https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS -- **Migration Patterns**: See AGENTS.md "Database Migrations" section - ---- - -**Implementation Date**: January 24, 2026 -**Status**: ✅ Complete and Ready for Use diff --git a/cmd/oslstats/httpserver.go b/cmd/oslstats/httpserver.go index e37789e..ac3fa77 100644 --- a/cmd/oslstats/httpserver.go +++ b/cmd/oslstats/httpserver.go @@ -38,6 +38,7 @@ func setupHttpServer( "/static/js/toasts.js", "/static/js/copytoclipboard.js", "/static/js/theme.js", + "/static/js/pagination.js", "/static/vendored/htmx@2.0.8.min.js", "/static/vendored/htmx-ext-ws.min.js", "/static/vendored/alpinejs@3.15.4.min.js", diff --git a/cmd/oslstats/migrate.go b/cmd/oslstats/migrate.go index ae8858e..3cc05ce 100644 --- a/cmd/oslstats/migrate.go +++ b/cmd/oslstats/migrate.go @@ -51,13 +51,14 @@ func migrateUp(ctx context.Context, migrator *migrate.Migrator, conn *bun.DB, cf fmt.Println("[INFO] Migration validation passed ✓") fmt.Println("[INFO] Step 2/5: Checking for pending migrations...") - // Check for pending migrations - group, err := migrator.Migrate(ctx, migrate.WithNopMigration()) + // Check for pending migrations using MigrationsWithStatus (read-only) + ms, err := migrator.MigrationsWithStatus(ctx) if err != nil { - return errors.Wrap(err, "check pending migrations") + return errors.Wrap(err, "get migration status") } - if group.IsZero() { + unapplied := ms.Unapplied() + if len(unapplied) == 0 { fmt.Println("[INFO] No pending migrations") return nil } @@ -88,7 +89,7 @@ func migrateUp(ctx context.Context, migrator *migrate.Migrator, conn *bun.DB, cf // Run migrations fmt.Println("[INFO] Step 5/5: Applying migrations...") - group, err = migrator.Migrate(ctx) + group, err := migrator.Migrate(ctx) if err != nil { return errors.Wrap(err, "migrate") } @@ -290,13 +291,13 @@ import ( func init() { Migrations.MustRegister( - // UP migration - TODO: Implement - func(ctx context.Context, db *bun.DB) error { + // UP migration + func(ctx context.Context, dbConn *bun.DB) error { // TODO: Add your migration code here return nil }, - // DOWN migration - TODO: Implement - func(ctx context.Context, db *bun.DB) error { + // DOWN migration + func(ctx context.Context, dbConn *bun.DB) error { // TODO: Add your rollback code here return nil }, diff --git a/cmd/oslstats/routes.go b/cmd/oslstats/routes.go index fc22b64..11979cb 100644 --- a/cmd/oslstats/routes.go +++ b/cmd/oslstats/routes.go @@ -62,6 +62,16 @@ func addRoutes( Handler: handlers.NotifyTester(s), // TODO: add login protection }, + { + Path: "/seasons", + Method: hws.MethodGET, + Handler: handlers.SeasonsPage(s, conn), + }, + { + Path: "/seasons", + Method: hws.MethodPOST, + Handler: handlers.SeasonsList(s, conn), + }, } htmxRoutes := []hws.Route{ diff --git a/go.sum b/go.sum index 536c99a..78c992b 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,6 @@ git.haelnorr.com/h/golib/hlog v0.10.4 h1:vpCsV/OddjIYx8F48U66WxojjmhEbeLGQAOBG4V git.haelnorr.com/h/golib/hlog v0.10.4/go.mod h1:+wJ8vecQY/JITTXKmI3JfkHiUGyMs7N6wooj2wuWZbc= git.haelnorr.com/h/golib/hws v0.4.3 h1:rpqe0Dcbm3b5XZ/Bfy0LUhph6RR7+bmANrSA/W81l0A= git.haelnorr.com/h/golib/hws v0.4.3/go.mod h1:UqB83p9lGjidDkk0pWRqxxOFrCkg8t+9J6uGtBOjNLo= -git.haelnorr.com/h/golib/hwsauth v0.5.2 h1:K4McXMEHtI5o4fAL3AZrmaMkwORNqSTV3MM6BExNKag= -git.haelnorr.com/h/golib/hwsauth v0.5.2/go.mod h1:NOonrVU/lX8lzuV77eDEiTwBjn7RrzYVcSdXUJWeHmQ= git.haelnorr.com/h/golib/hwsauth v0.5.3 h1:Vgw8khDQZJRCc3m7z9QlbL9CYPyFB9JXUC3+omKRZPc= git.haelnorr.com/h/golib/hwsauth v0.5.3/go.mod h1:NOonrVU/lX8lzuV77eDEiTwBjn7RrzYVcSdXUJWeHmQ= git.haelnorr.com/h/golib/jwt v0.10.1 h1:1Adxt9H3Y4fWFvFjWpvg/vSFhbgCMDMxgiE3m7KvDMI= diff --git a/internal/db/paginate.go b/internal/db/paginate.go new file mode 100644 index 0000000..e4ad389 --- /dev/null +++ b/internal/db/paginate.go @@ -0,0 +1,96 @@ +package db + +import "github.com/uptrace/bun" + +type PageOpts struct { + Page int + PerPage int + Order bun.Order + OrderBy string +} + +type OrderOpts struct { + Order bun.Order + OrderBy string + Label string +} + +// TotalPages calculates the total number of pages +func (p *PageOpts) TotalPages(total int) int { + if p.PerPage == 0 { + return 0 + } + pages := total / p.PerPage + if total%p.PerPage > 0 { + pages++ + } + return pages +} + +// HasPrevPage checks if there is a previous page +func (p *PageOpts) HasPrevPage() bool { + return p.Page > 1 +} + +// HasNextPage checks if there is a next page +func (p *PageOpts) HasNextPage(total int) bool { + return p.Page < p.TotalPages(total) +} + +// GetPageRange returns an array of page numbers to display +// maxButtons controls how many page buttons to show +func (p *PageOpts) GetPageRange(total int, maxButtons int) []int { + totalPages := p.TotalPages(total) + if totalPages == 0 { + return []int{} + } + + // If total pages is less than max buttons, show all pages + if totalPages <= maxButtons { + pages := make([]int, totalPages) + for i := 0; i < totalPages; i++ { + pages[i] = i + 1 + } + return pages + } + + // Calculate range around current page + halfButtons := maxButtons / 2 + start := p.Page - halfButtons + end := p.Page + halfButtons + + // Adjust if at beginning + if start < 1 { + start = 1 + end = maxButtons + } + + // Adjust if at end + if end > totalPages { + end = totalPages + start = totalPages - maxButtons + 1 + } + + pages := make([]int, 0, maxButtons) + for i := start; i <= end; i++ { + pages = append(pages, i) + } + return pages +} + +// StartItem returns the number of the first item on the current page +func (p *PageOpts) StartItem() int { + if p.Page < 1 { + return 0 + } + return (p.Page-1)*p.PerPage + 1 +} + +// EndItem returns the number of the last item on the current page +func (p *PageOpts) EndItem(total int) int { + end := p.Page * p.PerPage + if end > total { + return total + } + return end +} diff --git a/internal/db/season.go b/internal/db/season.go new file mode 100644 index 0000000..fab36ae --- /dev/null +++ b/internal/db/season.go @@ -0,0 +1,83 @@ +package db + +import ( + "context" + "database/sql" + + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +type Season struct { + bun.BaseModel `bun:"table:seasons,alias:s"` + + ID int `bun:"id,pk,autoincrement"` + Name string `bun:"name,unique"` + ShortName string `bun:"short_name,unique"` +} + +type SeasonList struct { + Seasons []Season + Total int + PageOpts PageOpts +} + +func NewSeason(ctx context.Context, tx bun.Tx, name, shortname string) (*Season, error) { + if name == "" { + return nil, errors.New("name cannot be empty") + } + if shortname == "" { + return nil, errors.New("shortname cannot be empty") + } + season := &Season{ + Name: name, + ShortName: shortname, + } + _, err := tx.NewInsert(). + Model(season). + Exec(ctx) + if err != nil { + return nil, errors.Wrap(err, "tx.NewInsert") + } + return season, nil +} + +func ListSeasons(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*SeasonList, error) { + if pageOpts == nil { + pageOpts = &PageOpts{} + } + if pageOpts.Page == 0 { + pageOpts.Page = 1 + } + if pageOpts.PerPage == 0 { + pageOpts.PerPage = 10 + } + if pageOpts.Order == "" { + pageOpts.Order = bun.OrderDesc + } + if pageOpts.OrderBy == "" { + pageOpts.OrderBy = "name" + } + seasons := []Season{} + err := tx.NewSelect(). + Model(&seasons). + OrderBy(pageOpts.OrderBy, pageOpts.Order). + Offset(pageOpts.PerPage * (pageOpts.Page - 1)). + Limit(pageOpts.PerPage). + Scan(ctx) + if err != nil && err != sql.ErrNoRows { + return nil, errors.Wrap(err, "tx.NewSelect") + } + total, err := tx.NewSelect(). + Model(&seasons). + Count(ctx) + if err != nil && err != sql.ErrNoRows { + return nil, errors.Wrap(err, "tx.NewSelect") + } + sl := &SeasonList{ + Seasons: seasons, + Total: total, + PageOpts: *pageOpts, + } + return sl, nil +} diff --git a/internal/db/user.go b/internal/db/user.go index 40c5ca8..8546c1e 100644 --- a/internal/db/user.go +++ b/internal/db/user.go @@ -34,7 +34,7 @@ func (user *User) ChangeUsername(ctx context.Context, tx bun.Tx, newUsername str Where("id = ?", user.ID). Exec(ctx) if err != nil { - return errors.Wrap(err, "tx.Update") + return errors.Wrap(err, "tx.NewUpdate") } user.Username = newUsername return nil @@ -55,7 +55,7 @@ func CreateUser(ctx context.Context, tx bun.Tx, username string, discorduser *di Model(user). Exec(ctx) if err != nil { - return nil, errors.Wrap(err, "tx.Insert") + return nil, errors.Wrap(err, "tx.NewInsert") } return user, nil @@ -75,7 +75,7 @@ func GetUserByID(ctx context.Context, tx bun.Tx, id int) (*User, error) { if err.Error() == "sql: no rows in result set" { return nil, nil } - return nil, errors.Wrap(err, "tx.Select") + return nil, errors.Wrap(err, "tx.NewSelect") } return user, nil } @@ -111,7 +111,7 @@ func GetUserByDiscordID(ctx context.Context, tx bun.Tx, discordID string) (*User if err.Error() == "sql: no rows in result set" { return nil, nil } - return nil, errors.Wrap(err, "tx.Select") + return nil, errors.Wrap(err, "tx.NewSelect") } return user, nil } @@ -124,7 +124,7 @@ func IsUsernameUnique(ctx context.Context, tx bun.Tx, username string) (bool, er Where("username = ?", username). Count(ctx) if err != nil { - return false, errors.Wrap(err, "tx.Count") + return false, errors.Wrap(err, "tx.NewSelect") } return count == 0, nil } diff --git a/internal/handlers/seasons.go b/internal/handlers/seasons.go new file mode 100644 index 0000000..930db6d --- /dev/null +++ b/internal/handlers/seasons.go @@ -0,0 +1,128 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + "strconv" + "time" + + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/db" + "git.haelnorr.com/h/oslstats/internal/view/page" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +func SeasonsPage( + s *hws.Server, + conn *bun.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "conn.BeginTx")) + return + } + defer tx.Rollback() + var pageNum, perPage int + var order bun.Order + var orderBy string + if pageStr := r.URL.Query().Get("page"); pageStr != "" { + pageNum, err = strconv.Atoi(pageStr) + if err != nil { + throwBadRequest(s, w, r, "Invalid page number", err) + return + } + } + if perPageStr := r.URL.Query().Get("per_page"); perPageStr != "" { + perPage, err = strconv.Atoi(perPageStr) + if err != nil { + throwBadRequest(s, w, r, "Invalid per_page number", err) + return + } + } + order = bun.Order(r.URL.Query().Get("order")) + orderBy = r.URL.Query().Get("order_by") + pageOpts := &db.PageOpts{ + Page: pageNum, + PerPage: perPage, + Order: order, + OrderBy: orderBy, + } + seasons, err := db.ListSeasons(ctx, tx, pageOpts) + if err != nil { + throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "db.ListSeasons")) + return + } + tx.Commit() + page.SeasonsPage(seasons).Render(r.Context(), w) + }) +} + +func SeasonsList( + s *hws.Server, + conn *bun.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + // Parse form values + if err := r.ParseForm(); err != nil { + throwBadRequest(s, w, r, "Invalid form data", err) + return + } + + // Extract pagination/sort params from form + var pageNum, perPage int + var order bun.Order + var orderBy string + var err error + + if pageStr := r.FormValue("page"); pageStr != "" { + pageNum, err = strconv.Atoi(pageStr) + if err != nil { + throwBadRequest(s, w, r, "Invalid page number", err) + return + } + } + if perPageStr := r.FormValue("per_page"); perPageStr != "" { + perPage, err = strconv.Atoi(perPageStr) + if err != nil { + throwBadRequest(s, w, r, "Invalid per_page number", err) + return + } + } + order = bun.Order(r.FormValue("order")) + orderBy = r.FormValue("order_by") + + pageOpts := &db.PageOpts{ + Page: pageNum, + PerPage: perPage, + Order: order, + OrderBy: orderBy, + } + fmt.Println(pageOpts) + + // Database query + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "conn.BeginTx")) + return + } + defer tx.Rollback() + + seasons, err := db.ListSeasons(ctx, tx, pageOpts) + if err != nil { + throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "db.ListSeasons")) + return + } + tx.Commit() + + // Return only the list component (hx-push-url handles URL update client-side) + page.SeasonsList(seasons).Render(r.Context(), w) + }) +} diff --git a/internal/view/component/nav/navbar.templ b/internal/view/component/nav/navbar.templ index e9471a0..09fb94d 100644 --- a/internal/view/component/nav/navbar.templ +++ b/internal/view/component/nav/navbar.templ @@ -7,7 +7,12 @@ type NavItem struct { // Return the list of navbar links func getNavItems() []NavItem { - return []NavItem{} + return []NavItem{ + { + name: "Seasons", + href: "/seasons", + }, + } } // Returns the navbar template fragment diff --git a/internal/view/component/pagination/pagination.templ b/internal/view/component/pagination/pagination.templ new file mode 100644 index 0000000..bdacab8 --- /dev/null +++ b/internal/view/component/pagination/pagination.templ @@ -0,0 +1,101 @@ +package pagination + +import "git.haelnorr.com/h/oslstats/internal/db" +import "fmt" + +templ Pagination(opts db.PageOpts, total int) { +
+ + + +
+
+ if total > 0 { + Showing { fmt.Sprintf("%d", opts.StartItem()) } - { fmt.Sprintf("%d", opts.EndItem(total)) } of { fmt.Sprintf("%d", total) } results + } else { + No results + } +
+
+ + +
+
+ + if total > 0 && opts.TotalPages(total) > 1 { +
+ + + + + + for _, pageNum := range opts.GetPageRange(total, 7) { + + } + + + + +
+ } +
+} diff --git a/internal/view/component/sort/dropdown.templ b/internal/view/component/sort/dropdown.templ new file mode 100644 index 0000000..2e0ede8 --- /dev/null +++ b/internal/view/component/sort/dropdown.templ @@ -0,0 +1,26 @@ +package sort + +import "git.haelnorr.com/h/oslstats/internal/db" +import "strings" + +templ Dropdown(pageopts db.PageOpts, orderopts []db.OrderOpts) { +
+ + + + +
+} diff --git a/internal/view/page/index.templ b/internal/view/page/index.templ index c18d5ba..8882c04 100644 --- a/internal/view/page/index.templ +++ b/internal/view/page/index.templ @@ -5,7 +5,7 @@ import "git.haelnorr.com/h/oslstats/internal/view/layout" // Page content for the index page templ Index() { @layout.Global("OSL Stats") { -
+
OSL Stats
Placeholder text
diff --git a/internal/view/page/seasons_list.templ b/internal/view/page/seasons_list.templ new file mode 100644 index 0000000..b12601a --- /dev/null +++ b/internal/view/page/seasons_list.templ @@ -0,0 +1,85 @@ +package page + +import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/view/layout" +import "git.haelnorr.com/h/oslstats/internal/view/component/pagination" +import "git.haelnorr.com/h/oslstats/internal/view/component/sort" +import "fmt" +import "github.com/uptrace/bun" + +templ SeasonsPage(seasons *db.SeasonList) { + @layout.Global("Seasons") { +
+ @SeasonsList(seasons) +
+ } +} + +templ SeasonsList(seasons *db.SeasonList) { + {{ + sortOpts := []db.OrderOpts{ + { + Order: bun.OrderAsc, + OrderBy: "name", + Label: "Name (A-Z)", + }, + { + Order: bun.OrderDesc, + OrderBy: "name", + Label: "Name (Z-A)", + }, + { + Order: bun.OrderAsc, + OrderBy: "short_name", + Label: "Short Name (A-Z)", + }, + { + Order: bun.OrderDesc, + OrderBy: "short_name", + Label: "Short Name (Z-A)", + }, + } + }} +
+
+ +
+

Seasons

+ @sort.Dropdown(seasons.PageOpts, sortOpts) +
+ + if len(seasons.Seasons) == 0 { +
+

No seasons found

+
+ } else { + +
+ for _, season := range seasons.Seasons { + +

{ season.Name }

+
+ } +
+ + @pagination.Pagination(seasons.PageOpts, seasons.Total) + } +
+ +
+} diff --git a/pkg/embedfs/files/css/output.css b/pkg/embedfs/files/css/output.css index efa074f..8ad1afd 100644 --- a/pkg/embedfs/files/css/output.css +++ b/pkg/embedfs/files/css/output.css @@ -10,6 +10,7 @@ monospace; --spacing: 0.25rem; --breakpoint-xl: 80rem; + --breakpoint-2xl: 96rem; --container-sm: 24rem; --container-md: 28rem; --container-2xl: 42rem; @@ -303,9 +304,15 @@ .mt-24 { margin-top: calc(var(--spacing) * 24); } + .mt-25 { + margin-top: calc(var(--spacing) * 25); + } .mb-2 { margin-bottom: calc(var(--spacing) * 2); } + .mb-6 { + margin-bottom: calc(var(--spacing) * 6); + } .mb-8 { margin-bottom: calc(var(--spacing) * 8); } @@ -336,9 +343,6 @@ .inline-flex { display: inline-flex; } - .table { - display: table; - } .size-5 { width: calc(var(--spacing) * 5); height: calc(var(--spacing) * 5); @@ -394,6 +398,9 @@ .max-w-md { max-width: var(--container-md); } + .max-w-screen-2xl { + max-width: var(--breakpoint-2xl); + } .max-w-screen-xl { max-width: var(--breakpoint-xl); } @@ -412,12 +419,18 @@ .transform { transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); } + .cursor-not-allowed { + cursor: not-allowed; + } .cursor-pointer { cursor: pointer; } .resize { resize: both; } + .grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } .flex-col { flex-direction: column; } @@ -729,6 +742,9 @@ .text-yellow { color: var(--yellow); } + .opacity-50 { + opacity: 50%; + } .shadow-lg { --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); @@ -746,6 +762,11 @@ transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); } + .transition-colors { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } .outline-none { --tw-outline-style: none; outline-style: none; @@ -761,6 +782,13 @@ } } } + .hover\:border-blue { + &:hover { + @media (hover: hover) { + border-color: var(--blue); + } + } + } .hover\:bg-crust { &:hover { @media (hover: hover) { @@ -808,6 +836,13 @@ } } } + .hover\:bg-surface0 { + &:hover { + @media (hover: hover) { + background-color: var(--surface0); + } + } + } .hover\:bg-surface2 { &:hover { @media (hover: hover) { @@ -931,6 +966,21 @@ display: none; } } + .sm\:inline { + @media (width >= 40rem) { + display: inline; + } + } + .sm\:flex-row { + @media (width >= 40rem) { + flex-direction: row; + } + } + .sm\:items-center { + @media (width >= 40rem) { + align-items: center; + } + } .sm\:justify-between { @media (width >= 40rem) { justify-content: space-between; @@ -957,6 +1007,11 @@ line-height: var(--tw-leading, var(--text-4xl--line-height)); } } + .md\:grid-cols-2 { + @media (width >= 48rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } .md\:gap-8 { @media (width >= 48rem) { gap: calc(var(--spacing) * 8); @@ -992,6 +1047,11 @@ display: inline; } } + .lg\:grid-cols-3 { + @media (width >= 64rem) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } .lg\:items-end { @media (width >= 64rem) { align-items: flex-end; diff --git a/pkg/embedfs/files/js/pagination.js b/pkg/embedfs/files/js/pagination.js new file mode 100644 index 0000000..10c5173 --- /dev/null +++ b/pkg/embedfs/files/js/pagination.js @@ -0,0 +1,45 @@ +function paginateData( + formID, + rootPath, + initPage, + initPerPage, + initOrder, + initOrderBy, +) { + return { + page: initPage, + perPage: initPerPage, + order: initOrder || "ASC", + orderBy: initOrderBy || "name", + + goToPage(n) { + this.page = n; + this.submit(); + }, + + handleSortChange(value) { + const [field, direction] = value.split("|"); + this.orderBy = field; + this.order = direction; + this.page = 1; // Reset to first page when sorting + this.submit(); + }, + + setPerPage(n) { + this.perPage = n; + this.page = 1; // Reset to first page when changing per page + this.submit(); + }, + + submit() { + var url = `${rootPath}?page=${this.page}&per_page=${this.perPage}&order=${this.order}&order_by=${this.orderBy}`; + htmx.find("#pagination-page").value = this.page; + htmx.find("#pagination-per-page").value = this.perPage; + htmx.find("#sort-order").value = this.order; + htmx.find("#sort-order-by").value = this.orderBy; + htmx.find(`#${formID}`).setAttribute("hx-post", url); + htmx.process(`#${formID}`); + htmx.trigger(`#${formID}`, "submit"); + }, + }; +}