feat: extend PWA offline support to all recipe routes and glaube pages

- Add offline support for category, tag, icon list pages
- Add offline support for favorites page (stores locally for offline)
- Add offline support for season list page
- Cache root page and glaube pages for offline access
- Dynamically discover glaube routes at build time using Vite glob
- Add db functions for getAllCategories, getAllTags, getAllIcons
- Pre-cache __data.json for all category, tag, icon, season subroutes
- Update service worker to cache glaube and root page responses
This commit is contained in:
2026-01-29 09:54:26 +01:00
parent 9ff30b28cd
commit c86a734da0
8 changed files with 371 additions and 29 deletions

View File

@@ -127,6 +127,41 @@ export async function getBriefRecipesByIcon(icon: string): Promise<BriefRecipeTy
return allRecipes.filter(recipe => recipe.icon === icon);
}
export async function getAllCategories(): Promise<string[]> {
const allRecipes = await getAllBriefRecipes();
const categories = new Set<string>();
for (const recipe of allRecipes) {
if (recipe.category) {
categories.add(recipe.category);
}
}
return Array.from(categories).sort();
}
export async function getAllTags(): Promise<string[]> {
const allRecipes = await getAllBriefRecipes();
const tags = new Set<string>();
for (const recipe of allRecipes) {
if (recipe.tags) {
for (const tag of recipe.tags) {
tags.add(tag);
}
}
}
return Array.from(tags).sort();
}
export async function getAllIcons(): Promise<string[]> {
const allRecipes = await getAllBriefRecipes();
const icons = new Set<string>();
for (const recipe of allRecipes) {
if (recipe.icon) {
icons.add(recipe.icon);
}
}
return Array.from(icons).sort();
}
export async function saveAllRecipes(
briefRecipes: BriefRecipeType[],
fullRecipes: RecipeModelType[]

View File

@@ -1,6 +1,17 @@
import { saveAllRecipes } from './db';
import type { BriefRecipeType, RecipeModelType } from '../../types/types';
// Discover glaube routes at build time using Vite's glob import
const glaubePageModules = import.meta.glob('/src/routes/glaube/**/+page.svelte');
const glaubeRoutes = Object.keys(glaubePageModules).map(path => {
// Convert file path to route path
// /src/routes/glaube/+page.svelte -> /glaube
// /src/routes/glaube/angelus/+page.svelte -> /glaube/angelus
return path
.replace('/src/routes', '')
.replace('/+page.svelte', '') || '/glaube';
});
export type SyncResult = {
success: boolean;
recipeCount: number;
@@ -56,18 +67,53 @@ async function precacheMainPages(_fetchFn: typeof fetch): Promise<void> {
const registration = await navigator.serviceWorker.ready;
if (!registration.active) return;
// Send message to service worker to cache main pages, offline shells, and their data
// The offline shells are crucial for direct navigation to recipe pages when offline
// Build list of pages to cache
const pagesToCache: string[] = [
// Root page
'/',
'/__data.json',
// Recipe main pages
'/rezepte',
'/recipes',
'/rezepte/offline-shell',
'/recipes/offline-shell',
// Recipe main page data
'/rezepte/__data.json',
'/recipes/__data.json',
// Recipe list pages
'/rezepte/category',
'/rezepte/tag',
'/rezepte/icon',
'/rezepte/season',
'/rezepte/favorites',
'/recipes/category',
'/recipes/tag',
'/recipes/icon',
'/recipes/season',
'/recipes/favorites',
// Recipe list page data
'/rezepte/category/__data.json',
'/rezepte/tag/__data.json',
'/rezepte/icon/__data.json',
'/rezepte/season/__data.json',
'/rezepte/favorites/__data.json',
'/recipes/category/__data.json',
'/recipes/tag/__data.json',
'/recipes/icon/__data.json',
'/recipes/season/__data.json',
'/recipes/favorites/__data.json'
];
// Add dynamically discovered glaube routes (HTML and __data.json)
for (const route of glaubeRoutes) {
pagesToCache.push(route);
pagesToCache.push(`${route}/__data.json`);
}
// Send message to service worker to cache all pages
registration.active.postMessage({
type: 'CACHE_PAGES',
urls: [
'/rezepte',
'/recipes',
'/rezepte/offline-shell',
'/recipes/offline-shell',
'/rezepte/__data.json',
'/recipes/__data.json'
]
urls: pagesToCache
});
}
@@ -80,6 +126,12 @@ async function precacheRecipeData(recipes: BriefRecipeType[]): Promise<void> {
// Collect __data.json URLs for all recipes (both German and English if translated)
const dataUrls: string[] = [];
// Collect unique categories, tags, and icons
const categories = new Set<string>();
const tags = new Set<string>();
const icons = new Set<string>();
for (const recipe of recipes) {
// German recipe data
dataUrls.push(`/rezepte/${recipe.short_name}/__data.json`);
@@ -88,6 +140,39 @@ async function precacheRecipeData(recipes: BriefRecipeType[]): Promise<void> {
if (recipe.translations?.en?.short_name) {
dataUrls.push(`/recipes/${recipe.translations.en.short_name}/__data.json`);
}
// Collect metadata for subroute caching
if (recipe.category) categories.add(recipe.category);
if (recipe.icon) icons.add(recipe.icon);
if (recipe.tags) {
for (const tag of recipe.tags) {
tags.add(tag);
}
}
}
// Add category subroute data
for (const category of categories) {
dataUrls.push(`/rezepte/category/${encodeURIComponent(category)}/__data.json`);
dataUrls.push(`/recipes/category/${encodeURIComponent(category)}/__data.json`);
}
// Add tag subroute data
for (const tag of tags) {
dataUrls.push(`/rezepte/tag/${encodeURIComponent(tag)}/__data.json`);
dataUrls.push(`/recipes/tag/${encodeURIComponent(tag)}/__data.json`);
}
// Add icon subroute data
for (const icon of icons) {
dataUrls.push(`/rezepte/icon/${encodeURIComponent(icon)}/__data.json`);
dataUrls.push(`/recipes/icon/${encodeURIComponent(icon)}/__data.json`);
}
// Add season subroute data (all 12 months)
for (let month = 1; month <= 12; month++) {
dataUrls.push(`/rezepte/season/${month}/__data.json`);
dataUrls.push(`/recipes/season/${month}/__data.json`);
}
// Send message to service worker to cache these URLs

View File

@@ -1,10 +1,43 @@
import type { PageLoad } from "./$types";
import { browser } from '$app/environment';
import { isOffline, canUseOfflineData } from '$lib/offline/helpers';
import { getAllCategories, isOfflineDataAvailable } from '$lib/offline/db';
export async function load({ fetch, params}) {
export const load: PageLoad = async ({ fetch, params }) => {
const isEnglish = params.recipeLang === 'recipes';
const apiBase = isEnglish ? '/api/recipes' : '/api/rezepte';
const res = await fetch(`${apiBase}/items/category`);
const categories= await res.json();
return {categories}
// Check if we should use offline data
if (browser && isOffline() && canUseOfflineData()) {
try {
const hasOfflineData = await isOfflineDataAvailable();
if (hasOfflineData) {
const categories = await getAllCategories();
return { categories, isOffline: true };
}
} catch (error) {
console.error('Failed to load offline categories:', error);
}
}
// Online mode - fetch from API
try {
const res = await fetch(`${apiBase}/items/category`);
const categories = await res.json();
return { categories, isOffline: false };
} catch (error) {
// Network error - try offline fallback
if (browser && canUseOfflineData()) {
try {
const hasOfflineData = await isOfflineDataAvailable();
if (hasOfflineData) {
const categories = await getAllCategories();
return { categories, isOffline: true };
}
} catch (offlineError) {
console.error('Failed to load offline categories:', offlineError);
}
}
throw error;
}
};

View File

@@ -0,0 +1,78 @@
import type { PageLoad } from "./$types";
import { browser } from '$app/environment';
import { isOffline, canUseOfflineData } from '$lib/offline/helpers';
import { getAllBriefRecipes, isOfflineDataAvailable } from '$lib/offline/db';
// Store favorites in localStorage for offline access
const FAVORITES_STORAGE_KEY = 'bocken-favorites';
function getStoredFavorites(): string[] {
if (!browser) return [];
try {
const stored = localStorage.getItem(FAVORITES_STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
function storeFavorites(favoriteIds: string[]): void {
if (!browser) return;
try {
localStorage.setItem(FAVORITES_STORAGE_KEY, JSON.stringify(favoriteIds));
} catch {
// Storage full or unavailable
}
}
export const load: PageLoad = async ({ data, params }) => {
const isEnglish = params.recipeLang === 'recipes';
// If we have server data, store the favorite IDs for offline use
if (data?.favorites && Array.isArray(data.favorites) && data.favorites.length > 0) {
const favoriteIds = data.favorites.map((r: any) => r.short_name);
storeFavorites(favoriteIds);
}
// Check if we should use offline data
const shouldUseOffline = browser && (isOffline() || data?.isOffline) && canUseOfflineData();
if (shouldUseOffline) {
try {
const hasOfflineData = await isOfflineDataAvailable();
if (hasOfflineData) {
const storedFavoriteIds = getStoredFavorites();
if (storedFavoriteIds.length === 0) {
return {
...data,
favorites: [],
isOffline: true,
offlineMessage: isEnglish
? 'Favorites are not available offline. Please sync while online first.'
: 'Favoriten sind offline nicht verfügbar. Bitte zuerst online synchronisieren.'
};
}
const allRecipes = await getAllBriefRecipes();
const favorites = allRecipes
.filter(recipe => storedFavoriteIds.includes(recipe.short_name))
.map(recipe => ({ ...recipe, isFavorite: true }));
return {
...data,
favorites,
isOffline: true
};
}
} catch (error) {
console.error('Failed to load offline favorites:', error);
}
}
// Return server data as-is
return {
...data,
isOffline: false
};
};

View File

@@ -1,10 +1,40 @@
import type { PageLoad } from "./$types";
import { browser } from '$app/environment';
import { isOffline, canUseOfflineData } from '$lib/offline/helpers';
import { getAllIcons, isOfflineDataAvailable } from '$lib/offline/db';
export async function load({ fetch }) {
let current_month = new Date().getMonth() + 1
const res_icons = await fetch(`/api/rezepte/items/icon`);
const item = await res_icons.json();
return {
icons: item,
};
export const load: PageLoad = async ({ fetch }) => {
// Check if we should use offline data
if (browser && isOffline() && canUseOfflineData()) {
try {
const hasOfflineData = await isOfflineDataAvailable();
if (hasOfflineData) {
const icons = await getAllIcons();
return { icons, isOffline: true };
}
} catch (error) {
console.error('Failed to load offline icons:', error);
}
}
// Online mode - fetch from API
try {
const res_icons = await fetch(`/api/rezepte/items/icon`);
const icons = await res_icons.json();
return { icons, isOffline: false };
} catch (error) {
// Network error - try offline fallback
if (browser && canUseOfflineData()) {
try {
const hasOfflineData = await isOfflineDataAvailable();
if (hasOfflineData) {
const icons = await getAllIcons();
return { icons, isOffline: true };
}
} catch (offlineError) {
console.error('Failed to load offline icons:', offlineError);
}
}
throw error;
}
};

View File

@@ -0,0 +1,41 @@
import { browser } from '$app/environment';
import { isOffline, canUseOfflineData } from '$lib/offline/helpers';
import { getBriefRecipesBySeason, isOfflineDataAvailable } from '$lib/offline/db';
import { rand_array } from '$lib/js/randomize';
export async function load({ data }) {
// On the server, just pass through the server data unchanged
if (!browser) {
return {
...data,
isOffline: false
};
}
// On the client, check if we need to load from IndexedDB
const shouldUseOfflineData = (isOffline() || data?.isOffline || !data?.season?.length) && canUseOfflineData();
if (shouldUseOfflineData) {
try {
const hasOfflineData = await isOfflineDataAvailable();
if (hasOfflineData) {
const currentMonth = new Date().getMonth() + 1;
const recipes = await getBriefRecipesBySeason(currentMonth);
return {
...data,
season: rand_array(recipes),
isOffline: true
};
}
} catch (error) {
console.error('Failed to load offline season data:', error);
}
}
// Return server data as-is
return {
...data,
isOffline: false
};
}

View File

@@ -1,10 +1,43 @@
import type { PageLoad } from "./$types";
import { browser } from '$app/environment';
import { isOffline, canUseOfflineData } from '$lib/offline/helpers';
import { getAllTags, isOfflineDataAvailable } from '$lib/offline/db';
export async function load({ fetch, params}) {
export const load: PageLoad = async ({ fetch, params }) => {
const isEnglish = params.recipeLang === 'recipes';
const apiBase = isEnglish ? '/api/recipes' : '/api/rezepte';
const res = await fetch(`${apiBase}/items/tag`);
const tags = await res.json();
return {tags}
// Check if we should use offline data
if (browser && isOffline() && canUseOfflineData()) {
try {
const hasOfflineData = await isOfflineDataAvailable();
if (hasOfflineData) {
const tags = await getAllTags();
return { tags, isOffline: true };
}
} catch (error) {
console.error('Failed to load offline tags:', error);
}
}
// Online mode - fetch from API
try {
const res = await fetch(`${apiBase}/items/tag`);
const tags = await res.json();
return { tags, isOffline: false };
} catch (error) {
// Network error - try offline fallback
if (browser && canUseOfflineData()) {
try {
const hasOfflineData = await isOfflineDataAvailable();
if (hasOfflineData) {
const tags = await getAllTags();
return { tags, isOffline: true };
}
} catch (offlineError) {
console.error('Failed to load offline tags:', offlineError);
}
}
throw error;
}
};

View File

@@ -70,9 +70,12 @@ sw.addEventListener('fetch', (event) => {
// Skip API requests - let them go to network (IndexedDB handles offline)
if (url.pathname.startsWith('/api/')) return;
// Handle SvelteKit __data.json requests for recipe routes
// Handle SvelteKit __data.json requests for cacheable routes (recipes, glaube, root)
// Cache successful responses, serve from cache when offline
if (url.pathname.includes('__data.json') && url.pathname.match(/^\/(rezepte|recipes)/)) {
const isCacheableDataRoute = url.pathname.includes('__data.json') &&
(url.pathname.match(/^\/(rezepte|recipes|glaube)(\/|$)/) || url.pathname === '/__data.json');
if (isCacheableDataRoute) {
event.respondWith(
(async () => {
const cache = await caches.open(CACHE_PAGES);
@@ -168,8 +171,12 @@ sw.addEventListener('fetch', (event) => {
// Try network first
const response = await fetch(event.request);
// Cache successful HTML responses for recipe pages (using pathname as key)
if (response.ok && url.pathname.match(/^\/(rezepte|recipes)(\/|$)/)) {
// Cache successful HTML responses for cacheable pages (using pathname as key)
const isCacheablePage = response.ok && (
url.pathname.match(/^\/(rezepte|recipes|glaube)(\/|$)/) ||
url.pathname === '/'
);
if (isCacheablePage) {
cache.put(cacheKey, response.clone());
}