Web Push Zustand und Deep Links stabilisieren
All checks were successful
CI / Build and Deploy (push) Successful in 2m59s

This commit is contained in:
jan
2026-05-06 21:13:54 +02:00
parent 0d72cfb144
commit 3e5ac7730d
5 changed files with 123 additions and 13 deletions

View File

@@ -1,4 +1,4 @@
const CACHE_NAME = "rave-budget-control-v1"; const CACHE_NAME = "rave-budget-control-v2";
const APP_SHELL = ["/", "/login", "/icon.svg", "/manifest.webmanifest"]; const APP_SHELL = ["/", "/login", "/icon.svg", "/manifest.webmanifest"];
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
@@ -14,7 +14,7 @@ self.addEventListener("activate", (event) => {
event.waitUntil( event.waitUntil(
caches.keys().then((cacheNames) => caches.keys().then((cacheNames) =>
Promise.all(cacheNames.filter((cacheName) => cacheName !== CACHE_NAME).map((cacheName) => caches.delete(cacheName))) Promise.all(cacheNames.filter((cacheName) => cacheName !== CACHE_NAME).map((cacheName) => caches.delete(cacheName)))
) ).then(() => self.clients.claim())
); );
}); });
@@ -66,10 +66,24 @@ self.addEventListener("notificationclick", (event) => {
event.waitUntil( event.waitUntil(
clients.matchAll({ type: "window", includeUncontrolled: true }).then(async (clientList) => { clients.matchAll({ type: "window", includeUncontrolled: true }).then(async (clientList) => {
for (const client of clientList) { const sameOriginClients = clientList.filter((client) => {
const clientUrl = new URL(client.url); const clientUrl = new URL(client.url);
return clientUrl.origin === targetUrl.origin;
});
if (clientUrl.origin === targetUrl.origin && "focus" in client) { const exactClient = sameOriginClients.find((client) => new URL(client.url).href === targetUrl.href);
const targetClient = exactClient ?? sameOriginClients[0];
if (targetClient && "focus" in targetClient) {
if ("navigate" in targetClient && new URL(targetClient.url).href !== targetUrl.href) {
await targetClient.navigate(targetUrl.href).catch(() => null);
}
return targetClient.focus();
}
for (const client of sameOriginClients) {
if ("focus" in client) {
if ("navigate" in client) { if ("navigate" in client) {
await client.navigate(targetUrl.href).catch(() => null); await client.navigate(targetUrl.href).catch(() => null);
} }

View File

@@ -5,7 +5,9 @@ export default function manifest(): MetadataRoute.Manifest {
name: "RFP Finanz\u00fcbersicht", name: "RFP Finanz\u00fcbersicht",
short_name: "RFP Finanzen", short_name: "RFP Finanzen",
description: "Budgetfreigaben und Finanzstatus f\u00fcr Vereins-AGs.", description: "Budgetfreigaben und Finanzstatus f\u00fcr Vereins-AGs.",
id: "/",
start_url: "/", start_url: "/",
scope: "/",
display: "standalone", display: "standalone",
background_color: "#F5F1E8", background_color: "#F5F1E8",
theme_color: "#3B5AE0", theme_color: "#3B5AE0",

View File

@@ -50,6 +50,7 @@ type BudgetColumnProps = {
busy: boolean; busy: boolean;
approvalThreshold: number; approvalThreshold: number;
requiredApprovalTypes: ("CHAIR_A" | "CHAIR_B" | "FINANCE")[]; requiredApprovalTypes: ("CHAIR_A" | "CHAIR_B" | "FINANCE")[];
focusBudgetId?: string | null;
onApprove: (expenseId: string, approvalType: "CHAIR_A" | "CHAIR_B" | "FINANCE") => Promise<void>; onApprove: (expenseId: string, approvalType: "CHAIR_A" | "CHAIR_B" | "FINANCE") => Promise<void>;
onMarkPaid: (expenseId: string) => Promise<void>; onMarkPaid: (expenseId: string) => Promise<void>;
onDocument: (expenseId: string, proofUrl?: string) => Promise<void>; onDocument: (expenseId: string, proofUrl?: string) => Promise<void>;
@@ -143,6 +144,7 @@ export function BudgetColumn({
busy, busy,
approvalThreshold, approvalThreshold,
requiredApprovalTypes, requiredApprovalTypes,
focusBudgetId,
onApprove, onApprove,
onMarkPaid, onMarkPaid,
onDocument, onDocument,
@@ -193,6 +195,12 @@ export function BudgetColumn({
} }
}, [group.budgets, selectedBudgetId]); }, [group.budgets, selectedBudgetId]);
useEffect(() => {
if (focusBudgetId && group.budgets.some((budget) => budget.id === focusBudgetId)) {
setSelectedBudgetId(focusBudgetId);
}
}, [focusBudgetId, group.budgets]);
const approvedSpend = useMemo( const approvedSpend = useMemo(
() => group.budgets.reduce((sum, budget) => sum + getApprovedSpend(budget.expenses), 0), () => group.budgets.reduce((sum, budget) => sum + getApprovedSpend(budget.expenses), 0),
[group.budgets] [group.budgets]

View File

@@ -39,7 +39,7 @@ import { alpha, useTheme } from "@mui/material/styles";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import type { FormEvent } from "react"; import type { FormEvent } from "react";
import { startTransition, useEffect, useMemo, useState } from "react"; import { startTransition, useEffect, useMemo, useRef, useState } from "react";
import { BudgetColumn } from "@/components/dashboard/budget-column"; import { BudgetColumn } from "@/components/dashboard/budget-column";
import { ColorPickerField } from "@/components/dashboard/color-picker-field"; import { ColorPickerField } from "@/components/dashboard/color-picker-field";
@@ -343,6 +343,7 @@ export function DashboardShell({
const [desktopSection, setDesktopSection] = useState<DesktopSection>("overview"); const [desktopSection, setDesktopSection] = useState<DesktopSection>("overview");
const [selectedCurrentPeriodId, setSelectedCurrentPeriodId] = useState(currentPeriodId); const [selectedCurrentPeriodId, setSelectedCurrentPeriodId] = useState(currentPeriodId);
const [selectedMobileGroupId, setSelectedMobileGroupId] = useState(visibleGroups[0]?.id ?? ""); const [selectedMobileGroupId, setSelectedMobileGroupId] = useState(visibleGroups[0]?.id ?? "");
const [focusedBudgetId, setFocusedBudgetId] = useState<string | null>(null);
const [backupFile, setBackupFile] = useState<File | null>(null); const [backupFile, setBackupFile] = useState<File | null>(null);
const [editingPasswordUserId, setEditingPasswordUserId] = useState<string | null>(null); const [editingPasswordUserId, setEditingPasswordUserId] = useState<string | null>(null);
const [editingUserId, setEditingUserId] = useState<string | null>(null); const [editingUserId, setEditingUserId] = useState<string | null>(null);
@@ -359,6 +360,7 @@ export function DashboardShell({
const [periodForm, setPeriodForm] = useState<PeriodFormState>(getSuggestedPeriodDraft(currentPeriod)); const [periodForm, setPeriodForm] = useState<PeriodFormState>(getSuggestedPeriodDraft(currentPeriod));
const [periodEditForm, setPeriodEditForm] = useState<PeriodEditFormState>(getPeriodEditDraft(currentPeriod)); const [periodEditForm, setPeriodEditForm] = useState<PeriodEditFormState>(getPeriodEditDraft(currentPeriod));
const [pushStatus, setPushStatus] = useState<"idle" | "enabled" | "blocked" | "unsupported">("idle"); const [pushStatus, setPushStatus] = useState<"idle" | "enabled" | "blocked" | "unsupported">("idle");
const handledDeepLinkRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
setSelectedCurrentPeriodId(currentPeriodId); setSelectedCurrentPeriodId(currentPeriodId);
setPeriodForm(getSuggestedPeriodDraft(currentPeriod)); setPeriodForm(getSuggestedPeriodDraft(currentPeriod));
@@ -379,25 +381,102 @@ export function DashboardShell({
const directGroupId = searchParams.get("group"); const directGroupId = searchParams.get("group");
const budgetId = searchParams.get("budget"); const budgetId = searchParams.get("budget");
const expenseId = searchParams.get("expense"); const expenseId = searchParams.get("expense");
const deepLinkKey = [directGroupId, budgetId, expenseId].filter(Boolean).join(":");
if (!deepLinkKey) {
handledDeepLinkRef.current = null;
return;
}
if (handledDeepLinkRef.current === deepLinkKey) {
return;
}
const budgetGroup = visibleGroups.find((group) => budgetId && group.budgets.some((budget) => budget.id === budgetId));
const expenseBudget = visibleGroups
.flatMap((group) => group.budgets)
.find((budget) => expenseId && budget.expenses.some((expense) => expense.id === expenseId));
const targetGroupId = const targetGroupId =
(directGroupId && visibleGroups.some((group) => group.id === directGroupId) ? directGroupId : null) ?? (directGroupId && visibleGroups.some((group) => group.id === directGroupId) ? directGroupId : null) ??
visibleGroups.find((group) => budgetId && group.budgets.some((budget) => budget.id === budgetId))?.id ?? budgetGroup?.id ??
visibleGroups.find((group) => visibleGroups.find((group) =>
expenseId && group.budgets.some((budget) => budget.expenses.some((expense) => expense.id === expenseId)) expenseId && group.budgets.some((budget) => budget.expenses.some((expense) => expense.id === expenseId))
)?.id ?? )?.id ??
null; null;
const targetBudgetId = budgetId ?? expenseBudget?.id ?? null;
if (targetGroupId) { if (targetGroupId) {
handledDeepLinkRef.current = deepLinkKey;
setSelectedMobileGroupId(targetGroupId); setSelectedMobileGroupId(targetGroupId);
setFocusedBudgetId(targetBudgetId);
setMobileSection("overview"); setMobileSection("overview");
setDesktopSection("overview"); setDesktopSection("overview");
return; router.replace("/", { scroll: false });
} }
}, [router, searchParams, visibleGroups]);
useEffect(() => {
if (!visibleGroups.some((group) => group.id === selectedMobileGroupId)) { if (!visibleGroups.some((group) => group.id === selectedMobileGroupId)) {
setSelectedMobileGroupId(visibleGroups[0]?.id ?? ""); setSelectedMobileGroupId(visibleGroups[0]?.id ?? "");
} }
}, [searchParams, selectedMobileGroupId, visibleGroups]); }, [selectedMobileGroupId, visibleGroups]);
useEffect(() => {
let cancelled = false;
async function syncExistingPushSubscription() {
if (!("serviceWorker" in navigator) || !("PushManager" in window) || !("Notification" in window)) {
if (!cancelled) {
setPushStatus("unsupported");
}
return;
}
if (Notification.permission === "denied") {
if (!cancelled) {
setPushStatus("blocked");
}
return;
}
if (Notification.permission !== "granted") {
if (!cancelled) {
setPushStatus("idle");
}
return;
}
const registration = await navigator.serviceWorker.ready.catch(() => null);
const subscription = await registration?.pushManager.getSubscription().catch(() => null);
if (cancelled) {
return;
}
if (!subscription) {
setPushStatus("idle");
return;
}
setPushStatus("enabled");
await fetch("/api/push-subscriptions", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(subscription.toJSON())
}).catch(() => {
// Der Browser hat noch eine Subscription; ein temporärer Sync-Fehler soll den UI-Status nicht zurücksetzen.
});
}
syncExistingPushSubscription();
return () => {
cancelled = true;
};
}, []);
useEffect(() => { useEffect(() => {
if (visibleGroups.length === 0) { if (visibleGroups.length === 0) {
@@ -2448,7 +2527,10 @@ export function DashboardShell({
select select
label="AG auswählen" label="AG auswählen"
value={selectedMobileGroup?.id ?? ""} value={selectedMobileGroup?.id ?? ""}
onChange={(event) => setSelectedMobileGroupId(event.target.value)} onChange={(event) => {
setSelectedMobileGroupId(event.target.value);
setFocusedBudgetId(null);
}}
fullWidth fullWidth
> >
{visibleGroups.map((group) => ( {visibleGroups.map((group) => (
@@ -2481,6 +2563,7 @@ export function DashboardShell({
busy={busy} busy={busy}
approvalThreshold={approvalThreshold} approvalThreshold={approvalThreshold}
requiredApprovalTypes={settings.requiredApprovalTypes} requiredApprovalTypes={settings.requiredApprovalTypes}
focusBudgetId={focusedBudgetId}
onApprove={handleApprove} onApprove={handleApprove}
onMarkPaid={handleMarkPaid} onMarkPaid={handleMarkPaid}
onDocument={handleDocument} onDocument={handleDocument}
@@ -2527,6 +2610,7 @@ export function DashboardShell({
busy={busy} busy={busy}
approvalThreshold={approvalThreshold} approvalThreshold={approvalThreshold}
requiredApprovalTypes={settings.requiredApprovalTypes} requiredApprovalTypes={settings.requiredApprovalTypes}
focusBudgetId={focusedBudgetId}
onApprove={handleApprove} onApprove={handleApprove}
onMarkPaid={handleMarkPaid} onMarkPaid={handleMarkPaid}
onDocument={handleDocument} onDocument={handleDocument}

View File

@@ -8,11 +8,13 @@ export function ServiceWorkerRegistration() {
return; return;
} }
navigator.serviceWorker.register("/sw.js").catch(() => { navigator.serviceWorker
.register("/sw.js")
.then((registration) => registration.update().catch(() => null))
.catch(() => {
// Registrierung darf die App nicht blockieren. // Registrierung darf die App nicht blockieren.
}); });
}, []); }, []);
return null; return null;
} }