diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index 59614a2..3184498 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -20,6 +20,7 @@ --container-3xl: 48rem; --container-4xl: 56rem; --container-5xl: 64rem; + --container-6xl: 72rem; --container-7xl: 80rem; --text-xs: 0.75rem; --text-xs--line-height: calc(1 / 0.75); @@ -266,6 +267,9 @@ .top-0 { top: calc(var(--spacing) * 0); } + .top-1 { + top: calc(var(--spacing) * 1); + } .top-1\/2 { top: calc(1 / 2 * 100%); } @@ -335,6 +339,9 @@ .-mt-3 { margin-top: calc(var(--spacing) * -3); } + .mt-0 { + margin-top: calc(var(--spacing) * 0); + } .mt-0\.5 { margin-top: calc(var(--spacing) * 0.5); } @@ -368,12 +375,12 @@ .mt-12 { margin-top: calc(var(--spacing) * 12); } + .mt-16 { + margin-top: calc(var(--spacing) * 16); + } .mt-24 { margin-top: calc(var(--spacing) * 24); } - .mt-25 { - margin-top: calc(var(--spacing) * 25); - } .mt-auto { margin-top: auto; } @@ -395,6 +402,9 @@ .mb-8 { margin-bottom: calc(var(--spacing) * 8); } + .mb-12 { + margin-bottom: calc(var(--spacing) * 12); + } .mb-auto { margin-bottom: auto; } @@ -582,6 +592,9 @@ .max-w-5xl { max-width: var(--container-5xl); } + .max-w-6xl { + max-width: var(--container-6xl); + } .max-w-7xl { max-width: var(--container-7xl); } @@ -621,12 +634,22 @@ .flex-1 { flex: 1; } + .flex-shrink { + flex-shrink: 1; + } .flex-shrink-0 { flex-shrink: 0; } .shrink-0 { flex-shrink: 0; } + .border-collapse { + border-collapse: collapse; + } + .-translate-y-1 { + --tw-translate-y: calc(var(--spacing) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + } .-translate-y-1\/2 { --tw-translate-y: calc(calc(1 / 2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); @@ -711,6 +734,9 @@ .place-content-center { place-content: center; } + .items-baseline { + align-items: baseline; + } .items-center { align-items: center; } @@ -732,6 +758,9 @@ .justify-end { justify-content: flex-end; } + .gap-0 { + gap: calc(var(--spacing) * 0); + } .gap-0\.5 { gap: calc(var(--spacing) * 0.5); } @@ -963,6 +992,9 @@ .border-overlay0 { border-color: var(--overlay0); } + .border-peach { + border-color: var(--peach); + } .border-peach\/50 { border-color: var(--peach); @supports (color: color-mix(in lab, red, red)) { @@ -1080,6 +1112,9 @@ .bg-mauve { background-color: var(--mauve); } + .bg-overlay0 { + background-color: var(--overlay0); + } .bg-overlay0\/10 { background-color: var(--overlay0); @supports (color: color-mix(in lab, red, red)) { @@ -1227,6 +1262,9 @@ .px-6 { padding-inline: calc(var(--spacing) * 6); } + .py-0 { + padding-block: calc(var(--spacing) * 0); + } .py-0\.5 { padding-block: calc(var(--spacing) * 0.5); } @@ -1399,6 +1437,9 @@ .whitespace-pre-wrap { white-space: pre-wrap; } + .text-base { + color: var(--base); + } .text-blue { color: var(--blue); } @@ -1516,6 +1557,10 @@ --tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px 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); } + .outline { + outline-style: var(--tw-outline-style); + outline-width: 1px; + } .blur { --tw-blur: blur(8px); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); @@ -1578,6 +1623,13 @@ } } } + .group-hover\:text-blue { + &:is(:where(.group):hover *) { + @media (hover: hover) { + color: var(--blue); + } + } + } .group-hover\:opacity-100 { &:is(:where(.group):hover *) { @media (hover: hover) { @@ -2633,6 +2685,11 @@ inherits: false; initial-value: 0 0 #0000; } +@property --tw-outline-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} @property --tw-blur { syntax: "*"; inherits: false; @@ -2735,6 +2792,7 @@ --tw-ring-offset-width: 0px; --tw-ring-offset-color: #fff; --tw-ring-offset-shadow: 0 0 #0000; + --tw-outline-style: solid; --tw-blur: initial; --tw-brightness: initial; --tw-contrast: initial; diff --git a/internal/handlers/index.go b/internal/handlers/index.go index 6eaa23a..f647a9b 100644 --- a/internal/handlers/index.go +++ b/internal/handlers/index.go @@ -1,22 +1,85 @@ package handlers import ( + "context" "net/http" "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/db" "git.haelnorr.com/h/oslstats/internal/throw" homeview "git.haelnorr.com/h/oslstats/internal/view/homeview" + "github.com/pkg/errors" + "github.com/uptrace/bun" ) // Index handles responses to the / path. Also serves a 404 Page for paths that // don't have explicit handlers -func Index(s *hws.Server) http.Handler { +func Index(s *hws.Server, conn *db.DB) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { throw.NotFound(s, w, r, r.URL.Path) + return } - renderSafely(homeview.IndexPage(), s, r, w) + + var season *db.Season + var standings []homeview.LeagueStandings + + if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { + // Get the most recent season + seasons, err := db.ListSeasons(ctx, tx, &db.PageOpts{ + Page: 1, + PerPage: 1, + Order: bun.OrderDesc, + OrderBy: "start_date", + }) + if err != nil { + return false, errors.Wrap(err, "db.ListSeasons") + } + + if seasons.Total == 0 || len(seasons.Items) == 0 { + return true, nil + } + + season = seasons.Items[0] + + // Build leaderboards for each league in this season + standings = make([]homeview.LeagueStandings, 0, len(season.Leagues)) + for _, league := range season.Leagues { + _, l, teams, err := db.GetSeasonLeagueWithTeams(ctx, tx, season.ShortName, league.ShortName) + if err != nil { + return false, errors.Wrap(err, "db.GetSeasonLeagueWithTeams") + } + + fixtures, err := db.GetAllocatedFixtures(ctx, tx, season.ID, l.ID) + if err != nil { + return false, errors.Wrap(err, "db.GetAllocatedFixtures") + } + + fixtureIDs := make([]int, len(fixtures)) + for i, f := range fixtures { + fixtureIDs[i] = f.ID + } + + resultMap, err := db.GetFinalizedResultsForFixtures(ctx, tx, fixtureIDs) + if err != nil { + return false, errors.Wrap(err, "db.GetFinalizedResultsForFixtures") + } + + leaderboard := db.ComputeLeaderboard(teams, fixtures, resultMap) + + standings = append(standings, homeview.LeagueStandings{ + League: l, + Leaderboard: leaderboard, + }) + } + + return true, nil + }); !ok { + return + } + + renderSafely(homeview.IndexPage(season, standings), s, r, w) }, ) } diff --git a/internal/server/routes.go b/internal/server/routes.go index cda9e57..b7afe09 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -39,7 +39,7 @@ func addRoutes( { Path: "/", Method: hws.MethodGET, - Handler: handlers.Index(s), + Handler: handlers.Index(s, conn), }, } diff --git a/internal/view/baseview/footer.templ b/internal/view/baseview/footer.templ index bcb50d9..a3415be 100644 --- a/internal/view/baseview/footer.templ +++ b/internal/view/baseview/footer.templ @@ -26,6 +26,15 @@ templ Footer() { + + } templ backToTopButton() { @@ -56,12 +65,9 @@ templ backToTopButton() { templ footerBranding() {
-
+
OSL Stats
-

- placeholder text -

} @@ -86,7 +92,7 @@ templ footerLinks(items []FooterItem) { templ footerCopyright() {

- by Haelnorr | placeholder text + by Haelnorr

} diff --git a/internal/view/homeview/external_links.templ b/internal/view/homeview/external_links.templ new file mode 100644 index 0000000..9f08eab --- /dev/null +++ b/internal/view/homeview/external_links.templ @@ -0,0 +1,33 @@ +package homeview + +// ExternalLinks renders card tiles for external community resources +templ ExternalLinks() { +
+ +

+ Join Our Discord +

+

+ Connect with other players, find teams, and stay up to date with league announcements. +

+
+ +

+ Official Slapshot +

+

+ Visit the official Slapshot website to learn more about the game. +

+
+
+} diff --git a/internal/view/homeview/index_page.templ b/internal/view/homeview/index_page.templ index 452aa5b..66cfc71 100644 --- a/internal/view/homeview/index_page.templ +++ b/internal/view/homeview/index_page.templ @@ -1,13 +1,32 @@ package homeview +import "git.haelnorr.com/h/oslstats/internal/db" import "git.haelnorr.com/h/oslstats/internal/view/baseview" // Page content for the index page -templ IndexPage() { - @baseview.Layout("OSL Stats") { -
-
OSL Stats
-
Placeholder text
+templ IndexPage(season *db.Season, standings []LeagueStandings) { + @baseview.Layout("Oceanic Slapshot League") { +
+
+

+ Oceanic Slapshot League +

+
+

+ The Oceanic Slapshot League (OSL) is a community for casual and competitive play of Slapshot: Rebound. + It is managed by a small group of community members, and aims to provide a place for players in the Oceanic + region (primarily Australia and New Zealand) to compete and play in organised League competitions, as well as + casual pick-up games (RPUGs) and public matches (in-game matchmaking). + The league is open to everyone, regardless of skill level. +

+
+
+
+ @LatestStandings(season, standings) +
+
+ @ExternalLinks() +
} } diff --git a/internal/view/homeview/latest_standings.templ b/internal/view/homeview/latest_standings.templ new file mode 100644 index 0000000..c99825e --- /dev/null +++ b/internal/view/homeview/latest_standings.templ @@ -0,0 +1,151 @@ +package homeview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/view/component/links" +import "fmt" + +// LeagueStandings holds the data needed to render a single league's table +type LeagueStandings struct { + League *db.League + Leaderboard []*db.LeaderboardEntry +} + +// LatestStandings renders the latest standings section with tabs to switch +// between leagues from the most recent season +templ LatestStandings(season *db.Season, standings []LeagueStandings) { +
+
+

Latest Standings

+ if season != nil { + + { season.Name } + + } +
+ if season == nil || len(standings) == 0 { +
+

No standings available yet.

+
+ } else { +
+ if len(standings) > 1 { +
+ for _, s := range standings { + + } +
+ } + for _, s := range standings { +
+ @standingsTable(season, s.League, s.Leaderboard) +
+ } +
+ } +
+} + +templ standingsTable(season *db.Season, league *db.League, leaderboard []*db.LeaderboardEntry) { + if len(leaderboard) == 0 { +
+

No teams in this league yet.

+
+ } else { +
+
+ Points: + W = { fmt.Sprint(db.PointsWin) } + OTW = { fmt.Sprint(db.PointsOvertimeWin) } + OTL = { fmt.Sprint(db.PointsOvertimeLoss) } + L = { fmt.Sprint(db.PointsLoss) } +
+
+ + + + + + + + + + + + + + + + + + for _, entry := range leaderboard { + @standingsRow(entry, season, league) + } + +
#TeamGPWOTWOTLLGFGAGDPTS
+
+
+ } +} + +templ standingsRow(entry *db.LeaderboardEntry, season *db.Season, league *db.League) { + {{ + r := entry.Record + goalDiff := r.GoalsFor - r.GoalsAgainst + var gdStr string + if goalDiff > 0 { + gdStr = fmt.Sprintf("+%d", goalDiff) + } else { + gdStr = fmt.Sprint(goalDiff) + } + }} + + + { fmt.Sprint(entry.Position) } + + + @links.TeamLinkInSeason(entry.Team, season, league) + + + { fmt.Sprint(r.Played) } + + + { fmt.Sprint(r.Wins) } + + + { fmt.Sprint(r.OvertimeWins) } + + + { fmt.Sprint(r.OvertimeLosses) } + + + { fmt.Sprint(r.Losses) } + + + { fmt.Sprint(r.GoalsFor) } + + + { fmt.Sprint(r.GoalsAgainst) } + + + if goalDiff > 0 { + { gdStr } + } else if goalDiff < 0 { + { gdStr } + } else { + { gdStr } + } + + + { fmt.Sprint(r.Points) } + + +}