feat: add async tour system

Players can create tours with selected courses/layouts and a date range.
Others join via a 6-character tour code, play the courses, and report
their total strokes. Live leaderboard with points and +/- par display.

Includes: database schema, model, service, routes, views, and styling.
This commit is contained in:
Samuel Enocsson
2026-03-19 22:47:54 +01:00
parent d567c4bca9
commit 2ccb018bdf
14 changed files with 2489 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
<% if (!tour) { %>
<% var body = `
<div class="card-section">
<h3>Tour Not Found</h3>
<p>The tour code is invalid or the tour has been removed.</p>
<a href="/tours" class="btn"><i class="fas fa-arrow-left"></i> Back to Tours</a>
</div>
`; %>
<%- include('../partials/layout', {
title: 'Tour Not Found',
heading: 'Tour Not Found',
activePage: 'tours',
cssFiles: ['tours.css'],
body: body
}) %>
<% } else { %>
<%
var statusBadge = isActive
? '<span class="badge badge-active">Active</span>'
: isFinished
? '<span class="badge badge-finished">Finished</span>'
: '<span class="badge badge-upcoming">Upcoming</span>';
var courseOptionsHtml = '';
courses.forEach(function(c) {
courseOptionsHtml += '<option value="' + c.tour_course_id + '">'
+ c.course_name.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
+ ' - '
+ c.layout_name.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
+ ' (Par ' + c.par + ')'
+ '</option>';
});
var escapedCode = tour.code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
var escapedStartDate = tour.start_date.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
var escapedEndDate = tour.end_date.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
%>
<% var body = `
<div class="tour-header">
<div class="tour-info">
<div class="tour-meta">
${statusBadge}
<span class="tour-dates"><i class="fas fa-calendar"></i> ${escapedStartDate} &mdash; ${escapedEndDate}</span>
<span class="tour-code-label"><i class="fas fa-key"></i> ${escapedCode}</span>
</div>
</div>
</div>
<div class="card-section" id="join-section">
<h3>Join This Tour</h3>
<div class="card-section-form">
<input type="text" class="input" id="pdga-number" placeholder="PDGA Number" style="width: 160px;" />
<input type="text" class="input" id="player-name" placeholder="Your Name" style="width: 200px;" />
<button class="btn" onclick="joinTour()">
<i class="fas fa-user-plus"></i> Join
</button>
</div>
</div>
<div class="card-section" id="result-section" style="display: none;">
<h3>Record Result</h3>
<div class="card-section-form">
<select class="input" id="result-course" style="width: 280px;">
<option value="">Select course...</option>
${courseOptionsHtml}
</select>
<input type="number" class="input" id="result-strokes" placeholder="Total strokes" style="width: 140px;" min="1" />
<button class="btn" onclick="recordResult('${escapedCode}')">
<i class="fas fa-save"></i> Save
</button>
</div>
</div>
<div id="leaderboard-container"
hx-get="/partials/tour-leaderboard/${escapedCode}"
hx-trigger="load">
<div class="loading">Loading leaderboard...</div>
</div>
`; %>
<%- include('../partials/layout', {
title: tour.name + ' - PDGA Tours',
heading: tour.name,
activePage: 'tours',
cssFiles: ['tours.css'],
jsFiles: ['tour.js'],
initScript: 'initTour("' + escapedCode + '");',
body: body
}) %>
<% } %>
+67
View File
@@ -0,0 +1,67 @@
<% var body = `
<div class="card-section">
<h3>Create a Tour</h3>
<div class="tour-form">
<div class="form-group">
<label for="tour-name">Tour Name</label>
<input type="text" class="input" id="tour-name" placeholder="e.g. Summer Tour 2026" />
</div>
<div class="form-row">
<div class="form-group">
<label for="tour-start">Start Date</label>
<input type="date" class="input" id="tour-start" />
</div>
<div class="form-group">
<label for="tour-end">End Date</label>
<input type="date" class="input" id="tour-end" />
</div>
</div>
<div class="form-group">
<label>Courses</label>
<div id="course-selector">
<div class="course-entry">
<select class="input course-select" data-index="0">
<option value="">Loading courses...</option>
</select>
<select class="input layout-select" data-index="0" disabled>
<option value="">Select course first</option>
</select>
</div>
</div>
<button class="btn btn-secondary" onclick="addCourseEntry()" id="add-course-btn">
<i class="fas fa-plus"></i> Add Course
</button>
</div>
<button class="btn" onclick="createTour()" id="create-tour-btn">
<i class="fas fa-flag-checkered"></i> Create Tour
</button>
</div>
</div>
<div class="card-section">
<h3>Join a Tour</h3>
<div class="card-section-form">
<input type="text" class="input" id="tour-code" placeholder="Tour code (e.g. X7K9M2)" maxlength="6" style="width: 180px;" />
<button class="btn" onclick="goToTour()">
<i class="fas fa-arrow-right"></i> Go
</button>
</div>
</div>
<div id="tour-created" class="card-section" style="display: none;">
<h3>Tour Created!</h3>
<p class="tour-created-message">Share this link with players:</p>
<div class="tour-code-display">
<a id="tour-link" href="#" target="_blank"></a>
</div>
</div>
`; %>
<%- include('../partials/layout', {
title: 'Tours - PDGA Ratings',
heading: 'Tours',
activePage: 'tours',
cssFiles: ['tours.css'],
jsFiles: ['tours.js'],
body: body
}) %>
+1
View File
@@ -1,4 +1,5 @@
<nav class="app-nav">
<a href="/" class="<%= activePage === 'players' ? 'active' : '' %>">Players</a>
<a href="/courses" class="<%= activePage === 'courses' ? 'active' : '' %>">Courses</a>
<a href="/tours" class="<%= activePage === 'tours' ? 'active' : '' %>">Tours</a>
</nav>
+52
View File
@@ -0,0 +1,52 @@
<% if (leaderboard.length === 0) { %>
<div class="card-section">
<p style="text-align: center; color: var(--text-secondary);">No players have joined yet. Share the tour link to get started!</p>
</div>
<% } else { %>
<div class="card-section">
<h3>Leaderboard</h3>
<table>
<thead>
<tr>
<th>#</th>
<th>Player</th>
<% courses.forEach(function(c) { %>
<th class="mobile-hide"><%= c.course_name %><br><small><%= c.layout_name %></small></th>
<% }); %>
<th>Points</th>
</tr>
</thead>
<tbody>
<% var rank = 1; %>
<% leaderboard.forEach(function(player, i) { %>
<% if (i > 0 && player.total_points < leaderboard[i-1].total_points) rank = i + 1; %>
<tr>
<td><strong><%= rank %></strong></td>
<td>
<%= player.player_name %>
<span style="color: var(--text-muted); font-size: 12px;"><%= player.pdga_number %></span>
</td>
<% courses.forEach(function(c) { %>
<td class="mobile-hide">
<% var result = player.courses[c.tour_course_id]; %>
<% if (result) { %>
<span class="strokes"><%= result.total_strokes %></span>
<% if (result.relative_par > 0) { %>
<span class="over-par">(+<%= result.relative_par %>)</span>
<% } else if (result.relative_par < 0) { %>
<span class="under-par">(<%= result.relative_par %>)</span>
<% } else { %>
<span class="even-par">(E)</span>
<% } %>
<% } else { %>
<span style="color: var(--text-muted);">-</span>
<% } %>
</td>
<% }); %>
<td><strong><%= player.total_points %></strong></td>
</tr>
<% }); %>
</tbody>
</table>
</div>
<% } %>