feat: mobile UI card layout for players and courses (#16)

This commit is contained in:
Samuel Enocsson
2026-05-22 21:07:00 +02:00
parent e25f66c5d3
commit cc9d8eb4cd
14 changed files with 1007 additions and 56 deletions
+6
View File
@@ -19,6 +19,12 @@
</div>
</div>
<!-- Mobile tab pill (visible only on mobile via CSS) -->
<div class="m-tab-pill">
<button class="m-tab-pill__btn m-tab-pill__btn--active" type="button">Find courses</button>
<button class="m-tab-pill__btn m-tab-pill__btn--disabled" type="button" disabled>Import from Tjing</button>
</div>
<div id="courses-table" hx-get="/partials/course-table" hx-trigger="load"></div>
`; %>
+14
View File
@@ -78,6 +78,20 @@
<!-- Footnote -->
<p class="footnote">Unofficial PDGA rating tracker. Ratings scraped from pdga.com on each refresh.</p>
<!-- Mobile sticky add-bar (visible only on mobile via CSS) -->
<form class="mobile-add-bar" onsubmit="searchAndAddPlayerMobile(event)">
<input
id="pdga-number-input-mobile"
name="pdga"
type="text"
inputmode="numeric"
placeholder="PDGA #"
oninput="this.value = this.value.replace(/\\D/g,'')"
aria-label="PDGA number"
/>
<button type="submit" class="btn-primary">Add</button>
</form>
`; %>
<% var modals = `
+41
View File
@@ -0,0 +1,41 @@
<%
var _query = (typeof query !== 'undefined') ? query : null;
var _total = (typeof total !== 'undefined') ? total : courses.length;
%>
<div class="mobile-section-head">
<span class="kicker">Showing <%= courses.length %> of <%= _total %></span>
<a href="/courses">View all &rarr;</a>
</div>
<% if (courses.length === 0) { %>
<p style="text-align: center; color: var(--ink-3); padding: 40px 0;">No courses found.</p>
<% } else { %>
<div class="mobile-list">
<% courses.forEach(function(course) {
var lastUpdated = new Date(course.last_updated).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
var layoutCount = course.layoutCount || 0;
%>
<div class="m-course-card" id="m-course-<%= course.id %>" onclick="toggleMobileCourseLayouts(<%= course.id %>)">
<div class="m-course-card__head">
<div class="m-course-card__name-stack">
<div class="m-course-name-row">
<span class="m-course-name"><%= course.name %></span>
<% if (layoutCount > 0) { %>
<span class="m-layouts-pill"><%= layoutCount %> layout<%= layoutCount !== 1 ? 's' : '' %></span>
<% } %>
</div>
<div class="m-course-card__meta"><%= course.city %> &middot; <%= lastUpdated %></div>
</div>
<span class="m-chevron">&#9660;</span>
</div>
<div class="m-course-card__expand">
<div id="m-layouts-container-<%= course.id %>" class="layouts-container">
<div class="no-layouts">Tap to load layouts...</div>
</div>
</div>
</div>
<% }); %>
</div>
<% } %>
+1
View File
@@ -43,4 +43,5 @@
<% }); %>
</tbody>
</table>
<%- include('course-cards', { courses: courses, query: locals.query, total: locals.total }) %>
<% } %>
+2 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title><%= title %></title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
@@ -10,6 +10,7 @@
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,300..800;1,300..800&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<link rel="stylesheet" href="/css/shared.css">
<link rel="stylesheet" href="/css/mobile.css">
<% if (typeof cssFiles !== 'undefined') { %>
<% cssFiles.forEach(function(file) { %>
<link rel="stylesheet" href="/css/<%= file %>">
+12
View File
@@ -0,0 +1,12 @@
<form class="mobile-add-bar" onsubmit="searchAndAddPlayerMobile(event)">
<input
id="pdga-number-input-mobile"
name="pdga"
type="text"
inputmode="numeric"
placeholder="PDGA #"
oninput="this.value = this.value.replace(/\D/g,'')"
aria-label="PDGA number"
/>
<button type="submit" class="btn-primary">Add</button>
</form>
+120
View File
@@ -0,0 +1,120 @@
<%
// Mobile sparkline helper — parametrised, used only in this partial
function renderSparkline(values, opts) {
opts = opts || {};
var w = opts.w || 70;
var h = opts.h || 26;
if (!values || values.length < 2) return '';
var min = Math.min.apply(null, values);
var max = Math.max.apply(null, values);
var range = max - min || 1;
var xStep = w / (values.length - 1);
var pts = values.map(function(v, i) {
return {
x: (i * xStep).toFixed(1),
y: (((max - v) / range) * (h - 4) + 2).toFixed(1)
};
});
var linePath = pts.map(function(p, i) {
return (i === 0 ? 'M' : 'L') + ' ' + p.x + ' ' + p.y;
}).join(' ');
var last = pts[pts.length - 1];
var areaPath = linePath + ' L ' + last.x + ' ' + h + ' L 0 ' + h + ' Z';
return '<svg width="' + w + '" height="' + h + '" viewBox="0 0 ' + w + ' ' + h + '" class="m-chart-spark" aria-hidden="true">' +
'<path d="' + areaPath + '" style="fill:var(--accent);fill-opacity:0.10"/>' +
'<path d="' + linePath + '" style="stroke:var(--accent);stroke-width:1.5;fill:none;stroke-linejoin:round;stroke-linecap:round"/>' +
'<circle cx="' + last.x + '" cy="' + last.y + '" r="2.5" style="fill:var(--accent)"/>' +
'</svg>';
}
%>
<div class="mobile-section-head">
<span class="kicker">TRACKED PLAYERS &middot; <%= ratings.length %></span>
<button id="trendchart-toggle-mobile" class="pill-button" type="button" aria-pressed="false">Trend chart</button>
</div>
<% if (ratings.length === 0) { %>
<p style="text-align: center; color: var(--ink-3); padding: 40px 0;">No players tracked yet.</p>
<% } else { %>
<div class="mobile-list">
<% ratings.forEach(function(player, index) {
var sparkSvg = renderSparkline(player.monthlyHistory || [], { w: 70, h: 26 });
var isFirst = index === 0;
var rank = index + 1;
var ratingIsNull = (player.rating == null);
var ratingCls = ratingIsNull ? 'flat' : (player.ratingChange > 0 ? 'up' : player.ratingChange < 0 ? 'down' : 'flat');
var ratingGlyph = (ratingIsNull || player.ratingChange === 0) ? '' : (player.ratingChange > 0 ? '▲' : '▼');
var ratingNum = ratingIsNull ? '—' : (player.ratingChange > 0 ? '+' + player.ratingChange : String(player.ratingChange));
var predIsNull = (player.predictedRating == null);
var predCls = predIsNull ? 'flat' : (player.deltaPredicted > 0 ? 'up' : player.deltaPredicted < 0 ? 'down' : 'flat');
var predGlyph = (predIsNull || player.deltaPredicted === 0) ? '' : (player.deltaPredicted > 0 ? '▲' : '▼');
var predNum = predIsNull ? '—' : (player.deltaPredicted > 0 ? '+' + player.deltaPredicted : String(player.deltaPredicted));
%>
<div class="m-card" id="m-card-<%= player.pdgaNumber %>" onclick="toggleMobilePlayerCard(<%= player.pdgaNumber %>)">
<div class="m-card__head">
<div class="m-rank-chip<%= isFirst ? ' m-rank-chip--first' : '' %>"><%= rank %></div>
<div class="m-card__name-stack">
<span class="m-player-name"><%= player.name %></span>
<span class="m-pdga-num">#<%= player.pdgaNumber %></span>
</div>
<span class="m-chevron">&#9660;</span>
</div>
<div class="m-card__body">
<div class="m-card__stats">
<div class="m-stat-row">
<span class="m-stat-label">RATING</span>
<span class="m-num"><%= player.rating || '—' %></span>
<span class="delta-pill <%= ratingCls %>"><span class="delta-glyph"><%= ratingGlyph %></span><span class="delta-num"><%= ratingNum %></span></span>
</div>
<div class="m-stat-row">
<span class="m-stat-label">PREDICTED</span>
<span class="m-num m-num--predicted"><%= player.predictedRating || '—' %></span>
<span class="delta-pill <%= predCls %> delta-predicted-pill"><span class="delta-glyph"><%= predGlyph %></span><span class="delta-num"><%= predNum %></span></span>
</div>
</div>
<% if (sparkSvg) { %>
<div class="m-card__sparkline"><span class="sparkline"><%- sparkSvg %></span></div>
<% } %>
</div>
<div class="m-card__expand">
<% if (player.ratingHistory && player.ratingHistory.length > 0) { %>
<div class="player-chart m-chart"
data-variant="mobile"
data-history='<%- JSON.stringify(player.ratingHistory) %>'>
</div>
<% } %>
<dl class="m-detail-grid">
<div>
<dt>Current rating</dt>
<dd><%= player.rating || '—' %></dd>
</div>
<div>
<dt>Last month</dt>
<dd><%= player.lastMonthRating || '—' %></dd>
</div>
<div>
<dt>Change vs last month</dt>
<dd><span class="delta-pill <%= ratingCls %>"><span class="delta-glyph"><%= ratingGlyph %></span><span class="delta-num"><%= ratingNum %></span></span></dd>
</div>
<div>
<dt>Predicted next update</dt>
<dd><%= player.predictedRating || '—' %></dd>
</div>
<div>
<dt>Gap to predicted</dt>
<dd><span class="delta-pill <%= predCls %> delta-predicted-pill"><span class="delta-glyph"><%= predGlyph %></span><span class="delta-num"><%= predNum %></span></span></dd>
</div>
</dl>
</div>
</div>
<% }); %>
</div>
<% } %>
+5 -2
View File
@@ -1,7 +1,9 @@
<%
function renderSparkline(values) {
function renderSparkline(values, opts) {
opts = opts || {};
var w = opts.w || 96;
var h = opts.h || 28;
if (!values || values.length < 2) return '';
var w = 96, h = 28;
var min = Math.min.apply(null, values);
var max = Math.max.apply(null, values);
var range = max - min || 1;
@@ -104,4 +106,5 @@ function renderSparkline(values) {
<% }); %>
</tbody>
</table>
<%- include('ratings-cards', { ratings: ratings }) %>
<% } %>
+37
View File
@@ -43,4 +43,41 @@
</button>
</div>
</div>
<div class="topbar__mobile">
<div class="topbar__mobile-row1">
<a href="/" class="topbar__mobile-brand">
<span class="topbar__mobile-mark" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 17 L9 11 L13 15 L21 6" />
<path d="M14 6 L21 6 L21 13" />
</svg>
</span>
<span class="topbar__mobile-brand-text">
<span class="topbar__mobile-title">Rating Tracker</span>
<span class="topbar__mobile-sub">Disc golf &middot; unofficial</span>
</span>
</a>
<button
class="topbar__mobile-refresh"
type="button"
hx-post="/api/refresh-all"
hx-vals='{"page": "<%= activePage %>"}'
hx-target="#topbar"
hx-swap="outerHTML"
hx-disabled-elt="this"
title="Refresh all"
aria-label="Refresh all"
>
<span class="topbar__refresh-icon" aria-hidden="true">&#8635;</span>
<span class="topbar__refresh-spinner" aria-hidden="true"></span>
</button>
</div>
<div class="topbar__mobile-row2">
<nav class="topbar__mobile-nav" aria-label="Primary">
<a href="/" class="<%= activePage === 'players' ? 'active' : '' %>">Players</a>
<a href="/courses" class="<%= activePage === 'courses' ? 'active' : '' %>">Courses</a>
</nav>
</div>
</div>
</header>