fixtures #2

Merged
h merged 20 commits from fixtures into master 2026-02-23 20:38:26 +11:00
4 changed files with 334 additions and 237 deletions
Showing only changes of commit bb3bed3e89 - Show all commits

View File

@@ -489,9 +489,6 @@
.w-20 { .w-20 {
width: calc(var(--spacing) * 20); width: calc(var(--spacing) * 20);
} }
.w-24 {
width: calc(var(--spacing) * 24);
}
.w-26 { .w-26 {
width: calc(var(--spacing) * 26); width: calc(var(--spacing) * 26);
} }

View File

@@ -2,6 +2,7 @@ package handlers
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
@@ -69,6 +70,7 @@ func UpdateFixtures(
leagueShortName := getter.String("league_short_name").TrimSpace().Required().Value leagueShortName := getter.String("league_short_name").TrimSpace().Required().Value
allocations := getter.GetMaps("allocations") allocations := getter.GetMaps("allocations")
if !getter.ValidateAndNotify(s, w, r) { if !getter.ValidateAndNotify(s, w, r) {
w.WriteHeader(http.StatusBadRequest)
return return
} }
updates, err := mapUpdates(allocations) updates, err := mapUpdates(allocations)
@@ -91,6 +93,7 @@ func UpdateFixtures(
} }
var valid bool var valid bool
fixtures, valid = updateFixtures(fixtures, updates) fixtures, valid = updateFixtures(fixtures, updates)
fmt.Println(len(fixtures))
if !valid { if !valid {
notify.Warn(s, w, r, "Invalid game weeks", "A game week is missing or has no games", nil) notify.Warn(s, w, r, "Invalid game weeks", "A game week is missing or has no games", nil)
return false, nil return false, nil
@@ -158,11 +161,28 @@ func updateFixtures(fixtures []*db.Fixture, updates map[int]int) ([]*db.Fixture,
gameWeeks := map[int]int{} gameWeeks := map[int]int{}
for _, fixture := range fixtures { for _, fixture := range fixtures {
if gameWeek, exists := updates[fixture.ID]; exists { if gameWeek, exists := updates[fixture.ID]; exists {
fixture.GameWeek = &gameWeek var newValue *int
var oldValue int
if fixture.GameWeek != nil {
oldValue = *fixture.GameWeek
} else {
oldValue = 0
}
if gameWeek == 0 {
newValue = nil
} else {
newValue = &gameWeek
}
if gameWeek != oldValue {
fixture.GameWeek = newValue
updated = append(updated, fixture) updated = append(updated, fixture)
} }
// fuck i hate pointers sometimes
}
if fixture.GameWeek != nil {
gameWeeks[*fixture.GameWeek]++ gameWeeks[*fixture.GameWeek]++
} }
}
for i := range len(gameWeeks) { for i := range len(gameWeeks) {
count, exists := gameWeeks[i+1] count, exists := gameWeeks[i+1]
if !exists || count < 1 { if !exists || count < 1 {

View File

@@ -3,7 +3,6 @@ package validation
import ( import (
"net/http" "net/http"
"regexp" "regexp"
"strconv"
"strings" "strings"
"git.haelnorr.com/h/golib/hws" "git.haelnorr.com/h/golib/hws"
@@ -32,18 +31,24 @@ func (f *FormGetter) GetList(key string) []string {
} }
func (f *FormGetter) GetMaps(key string) []map[string]string { func (f *FormGetter) GetMaps(key string) []map[string]string {
var result []map[string]string results := map[string]map[string]string{}
for key, values := range f.r.Form { re := regexp.MustCompile(key + "\\[([0-9]+)\\]\\[([a-zA-Z_]+)\\]")
re := regexp.MustCompile(key + "\\[([0-9]+)\\]\\[([a-zA-Z]+)\\]") for k, v := range f.r.Form {
matches := re.FindStringSubmatch(key) matches := re.FindStringSubmatch(k)
if len(matches) >= 3 { if len(matches) >= 3 {
index, _ := strconv.Atoi(matches[1]) realKey := matches[1]
for index >= len(result) { field := matches[2]
result = append(result, map[string]string{}) value := strings.Join(v, ",")
if _, exists := results[realKey]; !exists {
results[realKey] = map[string]string{}
} }
result[index][matches[2]] = values[0] results[realKey][field] = value
} }
} }
result := []map[string]string{}
for _, v := range results {
result = append(result, v)
}
return result return result
} }

View File

@@ -60,18 +60,19 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
<div class="flex items-center gap-3 mb-6"> <div class="flex items-center gap-3 mb-6">
<!-- Generate --> <!-- Generate -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input <label class="text-sm text-subtext0">Round:</label>
type="number" <select
x-model.number="generateRounds" x-model.number="generateRounds"
min="1" class="py-2 px-3 rounded-lg text-sm bg-base border-2 border-overlay0
max="20"
placeholder="Rounds"
class="w-24 py-2 px-3 rounded-lg text-sm bg-base border-2 border-overlay0
focus:border-blue outline-none text-text" focus:border-blue outline-none text-text"
/> >
<template x-for="r in availableRounds" :key="r">
<option :value="r" x-text="r"></option>
</template>
</select>
<button <button
@click="generate()" @click="generate()"
:disabled="isGenerating || generateRounds < 1" :disabled="isGenerating || availableRounds.length === 0"
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
bg-blue hover:bg-blue/80 text-mantle transition bg-blue hover:bg-blue/80 text-mantle transition
disabled:bg-blue/40 disabled:cursor-not-allowed" disabled:bg-blue/40 disabled:cursor-not-allowed"
@@ -79,32 +80,40 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
<span x-text="isGenerating ? 'Generating...' : 'Generate Round'"></span> <span x-text="isGenerating ? 'Generating...' : 'Generate Round'"></span>
</button> </button>
</div> </div>
<!-- Clear All --> <!-- Delete All -->
<button <button
x-show="allFixtures.length > 0" x-show="allFixtures.length > 0"
@click="clearAll()" @click="deleteAll()"
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
bg-red hover:bg-red/80 text-mantle transition" bg-red hover:bg-red/80 text-mantle transition"
> >
Clear All Delete All
</button>
<!-- Save / Reset -->
<div x-show="unsavedChanges" class="flex items-center gap-2 ml-auto">
<span
x-show="!canSave()"
class="text-yellow text-xs"
>
All game weeks must have at least 1 fixture
</span>
<button
@click="reset()"
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
bg-surface1 hover:bg-surface2 text-text transition"
>
Reset
</button> </button>
<!-- Save -->
<button <button
x-show="unsavedChanges"
@click="save()" @click="save()"
:disabled="isSaving || !canSave()" :disabled="isSaving || !canSave()"
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
bg-green hover:bg-green/75 text-mantle transition bg-green hover:bg-green/75 text-mantle transition
disabled:bg-green/40 disabled:cursor-not-allowed ml-auto" disabled:bg-green/40 disabled:cursor-not-allowed"
> >
<span x-text="isSaving ? 'Saving...' : 'Save'"></span> <span x-text="isSaving ? 'Saving...' : 'Save'"></span>
</button> </button>
<span </div>
x-show="unsavedChanges && !canSave()"
class="text-yellow text-xs ml-2"
>
All game weeks must have at least 1 fixture
</span>
</div> </div>
<!-- Main content panels --> <!-- Main content panels -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
@@ -114,7 +123,7 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
<div x-show="selectedGameWeek === null"> <div x-show="selectedGameWeek === null">
<h3 class="text-lg font-bold text-text mb-4">Game Weeks</h3> <h3 class="text-lg font-bold text-text mb-4">Game Weeks</h3>
<div x-show="allGameWeekNumbers.length === 0" class="text-subtext0 text-sm italic mb-4"> <div x-show="allGameWeekNumbers.length === 0" class="text-subtext0 text-sm italic mb-4">
No game weeks yet. Add one to start allocating fixtures. No game weeks yet. Generate fixtures to get started.
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<template x-for="week in allGameWeekNumbers" :key="week"> <template x-for="week in allGameWeekNumbers" :key="week">
@@ -271,9 +280,12 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
</div> </div>
<!-- Alpine.js component --> <!-- Alpine.js component -->
<script> <script>
document.addEventListener('alpine:init', () => { document.addEventListener("alpine:init", () => {
Alpine.data('fixturesManager', (initialFixtures, seasonShortName, leagueShortName) => ({ Alpine.data(
"fixturesManager",
(initialFixtures, seasonShortName, leagueShortName) => ({
allFixtures: initialFixtures || [], allFixtures: initialFixtures || [],
_initialFixtures: JSON.parse(JSON.stringify(initialFixtures || [])),
seasonShortName: seasonShortName, seasonShortName: seasonShortName,
leagueShortName: leagueShortName, leagueShortName: leagueShortName,
@@ -282,7 +294,12 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
unsavedChanges: false, unsavedChanges: false,
isSaving: false, isSaving: false,
isGenerating: false, isGenerating: false,
generateRounds: 1, generateRounds: null,
init() {
this.generateRounds =
this.availableRounds.length > 0 ? this.availableRounds[0] : 1;
},
// Drag state // Drag state
draggedFixture: null, draggedFixture: null,
@@ -291,7 +308,7 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
// Computed // Computed
get unallocatedFixtures() { get unallocatedFixtures() {
return this.allFixtures return this.allFixtures
.filter(f => f.gameWeek === null) .filter((f) => f.gameWeek === null)
.sort((a, b) => a.round - b.round || a.id - b.id); .sort((a, b) => a.round - b.round || a.id - b.id);
}, },
@@ -309,17 +326,41 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
return [...weeks].sort((a, b) => a - b); return [...weeks].sort((a, b) => a - b);
}, },
get existingRounds() {
const rounds = new Set();
for (const f of this.allFixtures) {
rounds.add(f.round);
}
return rounds;
},
get availableRounds() {
const taken = this.existingRounds;
const maxTaken = taken.size > 0 ? Math.max(...taken) : 0;
const limit = maxTaken + 5;
const available = [];
for (let i = 1; i <= limit; i++) {
if (!taken.has(i)) available.push(i);
}
return available;
},
// Track empty weeks that user created // Track empty weeks that user created
_emptyWeeks: [], // Default to [1] if no fixtures have game weeks assigned
_emptyWeeks: (initialFixtures || []).some(
(f) => f.gameWeek !== null,
)
? []
: [1],
getGameWeekFixtures(week) { getGameWeekFixtures(week) {
return this.allFixtures return this.allFixtures
.filter(f => f.gameWeek === week) .filter((f) => f.gameWeek === week)
.sort((a, b) => a.round - b.round || a.id - b.id); .sort((a, b) => a.round - b.round || a.id - b.id);
}, },
getFixtureCount(week) { getFixtureCount(week) {
return this.allFixtures.filter(f => f.gameWeek === week).length; return this.allFixtures.filter((f) => f.gameWeek === week).length;
}, },
getPreview(week) { getPreview(week) {
@@ -336,7 +377,7 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
deleteGameWeek(week) { deleteGameWeek(week) {
if (this.getFixtureCount(week) > 0) return; if (this.getFixtureCount(week) > 0) return;
this._emptyWeeks = this._emptyWeeks.filter(w => w !== week); this._emptyWeeks = this._emptyWeeks.filter((w) => w !== week);
if (this.selectedGameWeek === week) { if (this.selectedGameWeek === week) {
this.selectedGameWeek = null; this.selectedGameWeek = null;
} }
@@ -354,8 +395,8 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
// Drag and drop // Drag and drop
onDragStart(event, fixture) { onDragStart(event, fixture) {
this.draggedFixture = fixture; this.draggedFixture = fixture;
event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData('text/plain', fixture.id); event.dataTransfer.setData("text/plain", fixture.id);
}, },
onDragEnd() { onDragEnd() {
@@ -366,15 +407,17 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
onDrop(target) { onDrop(target) {
if (!this.draggedFixture) return; if (!this.draggedFixture) return;
const fixture = this.allFixtures.find(f => f.id === this.draggedFixture.id); const fixture = this.allFixtures.find(
(f) => f.id === this.draggedFixture.id,
);
if (!fixture) return; if (!fixture) return;
if (target === 'unallocated') { if (target === "unallocated") {
fixture.gameWeek = null; fixture.gameWeek = null;
} else { } else {
fixture.gameWeek = target; fixture.gameWeek = target;
// Remove from empty weeks if it now has fixtures // Remove from empty weeks if it now has fixtures
this._emptyWeeks = this._emptyWeeks.filter(w => w !== target); this._emptyWeeks = this._emptyWeeks.filter((w) => w !== target);
} }
this.unsavedChanges = true; this.unsavedChanges = true;
@@ -383,7 +426,7 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
}, },
unallocateFixture(fixture) { unallocateFixture(fixture) {
const f = this.allFixtures.find(ff => ff.id === fixture.id); const f = this.allFixtures.find((ff) => ff.id === fixture.id);
if (f) { if (f) {
const oldWeek = f.gameWeek; const oldWeek = f.gameWeek;
f.gameWeek = null; f.gameWeek = null;
@@ -405,21 +448,36 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
return true; return true;
}, },
reset() {
this.allFixtures = JSON.parse(
JSON.stringify(this._initialFixtures),
);
this._emptyWeeks = this._initialFixtures.some(
(f) => f.gameWeek !== null,
)
? []
: [1];
this.selectedGameWeek = null;
this.unsavedChanges = false;
},
// Server actions // Server actions
generate() { generate() {
if (this.generateRounds < 1) return; if (this.generateRounds < 1) return;
this.isGenerating = true; this.isGenerating = true;
const form = new FormData(); const form = new FormData();
form.append('season_short_name', this.seasonShortName); form.append("season_short_name", this.seasonShortName);
form.append('league_short_name', this.leagueShortName); form.append("league_short_name", this.leagueShortName);
form.append('round', this.generateRounds); form.append("round", this.generateRounds);
htmx.ajax('POST', '/fixtures/generate', { htmx
target: '#manage-fixtures-content', .ajax("POST", "/fixtures/generate", {
swap: 'outerHTML', target: "#manage-fixtures-content",
values: Object.fromEntries(form) swap: "outerHTML",
}).finally(() => { values: Object.fromEntries(form),
})
.finally(() => {
this.isGenerating = false; this.isGenerating = false;
}); });
}, },
@@ -428,49 +486,66 @@ templ SeasonLeagueManageFixtures(season *db.Season, league *db.League, fixtures
if (!this.canSave()) return; if (!this.canSave()) return;
this.isSaving = true; this.isSaving = true;
const form = new FormData(); const params = new URLSearchParams();
form.append('season_short_name', this.seasonShortName); params.append("season_short_name", this.seasonShortName);
form.append('league_short_name', this.leagueShortName); params.append("league_short_name", this.leagueShortName);
this.allFixtures.forEach((f, i) => { this.allFixtures.forEach((f, i) => {
form.append('allocations[' + i + '][id]', f.id); params.append("allocations[" + i + "][id]", f.id);
form.append('allocations[' + i + '][game_week]', f.gameWeek !== null ? f.gameWeek : 0); params.append(
"allocations[" + i + "][game_week]",
f.gameWeek !== null ? f.gameWeek : 0,
);
}); });
fetch('/fixtures/update-game-weeks', { fetch("/fixtures/update-game-weeks", {
method: 'POST', method: "POST",
body: form headers: {
}).then(response => { "Content-Type": "application/x-www-form-urlencoded",
},
body: params.toString(),
})
.then((response) => {
if (response.ok) { if (response.ok) {
this.unsavedChanges = false; this.unsavedChanges = false;
} }
this.isSaving = false; this.isSaving = false;
}).catch(() => { })
.catch(() => {
this.isSaving = false; this.isSaving = false;
}); });
}, },
clearAll() { deleteAll() {
const seasonShort = this.seasonShortName; const seasonShort = this.seasonShortName;
const leagueShort = this.leagueShortName; const leagueShort = this.leagueShortName;
window.dispatchEvent(new CustomEvent('confirm-action', { window.dispatchEvent(
new CustomEvent("confirm-action", {
detail: { detail: {
title: 'Clear All Fixtures', title: "Delete All Fixtures",
message: 'This will delete all fixtures for this league. This action cannot be undone.', message:
"This will delete all fixtures for this league. This action cannot be undone.",
action: () => { action: () => {
htmx.ajax('DELETE', htmx.ajax(
'/seasons/' + seasonShort + '/leagues/' + leagueShort + '/fixtures', "DELETE",
"/seasons/" +
seasonShort +
"/leagues/" +
leagueShort +
"/fixtures",
{ {
target: '#manage-fixtures-content', target: "#manage-fixtures-content",
swap: 'outerHTML' swap: "outerHTML",
}
);
}
}
}));
}, },
})); );
},
},
}),
);
},
}),
);
}); });
</script> </script>
</div> </div>