Compare commits
4 Commits
9a97e41c28
...
98417046bc
| Author | SHA1 | Date | |
|---|---|---|---|
|
98417046bc
|
|||
|
244050fa75
|
|||
|
0814803fc7
|
|||
|
eb2ffac536
|
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.67.3",
|
||||
"version": "1.68.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -200,47 +200,107 @@
|
||||
}
|
||||
|
||||
// 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 lutealLength = 14;
|
||||
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 = [];
|
||||
if (lastStart) {
|
||||
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++) {
|
||||
const start = new Date(base + cycleMs);
|
||||
const end = new Date(start.getTime() + periodMs);
|
||||
const ov = new Date(start.getTime() - lutealLength * 86400000);
|
||||
// Luteal phase: day after ovulation until day before next period
|
||||
const lutealStart = new Date(ov.getTime() + 86400000);
|
||||
const lutealEnd = new Date(start.getTime() - 86400000);
|
||||
const nextPeriodStartMs = base + cycleMs;
|
||||
const periodEndMs = nextPeriodStartMs + periodMs;
|
||||
const w = buildWindow(base, priorPeriodEndMs, nextPeriodStartMs, /* widen */ true);
|
||||
futureCycles.push({
|
||||
start, end,
|
||||
fertileStart: new Date(ov.getTime() - 5 * 86400000),
|
||||
fertileEnd: ov,
|
||||
peakStart: new Date(ov.getTime() - 2 * 86400000),
|
||||
lutealStart,
|
||||
lutealEnd
|
||||
start: new Date(nextPeriodStartMs),
|
||||
end: new Date(periodEndMs),
|
||||
...w
|
||||
});
|
||||
base = start.getTime();
|
||||
base = nextPeriodStartMs;
|
||||
priorPeriodEndMs = periodEndMs;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = [];
|
||||
for (let i = 1; i < completed.length; i++) {
|
||||
const nextPeriodStart = new Date(completed[i].startDate);
|
||||
const ov = new Date(nextPeriodStart.getTime() - lutealLength * 86400000);
|
||||
pastFertileWindows.push({
|
||||
fertileStart: new Date(ov.getTime() - 5 * 86400000),
|
||||
fertileEnd: ov,
|
||||
peakStart: new Date(ov.getTime() - 2 * 86400000),
|
||||
lutealStart: new Date(ov.getTime() + 86400000),
|
||||
lutealEnd: new Date(nextPeriodStart.getTime() - 86400000)
|
||||
});
|
||||
const cycleStartMs = midnight(new Date(completed[i - 1].startDate));
|
||||
const priorPeriodEndMs = completed[i - 1].endDate
|
||||
? midnight(new Date(completed[i - 1].endDate))
|
||||
: null;
|
||||
const nextPeriodStartMs = midnight(new Date(completed[i].startDate));
|
||||
pastFertileWindows.push(buildWindow(cycleStartMs, priorPeriodEndMs, nextPeriodStartMs, /* widen */ false));
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -405,12 +465,14 @@
|
||||
const cs = midnight(c.start);
|
||||
const ce = midnight(c.end);
|
||||
if (d >= cs && d <= ce) return 'predicted';
|
||||
const fe = midnight(c.fertileEnd);
|
||||
if (d === fe) return 'ovulation';
|
||||
const ovDay = midnight(c.ovulation);
|
||||
if (d === ovDay) return 'ovulation';
|
||||
const ps = midnight(c.peakStart);
|
||||
const pe = midnight(c.peakEnd);
|
||||
if (d >= ps && d <= pe) return 'peak-fertile';
|
||||
const fs = midnight(c.fertileStart);
|
||||
if (d >= ps && d < fe) return 'peak-fertile';
|
||||
if (d >= fs && d < ps) return 'fertile';
|
||||
const fe = midnight(c.fertileEnd);
|
||||
if (d >= fs && d <= fe) return 'fertile';
|
||||
const ls = midnight(c.lutealStart);
|
||||
const le = midnight(c.lutealEnd);
|
||||
if (d >= ls && d <= le) return 'luteal';
|
||||
@@ -418,12 +480,14 @@
|
||||
|
||||
// Past fertility/luteal windows
|
||||
for (const w of predictions.pastFertileWindows) {
|
||||
const fe = midnight(w.fertileEnd);
|
||||
if (d === fe) return 'ovulation';
|
||||
const ovDay = midnight(w.ovulation);
|
||||
if (d === ovDay) return 'ovulation';
|
||||
const ps = midnight(w.peakStart);
|
||||
const pe = midnight(w.peakEnd);
|
||||
if (d >= ps && d <= pe) return 'peak-fertile';
|
||||
const fs = midnight(w.fertileStart);
|
||||
if (d >= ps && d < fe) return 'peak-fertile';
|
||||
if (d >= fs && d < ps) return 'fertile';
|
||||
const fe = midnight(w.fertileEnd);
|
||||
if (d >= fs && d <= fe) return 'fertile';
|
||||
const ls = midnight(w.lutealStart);
|
||||
const le = midnight(w.lutealEnd);
|
||||
if (d >= ls && d <= le) return 'luteal';
|
||||
@@ -737,8 +801,8 @@
|
||||
<div class="status-side">
|
||||
<div class="status-side-item ovulation-accent">
|
||||
<span class="status-side-label">{t.ovulation}</span>
|
||||
<span class="status-side-relative">{relativeDate(nextCycle.fertileEnd)}</span>
|
||||
<span class="status-side-date">{formatDate(nextCycle.fertileEnd)}</span>
|
||||
<span class="status-side-relative">{relativeDate(nextCycle.ovulation)}</span>
|
||||
<span class="status-side-date">{formatDate(nextCycle.ovulation)}</span>
|
||||
</div>
|
||||
<div class="status-side-item fertile-accent">
|
||||
<span class="status-side-label">{t.fertile}</span>
|
||||
@@ -762,8 +826,8 @@
|
||||
<div class="status-side">
|
||||
<div class="status-side-item ovulation-accent">
|
||||
<span class="status-side-label">{t.ovulation}</span>
|
||||
<span class="status-side-relative">{relativeDate(nextCycle.fertileEnd)}</span>
|
||||
<span class="status-side-date">{formatDate(nextCycle.fertileEnd)}</span>
|
||||
<span class="status-side-relative">{relativeDate(nextCycle.ovulation)}</span>
|
||||
<span class="status-side-date">{formatDate(nextCycle.ovulation)}</span>
|
||||
</div>
|
||||
<div class="status-side-item fertile-accent">
|
||||
<span class="status-side-label">{t.fertile}</span>
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
import Route from '@lucide/svelte/icons/route';
|
||||
import Gauge from '@lucide/svelte/icons/gauge';
|
||||
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 sl = $derived(fitnessSlugs(lang));
|
||||
const t = $derived(m[lang]);
|
||||
|
||||
/**
|
||||
* @type {{
|
||||
@@ -30,10 +32,11 @@
|
||||
* gpsPreview?: 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 */
|
||||
function formatDuration(mins) {
|
||||
@@ -153,9 +156,17 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<a href={resolve('/fitness/[history=fitnessHistory]/[id]', { history: sl.history, id: session._id })} class="session-card">
|
||||
{#snippet cardBody()}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -207,7 +218,13 @@
|
||||
<span class="stat pr"><Trophy size={14} /> {session.prs.length} PR{session.prs.length > 1 ? 's' : ''}</span>
|
||||
{/if}
|
||||
</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>
|
||||
.session-card {
|
||||
@@ -228,6 +245,30 @@
|
||||
.session-card:active {
|
||||
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 {
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export const de = {
|
||||
weight: "Gewicht",
|
||||
history_title: "Verlauf",
|
||||
no_workouts_yet: "Noch keine Trainings. Starte dein erstes Training!",
|
||||
unsynced_label: "Nicht synchronisiert",
|
||||
load_more: "Mehr laden",
|
||||
date: "Datum",
|
||||
time: "Uhrzeit",
|
||||
|
||||
@@ -33,6 +33,7 @@ export const en = {
|
||||
weight: "Weight",
|
||||
history_title: "History",
|
||||
no_workouts_yet: "No workouts yet. Start your first workout!",
|
||||
unsynced_label: "Unsynced",
|
||||
load_more: "Load more",
|
||||
date: "Date",
|
||||
time: "Time",
|
||||
|
||||
@@ -16,15 +16,8 @@ function addFavoriteStatus<T extends { _id: unknown }>(
|
||||
}));
|
||||
}
|
||||
|
||||
export const load: PageLoad = async ({ params, fetch, parent }) => {
|
||||
const parentData = await parent();
|
||||
const apiBase = `/api/${params.recipeLang}`;
|
||||
const useOfflineData =
|
||||
browser && (isOffline() || parentData.isOffline) && canUseOfflineData();
|
||||
|
||||
if (useOfflineData) {
|
||||
try {
|
||||
if (await isOfflineDataAvailable()) {
|
||||
async function loadFromIndexedDB() {
|
||||
if (!await isOfflineDataAvailable()) return null;
|
||||
const [allBrief, seasonRecipes] = await Promise.all([
|
||||
getAllBriefRecipes(),
|
||||
getBriefRecipesInSeasonOn(new Date())
|
||||
@@ -33,9 +26,21 @@ export const load: PageLoad = async ({ params, fetch, parent }) => {
|
||||
all_brief: rand_array(allBrief),
|
||||
season: rand_array(seasonRecipes),
|
||||
heroIndex: Math.random(),
|
||||
isOffline: true
|
||||
isOffline: true as const
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const load: PageLoad = async ({ params, fetch, parent }) => {
|
||||
const parentData = await parent();
|
||||
const apiBase = `/api/${params.recipeLang}`;
|
||||
const canUseOffline = browser && canUseOfflineData();
|
||||
const knownOffline = browser && (isOffline() || parentData.isOffline);
|
||||
|
||||
// Skip the network entirely when device is known offline.
|
||||
if (canUseOffline && knownOffline) {
|
||||
try {
|
||||
const offline = await loadFromIndexedDB();
|
||||
if (offline) return offline;
|
||||
} catch (e) {
|
||||
console.error('Failed to load offline data:', e);
|
||||
}
|
||||
@@ -54,7 +59,18 @@ export const load: PageLoad = async ({ params, fetch, parent }) => {
|
||||
favorites = body.favorites ?? [];
|
||||
}
|
||||
} 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);
|
||||
|
||||
@@ -15,23 +15,28 @@ function addFavoriteStatus<T extends { _id: unknown }>(
|
||||
}));
|
||||
}
|
||||
|
||||
export const load: PageLoad = async ({ params, fetch, parent }) => {
|
||||
const parentData = await parent();
|
||||
const month = parseInt(params.month, 10);
|
||||
const apiBase = `/api/${params.recipeLang}`;
|
||||
const useOfflineData =
|
||||
browser && (isOffline() || parentData.isOffline) && canUseOfflineData();
|
||||
|
||||
if (useOfflineData) {
|
||||
try {
|
||||
if (await isOfflineDataAvailable()) {
|
||||
async function loadFromIndexedDB(month: number) {
|
||||
if (!await isOfflineDataAvailable()) return null;
|
||||
const recipes = await getBriefRecipesOverlappingMonth(month);
|
||||
return {
|
||||
month,
|
||||
season: rand_array(recipes),
|
||||
isOffline: true
|
||||
isOffline: true as const
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const load: PageLoad = async ({ params, fetch, parent }) => {
|
||||
const parentData = await parent();
|
||||
const month = parseInt(params.month, 10);
|
||||
const apiBase = `/api/${params.recipeLang}`;
|
||||
const canUseOffline = browser && canUseOfflineData();
|
||||
const knownOffline = browser && (isOffline() || parentData.isOffline);
|
||||
|
||||
// Skip the network entirely when device is known offline.
|
||||
if (canUseOffline && knownOffline) {
|
||||
try {
|
||||
const offline = await loadFromIndexedDB(month);
|
||||
if (offline) return offline;
|
||||
} catch (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 ?? [];
|
||||
}
|
||||
} 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 {
|
||||
|
||||
@@ -34,12 +34,14 @@
|
||||
'workout', 'training', 'workout/active', 'training/aktiv',
|
||||
'exercises', 'uebungen', 'stats', 'statistik',
|
||||
'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 });
|
||||
// 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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { page as appPage } from '$app/state';
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import SessionCard from '$lib/components/fitness/SessionCard.svelte';
|
||||
import { detectFitnessLang, fitnessSlugs, m } from '$lib/js/fitnessI18n';
|
||||
import { getQueuedSessions } from '$lib/offline/fitnessQueue';
|
||||
|
||||
const lang = $derived(detectFitnessLang(appPage.url.pathname));
|
||||
const t = $derived(m[lang]);
|
||||
@@ -12,9 +15,68 @@
|
||||
|
||||
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
|
||||
|
||||
/** 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>} */
|
||||
const grouped = $derived.by(() => {
|
||||
/** @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>
|
||||
<div class="session-list">
|
||||
{#each monthSessions as session (session._id)}
|
||||
<SessionCard {session} />
|
||||
<SessionCard {session} unsynced={queuedIds.has(session._id)} />
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+44
-26
@@ -28,7 +28,13 @@ const PRECACHE_SHELLS = [
|
||||
'/recipes/offline-shell',
|
||||
'/glaube',
|
||||
'/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
|
||||
@@ -117,30 +123,34 @@ sw.addEventListener('fetch', (event) => {
|
||||
// SvelteKit adds ?x-sveltekit-invalidated=... which we need to ignore
|
||||
const cacheKey = url.pathname;
|
||||
|
||||
let response: Response | undefined;
|
||||
try {
|
||||
// Try network first
|
||||
const response = await fetch(event.request);
|
||||
response = await fetch(event.request);
|
||||
} catch {
|
||||
// Network unreachable — fall through to cache fallback below.
|
||||
}
|
||||
|
||||
// Cache successful responses for offline use (using pathname as key)
|
||||
if (response.ok) {
|
||||
if (response?.ok) {
|
||||
cache.put(cacheKey, response.clone());
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch {
|
||||
// Network failed - try to serve from cache (ignoring query params)
|
||||
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
|
||||
// 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;
|
||||
@@ -222,27 +232,32 @@ sw.addEventListener('fetch', (event) => {
|
||||
// Use pathname only for cache key (ignore query params)
|
||||
const cacheKey = url.pathname;
|
||||
|
||||
let response: Response | undefined;
|
||||
try {
|
||||
// Try network first
|
||||
const response = await fetch(event.request);
|
||||
response = await fetch(event.request);
|
||||
} catch {
|
||||
// Network unreachable — fall through to fallback below.
|
||||
}
|
||||
|
||||
// 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 === '/'
|
||||
);
|
||||
url.pathname === '/';
|
||||
if (isCacheablePage) {
|
||||
cache.put(cacheKey, response.clone());
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch {
|
||||
// Network failed - try to serve from cache (ignoring query params)
|
||||
const cached = await cache.match(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// 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);
|
||||
if (cached) return cached;
|
||||
|
||||
// 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
|
||||
// Skip if this is already the offline-shell or an offline navigation to prevent loops
|
||||
@@ -262,6 +277,10 @@ sw.addEventListener('fetch', (event) => {
|
||||
return Response.redirect(redirectUrl, 302);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass through non-5xx errors (404, 401, ...) untouched.
|
||||
if (response && !upstreamDown) return response;
|
||||
|
||||
// Last resort - return a styled offline response
|
||||
return new Response(
|
||||
@@ -299,7 +318,6 @@ p{color:#aaa}
|
||||
</body></html>`,
|
||||
{ headers: { 'Content-Type': 'text/html' } }
|
||||
);
|
||||
}
|
||||
})()
|
||||
);
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user