feat: path-based month URLs for workout history with month navigation
CI / update (push) Successful in 3m37s
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:
@@ -0,0 +1,5 @@
|
||||
import type { ParamMatcher } from '@sveltejs/kit';
|
||||
|
||||
export const match: ParamMatcher = (param) => {
|
||||
return /^\d{4}-\d{2}$/.test(param);
|
||||
};
|
||||
@@ -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 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 query = { createdBy: session.user.nickname };
|
||||
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>
|
||||
Reference in New Issue
Block a user