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 limit = parseInt(url.searchParams.get('limit') || '20');
|
||||||
const offset = parseInt(url.searchParams.get('offset') || '0');
|
const offset = parseInt(url.searchParams.get('offset') || '0');
|
||||||
|
const month = url.searchParams.get('month'); // YYYY-MM
|
||||||
const query = { createdBy: session.user.nickname };
|
|
||||||
|
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([
|
const [sessions, total] = await Promise.all([
|
||||||
WorkoutSession.find(query).select('-exercises.gpsTrack -gpsTrack')
|
WorkoutSession.find(query).select('-exercises.gpsTrack -gpsTrack')
|
||||||
.sort({ startTime: -1 }).limit(limit).skip(offset),
|
.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