scaffolding for new seasons

This commit is contained in:
2026-02-02 19:12:14 +11:00
parent 9366481552
commit 9c70458b76
21 changed files with 520 additions and 41 deletions

2
internal/db/doc.go Normal file
View File

@@ -0,0 +1,2 @@
// Package db is an internal package for all the database models and related methods
package db

View File

@@ -3,6 +3,8 @@ package db
import (
"context"
"database/sql"
"strings"
"time"
"github.com/pkg/errors"
"github.com/uptrace/bun"
@@ -11,9 +13,13 @@ import (
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"`
ID int `bun:"id,pk,autoincrement"`
Name string `bun:"name,unique"`
ShortName string `bun:"short_name,unique"`
StartDate time.Time `bun:"start_date,notnull"`
EndDate bun.NullTime `bun:"end_date"`
FinalsStartDate bun.NullTime `bun:"finals_start_date"`
FinalsEndDate bun.NullTime `bun:"finals_end_date"`
}
type SeasonList struct {
@@ -22,7 +28,7 @@ type SeasonList struct {
PageOpts PageOpts
}
func NewSeason(ctx context.Context, tx bun.Tx, name, shortname string) (*Season, error) {
func NewSeason(ctx context.Context, tx bun.Tx, name, shortname string, start time.Time) (*Season, error) {
if name == "" {
return nil, errors.New("name cannot be empty")
}
@@ -31,7 +37,8 @@ func NewSeason(ctx context.Context, tx bun.Tx, name, shortname string) (*Season,
}
season := &Season{
Name: name,
ShortName: shortname,
ShortName: strings.ToUpper(shortname),
StartDate: start.Truncate(time.Hour * 24),
}
_, err := tx.NewInsert().
Model(season).
@@ -81,3 +88,38 @@ func ListSeasons(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*SeasonLis
}
return sl, nil
}
func GetSeason(ctx context.Context, tx bun.Tx, shortname string) (*Season, error) {
var season *Season
err := tx.NewSelect().
Model(season).
Where("short_name = ?", strings.ToUpper(shortname)).
Limit(1).
Scan(ctx)
if err != nil && err != sql.ErrNoRows {
return nil, errors.Wrap(err, "tx.NewSelect")
}
return season, nil
}
func IsSeasonNameUnique(ctx context.Context, tx bun.Tx, name string) (bool, error) {
count, err := tx.NewSelect().
Model((*Season)(nil)).
Where("name = ?", name).
Count(ctx)
if err != nil {
return false, errors.Wrap(err, "tx.NewSelect")
}
return count == 0, nil
}
func IsSeasonShortNameUnique(ctx context.Context, tx bun.Tx, shortname string) (bool, error) {
count, err := tx.NewSelect().
Model((*Season)(nil)).
Where("short_name = ?", shortname).
Count(ctx)
if err != nil {
return false, errors.Wrap(err, "tx.NewSelect")
}
return count == 0, nil
}

View File

@@ -26,20 +26,6 @@ func (user *User) GetID() int {
return user.ID
}
// Change the user's username
func (user *User) ChangeUsername(ctx context.Context, tx bun.Tx, newUsername string) error {
_, err := tx.NewUpdate().
Model(user).
Set("username = ?", newUsername).
Where("id = ?", user.ID).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "tx.NewUpdate")
}
user.Username = newUsername
return nil
}
// CreateUser creates a new user with the given username and password
func CreateUser(ctx context.Context, tx bun.Tx, username string, discorduser *discordgo.User) (*User, error) {
if discorduser == nil {

2
internal/handlers/doc.go Normal file
View File

@@ -0,0 +1,2 @@
// Package handlers contains all the functions for handling http requests and serving content
package handlers

View File

@@ -6,6 +6,7 @@ import (
"net/http"
"git.haelnorr.com/h/golib/hws"
"github.com/a-h/templ"
"github.com/pkg/errors"
)
@@ -166,3 +167,10 @@ func parseErrorDetails(details string) (int, string) {
return errDetails.Code, errDetails.Stacktrace
}
func renderSafely(page templ.Component, s *hws.Server, r *http.Request, w http.ResponseWriter) {
err := page.Render(r.Context(), w)
if err != nil {
throwInternalServiceError(s, w, r, "Failed to render page", errors.Wrap(err, "page."))
}
}

View File

@@ -8,15 +8,15 @@ import (
"git.haelnorr.com/h/golib/hws"
)
// Handles responses to the / path. Also serves a 404 Page for paths that
// Index handles responses to the / path. Also serves a 404 Page for paths that
// don't have explicit handlers
func Index(server *hws.Server) http.Handler {
func Index(s *hws.Server) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
throwNotFound(server, w, r, r.URL.Path)
throwNotFound(s, w, r, r.URL.Path)
}
page.Index().Render(r.Context(), w)
renderSafely(page.Index(), s, r, w)
},
)
}

View File

@@ -0,0 +1,105 @@
package handlers
import (
"context"
"net/http"
"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 NewSeason(
s *hws.Server,
conn *bun.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
renderSafely(page.NewSeason(), s, r, w)
return
}
})
}
func NewSeasonSubmit(
s *hws.Server,
conn *bun.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
})
}
func IsSeasonNameUnique(
s *hws.Server,
conn *bun.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "conn.BeginTx"))
if err != nil {
throwInternalServiceError(s, w, r, "Error notifying client", err)
}
return
}
name := r.FormValue("name")
unique, err := db.IsSeasonNameUnique(ctx, tx, name)
if err != nil {
err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "db.IsSeasonNameUnique"))
if err != nil {
throwInternalServiceError(s, w, r, "Error notifying client", err)
}
return
}
if !unique {
w.WriteHeader(http.StatusConflict)
return
}
})
}
func IsSeasonShortNameUnique(
s *hws.Server,
conn *bun.DB,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "conn.BeginTx"))
if err != nil {
throwInternalServiceError(s, w, r, "Error notifying client", err)
}
return
}
shortname := r.FormValue("short_name")
unique, err := db.IsSeasonShortNameUnique(ctx, tx, shortname)
if err != nil {
err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "db.IsSeasonShortNameUnique"))
if err != nil {
throwInternalServiceError(s, w, r, "Error notifying client", err)
}
return
}
if !unique {
w.WriteHeader(http.StatusConflict)
return
}
})
}

View File

@@ -76,7 +76,7 @@ func Register(
method := r.Method
if method == "GET" {
tx.Commit()
page.Register(details.DiscordUser.Username).Render(r.Context(), w)
renderSafely(page.Register(details.DiscordUser.Username), s, r, w)
return
}
if method == "POST" {

View File

@@ -0,0 +1,41 @@
package handlers
import (
"context"
"net/http"
"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 SeasonPage(
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()
seasonStr := r.PathValue("season_short_name")
season, err := db.GetSeason(ctx, tx, seasonStr)
if err != nil {
throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "db.GetSeason"))
return
}
tx.Commit()
if season == nil {
throwNotFound(s, w, r, r.URL.Path)
return
}
renderSafely(page.SeasonPage(season), s, r, w)
})
}

View File

@@ -58,7 +58,7 @@ func SeasonsPage(
return
}
tx.Commit()
page.SeasonsPage(seasons).Render(r.Context(), w)
renderSafely(page.SeasonsList(seasons), s, r, w)
})
}
@@ -123,6 +123,6 @@ func SeasonsList(
tx.Commit()
// Return only the list component (hx-push-url handles URL update client-side)
page.SeasonsList(seasons).Render(r.Context(), w)
renderSafely(page.SeasonsList(seasons), s, r, w)
})
}

View File

@@ -1,13 +1,14 @@
package handlers
import (
"git.haelnorr.com/h/golib/hws"
"net/http"
"path/filepath"
"strings"
"git.haelnorr.com/h/golib/hws"
)
// Handles requests for static files, without allowing access to the
// StaticFS handles requests for static files, without allowing access to the
// directory viewer and returning 404 if an exact file is not found
func StaticFS(staticFS *http.FileSystem, server *hws.Server) http.Handler {
// Create the file server once, not on every request

View File

@@ -9,7 +9,7 @@ import (
"git.haelnorr.com/h/oslstats/internal/view/page"
)
// Handles responses to the / path. Also serves a 404 Page for paths that
// NotifyTester handles responses to the / path. Also serves a 404 Page for paths that
// don't have explicit handlers
func NotifyTester(s *hws.Server) http.Handler {
return http.HandlerFunc(
@@ -22,9 +22,9 @@ func NotifyTester(s *hws.Server) http.Handler {
// Error: testErr,
// })
// page.Render(r.Context(), w)
page.Test().Render(r.Context(), w)
renderSafely(page.Test(), s, r, w)
} else {
r.ParseForm()
_ = r.ParseForm()
// target := r.Form.Get("target")
title := r.Form.Get("title")
level := r.Form.Get("type")

View File

@@ -0,0 +1,4 @@
package form
templ NewSeason() {
}

View File

@@ -0,0 +1,9 @@
package page
import "git.haelnorr.com/h/oslstats/internal/view/component/form"
import "git.haelnorr.com/h/oslstats/internal/view/layout"
templ NewSeason() {
@layout.Global("New Season")
@form.NewSeason()
}

View File

@@ -0,0 +1,202 @@
package page
import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/view/layout"
import "time"
import "strconv"
templ SeasonPage(season *db.Season) {
@layout.Global(season.Name) {
<div class="max-w-screen-2xl mx-auto px-4 py-8">
@SeasonDetails(season)
</div>
}
}
templ SeasonDetails(season *db.Season) {
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
<!-- Header Section -->
<div class="bg-surface0 border-b border-surface1 px-6 py-8">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 class="text-4xl font-bold text-text mb-2">{ season.Name }</h1>
<span class="inline-block bg-blue px-3 py-1 rounded-full text-sm font-semibold text-mantle">
{ season.ShortName }
</span>
</div>
<div class="flex gap-2">
<a
href={ templ.SafeURL("/seasons/" + season.ShortName + "/edit") }
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
bg-blue hover:bg-blue/75 text-mantle transition"
>
Edit
</a>
<a
href="/seasons"
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
bg-surface1 hover:bg-surface2 text-text transition"
>
Back to Seasons
</a>
</div>
</div>
</div>
<!-- Information Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 p-6">
<!-- Regular Season Section -->
<div class="bg-surface0 border border-surface1 rounded-lg p-6">
<h2 class="text-2xl font-bold text-text mb-4 flex items-center gap-2">
<span class="text-blue">●</span>
Regular Season
</h2>
<div class="space-y-3">
<div class="flex justify-between items-center">
<span class="text-subtext0">Start Date:</span>
<span class="text-text font-semibold">
{ formatDate(season.StartDate) }
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-subtext0">End Date:</span>
<span class="text-text font-semibold">
if !season.EndDate.IsZero() {
{ formatDate(season.EndDate.Time) }
} else {
<span class="text-subtext1 italic">Not set</span>
}
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-subtext0">Duration:</span>
<span class="text-text font-semibold">
if !season.EndDate.IsZero() {
{ formatDuration(season.StartDate, season.EndDate.Time) }
} else {
<span class="text-subtext1 italic">Ongoing</span>
}
</span>
</div>
</div>
</div>
<!-- Finals Section -->
<div class="bg-surface0 border border-surface1 rounded-lg p-6">
<h2 class="text-2xl font-bold text-text mb-4 flex items-center gap-2">
<span class="text-yellow">★</span>
Finals
</h2>
<div class="space-y-3">
<div class="flex justify-between items-center">
<span class="text-subtext0">Start Date:</span>
<span class="text-text font-semibold">
if !season.FinalsStartDate.IsZero() {
{ formatDate(season.FinalsStartDate.Time) }
} else {
<span class="text-subtext1 italic">Not set</span>
}
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-subtext0">End Date:</span>
<span class="text-text font-semibold">
if !season.FinalsEndDate.IsZero() {
{ formatDate(season.FinalsEndDate.Time) }
} else {
<span class="text-subtext1 italic">Not set</span>
}
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-subtext0">Duration:</span>
<span class="text-text font-semibold">
if !season.FinalsStartDate.IsZero() && !season.FinalsEndDate.IsZero() {
{ formatDuration(season.FinalsStartDate.Time, season.FinalsEndDate.Time) }
} else {
<span class="text-subtext1 italic">Not scheduled</span>
}
</span>
</div>
</div>
</div>
</div>
<!-- Status Section -->
<div class="px-6 pb-6">
<div class="bg-surface0 border border-surface1 rounded-lg p-6">
<h2 class="text-2xl font-bold text-text mb-4">Status</h2>
<div class="flex items-center gap-3">
@SeasonStatus(season)
</div>
</div>
</div>
</div>
}
templ SeasonStatus(season *db.Season) {
{{
now := time.Now()
status := ""
statusColor := ""
statusBg := ""
if now.Before(season.StartDate) {
status = "Upcoming"
statusColor = "text-blue"
statusBg = "bg-blue/10 border-blue"
} else if !season.EndDate.IsZero() && now.After(season.EndDate.Time) {
status = "Completed"
statusColor = "text-green"
statusBg = "bg-green/10 border-green"
} else if !season.FinalsStartDate.IsZero() && now.After(season.FinalsStartDate.Time) {
if !season.FinalsEndDate.IsZero() && now.After(season.FinalsEndDate.Time) {
status = "Completed"
statusColor = "text-green"
statusBg = "bg-green/10 border-green"
} else {
status = "Finals in Progress"
statusColor = "text-yellow"
statusBg = "bg-yellow/10 border-yellow"
}
} else {
status = "In Progress"
statusColor = "text-green"
statusBg = "bg-green/10 border-green"
}
}}
<div class={ "px-4 py-2 rounded-lg border-2 inline-flex items-center gap-2 " + statusBg }>
<span class={ "text-2xl " + statusColor }>●</span>
<span class={ "text-lg font-semibold " + statusColor }>{ status }</span>
</div>
}
func formatDate(t time.Time) string {
return t.Format("January 2, 2006")
}
func formatDuration(start, end time.Time) string {
days := int(end.Sub(start).Hours() / 24)
if days == 0 {
return "Same day"
} else if days == 1 {
return "1 day"
} else if days < 7 {
return strconv.Itoa(days) + " days"
} else if days < 30 {
weeks := days / 7
if weeks == 1 {
return "1 week"
}
return strconv.Itoa(weeks) + " weeks"
} else if days < 365 {
months := days / 30
if months == 1 {
return "1 month"
}
return strconv.Itoa(months) + " months"
} else {
years := days / 365
if years == 1 {
return "1 year"
}
return strconv.Itoa(years) + " years"
}
}

View File

@@ -55,8 +55,15 @@ templ SeasonsList(seasons *db.SeasonList) {
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>
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6 px-4">
<div class="flex gap-4 items-center">
<span class="text-3xl font-bold">Seasons</span>
<a
href="/seasons/new"
class="rounded-lg px-2 py-1 hover:cursor-pointer text-center text-sm
bg-green hover:bg-green/75 text-mantle transition"
>Add season</a>
</div>
@sort.Dropdown(seasons.PageOpts, sortOpts)
</div>
<!-- Results section -->