From 3e8340fde12a8b250f9b5aa87f6a6ba122a9acbe Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Mon, 6 Apr 2026 15:12:02 +0200 Subject: [PATCH] feat: add period tracker with calendar, predictions, fertility tracking, and sharing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 7 +- package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- .../components/fitness/PeriodTracker.svelte | 1612 +++++++++++++++++ src/lib/js/fitnessI18n.ts | 32 + src/models/PeriodEntry.ts | 23 + src/models/PeriodShare.ts | 18 + src/routes/api/fitness/period/+server.ts | 56 + src/routes/api/fitness/period/[id]/+server.ts | 55 + .../api/fitness/period/share/+server.ts | 38 + .../api/fitness/period/shared/+server.ts | 38 + .../[measure=fitnessMeasure]/+page.server.ts | 12 +- .../[measure=fitnessMeasure]/+page.svelte | 218 ++- 14 files changed, 2028 insertions(+), 87 deletions(-) create mode 100644 src/lib/components/fitness/PeriodTracker.svelte create mode 100644 src/models/PeriodEntry.ts create mode 100644 src/models/PeriodShare.ts create mode 100644 src/routes/api/fitness/period/+server.ts create mode 100644 src/routes/api/fitness/period/[id]/+server.ts create mode 100644 src/routes/api/fitness/period/share/+server.ts create mode 100644 src/routes/api/fitness/period/shared/+server.ts diff --git a/CLAUDE.md b/CLAUDE.md index 1749a4b..d313269 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/package.json b/package.json index 9c5b7f8..ed93985 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.1.1", + "version": "1.2.0", "private": true, "type": "module", "scripts": { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 26fd542..589d73e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bocken" -version = "0.2.1" +version = "0.3.0" edition = "2021" [lib] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index db23691..3f2cd8c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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" diff --git a/src/lib/components/fitness/PeriodTracker.svelte b/src/lib/components/fitness/PeriodTracker.svelte new file mode 100644 index 0000000..f2b8a0b --- /dev/null +++ b/src/lib/components/fitness/PeriodTracker.svelte @@ -0,0 +1,1612 @@ + + +
+

+ {#if readOnly && ownerName} + + + {ownerName} + + {:else} + {t('period_tracker', lang)} + {/if} +

+ + +
+ {#if ongoing} +
+
+ {t('current_period', lang)} + {t('period_day', lang)} {ongoingDay} + {#if predictions.predictedEndOfOngoing} + {t('predicted_end', lang)} + {relativeDate(predictions.predictedEndOfOngoing)} + {formatDate(predictions.predictedEndOfOngoing)} + {/if} + {#if !readOnly} + + {/if} +
+ {#if nextCycle} +
+
+ {t('ovulation', lang)} + {relativeDate(nextCycle.fertileEnd)} + {formatDate(nextCycle.fertileEnd)} +
+
+ {/if} +
+ {:else if nextCycle} +
+
+ {t('next_period', lang)} + {relativeRange(nextCycle.start, nextCycle.end)} + {formatDate(nextCycle.start)} — {formatDate(nextCycle.end)} + {#if !readOnly} + + {/if} +
+
+
+ {t('ovulation', lang)} + {relativeDate(nextCycle.fertileEnd)} + {formatDate(nextCycle.fertileEnd)} +
+
+ {t('fertile', lang)} + {formatDate(nextCycle.fertileStart)} — {formatDate(nextCycle.fertileEnd)} +
+
+
+ {:else} +
+ {t('no_period_data', lang)} + {#if !readOnly} + + {/if} +
+ {/if} +
+ + +
+
+ + {calendarLabel} + +
+
+ {#each weekDays as wd} + {wd} + {/each} +
+
+ {#each calendarDays as cell} + {cell.day} + {/each} +
+
+ {lang === 'de' ? 'Periode' : 'Period'} + {lang === 'de' ? 'Prognose' : 'Predicted'} + {t('fertile', lang)} + {t('peak_fertility', lang)} + {t('ovulation', lang)} + {t('luteal_phase', lang)} +
+
+ + {#if completed.length >= 2} +
+
+ {t('cycle_length', lang)} + {Math.round(predictions.avgCycle)} {t('days', lang)} + {#if predictions.cycleVariance > 0} + ± {predictions.cycleVariance} {t('days', lang)} (95% CI) + {/if} +
+
+ {t('period_length', lang)} + {Math.round(predictions.avgPeriod)} {t('days', lang)} + {#if predictions.periodVariance > 0} + ± {predictions.periodVariance} {t('days', lang)} (95% CI) + {/if} +
+
+ {/if} + + {#if !readOnly} + + {#if sorted.length > 0} +
+
+ + +
+ + {#if showHistory} +
+ +
+ + {#if showAddForm} +
+
+ + +
+
+ + +
+
+ {/if} + +
+ {#each sorted as p (p._id)} + {#if editId === p._id} +
+
+ + +
+
+ + +
+
+ {:else} +
+
+ + {formatDate(p.startDate)} + {#if p.endDate} + — {formatDate(p.endDate)} + {:else} + {t('ongoing', lang)} + {/if} + + {#if p.endDate} + {@const dur = Math.round((new Date(p.endDate).getTime() - new Date(p.startDate).getTime()) / 86400000) + 1} + {dur} {t('days', lang)} + {/if} +
+
+ + +
+
+ {/if} + {/each} +
+ {/if} +
+ {:else} +
+ + + {#if showAddForm} +
+
+ + +
+
+ + +
+
+ {/if} +
+ {/if} + + {/if} +
+ + +{#if showShare} + +
showShare = false} onkeydown={(e) => e.key === 'Escape' && (showShare = false)}> + + +
+{/if} + + diff --git a/src/lib/js/fitnessI18n.ts b/src/lib/js/fitnessI18n.ts index 2c6e331..3628688 100644 --- a/src/lib/js/fitnessI18n.ts +++ b/src/lib/js/fitnessI18n.ts @@ -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' }, diff --git a/src/models/PeriodEntry.ts b/src/models/PeriodEntry.ts new file mode 100644 index 0000000..0e7676a --- /dev/null +++ b/src/models/PeriodEntry.ts @@ -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('PeriodEntry', PeriodEntrySchema); diff --git a/src/models/PeriodShare.ts b/src/models/PeriodShare.ts new file mode 100644 index 0000000..c0edd6c --- /dev/null +++ b/src/models/PeriodShare.ts @@ -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('PeriodShare', PeriodShareSchema); diff --git a/src/routes/api/fitness/period/+server.ts b/src/routes/api/fitness/period/+server.ts new file mode 100644 index 0000000..35b0e23 --- /dev/null +++ b/src/routes/api/fitness/period/+server.ts @@ -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 }); +}; diff --git a/src/routes/api/fitness/period/[id]/+server.ts b/src/routes/api/fitness/period/[id]/+server.ts new file mode 100644 index 0000000..e4cc016 --- /dev/null +++ b/src/routes/api/fitness/period/[id]/+server.ts @@ -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 = {}; + + 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' }); +}; diff --git a/src/routes/api/fitness/period/share/+server.ts b/src/routes/api/fitness/period/share/+server.ts new file mode 100644 index 0000000..65a2eff --- /dev/null +++ b/src/routes/api/fitness/period/share/+server.ts @@ -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 }); +}; diff --git a/src/routes/api/fitness/period/shared/+server.ts b/src/routes/api/fitness/period/shared/+server.ts new file mode 100644 index 0000000..62a70ad --- /dev/null +++ b/src/routes/api/fitness/period/shared/+server.ts @@ -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(); + 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 }); +}; diff --git a/src/routes/fitness/[measure=fitnessMeasure]/+page.server.ts b/src/routes/fitness/[measure=fitnessMeasure]/+page.server.ts index 1a40ec8..6210ca0 100644 --- a/src/routes/fitness/[measure=fitnessMeasure]/+page.server.ts +++ b/src/routes/fitness/[measure=fitnessMeasure]/+page.server.ts @@ -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 : [] }; }; diff --git a/src/routes/fitness/[measure=fitnessMeasure]/+page.svelte b/src/routes/fitness/[measure=fitnessMeasure]/+page.svelte index 1bf2d3d..8568c03 100644 --- a/src/routes/fitness/[measure=fitnessMeasure]/+page.svelte +++ b/src/routes/fitness/[measure=fitnessMeasure]/+page.svelte @@ -1,6 +1,6 @@