From 3d4502e9ac37bcb15d57978dd6fb701ca2574fbe Mon Sep 17 00:00:00 2001 From: Daniel Bimschas Date: Sat, 31 Jan 2026 09:27:37 +0100 Subject: [PATCH 01/17] refactor: replace exception-based error handling with neverthrow Result types Domain services (HeatService, generateBracketForDivision) now return Promise> instead of throwing, making error paths explicit and type-safe. API handlers use unwrapOrThrow() at the oRPC boundary and result.isErr() checks in legacy REST routes. - Add neverthrow dependency and src/domain/result.ts re-export - Convert 6 HeatService methods and generateBracketForDivision to Result - Add HeatServiceError/BracketServiceError union types, TooManyParticipantsError - Create unwrapOrThrow() utility for oRPC handlers - Simplify domainErrorMapper middleware to infrastructure-error safety net - Fix heat-repository to return early/empty instead of throwing generic errors - Map HeatDoesNotExistError and ScoreNotFoundError to 404 (was 400) - Update all domain, integration, and middleware tests Co-Authored-By: Claude Opus 4.5 --- AGENTS.md | 13 +- __tests__/api/heat-routes.test.ts | 4 +- .../api/middleware/error-handling.test.ts | 12 +- .../domain/bracket/bracket-service.test.ts | 106 ++++---- __tests__/domain/heat/heat-service.test.ts | 233 ++++++++++++------ .../integration/bracket-generation.test.ts | 82 +++--- bun.lock | 3 + package.json | 1 + src/api/middleware/error-handling.ts | 7 +- src/api/orpc/domain-error-mapper.ts | 32 ++- src/api/orpc/routes/brackets.ts | 4 +- src/api/orpc/routes/heats.ts | 4 +- src/api/orpc/routes/scores.ts | 19 +- src/api/orpc/unwrap-result.ts | 20 ++ src/api/routes/bracket-routes.ts | 23 +- src/api/routes/heat-routes.ts | 57 ++++- src/domain/bracket/bracket-service.ts | 25 +- src/domain/heat/errors.ts | 9 +- src/domain/heat/heat-service.ts | 112 ++++----- src/domain/heat/index.ts | 5 +- src/domain/result.ts | 1 + .../repositories/heat-repository.ts | 4 +- 22 files changed, 488 insertions(+), 288 deletions(-) create mode 100644 src/api/orpc/unwrap-result.ts create mode 100644 src/domain/result.ts diff --git a/AGENTS.md b/AGENTS.md index 157884d..c71a7e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,11 +93,14 @@ bun run db:seed # Seed test data - **Private helpers**: camelCase with descriptive names ### Error Handling -- **Custom error classes** extending `Error` (e.g., `HeatDoesNotExistError`) -- **Type guards** for domain errors: `isDomainError(error)` -- **Error middleware** wrapper: `withErrorHandling(async () => { ... })` -- **Domain errors** map to HTTP status codes (400/404/500) -- **Always log errors** with context +- **`neverthrow` Result types**: Domain services return `Promise>` instead of throwing +- **Custom error classes** extending `Error` (e.g., `HeatDoesNotExistError`) — used as the `E` in `Result` +- **`unwrapOrThrow(result)`**: API boundary utility that converts `err(domainError)` → `throw ORPCError` +- **`domainErrorMapper` middleware**: Safety net for unexpected infrastructure errors → 500 +- **`getDomainErrorStatusCode(error)`**: Maps domain errors to HTTP status codes for legacy REST routes +- **Error union types**: `HeatServiceError`, `BracketServiceError` for type-safe error handling +- **Pattern**: Domain services validate and return `err(...)`, API handlers call `unwrapOrThrow()` or check `result.isErr()` +- **Transactions**: Call `unwrapOrThrow(result)` inside `db.transaction()` so domain errors trigger rollback ### Domain-Driven Design Patterns - **Repository pattern**: Interfaces in `domain/`, implementations in `infrastructure/` diff --git a/__tests__/api/heat-routes.test.ts b/__tests__/api/heat-routes.test.ts index bd8e028..7624b7e 100644 --- a/__tests__/api/heat-routes.test.ts +++ b/__tests__/api/heat-routes.test.ts @@ -379,7 +379,7 @@ describe("Heat API Routes", () => { expect(data.error).toContain("Validation error"); }); - it("should return 400 if heat does not exist", async () => { + it("should return 404 if heat does not exist", async () => { const heatId = getUniqueHeatId("nonexistent"); const request = createWaveScoreRequest(heatId, { scoreUUID: "wave-1", @@ -388,7 +388,7 @@ describe("Heat API Routes", () => { }); const response = await handleAddWaveScore(request); - expect(response.status).toBe(400); + expect(response.status).toBe(404); const data = (await response.json()) as { error: string }; expect(data.error).toContain("does not exist"); diff --git a/__tests__/api/middleware/error-handling.test.ts b/__tests__/api/middleware/error-handling.test.ts index e2615c9..ab52df4 100644 --- a/__tests__/api/middleware/error-handling.test.ts +++ b/__tests__/api/middleware/error-handling.test.ts @@ -19,6 +19,7 @@ import { RiderAlreadyInHeatError, RiderNotInHeatError, ScoreMustBeInValidRangeError, + ScoreNotFoundError, ScoreUUIDAlreadyExistsError, } from "../../../src/domain/heat/errors.js"; @@ -78,9 +79,18 @@ describe("error-handling middleware", () => { expect(getDomainErrorStatusCode(error)).toBe(404); }); + it("should return 404 for HeatDoesNotExistError", () => { + const error = new HeatDoesNotExistError("test-heat"); + expect(getDomainErrorStatusCode(error)).toBe(404); + }); + + it("should return 404 for ScoreNotFoundError", () => { + const error = new ScoreNotFoundError("test-score"); + expect(getDomainErrorStatusCode(error)).toBe(404); + }); + it("should return 400 for heat domain errors", () => { expect(getDomainErrorStatusCode(new HeatAlreadyExistsError("test"))).toBe(400); - expect(getDomainErrorStatusCode(new HeatDoesNotExistError("test"))).toBe(400); expect(getDomainErrorStatusCode(new NonUniqueRiderIdsError())).toBe(400); expect(getDomainErrorStatusCode(new RiderNotInHeatError("test", "test"))).toBe(400); expect(getDomainErrorStatusCode(new ScoreMustBeInValidRangeError(15))).toBe(400); diff --git a/__tests__/domain/bracket/bracket-service.test.ts b/__tests__/domain/bracket/bracket-service.test.ts index 9c9f3d6..db95f59 100644 --- a/__tests__/domain/bracket/bracket-service.test.ts +++ b/__tests__/domain/bracket/bracket-service.test.ts @@ -4,6 +4,7 @@ import { DivisionNotFoundError, generateBracketForDivision, InsufficientParticipantsError, + TooManyParticipantsError, } from "../../../src/domain/bracket/bracket-service.js"; import type { Bracket, Division } from "../../../src/domain/contest/types.js"; @@ -11,7 +12,7 @@ describe("generateBracketForDivision", () => { // Note: Tests that create real heats in the event store are skipped // and covered by integration tests instead - it("should throw DivisionNotFoundError if division does not exist", async () => { + it("should return err(DivisionNotFoundError) if division does not exist", async () => { const mockDivisionRepo = { getDivisionById: mock(() => Promise.resolve(null)), }; @@ -19,19 +20,19 @@ describe("generateBracketForDivision", () => { const mockParticipantRepo = {}; const mockHeatRepo = {}; - await expect( - generateBracketForDivision("non-existent-division", { - divisionRepository: mockDivisionRepo as any, - bracketRepository: mockBracketRepo as any, - divisionParticipantRepository: mockParticipantRepo as any, - heatRepository: mockHeatRepo as any, - }) - ).rejects.toThrow(DivisionNotFoundError); + const result = await generateBracketForDivision("non-existent-division", { + divisionRepository: mockDivisionRepo as any, + bracketRepository: mockBracketRepo as any, + divisionParticipantRepository: mockParticipantRepo as any, + heatRepository: mockHeatRepo as any, + }); + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toBeInstanceOf(DivisionNotFoundError); expect(mockDivisionRepo.getDivisionById).toHaveBeenCalledWith("non-existent-division"); }); - it("should throw InsufficientParticipantsError if division has less than 2 participants", async () => { + it("should return err(InsufficientParticipantsError) if division has less than 2 participants", async () => { const mockDivision: Division = { id: "division-1", contestId: "contest-1", @@ -52,19 +53,19 @@ describe("generateBracketForDivision", () => { }; const mockHeatRepo = {}; - await expect( - generateBracketForDivision("division-1", { - divisionRepository: mockDivisionRepo as any, - bracketRepository: mockBracketRepo as any, - divisionParticipantRepository: mockParticipantRepo as any, - heatRepository: mockHeatRepo as any, - }) - ).rejects.toThrow(InsufficientParticipantsError); + const result = await generateBracketForDivision("division-1", { + divisionRepository: mockDivisionRepo as any, + bracketRepository: mockBracketRepo as any, + divisionParticipantRepository: mockParticipantRepo as any, + heatRepository: mockHeatRepo as any, + }); + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toBeInstanceOf(InsufficientParticipantsError); expect(mockParticipantRepo.getRiderIdsByDivisionId).toHaveBeenCalledWith("division-1"); }); - it("should throw InsufficientParticipantsError with correct message for 1 participant", async () => { + it("should return err(InsufficientParticipantsError) with correct message for 1 participant", async () => { const mockDivision: Division = { id: "division-1", contestId: "contest-1", @@ -85,21 +86,20 @@ describe("generateBracketForDivision", () => { }; const mockHeatRepo = {}; - try { - await generateBracketForDivision("division-1", { - divisionRepository: mockDivisionRepo as any, - bracketRepository: mockBracketRepo as any, - divisionParticipantRepository: mockParticipantRepo as any, - heatRepository: mockHeatRepo as any, - }); - expect.unreachable("Should have thrown error"); - } catch (error) { - expect(error).toBeInstanceOf(InsufficientParticipantsError); - expect((error as Error).message).toBe("Division has 1 participants, need at least 2"); - } + const result = await generateBracketForDivision("division-1", { + divisionRepository: mockDivisionRepo as any, + bracketRepository: mockBracketRepo as any, + divisionParticipantRepository: mockParticipantRepo as any, + heatRepository: mockHeatRepo as any, + }); + + expect(result.isErr()).toBe(true); + const error = result._unsafeUnwrapErr(); + expect(error).toBeInstanceOf(InsufficientParticipantsError); + expect(error.message).toBe("Division has 1 participants, need at least 2"); }); - it("should throw BracketAlreadyExistsError if bracket already exists for division", async () => { + it("should return err(BracketAlreadyExistsError) if bracket already exists for division", async () => { const mockDivision: Division = { id: "division-1", contestId: "contest-1", @@ -128,19 +128,19 @@ describe("generateBracketForDivision", () => { const mockParticipantRepo = {}; const mockHeatRepo = {}; - await expect( - generateBracketForDivision("division-1", { - divisionRepository: mockDivisionRepo as any, - bracketRepository: mockBracketRepo as any, - divisionParticipantRepository: mockParticipantRepo as any, - heatRepository: mockHeatRepo as any, - }) - ).rejects.toThrow(BracketAlreadyExistsError); + const result = await generateBracketForDivision("division-1", { + divisionRepository: mockDivisionRepo as any, + bracketRepository: mockBracketRepo as any, + divisionParticipantRepository: mockParticipantRepo as any, + heatRepository: mockHeatRepo as any, + }); + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toBeInstanceOf(BracketAlreadyExistsError); expect(mockBracketRepo.getBracketByDivisionId).toHaveBeenCalledWith("division-1"); }); - it("should throw error if division has more than 64 participants", async () => { + it("should return err(TooManyParticipantsError) if division has more than 64 participants", async () => { const mockDivision: Division = { id: "division-1", contestId: "contest-1", @@ -163,14 +163,17 @@ describe("generateBracketForDivision", () => { }; const mockHeatRepo = {}; - await expect( - generateBracketForDivision("division-1", { - divisionRepository: mockDivisionRepo as any, - bracketRepository: mockBracketRepo as any, - divisionParticipantRepository: mockParticipantRepo as any, - heatRepository: mockHeatRepo as any, - }) - ).rejects.toThrow("Division has 65 participants, maximum is 64"); + const result = await generateBracketForDivision("division-1", { + divisionRepository: mockDivisionRepo as any, + bracketRepository: mockBracketRepo as any, + divisionParticipantRepository: mockParticipantRepo as any, + heatRepository: mockHeatRepo as any, + }); + + expect(result.isErr()).toBe(true); + const error = result._unsafeUnwrapErr(); + expect(error).toBeInstanceOf(TooManyParticipantsError); + expect(error.message).toBe("Division has 65 participants, maximum is 64"); }); // These tests require event store and are covered by integration tests @@ -211,14 +214,15 @@ describe("generateBracketForDivision", () => { completeHeat: mock(() => Promise.resolve()), }; - const bracketId = await generateBracketForDivision(`division-${testId}`, { + const result = await generateBracketForDivision(`division-${testId}`, { divisionRepository: mockDivisionRepo as any, bracketRepository: mockBracketRepo as any, divisionParticipantRepository: mockParticipantRepo as any, heatRepository: mockHeatRepo as any, }); - expect(bracketId).toBe(`bracket-${testId}`); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toBe(`bracket-${testId}`); expect(mockBracketRepo.createBracket).toHaveBeenCalledWith({ divisionId: `division-${testId}`, name: "Single Elimination", diff --git a/__tests__/domain/heat/heat-service.test.ts b/__tests__/domain/heat/heat-service.test.ts index 78bf688..92f8ed5 100644 --- a/__tests__/domain/heat/heat-service.test.ts +++ b/__tests__/domain/heat/heat-service.test.ts @@ -109,8 +109,16 @@ describe("HeatService", () => { (heatRepo.getHeatByHeatId as ReturnType).mockResolvedValue(createMockHeat()); (scoreRepo.getScoreByUuid as ReturnType).mockResolvedValue(null); - await service.addWaveScore("heat-1", "new-score-uuid", "rider-1", "judge-1", 7.5, new Date()); + const result = await service.addWaveScore( + "heat-1", + "new-score-uuid", + "rider-1", + "judge-1", + 7.5, + new Date() + ); + expect(result.isOk()).toBe(true); expect(scoreRepo.insertScore).toHaveBeenCalledTimes(1); const insertCall = (scoreRepo.insertScore as ReturnType).mock.calls[0]; expect(insertCall[0]).toMatchObject({ @@ -127,8 +135,16 @@ describe("HeatService", () => { (heatRepo.getHeatByHeatId as ReturnType).mockResolvedValue(createMockHeat()); (scoreRepo.getScoreByUuid as ReturnType).mockResolvedValue(null); - await service.addWaveScore("heat-1", "score-zero", "rider-1", "judge-1", 0, new Date()); + const result = await service.addWaveScore( + "heat-1", + "score-zero", + "rider-1", + "judge-1", + 0, + new Date() + ); + expect(result.isOk()).toBe(true); expect(scoreRepo.insertScore).toHaveBeenCalledTimes(1); const insertCall = (scoreRepo.insertScore as ReturnType).mock.calls[0]; expect(insertCall[0].scoreValue).toBe(0); @@ -138,66 +154,122 @@ describe("HeatService", () => { (heatRepo.getHeatByHeatId as ReturnType).mockResolvedValue(createMockHeat()); (scoreRepo.getScoreByUuid as ReturnType).mockResolvedValue(null); - await service.addWaveScore("heat-1", "score-ten", "rider-1", "judge-1", 10, new Date()); + const result = await service.addWaveScore( + "heat-1", + "score-ten", + "rider-1", + "judge-1", + 10, + new Date() + ); + expect(result.isOk()).toBe(true); expect(scoreRepo.insertScore).toHaveBeenCalledTimes(1); const insertCall = (scoreRepo.insertScore as ReturnType).mock.calls[0]; expect(insertCall[0].scoreValue).toBe(10); }); - it("should throw HeatDoesNotExistError when heat not found", async () => { + it("should return err(HeatDoesNotExistError) when heat not found", async () => { (heatRepo.getHeatByHeatId as ReturnType).mockResolvedValue(null); - await expect( - service.addWaveScore("nonexistent-heat", "score-uuid", "rider-1", "judge-1", 5, new Date()) - ).rejects.toThrow(HeatDoesNotExistError); + const result = await service.addWaveScore( + "nonexistent-heat", + "score-uuid", + "rider-1", + "judge-1", + 5, + new Date() + ); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toBeInstanceOf(HeatDoesNotExistError); }); - it("should throw HeatCompletedError when heat is completed", async () => { + it("should return err(HeatCompletedError) when heat is completed", async () => { (heatRepo.getHeatByHeatId as ReturnType).mockResolvedValue( createMockHeat({ completedAt: new Date("2025-06-01") }) ); - await expect( - service.addWaveScore("heat-1", "score-uuid", "rider-1", "judge-1", 5, new Date()) - ).rejects.toThrow(HeatCompletedError); + const result = await service.addWaveScore( + "heat-1", + "score-uuid", + "rider-1", + "judge-1", + 5, + new Date() + ); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toBeInstanceOf(HeatCompletedError); }); - it("should throw RiderNotInHeatError when rider is not in heat", async () => { + it("should return err(RiderNotInHeatError) when rider is not in heat", async () => { (heatRepo.getHeatByHeatId as ReturnType).mockResolvedValue( createMockHeat({ riderIds: ["rider-1", "rider-2"] }) ); - await expect( - service.addWaveScore("heat-1", "score-uuid", "rider-999", "judge-1", 5, new Date()) - ).rejects.toThrow(RiderNotInHeatError); + const result = await service.addWaveScore( + "heat-1", + "score-uuid", + "rider-999", + "judge-1", + 5, + new Date() + ); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toBeInstanceOf(RiderNotInHeatError); }); - it("should throw ScoreMustBeInValidRangeError for score > 10", async () => { + it("should return err(ScoreMustBeInValidRangeError) for score > 10", async () => { (heatRepo.getHeatByHeatId as ReturnType).mockResolvedValue(createMockHeat()); - await expect( - service.addWaveScore("heat-1", "score-uuid", "rider-1", "judge-1", 10.1, new Date()) - ).rejects.toThrow(ScoreMustBeInValidRangeError); + const result = await service.addWaveScore( + "heat-1", + "score-uuid", + "rider-1", + "judge-1", + 10.1, + new Date() + ); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ScoreMustBeInValidRangeError); }); - it("should throw ScoreMustBeInValidRangeError for score < 0", async () => { + it("should return err(ScoreMustBeInValidRangeError) for score < 0", async () => { (heatRepo.getHeatByHeatId as ReturnType).mockResolvedValue(createMockHeat()); - await expect( - service.addWaveScore("heat-1", "score-uuid", "rider-1", "judge-1", -0.1, new Date()) - ).rejects.toThrow(ScoreMustBeInValidRangeError); + const result = await service.addWaveScore( + "heat-1", + "score-uuid", + "rider-1", + "judge-1", + -0.1, + new Date() + ); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ScoreMustBeInValidRangeError); }); - it("should throw ScoreUUIDAlreadyExistsError for duplicate UUID", async () => { + it("should return err(ScoreUUIDAlreadyExistsError) for duplicate UUID", async () => { (heatRepo.getHeatByHeatId as ReturnType).mockResolvedValue(createMockHeat()); (scoreRepo.getScoreByUuid as ReturnType).mockResolvedValue( createMockScore({ scoreUuid: "duplicate-uuid" }) ); - await expect( - service.addWaveScore("heat-1", "duplicate-uuid", "rider-1", "judge-1", 5, new Date()) - ).rejects.toThrow(ScoreUUIDAlreadyExistsError); + const result = await service.addWaveScore( + "heat-1", + "duplicate-uuid", + "rider-1", + "judge-1", + 5, + new Date() + ); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ScoreUUIDAlreadyExistsError); }); }); @@ -209,7 +281,7 @@ describe("HeatService", () => { (heatRepo.getHeatByHeatId as ReturnType).mockResolvedValue(createMockHeat()); (scoreRepo.getScoreByUuid as ReturnType).mockResolvedValue(null); - await service.addJumpScore( + const result = await service.addJumpScore( "heat-1", "jump-score-uuid", "rider-1", @@ -220,6 +292,7 @@ describe("HeatService", () => { new Date() ); + expect(result.isOk()).toBe(true); expect(scoreRepo.insertScore).toHaveBeenCalledTimes(1); const insertCall = (scoreRepo.insertScore as ReturnType).mock.calls[0]; expect(insertCall[0]).toMatchObject({ @@ -238,7 +311,7 @@ describe("HeatService", () => { (heatRepo.getHeatByHeatId as ReturnType).mockResolvedValue(createMockHeat()); (scoreRepo.getScoreByUuid as ReturnType).mockResolvedValue(null); - await service.addJumpScore( + const result = await service.addJumpScore( "heat-1", "jump-score-uuid-2", "rider-2", @@ -249,6 +322,7 @@ describe("HeatService", () => { new Date() ); + expect(result.isOk()).toBe(true); expect(scoreRepo.insertScore).toHaveBeenCalledTimes(1); const insertCall = (scoreRepo.insertScore as ReturnType).mock.calls[0]; expect(insertCall[0]).toMatchObject({ @@ -258,21 +332,22 @@ describe("HeatService", () => { }); }); - it("should throw HeatDoesNotExistError when heat not found", async () => { + it("should return err(HeatDoesNotExistError) when heat not found", async () => { (heatRepo.getHeatByHeatId as ReturnType).mockResolvedValue(null); - await expect( - service.addJumpScore( - "nonexistent-heat", - "score-uuid", - "rider-1", - "judge-1", - 5, - "forward", - [], - new Date() - ) - ).rejects.toThrow(HeatDoesNotExistError); + const result = await service.addJumpScore( + "nonexistent-heat", + "score-uuid", + "rider-1", + "judge-1", + 5, + "forward", + [], + new Date() + ); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toBeInstanceOf(HeatDoesNotExistError); }); }); @@ -289,34 +364,37 @@ describe("HeatService", () => { (scoreRepo.getScoreByUuid as ReturnType).mockResolvedValue(existingScore); (heatRepo.getHeatByHeatId as ReturnType).mockResolvedValue(createMockHeat()); - await service.updateWaveScore("wave-uuid", 9.0); + const result = await service.updateWaveScore("wave-uuid", 9.0); + expect(result.isOk()).toBe(true); expect(scoreRepo.updateScore).toHaveBeenCalledWith("wave-uuid", { scoreValue: 9.0, }); }); - it("should throw when score not found", async () => { + it("should return err when score not found", async () => { (scoreRepo.getScoreByUuid as ReturnType).mockResolvedValue(null); - await expect(service.updateWaveScore("missing-uuid", 5)).rejects.toBeInstanceOf( - ScoreNotFoundError - ); + const result = await service.updateWaveScore("missing-uuid", 5); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ScoreNotFoundError); }); - it("should throw when score is not a wave score", async () => { + it("should return err when score is not a wave score", async () => { const jumpScore = createMockScore({ scoreUuid: "jump-uuid", type: "jump", }); (scoreRepo.getScoreByUuid as ReturnType).mockResolvedValue(jumpScore); - await expect(service.updateWaveScore("jump-uuid", 5)).rejects.toBeInstanceOf( - ScoreTypeMismatchError - ); + const result = await service.updateWaveScore("jump-uuid", 5); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ScoreTypeMismatchError); }); - it("should throw HeatCompletedError when heat is completed", async () => { + it("should return err(HeatCompletedError) when heat is completed", async () => { const existingScore = createMockScore({ scoreUuid: "wave-uuid", type: "wave", @@ -327,10 +405,13 @@ describe("HeatService", () => { createMockHeat({ completedAt: new Date("2025-06-01") }) ); - await expect(service.updateWaveScore("wave-uuid", 8)).rejects.toThrow(HeatCompletedError); + const result = await service.updateWaveScore("wave-uuid", 8); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toBeInstanceOf(HeatCompletedError); }); - it("should throw ScoreMustBeInValidRangeError for invalid score", async () => { + it("should return err(ScoreMustBeInValidRangeError) for invalid score", async () => { const existingScore = createMockScore({ scoreUuid: "wave-uuid", type: "wave", @@ -339,13 +420,13 @@ describe("HeatService", () => { (scoreRepo.getScoreByUuid as ReturnType).mockResolvedValue(existingScore); (heatRepo.getHeatByHeatId as ReturnType).mockResolvedValue(createMockHeat()); - await expect(service.updateWaveScore("wave-uuid", 11)).rejects.toThrow( - ScoreMustBeInValidRangeError - ); + const result1 = await service.updateWaveScore("wave-uuid", 11); + expect(result1.isErr()).toBe(true); + expect(result1._unsafeUnwrapErr()).toBeInstanceOf(ScoreMustBeInValidRangeError); - await expect(service.updateWaveScore("wave-uuid", -1)).rejects.toThrow( - ScoreMustBeInValidRangeError - ); + const result2 = await service.updateWaveScore("wave-uuid", -1); + expect(result2.isErr()).toBe(true); + expect(result2._unsafeUnwrapErr()).toBeInstanceOf(ScoreMustBeInValidRangeError); }); }); @@ -364,8 +445,9 @@ describe("HeatService", () => { (scoreRepo.getScoreByUuid as ReturnType).mockResolvedValue(existingScore); (heatRepo.getHeatByHeatId as ReturnType).mockResolvedValue(createMockHeat()); - await service.updateJumpScore("jump-uuid", 9.0, "backloop", ["grabbed"]); + const result = await service.updateJumpScore("jump-uuid", 9.0, "backloop", ["grabbed"]); + expect(result.isOk()).toBe(true); expect(scoreRepo.updateScore).toHaveBeenCalledWith("jump-uuid", { scoreValue: 9.0, jumpType: "backloop", @@ -384,8 +466,9 @@ describe("HeatService", () => { (scoreRepo.getScoreByUuid as ReturnType).mockResolvedValue(existingScore); (heatRepo.getHeatByHeatId as ReturnType).mockResolvedValue(createMockHeat()); - await service.updateJumpScore("jump-uuid", 8.0); + const result = await service.updateJumpScore("jump-uuid", 8.0); + expect(result.isOk()).toBe(true); expect(scoreRepo.updateScore).toHaveBeenCalledWith("jump-uuid", { scoreValue: 8.0, jumpType: undefined, @@ -393,16 +476,17 @@ describe("HeatService", () => { }); }); - it("should throw when score is not a jump score", async () => { + it("should return err when score is not a jump score", async () => { const waveScore = createMockScore({ scoreUuid: "wave-uuid", type: "wave", }); (scoreRepo.getScoreByUuid as ReturnType).mockResolvedValue(waveScore); - await expect(service.updateJumpScore("wave-uuid", 5, "forward", [])).rejects.toBeInstanceOf( - ScoreTypeMismatchError - ); + const result = await service.updateJumpScore("wave-uuid", 5, "forward", []); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ScoreTypeMismatchError); }); }); @@ -418,18 +502,22 @@ describe("HeatService", () => { (scoreRepo.getScoreByUuid as ReturnType).mockResolvedValue(existingScore); (heatRepo.getHeatByHeatId as ReturnType).mockResolvedValue(createMockHeat()); - await service.deleteScore("delete-me"); + const result = await service.deleteScore("delete-me"); + expect(result.isOk()).toBe(true); expect(scoreRepo.deleteScore).toHaveBeenCalledWith("delete-me"); }); - it("should throw when score not found", async () => { + it("should return err when score not found", async () => { (scoreRepo.getScoreByUuid as ReturnType).mockResolvedValue(null); - await expect(service.deleteScore("missing-uuid")).rejects.toBeInstanceOf(ScoreNotFoundError); + const result = await service.deleteScore("missing-uuid"); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ScoreNotFoundError); }); - it("should throw HeatCompletedError when heat is completed", async () => { + it("should return err(HeatCompletedError) when heat is completed", async () => { const existingScore = createMockScore({ scoreUuid: "score-in-completed", heatId: "heat-1", @@ -439,7 +527,10 @@ describe("HeatService", () => { createMockHeat({ completedAt: new Date("2025-06-01") }) ); - await expect(service.deleteScore("score-in-completed")).rejects.toThrow(HeatCompletedError); + const result = await service.deleteScore("score-in-completed"); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toBeInstanceOf(HeatCompletedError); }); }); diff --git a/__tests__/integration/bracket-generation.test.ts b/__tests__/integration/bracket-generation.test.ts index f81ba1d..bbfa4d0 100644 --- a/__tests__/integration/bracket-generation.test.ts +++ b/__tests__/integration/bracket-generation.test.ts @@ -1,5 +1,10 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; -import { generateBracketForDivision } from "../../src/domain/bracket/bracket-service.js"; +import { + BracketAlreadyExistsError, + DivisionNotFoundError, + generateBracketForDivision, + InsufficientParticipantsError, +} from "../../src/domain/bracket/bracket-service.js"; import { createBracketRepository, createContestRepository, @@ -80,7 +85,7 @@ describe("Bracket Generation Integration Tests", () => { } // Execute: Generate bracket - const bracketId = await generateBracketForDivision(division.id, { + const result = await generateBracketForDivision(division.id, { divisionRepository: divisionRepo, bracketRepository: bracketRepo, divisionParticipantRepository: participantRepo, @@ -88,6 +93,8 @@ describe("Bracket Generation Integration Tests", () => { }); // Verify: Check bracket structure + expect(result.isOk()).toBe(true); + const bracketId = result._unsafeUnwrap(); expect(bracketId).toBeDefined(); const bracket = await bracketRepo.getBracketById(bracketId); @@ -195,13 +202,16 @@ describe("Bracket Generation Integration Tests", () => { } // Execute: Generate bracket - const bracketId = await generateBracketForDivision(division.id, { + const result = await generateBracketForDivision(division.id, { divisionRepository: divisionRepo, bracketRepository: bracketRepo, divisionParticipantRepository: participantRepo, heatRepository: heatRepo, }); + expect(result.isOk()).toBe(true); + const bracketId = result._unsafeUnwrap(); + // Verify: Check bracket structure const bracketWithHeats = await bracketRepo.getBracketWithHeats(bracketId); expect(bracketWithHeats).not.toBeNull(); @@ -265,19 +275,22 @@ describe("Bracket Generation Integration Tests", () => { }); describe("error cases", () => { - it("should throw error if division does not exist", async () => { + it("should return err if division does not exist", async () => { const nonExistentDivisionId = "00000000-0000-0000-0000-000000000000"; - await expect( - generateBracketForDivision(nonExistentDivisionId, { - divisionRepository: divisionRepo, - bracketRepository: bracketRepo, - divisionParticipantRepository: participantRepo, - heatRepository: heatRepo, - }) - ).rejects.toThrow(`Division ${nonExistentDivisionId} not found`); + const result = await generateBracketForDivision(nonExistentDivisionId, { + divisionRepository: divisionRepo, + bracketRepository: bracketRepo, + divisionParticipantRepository: participantRepo, + heatRepository: heatRepo, + }); + + expect(result.isErr()).toBe(true); + const error = result._unsafeUnwrapErr(); + expect(error).toBeInstanceOf(DivisionNotFoundError); + expect(error.message).toContain(nonExistentDivisionId); }); - it("should throw error if division has insufficient participants", async () => { + it("should return err if division has insufficient participants", async () => { const season = await seasonRepo.createSeason({ name: "2026 Season", year: 2026, @@ -308,17 +321,20 @@ describe("Bracket Generation Integration Tests", () => { }); await participantRepo.addParticipant(division.id, rider.id); - await expect( - generateBracketForDivision(division.id, { - divisionRepository: divisionRepo, - bracketRepository: bracketRepo, - divisionParticipantRepository: participantRepo, - heatRepository: heatRepo, - }) - ).rejects.toThrow("Division has 1 participants, need at least 2"); + const result = await generateBracketForDivision(division.id, { + divisionRepository: divisionRepo, + bracketRepository: bracketRepo, + divisionParticipantRepository: participantRepo, + heatRepository: heatRepo, + }); + + expect(result.isErr()).toBe(true); + const error = result._unsafeUnwrapErr(); + expect(error).toBeInstanceOf(InsufficientParticipantsError); + expect(error.message).toBe("Division has 1 participants, need at least 2"); }); - it("should throw error if bracket already exists for division", async () => { + it("should return err if bracket already exists for division", async () => { const season = await seasonRepo.createSeason({ name: "2026 Season", year: 2026, @@ -352,22 +368,26 @@ describe("Bracket Generation Integration Tests", () => { } // Generate first bracket - await generateBracketForDivision(division.id, { + const firstResult = await generateBracketForDivision(division.id, { divisionRepository: divisionRepo, bracketRepository: bracketRepo, divisionParticipantRepository: participantRepo, heatRepository: heatRepo, }); + expect(firstResult.isOk()).toBe(true); // Try to generate second bracket - await expect( - generateBracketForDivision(division.id, { - divisionRepository: divisionRepo, - bracketRepository: bracketRepo, - divisionParticipantRepository: participantRepo, - heatRepository: heatRepo, - }) - ).rejects.toThrow("Bracket already exists for division"); + const secondResult = await generateBracketForDivision(division.id, { + divisionRepository: divisionRepo, + bracketRepository: bracketRepo, + divisionParticipantRepository: participantRepo, + heatRepository: heatRepo, + }); + + expect(secondResult.isErr()).toBe(true); + const error = secondResult._unsafeUnwrapErr(); + expect(error).toBeInstanceOf(BracketAlreadyExistsError); + expect(error.message).toContain("Bracket already exists for division"); }); }); }); diff --git a/bun.lock b/bun.lock index 30b977f..57e22dc 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.37.0", + "neverthrow": "^8.2.0", "pg": "^8.16.3", "solid-js": "^1.9.3", "tailwind-merge": "^3.4.0", @@ -515,6 +516,8 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "neverthrow": ["neverthrow@8.2.0", "", { "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "^4.24.0" } }, "sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ=="], + "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], diff --git a/package.json b/package.json index 4c4a063..0992aea 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.37.0", + "neverthrow": "^8.2.0", "pg": "^8.16.3", "solid-js": "^1.9.3", "tailwind-merge": "^3.4.0", diff --git a/src/api/middleware/error-handling.ts b/src/api/middleware/error-handling.ts index 73c7b47..fb4075a 100644 --- a/src/api/middleware/error-handling.ts +++ b/src/api/middleware/error-handling.ts @@ -14,6 +14,7 @@ import { RiderAlreadyInHeatError, RiderNotInHeatError, ScoreMustBeInValidRangeError, + ScoreNotFoundError, ScoreUUIDAlreadyExistsError, } from "../../domain/heat/errors.js"; import { createErrorResponse } from "../helpers.js"; @@ -67,7 +68,11 @@ export function isDomainError(error: unknown): error is DomainError { // Map domain errors to HTTP status codes export function getDomainErrorStatusCode(error: Error): number { // 404 errors - if (error instanceof DivisionNotFoundError) { + if ( + error instanceof DivisionNotFoundError || + error instanceof HeatDoesNotExistError || + error instanceof ScoreNotFoundError + ) { return 404; } diff --git a/src/api/orpc/domain-error-mapper.ts b/src/api/orpc/domain-error-mapper.ts index d6bff4b..148b80e 100644 --- a/src/api/orpc/domain-error-mapper.ts +++ b/src/api/orpc/domain-error-mapper.ts @@ -4,6 +4,7 @@ import { BracketAlreadyExistsError, DivisionNotFoundError, InsufficientParticipantsError, + TooManyParticipantsError, } from "../../domain/bracket/bracket-service.js"; import { HeatAlreadyExistsError, @@ -24,12 +25,14 @@ type ErrorConstructor = new (...args: any[]) => Error; /** * Maps domain error classes to oRPC error codes. + * Shared by both `unwrapOrThrow` (for Result-based handlers) and + * the `domainErrorMapper` middleware (safety net for unexpected errors). * * To add a new domain error: * 1. Create the error class in `domain/{entity}/errors.ts` * 2. Add one entry here: [ErrorClass, "STATUS_CODE"] */ -const DOMAIN_ERROR_MAP: Array<[ErrorConstructor, CommonORPCErrorCode]> = [ +export const DOMAIN_ERROR_MAP: Array<[ErrorConstructor, CommonORPCErrorCode]> = [ // 400 BAD_REQUEST — client violated a business rule [HeatAlreadyExistsError, "BAD_REQUEST"], [HeatCompletedError, "BAD_REQUEST"], @@ -42,6 +45,7 @@ const DOMAIN_ERROR_MAP: Array<[ErrorConstructor, CommonORPCErrorCode]> = [ [ScoreUUIDAlreadyExistsError, "BAD_REQUEST"], [BracketAlreadyExistsError, "BAD_REQUEST"], [InsufficientParticipantsError, "BAD_REQUEST"], + [TooManyParticipantsError, "BAD_REQUEST"], // 404 NOT_FOUND — referenced entity doesn't exist [HeatDoesNotExistError, "NOT_FOUND"], @@ -49,28 +53,20 @@ const DOMAIN_ERROR_MAP: Array<[ErrorConstructor, CommonORPCErrorCode]> = [ [DivisionNotFoundError, "NOT_FOUND"], ]; -function mapDomainError(error: unknown): never { - if (error instanceof ORPCError) throw error; - - if (error instanceof Error) { - for (const [ErrorClass, code] of DOMAIN_ERROR_MAP) { - if (error instanceof ErrorClass) { - throw new ORPCError(code, { message: error.message }); - } - } - } - - throw error; -} - /** - * oRPC middleware that maps domain errors to ORPCError. - * Applied to base procedures so all routes get automatic error mapping. + * oRPC middleware safety net for unexpected errors. + * Domain errors are now handled explicitly via `unwrapOrThrow()` in handlers. + * This middleware catches unexpected infrastructure errors (DB failures, etc.) → 500. */ export const domainErrorMapper = os.middleware(async ({ next }) => { try { return await next({}); } catch (error) { - mapDomainError(error); + if (error instanceof ORPCError) throw error; + + console.error("Unexpected error:", error); + throw new ORPCError("INTERNAL_SERVER_ERROR", { + message: error instanceof Error ? error.message : "Internal server error", + }); } }); diff --git a/src/api/orpc/routes/brackets.ts b/src/api/orpc/routes/brackets.ts index ff3bedf..ac8fb99 100644 --- a/src/api/orpc/routes/brackets.ts +++ b/src/api/orpc/routes/brackets.ts @@ -15,6 +15,7 @@ import { updateBracketRequestSchema, } from "../../schemas.js"; import { adminProcedure, authedProcedure } from "../context.js"; +import { unwrapOrThrow } from "../unwrap-result.js"; function formatBracket(bracket: Bracket) { return { @@ -140,12 +141,13 @@ export const generate = adminProcedure .handler(async ({ input }) => { const db = await getDb(); const bracketId = await db.transaction(async (tx) => { - return generateBracketForDivision(input.divisionId, { + const result = await generateBracketForDivision(input.divisionId, { divisionRepository: createDivisionRepository(tx), bracketRepository: createBracketRepository(tx), divisionParticipantRepository: createDivisionParticipantRepository(tx), heatRepository: createHeatRepository(tx), }); + return unwrapOrThrow(result); }); return { bracketId }; }); diff --git a/src/api/orpc/routes/heats.ts b/src/api/orpc/routes/heats.ts index f1e9419..3e1a12a 100644 --- a/src/api/orpc/routes/heats.ts +++ b/src/api/orpc/routes/heats.ts @@ -20,6 +20,7 @@ import { createHeatRequestSchema, updateHeatRequestSchema } from "../../schemas. import { broadcastHeatUpdate } from "../../websocket.js"; import { broadcastHeadJudgeUpdate } from "../../websocket-head-judge.js"; import { adminProcedure, authedProcedure, publicProcedure } from "../context.js"; +import { unwrapOrThrow } from "../unwrap-result.js"; const scoreSchema = z.object({ scoreUUID: z.string(), @@ -384,7 +385,8 @@ export const completeHeat = authedProcedure const heatRepo = createHeatRepository(tx); const scoreRepo = createScoreRepository(tx); const heatService = new HeatService(heatRepo, scoreRepo); - await heatService.completeHeat(input.heatId, new Date()); + const result = await heatService.completeHeat(input.heatId, new Date()); + unwrapOrThrow(result); }); await broadcastHeatUpdate(input.heatId); diff --git a/src/api/orpc/routes/scores.ts b/src/api/orpc/routes/scores.ts index 4946957..ea2ecae 100644 --- a/src/api/orpc/routes/scores.ts +++ b/src/api/orpc/routes/scores.ts @@ -16,6 +16,7 @@ import { import { broadcastHeatUpdate } from "../../websocket.js"; import { broadcastHeadJudgeUpdate } from "../../websocket-head-judge.js"; import { authedProcedure } from "../context.js"; +import { unwrapOrThrow } from "../unwrap-result.js"; function createHeatService(conn: DbConnection): HeatService { return new HeatService(createHeatRepository(conn), createScoreRepository(conn)); @@ -41,7 +42,7 @@ export const addWave = authedProcedure const db = await getDb(); const heatService = createHeatService(db); - await heatService.addWaveScore( + const result = await heatService.addWaveScore( input.heatId, input.scoreUUID, input.riderId, @@ -49,6 +50,7 @@ export const addWave = authedProcedure input.waveScore, new Date() ); + unwrapOrThrow(result); await broadcastHeatUpdate(input.heatId); await broadcastHeadJudgeUpdate(input.heatId); @@ -83,7 +85,8 @@ export const updateWave = authedProcedure throw new ORPCError("FORBIDDEN", { message: "You can only update your own scores" }); } - await heatService.updateWaveScore(input.scoreUUID, input.data.waveScore); + const result = await heatService.updateWaveScore(input.scoreUUID, input.data.waveScore); + unwrapOrThrow(result); await broadcastHeatUpdate(input.heatId); await broadcastHeadJudgeUpdate(input.heatId); @@ -116,7 +119,8 @@ export const deleteWave = authedProcedure throw new ORPCError("FORBIDDEN", { message: "You can only delete your own scores" }); } - await heatService.deleteScore(input.scoreUUID); + const result = await heatService.deleteScore(input.scoreUUID); + unwrapOrThrow(result); await broadcastHeatUpdate(input.heatId); await broadcastHeadJudgeUpdate(input.heatId); @@ -135,7 +139,7 @@ export const addJump = authedProcedure const db = await getDb(); const heatService = createHeatService(db); - await heatService.addJumpScore( + const result = await heatService.addJumpScore( input.heatId, input.scoreUUID, input.riderId, @@ -145,6 +149,7 @@ export const addJump = authedProcedure input.modifiers, new Date() ); + unwrapOrThrow(result); await broadcastHeatUpdate(input.heatId); await broadcastHeadJudgeUpdate(input.heatId); @@ -179,12 +184,13 @@ export const updateJump = authedProcedure throw new ORPCError("FORBIDDEN", { message: "You can only update your own scores" }); } - await heatService.updateJumpScore( + const result = await heatService.updateJumpScore( input.scoreUUID, input.data.jumpScore, input.data.jumpType, input.data.modifiers ); + unwrapOrThrow(result); await broadcastHeatUpdate(input.heatId); await broadcastHeadJudgeUpdate(input.heatId); @@ -217,7 +223,8 @@ export const deleteJump = authedProcedure throw new ORPCError("FORBIDDEN", { message: "You can only delete your own scores" }); } - await heatService.deleteScore(input.scoreUUID); + const result = await heatService.deleteScore(input.scoreUUID); + unwrapOrThrow(result); await broadcastHeatUpdate(input.heatId); await broadcastHeadJudgeUpdate(input.heatId); diff --git a/src/api/orpc/unwrap-result.ts b/src/api/orpc/unwrap-result.ts new file mode 100644 index 0000000..feafe1e --- /dev/null +++ b/src/api/orpc/unwrap-result.ts @@ -0,0 +1,20 @@ +import { ORPCError } from "@orpc/server"; +import type { Result } from "neverthrow"; +import { DOMAIN_ERROR_MAP } from "./domain-error-mapper.js"; + +/** + * Unwraps a Result, returning the value on success or throwing an ORPCError on failure. + * Used at the API boundary where oRPC needs thrown errors for non-2xx responses. + */ +export function unwrapOrThrow(result: Result): T { + if (result.isOk()) return result.value; + + const error = result.error; + for (const [ErrorClass, code] of DOMAIN_ERROR_MAP) { + if (error instanceof ErrorClass) { + throw new ORPCError(code, { message: error.message }); + } + } + + throw new ORPCError("INTERNAL_SERVER_ERROR", { message: error.message }); +} diff --git a/src/api/routes/bracket-routes.ts b/src/api/routes/bracket-routes.ts index 15f27ed..d292458 100644 --- a/src/api/routes/bracket-routes.ts +++ b/src/api/routes/bracket-routes.ts @@ -1,8 +1,6 @@ import { - BracketAlreadyExistsError, DivisionNotFoundError, generateBracketForDivision, - InsufficientParticipantsError, } from "../../domain/bracket/bracket-service.js"; import { getDb } from "../../infrastructure/db/index.js"; import { @@ -30,7 +28,7 @@ export async function handleGenerateBracket( // Create repositories within a transaction const db = await getDb(); - const bracketId = await db.transaction(async (tx) => { + const result = await db.transaction(async (tx) => { return generateBracketForDivision(divisionId, { divisionRepository: createDivisionRepository(tx), bracketRepository: createBracketRepository(tx), @@ -39,19 +37,18 @@ export async function handleGenerateBracket( }); }); - return createSuccessResponse({ bracketId }, 201); - } catch (error) { - if (error instanceof DivisionNotFoundError) { - return createErrorResponse(error.message, 404); - } - if (error instanceof BracketAlreadyExistsError) { - return createErrorResponse(error.message, 400); - } - if (error instanceof InsufficientParticipantsError) { + if (result.isErr()) { + const error = result.error; + if (error instanceof DivisionNotFoundError) { + return createErrorResponse(error.message, 404); + } return createErrorResponse(error.message, 400); } + + return createSuccessResponse({ bracketId: result.value }, 201); + } catch (error) { if (error instanceof Error) { - return createErrorResponse(error.message, 400); + return createErrorResponse(error.message, 500); } return createErrorResponse("Internal server error", 500); } diff --git a/src/api/routes/heat-routes.ts b/src/api/routes/heat-routes.ts index b549628..67bffe4 100644 --- a/src/api/routes/heat-routes.ts +++ b/src/api/routes/heat-routes.ts @@ -18,7 +18,7 @@ import { createScoreRepository, } from "../../infrastructure/repositories/index.js"; import { createErrorResponse, createSuccessResponse } from "../helpers.js"; -import { withErrorHandling } from "../middleware/error-handling.js"; +import { getDomainErrorStatusCode, withErrorHandling } from "../middleware/error-handling.js"; import { withValidation } from "../middleware/validation.js"; import { addJumpScoreRequestSchema, @@ -106,16 +106,20 @@ export async function handleAddWaveScore( const db = await getDb(); const heatService = createHeatService(db); - // Add wave score using HeatService - await heatService.addWaveScore( + const result = await heatService.addWaveScore( data.heatId, data.scoreUUID, data.riderId, - request.user.id, // judgeId from authenticated user + request.user.id, data.waveScore, new Date() ); + if (result.isErr()) { + const status = getDomainErrorStatusCode(result.error); + return createErrorResponse(result.error.message, status); + } + // Broadcast heat update await broadcastHeatUpdate(data.heatId); await broadcastHeadJudgeUpdate(data.heatId); @@ -137,18 +141,22 @@ export async function handleAddJumpScore( const db = await getDb(); const heatService = createHeatService(db); - // Add jump score using HeatService - await heatService.addJumpScore( + const result = await heatService.addJumpScore( data.heatId, data.scoreUUID, data.riderId, - request.user.id, // judgeId from authenticated user + request.user.id, data.jumpScore, data.jumpType, data.modifiers, new Date() ); + if (result.isErr()) { + const status = getDomainErrorStatusCode(result.error); + return createErrorResponse(result.error.message, status); + } + // Broadcast heat update await broadcastHeatUpdate(data.heatId); await broadcastHeadJudgeUpdate(data.heatId); @@ -456,7 +464,11 @@ export async function handleCompleteHeat(heatId: string, _request: Request): Pro const heatRepo = createHeatRepository(tx); const scoreRepo = createScoreRepository(tx); const heatService = new HeatService(heatRepo, scoreRepo); - await heatService.completeHeat(heatId, new Date()); + const result = await heatService.completeHeat(heatId, new Date()); + if (result.isErr()) { + const status = getDomainErrorStatusCode(result.error); + throw Object.assign(result.error, { _statusCode: status }); + } }); // Broadcast heat update @@ -493,7 +505,11 @@ export async function handleUpdateWaveScore( } // Update score using HeatService - await heatService.updateWaveScore(scoreUUID, data.waveScore); + const result = await heatService.updateWaveScore(scoreUUID, data.waveScore); + if (result.isErr()) { + const status = getDomainErrorStatusCode(result.error); + return createErrorResponse(result.error.message, status); + } // Broadcast heat update await broadcastHeatUpdate(heatId); @@ -534,7 +550,16 @@ export async function handleUpdateJumpScore( } // Update score using HeatService - await heatService.updateJumpScore(scoreUUID, data.jumpScore, data.jumpType, data.modifiers); + const result = await heatService.updateJumpScore( + scoreUUID, + data.jumpScore, + data.jumpType, + data.modifiers + ); + if (result.isErr()) { + const status = getDomainErrorStatusCode(result.error); + return createErrorResponse(result.error.message, status); + } // Broadcast heat update await broadcastHeatUpdate(heatId); @@ -579,7 +604,11 @@ export async function handleDeleteWaveScore( } // Delete score using HeatService - await heatService.deleteScore(scoreUUID); + const result = await heatService.deleteScore(scoreUUID); + if (result.isErr()) { + const status = getDomainErrorStatusCode(result.error); + return createErrorResponse(result.error.message, status); + } // Broadcast heat update await broadcastHeatUpdate(heatId); @@ -623,7 +652,11 @@ export async function handleDeleteJumpScore( } // Delete score using HeatService - await heatService.deleteScore(scoreUUID); + const result = await heatService.deleteScore(scoreUUID); + if (result.isErr()) { + const status = getDomainErrorStatusCode(result.error); + return createErrorResponse(result.error.message, status); + } // Broadcast heat update await broadcastHeatUpdate(heatId); diff --git a/src/domain/bracket/bracket-service.ts b/src/domain/bracket/bracket-service.ts index 81b118d..4e051df 100644 --- a/src/domain/bracket/bracket-service.ts +++ b/src/domain/bracket/bracket-service.ts @@ -1,5 +1,6 @@ import type { BracketRepository, DivisionRepository } from "../contest/repositories.js"; import type { HeatRepository } from "../heat/repositories.js"; +import { err, ok, type Result } from "../result.js"; import type { DivisionParticipantRepository } from "../rider/repositories.js"; import { generateSingleEliminationBracket } from "./bracket-generator.js"; @@ -21,6 +22,18 @@ export class InsufficientParticipantsError extends Error { } } +export class TooManyParticipantsError extends Error { + constructor(count: number) { + super(`Division has ${count} participants, maximum is 64`); + } +} + +export type BracketServiceError = + | BracketAlreadyExistsError + | DivisionNotFoundError + | InsufficientParticipantsError + | TooManyParticipantsError; + export async function generateBracketForDivision( divisionId: string, repositories: { @@ -29,30 +42,30 @@ export async function generateBracketForDivision( divisionParticipantRepository: DivisionParticipantRepository; heatRepository: HeatRepository; } -): Promise { +): Promise> { const { divisionRepository, bracketRepository, divisionParticipantRepository, heatRepository } = repositories; // Validate division exists const division = await divisionRepository.getDivisionById(divisionId); if (!division) { - throw new DivisionNotFoundError(divisionId); + return err(new DivisionNotFoundError(divisionId)); } // Check if bracket already exists const existingBracket = await bracketRepository.getBracketByDivisionId(divisionId); if (existingBracket) { - throw new BracketAlreadyExistsError(divisionId); + return err(new BracketAlreadyExistsError(divisionId)); } // Get participants const riderIds = await divisionParticipantRepository.getRiderIdsByDivisionId(divisionId); if (riderIds.length < 2) { - throw new InsufficientParticipantsError(riderIds.length); + return err(new InsufficientParticipantsError(riderIds.length)); } if (riderIds.length > 64) { - throw new Error(`Division has ${riderIds.length} participants, maximum is 64`); + return err(new TooManyParticipantsError(riderIds.length)); } // Generate bracket structure @@ -130,5 +143,5 @@ export async function generateBracketForDivision( } } - return bracket.id; + return ok(bracket.id); } diff --git a/src/domain/heat/errors.ts b/src/domain/heat/errors.ts index 9a9803b..7d3bbfe 100644 --- a/src/domain/heat/errors.ts +++ b/src/domain/heat/errors.ts @@ -1,15 +1,20 @@ // Heat domain errors -export type BadUserRequestError = +export type HeatServiceError = | HeatAlreadyExistsError | HeatDoesNotExistError + | HeatCompletedError | NonUniqueRiderIdsError | RiderNotInHeatError | ScoreMustBeInValidRangeError | ScoreUUIDAlreadyExistsError | InvalidHeatRulesError | RiderAlreadyInHeatError - | HeatCompletedError; + | ScoreNotFoundError + | ScoreTypeMismatchError; + +/** @deprecated Use HeatServiceError instead */ +export type BadUserRequestError = HeatServiceError; export class HeatAlreadyExistsError extends Error { constructor(heatId: string) { diff --git a/src/domain/heat/heat-service.ts b/src/domain/heat/heat-service.ts index 3f5f666..fed0478 100644 --- a/src/domain/heat/heat-service.ts +++ b/src/domain/heat/heat-service.ts @@ -1,3 +1,5 @@ +import { err, ok, type Result } from "../result.js"; +import type { HeatServiceError } from "./errors.js"; import { HeatCompletedError, HeatDoesNotExistError, @@ -23,33 +25,26 @@ export class HeatService { judgeId: string, scoreValue: number, timestamp: Date - ): Promise { + ): Promise> { // Validate heat exists and is not completed const heat = await this.heatRepository.getHeatByHeatId(heatId); if (!heat) { - throw new HeatDoesNotExistError(heatId); + return err(new HeatDoesNotExistError(heatId)); } if (heat.completedAt) { - throw new HeatCompletedError("Heat already completed"); + return err(new HeatCompletedError("Heat already completed")); } - - // Validate rider is in heat if (!heat.riderIds.includes(riderId)) { - throw new RiderNotInHeatError(riderId, heatId); + return err(new RiderNotInHeatError(riderId, heatId)); } - - // Validate score range if (scoreValue < 0 || scoreValue > 10) { - throw new ScoreMustBeInValidRangeError(scoreValue); + return err(new ScoreMustBeInValidRangeError(scoreValue)); } - - // Check for duplicate score UUID const existingScore = await this.scoreRepository.getScoreByUuid(scoreUuid); if (existingScore) { - throw new ScoreUUIDAlreadyExistsError(scoreUuid); + return err(new ScoreUUIDAlreadyExistsError(scoreUuid)); } - // Insert score await this.scoreRepository.insertScore({ scoreUuid, heatId, @@ -59,6 +54,8 @@ export class HeatService { scoreValue, timestamp, }); + + return ok(undefined); } async addJumpScore( @@ -70,33 +67,25 @@ export class HeatService { jumpType: string, jumpModifiers: string[], timestamp: Date - ): Promise { - // Validate heat exists and is not completed + ): Promise> { const heat = await this.heatRepository.getHeatByHeatId(heatId); if (!heat) { - throw new HeatDoesNotExistError(heatId); + return err(new HeatDoesNotExistError(heatId)); } if (heat.completedAt) { - throw new HeatCompletedError("Heat already completed"); + return err(new HeatCompletedError("Heat already completed")); } - - // Validate rider is in heat if (!heat.riderIds.includes(riderId)) { - throw new RiderNotInHeatError(riderId, heatId); + return err(new RiderNotInHeatError(riderId, heatId)); } - - // Validate score range if (scoreValue < 0 || scoreValue > 10) { - throw new ScoreMustBeInValidRangeError(scoreValue); + return err(new ScoreMustBeInValidRangeError(scoreValue)); } - - // Check for duplicate score UUID const existingScore = await this.scoreRepository.getScoreByUuid(scoreUuid); if (existingScore) { - throw new ScoreUUIDAlreadyExistsError(scoreUuid); + return err(new ScoreUUIDAlreadyExistsError(scoreUuid)); } - // Insert score await this.scoreRepository.insertScore({ scoreUuid, heatId, @@ -108,35 +97,35 @@ export class HeatService { jumpModifiers, timestamp, }); + + return ok(undefined); } - async updateWaveScore(scoreUuid: string, scoreValue: number): Promise { - // Validate score exists + async updateWaveScore( + scoreUuid: string, + scoreValue: number + ): Promise> { const existingScore = await this.scoreRepository.getScoreByUuid(scoreUuid); if (!existingScore) { - throw new ScoreNotFoundError(scoreUuid); + return err(new ScoreNotFoundError(scoreUuid)); } - if (existingScore.type !== "wave") { - throw new ScoreTypeMismatchError(scoreUuid, "wave", existingScore.type); + return err(new ScoreTypeMismatchError(scoreUuid, "wave", existingScore.type)); } - - // Validate heat is not completed const heat = await this.heatRepository.getHeatByHeatId(existingScore.heatId); if (!heat) { - throw new HeatDoesNotExistError(existingScore.heatId); + return err(new HeatDoesNotExistError(existingScore.heatId)); } if (heat.completedAt) { - throw new HeatCompletedError("Cannot update scores in a completed heat"); + return err(new HeatCompletedError("Cannot update scores in a completed heat")); } - - // Validate score range if (scoreValue < 0 || scoreValue > 10) { - throw new ScoreMustBeInValidRangeError(scoreValue); + return err(new ScoreMustBeInValidRangeError(scoreValue)); } - // Update score await this.scoreRepository.updateScore(scoreUuid, { scoreValue }); + + return ok(undefined); } async updateJumpScore( @@ -144,56 +133,49 @@ export class HeatService { scoreValue: number, jumpType?: string, jumpModifiers?: string[] - ): Promise { - // Validate score exists + ): Promise> { const existingScore = await this.scoreRepository.getScoreByUuid(scoreUuid); if (!existingScore) { - throw new ScoreNotFoundError(scoreUuid); + return err(new ScoreNotFoundError(scoreUuid)); } - if (existingScore.type !== "jump") { - throw new ScoreTypeMismatchError(scoreUuid, "jump", existingScore.type); + return err(new ScoreTypeMismatchError(scoreUuid, "jump", existingScore.type)); } - - // Validate heat is not completed const heat = await this.heatRepository.getHeatByHeatId(existingScore.heatId); if (!heat) { - throw new HeatDoesNotExistError(existingScore.heatId); + return err(new HeatDoesNotExistError(existingScore.heatId)); } if (heat.completedAt) { - throw new HeatCompletedError("Cannot update scores in a completed heat"); + return err(new HeatCompletedError("Cannot update scores in a completed heat")); } - - // Validate score range if (scoreValue < 0 || scoreValue > 10) { - throw new ScoreMustBeInValidRangeError(scoreValue); + return err(new ScoreMustBeInValidRangeError(scoreValue)); } - // Update score await this.scoreRepository.updateScore(scoreUuid, { scoreValue, jumpType, jumpModifiers }); + + return ok(undefined); } - async deleteScore(scoreUuid: string): Promise { - // Validate score exists + async deleteScore(scoreUuid: string): Promise> { const existingScore = await this.scoreRepository.getScoreByUuid(scoreUuid); if (!existingScore) { - throw new ScoreNotFoundError(scoreUuid); + return err(new ScoreNotFoundError(scoreUuid)); } - - // Validate heat is not completed const heat = await this.heatRepository.getHeatByHeatId(existingScore.heatId); if (!heat) { - throw new HeatDoesNotExistError(existingScore.heatId); + return err(new HeatDoesNotExistError(existingScore.heatId)); } if (heat.completedAt) { - throw new HeatCompletedError("Cannot delete scores in a completed heat"); + return err(new HeatCompletedError("Cannot delete scores in a completed heat")); } - // Delete score await this.scoreRepository.deleteScore(scoreUuid); + + return ok(undefined); } - async completeHeat(heatId: string, completedAt: Date): Promise { + async completeHeat(heatId: string, completedAt: Date): Promise> { // 1. Mark heat completed await this.heatRepository.markCompleted(heatId, completedAt); @@ -202,13 +184,13 @@ export class HeatService { const heat = await this.heatRepository.getHeatByHeatId(heatId); if (!heat) { - throw new HeatDoesNotExistError(heatId); + return err(new HeatDoesNotExistError(heatId)); } const totals = calculateRiderScoreTotals(scores, heat.wavesCounting, heat.jumpsCounting); if (totals.length === 0) { - return; // No riders, nothing to advance + return ok(undefined); // No riders, nothing to advance } const winner = totals[0]; @@ -224,5 +206,7 @@ export class HeatService { if (loser && metadata?.loserDestinationHeatId) { await this.heatRepository.addRiderToHeat(metadata.loserDestinationHeatId, loser.riderId); } + + return ok(undefined); } } diff --git a/src/domain/heat/index.ts b/src/domain/heat/index.ts index fff3645..f9bb3de 100644 --- a/src/domain/heat/index.ts +++ b/src/domain/heat/index.ts @@ -1,14 +1,17 @@ // Export all types and errors -export type { BadUserRequestError } from "./errors.js"; +export type { BadUserRequestError, HeatServiceError } from "./errors.js"; export { HeatAlreadyExistsError, + HeatCompletedError, HeatDoesNotExistError, InvalidHeatRulesError, NonUniqueRiderIdsError, RiderAlreadyInHeatError, RiderNotInHeatError, ScoreMustBeInValidRangeError, + ScoreNotFoundError, + ScoreTypeMismatchError, ScoreUUIDAlreadyExistsError, } from "./errors.js"; export { diff --git a/src/domain/result.ts b/src/domain/result.ts new file mode 100644 index 0000000..2287454 --- /dev/null +++ b/src/domain/result.ts @@ -0,0 +1 @@ +export { err, errAsync, ok, okAsync, Result, ResultAsync } from "neverthrow"; diff --git a/src/infrastructure/repositories/heat-repository.ts b/src/infrastructure/repositories/heat-repository.ts index 1c93d3d..46de7d0 100644 --- a/src/infrastructure/repositories/heat-repository.ts +++ b/src/infrastructure/repositories/heat-repository.ts @@ -142,7 +142,7 @@ export class HeatRepositoryImpl implements HeatRepository { const [heat] = await this.conn.select().from(heats).where(eq(heats.heatId, heatId)).limit(1); if (!heat) { - throw new Error(`Heat ${heatId} not found`); + return; // Heat existence is validated upstream by domain service } const riderIds = JSON.parse(heat.riderIds) as string[]; @@ -164,7 +164,7 @@ export class HeatRepositoryImpl implements HeatRepository { const [heat] = await this.conn.select().from(heats).where(eq(heats.heatId, heatId)).limit(1); if (!heat) { - throw new Error(`Heat ${heatId} not found`); + return []; } return JSON.parse(heat.riderIds) as string[]; From 243a9bac7d997bfeba44c7df94ed914a9310379b Mon Sep 17 00:00:00 2001 From: Daniel Bimschas Date: Mon, 2 Feb 2026 14:35:33 +0100 Subject: [PATCH 02/17] docs: add landing page design document Covers page structure, Playwright screenshot generation, CI workflow, GitHub Pages deployment, and maintenance instructions for LLMs. Co-Authored-By: Claude Opus 4.5 --- docs/plans/2026-02-02-landing-page-design.md | 159 +++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 docs/plans/2026-02-02-landing-page-design.md diff --git a/docs/plans/2026-02-02-landing-page-design.md b/docs/plans/2026-02-02-landing-page-design.md new file mode 100644 index 0000000..13f36b7 --- /dev/null +++ b/docs/plans/2026-02-02-landing-page-design.md @@ -0,0 +1,159 @@ +# Landing Page Design + +## Overview + +A public landing page for WS Scoring hosted on GitHub Pages, showcasing the application's features with auto-generated screenshots organized by target audience. + +## Target Audiences + +- **Judges** — Score entry, offline support, mobile-optimized experience +- **Head Judges** — Bracket visualization, heat oversight, contest management +- **Spectators** — Live heat viewer, real-time score updates, no login required + +## Page Structure + +**File layout:** + +``` +landing_page/ +├── index.html # Single page, Tailwind via CDN +├── screenshots/ # Auto-generated by Playwright +│ ├── judge-scoresheet-desktop.png +│ ├── judge-scoresheet-mobile.png +│ ├── judge-wave-modal-desktop.png +│ ├── judge-wave-modal-mobile.png +│ ├── judge-jump-modal-desktop.png +│ ├── judge-jump-modal-mobile.png +│ ├── headjudge-bracket-desktop.png +│ ├── headjudge-heats-desktop.png +│ ├── spectator-viewer-desktop.png +│ ├── spectator-viewer-mobile.png +│ └── ... +└── CNAME # Optional, if custom domain later +``` + +**Page sections:** + +``` +Hero Section +├── App name "WS Scoring" + tagline +├── Ocean/wave gradient background (CSS only, no images) +├── CTA button → links to the deployed app + +For Judges +├── Desktop + mobile screenshots: score sheet, wave/jump modals, on-screen keyboard +├── Feature bullets: real-time scoring, offline support, mobile-optimized + +For Head Judges +├── Desktop screenshots only: bracket view, heat overview, contest management +├── Feature bullets: bracket visualization, score oversight, contest setup + +For Spectators +├── Desktop + mobile screenshots: public heat viewer, live score updates +├── Feature bullets: no login required, real-time WebSocket updates + +Footer +├── Link to GitHub repo +├── "Built for the Danish Open 2026" +``` + +**Visual style:** Sporty & energetic. Ocean blue → teal → white gradient palette, dynamic layout, wave-themed accents. + +**Tech:** Single HTML file with Tailwind CSS via CDN. Zero build step. Zero dependencies. + +## Screenshot Generation + +### Setup + +``` +e2e/ +├── playwright.config.ts # Config: baseURL, screenshots dir, viewport sizes +├── screenshots.spec.ts # Single spec capturing all screenshots +└── package.json # Separate from main app (Playwright dependency) +``` + +Playwright is isolated in `e2e/` with its own `package.json`, separate from the main app's Bun dependencies. + +### Spec Flow + +1. App is already running with seeded data (DB seeded via existing seed script, server started by CI) +2. Log in as a judge user +3. Navigate to a heat → capture score sheet (desktop + mobile) +4. Open wave score modal → capture (desktop + mobile) +5. Open jump score modal → capture (desktop + mobile) +6. Add several scores through the UI (wave scores, jump scores for multiple riders) to make data look realistic +7. Log in as head judge +8. Navigate to bracket view → capture (desktop only) +9. Navigate to heat overview → capture (desktop only) +10. Open contest/division management → capture (desktop only) +11. Visit the public viewer URL (no auth) → capture spectator view with live scores (desktop + mobile) + +### Screenshot Settings + +- Desktop viewport: 1280x800 +- Mobile viewport: 390x844 +- `fullPage: false` — capture viewport only for consistent sizing +- PNG format +- Output directory: `landing_page/screenshots/` + +## CI Workflow + +**`.github/workflows/screenshots.yml`** + +Triggers on push/merge to `main`. + +### Steps + +1. Checkout repo +2. Install Bun + app dependencies (`bun install`) +3. Install Playwright (`cd e2e && npm install && npx playwright install --with-deps chromium`) +4. Start app with seeded data: + - Start PostgreSQL service container + - Run `bun run db:migrate` + `bun run db:seed` + - Start API server in background (`bun run dev:api &`) + - Start frontend dev server in background (`bun run dev:app &`) + - Wait for both to be healthy +5. Run Playwright (`cd e2e && npx playwright test`) +6. Commit & push updated screenshots: + ```bash + git add landing_page/screenshots/ + git diff --staged --quiet || git commit -m "chore: update landing page screenshots" + git push + ``` + +### Safety + +- `git diff --staged --quiet` check avoids empty commits when screenshots haven't changed +- Commit message or bot check prevents re-triggering the workflow +- Uses `GITHUB_TOKEN` with write permissions + +## GitHub Pages Deployment + +- GitHub Pages configured to serve from `landing_page/` directory on `main` branch +- No separate build or deploy step needed +- Screenshots committed by CI workflow are automatically picked up by Pages + +## Links & Discoverability + +### README.md + +Prominent section near the top, before technical details: + +```markdown +## See It In Action + +Check out the [WS Scoring Landing Page](https://.github.io/ws-scoring/) +to see the app in action with screenshots for judges, head judges, and spectators. +``` + +### GitHub Repository + +Set the repository "Website" field to the GitHub Pages URL so it appears at the top of the repo page. + +## Maintenance Instructions + +Added to `CLAUDE.md` so LLMs keep the landing page in sync: + +- When adding or changing user-facing features, update `landing_page/index.html` feature descriptions +- When adding new screens/pages, consider adding Playwright screenshots in `e2e/screenshots.spec.ts` and updating the landing page layout +- Screenshots are auto-regenerated on push to main via CI From c82d75e72d8cc75ef56aa3b1b4ef2c522b383c44 Mon Sep 17 00:00:00 2001 From: Daniel Bimschas Date: Mon, 2 Feb 2026 14:45:06 +0100 Subject: [PATCH 03/17] docs: add landing page implementation plan 11 tasks covering HTML page, Playwright setup, seed users, screenshot spec, CI workflow, README/CLAUDE.md updates, and GitHub Pages config. Co-Authored-By: Claude Opus 4.5 --- .../2026-02-02-landing-page-implementation.md | 1146 +++++++++++++++++ 1 file changed, 1146 insertions(+) create mode 100644 docs/plans/2026-02-02-landing-page-implementation.md diff --git a/docs/plans/2026-02-02-landing-page-implementation.md b/docs/plans/2026-02-02-landing-page-implementation.md new file mode 100644 index 0000000..5d47342 --- /dev/null +++ b/docs/plans/2026-02-02-landing-page-implementation.md @@ -0,0 +1,1146 @@ +# Landing Page Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Create a public landing page with auto-generated screenshots, hosted on GitHub Pages from the `landing_page` directory. + +**Architecture:** Single HTML file with Tailwind CDN (zero build step). Playwright E2E tests generate screenshots by running against the seeded app. A dedicated GitHub Actions workflow regenerates screenshots on push to main and commits them back. + +**Tech Stack:** HTML + Tailwind CDN, Playwright (isolated in `e2e/`), GitHub Actions, GitHub Pages + +**Working directory:** `/Users/danbim/coding/ws_scoring/.worktrees/landing-page` (branch: `landing-page`) + +**Worktree ports:** API=3260, Vite=5433, PostgreSQL=5692 + +--- + +### Task 1: Create landing page HTML skeleton + +**Files:** +- Create: `landing_page/index.html` + +**Step 1: Create the directory and HTML file** + +Create `landing_page/index.html` with the full landing page. Uses Tailwind CSS via CDN. Ocean blue/teal gradient theme, sporty styling. + +```html + + + + + + WS Scoring - Windsurfing Contest Judging + + + + + + + +
+
+

+ WS Scoring +

+

+ Real-time windsurfing wave contest judging. Score waves and jumps, manage brackets, and stream results live. +

+ + Launch App + +
+
+ + +
+
+
+ For Judges +

Score Waves & Jumps in Real Time

+

+ Enter scores from your phone or tablet with the touch-optimized interface. Works offline and syncs automatically when reconnected. +

+
+ +
+
+ Heat score sheet - desktop view +

Heat score sheet with rider cards and live totals

+
+
+ Heat score sheet - mobile view +

Mobile-optimized for on-the-water judging

+
+
+ +
+
+ Wave score entry - desktop +

Wave score entry

+
+
+ Wave score entry - mobile +

Wave score (mobile)

+
+
+ Jump score entry - desktop +

Jump score with trick selection

+
+
+ Jump score entry - mobile +

Jump score (mobile)

+
+
+ +
    +
  • +
    🌊
    +

    Wave & Jump Scoring

    +

    Score 0-10 with decimal precision. Supports all PWA jump types.

    +
  • +
  • +
    📶
    +

    Offline Support

    +

    Keep scoring even without connectivity. Syncs when back online.

    +
  • +
  • +
    📱
    +

    Mobile Optimized

    +

    Touch-friendly on-screen keyboard designed for quick entry.

    +
  • +
+
+
+ + +
+
+
+ For Head Judges +

Oversee Scores & Manage Brackets

+

+ See all judges' scores side by side, manage single elimination brackets, and control heat progression. +

+
+ +
+
+ Bracket visualization +

Single elimination bracket with automatic progression

+
+
+ Head judge heat overview +

All judges' scores at a glance with final averages

+
+
+ +
    +
  • +
    🏆
    +

    Bracket Management

    +

    Single elimination brackets for 2-64 riders with automatic bye handling.

    +
  • +
  • +
    📊
    +

    Score Oversight

    +

    View all judges' scores side by side with computed averages.

    +
  • +
  • +
    +

    Heat Control

    +

    Complete heats and trigger automatic bracket advancement.

    +
  • +
+
+
+ + +
+
+
+ For Spectators +

Follow the Action Live

+

+ Watch scores update in real time from the beach or anywhere. No login required. +

+
+ +
+
+ Live heat viewer - desktop +

Live scoreboard with real-time WebSocket updates

+
+
+ Live heat viewer - mobile +

Works on any device, no app needed

+
+
+ +
    +
  • +
    +

    Real-Time Updates

    +

    Scores stream live via WebSocket. No page refresh needed.

    +
  • +
  • +
    🔓
    +

    No Login Required

    +

    Public viewer accessible to everyone. Share the link.

    +
  • +
  • +
    📺
    +

    Big Screen Ready

    +

    Embed the viewer on any display at the beach or event.

    +
  • +
+
+
+ + + + + + +``` + +**Step 2: Verify it renders locally** + +Open the file in a browser to check the layout: + +```bash +open landing_page/index.html +``` + +Expected: The page loads with the ocean gradient hero, three audience sections with placeholder image areas (broken images are fine - screenshots don't exist yet), and the footer. + +**Step 3: Commit** + +```bash +git add landing_page/index.html +git commit -m "feat: add landing page HTML with Tailwind CDN" +``` + +--- + +### Task 2: Set up Playwright E2E project + +**Files:** +- Create: `e2e/package.json` +- Create: `e2e/playwright.config.ts` +- Create: `e2e/tsconfig.json` + +**Step 1: Create the e2e directory and package.json** + +Create `e2e/package.json`: + +```json +{ + "name": "ws-scoring-e2e", + "private": true, + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed" + }, + "devDependencies": { + "@playwright/test": "^1.50.0" + } +} +``` + +**Step 2: Create Playwright config** + +Create `e2e/playwright.config.ts`: + +```typescript +import { defineConfig } from "@playwright/test"; + +const BASE_URL = process.env.BASE_URL || "http://localhost:5173"; +const API_URL = process.env.API_URL || "http://localhost:3000"; + +export default defineConfig({ + testDir: ".", + testMatch: "**/*.spec.ts", + timeout: 60_000, + expect: { timeout: 10_000 }, + fullyParallel: false, + retries: 0, + workers: 1, + reporter: "list", + use: { + baseURL: BASE_URL, + screenshot: "off", + video: "off", + trace: "off", + }, + projects: [ + { + name: "screenshots", + use: { + browserName: "chromium", + }, + }, + ], +}); +``` + +**Step 3: Create tsconfig for e2e** + +Create `e2e/tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["*.ts"] +} +``` + +**Step 4: Install Playwright dependencies** + +```bash +cd e2e && npm install && npx playwright install chromium +``` + +Expected: Chromium browser binary downloaded, `node_modules` created in `e2e/`. + +**Step 5: Add e2e/node_modules to .gitignore** + +Add to the project's `.gitignore`: + +``` +# E2E tests +e2e/node_modules/ +e2e/test-results/ +e2e/playwright-report/ +``` + +**Step 6: Commit** + +```bash +git add e2e/package.json e2e/playwright.config.ts e2e/tsconfig.json .gitignore +git commit -m "feat: set up Playwright E2E project for screenshot generation" +``` + +--- + +### Task 3: Add seed script for E2E test users + +The existing seed script (`scripts/db/seed.ts`) creates riders, seasons, contests, divisions, and brackets, but does NOT create users. The user creation script (`scripts/users/create-user.ts`) is interactive. We need a non-interactive way to create test users for E2E. + +**Files:** +- Create: `scripts/db/seed-users.ts` +- Modify: `package.json` (add `db:seed:users` script) + +**Step 1: Create the seed-users script** + +Create `scripts/db/seed-users.ts`: + +```typescript +// Create test users for E2E screenshot generation +// Non-interactive — used by CI and local E2E setup + +import { getDb } from "../../src/infrastructure/db/index.js"; +import { createUserRepository } from "../../src/infrastructure/repositories/index.js"; +import type { CreateUserInput } from "../../src/domain/user/types.js"; + +const TEST_USERS: CreateUserInput[] = [ + { + username: "judge1", + password: "password123", + role: "judge", + email: null, + }, + { + username: "judge2", + password: "password123", + role: "judge", + email: null, + }, + { + username: "headjudge", + password: "password123", + role: "head_judge", + email: null, + }, +]; + +async function seedUsers() { + const db = await getDb(); + const userRepository = createUserRepository(db); + + for (const userInput of TEST_USERS) { + const existing = await userRepository.getUserByUsername(userInput.username); + if (existing) { + console.log(` User "${userInput.username}" already exists, skipping`); + continue; + } + const user = await userRepository.createUser(userInput); + console.log(` Created user: ${user.username} (${user.role})`); + } + + console.log("\nTest users ready."); + process.exit(0); +} + +if (import.meta.main) { + seedUsers().catch((error) => { + console.error("Failed to seed users:", error); + process.exit(1); + }); +} +``` + +**Step 2: Add script to package.json** + +Add to the `"scripts"` section of `package.json`: + +```json +"db:seed:users": "bun run scripts/db/seed-users.ts" +``` + +**Step 3: Run it locally to verify (requires running PostgreSQL)** + +```bash +bun run db:seed:users +``` + +Expected output: +``` + Created user: judge1 (judge) + Created user: judge2 (judge) + Created user: headjudge (head_judge) + +Test users ready. +``` + +**Step 4: Commit** + +```bash +git add scripts/db/seed-users.ts package.json +git commit -m "feat: add non-interactive seed script for E2E test users" +``` + +--- + +### Task 4: Write Playwright screenshot spec + +This is the core task. The spec logs in, navigates the app, enters scores, and captures screenshots at desktop and mobile viewports. + +**Files:** +- Create: `e2e/screenshots.spec.ts` + +**Step 1: Create the screenshot spec** + +Create `e2e/screenshots.spec.ts`: + +```typescript +import { test, expect, type Page } from "@playwright/test"; +import path from "node:path"; + +const SCREENSHOT_DIR = path.resolve(__dirname, "../landing_page/screenshots"); + +const DESKTOP = { width: 1280, height: 800 }; +const MOBILE = { width: 390, height: 844 }; + +const BASE_URL = process.env.BASE_URL || "http://localhost:5173"; +const API_URL = process.env.API_URL || "http://localhost:3000"; + +// Credentials matching scripts/db/seed-users.ts +const JUDGE_USER = { username: "judge1", password: "password123" }; +const HEAD_JUDGE_USER = { username: "headjudge", password: "password123" }; + +async function screenshot(page: Page, name: string) { + await page.screenshot({ + path: path.join(SCREENSHOT_DIR, `${name}.png`), + fullPage: false, + }); +} + +async function login(page: Page, username: string, password: string) { + await page.goto(`${BASE_URL}/login`); + await page.fill('input[name="username"]', username); + await page.fill('input[name="password"]', password); + await page.click('button[type="submit"]'); + await page.waitForURL(`${BASE_URL}/`); +} + +async function logout(page: Page) { + // Click logout button in the navbar + await page.click('button:has-text("Logout")'); + await page.waitForURL(`${BASE_URL}/login`); +} + +// Helper to find the first heat with riders so we can navigate to it +async function findFirstHeatUrl(page: Page): Promise { + // Navigate: Seasons -> first contest -> first division -> first bracket -> first heat + await page.goto(`${BASE_URL}/`); + // Click the first season link + await page.locator("a").filter({ hasText: "2026 Season" }).first().click(); + await page.waitForURL(/\/contests/); + + // Click the first contest + await page.locator("a").filter({ hasText: "Danish Open" }).first().click(); + await page.waitForURL(/\/divisions/); + + // Click the first division + await page.locator("a").first().click(); + await page.waitForURL(/\/participants|\/brackets/); + + // We need to navigate to a heat - look for heat links on the page + // The bracket view should show heats + const heatLink = page.locator('a[href*="/heats/"]').first(); + await heatLink.click(); + await page.waitForURL(/\/heats\//); + + return page.url(); +} + +test.describe("Screenshot generation", () => { + + test("Judge screenshots - desktop and mobile", async ({ browser }) => { + // Desktop context + const desktopContext = await browser.newContext({ viewport: DESKTOP }); + const desktopPage = await desktopContext.newPage(); + + await login(desktopPage, JUDGE_USER.username, JUDGE_USER.password); + + // Navigate to a heat + const heatUrl = await findFirstHeatUrl(desktopPage); + + // Screenshot: Score sheet (desktop) + await desktopPage.waitForTimeout(1000); // Let scores render + await screenshot(desktopPage, "judge-scoresheet-desktop"); + + // Add wave scores to make it look realistic + const addWaveButtons = desktopPage.locator('button:has-text("Add Wave")'); + if (await addWaveButtons.count() > 0) { + // Add wave score for first rider + await addWaveButtons.first().click(); + await desktopPage.waitForTimeout(500); + + // Screenshot: Wave modal (desktop) + await screenshot(desktopPage, "judge-wave-modal-desktop"); + + // Enter a score using on-screen keyboard + await desktopPage.locator('button:has-text("7")').first().click(); + await desktopPage.locator('button:has-text(".")').first().click(); + await desktopPage.locator('button:has-text("5")').first().click(); + // Submit the score + const submitButton = desktopPage.locator('button:has-text("Submit"), button:has-text("Save"), button:has-text("OK")').first(); + if (await submitButton.isVisible()) { + await submitButton.click(); + } + await desktopPage.waitForTimeout(500); + + // Add more wave scores for realism + if (await addWaveButtons.count() > 0) { + await addWaveButtons.first().click(); + await desktopPage.waitForTimeout(300); + await desktopPage.locator('button:has-text("8")').first().click(); + const submitBtn = desktopPage.locator('button:has-text("Submit"), button:has-text("Save"), button:has-text("OK")').first(); + if (await submitBtn.isVisible()) { + await submitBtn.click(); + } + await desktopPage.waitForTimeout(500); + } + } + + // Add jump score + const addJumpButtons = desktopPage.locator('button:has-text("Add Jump")'); + if (await addJumpButtons.count() > 0) { + await addJumpButtons.first().click(); + await desktopPage.waitForTimeout(500); + + // Screenshot: Jump modal step 1 - trick selection (desktop) + // Select a jump type (e.g., Forward) + await desktopPage.locator('button:has-text("F")').first().click(); + await desktopPage.waitForTimeout(300); + + // Click Next to go to score entry + const nextButton = desktopPage.locator('button:has-text("Next")').first(); + if (await nextButton.isVisible()) { + await nextButton.click(); + await desktopPage.waitForTimeout(300); + } + + await screenshot(desktopPage, "judge-jump-modal-desktop"); + + // Enter jump score + await desktopPage.locator('button:has-text("8")').first().click(); + await desktopPage.locator('button:has-text(".")').first().click(); + await desktopPage.locator('button:has-text("5")').first().click(); + const submitJump = desktopPage.locator('button:has-text("Submit"), button:has-text("Save"), button:has-text("OK")').first(); + if (await submitJump.isVisible()) { + await submitJump.click(); + } + await desktopPage.waitForTimeout(500); + } + + // Re-screenshot score sheet with scores filled in + await screenshot(desktopPage, "judge-scoresheet-desktop"); + + // Mobile context - same heat URL + const mobileContext = await browser.newContext({ viewport: MOBILE }); + const mobilePage = await mobileContext.newPage(); + + await login(mobilePage, JUDGE_USER.username, JUDGE_USER.password); + await mobilePage.goto(heatUrl); + await mobilePage.waitForTimeout(1000); + + // Screenshot: Score sheet (mobile) + await screenshot(mobilePage, "judge-scoresheet-mobile"); + + // Open wave modal on mobile + const mobileWaveButtons = mobilePage.locator('button:has-text("Add Wave")'); + if (await mobileWaveButtons.count() > 0) { + await mobileWaveButtons.first().click(); + await mobilePage.waitForTimeout(500); + await screenshot(mobilePage, "judge-wave-modal-mobile"); + + // Close modal + const cancelButton = mobilePage.locator('button:has-text("Cancel")').first(); + if (await cancelButton.isVisible()) { + await cancelButton.click(); + } + await mobilePage.waitForTimeout(300); + } + + // Open jump modal on mobile + const mobileJumpButtons = mobilePage.locator('button:has-text("Add Jump")'); + if (await mobileJumpButtons.count() > 0) { + await mobileJumpButtons.first().click(); + await mobilePage.waitForTimeout(500); + + // Select jump type then go to score screen + await mobilePage.locator('button:has-text("F")').first().click(); + const nextBtn = mobilePage.locator('button:has-text("Next")').first(); + if (await nextBtn.isVisible()) { + await nextBtn.click(); + await mobilePage.waitForTimeout(300); + } + + await screenshot(mobilePage, "judge-jump-modal-mobile"); + + const cancelBtn = mobilePage.locator('button:has-text("Cancel"), button:has-text("Back")').first(); + if (await cancelBtn.isVisible()) { + await cancelBtn.click(); + } + } + + await desktopContext.close(); + await mobileContext.close(); + }); + + test("Head Judge screenshots - desktop only", async ({ browser }) => { + const desktopContext = await browser.newContext({ viewport: DESKTOP }); + const desktopPage = await desktopContext.newPage(); + + await login(desktopPage, HEAD_JUDGE_USER.username, HEAD_JUDGE_USER.password); + + // Navigate to find a heat, then go to head judge view + const heatUrl = await findFirstHeatUrl(desktopPage); + + // Extract heatId from URL and navigate to head judge view + const heatIdMatch = heatUrl.match(/heats\/([^/?]+)/); + if (heatIdMatch) { + const heatId = heatIdMatch[1]; + await desktopPage.goto(`${BASE_URL}/head-judge/heats/${heatId}`); + await desktopPage.waitForTimeout(2000); // Wait for WebSocket data + + // Screenshot: Head judge view with scores + await screenshot(desktopPage, "headjudge-heats-desktop"); + } + + // Navigate to bracket view + // Go back to divisions page which shows brackets + await desktopPage.goto(`${BASE_URL}/`); + await desktopPage.locator("a").filter({ hasText: "2026 Season" }).first().click(); + await desktopPage.waitForURL(/\/contests/); + await desktopPage.locator("a").filter({ hasText: "Danish Open" }).first().click(); + await desktopPage.waitForURL(/\/divisions/); + + await desktopPage.waitForTimeout(1000); + + // Screenshot: Bracket/division overview + await screenshot(desktopPage, "headjudge-bracket-desktop"); + + await desktopContext.close(); + }); + + test("Spectator screenshots - desktop and mobile", async ({ browser }) => { + // First, we need to find a heat ID. Log in temporarily to navigate + const tempContext = await browser.newContext({ viewport: DESKTOP }); + const tempPage = await tempContext.newPage(); + await login(tempPage, JUDGE_USER.username, JUDGE_USER.password); + const heatUrl = await findFirstHeatUrl(tempPage); + const heatIdMatch = heatUrl.match(/heats\/([^/?]+)/); + const heatId = heatIdMatch ? heatIdMatch[1] : ""; + await tempContext.close(); + + if (!heatId) { + throw new Error("Could not find a heat ID for spectator screenshots"); + } + + // Desktop - public viewer (no auth needed) + const desktopContext = await browser.newContext({ viewport: DESKTOP }); + const desktopPage = await desktopContext.newPage(); + + // The viewer is served by the API server directly, not by Vite + await desktopPage.goto(`${API_URL}/viewer/${heatId}`); + await desktopPage.waitForTimeout(2000); // Wait for WebSocket data + + await screenshot(desktopPage, "spectator-viewer-desktop"); + await desktopContext.close(); + + // Mobile + const mobileContext = await browser.newContext({ viewport: MOBILE }); + const mobilePage = await mobileContext.newPage(); + + await mobilePage.goto(`${API_URL}/viewer/${heatId}`); + await mobilePage.waitForTimeout(2000); + + await screenshot(mobilePage, "spectator-viewer-mobile"); + await mobileContext.close(); + }); + +}); +``` + +**Important notes for the implementer:** +- The spec uses resilient locators (`has-text`, `first()`) since UI may evolve +- `waitForTimeout` calls give the app time to render and WebSocket to connect +- The `findFirstHeatUrl` helper navigates through the hierarchy to find a real heat +- Screenshot filenames match what `landing_page/index.html` references +- The viewer runs on the API server (port 3000/3260), not on Vite + +**Step 2: Verify the spec can be parsed** + +```bash +cd e2e && npx playwright test --list +``` + +Expected: Lists the 3 test cases without errors. + +**Step 3: Commit** + +```bash +git add e2e/screenshots.spec.ts +git commit -m "feat: add Playwright screenshot generation spec" +``` + +--- + +### Task 5: Create GitHub Actions screenshot workflow + +**Files:** +- Create: `.github/workflows/screenshots.yml` + +**Step 1: Create the workflow** + +Create `.github/workflows/screenshots.yml`: + +```yaml +name: Generate Landing Page Screenshots + +on: + push: + branches: [main] + workflow_dispatch: + +# Prevent screenshot commit from re-triggering +concurrency: + group: screenshots + cancel-in-progress: false + +jobs: + screenshots: + name: Generate Screenshots + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:18-alpine + env: + POSTGRES_USER: user + POSTGRES_PASSWORD: password + POSTGRES_DB: ws_scoring + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + POSTGRESQL_CONNECTION_STRING: postgresql://user:password@localhost:5432/ws_scoring + PORT: 3000 + CORS_ALLOWED_ORIGIN: http://localhost:5173 + API_TARGET: http://localhost:3000 + NODE_ENV: development + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Install app dependencies + run: bun install --frozen-lockfile + + - name: Run database migrations + run: bun run db:migrate + + - name: Seed database + run: bun run db:seed + + - name: Seed test users + run: bun run db:seed:users + + - name: Build frontend + run: bun run build:app + + - name: Start API server + run: | + bun run dev:api & + echo "Waiting for API server..." + for i in $(seq 1 30); do + curl -s http://localhost:3000/rpc > /dev/null && break + sleep 1 + done + echo "API server ready" + + - name: Start frontend dev server + run: | + bun run dev:app & + echo "Waiting for Vite dev server..." + for i in $(seq 1 30); do + curl -s http://localhost:5173 > /dev/null && break + sleep 1 + done + echo "Vite dev server ready" + + - name: Install Playwright + working-directory: e2e + run: | + npm ci + npx playwright install --with-deps chromium + + - name: Create screenshots directory + run: mkdir -p landing_page/screenshots + + - name: Run screenshot tests + working-directory: e2e + env: + BASE_URL: http://localhost:5173 + API_URL: http://localhost:3000 + run: npx playwright test + + - name: Commit and push screenshots + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add landing_page/screenshots/ + git diff --staged --quiet || git commit -m "chore: update landing page screenshots [skip ci]" + git push +``` + +**Key details:** +- Uses `[skip ci]` in the commit message to prevent re-triggering workflows +- PostgreSQL service container for real database +- Waits for both servers to be healthy before running Playwright +- Uses `concurrency` group to prevent parallel runs + +**Step 2: Commit** + +```bash +git add .github/workflows/screenshots.yml +git commit -m "feat: add GitHub Actions workflow for screenshot generation" +``` + +--- + +### Task 6: Update README.md with landing page link + +**Files:** +- Modify: `README.md` + +**Step 1: Add "See It In Action" section** + +Add immediately after the disclaimer section (after line 14), before "## Features": + +```markdown +## See It In Action + +Check out the [WS Scoring Landing Page](https://danbim.github.io/ws-scoring/) to see the app in action with screenshots for judges, head judges, and spectators. +``` + +**Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: add landing page link to README" +``` + +--- + +### Task 7: Update CLAUDE.md with landing page maintenance instructions + +**Files:** +- Modify: `CLAUDE.md` + +**Step 1: Add maintenance section** + +Add a new section at the end of `CLAUDE.md`: + +```markdown +## Landing Page + +The project has a public landing page at `landing_page/index.html` hosted on GitHub Pages. + +### Landing Page Maintenance + +When adding or changing user-facing features: +- Update `landing_page/index.html` feature descriptions to reflect the change +- If new screens/pages are added, consider adding Playwright screenshots in `e2e/screenshots.spec.ts` and updating the landing page layout +- Screenshots are auto-regenerated on push to main via `.github/workflows/screenshots.yml` + +### E2E Screenshot Tests + +- E2E tests live in `e2e/` with their own `package.json` (Playwright, isolated from main app) +- Run locally: `cd e2e && npm test` (requires app + seeded DB running) +- Screenshots saved to `landing_page/screenshots/` +- Test users created by `bun run db:seed:users` (judge1, judge2, headjudge) +``` + +**Step 2: Commit** + +```bash +git add CLAUDE.md +git commit -m "docs: add landing page maintenance instructions to CLAUDE.md" +``` + +--- + +### Task 8: Configure GitHub Pages and repository website + +**Step 1: Enable GitHub Pages** + +Use the GitHub API to enable Pages from the `landing_page` directory on the `main` branch. This will be done after merging to main, but document it here: + +```bash +gh api repos/danbim/ws-scoring/pages \ + --method POST \ + --field source='{"branch":"main","path":"/landing_page"}' \ + || echo "Pages may already be configured" +``` + +**Step 2: Set repository website URL** + +```bash +gh repo edit danbim/ws-scoring --homepage "https://danbim.github.io/ws-scoring/" +``` + +**Step 3: Verify** + +```bash +gh repo view danbim/ws-scoring --json homepageUrl +``` + +Expected: `{"homepageUrl":"https://danbim.github.io/ws-scoring/"}` + +**Note:** GitHub Pages configuration requires the branch to be `main`. These commands should be run after merging the `landing-page` branch, or can be configured in advance (Pages will show 404 until the `landing_page` directory exists on main). + +--- + +### Task 9: Add placeholder screenshots for development + +Until the CI generates real screenshots, add placeholder images so the landing page doesn't show broken images during development. + +**Files:** +- Create: `landing_page/screenshots/.gitkeep` + +**Step 1: Create the screenshots directory with gitkeep** + +```bash +mkdir -p landing_page/screenshots +touch landing_page/screenshots/.gitkeep +``` + +**Step 2: Commit** + +```bash +git add landing_page/screenshots/.gitkeep +git commit -m "chore: add screenshots directory placeholder" +``` + +--- + +### Task 10: Local E2E test run and screenshot verification + +This task verifies everything works end-to-end locally before merging. + +**Prerequisites:** PostgreSQL running on port 5692 (worktree port), app seeded with data and test users. + +**Step 1: Set up database and seed data** + +```bash +bun run db:migrate +bun run db:seed +bun run db:seed:users +``` + +**Step 2: Start the app servers** + +```bash +bun run dev:api & +bun run dev:app & +``` + +Wait for both to be ready. + +**Step 3: Run Playwright screenshot tests** + +```bash +cd e2e && BASE_URL=http://localhost:5433 API_URL=http://localhost:3260 npx playwright test +``` + +Expected: 3 tests pass, screenshots appear in `landing_page/screenshots/`. + +**Step 4: Verify screenshots** + +```bash +ls -la landing_page/screenshots/ +``` + +Expected files: +- `judge-scoresheet-desktop.png` +- `judge-scoresheet-mobile.png` +- `judge-wave-modal-desktop.png` +- `judge-wave-modal-mobile.png` +- `judge-jump-modal-desktop.png` +- `judge-jump-modal-mobile.png` +- `headjudge-bracket-desktop.png` +- `headjudge-heats-desktop.png` +- `spectator-viewer-desktop.png` +- `spectator-viewer-mobile.png` + +**Step 5: Open the landing page and verify it looks good** + +```bash +open landing_page/index.html +``` + +Verify screenshots appear correctly in each section. + +**Step 6: Commit screenshots (for the PR)** + +```bash +git add landing_page/screenshots/*.png +git commit -m "chore: add initial landing page screenshots" +``` + +--- + +### Task 11: Final quality checks and PR + +**Step 1: Run project quality checks** + +```bash +bun format +bun check:fix +bun typecheck +bun run check:boundaries +``` + +Fix any issues found. + +**Step 2: Commit any fixes** + +```bash +git add -A +git diff --staged --quiet || git commit -m "chore: fix lint and formatting issues" +``` + +**Step 3: Create pull request** + +```bash +gh pr create \ + --title "Add landing page with auto-generated screenshots" \ + --body "$(cat <<'EOF' +## Summary + +- Add public landing page (`landing_page/index.html`) with Tailwind CDN +- Organize features by audience: Judges, Head Judges, Spectators +- Desktop + mobile screenshots for Judges and Spectators, desktop only for Head Judges +- Playwright E2E tests generate screenshots from seeded app +- GitHub Actions workflow auto-regenerates screenshots on push to main +- GitHub Pages serves from `landing_page/` directory on main +- Landing page linked from README.md +- CLAUDE.md updated with maintenance instructions + +## Test plan + +- [ ] Open `landing_page/index.html` in browser - verify layout and styling +- [ ] Run `cd e2e && npx playwright test` locally - verify all screenshots generated +- [ ] Verify screenshots appear correctly in the landing page +- [ ] Verify GitHub Actions workflow syntax is valid +- [ ] After merge, enable GitHub Pages and verify site is live + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" \ + --base main +``` From dcc6715f475203df8145043e3487ccc317f6ad81 Mon Sep 17 00:00:00 2001 From: Daniel Bimschas Date: Mon, 2 Feb 2026 14:48:43 +0100 Subject: [PATCH 04/17] feat: add landing page HTML skeleton with Tailwind CSS Static landing page for GitHub Pages with ocean blue/teal gradient theme, sections for judges, head judges, and spectators, screenshot placeholders, and responsive layout using Tailwind CSS CDN. Co-Authored-By: Claude Opus 4.5 --- landing_page/index.html | 218 ++++++++++++++++++++++++++++++ landing_page/screenshots/.gitkeep | 0 2 files changed, 218 insertions(+) create mode 100644 landing_page/index.html create mode 100644 landing_page/screenshots/.gitkeep diff --git a/landing_page/index.html b/landing_page/index.html new file mode 100644 index 0000000..b9eb5bc --- /dev/null +++ b/landing_page/index.html @@ -0,0 +1,218 @@ + + + + + + WS Scoring - Windsurfing Contest Judging + + + + + + + +
+
+

+ WS Scoring +

+

+ Real-time windsurfing wave contest judging. Score waves and jumps, manage brackets, and stream results live. +

+ + Launch App + +
+
+ + +
+
+
+ For Judges +

Score Waves & Jumps in Real Time

+

+ Enter scores from your phone or tablet with the touch-optimized interface. Works offline and syncs automatically when reconnected. +

+
+ +
+
+ Heat score sheet - desktop view +

Heat score sheet with rider cards and live totals

+
+
+ Heat score sheet - mobile view +

Mobile-optimized for on-the-water judging

+
+
+ +
+
+ Wave score entry - desktop +

Wave score entry

+
+
+ Wave score entry - mobile +

Wave score (mobile)

+
+
+ Jump score entry - desktop +

Jump score with trick selection

+
+
+ Jump score entry - mobile +

Jump score (mobile)

+
+
+ +
    +
  • +
    🌊
    +

    Wave & Jump Scoring

    +

    Score 0-10 with decimal precision. Supports all PWA jump types.

    +
  • +
  • +
    📶
    +

    Offline Support

    +

    Keep scoring even without connectivity. Syncs when back online.

    +
  • +
  • +
    📱
    +

    Mobile Optimized

    +

    Touch-friendly on-screen keyboard designed for quick entry.

    +
  • +
+
+
+ + +
+
+
+ For Head Judges +

Oversee Scores & Manage Brackets

+

+ See all judges' scores side by side, manage single elimination brackets, and control heat progression. +

+
+ +
+
+ Bracket visualization +

Single elimination bracket with automatic progression

+
+
+ Head judge heat overview +

All judges' scores at a glance with final averages

+
+
+ +
    +
  • +
    🏆
    +

    Bracket Management

    +

    Single elimination brackets for 2-64 riders with automatic bye handling.

    +
  • +
  • +
    📊
    +

    Score Oversight

    +

    View all judges' scores side by side with computed averages.

    +
  • +
  • +
    +

    Heat Control

    +

    Complete heats and trigger automatic bracket advancement.

    +
  • +
+
+
+ + +
+
+
+ For Spectators +

Follow the Action Live

+

+ Watch scores update in real time from the beach or anywhere. No login required. +

+
+ +
+
+ Live heat viewer - desktop +

Live scoreboard with real-time WebSocket updates

+
+
+ Live heat viewer - mobile +

Works on any device, no app needed

+
+
+ +
    +
  • +
    +

    Real-Time Updates

    +

    Scores stream live via WebSocket. No page refresh needed.

    +
  • +
  • +
    🔓
    +

    No Login Required

    +

    Public viewer accessible to everyone. Share the link.

    +
  • +
  • +
    📺
    +

    Big Screen Ready

    +

    Embed the viewer on any display at the beach or event.

    +
  • +
+
+
+ + + + + + diff --git a/landing_page/screenshots/.gitkeep b/landing_page/screenshots/.gitkeep new file mode 100644 index 0000000..e69de29 From 62957f4411ff486f3367b11a1cc87bcb5bf5fa3f Mon Sep 17 00:00:00 2001 From: Daniel Bimschas Date: Mon, 2 Feb 2026 14:49:25 +0100 Subject: [PATCH 05/17] feat: add non-interactive seed script for E2E test users Add scripts/db/seed-users.ts that creates judge1, judge2, and headjudge test users without interactive prompts, for use in CI and local E2E setup. Skips users that already exist. Add db:seed:users npm script. Co-Authored-By: Claude Opus 4.5 --- package.json | 1 + scripts/db/seed-users.ts | 52 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 scripts/db/seed-users.ts diff --git a/package.json b/package.json index 0992aea..e98b32e 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "db:reset": "bun run scripts/db/reset.ts", "db:seed": "bun run scripts/db/seed.ts", "db:seed:dry-run": "bun run scripts/db/seed.ts --dry-run", + "db:seed:users": "bun run scripts/db/seed-users.ts", "create-heat": "bun run scripts/create-heat.ts", "submit-jump-score": "bun run scripts/submit-jump-score.ts", "submit-wave-score": "bun run scripts/submit-wave-score.ts", diff --git a/scripts/db/seed-users.ts b/scripts/db/seed-users.ts new file mode 100644 index 0000000..c1eb2a4 --- /dev/null +++ b/scripts/db/seed-users.ts @@ -0,0 +1,52 @@ +// Create test users for E2E screenshot generation +// Non-interactive — used by CI and local E2E setup + +import type { CreateUserInput } from "../../src/domain/user/types.js"; +import { getDb } from "../../src/infrastructure/db/index.js"; +import { createUserRepository } from "../../src/infrastructure/repositories/index.js"; + +const TEST_USERS: CreateUserInput[] = [ + { + username: "judge1", + password: "password123", + role: "judge", + email: null, + }, + { + username: "judge2", + password: "password123", + role: "judge", + email: null, + }, + { + username: "headjudge", + password: "password123", + role: "head_judge", + email: null, + }, +]; + +async function seedUsers() { + const db = await getDb(); + const userRepository = createUserRepository(db); + + for (const userInput of TEST_USERS) { + const existing = await userRepository.getUserByUsername(userInput.username); + if (existing) { + console.log(` User "${userInput.username}" already exists, skipping`); + continue; + } + const user = await userRepository.createUser(userInput); + console.log(` Created user: ${user.username} (${user.role})`); + } + + console.log("\nTest users ready."); + process.exit(0); +} + +if (import.meta.main) { + seedUsers().catch((error) => { + console.error("Failed to seed users:", error); + process.exit(1); + }); +} From 4936ba921fe520528f723196d88a5be4dc0f48ce Mon Sep 17 00:00:00 2001 From: Daniel Bimschas Date: Mon, 2 Feb 2026 14:49:38 +0100 Subject: [PATCH 06/17] chore: set up Playwright E2E project for screenshot generation Add isolated e2e/ directory with its own package.json, Playwright config, and TypeScript config for landing page screenshot generation. Update .gitignore to exclude e2e build artifacts, node_modules, and lock files. Co-Authored-By: Claude Opus 4.5 --- .gitignore | 7 +++++++ e2e/package.json | 11 +++++++++++ e2e/playwright.config.ts | 29 +++++++++++++++++++++++++++++ e2e/tsconfig.json | 11 +++++++++++ 4 files changed, 58 insertions(+) create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/tsconfig.json diff --git a/.gitignore b/.gitignore index 0c3f9c8..8e22055 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,10 @@ postgres-data/ # Reviews ran locally reviews/ + +# E2E tests +e2e/node_modules/ +e2e/bun.lock +e2e/package-lock.json +e2e/test-results/ +e2e/playwright-report/ diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..da6fa03 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,11 @@ +{ + "name": "ws-scoring-e2e", + "private": true, + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed" + }, + "devDependencies": { + "@playwright/test": "^1.50.0" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..9b8f4ce --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from "@playwright/test"; + +const BASE_URL = process.env.BASE_URL || "http://localhost:5173"; +const API_URL = process.env.API_URL || "http://localhost:3000"; + +export default defineConfig({ + testDir: ".", + testMatch: "**/*.spec.ts", + timeout: 60_000, + expect: { timeout: 10_000 }, + fullyParallel: false, + retries: 0, + workers: 1, + reporter: "list", + use: { + baseURL: BASE_URL, + screenshot: "off", + video: "off", + trace: "off", + }, + projects: [ + { + name: "screenshots", + use: { + browserName: "chromium", + }, + }, + ], +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..18993d9 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["*.ts"] +} From a00669b6c37bedd1152cfb858bb97bed967b15a8 Mon Sep 17 00:00:00 2001 From: Daniel Bimschas Date: Mon, 2 Feb 2026 14:52:58 +0100 Subject: [PATCH 07/17] feat: add GitHub Actions workflow for screenshot generation --- .github/workflows/screenshots.yml | 108 ++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 .github/workflows/screenshots.yml diff --git a/.github/workflows/screenshots.yml b/.github/workflows/screenshots.yml new file mode 100644 index 0000000..77b9f61 --- /dev/null +++ b/.github/workflows/screenshots.yml @@ -0,0 +1,108 @@ +name: Generate Landing Page Screenshots + +on: + push: + branches: [main] + workflow_dispatch: + +# Prevent screenshot commit from re-triggering +concurrency: + group: screenshots + cancel-in-progress: false + +jobs: + screenshots: + name: Generate Screenshots + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:18-alpine + env: + POSTGRES_USER: user + POSTGRES_PASSWORD: password + POSTGRES_DB: ws_scoring + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + POSTGRESQL_CONNECTION_STRING: postgresql://user:password@localhost:5432/ws_scoring + PORT: 3000 + CORS_ALLOWED_ORIGIN: http://localhost:5173 + API_TARGET: http://localhost:3000 + NODE_ENV: development + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Install app dependencies + run: bun install --frozen-lockfile + + - name: Run database migrations + run: bun run db:migrate + + - name: Seed database + run: bun run db:seed + + - name: Seed test users + run: bun run db:seed:users + + - name: Build frontend + run: bun run build:app + + - name: Start API server + run: | + bun run dev:api & + echo "Waiting for API server..." + for i in $(seq 1 30); do + curl -s http://localhost:3000/rpc > /dev/null && break + sleep 1 + done + echo "API server ready" + + - name: Start frontend dev server + run: | + bun run dev:app & + echo "Waiting for Vite dev server..." + for i in $(seq 1 30); do + curl -s http://localhost:5173 > /dev/null && break + sleep 1 + done + echo "Vite dev server ready" + + - name: Install Playwright + working-directory: e2e + run: | + npm ci + npx playwright install --with-deps chromium + + - name: Create screenshots directory + run: mkdir -p landing_page/screenshots + + - name: Run screenshot tests + working-directory: e2e + env: + BASE_URL: http://localhost:5173 + API_URL: http://localhost:3000 + run: npx playwright test + + - name: Commit and push screenshots + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add landing_page/screenshots/ + git diff --staged --quiet || git commit -m "chore: update landing page screenshots [skip ci]" + git push From 3befb8e672a05b788461a365745700101177a556 Mon Sep 17 00:00:00 2001 From: Daniel Bimschas Date: Mon, 2 Feb 2026 14:53:15 +0100 Subject: [PATCH 08/17] docs: add landing page link to README --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index d2e2a88..5f53b95 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,10 @@ a real project. Currently, I also intentionally don't focus on clean/component-based/reusable/testable frontend code (it is messy what the genie generated so far). This is fine ;) as I'll eventually throw it away and rebuild once I got user feedback. +## See It In Action + +Check out the [WS Scoring Landing Page](https://danbim.github.io/ws-scoring/) to see the app in action with screenshots for judges, head judges, and spectators. + ## Features - **PostgreSQL Database**: Relational database with Drizzle ORM for type-safe queries From 7cce970581651017977f914e3a422ba8fbd44731 Mon Sep 17 00:00:00 2001 From: Daniel Bimschas Date: Mon, 2 Feb 2026 14:53:18 +0100 Subject: [PATCH 09/17] docs: add landing page maintenance instructions to CLAUDE.md --- CLAUDE.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 3c2a6d6..a57b3fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -252,3 +252,21 @@ describe("MyComponent", () => { - Simple presentational components (just display props) - Components that are better tested via integration/E2E tests - Styling/layout (use visual regression testing instead) + +## Landing Page + +The project has a public landing page at `landing_page/index.html` hosted on GitHub Pages. + +### Landing Page Maintenance + +When adding or changing user-facing features: +- Update `landing_page/index.html` feature descriptions to reflect the change +- If new screens/pages are added, consider adding Playwright screenshots in `e2e/screenshots.spec.ts` and updating the landing page layout +- Screenshots are auto-regenerated on push to main via `.github/workflows/screenshots.yml` + +### E2E Screenshot Tests + +- E2E tests live in `e2e/` with their own `package.json` (Playwright, isolated from main app) +- Run locally: `cd e2e && npm test` (requires app + seeded DB running) +- Screenshots saved to `landing_page/screenshots/` +- Test users created by `bun run db:seed:users` (judge1, judge2, headjudge) From 715837eabc804a1534eb3b9fae2a28e3dece8711 Mon Sep 17 00:00:00 2001 From: Daniel Bimschas Date: Mon, 2 Feb 2026 14:59:31 +0100 Subject: [PATCH 10/17] feat: add Playwright screenshot generation spec --- e2e/screenshots.spec.ts | 270 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 e2e/screenshots.spec.ts diff --git a/e2e/screenshots.spec.ts b/e2e/screenshots.spec.ts new file mode 100644 index 0000000..996c3e5 --- /dev/null +++ b/e2e/screenshots.spec.ts @@ -0,0 +1,270 @@ +import { test, expect, type Page } from "@playwright/test"; +import path from "node:path"; + +const SCREENSHOT_DIR = path.resolve(__dirname, "../landing_page/screenshots"); + +const DESKTOP = { width: 1280, height: 800 }; +const MOBILE = { width: 390, height: 844 }; + +const BASE_URL = process.env.BASE_URL || "http://localhost:5173"; +const API_URL = process.env.API_URL || "http://localhost:3000"; + +// Credentials matching scripts/db/seed-users.ts +const JUDGE_USER = { username: "judge1", password: "password123" }; +const HEAD_JUDGE_USER = { username: "headjudge", password: "password123" }; + +async function screenshot(page: Page, name: string) { + await page.screenshot({ + path: path.join(SCREENSHOT_DIR, `${name}.png`), + fullPage: false, + }); +} + +async function login(page: Page, username: string, password: string) { + await page.goto(`${BASE_URL}/login`); + await page.fill('input[name="username"]', username); + await page.fill('input[name="password"]', password); + await page.click('button[type="submit"]'); + await page.waitForURL(`${BASE_URL}/`); +} + +async function logout(page: Page) { + // Click logout button in the navbar + await page.click('button:has-text("Logout")'); + await page.waitForURL(`${BASE_URL}/login`); +} + +// Helper to find the first heat with riders so we can navigate to it +async function findFirstHeatUrl(page: Page): Promise { + // Navigate: Seasons -> first contest -> first division -> first bracket -> first heat + await page.goto(`${BASE_URL}/`); + // Click the first season link + await page.locator("a").filter({ hasText: "2026 Season" }).first().click(); + await page.waitForURL(/\/contests/); + + // Click the first contest + await page.locator("a").filter({ hasText: "Danish Open" }).first().click(); + await page.waitForURL(/\/divisions/); + + // Click the first division + await page.locator("a").first().click(); + await page.waitForURL(/\/participants|\/brackets/); + + // We need to navigate to a heat - look for heat links on the page + // The bracket view should show heats + const heatLink = page.locator('a[href*="/heats/"]').first(); + await heatLink.click(); + await page.waitForURL(/\/heats\//); + + return page.url(); +} + +test.describe("Screenshot generation", () => { + + test("Judge screenshots - desktop and mobile", async ({ browser }) => { + // Desktop context + const desktopContext = await browser.newContext({ viewport: DESKTOP }); + const desktopPage = await desktopContext.newPage(); + + await login(desktopPage, JUDGE_USER.username, JUDGE_USER.password); + + // Navigate to a heat + const heatUrl = await findFirstHeatUrl(desktopPage); + + // Screenshot: Score sheet (desktop) + await desktopPage.waitForTimeout(1000); // Let scores render + await screenshot(desktopPage, "judge-scoresheet-desktop"); + + // Add wave scores to make it look realistic + const addWaveButtons = desktopPage.locator('button:has-text("Add Wave")'); + if (await addWaveButtons.count() > 0) { + // Add wave score for first rider + await addWaveButtons.first().click(); + await desktopPage.waitForTimeout(500); + + // Screenshot: Wave modal (desktop) + await screenshot(desktopPage, "judge-wave-modal-desktop"); + + // Enter a score using on-screen keyboard + await desktopPage.locator('button:has-text("7")').first().click(); + await desktopPage.locator('button:has-text(".")').first().click(); + await desktopPage.locator('button:has-text("5")').first().click(); + // Submit the score + const submitButton = desktopPage.locator('button:has-text("Submit"), button:has-text("Save"), button:has-text("OK")').first(); + if (await submitButton.isVisible()) { + await submitButton.click(); + } + await desktopPage.waitForTimeout(500); + + // Add more wave scores for realism + if (await addWaveButtons.count() > 0) { + await addWaveButtons.first().click(); + await desktopPage.waitForTimeout(300); + await desktopPage.locator('button:has-text("8")').first().click(); + const submitBtn = desktopPage.locator('button:has-text("Submit"), button:has-text("Save"), button:has-text("OK")').first(); + if (await submitBtn.isVisible()) { + await submitBtn.click(); + } + await desktopPage.waitForTimeout(500); + } + } + + // Add jump score + const addJumpButtons = desktopPage.locator('button:has-text("Add Jump")'); + if (await addJumpButtons.count() > 0) { + await addJumpButtons.first().click(); + await desktopPage.waitForTimeout(500); + + // Screenshot: Jump modal step 1 - trick selection (desktop) + // Select a jump type (e.g., Forward) + await desktopPage.locator('button:has-text("F")').first().click(); + await desktopPage.waitForTimeout(300); + + // Click Next to go to score entry + const nextButton = desktopPage.locator('button:has-text("Next")').first(); + if (await nextButton.isVisible()) { + await nextButton.click(); + await desktopPage.waitForTimeout(300); + } + + await screenshot(desktopPage, "judge-jump-modal-desktop"); + + // Enter jump score + await desktopPage.locator('button:has-text("8")').first().click(); + await desktopPage.locator('button:has-text(".")').first().click(); + await desktopPage.locator('button:has-text("5")').first().click(); + const submitJump = desktopPage.locator('button:has-text("Submit"), button:has-text("Save"), button:has-text("OK")').first(); + if (await submitJump.isVisible()) { + await submitJump.click(); + } + await desktopPage.waitForTimeout(500); + } + + // Re-screenshot score sheet with scores filled in + await screenshot(desktopPage, "judge-scoresheet-desktop"); + + // Mobile context - same heat URL + const mobileContext = await browser.newContext({ viewport: MOBILE }); + const mobilePage = await mobileContext.newPage(); + + await login(mobilePage, JUDGE_USER.username, JUDGE_USER.password); + await mobilePage.goto(heatUrl); + await mobilePage.waitForTimeout(1000); + + // Screenshot: Score sheet (mobile) + await screenshot(mobilePage, "judge-scoresheet-mobile"); + + // Open wave modal on mobile + const mobileWaveButtons = mobilePage.locator('button:has-text("Add Wave")'); + if (await mobileWaveButtons.count() > 0) { + await mobileWaveButtons.first().click(); + await mobilePage.waitForTimeout(500); + await screenshot(mobilePage, "judge-wave-modal-mobile"); + + // Close modal + const cancelButton = mobilePage.locator('button:has-text("Cancel")').first(); + if (await cancelButton.isVisible()) { + await cancelButton.click(); + } + await mobilePage.waitForTimeout(300); + } + + // Open jump modal on mobile + const mobileJumpButtons = mobilePage.locator('button:has-text("Add Jump")'); + if (await mobileJumpButtons.count() > 0) { + await mobileJumpButtons.first().click(); + await mobilePage.waitForTimeout(500); + + // Select jump type then go to score screen + await mobilePage.locator('button:has-text("F")').first().click(); + const nextBtn = mobilePage.locator('button:has-text("Next")').first(); + if (await nextBtn.isVisible()) { + await nextBtn.click(); + await mobilePage.waitForTimeout(300); + } + + await screenshot(mobilePage, "judge-jump-modal-mobile"); + + const cancelBtn = mobilePage.locator('button:has-text("Cancel"), button:has-text("Back")').first(); + if (await cancelBtn.isVisible()) { + await cancelBtn.click(); + } + } + + await desktopContext.close(); + await mobileContext.close(); + }); + + test("Head Judge screenshots - desktop only", async ({ browser }) => { + const desktopContext = await browser.newContext({ viewport: DESKTOP }); + const desktopPage = await desktopContext.newPage(); + + await login(desktopPage, HEAD_JUDGE_USER.username, HEAD_JUDGE_USER.password); + + // Navigate to find a heat, then go to head judge view + const heatUrl = await findFirstHeatUrl(desktopPage); + + // Extract heatId from URL and navigate to head judge view + const heatIdMatch = heatUrl.match(/heats\/([^/?]+)/); + if (heatIdMatch) { + const heatId = heatIdMatch[1]; + await desktopPage.goto(`${BASE_URL}/head-judge/heats/${heatId}`); + await desktopPage.waitForTimeout(2000); // Wait for WebSocket data + + // Screenshot: Head judge view with scores + await screenshot(desktopPage, "headjudge-heats-desktop"); + } + + // Navigate to bracket view + // Go back to divisions page which shows brackets + await desktopPage.goto(`${BASE_URL}/`); + await desktopPage.locator("a").filter({ hasText: "2026 Season" }).first().click(); + await desktopPage.waitForURL(/\/contests/); + await desktopPage.locator("a").filter({ hasText: "Danish Open" }).first().click(); + await desktopPage.waitForURL(/\/divisions/); + + await desktopPage.waitForTimeout(1000); + + // Screenshot: Bracket/division overview + await screenshot(desktopPage, "headjudge-bracket-desktop"); + + await desktopContext.close(); + }); + + test("Spectator screenshots - desktop and mobile", async ({ browser }) => { + // First, we need to find a heat ID. Log in temporarily to navigate + const tempContext = await browser.newContext({ viewport: DESKTOP }); + const tempPage = await tempContext.newPage(); + await login(tempPage, JUDGE_USER.username, JUDGE_USER.password); + const heatUrl = await findFirstHeatUrl(tempPage); + const heatIdMatch = heatUrl.match(/heats\/([^/?]+)/); + const heatId = heatIdMatch ? heatIdMatch[1] : ""; + await tempContext.close(); + + if (!heatId) { + throw new Error("Could not find a heat ID for spectator screenshots"); + } + + // Desktop - public viewer (no auth needed) + const desktopContext = await browser.newContext({ viewport: DESKTOP }); + const desktopPage = await desktopContext.newPage(); + + // The viewer is served by the API server directly, not by Vite + await desktopPage.goto(`${API_URL}/viewer/${heatId}`); + await desktopPage.waitForTimeout(2000); // Wait for WebSocket data + + await screenshot(desktopPage, "spectator-viewer-desktop"); + await desktopContext.close(); + + // Mobile + const mobileContext = await browser.newContext({ viewport: MOBILE }); + const mobilePage = await mobileContext.newPage(); + + await mobilePage.goto(`${API_URL}/viewer/${heatId}`); + await mobilePage.waitForTimeout(2000); + + await screenshot(mobilePage, "spectator-viewer-mobile"); + await mobileContext.close(); + }); + +}); From ed941b2854641b4dd2db20affe2e39580ad726ae Mon Sep 17 00:00:00 2001 From: Daniel Bimschas Date: Mon, 2 Feb 2026 15:08:56 +0100 Subject: [PATCH 11/17] feat: add GitHub Pages deployment workflow Uses the modern Actions-based Pages deployment since the classic source.path only supports / or /docs, not /landing_page. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/pages.yml | 41 +++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/pages.yml diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..de6e0a3 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,41 @@ +name: Deploy Landing Page to GitHub Pages + +on: + push: + branches: [main] + paths: + - 'landing_page/**' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + deploy: + name: Deploy to GitHub Pages + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Pages + uses: actions/configure-pages@v5 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: landing_page + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 From c6aa5cde81349a07606f86722ae1dc97dc5c1856 Mon Sep 17 00:00:00 2001 From: Daniel Bimschas Date: Mon, 2 Feb 2026 15:09:53 +0100 Subject: [PATCH 12/17] chore: fix formatting and remove unused imports in screenshot spec Co-Authored-By: Claude Opus 4.5 --- e2e/screenshots.spec.ts | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/e2e/screenshots.spec.ts b/e2e/screenshots.spec.ts index 996c3e5..8d31bac 100644 --- a/e2e/screenshots.spec.ts +++ b/e2e/screenshots.spec.ts @@ -1,5 +1,5 @@ -import { test, expect, type Page } from "@playwright/test"; import path from "node:path"; +import { type Page, test } from "@playwright/test"; const SCREENSHOT_DIR = path.resolve(__dirname, "../landing_page/screenshots"); @@ -28,12 +28,6 @@ async function login(page: Page, username: string, password: string) { await page.waitForURL(`${BASE_URL}/`); } -async function logout(page: Page) { - // Click logout button in the navbar - await page.click('button:has-text("Logout")'); - await page.waitForURL(`${BASE_URL}/login`); -} - // Helper to find the first heat with riders so we can navigate to it async function findFirstHeatUrl(page: Page): Promise { // Navigate: Seasons -> first contest -> first division -> first bracket -> first heat @@ -60,7 +54,6 @@ async function findFirstHeatUrl(page: Page): Promise { } test.describe("Screenshot generation", () => { - test("Judge screenshots - desktop and mobile", async ({ browser }) => { // Desktop context const desktopContext = await browser.newContext({ viewport: DESKTOP }); @@ -77,7 +70,7 @@ test.describe("Screenshot generation", () => { // Add wave scores to make it look realistic const addWaveButtons = desktopPage.locator('button:has-text("Add Wave")'); - if (await addWaveButtons.count() > 0) { + if ((await addWaveButtons.count()) > 0) { // Add wave score for first rider await addWaveButtons.first().click(); await desktopPage.waitForTimeout(500); @@ -90,18 +83,22 @@ test.describe("Screenshot generation", () => { await desktopPage.locator('button:has-text(".")').first().click(); await desktopPage.locator('button:has-text("5")').first().click(); // Submit the score - const submitButton = desktopPage.locator('button:has-text("Submit"), button:has-text("Save"), button:has-text("OK")').first(); + const submitButton = desktopPage + .locator('button:has-text("Submit"), button:has-text("Save"), button:has-text("OK")') + .first(); if (await submitButton.isVisible()) { await submitButton.click(); } await desktopPage.waitForTimeout(500); // Add more wave scores for realism - if (await addWaveButtons.count() > 0) { + if ((await addWaveButtons.count()) > 0) { await addWaveButtons.first().click(); await desktopPage.waitForTimeout(300); await desktopPage.locator('button:has-text("8")').first().click(); - const submitBtn = desktopPage.locator('button:has-text("Submit"), button:has-text("Save"), button:has-text("OK")').first(); + const submitBtn = desktopPage + .locator('button:has-text("Submit"), button:has-text("Save"), button:has-text("OK")') + .first(); if (await submitBtn.isVisible()) { await submitBtn.click(); } @@ -111,7 +108,7 @@ test.describe("Screenshot generation", () => { // Add jump score const addJumpButtons = desktopPage.locator('button:has-text("Add Jump")'); - if (await addJumpButtons.count() > 0) { + if ((await addJumpButtons.count()) > 0) { await addJumpButtons.first().click(); await desktopPage.waitForTimeout(500); @@ -133,7 +130,9 @@ test.describe("Screenshot generation", () => { await desktopPage.locator('button:has-text("8")').first().click(); await desktopPage.locator('button:has-text(".")').first().click(); await desktopPage.locator('button:has-text("5")').first().click(); - const submitJump = desktopPage.locator('button:has-text("Submit"), button:has-text("Save"), button:has-text("OK")').first(); + const submitJump = desktopPage + .locator('button:has-text("Submit"), button:has-text("Save"), button:has-text("OK")') + .first(); if (await submitJump.isVisible()) { await submitJump.click(); } @@ -156,7 +155,7 @@ test.describe("Screenshot generation", () => { // Open wave modal on mobile const mobileWaveButtons = mobilePage.locator('button:has-text("Add Wave")'); - if (await mobileWaveButtons.count() > 0) { + if ((await mobileWaveButtons.count()) > 0) { await mobileWaveButtons.first().click(); await mobilePage.waitForTimeout(500); await screenshot(mobilePage, "judge-wave-modal-mobile"); @@ -171,7 +170,7 @@ test.describe("Screenshot generation", () => { // Open jump modal on mobile const mobileJumpButtons = mobilePage.locator('button:has-text("Add Jump")'); - if (await mobileJumpButtons.count() > 0) { + if ((await mobileJumpButtons.count()) > 0) { await mobileJumpButtons.first().click(); await mobilePage.waitForTimeout(500); @@ -185,7 +184,9 @@ test.describe("Screenshot generation", () => { await screenshot(mobilePage, "judge-jump-modal-mobile"); - const cancelBtn = mobilePage.locator('button:has-text("Cancel"), button:has-text("Back")').first(); + const cancelBtn = mobilePage + .locator('button:has-text("Cancel"), button:has-text("Back")') + .first(); if (await cancelBtn.isVisible()) { await cancelBtn.click(); } @@ -266,5 +267,4 @@ test.describe("Screenshot generation", () => { await screenshot(mobilePage, "spectator-viewer-mobile"); await mobileContext.close(); }); - }); From 9ff7b18f3c2afd3061866644548de45679fe9963 Mon Sep 17 00:00:00 2001 From: Daniel Bimschas Date: Tue, 3 Feb 2026 10:22:15 +0100 Subject: [PATCH 13/17] feat: add bun script to run E2E tests Run with: bun run test:e2e Co-Authored-By: Claude Opus 4.5 --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index e98b32e..40feea8 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "test:components": "vitest", "test:components:run": "vitest run", "test:all": "bun test --shuffle __tests__/api __tests__/domain __tests__/integration && vitest run", + "test:e2e": "cd e2e && npx playwright test", "lint": "biome lint --error-on-warnings .", "lint:fix": "biome lint --error-on-warnings --write .", "format": "biome format --write .", From e3eaef678531f689eb3b2b96a6175ee782eeecda Mon Sep 17 00:00:00 2001 From: Daniel Bimschas Date: Tue, 3 Feb 2026 10:25:23 +0100 Subject: [PATCH 14/17] fix: use env vars for E2E test ports Reads VITE_DEV_PORT and PORT from .env (bun auto-loads it) so E2E tests work in worktrees with non-default ports. Co-Authored-By: Claude Opus 4.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 40feea8..9b896a3 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "test:components": "vitest", "test:components:run": "vitest run", "test:all": "bun test --shuffle __tests__/api __tests__/domain __tests__/integration && vitest run", - "test:e2e": "cd e2e && npx playwright test", + "test:e2e": "cd e2e && BASE_URL=http://localhost:${VITE_DEV_PORT:-5173} API_URL=http://localhost:${PORT:-3000} npx playwright test", "lint": "biome lint --error-on-warnings .", "lint:fix": "biome lint --error-on-warnings --write .", "format": "biome format --write .", From 31a218f93038ef30c74f36b51418d681136a0c49 Mon Sep 17 00:00:00 2001 From: Daniel Bimschas Date: Wed, 4 Feb 2026 10:15:38 +0100 Subject: [PATCH 15/17] refactor: remove POSTGRESQL_CONNECTION_STRING in favor of individual env vars Simplify database configuration by removing support for the full connection string and requiring individual components: - POSTGRES_HOST (default: localhost) - POSTGRES_PORT (default: 5432) - POSTGRES_USER (required) - POSTGRES_PASSWORD (required) - POSTGRES_DB (required) Updated: - src/infrastructure/db/index.ts - drizzle.config.ts - scripts/db/reset.ts - docker-compose.yml - docker-compose.dev.yml - .github/workflows/screenshots.yml - .env.example - README.md - scripts/worktree-init.sh Co-Authored-By: Claude Opus 4.5 --- .env.example | 3 ++- .github/workflows/screenshots.yml | 6 +++++- README.md | 13 +++++++++---- docker-compose.dev.yml | 8 ++++++-- docker-compose.yml | 6 +++++- drizzle.config.ts | 3 +-- scripts/db/reset.ts | 4 +--- scripts/worktree-init.sh | 7 ++++--- src/infrastructure/db/index.ts | 3 +-- 9 files changed, 34 insertions(+), 19 deletions(-) diff --git a/.env.example b/.env.example index 2b758e2..0b64e54 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ # Database Configuration -POSTGRESQL_CONNECTION_STRING=postgresql://user:password@localhost:5432/ws_scoring +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 POSTGRES_USER=user POSTGRES_PASSWORD=password POSTGRES_DB=ws_scoring diff --git a/.github/workflows/screenshots.yml b/.github/workflows/screenshots.yml index 77b9f61..930bf7c 100644 --- a/.github/workflows/screenshots.yml +++ b/.github/workflows/screenshots.yml @@ -31,7 +31,11 @@ jobs: --health-retries 5 env: - POSTGRESQL_CONNECTION_STRING: postgresql://user:password@localhost:5432/ws_scoring + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + POSTGRES_USER: user + POSTGRES_PASSWORD: password + POSTGRES_DB: ws_scoring PORT: 3000 CORS_ALLOWED_ORIGIN: http://localhost:5173 API_TARGET: http://localhost:3000 diff --git a/README.md b/README.md index 5f53b95..6e10a55 100644 --- a/README.md +++ b/README.md @@ -54,13 +54,17 @@ Make sure you have a PostgreSQL instance running, e.g. by starting a correspondi docker run -e POSTGRES_USER=user -e POSTGRES_PASSWORD=password -e POSTGRES_DB=ws_scoring -p 5432:5432 postgres:18-alpine ``` -Set the `POSTGRESQL_CONNECTION_STRING` environment variable with your database connection details: +Configure the database connection using environment variables (or copy `.env.example` to `.env`): ```bash -export POSTGRESQL_CONNECTION_STRING="postgresql://user:password@host:port/database" +export POSTGRES_HOST=localhost +export POSTGRES_PORT=5432 +export POSTGRES_USER=user +export POSTGRES_PASSWORD=password +export POSTGRES_DB=ws_scoring ``` -If not provided, it defaults to `postgresql://localhost:5432/postgres`. +`POSTGRES_HOST` defaults to `localhost` and `POSTGRES_PORT` defaults to `5432` if not provided. #### Drizzle Database Migrations @@ -238,10 +242,11 @@ The application will be available on the configured `PORT` (default: 3000). Create a `.env` file based on `.env.example`: +- `POSTGRES_HOST` - PostgreSQL hostname (default: localhost) +- `POSTGRES_PORT` - PostgreSQL port (default: 5432) - `POSTGRES_USER` - PostgreSQL username (required for production, no default) - `POSTGRES_PASSWORD` - PostgreSQL password (required for production, no default) - `POSTGRES_DB` - Database name (default: ws_scoring) -- `POSTGRESQL_CONNECTION_STRING` - Full connection string (optional, overrides above) - `PORT` - API server port (default: 3000) - `CORS_ALLOWED_ORIGIN` - CORS allowed origin (default: http://localhost:5173 for dev, http://localhost:3000 for production) - `API_TARGET` - Target URL for Vite proxy (default: http://localhost:3000, or http://app:3000 in Docker) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 0d1c0f7..7752be0 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -22,7 +22,11 @@ services: context: . dockerfile: Dockerfile environment: - POSTGRESQL_CONNECTION_STRING: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-ws_scoring} + POSTGRES_HOST: postgres + POSTGRES_PORT: 5432 + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_DB: ${POSTGRES_DB:-ws_scoring} PORT: ${PORT:-3000} CORS_ALLOWED_ORIGIN: ${CORS_ALLOWED_ORIGIN:-http://localhost:5173} ports: @@ -65,7 +69,7 @@ services: - --port - "8080" - --dsn - - "postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-ws_scoring}" + - "postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-ws_scoring}" depends_on: postgres: condition: service_healthy diff --git a/docker-compose.yml b/docker-compose.yml index 7924a1d..de48a72 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,7 +23,11 @@ services: dockerfile: Dockerfile container_name: ws-scoring-app environment: - POSTGRESQL_CONNECTION_STRING: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-ws_scoring} + POSTGRES_HOST: postgres + POSTGRES_PORT: 5432 + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB:-ws_scoring} PORT: ${PORT:-3000} CORS_ALLOWED_ORIGIN: ${CORS_ALLOWED_ORIGIN:-} ports: diff --git a/drizzle.config.ts b/drizzle.config.ts index 77fcb6d..bda2638 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,8 +1,7 @@ import { defineConfig } from "drizzle-kit"; const getPostgresConnectionString = () => - process.env.POSTGRESQL_CONNECTION_STRING ?? - `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@${process.env.POSTGRES_HOST}:${process.env.POSTGRES_PORT}/${process.env.POSTGRES_DB}`; + `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@${process.env.POSTGRES_HOST ?? "localhost"}:${process.env.POSTGRES_PORT ?? "5432"}/${process.env.POSTGRES_DB}`; export default defineConfig({ schema: "./src/infrastructure/db/schema.ts", diff --git a/scripts/db/reset.ts b/scripts/db/reset.ts index 157d0ac..e95e122 100644 --- a/scripts/db/reset.ts +++ b/scripts/db/reset.ts @@ -2,9 +2,7 @@ import { Client } from "pg"; -const connectionString = - process.env.POSTGRESQL_CONNECTION_STRING ?? - `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@${process.env.POSTGRES_HOST}:${process.env.POSTGRES_PORT}/${process.env.POSTGRES_DB}`; +const connectionString = `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@${process.env.POSTGRES_HOST ?? "localhost"}:${process.env.POSTGRES_PORT ?? "5432"}/${process.env.POSTGRES_DB}`; async function findDrizzleTables(client: Client): Promise { // Query to find drizzle-managed relational tables diff --git a/scripts/worktree-init.sh b/scripts/worktree-init.sh index 36bf7f4..32e23d0 100755 --- a/scripts/worktree-init.sh +++ b/scripts/worktree-init.sh @@ -43,14 +43,14 @@ OFFSET=$(( (HASH % 100 + 1) * 10 )) API_PORT=$(( 3000 + OFFSET )) VITE_PORT=$(( 5173 + OFFSET )) -PG_PORT=$(( 5432 + OFFSET )) +POSTGRES_PORT=$(( 5432 + OFFSET )) DBHUB_PORT=$(( 8080 + OFFSET )) # Sanitize worktree name for use as a database name suffix (lowercase, replace non-alphanumeric with _) DB_SUFFIX=$(echo "$WORKTREE_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g') echo "[worktree-init] Worktree: $WORKTREE_NAME" -echo "[worktree-init] API port: $API_PORT, Vite port: $VITE_PORT, PG port: $PG_PORT, DBHub port: $DBHUB_PORT, DB: ws_scoring_$DB_SUFFIX" +echo "[worktree-init] API port: $API_PORT, Vite port: $VITE_PORT, PG port: $POSTGRES_PORT, DBHub port: $DBHUB_PORT, DB: ws_scoring_$DB_SUFFIX" # --- Generate .env from .env.example --- if [ -f "$WORKTREE_ROOT/.env" ]; then @@ -68,7 +68,8 @@ else -e "s|^VITE_DEV_PORT=.*|VITE_DEV_PORT=$VITE_PORT|" \ -e "s|^VITE_API_PORT=.*|VITE_API_PORT=$API_PORT|" \ -e "s|^POSTGRES_DB=.*|POSTGRES_DB=ws_scoring_$DB_SUFFIX|" \ - -e "s|localhost:5432/ws_scoring|localhost:$PG_PORT/ws_scoring_$DB_SUFFIX|" \ + -e "s|^POSTGRES_PORT=.*|POSTGRES_PORT=$POSTGRES_PORT|" \ + -e "s|localhost:5432/ws_scoring|localhost:$POSTGRES_PORT/ws_scoring_$DB_SUFFIX|" \ -e "s|^DBHUB_PORT=.*|DBHUB_PORT=$DBHUB_PORT|" \ "$ENV_EXAMPLE" > "$WORKTREE_ROOT/.env" echo "[worktree-init] .env generated" diff --git a/src/infrastructure/db/index.ts b/src/infrastructure/db/index.ts index f6b900a..ebce2cd 100644 --- a/src/infrastructure/db/index.ts +++ b/src/infrastructure/db/index.ts @@ -4,8 +4,7 @@ import { Pool } from "pg"; import * as schema from "./schema.js"; const getPostgresConnectionString = () => - process.env.POSTGRESQL_CONNECTION_STRING ?? - `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@${process.env.POSTGRES_HOST}:${process.env.POSTGRES_PORT}/${process.env.POSTGRES_DB}`; + `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@${process.env.POSTGRES_HOST ?? "localhost"}:${process.env.POSTGRES_PORT ?? "5432"}/${process.env.POSTGRES_DB}`; const pool = new Pool({ connectionString: getPostgresConnectionString() }); const db = drizzle(pool, { schema }); From 9652964dcbf0267f2308750a68b2fe3f74ed09f8 Mon Sep 17 00:00:00 2001 From: Daniel Bimschas Date: Wed, 4 Feb 2026 11:47:45 +0100 Subject: [PATCH 16/17] (dx) cleanly setup worktree initialization and simplify env var configuration --- .env.example | 27 +++++++++++---------------- docker-compose.dev.yml | 33 ++++++++++++++++++--------------- docker-compose.yml | 21 ++++++++++++--------- e2e/playwright.config.ts | 1 - scripts/worktree-init.sh | 9 ++++----- server.ts | 25 +++++++++++++------------ src/app/utils/viewerUrl.ts | 4 +--- src/app/utils/websocket.ts | 2 +- src/infrastructure/db/index.ts | 2 +- vite.config.ts | 18 ++++++++++++++---- 10 files changed, 75 insertions(+), 67 deletions(-) diff --git a/.env.example b/.env.example index 0b64e54..4ede375 100644 --- a/.env.example +++ b/.env.example @@ -1,24 +1,19 @@ -# Database Configuration +# API Server +API_PORT=3000 +API_CORS_ALLOWED_ORIGIN=http://localhost:5173 +API_CORS_DEV_ORIGIN=http://localhost:5173 +NODE_ENV=development + +# Database (shared between postgres container and API) POSTGRES_HOST=localhost POSTGRES_PORT=5432 -POSTGRES_USER=user -POSTGRES_PASSWORD=password +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres POSTGRES_DB=ws_scoring -# Server Configuration -PORT=3000 -NODE_ENV=development - -# CORS Configuration -CORS_ALLOWED_ORIGIN=http://localhost:5173 - -# Vite Proxy Configuration (for docker-compose.dev.yml) -API_TARGET=http://localhost:3000 - -# Vite Dev Server Port (used by vite.config.ts) +# Vite/Frontend VITE_DEV_PORT=5173 - -# API port exposed to frontend for WebSocket direct connections +VITE_API_TARGET=http://localhost:3000 VITE_API_PORT=3000 # MCP DBHub port (for docker-compose.dev.yml) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 7752be0..074d743 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -2,15 +2,15 @@ services: postgres: image: postgres:18-alpine environment: - POSTGRES_USER: ${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} - POSTGRES_DB: ${POSTGRES_DB:-ws_scoring} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} ports: - - "${POSTGRES_PORT:-5432}:5432" + - "${POSTGRES_PORT}:5432" volumes: - postgres-dev-data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 5s timeout: 5s retries: 5 @@ -22,15 +22,17 @@ services: context: . dockerfile: Dockerfile environment: + NODE_ENV: development POSTGRES_HOST: postgres POSTGRES_PORT: 5432 - POSTGRES_USER: ${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} - POSTGRES_DB: ${POSTGRES_DB:-ws_scoring} - PORT: ${PORT:-3000} - CORS_ALLOWED_ORIGIN: ${CORS_ALLOWED_ORIGIN:-http://localhost:5173} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + API_PORT: 3000 + API_CORS_ALLOWED_ORIGIN: ${API_CORS_ALLOWED_ORIGIN} + API_CORS_DEV_ORIGIN: ${API_CORS_DEV_ORIGIN} ports: - - "${PORT:-3000}:3000" + - "${API_PORT}:3000" volumes: - .:/app - /app/node_modules @@ -47,9 +49,10 @@ services: dockerfile: Dockerfile environment: NODE_ENV: development - API_TARGET: http://app:3000 + VITE_API_TARGET: http://app:3000 + VITE_DEV_PORT: 5173 ports: - - "${VITE_DEV_PORT:-5173}:5173" + - "${VITE_DEV_PORT}:5173" volumes: - .:/app - /app/node_modules @@ -62,14 +65,14 @@ services: mcp-dbhub: image: bytebase/dbhub:latest ports: - - "${DBHUB_PORT:-8080}:8080" + - "${DBHUB_PORT}:8080" command: - --transport - http - --port - "8080" - --dsn - - "postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-ws_scoring}" + - "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}" depends_on: postgres: condition: service_healthy diff --git a/docker-compose.yml b/docker-compose.yml index de48a72..b8d4767 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,14 +3,16 @@ services: image: postgres:18-alpine container_name: ws-scoring-postgres environment: - POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER environment variable is required} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD environment variable is required} - POSTGRES_DB: ${POSTGRES_DB:-ws_scoring} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + ports: + - "${POSTGRES_PORT}:5432" volumes: - postgres-data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] - interval: 10s + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 5s timeout: 5s retries: 5 restart: unless-stopped @@ -23,15 +25,16 @@ services: dockerfile: Dockerfile container_name: ws-scoring-app environment: + NODE_ENV: production POSTGRES_HOST: postgres POSTGRES_PORT: 5432 POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_DB:-ws_scoring} - PORT: ${PORT:-3000} - CORS_ALLOWED_ORIGIN: ${CORS_ALLOWED_ORIGIN:-} + POSTGRES_DB: ${POSTGRES_DB} + API_PORT: 3000 + API_CORS_ALLOWED_ORIGIN: ${API_CORS_ALLOWED_ORIGIN} ports: - - "${PORT:-3000}:3000" + - "${API_PORT}:3000" depends_on: postgres: condition: service_healthy diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 9b8f4ce..b639d54 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,7 +1,6 @@ import { defineConfig } from "@playwright/test"; const BASE_URL = process.env.BASE_URL || "http://localhost:5173"; -const API_URL = process.env.API_URL || "http://localhost:3000"; export default defineConfig({ testDir: ".", diff --git a/scripts/worktree-init.sh b/scripts/worktree-init.sh index 32e23d0..bb96fd0 100755 --- a/scripts/worktree-init.sh +++ b/scripts/worktree-init.sh @@ -62,14 +62,13 @@ else else echo "[worktree-init] Generating .env from .env.example with ports API=$API_PORT, Vite=$VITE_PORT" sed \ - -e "s|^PORT=.*|PORT=$API_PORT|" \ - -e "s|^CORS_ALLOWED_ORIGIN=.*|CORS_ALLOWED_ORIGIN=http://localhost:$VITE_PORT|" \ - -e "s|^API_TARGET=.*|API_TARGET=http://localhost:$API_PORT|" \ + -e "s|^API_PORT=.*|API_PORT=$API_PORT|" \ + -e "s|^API_CORS_ALLOWED_ORIGIN=.*|API_CORS_ALLOWED_ORIGIN=http://localhost:$VITE_PORT|" \ + -e "s|^API_CORS_DEV_ORIGIN=.*|API_CORS_DEV_ORIGIN=http://localhost:$VITE_PORT|" \ + -e "s|^VITE_API_TARGET=.*|VITE_API_TARGET=http://localhost:$API_PORT|" \ -e "s|^VITE_DEV_PORT=.*|VITE_DEV_PORT=$VITE_PORT|" \ -e "s|^VITE_API_PORT=.*|VITE_API_PORT=$API_PORT|" \ - -e "s|^POSTGRES_DB=.*|POSTGRES_DB=ws_scoring_$DB_SUFFIX|" \ -e "s|^POSTGRES_PORT=.*|POSTGRES_PORT=$POSTGRES_PORT|" \ - -e "s|localhost:5432/ws_scoring|localhost:$POSTGRES_PORT/ws_scoring_$DB_SUFFIX|" \ -e "s|^DBHUB_PORT=.*|DBHUB_PORT=$DBHUB_PORT|" \ "$ENV_EXAMPLE" > "$WORKTREE_ROOT/.env" echo "[worktree-init] .env generated" diff --git a/server.ts b/server.ts index aa6e7b2..f7c7663 100644 --- a/server.ts +++ b/server.ts @@ -16,25 +16,26 @@ import { } from "./src/api/websocket-head-judge.js"; import { getDb, type schema } from "./src/infrastructure/db/index.js"; -const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000; +// Require API_PORT environment variable +if (!process.env.API_PORT) { + throw new Error("API_PORT environment variable is required"); +} +const port = parseInt(process.env.API_PORT, 10); // CORS configuration -// Allow both the Bun server and Vite dev server origins -const defaultAllowedOrigin = "http://localhost:3000"; -const viteDevPort = process.env.VITE_DEV_PORT || "5173"; -const viteDevOrigin = `http://localhost:${viteDevPort}`; -const allowedOrigin = - process.env.CORS_ALLOWED_ORIGIN && process.env.CORS_ALLOWED_ORIGIN.trim().length > 0 - ? process.env.CORS_ALLOWED_ORIGIN.trim() - : defaultAllowedOrigin; +// Require API_CORS_ALLOWED_ORIGIN environment variable +if (!process.env.API_CORS_ALLOWED_ORIGIN) { + throw new Error("API_CORS_ALLOWED_ORIGIN environment variable is required"); +} +const allowedOrigin = process.env.API_CORS_ALLOWED_ORIGIN.trim(); // Build a whitelist of allowed origins -// In development, allow both the configured origin and Vite dev server +// In development, allow both the configured origin and Vite dev server origin // In production, only allow the configured origin const isDevelopment = process.env.NODE_ENV !== "production"; const allowedOrigins = new Set([allowedOrigin]); -if (isDevelopment) { - allowedOrigins.add(viteDevOrigin); +if (isDevelopment && process.env.API_CORS_DEV_ORIGIN) { + allowedOrigins.add(process.env.API_CORS_DEV_ORIGIN.trim()); } // CORS headers diff --git a/src/app/utils/viewerUrl.ts b/src/app/utils/viewerUrl.ts index 51d9674..a32a312 100644 --- a/src/app/utils/viewerUrl.ts +++ b/src/app/utils/viewerUrl.ts @@ -1,6 +1,4 @@ export function getViewerUrl(heatId: string): string { - const baseUrl = import.meta.env.DEV - ? `http://localhost:${import.meta.env.VITE_API_PORT || "3000"}` - : ""; + const baseUrl = import.meta.env.DEV ? `http://localhost:${import.meta.env.VITE_API_PORT}` : ""; return `${baseUrl}/viewer/${heatId}`; } diff --git a/src/app/utils/websocket.ts b/src/app/utils/websocket.ts index 4422cb9..f2c5308 100644 --- a/src/app/utils/websocket.ts +++ b/src/app/utils/websocket.ts @@ -1,7 +1,7 @@ export function getWebSocketUrl(path: string): string { if (import.meta.env.DEV) { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const apiPort = import.meta.env.VITE_API_PORT || "3000"; + const apiPort = import.meta.env.VITE_API_PORT; return `${protocol}//localhost:${apiPort}${path}`; } const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; diff --git a/src/infrastructure/db/index.ts b/src/infrastructure/db/index.ts index ebce2cd..586eceb 100644 --- a/src/infrastructure/db/index.ts +++ b/src/infrastructure/db/index.ts @@ -4,7 +4,7 @@ import { Pool } from "pg"; import * as schema from "./schema.js"; const getPostgresConnectionString = () => - `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@${process.env.POSTGRES_HOST ?? "localhost"}:${process.env.POSTGRES_PORT ?? "5432"}/${process.env.POSTGRES_DB}`; + `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@${process.env.POSTGRES_HOST}:${process.env.POSTGRES_PORT}/${process.env.POSTGRES_DB}`; const pool = new Pool({ connectionString: getPostgresConnectionString() }); const db = drizzle(pool, { schema }); diff --git a/vite.config.ts b/vite.config.ts index 0ad53db..0837000 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,7 +2,17 @@ import path from "node:path"; import { defineConfig } from "vite"; import solid from "vite-plugin-solid"; -const viteDevPort = process.env.VITE_DEV_PORT ? parseInt(process.env.VITE_DEV_PORT, 10) : 5173; +// Require VITE_DEV_PORT environment variable +if (!process.env.VITE_DEV_PORT) { + throw new Error("VITE_DEV_PORT environment variable is required"); +} +const viteDevPort = parseInt(process.env.VITE_DEV_PORT, 10); + +// Require VITE_API_TARGET environment variable +if (!process.env.VITE_API_TARGET) { + throw new Error("VITE_API_TARGET environment variable is required"); +} +const apiTarget = process.env.VITE_API_TARGET; export default defineConfig({ plugins: [solid()], @@ -21,18 +31,18 @@ export default defineConfig({ host: "0.0.0.0", // Allow access from outside container proxy: { "/api": { - target: process.env.API_TARGET || "http://localhost:3000", + target: apiTarget, changeOrigin: true, secure: false, ws: true, // Enable WebSocket proxying }, "/rpc": { - target: process.env.API_TARGET || "http://localhost:3000", + target: apiTarget, changeOrigin: true, secure: false, }, "/docs": { - target: process.env.API_TARGET || "http://localhost:3000", + target: apiTarget, changeOrigin: true, secure: false, }, From 97b3263ae1c6ced39a00e04152166ff8964244d8 Mon Sep 17 00:00:00 2001 From: Daniel Bimschas Date: Wed, 4 Feb 2026 13:00:34 +0100 Subject: [PATCH 17/17] Attempt to fix E2E --- e2e/screenshots.spec.ts | 24 ++++++++++++++---------- package.json | 2 +- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/e2e/screenshots.spec.ts b/e2e/screenshots.spec.ts index 8d31bac..2922f71 100644 --- a/e2e/screenshots.spec.ts +++ b/e2e/screenshots.spec.ts @@ -6,8 +6,8 @@ const SCREENSHOT_DIR = path.resolve(__dirname, "../landing_page/screenshots"); const DESKTOP = { width: 1280, height: 800 }; const MOBILE = { width: 390, height: 844 }; -const BASE_URL = process.env.BASE_URL || "http://localhost:5173"; -const API_URL = process.env.API_URL || "http://localhost:3000"; +const BASE_URL = process.env.BASE_URL; +const API_URL = process.env.API_URL; // Credentials matching scripts/db/seed-users.ts const JUDGE_USER = { username: "judge1", password: "password123" }; @@ -32,16 +32,20 @@ async function login(page: Page, username: string, password: string) { async function findFirstHeatUrl(page: Page): Promise { // Navigate: Seasons -> first contest -> first division -> first bracket -> first heat await page.goto(`${BASE_URL}/`); - // Click the first season link - await page.locator("a").filter({ hasText: "2026 Season" }).first().click(); + // Click the first season button (seasons are rendered as buttons, not links) + await page.locator("button").filter({ hasText: "2026 Season" }).first().click(); await page.waitForURL(/\/contests/); - // Click the first contest - await page.locator("a").filter({ hasText: "Danish Open" }).first().click(); + // Click the first contest (contests are also buttons) + await page.locator("button").filter({ hasText: "Danish Open" }).first().click(); await page.waitForURL(/\/divisions/); - // Click the first division - await page.locator("a").first().click(); + // Click the first division (also a button) + await page + .locator("button") + .filter({ hasText: /Men|Women|Open/ }) + .first() + .click(); await page.waitForURL(/\/participants|\/brackets/); // We need to navigate to a heat - look for heat links on the page @@ -219,9 +223,9 @@ test.describe("Screenshot generation", () => { // Navigate to bracket view // Go back to divisions page which shows brackets await desktopPage.goto(`${BASE_URL}/`); - await desktopPage.locator("a").filter({ hasText: "2026 Season" }).first().click(); + await desktopPage.locator("button").filter({ hasText: "2026 Season" }).first().click(); await desktopPage.waitForURL(/\/contests/); - await desktopPage.locator("a").filter({ hasText: "Danish Open" }).first().click(); + await desktopPage.locator("button").filter({ hasText: "Danish Open" }).first().click(); await desktopPage.waitForURL(/\/divisions/); await desktopPage.waitForTimeout(1000); diff --git a/package.json b/package.json index 9b896a3..c802b4d 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "test:components": "vitest", "test:components:run": "vitest run", "test:all": "bun test --shuffle __tests__/api __tests__/domain __tests__/integration && vitest run", - "test:e2e": "cd e2e && BASE_URL=http://localhost:${VITE_DEV_PORT:-5173} API_URL=http://localhost:${PORT:-3000} npx playwright test", + "test:e2e": "cd e2e && BASE_URL=http://localhost:${VITE_DEV_PORT} API_URL=http://localhost:${API_PORT} npx playwright test", "lint": "biome lint --error-on-warnings .", "lint:fix": "biome lint --error-on-warnings --write .", "format": "biome format --write .",