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:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.40.6",
|
||||
"version": "1.41.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
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
|
||||
let periods = $state([...initialPeriods]);
|
||||
@@ -592,18 +594,18 @@
|
||||
<div class="status-main">
|
||||
<span class="status-pill period-pill">{t('current_period', lang)}</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-relative">{relativeDate(predictions.predictedEndOfOngoing)}</span>
|
||||
<span class="status-date">{formatDate(predictions.predictedEndOfOngoing)}</span>
|
||||
{/if}
|
||||
{#if !readOnly}
|
||||
{#if showEntry && !readOnly}
|
||||
<button class="end-btn" onclick={endPeriod} disabled={loading}>
|
||||
{t('end_period', lang)}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if nextCycle}
|
||||
{#if showProjection && nextCycle}
|
||||
<div class="status-side">
|
||||
<div class="status-side-item ovulation-accent">
|
||||
<span class="status-side-label">{t('ovulation', lang)}</span>
|
||||
@@ -617,13 +619,13 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if nextCycle}
|
||||
{:else if showProjection && nextCycle}
|
||||
<div class="status-split">
|
||||
<div class="status-main">
|
||||
<span class="status-pill period-pill">{t('next_period', lang)}</span>
|
||||
<span class="status-hero">{relativeRange(nextCycle.start, 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}>
|
||||
{t('start_period', lang)}
|
||||
</button>
|
||||
@@ -641,9 +643,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{:else if showEntry}
|
||||
<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}
|
||||
<button class="start-btn" onclick={startPeriod} disabled={loading}>
|
||||
{t('start_period', lang)}
|
||||
@@ -653,6 +655,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showProjection}
|
||||
<!-- Calendar -->
|
||||
<div class="calendar">
|
||||
<div class="cal-header">
|
||||
@@ -691,7 +694,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if completed.length >= 2}
|
||||
{/if}
|
||||
|
||||
{#if showProjection && completed.length >= 2}
|
||||
<div class="cycle-stats">
|
||||
<div class="cycle-stat">
|
||||
<span class="cycle-stat-label">{t('cycle_length', lang)}</span>
|
||||
@@ -710,7 +715,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !readOnly}
|
||||
{#if showEntry && !readOnly}
|
||||
<!-- History + Share row -->
|
||||
{#if sorted.length > 0}
|
||||
<div class="history">
|
||||
|
||||
@@ -402,6 +402,7 @@ const translations: Translations = {
|
||||
period_tracker: { en: 'Period Tracker', de: 'Periodentracker' },
|
||||
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_active_period: { en: 'No active period.', de: 'Keine aktive Periode.' },
|
||||
start_period: { en: 'Start Period', de: 'Periode starten' },
|
||||
end_period: { en: 'Period Ended', de: 'Periode vorbei' },
|
||||
period_day: { en: 'Day', de: 'Tag' },
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
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?limit=200'),
|
||||
fetch('/api/fitness/goal'),
|
||||
fetch('/api/fitness/period').catch(() => null),
|
||||
fetch('/api/fitness/period/share').catch(() => null),
|
||||
fetch('/api/fitness/period/shared').catch(() => null)
|
||||
fetch('/api/fitness/period/share').catch(() => null)
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -15,7 +14,6 @@ export const load: PageServerLoad = async ({ fetch }) => {
|
||||
measurements: await listRes.json(),
|
||||
profile: goalRes.ok ? await goalRes.json() : {},
|
||||
periods: periodRes?.ok ? (await periodRes.json()).entries : [],
|
||||
periodSharedWith: shareRes?.ok ? (await shareRes.json()).sharedWith : [],
|
||||
sharedPeriods: sharedRes?.ok ? (await sharedRes.json()).shared : []
|
||||
periodSharedWith: shareRes?.ok ? (await shareRes.json()).sharedWith : []
|
||||
};
|
||||
};
|
||||
|
||||
@@ -353,10 +353,6 @@
|
||||
<PeriodTracker periods={data.periods ?? []} {lang} sharedWith={data.periodSharedWith ?? []} />
|
||||
{/if}
|
||||
|
||||
{#each data.sharedPeriods ?? [] as shared (shared.owner)}
|
||||
<PeriodTracker periods={shared.entries} {lang} readOnly ownerName={shared.owner} />
|
||||
{/each}
|
||||
|
||||
<div class="page-footer-actions">
|
||||
<button type="button" class="edit-profile-link" onclick={openProfileEdit}>
|
||||
<UserCog size={14} />
|
||||
|
||||
@@ -2,17 +2,21 @@ import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
||||
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/goal'),
|
||||
fetch('/api/fitness/stats/muscle-heatmap?weeks=8'),
|
||||
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 goal = goalRes.ok ? await goalRes.json() : { weeklyWorkouts: null, streak: 0 };
|
||||
const muscleHeatmap = heatmapRes.ok ? await heatmapRes.json() : { weeks: [], totals: {}, muscleGroups: [] };
|
||||
const nutritionStats = nutritionRes.ok ? await nutritionRes.json() : null;
|
||||
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 { Dumbbell, Route, Flame, Weight, Beef, Droplet, Wheat, Scale, Target, Info, Ruler } from '@lucide/svelte';
|
||||
import FitnessStreakAura from '$lib/components/fitness/FitnessStreakAura.svelte';
|
||||
import PeriodTracker from '$lib/components/fitness/PeriodTracker.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
@@ -429,6 +430,14 @@
|
||||
</div>
|
||||
</section>
|
||||
{/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>
|
||||
|
||||
<style>
|
||||
|
||||
Reference in New Issue
Block a user