feat: add period tracker with calendar, predictions, fertility tracking, and sharing
All checks were successful
CI / update (push) Successful in 3m18s

Full period tracking system for the fitness measure page:
- Period logging with start/end dates, edit/delete support
- EMA-based cycle and period length predictions (α=0.3, 12 future cycles)
- Calendar view with connected range strips, overflow days, today marker
- Fertility window, peak fertility, ovulation, and luteal phase visualization
- Period sharing between users with profile picture avatars
- Cycle/period stats with 95% CI below calendar
- Redesigned profile card as inline header metadata with Venus/Mars icons
- Collapsible weight and period history sections
- Full DE/EN i18n support
This commit is contained in:
2026-04-06 15:12:02 +02:00
parent d10946774d
commit 3e8340fde1
14 changed files with 2028 additions and 87 deletions

View File

@@ -85,7 +85,6 @@ When committing, bump version numbers as appropriate using semver:
- **minor** (x.Y.0): new features, significant UI changes, new pages/routes
- **major** (X.0.0): breaking changes, major redesigns, data model changes
Version files to update (keep in sync):
- `package.json` — site version
- `src-tauri/tauri.conf.json` — Tauri/Android app version
- `src-tauri/Cargo.toml` — Rust crate version
Version files to update:
- `package.json` — site version (bump on every commit)
- `src-tauri/tauri.conf.json` + `src-tauri/Cargo.toml` — Tauri/Android app version. Only bump these when the Tauri app codebase itself changes (e.g. `src-tauri/` files), NOT for website-only changes.

View File

@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.1.1",
"version": "1.2.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,6 +1,6 @@
[package]
name = "bocken"
version = "0.2.1"
version = "0.3.0"
edition = "2021"
[lib]

View File

@@ -1,7 +1,7 @@
{
"productName": "Bocken",
"identifier": "org.bocken.app",
"version": "0.2.1",
"version": "0.3.0",
"build": {
"devUrl": "http://192.168.1.4:5173",
"frontendDist": "https://bocken.org"

File diff suppressed because it is too large Load Diff

View File

@@ -298,6 +298,38 @@ const translations: Translations = {
log_food: { en: 'Log', de: 'Eintragen' },
delete_entry_confirm: { en: 'Delete this food entry?', de: 'Diesen Eintrag löschen?' },
// Period tracker
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.' },
start_period: { en: 'Start Period', de: 'Periode starten' },
end_period: { en: 'End Period', de: 'Periode beenden' },
period_day: { en: 'Day', de: 'Tag' },
predicted_end: { en: 'Predicted end', de: 'Voraussichtliches Ende' },
next_period: { en: 'Next period', de: 'Nächste Periode' },
cycle_length: { en: 'Cycle length', de: 'Zykluslänge' },
period_length: { en: 'Period length', de: 'Periodenlänge' },
avg_cycle: { en: 'Avg. cycle', de: 'Ø Zyklus' },
avg_period: { en: 'Avg. period', de: 'Ø Periode' },
days: { en: 'days', de: 'Tage' },
delete_period_confirm: { en: 'Delete this period entry?', de: 'Diesen Periodeneintrag löschen?' },
add_past_period: { en: 'Add Past Period', de: 'Vergangene Periode hinzufügen' },
period_start: { en: 'Start', de: 'Beginn' },
period_end: { en: 'End', de: 'Ende' },
ongoing: { en: 'ongoing', de: 'laufend' },
share: { en: 'Share', de: 'Teilen' },
shared_with: { en: 'Shared with', de: 'Geteilt mit' },
add_user: { en: 'Add user…', de: 'Nutzer hinzufügen…' },
no_shared: { en: 'Not shared with anyone.', de: 'Mit niemandem geteilt.' },
shared_by: { en: 'Shared by', de: 'Geteilt von' },
fertile_window: { en: 'Fertile window', de: 'Fruchtbares Fenster' },
peak_fertility: { en: 'Peak fertility', de: 'Höchste Fruchtbarkeit' },
ovulation: { en: 'Ovulation', de: 'Eisprung' },
fertile: { en: 'Fertile', de: 'Fruchtbar' },
luteal_phase: { en: 'Luteal', de: 'Luteal' },
predicted_ovulation: { en: 'Predicted ovulation', de: 'Voraussichtlicher Eisprung' },
to: { en: 'to', de: 'bis' },
// Custom meals
custom_meals: { en: 'Custom Meals', de: 'Eigene Mahlzeiten' },
custom_meal: { en: 'Custom Meal', de: 'Eigene Mahlzeit' },

23
src/models/PeriodEntry.ts Normal file
View File

@@ -0,0 +1,23 @@
import mongoose from 'mongoose';
export interface IPeriodEntry {
_id?: string;
startDate: Date;
endDate?: Date;
createdBy: string;
createdAt?: Date;
updatedAt?: Date;
}
const PeriodEntrySchema = new mongoose.Schema(
{
startDate: { type: Date, required: true },
endDate: { type: Date, default: null },
createdBy: { type: String, required: true, trim: true }
},
{ timestamps: true }
);
PeriodEntrySchema.index({ createdBy: 1, startDate: -1 });
export const PeriodEntry = mongoose.model<IPeriodEntry>('PeriodEntry', PeriodEntrySchema);

18
src/models/PeriodShare.ts Normal file
View File

@@ -0,0 +1,18 @@
import mongoose from 'mongoose';
export interface IPeriodShare {
owner: string;
sharedWith: string[];
}
const PeriodShareSchema = new mongoose.Schema(
{
owner: { type: String, required: true, unique: true, trim: true },
sharedWith: [{ type: String, trim: true }]
},
{ timestamps: true }
);
PeriodShareSchema.index({ sharedWith: 1 });
export const PeriodShare = mongoose.model<IPeriodShare>('PeriodShare', PeriodShareSchema);

View File

@@ -0,0 +1,56 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/auth';
import { dbConnect } from '$utils/db';
import { PeriodEntry } from '$models/PeriodEntry';
/** GET: List period entries (most recent first) */
export const GET: RequestHandler = async ({ url, locals }) => {
const user = await requireAuth(locals);
await dbConnect();
const limit = Math.min(parseInt(url.searchParams.get('limit') || '50'), 100);
const entries = await PeriodEntry.find({ createdBy: user.nickname })
.sort({ startDate: -1 })
.limit(limit)
.lean();
return json({ entries });
};
/** POST: Start a new period (or create a completed one with startDate + endDate) */
export const POST: RequestHandler = async ({ request, locals }) => {
const user = await requireAuth(locals);
await dbConnect();
const data = await request.json();
const { startDate, endDate } = data;
if (!startDate) {
return json({ error: 'startDate is required' }, { status: 400 });
}
const start = new Date(startDate);
if (isNaN(start.getTime())) {
return json({ error: 'Invalid startDate' }, { status: 400 });
}
// Check no ongoing period exists (endDate is null)
if (!endDate) {
const ongoing = await PeriodEntry.findOne({
createdBy: user.nickname,
endDate: null
});
if (ongoing) {
return json({ error: 'An ongoing period already exists. End it first.' }, { status: 409 });
}
}
const entry = await PeriodEntry.create({
startDate: start,
endDate: endDate ? new Date(endDate) : null,
createdBy: user.nickname
});
return json({ entry }, { status: 201 });
};

View File

@@ -0,0 +1,55 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/auth';
import { dbConnect } from '$utils/db';
import { PeriodEntry } from '$models/PeriodEntry';
import mongoose from 'mongoose';
/** PUT: Update a period entry (e.g. set endDate to end an ongoing period) */
export const PUT: RequestHandler = async ({ params, request, locals }) => {
const user = await requireAuth(locals);
await dbConnect();
if (!mongoose.Types.ObjectId.isValid(params.id)) {
return json({ error: 'Invalid period ID' }, { status: 400 });
}
const data = await request.json();
const updateData: Record<string, unknown> = {};
if (data.startDate !== undefined) updateData.startDate = new Date(data.startDate);
if (data.endDate !== undefined) updateData.endDate = data.endDate ? new Date(data.endDate) : null;
const entry = await PeriodEntry.findOneAndUpdate(
{ _id: params.id, createdBy: user.nickname },
updateData,
{ returnDocument: 'after' }
);
if (!entry) {
return json({ error: 'Period entry not found or unauthorized' }, { status: 404 });
}
return json({ entry });
};
/** DELETE: Remove a period entry */
export const DELETE: RequestHandler = async ({ params, locals }) => {
const user = await requireAuth(locals);
await dbConnect();
if (!mongoose.Types.ObjectId.isValid(params.id)) {
return json({ error: 'Invalid period ID' }, { status: 400 });
}
const entry = await PeriodEntry.findOneAndDelete({
_id: params.id,
createdBy: user.nickname
});
if (!entry) {
return json({ error: 'Period entry not found or unauthorized' }, { status: 404 });
}
return json({ message: 'Period entry deleted' });
};

View File

@@ -0,0 +1,38 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/auth';
import { dbConnect } from '$utils/db';
import { PeriodShare } from '$models/PeriodShare';
/** GET: Get current share settings */
export const GET: RequestHandler = async ({ locals }) => {
const user = await requireAuth(locals);
await dbConnect();
const doc = await PeriodShare.findOne({ owner: user.nickname }).lean();
return json({ sharedWith: doc?.sharedWith ?? [] });
};
/** PUT: Update share list (set full list of usernames) */
export const PUT: RequestHandler = async ({ request, locals }) => {
const user = await requireAuth(locals);
await dbConnect();
const { sharedWith } = await request.json();
if (!Array.isArray(sharedWith)) {
return json({ error: 'sharedWith must be an array of usernames' }, { status: 400 });
}
// Sanitize: lowercase, trim, dedupe, remove self
const cleaned = [...new Set(
sharedWith.map((u: string) => u.trim().toLowerCase()).filter((u: string) => u && u !== user.nickname.toLowerCase())
)];
const doc = await PeriodShare.findOneAndUpdate(
{ owner: user.nickname },
{ sharedWith: cleaned },
{ upsert: true, returnDocument: 'after' }
);
return json({ sharedWith: doc?.sharedWith ?? cleaned });
};

View File

@@ -0,0 +1,38 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/auth';
import { dbConnect } from '$utils/db';
import { PeriodShare } from '$models/PeriodShare';
import { PeriodEntry } from '$models/PeriodEntry';
/** GET: Get period data from all users who have shared with the current user */
export const GET: RequestHandler = async ({ locals }) => {
const user = await requireAuth(locals);
await dbConnect();
// Find all share docs where current user is in the sharedWith list
const shares = await PeriodShare.find({ sharedWith: user.nickname }).lean();
const owners = shares.map(s => s.owner);
if (owners.length === 0) return json({ shared: [] });
// Fetch period entries for all sharing owners
const entries = await PeriodEntry.find({ createdBy: { $in: owners } })
.sort({ startDate: -1 })
.limit(200)
.lean();
// Group by owner
const byOwner = new Map<string, any[]>();
for (const e of entries) {
const list = byOwner.get(e.createdBy) ?? [];
list.push(e);
byOwner.set(e.createdBy, list);
}
const shared = owners
.filter(o => byOwner.has(o))
.map(owner => ({ owner, entries: byOwner.get(owner)! }));
return json({ shared });
};

View File

@@ -1,15 +1,21 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch }) => {
const [latestRes, listRes, goalRes] = await Promise.all([
const [latestRes, listRes, goalRes, periodRes, shareRes, sharedRes] = await Promise.all([
fetch('/api/fitness/measurements/latest'),
fetch('/api/fitness/measurements?limit=20'),
fetch('/api/fitness/goal')
fetch('/api/fitness/goal'),
fetch('/api/fitness/period').catch(() => null),
fetch('/api/fitness/period/share').catch(() => null),
fetch('/api/fitness/period/shared').catch(() => null)
]);
return {
latest: await latestRes.json(),
measurements: await listRes.json(),
profile: goalRes.ok ? await goalRes.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 : []
};
};

View File

@@ -1,6 +1,6 @@
<script>
import { page } from '$app/stores';
import { Pencil, Trash2, ChevronDown } from '@lucide/svelte';
import { Pencil, Trash2, ChevronRight, Venus, Mars } from '@lucide/svelte';
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
import { toast } from '$lib/js/toast.svelte';
@@ -8,19 +8,32 @@
const measureSlug = $derived(lang === 'en' ? 'measure' : 'messen');
import { getWorkout } from '$lib/js/workout.svelte';
import AddButton from '$lib/components/AddButton.svelte';
import PeriodTracker from '$lib/components/fitness/PeriodTracker.svelte';
let { data } = $props();
const workout = getWorkout();
let latest = $state(data.latest ? { ...data.latest } : {});
let measurements = $state(data.measurements?.measurements ? [...data.measurements.measurements] : []);
let showWeightHistory = $state(false);
// Profile fields (sex, height, birth year) — stored in FitnessGoal
let showProfile = $state(false);
let profileSex = $state(data.profile?.sex ?? 'male');
let profileHeight = $state(data.profile?.heightCm != null ? String(data.profile.heightCm) : '');
let profileBirthYear = $state(data.profile?.birthYear != null ? String(data.profile.birthYear) : '');
let profileSaving = $state(false);
let profileEditing = $state(false);
const profileParts = $derived.by(() => {
/** @type {string[]} */
const parts = [];
const h = data.profile?.heightCm;
if (h) parts.push(`${h}cm`);
const by = data.profile?.birthYear;
if (by) parts.push(`*${by}`);
return parts;
});
let profileDirty = $derived(
profileSex !== (data.profile?.sex ?? 'male') ||
profileHeight !== (data.profile?.heightCm != null ? String(data.profile.heightCm) : '') ||
@@ -106,38 +119,51 @@
<svelte:head><title>{lang === 'en' ? 'Measure' : 'Messen'} - Bocken</title></svelte:head>
<div class="measure-page">
<h1>{t('measure_title', lang)}</h1>
<div class="page-header">
<h1>{t('measure_title', lang)}</h1>
<div class="profile-meta">
{#if data.profile?.sex}
<span class="profile-sex-icon">
{#if data.profile.sex === 'female'}
<Venus size={16} />
{:else}
<Mars size={16} />
{/if}
</span>
{/if}
{#if profileParts.length > 0}
<span class="profile-summary">{profileParts.join(' · ')}</span>
{/if}
<button class="profile-edit-btn" onclick={() => profileEditing = !profileEditing} aria-label="Edit profile">
<Pencil size={12} />
</button>
</div>
</div>
<section class="profile-section">
<button class="profile-toggle" onclick={() => showProfile = !showProfile}>
<h2>{t('profile', lang)}</h2>
<ChevronDown size={16} class={showProfile ? 'chevron open' : 'chevron'} />
</button>
{#if showProfile}
<div class="profile-row">
<div class="form-group">
<label for="p-sex">{t('sex', lang)}</label>
<select id="p-sex" bind:value={profileSex}>
<option value="male">{t('male', lang)}</option>
<option value="female">{t('female', lang)}</option>
</select>
</div>
<div class="form-group">
<label for="p-height">{t('height', lang)}</label>
<input id="p-height" type="number" min="100" max="250" placeholder="175" bind:value={profileHeight} />
</div>
<div class="form-group">
<label for="p-birthyear">{t('birth_year', lang)}</label>
<input id="p-birthyear" type="number" min="1900" max="2020" placeholder="1990" bind:value={profileBirthYear} />
</div>
{#if profileDirty}
<button class="profile-save-btn" onclick={saveProfile} disabled={profileSaving}>
{profileSaving ? t('saving', lang) : t('save', lang)}
</button>
{/if}
{#if profileEditing}
<div class="profile-fields">
<div class="form-group">
<label for="p-sex">{t('sex', lang)}</label>
<select id="p-sex" bind:value={profileSex}>
<option value="male">{t('male', lang)}</option>
<option value="female">{t('female', lang)}</option>
</select>
</div>
{/if}
</section>
<div class="form-group">
<label for="p-height">{t('height', lang)}</label>
<input id="p-height" type="number" min="100" max="250" placeholder="175" bind:value={profileHeight} />
</div>
<div class="form-group">
<label for="p-birthyear">{t('birth_year', lang)}</label>
<input id="p-birthyear" type="number" min="1900" max="2020" placeholder="1990" bind:value={profileBirthYear} />
</div>
{#if profileDirty}
<button class="profile-save-btn" onclick={saveProfile} disabled={profileSaving}>
{profileSaving ? t('saving', lang) : t('save', lang)}
</button>
{/if}
</div>
{/if}
<section class="latest-section">
<h2>{t('latest', lang)}</h2>
@@ -169,29 +195,42 @@
{#if measurements.length > 0}
<section class="history-section">
<h2>{t('history', lang)}</h2>
<div class="history-list">
{#each measurements as m (m._id)}
<div class="history-item">
<div class="history-main">
<div class="history-info">
<span class="history-date">{formatDate(m.date)}</span>
<span class="history-summary">{summaryParts(m)}</span>
</div>
<div class="history-actions">
<a class="icon-btn edit" href="/fitness/{measureSlug}/edit/{m._id}" aria-label="Edit measurement">
<Pencil size={14} />
</a>
<button class="icon-btn delete" onclick={() => deleteMeasurement(m._id)} aria-label="Delete measurement">
<Trash2 size={14} />
</button>
<button class="history-toggle" onclick={() => showWeightHistory = !showWeightHistory}>
<h2>{t('history', lang)}</h2>
<ChevronRight size={14} class={showWeightHistory ? 'chevron open' : 'chevron'} />
</button>
{#if showWeightHistory}
<div class="history-list">
{#each measurements as m (m._id)}
<div class="history-item">
<div class="history-main">
<div class="history-info">
<span class="history-date">{formatDate(m.date)}</span>
<span class="history-summary">{summaryParts(m)}</span>
</div>
<div class="history-actions">
<a class="icon-btn edit" href="/fitness/{measureSlug}/edit/{m._id}" aria-label="Edit measurement">
<Pencil size={14} />
</a>
<button class="icon-btn delete" onclick={() => deleteMeasurement(m._id)} aria-label="Delete measurement">
<Trash2 size={14} />
</button>
</div>
</div>
</div>
</div>
{/each}
</div>
{/each}
</div>
{/if}
</section>
{/if}
{#if data.profile?.sex === 'female'}
<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>
{#if !workout.active}
@@ -212,43 +251,47 @@
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
/* Profile */
.profile-section {
background: var(--color-surface);
border-radius: 8px;
box-shadow: var(--shadow-sm);
padding: 0.75rem 1rem;
/* Header with inline profile */
.page-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
}
.profile-toggle {
.profile-meta {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 0.4rem;
}
.profile-sex-icon {
display: flex;
color: var(--color-text-secondary);
}
.profile-summary {
font-size: 0.8rem;
color: var(--color-text-secondary);
letter-spacing: 0.02em;
}
.profile-edit-btn {
display: flex;
align-items: center;
padding: 0.25rem;
background: none;
border: none;
cursor: pointer;
padding: 0;
color: inherit;
color: var(--color-text-tertiary);
opacity: 0.6;
transition: opacity 0.15s;
}
.profile-toggle h2 {
margin: 0;
font-size: 0.9rem;
.profile-edit-btn:hover {
opacity: 1;
color: var(--color-text-secondary);
}
.profile-toggle :global(.chevron) {
transition: transform 0.2s;
}
.profile-toggle :global(.chevron.open) {
transform: rotate(180deg);
}
.profile-section h2 {
margin: 0 0 0.5rem;
font-size: 0.9rem;
}
.profile-row {
.profile-fields {
display: flex;
gap: 0.75rem;
align-items: flex-end;
margin-top: 0.5rem;
}
.profile-save-btn {
padding: 0.4rem 0.75rem;
@@ -346,6 +389,27 @@
}
/* History */
.history-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
background: none;
border: none;
cursor: pointer;
padding: 0;
color: inherit;
}
.history-toggle h2 {
margin: 0;
font-size: 1.1rem;
}
.history-toggle :global(.chevron) {
transition: transform 0.2s;
}
.history-toggle :global(.chevron.open) {
transform: rotate(90deg);
}
.history-list {
display: flex;
flex-direction: column;