Compare commits
4 Commits
9a97e41c28
...
98417046bc
| Author | SHA1 | Date | |
|---|---|---|---|
|
98417046bc
|
|||
|
244050fa75
|
|||
|
0814803fc7
|
|||
|
eb2ffac536
|
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.67.3",
|
"version": "1.68.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -200,47 +200,107 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate future predicted cycles (12 cycles ≈ ~1 year)
|
// Generate future predicted cycles (12 cycles ≈ ~1 year)
|
||||||
const cycleMs = Math.round(emaCycle) * 86400000;
|
const meanCycleDays = Math.round(emaCycle);
|
||||||
|
const cycleMs = meanCycleDays * 86400000;
|
||||||
const periodMs = (Math.round(emaPeriod) - 1) * 86400000;
|
const periodMs = (Math.round(emaPeriod) - 1) * 86400000;
|
||||||
const lutealLength = 14;
|
const lutealLength = 14;
|
||||||
const lastStart = sorted[0] ? new Date(sorted[0].startDate) : null;
|
const lastStart = sorted[0] ? new Date(sorted[0].startDate) : null;
|
||||||
|
|
||||||
/** @type {{ start: Date, end: Date, fertileStart: Date, fertileEnd: Date, peakStart: Date, lutealStart: Date, lutealEnd: Date }[]} */
|
// Cycle range for Ogino-style widening of future fertile windows.
|
||||||
|
// Without ≥2 observed cycles, widening collapses to a point estimate.
|
||||||
|
const shortestCycle = cycleLengths.length >= 2 ? Math.min(...cycleLengths) : meanCycleDays;
|
||||||
|
const longestCycle = cycleLengths.length >= 2 ? Math.max(...cycleLengths) : meanCycleDays;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a fertility window for one cycle.
|
||||||
|
*
|
||||||
|
* Anchor: the next period's start (luteal-back-count). Past cycles know it
|
||||||
|
* exactly; future cycles use the mean prediction and widen the outer fertile
|
||||||
|
* range to cover the earliest/latest historically observed ovulation day.
|
||||||
|
*
|
||||||
|
* Floor: fertile/peak never overlap the prior bleed. Day-after-period-end
|
||||||
|
* is the earliest possible fertile day shown — a hard biological floor for
|
||||||
|
* the user's mental model, even though sperm survival could in theory begin
|
||||||
|
* earlier in the bleed for very short cycles.
|
||||||
|
*
|
||||||
|
* @param {number} cycleStartMs ms of cycle start (= prior period start)
|
||||||
|
* @param {number | null} priorPeriodEndMs ms of prior bleed end, or null if unknown
|
||||||
|
* @param {number} nextPeriodStartMs ms of the next period's start
|
||||||
|
* @param {boolean} widen true → use shortest/longest cycle bounds; false → point estimate
|
||||||
|
*/
|
||||||
|
function buildWindow(cycleStartMs, priorPeriodEndMs, nextPeriodStartMs, widen) {
|
||||||
|
const ovMs = nextPeriodStartMs - lutealLength * 86400000;
|
||||||
|
const earliestOvMs = widen
|
||||||
|
? cycleStartMs + (shortestCycle - lutealLength) * 86400000
|
||||||
|
: ovMs;
|
||||||
|
let latestOvMs = widen
|
||||||
|
? cycleStartMs + (longestCycle - lutealLength) * 86400000
|
||||||
|
: ovMs;
|
||||||
|
// Cap latest ov before the next bleed starts.
|
||||||
|
if (latestOvMs > nextPeriodStartMs - 86400000) latestOvMs = nextPeriodStartMs - 86400000;
|
||||||
|
|
||||||
|
const floorMs = priorPeriodEndMs !== null ? priorPeriodEndMs + 86400000 : cycleStartMs;
|
||||||
|
|
||||||
|
let fertileStartMs = Math.max(earliestOvMs - 5 * 86400000, floorMs, cycleStartMs);
|
||||||
|
let peakStartMs = Math.max(ovMs - 2 * 86400000, floorMs, cycleStartMs);
|
||||||
|
const peakEndMs = ovMs - 86400000;
|
||||||
|
let fertileEndMs = Math.max(latestOvMs, ovMs);
|
||||||
|
|
||||||
|
// Suppress peak if floor pushed it past ov (e.g. very short cycle + long period).
|
||||||
|
if (peakStartMs > peakEndMs) peakStartMs = peakEndMs + 86400000;
|
||||||
|
// Keep fertile envelope around peak/ov.
|
||||||
|
if (fertileStartMs > peakStartMs && peakStartMs <= peakEndMs) fertileStartMs = peakStartMs;
|
||||||
|
|
||||||
|
return {
|
||||||
|
fertileStart: new Date(fertileStartMs),
|
||||||
|
fertileEnd: new Date(fertileEndMs),
|
||||||
|
peakStart: new Date(peakStartMs),
|
||||||
|
peakEnd: new Date(peakEndMs),
|
||||||
|
ovulation: new Date(ovMs),
|
||||||
|
lutealStart: new Date(latestOvMs + 86400000),
|
||||||
|
lutealEnd: new Date(nextPeriodStartMs - 86400000)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {{ start: Date, end: Date, fertileStart: Date, fertileEnd: Date, peakStart: Date, peakEnd: Date, ovulation: Date, lutealStart: Date, lutealEnd: Date }[]} */
|
||||||
const futureCycles = [];
|
const futureCycles = [];
|
||||||
if (lastStart) {
|
if (lastStart) {
|
||||||
let base = lastStart.getTime();
|
let base = lastStart.getTime();
|
||||||
|
// Prior bleed end for the first predicted cycle: actual end if recorded,
|
||||||
|
// predicted end if ongoing, else cycle start.
|
||||||
|
let priorPeriodEndMs;
|
||||||
|
if (sorted[0]?.endDate) {
|
||||||
|
priorPeriodEndMs = midnight(new Date(sorted[0].endDate));
|
||||||
|
} else if (predictedEndOfOngoing) {
|
||||||
|
priorPeriodEndMs = midnight(predictedEndOfOngoing);
|
||||||
|
} else {
|
||||||
|
priorPeriodEndMs = base;
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < 12; i++) {
|
for (let i = 0; i < 12; i++) {
|
||||||
const start = new Date(base + cycleMs);
|
const nextPeriodStartMs = base + cycleMs;
|
||||||
const end = new Date(start.getTime() + periodMs);
|
const periodEndMs = nextPeriodStartMs + periodMs;
|
||||||
const ov = new Date(start.getTime() - lutealLength * 86400000);
|
const w = buildWindow(base, priorPeriodEndMs, nextPeriodStartMs, /* widen */ true);
|
||||||
// Luteal phase: day after ovulation until day before next period
|
|
||||||
const lutealStart = new Date(ov.getTime() + 86400000);
|
|
||||||
const lutealEnd = new Date(start.getTime() - 86400000);
|
|
||||||
futureCycles.push({
|
futureCycles.push({
|
||||||
start, end,
|
start: new Date(nextPeriodStartMs),
|
||||||
fertileStart: new Date(ov.getTime() - 5 * 86400000),
|
end: new Date(periodEndMs),
|
||||||
fertileEnd: ov,
|
...w
|
||||||
peakStart: new Date(ov.getTime() - 2 * 86400000),
|
|
||||||
lutealStart,
|
|
||||||
lutealEnd
|
|
||||||
});
|
});
|
||||||
base = start.getTime();
|
base = nextPeriodStartMs;
|
||||||
|
priorPeriodEndMs = periodEndMs;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Past fertility/luteal windows (from completed cycles)
|
// Past fertility/luteal windows (from completed cycles)
|
||||||
/** @type {{ fertileStart: Date, fertileEnd: Date, peakStart: Date, lutealStart: Date, lutealEnd: Date }[]} */
|
/** @type {{ fertileStart: Date, fertileEnd: Date, peakStart: Date, peakEnd: Date, ovulation: Date, lutealStart: Date, lutealEnd: Date }[]} */
|
||||||
const pastFertileWindows = [];
|
const pastFertileWindows = [];
|
||||||
for (let i = 1; i < completed.length; i++) {
|
for (let i = 1; i < completed.length; i++) {
|
||||||
const nextPeriodStart = new Date(completed[i].startDate);
|
const cycleStartMs = midnight(new Date(completed[i - 1].startDate));
|
||||||
const ov = new Date(nextPeriodStart.getTime() - lutealLength * 86400000);
|
const priorPeriodEndMs = completed[i - 1].endDate
|
||||||
pastFertileWindows.push({
|
? midnight(new Date(completed[i - 1].endDate))
|
||||||
fertileStart: new Date(ov.getTime() - 5 * 86400000),
|
: null;
|
||||||
fertileEnd: ov,
|
const nextPeriodStartMs = midnight(new Date(completed[i].startDate));
|
||||||
peakStart: new Date(ov.getTime() - 2 * 86400000),
|
pastFertileWindows.push(buildWindow(cycleStartMs, priorPeriodEndMs, nextPeriodStartMs, /* widen */ false));
|
||||||
lutealStart: new Date(ov.getTime() + 86400000),
|
|
||||||
lutealEnd: new Date(nextPeriodStart.getTime() - 86400000)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -405,12 +465,14 @@
|
|||||||
const cs = midnight(c.start);
|
const cs = midnight(c.start);
|
||||||
const ce = midnight(c.end);
|
const ce = midnight(c.end);
|
||||||
if (d >= cs && d <= ce) return 'predicted';
|
if (d >= cs && d <= ce) return 'predicted';
|
||||||
const fe = midnight(c.fertileEnd);
|
const ovDay = midnight(c.ovulation);
|
||||||
if (d === fe) return 'ovulation';
|
if (d === ovDay) return 'ovulation';
|
||||||
const ps = midnight(c.peakStart);
|
const ps = midnight(c.peakStart);
|
||||||
|
const pe = midnight(c.peakEnd);
|
||||||
|
if (d >= ps && d <= pe) return 'peak-fertile';
|
||||||
const fs = midnight(c.fertileStart);
|
const fs = midnight(c.fertileStart);
|
||||||
if (d >= ps && d < fe) return 'peak-fertile';
|
const fe = midnight(c.fertileEnd);
|
||||||
if (d >= fs && d < ps) return 'fertile';
|
if (d >= fs && d <= fe) return 'fertile';
|
||||||
const ls = midnight(c.lutealStart);
|
const ls = midnight(c.lutealStart);
|
||||||
const le = midnight(c.lutealEnd);
|
const le = midnight(c.lutealEnd);
|
||||||
if (d >= ls && d <= le) return 'luteal';
|
if (d >= ls && d <= le) return 'luteal';
|
||||||
@@ -418,12 +480,14 @@
|
|||||||
|
|
||||||
// Past fertility/luteal windows
|
// Past fertility/luteal windows
|
||||||
for (const w of predictions.pastFertileWindows) {
|
for (const w of predictions.pastFertileWindows) {
|
||||||
const fe = midnight(w.fertileEnd);
|
const ovDay = midnight(w.ovulation);
|
||||||
if (d === fe) return 'ovulation';
|
if (d === ovDay) return 'ovulation';
|
||||||
const ps = midnight(w.peakStart);
|
const ps = midnight(w.peakStart);
|
||||||
|
const pe = midnight(w.peakEnd);
|
||||||
|
if (d >= ps && d <= pe) return 'peak-fertile';
|
||||||
const fs = midnight(w.fertileStart);
|
const fs = midnight(w.fertileStart);
|
||||||
if (d >= ps && d < fe) return 'peak-fertile';
|
const fe = midnight(w.fertileEnd);
|
||||||
if (d >= fs && d < ps) return 'fertile';
|
if (d >= fs && d <= fe) return 'fertile';
|
||||||
const ls = midnight(w.lutealStart);
|
const ls = midnight(w.lutealStart);
|
||||||
const le = midnight(w.lutealEnd);
|
const le = midnight(w.lutealEnd);
|
||||||
if (d >= ls && d <= le) return 'luteal';
|
if (d >= ls && d <= le) return 'luteal';
|
||||||
@@ -737,8 +801,8 @@
|
|||||||
<div class="status-side">
|
<div class="status-side">
|
||||||
<div class="status-side-item ovulation-accent">
|
<div class="status-side-item ovulation-accent">
|
||||||
<span class="status-side-label">{t.ovulation}</span>
|
<span class="status-side-label">{t.ovulation}</span>
|
||||||
<span class="status-side-relative">{relativeDate(nextCycle.fertileEnd)}</span>
|
<span class="status-side-relative">{relativeDate(nextCycle.ovulation)}</span>
|
||||||
<span class="status-side-date">{formatDate(nextCycle.fertileEnd)}</span>
|
<span class="status-side-date">{formatDate(nextCycle.ovulation)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-side-item fertile-accent">
|
<div class="status-side-item fertile-accent">
|
||||||
<span class="status-side-label">{t.fertile}</span>
|
<span class="status-side-label">{t.fertile}</span>
|
||||||
@@ -762,8 +826,8 @@
|
|||||||
<div class="status-side">
|
<div class="status-side">
|
||||||
<div class="status-side-item ovulation-accent">
|
<div class="status-side-item ovulation-accent">
|
||||||
<span class="status-side-label">{t.ovulation}</span>
|
<span class="status-side-label">{t.ovulation}</span>
|
||||||
<span class="status-side-relative">{relativeDate(nextCycle.fertileEnd)}</span>
|
<span class="status-side-relative">{relativeDate(nextCycle.ovulation)}</span>
|
||||||
<span class="status-side-date">{formatDate(nextCycle.fertileEnd)}</span>
|
<span class="status-side-date">{formatDate(nextCycle.ovulation)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-side-item fertile-accent">
|
<div class="status-side-item fertile-accent">
|
||||||
<span class="status-side-label">{t.fertile}</span>
|
<span class="status-side-label">{t.fertile}</span>
|
||||||
|
|||||||
@@ -8,10 +8,12 @@
|
|||||||
import Route from '@lucide/svelte/icons/route';
|
import Route from '@lucide/svelte/icons/route';
|
||||||
import Gauge from '@lucide/svelte/icons/gauge';
|
import Gauge from '@lucide/svelte/icons/gauge';
|
||||||
import Flame from '@lucide/svelte/icons/flame';
|
import Flame from '@lucide/svelte/icons/flame';
|
||||||
import { detectFitnessLang, fitnessSlugs } from '$lib/js/fitnessI18n';
|
import CloudOff from '@lucide/svelte/icons/cloud-off';
|
||||||
|
import { detectFitnessLang, fitnessSlugs, m } from '$lib/js/fitnessI18n';
|
||||||
|
|
||||||
const lang = $derived(detectFitnessLang(page.url.pathname));
|
const lang = $derived(detectFitnessLang(page.url.pathname));
|
||||||
const sl = $derived(fitnessSlugs(lang));
|
const sl = $derived(fitnessSlugs(lang));
|
||||||
|
const t = $derived(m[lang]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {{
|
* @type {{
|
||||||
@@ -30,10 +32,11 @@
|
|||||||
* gpsPreview?: number[][],
|
* gpsPreview?: number[][],
|
||||||
* sets: Array<{ reps?: number, weight?: number, rpe?: number, distance?: number, duration?: number }>
|
* sets: Array<{ reps?: number, weight?: number, rpe?: number, distance?: number, duration?: number }>
|
||||||
* }>
|
* }>
|
||||||
* }
|
* },
|
||||||
|
* unsynced?: boolean
|
||||||
* }}
|
* }}
|
||||||
*/
|
*/
|
||||||
let { session } = $props();
|
let { session, unsynced = false } = $props();
|
||||||
|
|
||||||
/** @param {number} mins */
|
/** @param {number} mins */
|
||||||
function formatDuration(mins) {
|
function formatDuration(mins) {
|
||||||
@@ -153,9 +156,17 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a href={resolve('/fitness/[history=fitnessHistory]/[id]', { history: sl.history, id: session._id })} class="session-card">
|
{#snippet cardBody()}
|
||||||
<div class="card-top">
|
<div class="card-top">
|
||||||
<h3 class="session-name">{session.name}</h3>
|
<h3 class="session-name">
|
||||||
|
{session.name}
|
||||||
|
{#if unsynced}
|
||||||
|
<span class="unsynced-badge" title={t.unsynced_label}>
|
||||||
|
<CloudOff size={12} strokeWidth={2} />
|
||||||
|
{t.unsynced_label}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</h3>
|
||||||
<span class="session-date">{formatDate(session.startTime)} · {formatTime(session.startTime)}</span>
|
<span class="session-date">{formatDate(session.startTime)} · {formatTime(session.startTime)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -207,7 +218,13 @@
|
|||||||
<span class="stat pr"><Trophy size={14} /> {session.prs.length} PR{session.prs.length > 1 ? 's' : ''}</span>
|
<span class="stat pr"><Trophy size={14} /> {session.prs.length} PR{session.prs.length > 1 ? 's' : ''}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
{/snippet}
|
||||||
|
|
||||||
|
{#if unsynced}
|
||||||
|
<div class="session-card unsynced">{@render cardBody()}</div>
|
||||||
|
{:else}
|
||||||
|
<a href={resolve('/fitness/[history=fitnessHistory]/[id]', { history: sl.history, id: session._id })} class="session-card">{@render cardBody()}</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.session-card {
|
.session-card {
|
||||||
@@ -228,6 +245,30 @@
|
|||||||
.session-card:active {
|
.session-card:active {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
.session-card.unsynced {
|
||||||
|
border-left: 3px solid var(--orange, var(--nord12));
|
||||||
|
opacity: 0.92;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.session-card.unsynced:hover {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
.unsynced-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--orange, var(--nord12));
|
||||||
|
background: color-mix(in srgb, var(--orange, var(--nord12)) 12%, transparent);
|
||||||
|
border-radius: 100px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
.card-top {
|
.card-top {
|
||||||
margin-bottom: 0.6rem;
|
margin-bottom: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const de = {
|
|||||||
weight: "Gewicht",
|
weight: "Gewicht",
|
||||||
history_title: "Verlauf",
|
history_title: "Verlauf",
|
||||||
no_workouts_yet: "Noch keine Trainings. Starte dein erstes Training!",
|
no_workouts_yet: "Noch keine Trainings. Starte dein erstes Training!",
|
||||||
|
unsynced_label: "Nicht synchronisiert",
|
||||||
load_more: "Mehr laden",
|
load_more: "Mehr laden",
|
||||||
date: "Datum",
|
date: "Datum",
|
||||||
time: "Uhrzeit",
|
time: "Uhrzeit",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const en = {
|
|||||||
weight: "Weight",
|
weight: "Weight",
|
||||||
history_title: "History",
|
history_title: "History",
|
||||||
no_workouts_yet: "No workouts yet. Start your first workout!",
|
no_workouts_yet: "No workouts yet. Start your first workout!",
|
||||||
|
unsynced_label: "Unsynced",
|
||||||
load_more: "Load more",
|
load_more: "Load more",
|
||||||
date: "Date",
|
date: "Date",
|
||||||
time: "Time",
|
time: "Time",
|
||||||
|
|||||||
@@ -16,26 +16,31 @@ function addFavoriteStatus<T extends { _id: unknown }>(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadFromIndexedDB() {
|
||||||
|
if (!await isOfflineDataAvailable()) return null;
|
||||||
|
const [allBrief, seasonRecipes] = await Promise.all([
|
||||||
|
getAllBriefRecipes(),
|
||||||
|
getBriefRecipesInSeasonOn(new Date())
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
all_brief: rand_array(allBrief),
|
||||||
|
season: rand_array(seasonRecipes),
|
||||||
|
heroIndex: Math.random(),
|
||||||
|
isOffline: true as const
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const load: PageLoad = async ({ params, fetch, parent }) => {
|
export const load: PageLoad = async ({ params, fetch, parent }) => {
|
||||||
const parentData = await parent();
|
const parentData = await parent();
|
||||||
const apiBase = `/api/${params.recipeLang}`;
|
const apiBase = `/api/${params.recipeLang}`;
|
||||||
const useOfflineData =
|
const canUseOffline = browser && canUseOfflineData();
|
||||||
browser && (isOffline() || parentData.isOffline) && canUseOfflineData();
|
const knownOffline = browser && (isOffline() || parentData.isOffline);
|
||||||
|
|
||||||
if (useOfflineData) {
|
// Skip the network entirely when device is known offline.
|
||||||
|
if (canUseOffline && knownOffline) {
|
||||||
try {
|
try {
|
||||||
if (await isOfflineDataAvailable()) {
|
const offline = await loadFromIndexedDB();
|
||||||
const [allBrief, seasonRecipes] = await Promise.all([
|
if (offline) return offline;
|
||||||
getAllBriefRecipes(),
|
|
||||||
getBriefRecipesInSeasonOn(new Date())
|
|
||||||
]);
|
|
||||||
return {
|
|
||||||
all_brief: rand_array(allBrief),
|
|
||||||
season: rand_array(seasonRecipes),
|
|
||||||
heroIndex: Math.random(),
|
|
||||||
isOffline: true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load offline data:', e);
|
console.error('Failed to load offline data:', e);
|
||||||
}
|
}
|
||||||
@@ -54,7 +59,18 @@ export const load: PageLoad = async ({ params, fetch, parent }) => {
|
|||||||
favorites = body.favorites ?? [];
|
favorites = body.favorites ?? [];
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Network unreachable — empty data; +page.svelte renders fallback layout.
|
// Network unreachable — IndexedDB fallback below picks up the slack.
|
||||||
|
}
|
||||||
|
|
||||||
|
// API failed or returned empty (502, slow cellular, server hiccup) — fall
|
||||||
|
// back to IndexedDB so the cached PWA shell stays useful.
|
||||||
|
if (canUseOffline && !all_brief.length) {
|
||||||
|
try {
|
||||||
|
const offline = await loadFromIndexedDB();
|
||||||
|
if (offline) return offline;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load offline data:', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const marked = addFavoriteStatus(all_brief, favorites);
|
const marked = addFavoriteStatus(all_brief, favorites);
|
||||||
|
|||||||
@@ -15,23 +15,28 @@ function addFavoriteStatus<T extends { _id: unknown }>(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadFromIndexedDB(month: number) {
|
||||||
|
if (!await isOfflineDataAvailable()) return null;
|
||||||
|
const recipes = await getBriefRecipesOverlappingMonth(month);
|
||||||
|
return {
|
||||||
|
month,
|
||||||
|
season: rand_array(recipes),
|
||||||
|
isOffline: true as const
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const load: PageLoad = async ({ params, fetch, parent }) => {
|
export const load: PageLoad = async ({ params, fetch, parent }) => {
|
||||||
const parentData = await parent();
|
const parentData = await parent();
|
||||||
const month = parseInt(params.month, 10);
|
const month = parseInt(params.month, 10);
|
||||||
const apiBase = `/api/${params.recipeLang}`;
|
const apiBase = `/api/${params.recipeLang}`;
|
||||||
const useOfflineData =
|
const canUseOffline = browser && canUseOfflineData();
|
||||||
browser && (isOffline() || parentData.isOffline) && canUseOfflineData();
|
const knownOffline = browser && (isOffline() || parentData.isOffline);
|
||||||
|
|
||||||
if (useOfflineData) {
|
// Skip the network entirely when device is known offline.
|
||||||
|
if (canUseOffline && knownOffline) {
|
||||||
try {
|
try {
|
||||||
if (await isOfflineDataAvailable()) {
|
const offline = await loadFromIndexedDB(month);
|
||||||
const recipes = await getBriefRecipesOverlappingMonth(month);
|
if (offline) return offline;
|
||||||
return {
|
|
||||||
month,
|
|
||||||
season: rand_array(recipes),
|
|
||||||
isOffline: true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load offline season data:', e);
|
console.error('Failed to load offline season data:', e);
|
||||||
}
|
}
|
||||||
@@ -50,7 +55,18 @@ export const load: PageLoad = async ({ params, fetch, parent }) => {
|
|||||||
favorites = body.favorites ?? [];
|
favorites = body.favorites ?? [];
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Empty arrays — page will render with no recipes
|
// Network unreachable — IndexedDB fallback below picks up the slack.
|
||||||
|
}
|
||||||
|
|
||||||
|
// API failed or returned empty (502, slow cellular, server hiccup) — fall
|
||||||
|
// back to IndexedDB so the cached PWA shell stays useful.
|
||||||
|
if (canUseOffline && !item_season.length) {
|
||||||
|
try {
|
||||||
|
const offline = await loadFromIndexedDB(month);
|
||||||
|
if (offline) return offline;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load offline season data:', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -34,12 +34,14 @@
|
|||||||
'workout', 'training', 'workout/active', 'training/aktiv',
|
'workout', 'training', 'workout/active', 'training/aktiv',
|
||||||
'exercises', 'uebungen', 'stats', 'statistik',
|
'exercises', 'uebungen', 'stats', 'statistik',
|
||||||
'history', 'verlauf', 'check-in', 'erfassung',
|
'history', 'verlauf', 'check-in', 'erfassung',
|
||||||
'nutrition', 'ernaehrung'
|
'nutrition', 'ernaehrung',
|
||||||
|
'nutrition/meals', 'ernaehrung/meals',
|
||||||
|
'check-in/body-parts', 'erfassung/body-parts'
|
||||||
];
|
];
|
||||||
const urls = slugs.map((s) => `/fitness/${s}`);
|
const urls = ['/fitness', ...slugs.map((s) => `/fitness/${s}`)];
|
||||||
navigator.serviceWorker.controller.postMessage({ type: 'CACHE_PAGES', urls });
|
navigator.serviceWorker.controller.postMessage({ type: 'CACHE_PAGES', urls });
|
||||||
// Also cache __data.json for client-side navigation
|
// Also cache __data.json for client-side navigation
|
||||||
const dataUrls = slugs.map((s) => `/fitness/${s}/__data.json`);
|
const dataUrls = ['/fitness/__data.json', ...slugs.map((s) => `/fitness/${s}/__data.json`)];
|
||||||
navigator.serviceWorker.controller.postMessage({ type: 'CACHE_DATA', urls: dataUrls });
|
navigator.serviceWorker.controller.postMessage({ type: 'CACHE_DATA', urls: dataUrls });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
import { page as appPage } from '$app/state';
|
import { page as appPage } from '$app/state';
|
||||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||||
import SessionCard from '$lib/components/fitness/SessionCard.svelte';
|
import SessionCard from '$lib/components/fitness/SessionCard.svelte';
|
||||||
import { detectFitnessLang, fitnessSlugs, m } from '$lib/js/fitnessI18n';
|
import { detectFitnessLang, fitnessSlugs, m } from '$lib/js/fitnessI18n';
|
||||||
|
import { getQueuedSessions } from '$lib/offline/fitnessQueue';
|
||||||
|
|
||||||
const lang = $derived(detectFitnessLang(appPage.url.pathname));
|
const lang = $derived(detectFitnessLang(appPage.url.pathname));
|
||||||
const t = $derived(m[lang]);
|
const t = $derived(m[lang]);
|
||||||
@@ -12,9 +15,68 @@
|
|||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
const sessions = $derived(data.sessions?.sessions ?? []);
|
/** @type {Array<{ _id: string, name: string, startTime: string, exercises: any[], duration?: number, totalVolume?: number, totalDistance?: number, prs?: any[], kcalEstimate?: any }>} */
|
||||||
|
let queuedSessions = $state([]);
|
||||||
|
|
||||||
|
async function loadQueued() {
|
||||||
|
try {
|
||||||
|
const entries = await getQueuedSessions();
|
||||||
|
queuedSessions = entries.map(e => {
|
||||||
|
const d = /** @type {any} */ (e.data);
|
||||||
|
// Server normally computes duration from start/end; do it locally
|
||||||
|
// so the unsynced card shows the same Clock stat as synced ones.
|
||||||
|
const duration = d.startTime && d.endTime
|
||||||
|
? Math.max(0, Math.round((new Date(d.endTime).getTime() - new Date(d.startTime).getTime()) / 60000))
|
||||||
|
: undefined;
|
||||||
|
return /** @type {any} */ ({
|
||||||
|
...d,
|
||||||
|
_id: `queued-${e.id}`,
|
||||||
|
duration,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load queued workouts:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onOnline() {
|
||||||
|
// The layout's online listener triggers flushQueue; give it a moment to
|
||||||
|
// finish, then refresh both the queue display and server-side sessions.
|
||||||
|
setTimeout(async () => {
|
||||||
|
await loadQueued();
|
||||||
|
await invalidateAll();
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadQueued();
|
||||||
|
window.addEventListener('online', onOnline);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (typeof window !== 'undefined') window.removeEventListener('online', onOnline);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Server sessions for the active view */
|
||||||
|
const serverSessions = $derived(data.sessions?.sessions ?? []);
|
||||||
const viewMonth = $derived(data.month); // YYYY-MM or null
|
const viewMonth = $derived(data.month); // YYYY-MM or null
|
||||||
|
|
||||||
|
/** Queued sessions filtered to the current view month (or shown on the recent view) */
|
||||||
|
const visibleQueued = $derived.by(() => {
|
||||||
|
if (!viewMonth) return queuedSessions; // recent view shows everything pending
|
||||||
|
return queuedSessions.filter(q => {
|
||||||
|
const d = new Date(q.startTime);
|
||||||
|
const ym = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
return ym === viewMonth;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Merged list, queued sessions first (newest unsynced on top) */
|
||||||
|
const sessions = $derived([...visibleQueued, ...serverSessions].sort(
|
||||||
|
(a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime()
|
||||||
|
));
|
||||||
|
const queuedIds = $derived(new Set(visibleQueued.map(q => q._id)));
|
||||||
|
|
||||||
/** @type {Record<string, typeof sessions>} */
|
/** @type {Record<string, typeof sessions>} */
|
||||||
const grouped = $derived.by(() => {
|
const grouped = $derived.by(() => {
|
||||||
/** @type {Record<string, typeof sessions>} */
|
/** @type {Record<string, typeof sessions>} */
|
||||||
@@ -74,7 +136,7 @@
|
|||||||
<h2 class="month-header">{month} — {monthSessions.length} {monthSessions.length !== 1 ? t.workouts_plural : t.workout_singular}</h2>
|
<h2 class="month-header">{month} — {monthSessions.length} {monthSessions.length !== 1 ? t.workouts_plural : t.workout_singular}</h2>
|
||||||
<div class="session-list">
|
<div class="session-list">
|
||||||
{#each monthSessions as session (session._id)}
|
{#each monthSessions as session (session._id)}
|
||||||
<SessionCard {session} />
|
<SessionCard {session} unsynced={queuedIds.has(session._id)} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+58
-40
@@ -28,7 +28,13 @@ const PRECACHE_SHELLS = [
|
|||||||
'/recipes/offline-shell',
|
'/recipes/offline-shell',
|
||||||
'/glaube',
|
'/glaube',
|
||||||
'/faith',
|
'/faith',
|
||||||
'/fitness'
|
'/fitness',
|
||||||
|
// Active workout shells precached so a fresh install can log workouts
|
||||||
|
// offline immediately without an online visit to /fitness first.
|
||||||
|
'/fitness/workout',
|
||||||
|
'/fitness/training',
|
||||||
|
'/fitness/workout/active',
|
||||||
|
'/fitness/training/aktiv'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Assets to precache
|
// Assets to precache
|
||||||
@@ -117,30 +123,34 @@ sw.addEventListener('fetch', (event) => {
|
|||||||
// SvelteKit adds ?x-sveltekit-invalidated=... which we need to ignore
|
// SvelteKit adds ?x-sveltekit-invalidated=... which we need to ignore
|
||||||
const cacheKey = url.pathname;
|
const cacheKey = url.pathname;
|
||||||
|
|
||||||
|
let response: Response | undefined;
|
||||||
try {
|
try {
|
||||||
// Try network first
|
response = await fetch(event.request);
|
||||||
const response = await fetch(event.request);
|
|
||||||
|
|
||||||
// Cache successful responses for offline use (using pathname as key)
|
|
||||||
if (response.ok) {
|
|
||||||
cache.put(cacheKey, response.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
} catch {
|
} catch {
|
||||||
// Network failed - try to serve from cache (ignoring query params)
|
// Network unreachable — fall through to cache fallback below.
|
||||||
const cached = await cache.match(cacheKey);
|
|
||||||
if (cached) {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No cached data available - return error response
|
|
||||||
// The page will need to handle this gracefully
|
|
||||||
return new Response(JSON.stringify({ error: 'offline' }), {
|
|
||||||
status: 503,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache successful responses for offline use (using pathname as key)
|
||||||
|
if (response?.ok) {
|
||||||
|
cache.put(cacheKey, response.clone());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network unreachable OR upstream 5xx (502/503/504 etc.) — serve
|
||||||
|
// stale cached data so the PWA stays usable when the origin is down.
|
||||||
|
if (!response || response.status >= 500) {
|
||||||
|
const cached = await cache.match(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass through non-5xx errors (404, 401, ...) untouched.
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
// No response and no cache — synthetic offline error.
|
||||||
|
return new Response(JSON.stringify({ error: 'offline' }), {
|
||||||
|
status: 503,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
})()
|
})()
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -222,26 +232,31 @@ sw.addEventListener('fetch', (event) => {
|
|||||||
// Use pathname only for cache key (ignore query params)
|
// Use pathname only for cache key (ignore query params)
|
||||||
const cacheKey = url.pathname;
|
const cacheKey = url.pathname;
|
||||||
|
|
||||||
|
let response: Response | undefined;
|
||||||
try {
|
try {
|
||||||
// Try network first
|
response = await fetch(event.request);
|
||||||
const response = await fetch(event.request);
|
} catch {
|
||||||
|
// Network unreachable — fall through to fallback below.
|
||||||
|
}
|
||||||
|
|
||||||
// Cache successful HTML responses for cacheable pages (using pathname as key)
|
// Cache successful HTML responses for cacheable pages (using pathname as key)
|
||||||
const isCacheablePage = response.ok && (
|
if (response?.ok) {
|
||||||
|
const isCacheablePage =
|
||||||
url.pathname.match(/^\/(rezepte|recipes|glaube|faith|fitness)(\/|$)/) ||
|
url.pathname.match(/^\/(rezepte|recipes|glaube|faith|fitness)(\/|$)/) ||
|
||||||
url.pathname === '/'
|
url.pathname === '/';
|
||||||
);
|
|
||||||
if (isCacheablePage) {
|
if (isCacheablePage) {
|
||||||
cache.put(cacheKey, response.clone());
|
cache.put(cacheKey, response.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch {
|
}
|
||||||
// Network failed - try to serve from cache (ignoring query params)
|
|
||||||
|
// Network unreachable OR upstream 5xx (502 Bad Gateway, 503, 504, ...) —
|
||||||
|
// serve stale shell so the PWA stays usable when the origin is down.
|
||||||
|
const upstreamDown = !response || response.status >= 500;
|
||||||
|
|
||||||
|
if (upstreamDown) {
|
||||||
const cached = await cache.match(cacheKey);
|
const cached = await cache.match(cacheKey);
|
||||||
if (cached) {
|
if (cached) return cached;
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For recipe routes, redirect to the offline shell with the target URL
|
// For recipe routes, redirect to the offline shell with the target URL
|
||||||
// The offline shell will then do client-side navigation to load from IndexedDB
|
// The offline shell will then do client-side navigation to load from IndexedDB
|
||||||
@@ -262,10 +277,14 @@ sw.addEventListener('fetch', (event) => {
|
|||||||
return Response.redirect(redirectUrl, 302);
|
return Response.redirect(redirectUrl, 302);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Last resort - return a styled offline response
|
// Pass through non-5xx errors (404, 401, ...) untouched.
|
||||||
return new Response(
|
if (response && !upstreamDown) return response;
|
||||||
`<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>Offline</title><style>
|
|
||||||
|
// Last resort - return a styled offline response
|
||||||
|
return new Response(
|
||||||
|
`<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>Offline</title><style>
|
||||||
*{box-sizing:border-box;margin:0;font-family:Helvetica,Arial,sans-serif}
|
*{box-sizing:border-box;margin:0;font-family:Helvetica,Arial,sans-serif}
|
||||||
body{background:#f8f6f1;color:#2a2a2a;min-height:100svh;display:flex;flex-direction:column;padding-top:env(safe-area-inset-top,0px)}
|
body{background:#f8f6f1;color:#2a2a2a;min-height:100svh;display:flex;flex-direction:column;padding-top:env(safe-area-inset-top,0px)}
|
||||||
nav{position:sticky;top:calc(12px + env(safe-area-inset-top,0px));z-index:100;display:flex;align-items:center;justify-content:center;height:3rem;padding:0 1.2rem;margin:12px auto 0;width:fit-content;max-width:calc(100% - 1.5rem);border-radius:100px;background:rgba(46,52,64,0.82);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border:1px solid rgba(255,255,255,0.08);box-shadow:0 4px 24px rgba(0,0,0,0.25)}
|
nav{position:sticky;top:calc(12px + env(safe-area-inset-top,0px));z-index:100;display:flex;align-items:center;justify-content:center;height:3rem;padding:0 1.2rem;margin:12px auto 0;width:fit-content;max-width:calc(100% - 1.5rem);border-radius:100px;background:rgba(46,52,64,0.82);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border:1px solid rgba(255,255,255,0.08);box-shadow:0 4px 24px rgba(0,0,0,0.25)}
|
||||||
@@ -297,9 +316,8 @@ p{color:#aaa}
|
|||||||
<p class="hint">Install the <a href="https://bocken.org/static/Bocken.apk">Android app</a> or add this site to your home screen to browse offline.</p>
|
<p class="hint">Install the <a href="https://bocken.org/static/Bocken.apk">Android app</a> or add this site to your home screen to browse offline.</p>
|
||||||
</main>
|
</main>
|
||||||
</body></html>`,
|
</body></html>`,
|
||||||
{ headers: { 'Content-Type': 'text/html' } }
|
{ headers: { 'Content-Type': 'text/html' } }
|
||||||
);
|
);
|
||||||
}
|
|
||||||
})()
|
})()
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user