feat(fitness): surface period projection on stats page

PeriodTracker gains an optional mode prop ('entry' | 'projection' |
'full') that gates which sections render. The measure page keeps the
full tracker for the user's own cycle (logging plus calendar). The
stats page now mirrors it in projection mode and is the sole home
for shared cycles, which used to clutter the measure page.
This commit is contained in:
2026-04-21 12:53:11 +02:00
parent 56d438631b
commit 5b7f23b8be
7 changed files with 37 additions and 24 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.40.6", "version": "1.41.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+16 -11
View File
@@ -7,9 +7,11 @@
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte'; import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
/** /**
* @type {{ periods: any[], lang: 'en' | 'de', sharedWith?: string[], readOnly?: boolean, ownerName?: string }} * @type {{ periods: any[], lang: 'en' | 'de', sharedWith?: string[], readOnly?: boolean, ownerName?: string, mode?: 'entry' | 'projection' | 'full' }}
*/ */
let { periods: initialPeriods = [], lang = 'en', sharedWith: initialSharedWith = [], readOnly = false, ownerName = '' } = $props(); let { periods: initialPeriods = [], lang = 'en', sharedWith: initialSharedWith = [], readOnly = false, ownerName = '', mode = 'full' } = $props();
const showEntry = $derived(mode !== 'projection');
const showProjection = $derived(mode !== 'entry');
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
let periods = $state([...initialPeriods]); let periods = $state([...initialPeriods]);
@@ -592,18 +594,18 @@
<div class="status-main"> <div class="status-main">
<span class="status-pill period-pill">{t('current_period', lang)}</span> <span class="status-pill period-pill">{t('current_period', lang)}</span>
<span class="status-hero ongoing-hero">{t('period_day', lang)} {ongoingDay}</span> <span class="status-hero ongoing-hero">{t('period_day', lang)} {ongoingDay}</span>
{#if predictions.predictedEndOfOngoing} {#if showProjection && predictions.predictedEndOfOngoing}
<span class="status-detail">{t('predicted_end', lang)}</span> <span class="status-detail">{t('predicted_end', lang)}</span>
<span class="status-relative">{relativeDate(predictions.predictedEndOfOngoing)}</span> <span class="status-relative">{relativeDate(predictions.predictedEndOfOngoing)}</span>
<span class="status-date">{formatDate(predictions.predictedEndOfOngoing)}</span> <span class="status-date">{formatDate(predictions.predictedEndOfOngoing)}</span>
{/if} {/if}
{#if !readOnly} {#if showEntry && !readOnly}
<button class="end-btn" onclick={endPeriod} disabled={loading}> <button class="end-btn" onclick={endPeriod} disabled={loading}>
{t('end_period', lang)} {t('end_period', lang)}
</button> </button>
{/if} {/if}
</div> </div>
{#if nextCycle} {#if showProjection && nextCycle}
<div class="status-side"> <div class="status-side">
<div class="status-side-item ovulation-accent"> <div class="status-side-item ovulation-accent">
<span class="status-side-label">{t('ovulation', lang)}</span> <span class="status-side-label">{t('ovulation', lang)}</span>
@@ -617,13 +619,13 @@
</div> </div>
{/if} {/if}
</div> </div>
{:else if nextCycle} {:else if showProjection && nextCycle}
<div class="status-split"> <div class="status-split">
<div class="status-main"> <div class="status-main">
<span class="status-pill period-pill">{t('next_period', lang)}</span> <span class="status-pill period-pill">{t('next_period', lang)}</span>
<span class="status-hero">{relativeRange(nextCycle.start, nextCycle.end)}</span> <span class="status-hero">{relativeRange(nextCycle.start, nextCycle.end)}</span>
<span class="status-date">{formatDate(nextCycle.start)}{formatDate(nextCycle.end)}</span> <span class="status-date">{formatDate(nextCycle.start)}{formatDate(nextCycle.end)}</span>
{#if !readOnly} {#if showEntry && !readOnly}
<button class="start-btn" onclick={startPeriod} disabled={loading}> <button class="start-btn" onclick={startPeriod} disabled={loading}>
{t('start_period', lang)} {t('start_period', lang)}
</button> </button>
@@ -641,9 +643,9 @@
</div> </div>
</div> </div>
</div> </div>
{:else} {:else if showEntry}
<div class="status-block"> <div class="status-block">
<span class="status-empty">{t('no_period_data', lang)}</span> <span class="status-empty">{sorted.length === 0 ? t('no_period_data', lang) : t('no_active_period', lang)}</span>
{#if !readOnly} {#if !readOnly}
<button class="start-btn" onclick={startPeriod} disabled={loading}> <button class="start-btn" onclick={startPeriod} disabled={loading}>
{t('start_period', lang)} {t('start_period', lang)}
@@ -653,6 +655,7 @@
{/if} {/if}
</div> </div>
{#if showProjection}
<!-- Calendar --> <!-- Calendar -->
<div class="calendar"> <div class="calendar">
<div class="cal-header"> <div class="cal-header">
@@ -691,7 +694,9 @@
</div> </div>
</div> </div>
{#if completed.length >= 2} {/if}
{#if showProjection && completed.length >= 2}
<div class="cycle-stats"> <div class="cycle-stats">
<div class="cycle-stat"> <div class="cycle-stat">
<span class="cycle-stat-label">{t('cycle_length', lang)}</span> <span class="cycle-stat-label">{t('cycle_length', lang)}</span>
@@ -710,7 +715,7 @@
</div> </div>
{/if} {/if}
{#if !readOnly} {#if showEntry && !readOnly}
<!-- History + Share row --> <!-- History + Share row -->
{#if sorted.length > 0} {#if sorted.length > 0}
<div class="history"> <div class="history">
+1
View File
@@ -402,6 +402,7 @@ const translations: Translations = {
period_tracker: { en: 'Period Tracker', de: 'Periodentracker' }, period_tracker: { en: 'Period Tracker', de: 'Periodentracker' },
current_period: { en: 'Current Period', de: 'Aktuelle Periode' }, current_period: { en: 'Current Period', de: 'Aktuelle Periode' },
no_period_data: { en: 'No period data yet. Log your first period to start tracking.', de: 'Noch keine Periodendaten. Erfasse deine erste Periode.' }, no_period_data: { en: 'No period data yet. Log your first period to start tracking.', de: 'Noch keine Periodendaten. Erfasse deine erste Periode.' },
no_active_period: { en: 'No active period.', de: 'Keine aktive Periode.' },
start_period: { en: 'Start Period', de: 'Periode starten' }, start_period: { en: 'Start Period', de: 'Periode starten' },
end_period: { en: 'Period Ended', de: 'Periode vorbei' }, end_period: { en: 'Period Ended', de: 'Periode vorbei' },
period_day: { en: 'Day', de: 'Tag' }, period_day: { en: 'Day', de: 'Tag' },
@@ -1,13 +1,12 @@
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch }) => { export const load: PageServerLoad = async ({ fetch }) => {
const [latestRes, listRes, goalRes, periodRes, shareRes, sharedRes] = await Promise.all([ const [latestRes, listRes, goalRes, periodRes, shareRes] = await Promise.all([
fetch('/api/fitness/measurements/latest'), fetch('/api/fitness/measurements/latest'),
fetch('/api/fitness/measurements?limit=200'), fetch('/api/fitness/measurements?limit=200'),
fetch('/api/fitness/goal'), fetch('/api/fitness/goal'),
fetch('/api/fitness/period').catch(() => null), fetch('/api/fitness/period').catch(() => null),
fetch('/api/fitness/period/share').catch(() => null), fetch('/api/fitness/period/share').catch(() => null)
fetch('/api/fitness/period/shared').catch(() => null)
]); ]);
return { return {
@@ -15,7 +14,6 @@ export const load: PageServerLoad = async ({ fetch }) => {
measurements: await listRes.json(), measurements: await listRes.json(),
profile: goalRes.ok ? await goalRes.json() : {}, profile: goalRes.ok ? await goalRes.json() : {},
periods: periodRes?.ok ? (await periodRes.json()).entries : [], periods: periodRes?.ok ? (await periodRes.json()).entries : [],
periodSharedWith: shareRes?.ok ? (await shareRes.json()).sharedWith : [], periodSharedWith: shareRes?.ok ? (await shareRes.json()).sharedWith : []
sharedPeriods: sharedRes?.ok ? (await sharedRes.json()).shared : []
}; };
}; };
@@ -353,10 +353,6 @@
<PeriodTracker periods={data.periods ?? []} {lang} sharedWith={data.periodSharedWith ?? []} /> <PeriodTracker periods={data.periods ?? []} {lang} sharedWith={data.periodSharedWith ?? []} />
{/if} {/if}
{#each data.sharedPeriods ?? [] as shared (shared.owner)}
<PeriodTracker periods={shared.entries} {lang} readOnly ownerName={shared.owner} />
{/each}
<div class="page-footer-actions"> <div class="page-footer-actions">
<button type="button" class="edit-profile-link" onclick={openProfileEdit}> <button type="button" class="edit-profile-link" onclick={openProfileEdit}>
<UserCog size={14} /> <UserCog size={14} />
@@ -2,17 +2,21 @@ import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch, locals }) => { export const load: PageServerLoad = async ({ fetch, locals }) => {
const session = await locals.auth(); const session = await locals.auth();
const [res, goalRes, heatmapRes, nutritionRes, latestRes] = await Promise.all([ const [res, goalRes, heatmapRes, nutritionRes, latestRes, periodRes, sharedRes] = await Promise.all([
fetch('/api/fitness/stats/overview'), fetch('/api/fitness/stats/overview'),
fetch('/api/fitness/goal'), fetch('/api/fitness/goal'),
fetch('/api/fitness/stats/muscle-heatmap?weeks=8'), fetch('/api/fitness/stats/muscle-heatmap?weeks=8'),
fetch('/api/fitness/stats/nutrition'), fetch('/api/fitness/stats/nutrition'),
fetch('/api/fitness/measurements/latest') fetch('/api/fitness/measurements/latest'),
fetch('/api/fitness/period').catch(() => null),
fetch('/api/fitness/period/shared').catch(() => null)
]); ]);
const stats = await res.json(); const stats = await res.json();
const goal = goalRes.ok ? await goalRes.json() : { weeklyWorkouts: null, streak: 0 }; const goal = goalRes.ok ? await goalRes.json() : { weeklyWorkouts: null, streak: 0 };
const muscleHeatmap = heatmapRes.ok ? await heatmapRes.json() : { weeks: [], totals: {}, muscleGroups: [] }; const muscleHeatmap = heatmapRes.ok ? await heatmapRes.json() : { weeks: [], totals: {}, muscleGroups: [] };
const nutritionStats = nutritionRes.ok ? await nutritionRes.json() : null; const nutritionStats = nutritionRes.ok ? await nutritionRes.json() : null;
const latest = latestRes.ok ? await latestRes.json() : {}; const latest = latestRes.ok ? await latestRes.json() : {};
return { session, stats, goal, muscleHeatmap, nutritionStats, latest }; const periods = periodRes?.ok ? (await periodRes.json()).entries : [];
const sharedPeriods = sharedRes?.ok ? (await sharedRes.json()).shared : [];
return { session, stats, goal, muscleHeatmap, nutritionStats, latest, periods, sharedPeriods };
}; };
@@ -5,6 +5,7 @@
import MuscleHeatmap from '$lib/components/fitness/MuscleHeatmap.svelte'; import MuscleHeatmap from '$lib/components/fitness/MuscleHeatmap.svelte';
import { Dumbbell, Route, Flame, Weight, Beef, Droplet, Wheat, Scale, Target, Info, Ruler } from '@lucide/svelte'; import { Dumbbell, Route, Flame, Weight, Beef, Droplet, Wheat, Scale, Target, Info, Ruler } from '@lucide/svelte';
import FitnessStreakAura from '$lib/components/fitness/FitnessStreakAura.svelte'; import FitnessStreakAura from '$lib/components/fitness/FitnessStreakAura.svelte';
import PeriodTracker from '$lib/components/fitness/PeriodTracker.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n'; import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
import { toast } from '$lib/js/toast.svelte'; import { toast } from '$lib/js/toast.svelte';
@@ -429,6 +430,14 @@
</div> </div>
</section> </section>
{/if} {/if}
{#if data.goal?.sex === 'female'}
<PeriodTracker periods={data.periods ?? []} {lang} mode="projection" />
{/if}
{#each data.sharedPeriods ?? [] as shared (shared.owner)}
<PeriodTracker periods={shared.entries} {lang} readOnly ownerName={shared.owner} mode="projection" />
{/each}
</div> </div>
<style> <style>