offline mode optimizations
This commit is contained in:
parent
0bb87b3933
commit
befc1d9384
11 changed files with 163 additions and 207 deletions
20
src/hooks.client.ts
Normal file
20
src/hooks.client.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import type { HandleClientError } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const handleError: HandleClientError = ({ error }) => {
|
||||||
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
// SvelteKit lazy-loads route chunks via dynamic import(). When offline and the
|
||||||
|
// chunk isn't cached, the import fails with this error. Show a helpful message
|
||||||
|
// instead of the generic "Internal Error" page.
|
||||||
|
if (
|
||||||
|
msg.includes('Importing a module script failed') ||
|
||||||
|
msg.includes('Failed to fetch dynamically imported module') ||
|
||||||
|
msg.includes('Unable to preload CSS')
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
message: 'App not cached yet. Open the app while online at least once to enable offline use.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { message: msg || 'An unexpected error occurred.' };
|
||||||
|
};
|
||||||
|
|
@ -80,18 +80,46 @@ function isAuthPath(path: string) {
|
||||||
return path.startsWith('/token/');
|
return path.startsWith('/token/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A TypeError thrown by fetch() always means a network-level failure
|
||||||
|
function isNetworkFailure(e: unknown): boolean {
|
||||||
|
return e instanceof TypeError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5xx from the proxy/server when backend is down — treat same as offline
|
||||||
|
function isServerUnavailable(res: Response): boolean {
|
||||||
|
return res.status >= 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queueMutation(method: 'POST' | 'PATCH' | 'DELETE', path: string, body: unknown): Promise<void> {
|
||||||
|
network.setOffline();
|
||||||
|
await enqueueMutation({ method, url: path, body });
|
||||||
|
network.incrementPending();
|
||||||
|
}
|
||||||
|
|
||||||
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
||||||
if (!navigator.onLine && !isAuthPath(path)) {
|
if (!navigator.onLine && !isAuthPath(path)) {
|
||||||
await enqueueMutation({ method: 'POST', url: path, body });
|
await queueMutation('POST', path, body);
|
||||||
network.incrementPending();
|
|
||||||
return {} as T;
|
return {} as T;
|
||||||
}
|
}
|
||||||
const res = await apiFetch(path, {
|
let res: Response;
|
||||||
method: 'POST',
|
try {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
res = await apiFetch(path, {
|
||||||
body: JSON.stringify(body)
|
method: 'POST',
|
||||||
});
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!isAuthPath(path) && isNetworkFailure(e)) {
|
||||||
|
await queueMutation('POST', path, body);
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
if (!isAuthPath(path) && isServerUnavailable(res)) {
|
||||||
|
await queueMutation('POST', path, body);
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
throw Object.assign(new Error(`POST ${path} failed: ${res.status}`), { status: res.status, detail: err });
|
throw Object.assign(new Error(`POST ${path} failed: ${res.status}`), { status: res.status, detail: err });
|
||||||
}
|
}
|
||||||
|
|
@ -100,25 +128,53 @@ export async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
|
||||||
export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
|
export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
|
||||||
if (!navigator.onLine && !isAuthPath(path)) {
|
if (!navigator.onLine && !isAuthPath(path)) {
|
||||||
await enqueueMutation({ method: 'PATCH', url: path, body });
|
await queueMutation('PATCH', path, body);
|
||||||
network.incrementPending();
|
|
||||||
return {} as T;
|
return {} as T;
|
||||||
}
|
}
|
||||||
const res = await apiFetch(path, {
|
let res: Response;
|
||||||
method: 'PATCH',
|
try {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
res = await apiFetch(path, {
|
||||||
body: JSON.stringify(body)
|
method: 'PATCH',
|
||||||
});
|
headers: { 'Content-Type': 'application/json' },
|
||||||
if (!res.ok) throw new Error(`PATCH ${path} failed: ${res.status}`);
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!isAuthPath(path) && isNetworkFailure(e)) {
|
||||||
|
await queueMutation('PATCH', path, body);
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
if (!isAuthPath(path) && isServerUnavailable(res)) {
|
||||||
|
await queueMutation('PATCH', path, body);
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
throw new Error(`PATCH ${path} failed: ${res.status}`);
|
||||||
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiDelete(path: string): Promise<void> {
|
export async function apiDelete(path: string): Promise<void> {
|
||||||
if (!navigator.onLine && !isAuthPath(path)) {
|
if (!navigator.onLine && !isAuthPath(path)) {
|
||||||
await enqueueMutation({ method: 'DELETE', url: path, body: undefined });
|
await queueMutation('DELETE', path, undefined);
|
||||||
network.incrementPending();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const res = await apiFetch(path, { method: 'DELETE' });
|
let res: Response;
|
||||||
if (!res.ok) throw new Error(`DELETE ${path} failed: ${res.status}`);
|
try {
|
||||||
|
res = await apiFetch(path, { method: 'DELETE' });
|
||||||
|
} catch (e) {
|
||||||
|
if (!isAuthPath(path) && isNetworkFailure(e)) {
|
||||||
|
await queueMutation('DELETE', path, undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
if (!isAuthPath(path) && isServerUnavailable(res)) {
|
||||||
|
await queueMutation('DELETE', path, undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`DELETE ${path} failed: ${res.status}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ export async function offlineAddEntry(
|
||||||
product: Product,
|
product: Product,
|
||||||
grams: number
|
grams: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (navigator.onLine) {
|
if (network.online) {
|
||||||
await createEntry(date, mealId, product.id, grams);
|
await createEntry(date, mealId, product.id, grams);
|
||||||
await queryClient.invalidateQueries({ queryKey: ['diary', date] });
|
await queryClient.invalidateQueries({ queryKey: ['diary', date] });
|
||||||
return;
|
return;
|
||||||
|
|
@ -99,7 +99,7 @@ export async function offlineEditEntry(
|
||||||
const diary = queryClient.getQueryData<Diary>(['diary', date]);
|
const diary = queryClient.getQueryData<Diary>(['diary', date]);
|
||||||
const mealId = diary?.meals.find(m => m.entries.some(e => e.id === entryId))?.id;
|
const mealId = diary?.meals.find(m => m.entries.some(e => e.id === entryId))?.id;
|
||||||
|
|
||||||
if (navigator.onLine) {
|
if (network.online) {
|
||||||
await updateEntry(date, mealId!, entryId, { grams: newGrams });
|
await updateEntry(date, mealId!, entryId, { grams: newGrams });
|
||||||
await queryClient.invalidateQueries({ queryKey: ['diary', date] });
|
await queryClient.invalidateQueries({ queryKey: ['diary', date] });
|
||||||
return;
|
return;
|
||||||
|
|
@ -150,7 +150,7 @@ export async function offlineAddMeal(
|
||||||
date: string,
|
date: string,
|
||||||
name: string
|
name: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (navigator.onLine) {
|
if (network.online) {
|
||||||
await createMeal(date, name || 'Meal');
|
await createMeal(date, name || 'Meal');
|
||||||
await queryClient.invalidateQueries({ queryKey: ['diary', date] });
|
await queryClient.invalidateQueries({ queryKey: ['diary', date] });
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -15,5 +15,6 @@ export const network = {
|
||||||
incrementPending() { _pendingCount++; },
|
incrementPending() { _pendingCount++; },
|
||||||
decrementPending() { _pendingCount = Math.max(0, _pendingCount - 1); },
|
decrementPending() { _pendingCount = Math.max(0, _pendingCount - 1); },
|
||||||
setPendingCount(n: number) { _pendingCount = n; },
|
setPendingCount(n: number) { _pendingCount = n; },
|
||||||
setSyncing(v: boolean) { _syncing = v; }
|
setSyncing(v: boolean) { _syncing = v; },
|
||||||
|
setOffline() { _online = false; }
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import { getCachedDiary, cacheDiary } from '$lib/offline/db';
|
import { getCachedDiary, cacheDiary } from '$lib/offline/db';
|
||||||
import { addDays, formatDisplay, today } from '$lib/utils/date';
|
import { addDays, formatDisplay, today } from '$lib/utils/date';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { network } from '$lib/offline/network.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import MacroSummary from '$lib/components/diary/MacroSummary.svelte';
|
import MacroSummary from '$lib/components/diary/MacroSummary.svelte';
|
||||||
import MealCard from '$lib/components/diary/MealCard.svelte';
|
import MealCard from '$lib/components/diary/MealCard.svelte';
|
||||||
|
|
@ -53,7 +54,7 @@
|
||||||
const diaryQuery = createQuery(() => ({
|
const diaryQuery = createQuery(() => ({
|
||||||
queryKey: ['diary', date],
|
queryKey: ['diary', date],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!navigator.onLine) {
|
if (!network.online) {
|
||||||
// Prefer in-memory cache — it contains any optimistic updates
|
// Prefer in-memory cache — it contains any optimistic updates
|
||||||
const inMemory = queryClient.getQueryData<import('$lib/types/api').Diary>(['diary', date]);
|
const inMemory = queryClient.getQueryData<import('$lib/types/api').Diary>(['diary', date]);
|
||||||
if (inMemory) return inMemory;
|
if (inMemory) return inMemory;
|
||||||
|
|
@ -64,17 +65,27 @@
|
||||||
}
|
}
|
||||||
// Online: fetch fresh data — this overwrites both TQ cache and IndexedDB,
|
// Online: fetch fresh data — this overwrites both TQ cache and IndexedDB,
|
||||||
// replacing any temporary negative-ID entries from optimistic updates
|
// replacing any temporary negative-ID entries from optimistic updates
|
||||||
const diary = await getDiary(date);
|
try {
|
||||||
if (diary === null) {
|
const diary = await getDiary(date);
|
||||||
if (date === today()) {
|
if (diary === null) {
|
||||||
const created = await createDiary(date);
|
if (date === today()) {
|
||||||
await cacheDiary(JSON.parse(JSON.stringify(created)));
|
const created = await createDiary(date);
|
||||||
return created;
|
await cacheDiary(JSON.parse(JSON.stringify(created)));
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return null;
|
await cacheDiary(JSON.parse(JSON.stringify(diary)));
|
||||||
|
return diary;
|
||||||
|
} catch {
|
||||||
|
// Network failed despite network.online being true — fall back to cache
|
||||||
|
network.setOffline();
|
||||||
|
const inMemory = queryClient.getQueryData<import('$lib/types/api').Diary>(['diary', date]);
|
||||||
|
if (inMemory) return inMemory;
|
||||||
|
const cached = await getCachedDiary(date);
|
||||||
|
if (cached) return cached;
|
||||||
|
throw new Error('Offline and no cached data');
|
||||||
}
|
}
|
||||||
await cacheDiary(JSON.parse(JSON.stringify(diary)));
|
|
||||||
return diary;
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -125,7 +136,7 @@
|
||||||
{:else if diaryQuery.isError}
|
{:else if diaryQuery.isError}
|
||||||
<div class="text-center text-zinc-500 mt-20">
|
<div class="text-center text-zinc-500 mt-20">
|
||||||
<p class="text-lg">Could not load diary</p>
|
<p class="text-lg">Could not load diary</p>
|
||||||
<p class="text-sm mt-1">{diaryQuery.error.message}</p>
|
<p class="text-sm mt-1">{diaryQuery.error?.message ?? 'Unknown error'}</p>
|
||||||
<button
|
<button
|
||||||
onclick={() => diaryQuery.refetch()}
|
onclick={() => diaryQuery.refetch()}
|
||||||
class="mt-4 px-4 py-2 bg-zinc-800 rounded-xl text-sm"
|
class="mt-4 px-4 py-2 bg-zinc-800 rounded-xl text-sm"
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,9 @@
|
||||||
import Sheet from "$lib/components/ui/Sheet.svelte";
|
import Sheet from "$lib/components/ui/Sheet.svelte";
|
||||||
import BarcodeScanner from "$lib/components/ui/BarcodeScanner.svelte";
|
import BarcodeScanner from "$lib/components/ui/BarcodeScanner.svelte";
|
||||||
import { kcal, g } from "$lib/utils/format";
|
import { kcal, g } from "$lib/utils/format";
|
||||||
|
import { today } from "$lib/utils/date";
|
||||||
|
|
||||||
const date = $derived(page.params.date!);
|
const date = $derived(page.params.date === 'today' ? today() : page.params.date!);
|
||||||
const mealId = $derived(Number(page.url.searchParams.get("meal_id")));
|
const mealId = $derived(Number(page.url.searchParams.get("meal_id")));
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|
@ -55,10 +56,14 @@
|
||||||
if (!network.online) {
|
if (!network.online) {
|
||||||
return searchCachedProducts(debouncedQ);
|
return searchCachedProducts(debouncedQ);
|
||||||
}
|
}
|
||||||
const products = await listProducts(debouncedQ, 30);
|
try {
|
||||||
// Cache for offline use — fire and forget
|
const products = await listProducts(debouncedQ, 30);
|
||||||
cacheProducts(products);
|
cacheProducts(products);
|
||||||
return products;
|
return products;
|
||||||
|
} catch {
|
||||||
|
network.setOffline();
|
||||||
|
return searchCachedProducts(debouncedQ);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,9 @@
|
||||||
import type { Preset } from '$lib/types/api';
|
import type { Preset } from '$lib/types/api';
|
||||||
import TopBar from '$lib/components/ui/TopBar.svelte';
|
import TopBar from '$lib/components/ui/TopBar.svelte';
|
||||||
import { kcal, g } from '$lib/utils/format';
|
import { kcal, g } from '$lib/utils/format';
|
||||||
|
import { today } from '$lib/utils/date';
|
||||||
|
|
||||||
const date = $derived(page.params.date!);
|
const date = $derived(page.params.date === 'today' ? today() : page.params.date!);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
type Tab = 'new' | 'preset';
|
type Tab = 'new' | 'preset';
|
||||||
|
|
@ -39,6 +40,9 @@
|
||||||
try {
|
try {
|
||||||
await offlineAddMeal(queryClient, date, mealName);
|
await offlineAddMeal(queryClient, date, mealName);
|
||||||
goto(`/diary/${date}`);
|
goto(`/diary/${date}`);
|
||||||
|
} catch {
|
||||||
|
// mutation was queued offline — navigate back anyway
|
||||||
|
goto(`/diary/${date}`);
|
||||||
} finally {
|
} finally {
|
||||||
submitting = false;
|
submitting = false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,9 @@
|
||||||
import { offlineEditEntry } from '$lib/offline/mutations';
|
import { offlineEditEntry } from '$lib/offline/mutations';
|
||||||
import { network } from '$lib/offline/network.svelte';
|
import { network } from '$lib/offline/network.svelte';
|
||||||
import TopBar from '$lib/components/ui/TopBar.svelte';
|
import TopBar from '$lib/components/ui/TopBar.svelte';
|
||||||
|
import { today } from '$lib/utils/date';
|
||||||
|
|
||||||
const date = $derived(page.params.date!);
|
const date = $derived(page.params.date === 'today' ? today() : page.params.date!);
|
||||||
const entryId = $derived(Number(page.params.entry_id));
|
const entryId = $derived(Number(page.params.entry_id));
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|
@ -43,6 +44,8 @@
|
||||||
try {
|
try {
|
||||||
await offlineEditEntry(queryClient, date, entryId, grams);
|
await offlineEditEntry(queryClient, date, entryId, grams);
|
||||||
goto(`/diary/${date}`);
|
goto(`/diary/${date}`);
|
||||||
|
} catch {
|
||||||
|
goto(`/diary/${date}`);
|
||||||
} finally {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
}
|
}
|
||||||
|
|
@ -55,6 +58,8 @@
|
||||||
await deleteEntry(date, entry!.meal_id, entryId);
|
await deleteEntry(date, entry!.meal_id, entryId);
|
||||||
await queryClient.invalidateQueries({ queryKey: ['diary', date] });
|
await queryClient.invalidateQueries({ queryKey: ['diary', date] });
|
||||||
goto(`/diary/${date}`);
|
goto(`/diary/${date}`);
|
||||||
|
} catch {
|
||||||
|
goto(`/diary/${date}`);
|
||||||
} finally {
|
} finally {
|
||||||
deleting = false;
|
deleting = false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,165 +1 @@
|
||||||
<script lang="ts">
|
<!-- Redirected by +page.ts to /diary/[date] -->
|
||||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
|
||||||
import { getDiary, createDiary } from '$lib/api/diary';
|
|
||||||
import { getCachedDiary, cacheDiary } from '$lib/offline/db';
|
|
||||||
import { addDays, formatDisplay, today } from '$lib/utils/date';
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import MacroSummary from '$lib/components/diary/MacroSummary.svelte';
|
|
||||||
import MealCard from '$lib/components/diary/MealCard.svelte';
|
|
||||||
import DateNav from '$lib/components/diary/DateNav.svelte';
|
|
||||||
import CalendarPicker from '$lib/components/diary/CalendarPicker.svelte';
|
|
||||||
import Sheet from '$lib/components/ui/Sheet.svelte';
|
|
||||||
import CommandPalette from '$lib/components/ui/CommandPalette.svelte';
|
|
||||||
import type { Command } from '$lib/components/ui/CommandPalette.svelte';
|
|
||||||
|
|
||||||
const date = $derived(today());
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
let calendarOpen = $state(false);
|
|
||||||
let commandOpen = $state(false);
|
|
||||||
|
|
||||||
const commands = $derived<Command[]>([
|
|
||||||
...(diaryQuery.data?.meals ?? []).map(meal => ({
|
|
||||||
id: `entry-${meal.id}`,
|
|
||||||
label: `Add entry → ${meal.name}`,
|
|
||||||
keywords: ['e', 'entry', 'food', 'add'],
|
|
||||||
action: () => goto(`/diary/${date}/add-entry?meal_id=${meal.id}`)
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
id: 'add-meal',
|
|
||||||
label: 'Add meal',
|
|
||||||
keywords: ['m', 'meal'],
|
|
||||||
action: () => goto(`/diary/${date}/add-meal`)
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
|
||||||
const tag = (e.target as HTMLElement).tagName;
|
|
||||||
const editable = (e.target as HTMLElement).isContentEditable;
|
|
||||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || editable) return;
|
|
||||||
if (e.key === '/' || e.key === ':') {
|
|
||||||
e.preventDefault();
|
|
||||||
commandOpen = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', handleKeydown);
|
|
||||||
return () => document.removeEventListener('keydown', handleKeydown);
|
|
||||||
});
|
|
||||||
|
|
||||||
const diaryQuery = createQuery(() => ({
|
|
||||||
queryKey: ['diary', date],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!navigator.onLine) {
|
|
||||||
const inMemory = queryClient.getQueryData<import('$lib/types/api').Diary>(['diary', date]);
|
|
||||||
if (inMemory) return inMemory;
|
|
||||||
const cached = await getCachedDiary(date);
|
|
||||||
if (cached) return cached;
|
|
||||||
throw new Error('Offline and no cached data');
|
|
||||||
}
|
|
||||||
const diary = await getDiary(date);
|
|
||||||
if (diary === null) {
|
|
||||||
const created = await createDiary(date);
|
|
||||||
await cacheDiary(JSON.parse(JSON.stringify(created)));
|
|
||||||
return created;
|
|
||||||
}
|
|
||||||
await cacheDiary(JSON.parse(JSON.stringify(diary)));
|
|
||||||
return diary;
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
function goDate(delta: number) {
|
|
||||||
goto(`/diary/${addDays(date, delta)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDateSelect(d: string) {
|
|
||||||
calendarOpen = false;
|
|
||||||
goto(`/diary/${d}`);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex flex-col h-screen">
|
|
||||||
<!-- Header -->
|
|
||||||
<header class="px-4 pt-[calc(0.75rem+var(--safe-top))] pb-3 bg-zinc-950 sticky top-0 z-10 border-b border-zinc-800">
|
|
||||||
<DateNav
|
|
||||||
{date}
|
|
||||||
label={formatDisplay(date)}
|
|
||||||
onPrev={() => goDate(-1)}
|
|
||||||
onNext={() => goDate(1)}
|
|
||||||
isToday={true}
|
|
||||||
onDateClick={() => calendarOpen = true}
|
|
||||||
/>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<main class="flex-1 overflow-y-auto px-4 py-4 pb-[calc(5rem+var(--safe-bottom))] lg:pb-6">
|
|
||||||
{#if diaryQuery.isPending}
|
|
||||||
<!-- Skeleton -->
|
|
||||||
<div class="animate-pulse space-y-4 lg:grid lg:grid-cols-[300px_1fr] lg:gap-6 lg:space-y-0 lg:items-start">
|
|
||||||
<div class="h-28 bg-zinc-800 rounded-2xl"></div>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="h-40 bg-zinc-800 rounded-2xl"></div>
|
|
||||||
<div class="h-40 bg-zinc-800 rounded-2xl"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else if diaryQuery.isError}
|
|
||||||
<div class="text-center text-zinc-500 mt-20">
|
|
||||||
<p class="text-lg">Could not load diary</p>
|
|
||||||
<p class="text-sm mt-1">{diaryQuery.error.message}</p>
|
|
||||||
<button
|
|
||||||
onclick={() => diaryQuery.refetch()}
|
|
||||||
class="mt-4 px-4 py-2 bg-zinc-800 rounded-xl text-sm"
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{:else if diaryQuery.data}
|
|
||||||
{@const diary = diaryQuery.data}
|
|
||||||
|
|
||||||
<div class="space-y-4 lg:grid lg:grid-cols-[300px_1fr] lg:gap-6 lg:space-y-0 lg:items-start">
|
|
||||||
<!-- Left: macros summary (sticky on desktop) -->
|
|
||||||
<div class="lg:sticky lg:top-[4.5rem]">
|
|
||||||
<MacroSummary {diary} {date} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right: meals -->
|
|
||||||
<div class="space-y-4">
|
|
||||||
{#each diary.meals as meal (meal.id)}
|
|
||||||
<MealCard {meal} {date} />
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<!-- Add meal button -->
|
|
||||||
<button
|
|
||||||
onclick={() => goto(`/diary/${date}/add-meal`)}
|
|
||||||
class="w-full border border-dashed border-zinc-700 rounded-2xl py-4 text-zinc-500 hover:text-zinc-300 hover:border-zinc-500 transition-colors text-sm font-medium"
|
|
||||||
>
|
|
||||||
+ Add meal
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- FAB: Add entry (mobile only) -->
|
|
||||||
{#if diaryQuery.data != null}
|
|
||||||
<button
|
|
||||||
onclick={() => {
|
|
||||||
const firstMeal = diaryQuery.data?.meals[0];
|
|
||||||
if (firstMeal) goto(`/diary/${date}/add-entry?meal_id=${firstMeal.id}`);
|
|
||||||
}}
|
|
||||||
class="lg:hidden fixed bottom-[calc(4rem+var(--safe-bottom))] right-4 z-30 w-14 h-14 rounded-full bg-green-600 hover:bg-green-500 shadow-lg flex items-center justify-center transition-colors"
|
|
||||||
aria-label="Add entry"
|
|
||||||
>
|
|
||||||
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Sheet open={calendarOpen} onclose={() => calendarOpen = false}>
|
|
||||||
<CalendarPicker selected={date} onSelect={handleDateSelect} />
|
|
||||||
</Sheet>
|
|
||||||
|
|
||||||
<CommandPalette {commands} open={commandOpen} onclose={() => commandOpen = false} />
|
|
||||||
</div>
|
|
||||||
|
|
|
||||||
6
src/routes/(app)/diary/today/+page.ts
Normal file
6
src/routes/(app)/diary/today/+page.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { today } from '$lib/utils/date';
|
||||||
|
|
||||||
|
export function load() {
|
||||||
|
throw redirect(307, `/diary/${today()}`);
|
||||||
|
}
|
||||||
|
|
@ -41,8 +41,20 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
workbox: {
|
workbox: {
|
||||||
navigateFallback: '/index.html',
|
navigateFallback: '/index.html',
|
||||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2,json}'],
|
||||||
|
skipWaiting: true,
|
||||||
|
clientsClaim: true,
|
||||||
|
cleanupOutdatedCaches: true,
|
||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
// SvelteKit immutable app chunks (hashed filenames — safe to cache forever)
|
||||||
|
urlPattern: ({ url }) => url.pathname.startsWith('/_app/'),
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'app-shell',
|
||||||
|
expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 365 }
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// Cache same-origin API responses (NetworkFirst: try network, fall back to cache)
|
// Cache same-origin API responses (NetworkFirst: try network, fall back to cache)
|
||||||
urlPattern: ({ url }) => url.pathname.startsWith('/api/'),
|
urlPattern: ({ url }) => url.pathname.startsWith('/api/'),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue