im actually goated

This commit is contained in:
2026-02-16 00:29:22 +11:00
parent 3b430d39e2
commit bb3bed3e89
4 changed files with 334 additions and 237 deletions

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,10 +161,27 @@ 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
updated = append(updated, fixture) 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)
}
// 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]

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> </button>
<!-- Save --> <!-- Save / Reset -->
<button <div x-show="unsavedChanges" class="flex items-center gap-2 ml-auto">
x-show="unsavedChanges" <span
@click="save()" x-show="!canSave()"
:disabled="isSaving || !canSave()" class="text-yellow text-xs"
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm >
bg-green hover:bg-green/75 text-mantle transition All game weeks must have at least 1 fixture
disabled:bg-green/40 disabled:cursor-not-allowed ml-auto" </span>
> <button
<span x-text="isSaving ? 'Saving...' : 'Save'"></span> @click="reset()"
</button> class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
<span bg-surface1 hover:bg-surface2 text-text transition"
x-show="unsavedChanges && !canSave()" >
class="text-yellow text-xs ml-2" Reset
> </button>
All game weeks must have at least 1 fixture <button
</span> @click="save()"
:disabled="isSaving || !canSave()"
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
bg-green hover:bg-green/75 text-mantle transition
disabled:bg-green/40 disabled:cursor-not-allowed"
>
<span x-text="isSaving ? 'Saving...' : 'Save'"></span>
</button>
</div>
</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,206 +280,272 @@ 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(
allFixtures: initialFixtures || [], "fixturesManager",
seasonShortName: seasonShortName, (initialFixtures, seasonShortName, leagueShortName) => ({
leagueShortName: leagueShortName, allFixtures: initialFixtures || [],
_initialFixtures: JSON.parse(JSON.stringify(initialFixtures || [])),
seasonShortName: seasonShortName,
leagueShortName: leagueShortName,
// UI state // UI state
selectedGameWeek: null, selectedGameWeek: null,
unsavedChanges: false, unsavedChanges: false,
isSaving: false, isSaving: false,
isGenerating: false, isGenerating: false,
generateRounds: 1, generateRounds: null,
// Drag state init() {
draggedFixture: null, this.generateRounds =
dropTarget: null, this.availableRounds.length > 0 ? this.availableRounds[0] : 1;
},
// Computed // Drag state
get unallocatedFixtures() { draggedFixture: null,
return this.allFixtures dropTarget: null,
.filter(f => f.gameWeek === null)
.sort((a, b) => a.round - b.round || a.id - b.id);
},
get allGameWeekNumbers() { // Computed
const weeks = new Set(); get unallocatedFixtures() {
for (const f of this.allFixtures) { return this.allFixtures
if (f.gameWeek !== null) { .filter((f) => f.gameWeek === null)
weeks.add(f.gameWeek); .sort((a, b) => a.round - b.round || a.id - b.id);
} },
}
// Also include manually added empty weeks
for (const w of this._emptyWeeks || []) {
weeks.add(w);
}
return [...weeks].sort((a, b) => a - b);
},
// Track empty weeks that user created get allGameWeekNumbers() {
_emptyWeeks: [], const weeks = new Set();
for (const f of this.allFixtures) {
getGameWeekFixtures(week) { if (f.gameWeek !== null) {
return this.allFixtures weeks.add(f.gameWeek);
.filter(f => f.gameWeek === week)
.sort((a, b) => a.round - b.round || a.id - b.id);
},
getFixtureCount(week) {
return this.allFixtures.filter(f => f.gameWeek === week).length;
},
getPreview(week) {
return this.getGameWeekFixtures(week).slice(0, 3);
},
// Game week management
addGameWeek() {
const existing = this.allGameWeekNumbers;
const next = existing.length > 0 ? Math.max(...existing) + 1 : 1;
this._emptyWeeks.push(next);
this.unsavedChanges = true;
},
deleteGameWeek(week) {
if (this.getFixtureCount(week) > 0) return;
this._emptyWeeks = this._emptyWeeks.filter(w => w !== week);
if (this.selectedGameWeek === week) {
this.selectedGameWeek = null;
}
this.unsavedChanges = true;
},
selectGameWeek(week) {
this.selectedGameWeek = week;
},
backToList() {
this.selectedGameWeek = null;
},
// Drag and drop
onDragStart(event, fixture) {
this.draggedFixture = fixture;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', fixture.id);
},
onDragEnd() {
this.draggedFixture = null;
this.dropTarget = null;
},
onDrop(target) {
if (!this.draggedFixture) return;
const fixture = this.allFixtures.find(f => f.id === this.draggedFixture.id);
if (!fixture) return;
if (target === 'unallocated') {
fixture.gameWeek = null;
} else {
fixture.gameWeek = target;
// Remove from empty weeks if it now has fixtures
this._emptyWeeks = this._emptyWeeks.filter(w => w !== target);
}
this.unsavedChanges = true;
this.draggedFixture = null;
this.dropTarget = null;
},
unallocateFixture(fixture) {
const f = this.allFixtures.find(ff => ff.id === fixture.id);
if (f) {
const oldWeek = f.gameWeek;
f.gameWeek = null;
// If the old week is now empty, track it
if (oldWeek !== null && this.getFixtureCount(oldWeek) === 0) {
this._emptyWeeks.push(oldWeek);
}
this.unsavedChanges = true;
}
},
// Validation
canSave() {
const weeks = this.allGameWeekNumbers;
if (weeks.length === 0) return false;
for (const week of weeks) {
if (this.getFixtureCount(week) === 0) return false;
}
return true;
},
// Server actions
generate() {
if (this.generateRounds < 1) return;
this.isGenerating = true;
const form = new FormData();
form.append('season_short_name', this.seasonShortName);
form.append('league_short_name', this.leagueShortName);
form.append('round', this.generateRounds);
htmx.ajax('POST', '/fixtures/generate', {
target: '#manage-fixtures-content',
swap: 'outerHTML',
values: Object.fromEntries(form)
}).finally(() => {
this.isGenerating = false;
});
},
save() {
if (!this.canSave()) return;
this.isSaving = true;
const form = new FormData();
form.append('season_short_name', this.seasonShortName);
form.append('league_short_name', this.leagueShortName);
this.allFixtures.forEach((f, i) => {
form.append('allocations[' + i + '][id]', f.id);
form.append('allocations[' + i + '][game_week]', f.gameWeek !== null ? f.gameWeek : 0);
});
fetch('/fixtures/update-game-weeks', {
method: 'POST',
body: form
}).then(response => {
if (response.ok) {
this.unsavedChanges = false;
}
this.isSaving = false;
}).catch(() => {
this.isSaving = false;
});
},
clearAll() {
const seasonShort = this.seasonShortName;
const leagueShort = this.leagueShortName;
window.dispatchEvent(new CustomEvent('confirm-action', {
detail: {
title: 'Clear All Fixtures',
message: 'This will delete all fixtures for this league. This action cannot be undone.',
action: () => {
htmx.ajax('DELETE',
'/seasons/' + seasonShort + '/leagues/' + leagueShort + '/fixtures',
{
target: '#manage-fixtures-content',
swap: 'outerHTML'
}
);
} }
} }
})); // Also include manually added empty weeks
}, for (const w of this._emptyWeeks || []) {
})); weeks.add(w);
}
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
// Default to [1] if no fixtures have game weeks assigned
_emptyWeeks: (initialFixtures || []).some(
(f) => f.gameWeek !== null,
)
? []
: [1],
getGameWeekFixtures(week) {
return this.allFixtures
.filter((f) => f.gameWeek === week)
.sort((a, b) => a.round - b.round || a.id - b.id);
},
getFixtureCount(week) {
return this.allFixtures.filter((f) => f.gameWeek === week).length;
},
getPreview(week) {
return this.getGameWeekFixtures(week).slice(0, 3);
},
// Game week management
addGameWeek() {
const existing = this.allGameWeekNumbers;
const next = existing.length > 0 ? Math.max(...existing) + 1 : 1;
this._emptyWeeks.push(next);
this.unsavedChanges = true;
},
deleteGameWeek(week) {
if (this.getFixtureCount(week) > 0) return;
this._emptyWeeks = this._emptyWeeks.filter((w) => w !== week);
if (this.selectedGameWeek === week) {
this.selectedGameWeek = null;
}
this.unsavedChanges = true;
},
selectGameWeek(week) {
this.selectedGameWeek = week;
},
backToList() {
this.selectedGameWeek = null;
},
// Drag and drop
onDragStart(event, fixture) {
this.draggedFixture = fixture;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", fixture.id);
},
onDragEnd() {
this.draggedFixture = null;
this.dropTarget = null;
},
onDrop(target) {
if (!this.draggedFixture) return;
const fixture = this.allFixtures.find(
(f) => f.id === this.draggedFixture.id,
);
if (!fixture) return;
if (target === "unallocated") {
fixture.gameWeek = null;
} else {
fixture.gameWeek = target;
// Remove from empty weeks if it now has fixtures
this._emptyWeeks = this._emptyWeeks.filter((w) => w !== target);
}
this.unsavedChanges = true;
this.draggedFixture = null;
this.dropTarget = null;
},
unallocateFixture(fixture) {
const f = this.allFixtures.find((ff) => ff.id === fixture.id);
if (f) {
const oldWeek = f.gameWeek;
f.gameWeek = null;
// If the old week is now empty, track it
if (oldWeek !== null && this.getFixtureCount(oldWeek) === 0) {
this._emptyWeeks.push(oldWeek);
}
this.unsavedChanges = true;
}
},
// Validation
canSave() {
const weeks = this.allGameWeekNumbers;
if (weeks.length === 0) return false;
for (const week of weeks) {
if (this.getFixtureCount(week) === 0) return false;
}
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
generate() {
if (this.generateRounds < 1) return;
this.isGenerating = true;
const form = new FormData();
form.append("season_short_name", this.seasonShortName);
form.append("league_short_name", this.leagueShortName);
form.append("round", this.generateRounds);
htmx
.ajax("POST", "/fixtures/generate", {
target: "#manage-fixtures-content",
swap: "outerHTML",
values: Object.fromEntries(form),
})
.finally(() => {
this.isGenerating = false;
});
},
save() {
if (!this.canSave()) return;
this.isSaving = true;
const params = new URLSearchParams();
params.append("season_short_name", this.seasonShortName);
params.append("league_short_name", this.leagueShortName);
this.allFixtures.forEach((f, i) => {
params.append("allocations[" + i + "][id]", f.id);
params.append(
"allocations[" + i + "][game_week]",
f.gameWeek !== null ? f.gameWeek : 0,
);
});
fetch("/fixtures/update-game-weeks", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params.toString(),
})
.then((response) => {
if (response.ok) {
this.unsavedChanges = false;
}
this.isSaving = false;
})
.catch(() => {
this.isSaving = false;
});
},
deleteAll() {
const seasonShort = this.seasonShortName;
const leagueShort = this.leagueShortName;
window.dispatchEvent(
new CustomEvent("confirm-action", {
detail: {
title: "Delete All Fixtures",
message:
"This will delete all fixtures for this league. This action cannot be undone.",
action: () => {
htmx.ajax(
"DELETE",
"/seasons/" +
seasonShort +
"/leagues/" +
leagueShort +
"/fixtures",
{
target: "#manage-fixtures-content",
swap: "outerHTML",
},
);
},
},
}),
);
},
}),
);
}); });
</script> </script>
</div> </div>