release nouri 0.6.0 polish backup and pwa

This commit is contained in:
2026-04-12 17:46:18 +02:00
parent 9ff7a6d57c
commit 555fddab80
31 changed files with 1257 additions and 164 deletions
+5 -15
View File
@@ -1,16 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffd7be"/>
<stop offset="100%" stop-color="#e39a63"/>
</linearGradient>
<linearGradient id="leaf" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#b5dfc8"/>
<stop offset="100%" stop-color="#72a98b"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="18" fill="url(#bg)"/>
<path d="M16 34c0-3 2.4-5.4 5.4-5.4h21.2c3 0 5.4 2.4 5.4 5.4 0 9.2-7.4 16.6-16.6 16.6h-.8C23.4 50.6 16 43.2 16 34z" fill="#fff9f4"/>
<path d="M21 25c2.4-6.7 7.2-10.6 11-10.6S40.6 18.3 43 25" fill="none" stroke="#fff9f4" stroke-linecap="round" stroke-width="4"/>
<path d="M40 12c5 .4 9 5 9 10.1 0 .5 0 1-.1 1.5-.8-.7-1.8-1.2-2.9-1.5-2.7-.8-4.3-3.3-4.2-6.1 0-1.3-.6-2.6-1.8-4z" fill="url(#leaf)"/>
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="58" height="58" rx="18" fill="#E3A06B"/>
<path d="M16 36H48C46.9 44.2 40.0727 50 31.8 50H32.2C23.9273 50 17.1 44.2 16 36Z" fill="#FFF5EC"/>
<path d="M31.9997 19C36.9702 19 40.9997 23.0781 40.9997 28.1081V29.3848C40.9997 32.7262 39.6098 35.9158 37.1535 38.1417L29.9949 44.6282H24.5605L32.4863 37.4468C34.0025 36.0726 34.8571 34.1103 34.8571 32.0653V28.1081C34.8571 26.551 33.5671 25.2162 31.9997 25.2162C30.4324 25.2162 29.1424 26.551 29.1424 28.1081V29.3848H23C23 23.0781 27.0295 19 31.9997 19Z" fill="#8C533B"/>
<rect x="24" y="39" width="16" height="6" rx="3" fill="#8C533B"/>
</svg>

Before

Width:  |  Height:  |  Size: 914 B

After

Width:  |  Height:  |  Size: 715 B

+15 -16
View File
@@ -1,21 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title">
<title>Nouri</title>
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffd7be"/>
<stop offset="55%" stop-color="#f5b17a"/>
<stop offset="100%" stop-color="#d58c57"/>
<linearGradient id="nouriBg" x1="88" y1="72" x2="420" y2="440" gradientUnits="userSpaceOnUse">
<stop stop-color="#F6C394"/>
<stop offset="1" stop-color="#DE9862"/>
</linearGradient>
<linearGradient id="leaf" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#b5dfc8"/>
<stop offset="100%" stop-color="#70aa87"/>
<linearGradient id="nouriBowl" x1="161" y1="176" x2="349" y2="339" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFF8F0"/>
<stop offset="1" stop-color="#FDE7D5"/>
</linearGradient>
</defs>
<rect width="256" height="256" rx="64" fill="url(#bg)"/>
<circle cx="128" cy="128" r="96" fill="rgba(255,255,255,0.16)"/>
<path d="M68 132c0-9.9 8.1-18 18-18h84c9.9 0 18 8.1 18 18 0 31.8-25.8 57.6-57.6 57.6h-4.8C93.8 189.6 68 163.8 68 132z" fill="#fff9f4"/>
<path d="M84 105c7-21.3 24-34 44-34s37 12.7 44 34" fill="none" stroke="#fff9f4" stroke-linecap="round" stroke-width="14"/>
<path d="M156 55c15 1 27 14.7 27 30.6 0 1.6-.1 3.1-.3 4.6-1.9-2.4-4.5-4.2-7.5-5.1-8-2.5-13.1-10.2-12.7-18.5.1-4.1-1.8-8-5.2-11.6z" fill="url(#leaf)"/>
<path d="M129 143h41" stroke="#f0a46c" stroke-linecap="round" stroke-width="10"/>
<path d="M92 96l-10 62" stroke="#fff9f4" stroke-linecap="round" stroke-width="10"/>
<rect x="24" y="24" width="464" height="464" rx="122" fill="url(#nouriBg)"/>
<rect x="48" y="48" width="416" height="416" rx="104" fill="white" fill-opacity="0.12"/>
<path d="M152 232C152 175.667 197.667 130 254 130H258C315.438 130 362 176.562 362 234V242C362 299.438 315.438 346 258 346H254C197.667 346 152 300.333 152 244V232Z" fill="url(#nouriBowl)"/>
<path d="M175 244C175 201.474 209.474 167 252 167H258C300.526 167 335 201.474 335 244V244C335 286.526 300.526 321 258 321H252C209.474 321 175 286.526 175 244V244Z" fill="#EFC39F" fill-opacity="0.22"/>
<path d="M164 287H348C344.6 332.419 307.445 368 261.2 368H250.8C204.555 368 167.4 332.419 164 287Z" fill="#F7B37D"/>
<path d="M198 287H314C311.53 314.638 288.347 336 260.2 336H251.8C223.653 336 200.47 314.638 198 287Z" fill="#A86244" fill-opacity="0.2"/>
<path d="M255.999 171C271.513 171 284.246 183.574 284.246 199.27V205.573C284.246 216.441 279.591 226.794 271.487 233.994L246.435 256.256C241.017 261.07 237.906 268.003 237.906 275.283V295.246H212.906V275.283C212.906 260.722 219.129 246.854 229.965 237.227L255.016 214.966C257.705 212.578 259.246 209.146 259.246 205.573V199.27C259.246 197.28 257.651 195.6 255.999 195.6C254.347 195.6 252.752 197.28 252.752 199.27V205.091H227.752V199.27C227.752 183.574 240.485 171 255.999 171Z" fill="#8C533B"/>
<rect x="226" y="280" width="63" height="25" rx="12.5" fill="#8C533B"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 805 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

+52 -10
View File
@@ -103,6 +103,17 @@ button,
transition: transform 160ms ease, background 160ms ease, border-color 160ms ease;
}
button:focus-visible,
.button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
summary:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent-strong) 78%, white 22%);
outline-offset: 3px;
}
button:hover,
.button:hover {
transform: translateY(-1px);
@@ -327,6 +338,12 @@ h3,
linear-gradient(180deg, color-mix(in srgb, var(--surface) 86%, #fff 14%), color-mix(in srgb, var(--surface) 80%, #ffe5d2 20%));
}
.hero h1,
.page-intro h1,
.panel h2 {
text-wrap: balance;
}
.eyebrow {
margin: 0 0 0.45rem;
text-transform: uppercase;
@@ -369,6 +386,10 @@ h3 {
line-height: 1.6;
}
.empty-panel {
text-align: left;
}
.intro-pills,
.chip-row {
display: flex;
@@ -598,6 +619,21 @@ h3 {
width: min(560px, 100%);
}
.setup-intro-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.8rem;
margin: 1.1rem 0 1.25rem;
}
.setup-tip,
.restore-warning {
padding: 1rem;
border-radius: 18px;
border: 1px solid var(--line);
background: color-mix(in srgb, var(--surface-strong) 84%, #fff 16%);
}
.stack-form,
.stack-sections,
.planner-day-stack,
@@ -714,6 +750,7 @@ legend {
width: min(220px, 100%);
border-radius: 18px;
border: 1px solid var(--line);
box-shadow: 0 16px 30px rgba(94, 68, 49, 0.12);
}
.compact-form-panel {
@@ -947,6 +984,12 @@ legend {
background: color-mix(in srgb, var(--surface-strong) 84%, #fff 16%);
}
.restore-warning strong,
.setup-tip strong {
display: block;
margin-bottom: 0.3rem;
}
.card-link-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
@@ -1259,18 +1302,14 @@ legend {
}
body.has-mobile-nav {
padding-top: 5.3rem;
padding-top: 0;
}
.site-header {
position: fixed;
top: calc(env(safe-area-inset-top, 0px) + 0.5rem);
left: 0.5rem;
right: 0.5rem;
position: static;
grid-template-columns: 1fr auto;
z-index: 30;
width: auto;
margin: 0;
width: 100%;
margin: 0 0 1.15rem;
}
.stats-grid,
@@ -1303,9 +1342,8 @@ legend {
grid-template-columns: 1fr auto;
gap: 0.6rem;
padding: 0.75rem 0.9rem;
margin-bottom: 0;
margin-bottom: 1rem;
border-radius: 22px;
z-index: 30;
}
.desktop-nav,
@@ -1369,6 +1407,10 @@ legend {
grid-template-columns: 1fr;
}
.setup-intro-grid {
grid-template-columns: 1fr;
}
.item-card {
grid-template-columns: 1fr;
}
+47 -2
View File
@@ -51,12 +51,57 @@
if (!container) return;
const items = Array.from(container.querySelectorAll("[data-filter-label]"));
const filterGroups = Array.from(container.querySelectorAll("[data-filter-group]"));
const resultLimit = Number.parseInt(input.getAttribute("data-filter-limit") || "", 10);
const hasLimit = Number.isFinite(resultLimit) && resultLimit > 0;
const scoreItem = (label, term) => {
if (label === term) return 0;
if (label.startsWith(term)) return 1;
if (label.split(/\s+/).some((part) => part.startsWith(term))) return 2;
if (label.includes(term)) return 3;
return 99;
};
const syncGroups = () => {
filterGroups.forEach((group) => {
const visibleChildren = Array.from(group.querySelectorAll("[data-filter-label]")).some((item) => !item.hidden);
const card = group.closest(".component-group, .template-list-card, .panel, .planner-subsection");
if (card) {
card.hidden = !visibleChildren;
} else {
group.hidden = !visibleChildren;
}
});
};
const applyFilter = () => {
const term = input.value.trim().toLowerCase();
if (!term) {
items.forEach((item) => {
item.hidden = false;
});
syncGroups();
return;
}
const rankedMatches = items
.map((item, index) => {
const haystack = (item.getAttribute("data-filter-label") || "").toLowerCase();
const score = scoreItem(haystack, term);
return { item, index, score, matches: score < 99 };
})
.filter((entry) => entry.matches)
.sort((left, right) => left.score - right.score || left.index - right.index);
const allowedItems = new Set(
(hasLimit ? rankedMatches.slice(0, resultLimit) : rankedMatches).map((entry) => entry.item)
);
items.forEach((item) => {
const haystack = (item.getAttribute("data-filter-label") || "").toLowerCase();
item.hidden = Boolean(term) && !haystack.includes(term);
item.hidden = !allowedItems.has(item);
});
syncGroups();
};
input.addEventListener("input", applyFilter);
+14 -1
View File
@@ -6,9 +6,16 @@
"start_url": "/",
"scope": "/",
"display": "standalone",
"display_override": ["standalone", "minimal-ui"],
"background_color": "#fff6ef",
"theme_color": "#efab72",
"theme_color": "#de9862",
"categories": ["food", "lifestyle", "productivity"],
"icons": [
{
"src": "/static/brand/pwa-180.png",
"sizes": "180x180",
"type": "image/png"
},
{
"src": "/static/brand/pwa-192.png",
"sizes": "192x192",
@@ -18,6 +25,12 @@
"src": "/static/brand/pwa-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/static/brand/pwa-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+79
View File
@@ -0,0 +1,79 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Nouri offline</title>
<style>
:root {
color-scheme: light;
--bg: #fff6ef;
--surface: rgba(255, 255, 255, 0.92);
--line: rgba(126, 104, 85, 0.14);
--text: #352d2b;
--muted: #7d7069;
--accent: #de9862;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 1.5rem;
font-family: "Avenir Next", "Segoe UI", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(255, 205, 174, 0.42), transparent 24rem),
radial-gradient(circle at 90% 8%, rgba(190, 226, 203, 0.34), transparent 24rem),
linear-gradient(180deg, var(--bg), #fdf0e6);
}
.card {
width: min(28rem, 100%);
padding: 1.4rem;
border-radius: 24px;
background: var(--surface);
border: 1px solid var(--line);
box-shadow: 0 22px 48px rgba(125, 92, 68, 0.12);
}
h1 {
margin: 0 0 0.45rem;
font-family: "Iowan Old Style", "Palatino Linotype", serif;
font-size: 2rem;
}
p {
margin: 0.35rem 0;
line-height: 1.55;
}
.muted {
color: var(--muted);
}
a {
display: inline-block;
margin-top: 1rem;
padding: 0.82rem 1.1rem;
border-radius: 999px;
background: var(--accent);
color: white;
text-decoration: none;
}
</style>
</head>
<body>
<main class="card">
<h1>Nouri ist gerade kurz offline</h1>
<p>Die App bleibt da und versucht es gleich wieder. Sobald die Verbindung zurück ist, kannst du normal weitermachen.</p>
<p class="muted">Ein Teil der Oberfläche ist schon lokal verfügbar. Für aktuelle Haushaltsdaten braucht Nouri aber wieder eine Verbindung.</p>
<a href="/">Erneut versuchen</a>
</main>
</body>
</html>
+36 -27
View File
@@ -1,15 +1,32 @@
const CACHE_NAME = "nouri-v0-5-1-1";
const CACHE_NAME = "nouri-v0-6-0";
const OFFLINE_URL = "/static/pwa/offline.html";
const STATIC_ASSETS = [
"/static/css/styles.css",
"/static/js/theme.js",
"/static/js/ui.js",
"/static/js/planner.js",
"/static/js/pwa.js",
"/static/brand/pwa-180.png",
"/static/brand/pwa-192.png",
"/static/brand/pwa-512.png",
"/static/brand/pwa-maskable-512.png",
"/static/brand/pwa-badge.png",
"/static/brand/favicon.svg",
"/app.webmanifest",
OFFLINE_URL,
];
const cacheFirst = async (request) => {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response && response.ok && response.type === "basic") {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
};
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)).then(() => self.skipWaiting())
@@ -28,40 +45,32 @@ self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return;
const requestUrl = new URL(event.request.url);
const isStaticAsset =
requestUrl.origin === self.location.origin &&
(
requestUrl.pathname.startsWith("/static/")
|| requestUrl.pathname === "/app.webmanifest"
|| requestUrl.pathname === "/service-worker.js"
);
const isSameOrigin = requestUrl.origin === self.location.origin;
const isStaticAsset = isSameOrigin && (
requestUrl.pathname.startsWith("/static/")
|| requestUrl.pathname === "/app.webmanifest"
|| requestUrl.pathname === "/service-worker.js"
);
const isUpload = isSameOrigin && requestUrl.pathname.startsWith("/uploads/");
if (event.request.mode === "navigate") {
event.respondWith(
fetch(event.request).catch(() => caches.match(event.request))
fetch(event.request)
.then((response) => {
const copy = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy));
return response;
})
.catch(async () => {
return (await caches.match(event.request)) || caches.match(OFFLINE_URL);
})
);
return;
}
if (!isStaticAsset) {
return;
if (isStaticAsset || isUpload) {
event.respondWith(cacheFirst(event.request));
}
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) {
return cached;
}
return fetch(event.request).then((response) => {
if (!response || response.status !== 200 || response.type !== "basic") {
return response;
}
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
return response;
});
})
);
});
self.addEventListener("push", (event) => {