Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ src/apps/web/ v2 mobile-first Angular website, empty for now
- **Desktop layering**: `internal/tournament` (pure: event types, projection, command validation) → `apps/desktop` services (load log, validate, append, emit `tournament.updated`). Services take `emit`, `now` and dialogs as dependencies so they are tested without Wails.
- **Settings** live in app.db and are broadcast with `settings.changed`; both windows apply them (language included).
- **Navigation**: going home keeps the tournament open (the top bar offers to resume it); opening another file replaces it. The home screen lists recent files with a summary saved in app.db when a file is opened or closed (`TournamentSummary`), live for the open one.
- **Sample tournament** (`CreateSampleTournament`, `sample.go`): from home, a new file in the tournaments folder (name translated by the frontend, numbered when taken) built through the service's commands on a clock 95 min back: two categories of fictional pairs, pools then a full-placement bracket, two rounds played, courts busy, a score pending. The same builder makes the frontend test fixtures.
- **Guide**: the top bar's "?" and the home first-run card open the website's guide in the browser (`core/guide.ts`, `/guide/` in French, `/en/guide/` otherwise). The app never needs it to work.
- **Duplicate** (`tournament.Duplicate`) starts a new file with the setup of another: courts, rest, court priority, categories, pool stages, brackets and routing (bracket IDs renumbered). Never entrants, draws or results.
- **Timed matches**: Go stores the round start time (`RoundStarted` event) and duration. The frontend only renders the countdown.
- **Never block the user.** Inconsistencies produce warnings, not refusals, unless the action is impossible.
Expand Down
157 changes: 21 additions & 136 deletions src/apps/desktop/fixtures_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package main
import (
"context"
"encoding/json"
"fmt"
"math/rand/v2"
"os"
"path/filepath"
Expand All @@ -12,7 +11,6 @@ import (

"github.com/OpenShuttle/openshuttle-app/src/internal/engine"
"github.com/OpenShuttle/openshuttle-app/src/internal/store"
"github.com/OpenShuttle/openshuttle-app/src/internal/tournament"
)

// TestWriteFrontendFixtures writes the views of a few demo tournaments as
Expand All @@ -33,6 +31,10 @@ func TestWriteFrontendFixtures(t *testing.T) {
}
write := func(name string, v any) {
t.Helper()
if tv, ok := v.(TournamentView); ok {
tv.Path = "/home/julie/Documents/OpenShuttle/" + filepath.Base(tv.Path) // not the test's temp dir
v = tv
}
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -62,6 +64,7 @@ func TestWriteFrontendFixtures(t *testing.T) {
md := d.svc.GetDrawView().Categories[0]
d.must(d.svc.SetSeeds(ctx, engine.MensDoubles, []engine.EntrantID{md.Unseeded[0].ID, md.Unseeded[1].ID}))
d.must(d.svc.DrawEntry(ctx, engine.MensDoubles))
d.must(nil)
write("before-tournament", d.svc.GetTournamentView())
write("before-entrants", d.svc.GetEntrantsView())
write("before-checkin", d.svc.GetCheckInView())
Expand All @@ -82,6 +85,7 @@ func TestWriteFrontendFixtures(t *testing.T) {
p.play(2)
p.callAll()
p.endOne()
p.must(nil)
write("progress-tournament", p.svc.GetTournamentView())
write("progress-desk", p.svc.GetControlDeskView())
write("progress-display", p.svc.GetDisplayView())
Expand All @@ -102,6 +106,7 @@ func TestWriteFrontendFixtures(t *testing.T) {
f.checkInAll()
f.draw(engine.MensDoubles, engine.WomensDoubles)
f.play(60)
f.must(nil)
write("finished-tournament", f.svc.GetTournamentView())
write("finished-phases", f.svc.GetPhasesView())
write("finished-results", f.svc.GetResultsView())
Expand All @@ -118,7 +123,8 @@ func TestWriteFrontendFixtures(t *testing.T) {
r.checkInAll()
r.draw(engine.MixedDoubles)
r.must(r.svc.StartRound(ctx))
r.clock = r.clock.Add(4 * time.Minute)
*r.clock = r.clock.Add(4 * time.Minute)
r.must(nil)
write("rounds-desk", r.svc.GetControlDeskView())
write("rounds-display", r.svc.GetDisplayView())

Expand All @@ -141,13 +147,12 @@ func TestWriteFrontendFixtures(t *testing.T) {
write("preview", preview)
}

// demo is a tournament built step by step with a fixed clock.
// demo is a tournament built step by step with a fixed clock, by the
// sample builder.
type demo struct {
t *testing.T
ctx context.Context
app *store.AppDB
svc *TournamentService
clock time.Time
*sampleBuilder
t *testing.T
app *store.AppDB
}

func newDemo(t *testing.T, name string, courts int) *demo {
Expand All @@ -158,7 +163,8 @@ func newDemo(t *testing.T, name string, courts int) *demo {
if err != nil {
t.Fatal(err)
}
d := &demo{t: t, ctx: ctx, app: app, clock: time.Date(2026, 10, 17, 9, 0, 0, 0, time.UTC)}
clock := time.Date(2026, 10, 17, 9, 0, 0, 0, time.UTC)
d := &demo{t: t, app: app, sampleBuilder: &sampleBuilder{ctx: ctx, clock: &clock}}
d.svc = NewTournamentService(app, func(string, any) {}, d.now, filepath.Join(dir, "backups"), nil)
d.svc.rng = func() *rand.Rand { return rand.New(rand.NewPCG(7, 11)) }
t.Cleanup(func() {
Expand All @@ -172,137 +178,16 @@ func newDemo(t *testing.T, name string, courts int) *demo {

// now ticks a second per call, so every event has its own time.
func (d *demo) now() time.Time {
d.clock = d.clock.Add(time.Second)
return d.clock
*d.clock = d.clock.Add(time.Second)
return *d.clock
}

func (d *demo) must(err error) {
d.t.Helper()
if err == nil {
err = d.err
}
if err != nil {
d.t.Fatal(err)
}
}

var (
demoClubs = []string{"Volant des Monts", "BC Rive Droite", "Plumes du Lac", "AS Belvédère"}
demoFirst = []string{"Lucas", "Hugo", "Paul", "Jules", "Louis", "Théo", "Nathan", "Simon", "Victor", "Kevin", "Mathis", "Félix",
"Camille", "Léa", "Jade", "Manon", "Inès", "Chloé", "Emma", "Clara", "Alice", "Océane", "Élise", "Lou"}
demoLast = []string{"Perrin", "Faure", "Blanc", "Masson", "Caron", "Mathieu", "Lopez", "Vidal", "Gauthier", "Brun", "Fontaine", "Simon",
"Moreau", "Roux", "Fournier", "Morin", "Guérin", "Henry", "Lambert", "Bertrand", "Laurent", "Dumont", "Clément", "André"}
)

// entrants registers n pairs of a category.
func (d *demo) entrants(c engine.Category, n int) {
d.must(d.svc.AddCategory(d.ctx, c))
women := c == engine.WomensDoubles || c == engine.WomensSingles
for i := range n {
k := len(d.svc.GetCheckInView().Teams) * 2 % 12
if women {
k += 12
}
player := func(j int) tournament.PlayerInput {
return tournament.PlayerInput{
Name: fmt.Sprintf("%s %s", demoFirst[(k+j)%len(demoFirst)], demoLast[(k+j*5+i)%len(demoLast)]),
Club: demoClubs[i%len(demoClubs)],
}
}
d.must(d.svc.RegisterEntrant(d.ctx, c, []tournament.PlayerInput{player(0), player(1)}))
}
}

func (d *demo) stages(c engine.Category) []tournament.StageConfig {
var stages []tournament.StageConfig
for _, fc := range d.svc.GetFormatView().Categories {
if fc.Category == c {
for _, st := range fc.Stages {
stages = append(stages, st.StageConfig)
}
}
}
return stages
}

// format sets pools, then a full-placement bracket for the first two of each.
func (d *demo) format(c engine.Category, pools int, bracket bool) {
if bracket {
d.must(d.svc.AddStage(d.ctx, c, tournament.StageBracket))
}
stages := d.stages(c)
win := engine.WinCondition{Kind: engine.WinBySets, SetsToWin: 2, PointsPerSet: 15, Cap: 21}
stages[0].Pools, stages[0].PoolSizes, stages[0].Win = pools, nil, win
if bracket {
stages[0].Routing = tournament.RanksRules("S2", "S2")
stages[1].Name, stages[1].Win, stages[1].FullPlacement = "Tableau principal", win, true
}
d.must(d.svc.SetFormat(d.ctx, c, stages))
}

func (d *demo) checkInAll() {
for _, team := range d.svc.GetCheckInView().Teams {
d.must(d.svc.CheckInEntrant(d.ctx, team.EntrantID))
}
}

func (d *demo) draw(categories ...engine.Category) {
for _, c := range categories {
d.must(d.svc.DrawEntry(d.ctx, c))
d.must(d.svc.ConfirmDraw(d.ctx, c))
}
}

var demoScores = []engine.Score{
{Sets: []engine.SetScore{{A: 15, B: 11}, {A: 15, B: 9}}},
{Sets: []engine.SetScore{{A: 13, B: 15}, {A: 15, B: 12}, {A: 15, B: 10}}},
{Sets: []engine.SetScore{{A: 9, B: 15}, {A: 12, B: 15}}},
{Sets: []engine.SetScore{{A: 17, B: 15}, {A: 15, B: 8}}},
}

// play runs up to n rounds of calls: every proposal called, ended and
// scored; brackets are filled once their pools are over.
func (d *demo) play(n int) {
scored := 0
for range n {
called := d.callAll()
if len(called) == 0 {
filled := false
for _, c := range d.svc.GetTournamentView().Categories {
if d.svc.FillStage(d.ctx, c.Category, "S2") == nil {
filled = true
}
}
if !filled {
return
}
continue
}
d.clock = d.clock.Add(22 * time.Minute)
for _, id := range called {
d.must(d.svc.EndMatch(d.ctx, id))
d.must(d.svc.RecordScore(d.ctx, id, demoScores[scored%len(demoScores)]))
scored++
}
d.clock = d.clock.Add(3 * time.Minute)
}
}

// callAll calls every proposal of the desk.
func (d *demo) callAll() []tournament.MatchID {
var called []tournament.MatchID
for _, c := range d.svc.GetControlDeskView().Courts {
if c.Proposal != nil && d.svc.CallMatch(d.ctx, c.Proposal.ID, c.Number) == nil {
called = append(called, c.Proposal.ID)
}
}
d.clock = d.clock.Add(9 * time.Minute)
return called
}

// endOne ends the first match on court, its score left pending.
func (d *demo) endOne() {
for _, c := range d.svc.GetControlDeskView().Courts {
if c.Match != nil {
d.must(d.svc.EndMatch(d.ctx, c.Match.ID))
return
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,18 @@ export function ConfirmDraw(c: engine$0.Category): $CancellablePromise<void> {
return $Call.ByID(3346116688, c);
}

/**
* CreateSampleTournament creates and opens a tournament to discover the app
* with: two categories of fictional pairs from fictional clubs, pools then a
* bracket, everybody checked in, the draws confirmed, two rounds played and
* the courts busy, a score pending. It is saved in the tournaments folder
* under name (translated by the frontend), numbered when the name is taken,
* and returns its path.
*/
export function CreateSampleTournament(name: string): $CancellablePromise<string> {
return $Call.ByID(30353995, name);
}

/**
* CreateTournament asks where to save a new tournament, creates it and opens
* it. It returns the path, or "" when the user cancelled the dialog.
Expand Down
8 changes: 8 additions & 0 deletions src/apps/desktop/frontend/src/app/core/guide.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { guideUrl } from './guide';

describe('guide', () => {
it('points to the guide in the UI language', () => {
expect(guideUrl('fr')).toBe('https://openshuttle.app/guide/');
expect(guideUrl('en')).toBe('https://openshuttle.app/en/guide/');
});
});
11 changes: 11 additions & 0 deletions src/apps/desktop/frontend/src/app/core/guide.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Browser } from '@wailsio/runtime';

/** The guide "a tournament in 10 steps" on the website, in the UI language. */
export function guideUrl(lang: string): string {
return lang === 'fr' ? 'https://openshuttle.app/guide/' : 'https://openshuttle.app/en/guide/';
}

/** Opens the guide in the system browser: the app itself never needs the network. */
export function openGuide(lang: string): Promise<void> {
return Browser.OpenURL(guideUrl(lang));
}
5 changes: 5 additions & 0 deletions src/apps/desktop/frontend/src/app/core/tournament.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ export class TournamentStore {
return this.run(async () => (await Backend.CreateTournament(name)) !== '');
}

/** Creates and opens the sample tournament; false when it failed. */
createSample(name: string): Promise<boolean> {
return this.run(async () => (await Backend.CreateSampleTournament(name)) !== '');
}

openDialog(): Promise<boolean> {
return this.run(async () => (await Backend.OpenTournamentDialog()) !== '');
}
Expand Down
10 changes: 9 additions & 1 deletion src/apps/desktop/frontend/src/app/home/home.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<h1>{{ t('home.recentTournaments') }}</h1>
<span class="page-sub">{{ t('home.lead') }}</span>
</div>
<button type="button" class="btn" [disabled]="preparingSample()" (click)="trySample()">{{ t('home.trySample') }}</button>
<button type="button" class="btn" (click)="openDialog()">{{ t('home.openFile') }}</button>
<button type="button" class="btn btn-primary" (click)="startCreate()">{{ t('home.newTournament') }}</button>
</div>
Expand All @@ -29,7 +30,14 @@ <h1>{{ t('home.recentTournaments') }}</h1>
}

@if (tournament.recent().length === 0) {
<div class="card empty">{{ t('home.noTournament') }}</div>
<div class="card first-run">
<span>{{ t('home.noTournament') }}</span>
<span class="hint">{{ t('home.sampleHint') }}</span>
<div class="first-run-actions">
<button type="button" class="btn btn-primary" [disabled]="preparingSample()" (click)="trySample()">{{ t('home.trySample') }}</button>
<button type="button" class="btn" (click)="guide()">{{ t('home.readGuide') }}</button>
</div>
</div>
} @else {
<div class="card list">
@for (r of tournament.recent(); track r.path) {
Expand Down
18 changes: 12 additions & 6 deletions src/apps/desktop/frontend/src/app/home/home.scss
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,6 @@
}
}

.empty {
padding: 28px 18px;
font-size: 15px;
color: var(--color-text-muted);
}

.list {
overflow: hidden;
}
Expand Down Expand Up @@ -98,3 +92,15 @@
display: block;
margin-top: 6px;
}

// First launch: the sample tournament and the guide as ways in.
.first-run {
@include stack(10px);
padding: 24px;
color: var(--color-text-muted);
}

.first-run-actions {
@include row(10px, true);
margin-top: 6px;
}
16 changes: 16 additions & 0 deletions src/apps/desktop/frontend/src/app/home/home.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,20 @@ describe('Home', () => {
go.on('TournamentService.DuplicateTournament', '');
await clickAll(await render(Home));
});

it('builds the sample tournament and opens its control desk', async () => {
go.on('TournamentService.CreateSampleTournament', '/home/julie/Tournoi d\'exemple.openshuttle');
const f = await render(Home);
await click(f, 'Try with a sample tournament');
expect(go.argsOf('TournamentService.CreateSampleTournament')).toEqual([['Sample tournament']]);
});

it('offers the sample tournament and the guide on first launch', async () => {
go.on('TournamentService.ListRecentTournaments', []);
const f = await render(Home, { lang: 'fr' });
expect(text(f)).toContain("Essayer avec un tournoi d'exemple");
await click(f, 'Lire le guide');
await click(f, "Essayer avec un tournoi d'exemple");
expect(go.argsOf('TournamentService.CreateSampleTournament')).toEqual([["Tournoi d'exemple"]]);
});
});
17 changes: 17 additions & 0 deletions src/apps/desktop/frontend/src/app/home/home.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Component, ElementRef, computed, effect, inject, signal, viewChild } fr
import { Router } from '@angular/router';
import { TranslocoDirective, TranslocoService } from '@jsverse/transloco';

import { openGuide } from '../core/guide';
import { LanguageService } from '../core/language.service';
import { AppSettings } from '../core/settings.service';
import { TournamentStore } from '../core/tournament.service';
Expand Down Expand Up @@ -46,6 +47,22 @@ export class Home {
}
}

/** Building the sample tournament takes a moment: the button waits. */
protected readonly preparingSample = signal(false);

protected async trySample(): Promise<void> {
this.preparingSample.set(true);
const done = await this.tournament.createSample(this.transloco.translate('home.sampleName'));
this.preparingSample.set(false);
if (done) {
void this.router.navigateByUrl('/tournament/control-desk');
}
}

protected guide(): void {
void openGuide(this.language.active);
}

protected async openDialog(): Promise<void> {
if (await this.tournament.openDialog()) {
void this.router.navigateByUrl('/tournament');
Expand Down
Loading
Loading