feat: path-based month URLs for workout history with month navigation
CI / update (push) Successful in 3m37s

Add ?month=YYYY-MM filter to sessions API. Migrate history page to
/fitness/history/[[month]] optional param route. Default view shows last
2 months; specific month view via /fitness/history/2026-04. Replace
load-more button with prev/next month anchor navigation.
This commit is contained in:
2026-04-10 08:57:11 +02:00
parent b7444e8bc7
commit 72a77b9dc3
6 changed files with 201 additions and 135 deletions
+5
View File
@@ -0,0 +1,5 @@
import type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (param) => {
return /^\d{4}-\d{2}$/.test(param);
};
+10 -2
View File
@@ -27,8 +27,16 @@ export const GET: RequestHandler = async ({ url, locals }) => {
const limit = parseInt(url.searchParams.get('limit') || '20');
const offset = parseInt(url.searchParams.get('offset') || '0');
const query = { createdBy: session.user.nickname };
const month = url.searchParams.get('month'); // YYYY-MM
const query: Record<string, any> = { createdBy: session.user.nickname };
if (month && /^\d{4}-\d{2}$/.test(month)) {
const start = new Date(month + '-01T00:00:00.000Z');
const end = new Date(start);
end.setMonth(end.getMonth() + 1);
query.startTime = { $gte: start, $lt: end };
}
const [sessions, total] = await Promise.all([
WorkoutSession.find(query).select('-exercises.gpsTrack -gpsTrack')
.sort({ startTime: -1 }).limit(limit).skip(offset),
@@ -1,12 +0,0 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch, url }) => {
const month = url.searchParams.get('month') || '';
const params = new URLSearchParams({ limit: '50' });
if (month) params.set('month', month);
const res = await fetch(`/api/fitness/sessions?${params}`);
return {
sessions: await res.json()
};
};
@@ -1,121 +0,0 @@
<script>
import { page as appPage } from '$app/stores';
import SessionCard from '$lib/components/fitness/SessionCard.svelte';
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
import { toast } from '$lib/js/toast.svelte';
const lang = $derived(detectFitnessLang($appPage.url.pathname));
let { data } = $props();
// svelte-ignore state_referenced_locally
let sessions = $state(data.sessions?.sessions ? [...data.sessions.sessions] : []);
// svelte-ignore state_referenced_locally
let total = $state(data.sessions?.total ? data.sessions.total : 0);
let loading = $state(false);
let page = $state(1);
/** @type {Record<string, typeof sessions>} */
const grouped = $derived.by(() => {
/** @type {Record<string, typeof sessions>} */
const groups = {};
for (const s of sessions) {
const d = new Date(s.startTime);
const key = `${d.toLocaleString('default', { month: 'long' })} ${d.getFullYear()}`;
if (!groups[key]) groups[key] = [];
groups[key].push(s);
}
return groups;
});
async function loadMore() {
if (loading || sessions.length >= total) return;
loading = true;
page++;
try {
const res = await fetch(`/api/fitness/sessions?limit=50&skip=${sessions.length}`);
if (!res.ok) { toast.error('Failed to load sessions'); loading = false; return; }
const data = await res.json();
sessions = [...sessions, ...(data.sessions ?? [])];
total = data.total ?? total;
} catch { toast.error('Failed to load sessions'); }
loading = false;
}
</script>
<svelte:head><title>{t('history_title', lang)} - Bocken</title></svelte:head>
<div class="history-page">
<h1>{t('history_title', lang)}</h1>
{#if sessions.length === 0}
<p class="empty">{t('no_workouts_yet', lang)}</p>
{:else}
{#each Object.entries(grouped) as [month, monthSessions] (month)}
<section class="month-group">
<h2 class="month-header">{month}{monthSessions.length} {monthSessions.length !== 1 ? t('workouts_plural', lang) : t('workout_singular', lang)}</h2>
<div class="session-list">
{#each monthSessions as session (session._id)}
<SessionCard {session} />
{/each}
</div>
</section>
{/each}
{#if sessions.length < total}
<button class="load-more" onclick={loadMore} disabled={loading}>
{loading ? t('loading', lang) : t('load_more', lang)}
</button>
{/if}
{/if}
</div>
<style>
.history-page {
display: flex;
flex-direction: column;
gap: 1rem;
}
h1 {
margin: 0;
font-size: 1.4rem;
}
.empty {
text-align: center;
color: var(--color-text-secondary);
padding: 3rem 0;
}
.month-group {
margin-bottom: 0.5rem;
}
.month-header {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
margin: 0 0 0.5rem;
}
.session-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.load-more {
align-self: center;
padding: 0.6rem 2rem;
background: transparent;
border: 1px solid var(--color-border);
border-radius: 8px;
color: var(--color-primary);
font-weight: 600;
cursor: pointer;
}
.load-more:hover {
border-color: var(--color-accent);
}
.load-more:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,36 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch, params }) => {
const month = params.month; // YYYY-MM or undefined
if (month) {
// Specific month view
const res = await fetch(`/api/fitness/sessions?month=${month}&limit=200`);
return {
sessions: await res.json(),
month,
};
}
// Default: last 2 months
const now = new Date();
const twoMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const fromMonth = `${twoMonthsAgo.getFullYear()}-${String(twoMonthsAgo.getMonth() + 1).padStart(2, '0')}`;
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
const [curRes, prevRes] = await Promise.all([
fetch(`/api/fitness/sessions?month=${currentMonth}&limit=200`),
fetch(`/api/fitness/sessions?month=${fromMonth}&limit=200`),
]);
const curData = await curRes.json();
const prevData = await prevRes.json();
return {
sessions: {
sessions: [...(curData.sessions ?? []), ...(prevData.sessions ?? [])],
total: (curData.total ?? 0) + (prevData.total ?? 0),
},
month: null,
};
};
@@ -0,0 +1,150 @@
<script>
import { page as appPage } from '$app/stores';
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
import SessionCard from '$lib/components/fitness/SessionCard.svelte';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang($appPage.url.pathname));
const s = $derived(fitnessSlugs(lang));
let { data } = $props();
const sessions = $derived(data.sessions?.sessions ?? []);
const viewMonth = $derived(data.month); // YYYY-MM or null
/** @type {Record<string, typeof sessions>} */
const grouped = $derived.by(() => {
/** @type {Record<string, typeof sessions>} */
const groups = {};
for (const sess of sessions) {
const d = new Date(sess.startTime);
const key = `${d.toLocaleString(lang === 'de' ? 'de-DE' : 'en-US', { month: 'long' })} ${d.getFullYear()}`;
if (!groups[key]) groups[key] = [];
groups[key].push(sess);
}
return groups;
});
// Month navigation
function offsetMonth(/** @type {string} */ ym, /** @type {number} */ delta) {
const [y, m] = ym.split('-').map(Number);
const d = new Date(y, m - 1 + delta, 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}
const now = new Date();
const currentYM = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
// When viewing a specific month, show prev/next; when default, show "older" link
const baseMonth = $derived(viewMonth ?? currentYM);
const prevMonth = $derived(offsetMonth(baseMonth, viewMonth ? -1 : -2));
const nextMonth = $derived(viewMonth ? offsetMonth(viewMonth, 1) : null);
const isCurrentOrFuture = $derived(nextMonth ? nextMonth > currentYM : true);
function formatMonthLabel(/** @type {string} */ ym) {
const [y, m] = ym.split('-').map(Number);
const d = new Date(y, m - 1, 1);
return d.toLocaleString(lang === 'de' ? 'de-DE' : 'en-US', { month: 'long', year: 'numeric' });
}
const prevHref = $derived(`/fitness/${s.history}/${prevMonth}`);
const nextHref = $derived(nextMonth && nextMonth === currentYM ? `/fitness/${s.history}` : nextMonth ? `/fitness/${s.history}/${nextMonth}` : null);
const recentHref = $derived(`/fitness/${s.history}`);
</script>
<svelte:head><title>{t('history_title', lang)} - Bocken</title></svelte:head>
<div class="history-page">
<h1>{t('history_title', lang)}</h1>
{#if sessions.length === 0}
<p class="empty">{t('no_workouts_yet', lang)}</p>
{:else}
{#each Object.entries(grouped) as [month, monthSessions] (month)}
<section class="month-group">
<h2 class="month-header">{month}{monthSessions.length} {monthSessions.length !== 1 ? t('workouts_plural', lang) : t('workout_singular', lang)}</h2>
<div class="session-list">
{#each monthSessions as session (session._id)}
<SessionCard {session} />
{/each}
</div>
</section>
{/each}
{/if}
<nav class="month-nav">
{#if nextHref && !isCurrentOrFuture}
<a class="month-link" href={nextHref}>
<ChevronLeft size={16} />
{formatMonthLabel(/** @type {string} */ (nextMonth))}
</a>
{/if}
{#if viewMonth}
<a class="month-link" href={recentHref}>
{lang === 'en' ? 'Recent' : 'Aktuell'}
</a>
{/if}
<a class="month-link" href={prevHref}>
{formatMonthLabel(prevMonth)}
<ChevronRight size={16} />
</a>
</nav>
</div>
<style>
.history-page {
display: flex;
flex-direction: column;
gap: 1rem;
}
h1 {
margin: 0;
font-size: 1.4rem;
}
.empty {
text-align: center;
color: var(--color-text-secondary);
padding: 3rem 0;
}
.month-group {
margin-bottom: 0.5rem;
}
.month-header {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
margin: 0 0 0.5rem;
}
.session-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.month-nav {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
padding: 1rem 0;
}
.month-link {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid var(--color-border);
border-radius: 8px;
color: var(--color-primary);
font-size: 0.85rem;
font-weight: 600;
text-decoration: none;
transition: border-color var(--transition-normal), background var(--transition-normal);
}
.month-link:hover {
border-color: var(--color-primary);
background: var(--color-bg-elevated);
}
</style>