added seasons list

This commit is contained in:
2026-02-01 13:25:11 +11:00
parent 96d534f045
commit 81d4ceb354
17 changed files with 660 additions and 679 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -38,6 +38,7 @@ func setupHttpServer(
"/static/js/toasts.js", "/static/js/toasts.js",
"/static/js/copytoclipboard.js", "/static/js/copytoclipboard.js",
"/static/js/theme.js", "/static/js/theme.js",
"/static/js/pagination.js",
"/static/vendored/htmx@2.0.8.min.js", "/static/vendored/htmx@2.0.8.min.js",
"/static/vendored/htmx-ext-ws.min.js", "/static/vendored/htmx-ext-ws.min.js",
"/static/vendored/alpinejs@3.15.4.min.js", "/static/vendored/alpinejs@3.15.4.min.js",

View File

@@ -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] Migration validation passed ✓")
fmt.Println("[INFO] Step 2/5: Checking for pending migrations...") fmt.Println("[INFO] Step 2/5: Checking for pending migrations...")
// Check for pending migrations // Check for pending migrations using MigrationsWithStatus (read-only)
group, err := migrator.Migrate(ctx, migrate.WithNopMigration()) ms, err := migrator.MigrationsWithStatus(ctx)
if err != nil { 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") fmt.Println("[INFO] No pending migrations")
return nil return nil
} }
@@ -88,7 +89,7 @@ func migrateUp(ctx context.Context, migrator *migrate.Migrator, conn *bun.DB, cf
// Run migrations // Run migrations
fmt.Println("[INFO] Step 5/5: Applying migrations...") fmt.Println("[INFO] Step 5/5: Applying migrations...")
group, err = migrator.Migrate(ctx) group, err := migrator.Migrate(ctx)
if err != nil { if err != nil {
return errors.Wrap(err, "migrate") return errors.Wrap(err, "migrate")
} }
@@ -290,13 +291,13 @@ import (
func init() { func init() {
Migrations.MustRegister( Migrations.MustRegister(
// UP migration - TODO: Implement // UP migration
func(ctx context.Context, db *bun.DB) error { func(ctx context.Context, dbConn *bun.DB) error {
// TODO: Add your migration code here // TODO: Add your migration code here
return nil return nil
}, },
// DOWN migration - TODO: Implement // DOWN migration
func(ctx context.Context, db *bun.DB) error { func(ctx context.Context, dbConn *bun.DB) error {
// TODO: Add your rollback code here // TODO: Add your rollback code here
return nil return nil
}, },

View File

@@ -62,6 +62,16 @@ func addRoutes(
Handler: handlers.NotifyTester(s), Handler: handlers.NotifyTester(s),
// TODO: add login protection // 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{ htmxRoutes := []hws.Route{

2
go.sum
View File

@@ -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/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 h1:rpqe0Dcbm3b5XZ/Bfy0LUhph6RR7+bmANrSA/W81l0A=
git.haelnorr.com/h/golib/hws v0.4.3/go.mod h1:UqB83p9lGjidDkk0pWRqxxOFrCkg8t+9J6uGtBOjNLo= 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 h1:Vgw8khDQZJRCc3m7z9QlbL9CYPyFB9JXUC3+omKRZPc=
git.haelnorr.com/h/golib/hwsauth v0.5.3/go.mod h1:NOonrVU/lX8lzuV77eDEiTwBjn7RrzYVcSdXUJWeHmQ= 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= git.haelnorr.com/h/golib/jwt v0.10.1 h1:1Adxt9H3Y4fWFvFjWpvg/vSFhbgCMDMxgiE3m7KvDMI=

96
internal/db/paginate.go Normal file
View File

@@ -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
}

83
internal/db/season.go Normal file
View File

@@ -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
}

View File

@@ -34,7 +34,7 @@ func (user *User) ChangeUsername(ctx context.Context, tx bun.Tx, newUsername str
Where("id = ?", user.ID). Where("id = ?", user.ID).
Exec(ctx) Exec(ctx)
if err != nil { if err != nil {
return errors.Wrap(err, "tx.Update") return errors.Wrap(err, "tx.NewUpdate")
} }
user.Username = newUsername user.Username = newUsername
return nil return nil
@@ -55,7 +55,7 @@ func CreateUser(ctx context.Context, tx bun.Tx, username string, discorduser *di
Model(user). Model(user).
Exec(ctx) Exec(ctx)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "tx.Insert") return nil, errors.Wrap(err, "tx.NewInsert")
} }
return user, nil 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" { if err.Error() == "sql: no rows in result set" {
return nil, nil return nil, nil
} }
return nil, errors.Wrap(err, "tx.Select") return nil, errors.Wrap(err, "tx.NewSelect")
} }
return user, nil 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" { if err.Error() == "sql: no rows in result set" {
return nil, nil return nil, nil
} }
return nil, errors.Wrap(err, "tx.Select") return nil, errors.Wrap(err, "tx.NewSelect")
} }
return user, nil return user, nil
} }
@@ -124,7 +124,7 @@ func IsUsernameUnique(ctx context.Context, tx bun.Tx, username string) (bool, er
Where("username = ?", username). Where("username = ?", username).
Count(ctx) Count(ctx)
if err != nil { if err != nil {
return false, errors.Wrap(err, "tx.Count") return false, errors.Wrap(err, "tx.NewSelect")
} }
return count == 0, nil return count == 0, nil
} }

View File

@@ -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)
})
}

View File

@@ -7,7 +7,12 @@ type NavItem struct {
// Return the list of navbar links // Return the list of navbar links
func getNavItems() []NavItem { func getNavItems() []NavItem {
return []NavItem{} return []NavItem{
{
name: "Seasons",
href: "/seasons",
},
}
} }
// Returns the navbar template fragment // Returns the navbar template fragment

View File

@@ -0,0 +1,101 @@
package pagination
import "git.haelnorr.com/h/oslstats/internal/db"
import "fmt"
templ Pagination(opts db.PageOpts, total int) {
<div class="mt-6 flex flex-col gap-4">
<input type="hidden" name="page" id="pagination-page"/>
<input type="hidden" name="per_page" id="pagination-per-page"/>
<!-- Page info and per-page selector -->
<div class="flex flex-col sm:flex-row justify-between items-center gap-4 text-sm text-subtext0">
<div>
if total > 0 {
Showing { fmt.Sprintf("%d", opts.StartItem()) } - { fmt.Sprintf("%d", opts.EndItem(total)) } of { fmt.Sprintf("%d", total) } results
} else {
No results
}
</div>
<div class="flex items-center gap-2">
<label for="per-page-select">Per page:</label>
<select
id="per-page-select"
class="py-1 px-2 rounded-lg bg-surface0 border border-surface1 text-text focus:border-blue outline-none"
x-model.number="perPage"
@change="setPerPage(perPage)"
>
<option value="1">1</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</div>
<!-- Pagination buttons -->
if total > 0 && opts.TotalPages(total) > 1 {
<div class="flex flex-wrap justify-center items-center gap-2">
<!-- First button -->
<button
type="button"
@click="goToPage(1)"
class="px-3 py-2 rounded-lg border transition border-surface1 text-text bg-mantle"
x-bind:disabled={ fmt.Sprintf("%t", !opts.HasPrevPage()) }
x-bind:class={ fmt.Sprintf("%t", !opts.HasPrevPage()) +
" ? 'text-subtext0 cursor-not-allowed opacity-50' : 'hover:bg-surface0 hover:border-blue cursor-pointer'" }
>
<span class="hidden sm:inline">First</span>
<span class="sm:hidden">&lt;&lt;</span>
</button>
<!-- Previous button -->
<button
type="button"
@click={ fmt.Sprintf("goToPage(%d)", opts.Page-1) }
class="px-3 py-2 rounded-lg border transition border-surface1 text-text bg-mantle"
x-bind:disabled={ fmt.Sprintf("%t", !opts.HasPrevPage()) }
x-bind:class={ fmt.Sprintf("%t", !opts.HasPrevPage()) +
" ? 'text-subtext0 cursor-not-allowed opacity-50' : 'hover:bg-surface0 hover:border-blue cursor-pointer'" }
>
<span class="hidden sm:inline">Previous</span>
<span class="sm:hidden">&lt;</span>
</button>
<!-- Page numbers -->
for _, pageNum := range opts.GetPageRange(total, 7) {
<button
type="button"
@click={ fmt.Sprintf("goToPage(%d)", pageNum) }
class={ "px-3 py-2 rounded-lg border transition",
templ.KV("bg-blue border-blue text-mantle font-bold", pageNum == opts.Page),
templ.KV("bg-mantle border-surface1 text-text hover:bg-surface0 hover:border-blue cursor-pointer", pageNum != opts.Page) }
>
{ fmt.Sprintf("%d", pageNum) }
</button>
}
<!-- Next button -->
<button
type="button"
@click={ fmt.Sprintf("goToPage(%d)", opts.Page+1) }
class="px-3 py-2 rounded-lg border transition border-surface1 text-text bg-mantle"
x-bind:disabled={ fmt.Sprintf("%t", !opts.HasNextPage(total)) }
x-bind:class={ fmt.Sprintf("%t", !opts.HasNextPage(total)) +
" ? 'text-subtext0 cursor-not-allowed opacity-50' : 'hover:bg-surface0 hover:border-blue cursor-pointer'" }
>
<span class="hidden sm:inline">Next</span>
<span class="sm:hidden">&gt;</span>
</button>
<!-- Last button -->
<button
type="button"
@click={ fmt.Sprintf("goToPage(%d)", opts.TotalPages(total)) }
class="px-3 py-2 rounded-lg border transition border-surface1 text-text bg-mantle"
x-bind:disabled={ fmt.Sprintf("%t", !opts.HasNextPage(total)) }
x-bind:class={ fmt.Sprintf("%t", !opts.HasNextPage(total)) +
" ? 'text-subtext0 cursor-not-allowed opacity-50' : 'hover:bg-surface0 hover:border-blue cursor-pointer'" }
>
<span class="hidden sm:inline">Last</span>
<span class="sm:hidden">&gt;&gt;</span>
</button>
</div>
}
</div>
}

View File

@@ -0,0 +1,26 @@
package sort
import "git.haelnorr.com/h/oslstats/internal/db"
import "strings"
templ Dropdown(pageopts db.PageOpts, orderopts []db.OrderOpts) {
<div class="flex items-center gap-2">
<input type="hidden" name="order" id="sort-order"/>
<input type="hidden" name="order_by" id="sort-order-by"/>
<label for="sort-select" class="text-sm text-subtext0">Sort by:</label>
<select
id="sort-select"
class="py-2 px-3 rounded-lg bg-surface0 border border-surface1 text-text focus:border-blue outline-none"
@change="handleSortChange($event.target.value)"
>
for _, opt := range orderopts {
<option
value={ strings.Join([]string{opt.OrderBy, string(opt.Order)}, "|") }
selected?={ pageopts.OrderBy == opt.OrderBy && pageopts.Order == opt.Order }
>
{ opt.Label }
</option>
}
</select>
</div>
}

View File

@@ -5,7 +5,7 @@ import "git.haelnorr.com/h/oslstats/internal/view/layout"
// Page content for the index page // Page content for the index page
templ Index() { templ Index() {
@layout.Global("OSL Stats") { @layout.Global("OSL Stats") {
<div class="text-center mt-24"> <div class="text-center mt-25">
<div class="text-4xl lg:text-6xl">OSL Stats</div> <div class="text-4xl lg:text-6xl">OSL Stats</div>
<div>Placeholder text</div> <div>Placeholder text</div>
</div> </div>

View File

@@ -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") {
<div class="max-w-screen-2xl mx-auto px-2">
@SeasonsList(seasons)
</div>
}
}
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)",
},
}
}}
<div id="seasons-list-container">
<form
id="seasons-form"
hx-target="#seasons-list-container"
hx-swap="outerHTML"
hx-push-url="true"
x-data={ templ.JSFuncCall("paginateData",
"seasons-form",
"/seasons",
seasons.PageOpts.Page,
seasons.PageOpts.PerPage,
seasons.PageOpts.Order,
seasons.PageOpts.OrderBy).CallInline }
>
<!-- Header with title and sort controls -->
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<h1 class="text-3xl font-bold">Seasons</h1>
@sort.Dropdown(seasons.PageOpts, sortOpts)
</div>
<!-- Results section -->
if len(seasons.Seasons) == 0 {
<div class="bg-mantle border border-surface1 rounded-lg p-8 text-center">
<p class="text-subtext0 text-lg">No seasons found</p>
</div>
} else {
<!-- Card grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
for _, season := range seasons.Seasons {
<a
class="bg-mantle border border-surface1 rounded-lg p-6 hover:bg-surface0 transition-colors"
href={ fmt.Sprintf("/seasons/%s", season.ShortName) }
>
<h3 class="text-xl font-bold text-text mb-2">{ season.Name }</h3>
</a>
}
</div>
<!-- Pagination controls -->
@pagination.Pagination(seasons.PageOpts, seasons.Total)
}
</form>
<script src="/static/js/pagination.js"></script>
</div>
}

View File

@@ -10,6 +10,7 @@
monospace; monospace;
--spacing: 0.25rem; --spacing: 0.25rem;
--breakpoint-xl: 80rem; --breakpoint-xl: 80rem;
--breakpoint-2xl: 96rem;
--container-sm: 24rem; --container-sm: 24rem;
--container-md: 28rem; --container-md: 28rem;
--container-2xl: 42rem; --container-2xl: 42rem;
@@ -303,9 +304,15 @@
.mt-24 { .mt-24 {
margin-top: calc(var(--spacing) * 24); margin-top: calc(var(--spacing) * 24);
} }
.mt-25 {
margin-top: calc(var(--spacing) * 25);
}
.mb-2 { .mb-2 {
margin-bottom: calc(var(--spacing) * 2); margin-bottom: calc(var(--spacing) * 2);
} }
.mb-6 {
margin-bottom: calc(var(--spacing) * 6);
}
.mb-8 { .mb-8 {
margin-bottom: calc(var(--spacing) * 8); margin-bottom: calc(var(--spacing) * 8);
} }
@@ -336,9 +343,6 @@
.inline-flex { .inline-flex {
display: inline-flex; display: inline-flex;
} }
.table {
display: table;
}
.size-5 { .size-5 {
width: calc(var(--spacing) * 5); width: calc(var(--spacing) * 5);
height: calc(var(--spacing) * 5); height: calc(var(--spacing) * 5);
@@ -394,6 +398,9 @@
.max-w-md { .max-w-md {
max-width: var(--container-md); max-width: var(--container-md);
} }
.max-w-screen-2xl {
max-width: var(--breakpoint-2xl);
}
.max-w-screen-xl { .max-w-screen-xl {
max-width: var(--breakpoint-xl); max-width: var(--breakpoint-xl);
} }
@@ -412,12 +419,18 @@
.transform { .transform {
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); 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 {
cursor: pointer; cursor: pointer;
} }
.resize { .resize {
resize: both; resize: both;
} }
.grid-cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.flex-col { .flex-col {
flex-direction: column; flex-direction: column;
} }
@@ -729,6 +742,9 @@
.text-yellow { .text-yellow {
color: var(--yellow); color: var(--yellow);
} }
.opacity-50 {
opacity: 50%;
}
.shadow-lg { .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)); --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); 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-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration)); 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 { .outline-none {
--tw-outline-style: none; --tw-outline-style: none;
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\:bg-crust {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -808,6 +836,13 @@
} }
} }
} }
.hover\:bg-surface0 {
&:hover {
@media (hover: hover) {
background-color: var(--surface0);
}
}
}
.hover\:bg-surface2 { .hover\:bg-surface2 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -931,6 +966,21 @@
display: none; 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 { .sm\:justify-between {
@media (width >= 40rem) { @media (width >= 40rem) {
justify-content: space-between; justify-content: space-between;
@@ -957,6 +1007,11 @@
line-height: var(--tw-leading, var(--text-4xl--line-height)); 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 { .md\:gap-8 {
@media (width >= 48rem) { @media (width >= 48rem) {
gap: calc(var(--spacing) * 8); gap: calc(var(--spacing) * 8);
@@ -992,6 +1047,11 @@
display: inline; display: inline;
} }
} }
.lg\:grid-cols-3 {
@media (width >= 64rem) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.lg\:items-end { .lg\:items-end {
@media (width >= 64rem) { @media (width >= 64rem) {
align-items: flex-end; align-items: flex-end;

View File

@@ -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");
},
};
}