fitness: add weekly workout goal with streak counter on stats page
Some checks failed
CI / update (push) Has been cancelled
Some checks failed
CI / update (push) Has been cancelled
Store a per-user weekly workout target (1-14) in a new FitnessGoal model. Compute consecutive-week streak from WorkoutSession history via a new /api/fitness/goal endpoint. Display streak as a 4th lifetime card on the stats page with an inline goal editor modal. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -216,6 +216,15 @@ const translations: Translations = {
|
|||||||
|
|
||||||
// WorkoutFab
|
// WorkoutFab
|
||||||
active_workout: { en: 'Active Workout', de: 'Aktives Training' },
|
active_workout: { en: 'Active Workout', de: 'Aktives Training' },
|
||||||
|
|
||||||
|
// Streak / Goal
|
||||||
|
streak: { en: 'Streak', de: 'Serie' },
|
||||||
|
streak_weeks: { en: 'Weeks', de: 'Wochen' },
|
||||||
|
streak_week: { en: 'Week', de: 'Woche' },
|
||||||
|
weekly_goal: { en: 'Weekly Goal', de: 'Wochenziel' },
|
||||||
|
workouts_per_week_goal: { en: 'workouts / week', de: 'Trainings / Woche' },
|
||||||
|
set_goal: { en: 'Set Goal', de: 'Ziel setzen' },
|
||||||
|
goal_set: { en: 'Goal set', de: 'Ziel gesetzt' },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get a translated string */
|
/** Get a translated string */
|
||||||
|
|||||||
18
src/models/FitnessGoal.ts
Normal file
18
src/models/FitnessGoal.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
|
||||||
|
const FitnessGoalSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
username: { type: String, required: true, unique: true },
|
||||||
|
weeklyWorkouts: { type: Number, required: true, default: 4, min: 1, max: 14 }
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
interface IFitnessGoal {
|
||||||
|
username: string;
|
||||||
|
weeklyWorkouts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _model: mongoose.Model<IFitnessGoal>;
|
||||||
|
try { _model = mongoose.model<IFitnessGoal>("FitnessGoal"); } catch { _model = mongoose.model<IFitnessGoal>("FitnessGoal", FitnessGoalSchema); }
|
||||||
|
export const FitnessGoal = _model;
|
||||||
111
src/routes/api/fitness/goal/+server.ts
Normal file
111
src/routes/api/fitness/goal/+server.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { requireAuth } from '$lib/server/middleware/auth';
|
||||||
|
import { dbConnect } from '$utils/db';
|
||||||
|
import { FitnessGoal } from '$models/FitnessGoal';
|
||||||
|
import { WorkoutSession } from '$models/WorkoutSession';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ locals }) => {
|
||||||
|
const user = await requireAuth(locals);
|
||||||
|
await dbConnect();
|
||||||
|
|
||||||
|
const goal = await FitnessGoal.findOne({ username: user.nickname }).lean() as any;
|
||||||
|
const weeklyWorkouts = goal?.weeklyWorkouts ?? null;
|
||||||
|
|
||||||
|
// If no goal set, return early
|
||||||
|
if (weeklyWorkouts === null) {
|
||||||
|
return json({ weeklyWorkouts: null, streak: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const streak = await computeStreak(user.nickname, weeklyWorkouts);
|
||||||
|
return json({ weeklyWorkouts, streak });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PUT: RequestHandler = async ({ request, locals }) => {
|
||||||
|
const user = await requireAuth(locals);
|
||||||
|
const { weeklyWorkouts } = await request.json();
|
||||||
|
|
||||||
|
if (typeof weeklyWorkouts !== 'number' || weeklyWorkouts < 1 || weeklyWorkouts > 14 || !Number.isInteger(weeklyWorkouts)) {
|
||||||
|
return json({ error: 'weeklyWorkouts must be an integer between 1 and 14' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbConnect();
|
||||||
|
|
||||||
|
await FitnessGoal.findOneAndUpdate(
|
||||||
|
{ username: user.nickname },
|
||||||
|
{ weeklyWorkouts },
|
||||||
|
{ upsert: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const streak = await computeStreak(user.nickname, weeklyWorkouts);
|
||||||
|
return json({ weeklyWorkouts, streak });
|
||||||
|
};
|
||||||
|
|
||||||
|
async function computeStreak(username: string, weeklyGoal: number): Promise<number> {
|
||||||
|
// Get weekly workout counts going back up to 2 years
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setFullYear(cutoff.getFullYear() - 2);
|
||||||
|
|
||||||
|
const weeklyAgg = await WorkoutSession.aggregate([
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
createdBy: username,
|
||||||
|
startTime: { $gte: cutoff }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: {
|
||||||
|
year: { $isoWeekYear: '$startTime' },
|
||||||
|
week: { $isoWeek: '$startTime' }
|
||||||
|
},
|
||||||
|
count: { $sum: 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$sort: { '_id.year': -1, '_id.week': -1 }
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Build a set of weeks that met the goal
|
||||||
|
const metGoal = new Set<string>();
|
||||||
|
for (const item of weeklyAgg) {
|
||||||
|
if (item.count >= weeklyGoal) {
|
||||||
|
metGoal.add(`${item._id.year}-${item._id.week}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk backwards week-by-week counting consecutive weeks that met the goal.
|
||||||
|
// Current (incomplete) week counts if it already meets the goal, otherwise skip it.
|
||||||
|
const now = new Date();
|
||||||
|
let streak = 0;
|
||||||
|
|
||||||
|
const currentKey = isoWeekKey(now);
|
||||||
|
const currentWeekMet = metGoal.has(currentKey);
|
||||||
|
|
||||||
|
// If current week already met: count it, then check previous weeks.
|
||||||
|
// If not: start checking from last week (current week still in progress).
|
||||||
|
if (currentWeekMet) streak = 1;
|
||||||
|
|
||||||
|
for (let i = 1; i <= 104; i++) {
|
||||||
|
const weekDate = new Date(now);
|
||||||
|
weekDate.setDate(weekDate.getDate() - i * 7);
|
||||||
|
if (metGoal.has(isoWeekKey(weekDate))) {
|
||||||
|
streak++;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return streak;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoWeekKey(date: Date): string {
|
||||||
|
const d = new Date(date.getTime());
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7));
|
||||||
|
const year = d.getFullYear();
|
||||||
|
const week1 = new Date(year, 0, 4);
|
||||||
|
const week = 1 + Math.round(((d.getTime() - week1.getTime()) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7);
|
||||||
|
return `${year}-${week}`;
|
||||||
|
}
|
||||||
@@ -2,7 +2,11 @@ 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 = await fetch('/api/fitness/stats/overview');
|
const [res, goalRes] = await Promise.all([
|
||||||
|
fetch('/api/fitness/stats/overview'),
|
||||||
|
fetch('/api/fitness/goal')
|
||||||
|
]);
|
||||||
const stats = await res.json();
|
const stats = await res.json();
|
||||||
return { session, stats };
|
const goal = goalRes.ok ? await goalRes.json() : { weeklyWorkouts: null, streak: 0 };
|
||||||
|
return { session, stats, goal };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import FitnessChart from '$lib/components/fitness/FitnessChart.svelte';
|
import FitnessChart from '$lib/components/fitness/FitnessChart.svelte';
|
||||||
import { Dumbbell, Route, Flame } from 'lucide-svelte';
|
import { Dumbbell, Route, Flame, Zap } from 'lucide-svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
|
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
|
||||||
|
|
||||||
@@ -32,6 +32,36 @@
|
|||||||
|
|
||||||
const stats = $derived(data.stats ?? {});
|
const stats = $derived(data.stats ?? {});
|
||||||
|
|
||||||
|
let goalStreak = $state(data.goal?.streak ?? 0);
|
||||||
|
let goalWeekly = $state(data.goal?.weeklyWorkouts ?? null);
|
||||||
|
let goalEditing = $state(false);
|
||||||
|
let goalInput = $state(4);
|
||||||
|
let goalSaving = $state(false);
|
||||||
|
|
||||||
|
function startGoalEdit() {
|
||||||
|
goalInput = goalWeekly ?? 4;
|
||||||
|
goalEditing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGoal() {
|
||||||
|
goalSaving = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/fitness/goal', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ weeklyWorkouts: goalInput })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const d = await res.json();
|
||||||
|
goalWeekly = d.weeklyWorkouts;
|
||||||
|
goalStreak = d.streak;
|
||||||
|
goalEditing = false;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
goalSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const workoutsChartData = $derived({
|
const workoutsChartData = $derived({
|
||||||
labels: stats.workoutsChart?.labels ?? [],
|
labels: stats.workoutsChart?.labels ?? [],
|
||||||
datasets: [{
|
datasets: [{
|
||||||
@@ -113,8 +143,39 @@
|
|||||||
<div class="card-value">{stats.totalCardioKm ?? 0}<span class="card-unit">km</span></div>
|
<div class="card-value">{stats.totalCardioKm ?? 0}<span class="card-unit">km</span></div>
|
||||||
<div class="card-label">{t('distance_covered', lang)}</div>
|
<div class="card-label">{t('distance_covered', lang)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="lifetime-card streak" onclick={startGoalEdit}>
|
||||||
|
<div class="card-icon"><Zap size={24} /></div>
|
||||||
|
<div class="card-value">{goalStreak}</div>
|
||||||
|
<div class="card-label">{t('streak', lang)}</div>
|
||||||
|
{#if goalWeekly !== null}
|
||||||
|
<div class="card-goal">{goalWeekly}x / {t('streak_week', lang).toLowerCase()}</div>
|
||||||
|
{:else}
|
||||||
|
<div class="card-goal">{t('set_goal', lang)}</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if goalEditing}
|
||||||
|
<div class="goal-editor-overlay" onkeydown={(e) => { if (e.key === 'Escape') goalEditing = false; }} role="dialog">
|
||||||
|
<div class="goal-editor-backdrop" onclick={() => goalEditing = false}></div>
|
||||||
|
<div class="goal-editor-panel">
|
||||||
|
<h3>{t('weekly_goal', lang)}</h3>
|
||||||
|
<div class="goal-input-row">
|
||||||
|
<button class="adj-btn" onclick={() => { if (goalInput > 1) goalInput--; }} disabled={goalInput <= 1}>-</button>
|
||||||
|
<span class="goal-value">{goalInput}</span>
|
||||||
|
<button class="adj-btn" onclick={() => { if (goalInput < 14) goalInput++; }} disabled={goalInput >= 14}>+</button>
|
||||||
|
</div>
|
||||||
|
<span class="goal-unit">{t('workouts_per_week_goal', lang)}</span>
|
||||||
|
<div class="goal-actions">
|
||||||
|
<button class="goal-save" onclick={saveGoal} disabled={goalSaving}>
|
||||||
|
{goalSaving ? t('saving', lang) : t('save', lang)}
|
||||||
|
</button>
|
||||||
|
<button class="goal-cancel" onclick={() => goalEditing = false}>{t('cancel', lang)}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if (stats.workoutsChart?.data?.length ?? 0) > 0}
|
{#if (stats.workoutsChart?.data?.length ?? 0) > 0}
|
||||||
<FitnessChart
|
<FitnessChart
|
||||||
type="bar"
|
type="bar"
|
||||||
@@ -149,7 +210,7 @@
|
|||||||
|
|
||||||
.lifetime-cards {
|
.lifetime-cards {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
}
|
}
|
||||||
.lifetime-card {
|
.lifetime-card {
|
||||||
@@ -165,6 +226,16 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
button.lifetime-card {
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
color: inherit;
|
||||||
|
transition: box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
button.lifetime-card:hover {
|
||||||
|
box-shadow: var(--shadow-sm), 0 0 0 2px var(--nord13);
|
||||||
|
}
|
||||||
.lifetime-card::before {
|
.lifetime-card::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -181,6 +252,9 @@
|
|||||||
.lifetime-card.cardio::before {
|
.lifetime-card.cardio::before {
|
||||||
background: var(--nord14);
|
background: var(--nord14);
|
||||||
}
|
}
|
||||||
|
.lifetime-card.streak::before {
|
||||||
|
background: var(--nord13);
|
||||||
|
}
|
||||||
.card-icon {
|
.card-icon {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -202,6 +276,10 @@
|
|||||||
color: var(--nord14);
|
color: var(--nord14);
|
||||||
background: color-mix(in srgb, var(--nord14) 15%, transparent);
|
background: color-mix(in srgb, var(--nord14) 15%, transparent);
|
||||||
}
|
}
|
||||||
|
.streak .card-icon {
|
||||||
|
color: var(--nord13);
|
||||||
|
background: color-mix(in srgb, var(--nord13) 15%, transparent);
|
||||||
|
}
|
||||||
.card-value {
|
.card-value {
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -221,7 +299,18 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
.card-goal {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.lifetime-cards {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
@media (max-width: 400px) {
|
@media (max-width: 400px) {
|
||||||
.lifetime-cards {
|
.lifetime-cards {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -238,6 +327,108 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Goal editor overlay */
|
||||||
|
.goal-editor-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.goal-editor-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.goal-editor-panel {
|
||||||
|
position: relative;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
min-width: 240px;
|
||||||
|
}
|
||||||
|
.goal-editor-panel h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.goal-input-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.adj-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid var(--color-border, var(--nord3));
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.adj-btn:hover:not(:disabled) {
|
||||||
|
background: var(--nord13);
|
||||||
|
color: var(--nord0);
|
||||||
|
border-color: var(--nord13);
|
||||||
|
}
|
||||||
|
.adj-btn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.goal-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 2ch;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.goal-unit {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.goal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.goal-save {
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--nord13);
|
||||||
|
color: var(--nord0);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.goal-save:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.goal-cancel {
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-chart {
|
.empty-chart {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
|
|||||||
Reference in New Issue
Block a user