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}
+
+ {: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}
+
+
+
+
+
+
+ {#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 shareList.length > 0}
+
+
{t('shared_with', lang)}
+ {#each shareList as user}
+
+ {/each}
+
+ {/if}
+
+
+
+
+ {#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}
+
+
+
{t('no_period_data', lang)}
+
+
+
+ {#if showAddForm}
+
+ {/if}
+
+ {/if}
+
+ {/if}
+
+
+
+{#if showShare}
+
+
showShare = false} onkeydown={(e) => e.key === 'Escape' && (showShare = false)}>
+
+
e.stopPropagation()}>
+
+ {#if shareList.length > 0}
+
+ {#each shareList as user}
+
+
+
{user}
+
+
+ {/each}
+
+ {:else}
+
{t('no_shared', lang)}
+ {/if}
+
+
+
+{/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 @@