feat: Add Web Push notifications and multiple UX improvements
continuous-integration/drone/push Build is failing
continuous-integration/drone/push Build is failing
- Add PWA manifest and service worker for push notifications
- Implement VAPID key generation and push subscription management
- Add push notification API endpoints (/api/push/*)
- Add push_subscriptions table to database schema
- Update notification settings UI with push support and iOS hints
- Fix translation issue showing "{ task } created" - add missing keys
- Fix mobile sidebar visibility for iOS home screen app
- Change default task filter from "all" to "open" (excludes done tasks)
- Add "Open" filter option to show only todo + inProgress tasks
This commit is contained in:
@@ -337,6 +337,14 @@ function AppContent() {
|
||||
<AppSidebar user={user} />
|
||||
<SidebarInset>
|
||||
<div className={`min-h-screen bg-background flex flex-col ${adhdEnabled ? 'adhd-mode' : ''} ${adhdEnabled && adhdSettings.reducedAnimations ? 'reduced-motion' : ''} ${adhdEnabled && adhdSettings.largerTargets ? 'larger-targets' : ''}`}>
|
||||
{/* Mobile Header with Sidebar Toggle */}
|
||||
<header className="sticky top-0 z-50 flex items-center gap-3 px-4 py-3 bg-background/80 backdrop-blur-xl border-b border-border/50 md:hidden">
|
||||
<SidebarTrigger className="h-9 w-9" />
|
||||
<div className="flex items-center gap-2">
|
||||
<img src="/favicon.png" alt="Logo" className="w-7 h-7 rounded-lg" />
|
||||
<span className="font-bold text-lg">{t('app.title')}</span>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 p-4 md:p-6 max-w-screen-2xl mx-auto w-full relative">
|
||||
<Switch>
|
||||
<Route path="/">
|
||||
|
||||
@@ -35,14 +35,14 @@ interface TasksWithCalendarProps {
|
||||
}
|
||||
|
||||
type SortOption = 'dueDate' | 'priority' | 'title' | 'status';
|
||||
type FilterOption = 'all' | 'todo' | 'inProgress' | 'done' | 'overdue' | 'planned' | 'unplanned';
|
||||
type FilterOption = 'all' | 'open' | 'todo' | 'inProgress' | 'done' | 'overdue' | 'planned' | 'unplanned';
|
||||
|
||||
export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onTaskDelete, onStartTimer, onStopTimer }: TasksWithCalendarProps) {
|
||||
const { t } = useTranslation();
|
||||
const dateLocale = useDateLocale();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortBy, setSortBy] = useState<SortOption>('dueDate');
|
||||
const [filterBy, setFilterBy] = useState<FilterOption>('all');
|
||||
const [filterBy, setFilterBy] = useState<FilterOption>('open');
|
||||
const [calendarStartDate, setCalendarStartDate] = useState(startOfToday());
|
||||
const [draggedTask, setDraggedTask] = useState<string | null>(null);
|
||||
const [hoveredDate, setHoveredDate] = useState<Date | null>(null);
|
||||
@@ -106,6 +106,8 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
return !task.dueDate && !task.startDate;
|
||||
case 'all':
|
||||
return true;
|
||||
case 'open':
|
||||
return task.status !== 'done';
|
||||
default:
|
||||
return task.status === filterBy;
|
||||
}
|
||||
@@ -173,6 +175,8 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
return tasks.filter(t => !t.dueDate && !t.startDate).length;
|
||||
case 'all':
|
||||
return tasks.length;
|
||||
case 'open':
|
||||
return tasks.filter(t => t.status !== 'done').length;
|
||||
default:
|
||||
return tasks.filter(task => task.status === filter).length;
|
||||
}
|
||||
@@ -286,6 +290,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="open">{t('taskList.filter.open')} ({getFilterCount('open')})</SelectItem>
|
||||
<SelectItem value="all">{t('taskList.filter.all')} ({getFilterCount('all')})</SelectItem>
|
||||
<SelectItem value="todo">{t('taskList.filter.todo')} ({getFilterCount('todo')})</SelectItem>
|
||||
<SelectItem value="inProgress">{t('taskList.filter.inProgress')} ({getFilterCount('inProgress')})</SelectItem>
|
||||
@@ -322,7 +327,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
{unscheduledTasks.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground" data-testid="text-no-tasks">
|
||||
{searchQuery || filterBy !== 'all'
|
||||
{searchQuery || (filterBy !== 'all' && filterBy !== 'open')
|
||||
? t('taskList.noMatchingTasks')
|
||||
: t('taskList.noUnscheduledTasks')
|
||||
}
|
||||
|
||||
@@ -1,116 +1,286 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { Task } from '@shared/schema';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { de, enUS } from 'date-fns/locale';
|
||||
|
||||
// Convert VAPID public key from base64 to Uint8Array (required for subscription)
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
// Check if push notifications are supported
|
||||
function isPushSupported(): boolean {
|
||||
return 'serviceWorker' in navigator && 'PushManager' in window;
|
||||
}
|
||||
|
||||
// Check if running as installed PWA (standalone)
|
||||
function isStandalone(): boolean {
|
||||
return window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as any).standalone === true;
|
||||
}
|
||||
|
||||
export function useNotifications({ poll = true }: { poll?: boolean } = {}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [permission, setPermission] = useState<NotificationPermission>('default');
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const { t, i18n } = useTranslation();
|
||||
const [permission, setPermission] = useState<NotificationPermission>('default');
|
||||
const [pushSupported, setPushSupported] = useState(false);
|
||||
const [pushSubscription, setPushSubscription] = useState<PushSubscription | null>(null);
|
||||
const [serviceWorkerReady, setServiceWorkerReady] = useState(false);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
// Track notified tasks to prevent duplicate alerts in same session
|
||||
const notifiedTasksRef = useRef<Set<string>>(new Set());
|
||||
// Track notified tasks to prevent duplicate alerts in same session
|
||||
const notifiedTasksRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
// Check support and register service worker on mount
|
||||
useEffect(() => {
|
||||
const initializePush = async () => {
|
||||
if (!isPushSupported()) {
|
||||
console.log('[Notifications] Push not supported');
|
||||
setPushSupported(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setPushSupported(true);
|
||||
|
||||
try {
|
||||
// Register service worker
|
||||
const registration = await navigator.serviceWorker.register('/sw.js', {
|
||||
scope: '/'
|
||||
});
|
||||
console.log('[Notifications] Service worker registered:', registration.scope);
|
||||
|
||||
// Wait for service worker to be ready
|
||||
await navigator.serviceWorker.ready;
|
||||
setServiceWorkerReady(true);
|
||||
|
||||
// Check current subscription
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
setPushSubscription(subscription);
|
||||
|
||||
// Update permission state
|
||||
if ('Notification' in window) {
|
||||
setPermission(Notification.permission);
|
||||
const isEnabled = localStorage.getItem('taskflow-notifications-enabled') === 'true';
|
||||
setEnabled(isEnabled && Notification.permission === 'granted');
|
||||
setPermission(Notification.permission);
|
||||
const isEnabled = localStorage.getItem('taskflow-push-enabled') === 'true';
|
||||
setEnabled(isEnabled && Notification.permission === 'granted' && subscription !== null);
|
||||
}
|
||||
}, []);
|
||||
} catch (error) {
|
||||
console.error('[Notifications] Service worker registration failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const requestPermission = useCallback(async () => {
|
||||
if (!('Notification' in window)) return false;
|
||||
initializePush();
|
||||
}, []);
|
||||
|
||||
// Fetch VAPID public key
|
||||
const { data: vapidData } = useQuery<{ publicKey: string }>({
|
||||
queryKey: ['/api/push/vapid-public-key'],
|
||||
enabled: serviceWorkerReady && pushSupported,
|
||||
retry: false,
|
||||
staleTime: Infinity, // VAPID key doesn't change
|
||||
});
|
||||
|
||||
// Subscribe mutation
|
||||
const subscribeMutation = useMutation({
|
||||
mutationFn: async (subscription: PushSubscription) => {
|
||||
const response = await fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ subscription: subscription.toJSON() }),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to subscribe');
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
localStorage.setItem('taskflow-push-enabled', 'true');
|
||||
setEnabled(true);
|
||||
},
|
||||
});
|
||||
|
||||
// Unsubscribe mutation
|
||||
const unsubscribeMutation = useMutation({
|
||||
mutationFn: async (endpoint: string) => {
|
||||
const response = await fetch('/api/push/unsubscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ endpoint }),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to unsubscribe');
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
localStorage.setItem('taskflow-push-enabled', 'false');
|
||||
setEnabled(false);
|
||||
setPushSubscription(null);
|
||||
},
|
||||
});
|
||||
|
||||
// Request permission and subscribe to push
|
||||
const requestPermission = useCallback(async (): Promise<boolean> => {
|
||||
if (!pushSupported || !serviceWorkerReady || !vapidData?.publicKey) {
|
||||
console.log('[Notifications] Cannot request permission - prerequisites not met');
|
||||
|
||||
// Fallback to basic notification permission
|
||||
if ('Notification' in window) {
|
||||
const result = await Notification.requestPermission();
|
||||
setPermission(result);
|
||||
return result === 'granted';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (result === 'granted') {
|
||||
setEnabled(true);
|
||||
localStorage.setItem('taskflow-notifications-enabled', 'true');
|
||||
new Notification(t('notifications.enabledTitle'), {
|
||||
body: t('notifications.enabledBody'),
|
||||
// icon: '/favicon.ico' // Chrome sometimes blocks if icon 404
|
||||
});
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
// Request notification permission
|
||||
const permissionResult = await Notification.requestPermission();
|
||||
setPermission(permissionResult);
|
||||
|
||||
if (permissionResult !== 'granted') {
|
||||
console.log('[Notifications] Permission denied');
|
||||
return false;
|
||||
}, [t]);
|
||||
}
|
||||
|
||||
const toggleEnabled = useCallback((value: boolean) => {
|
||||
if (value && permission !== 'granted') {
|
||||
requestPermission();
|
||||
} else {
|
||||
setEnabled(value);
|
||||
localStorage.setItem('taskflow-notifications-enabled', String(value));
|
||||
// Get service worker registration
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
|
||||
// Check for existing subscription
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
|
||||
if (!subscription) {
|
||||
// Create new subscription
|
||||
const applicationServerKey = urlBase64ToUint8Array(vapidData.publicKey);
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey,
|
||||
});
|
||||
console.log('[Notifications] New push subscription created');
|
||||
}
|
||||
|
||||
setPushSubscription(subscription);
|
||||
|
||||
// Send subscription to server
|
||||
await subscribeMutation.mutateAsync(subscription);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Notifications] Failed to subscribe:', error);
|
||||
return false;
|
||||
}
|
||||
}, [pushSupported, serviceWorkerReady, vapidData?.publicKey, subscribeMutation]);
|
||||
|
||||
// Toggle notifications on/off
|
||||
const toggleEnabled = useCallback(async (value: boolean) => {
|
||||
if (value) {
|
||||
// Enable notifications
|
||||
if (permission !== 'granted' || !pushSubscription) {
|
||||
await requestPermission();
|
||||
} else {
|
||||
setEnabled(true);
|
||||
localStorage.setItem('taskflow-push-enabled', 'true');
|
||||
}
|
||||
} else {
|
||||
// Disable notifications
|
||||
if (pushSubscription) {
|
||||
try {
|
||||
await pushSubscription.unsubscribe();
|
||||
await unsubscribeMutation.mutateAsync(pushSubscription.endpoint);
|
||||
} catch (error) {
|
||||
console.error('[Notifications] Failed to unsubscribe:', error);
|
||||
}
|
||||
}, [permission, requestPermission]);
|
||||
}
|
||||
setEnabled(false);
|
||||
localStorage.setItem('taskflow-push-enabled', 'false');
|
||||
}
|
||||
}, [permission, pushSubscription, requestPermission, unsubscribeMutation]);
|
||||
|
||||
// Query tasks for polling
|
||||
const { data: tasks } = useQuery<Task[]>({
|
||||
queryKey: ['/api/tasks'],
|
||||
enabled: enabled && poll // Only poll if enabled AND polling is active
|
||||
});
|
||||
// Send test notification
|
||||
const sendTestNotification = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/push/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Polling Logic
|
||||
useEffect(() => {
|
||||
if (!poll || !enabled || !tasks || permission !== 'granted') return;
|
||||
// Query tasks for fallback polling (when push isn't available or app is open)
|
||||
const { data: tasks } = useQuery<Task[]>({
|
||||
queryKey: ['/api/tasks'],
|
||||
enabled: enabled && poll && !pushSupported // Only poll if push isn't supported
|
||||
});
|
||||
|
||||
const checkTasks = () => {
|
||||
const now = new Date();
|
||||
// Fallback Polling Logic (for when push isn't available)
|
||||
useEffect(() => {
|
||||
if (!poll || !enabled || !tasks || permission !== 'granted' || pushSupported) return;
|
||||
|
||||
tasks.forEach(task => {
|
||||
if (!task.dueDate || task.status === 'done' || notifiedTasksRef.current.has(task.id)) return;
|
||||
const checkTasks = () => {
|
||||
const now = new Date();
|
||||
|
||||
const dueDate = new Date(task.dueDate);
|
||||
const diffMs = dueDate.getTime() - now.getTime();
|
||||
const diffMinutes = diffMs / (1000 * 60);
|
||||
tasks.forEach(task => {
|
||||
if (!task.dueDate || task.status === 'done' || notifiedTasksRef.current.has(task.id)) return;
|
||||
|
||||
// Alert: Upcoming (15 min before)
|
||||
if (diffMinutes > 0 && diffMinutes <= 15) {
|
||||
sendNotification(task, 'upcoming');
|
||||
notifiedTasksRef.current.add(task.id);
|
||||
}
|
||||
const dueDate = new Date(task.dueDate);
|
||||
const diffMs = dueDate.getTime() - now.getTime();
|
||||
const diffMinutes = diffMs / (1000 * 60);
|
||||
|
||||
// Alert: Just Overdue (within last 1 min to catch it once)
|
||||
// or purely check if overdue and not notified?
|
||||
// Let's stick to "Just became overdue" or "Is overdue" but protect with Set
|
||||
if (diffMinutes < 0) {
|
||||
sendNotification(task, 'overdue');
|
||||
notifiedTasksRef.current.add(task.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
// Alert: Upcoming (15 min before)
|
||||
if (diffMinutes > 0 && diffMinutes <= 15) {
|
||||
sendNotification(task, 'upcoming');
|
||||
notifiedTasksRef.current.add(task.id);
|
||||
}
|
||||
|
||||
const sendNotification = (task: Task, type: 'upcoming' | 'overdue') => {
|
||||
const title = type === 'upcoming'
|
||||
? t('notifications.upcomingTitle', { task: task.title })
|
||||
: t('notifications.overdueTitle', { task: task.title });
|
||||
|
||||
const body = type === 'upcoming'
|
||||
? t('notifications.upcomingBody', { time: formatDistanceToNow(new Date(task.dueDate!), { locale: i18n.language === 'de' ? de : enUS }) })
|
||||
: t('notifications.overdueBody');
|
||||
|
||||
new Notification(title, {
|
||||
body,
|
||||
// icon: '/favicon.ico',
|
||||
tag: `task-${task.id}-${type}` // prevent duplicate native notifications
|
||||
});
|
||||
};
|
||||
|
||||
// Check immediately and then interval
|
||||
checkTasks();
|
||||
const interval = setInterval(checkTasks, 60000); // Check every minute
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [enabled, tasks, permission, t, i18n.language]);
|
||||
|
||||
return {
|
||||
permission,
|
||||
enabled,
|
||||
requestPermission,
|
||||
toggleEnabled
|
||||
// Alert: Just Overdue
|
||||
if (diffMinutes < 0) {
|
||||
sendNotification(task, 'overdue');
|
||||
notifiedTasksRef.current.add(task.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const sendNotification = (task: Task, type: 'upcoming' | 'overdue') => {
|
||||
const title = type === 'upcoming'
|
||||
? t('notifications.upcomingTitle', { task: task.title })
|
||||
: t('notifications.overdueTitle', { task: task.title });
|
||||
|
||||
const body = type === 'upcoming'
|
||||
? t('notifications.upcomingBody', { time: formatDistanceToNow(new Date(task.dueDate!), { locale: i18n.language === 'de' ? de : enUS }) })
|
||||
: t('notifications.overdueBody');
|
||||
|
||||
new Notification(title, {
|
||||
body,
|
||||
tag: `task-${task.id}-${type}`
|
||||
});
|
||||
};
|
||||
|
||||
checkTasks();
|
||||
const interval = setInterval(checkTasks, 60000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [enabled, tasks, permission, t, i18n.language, poll, pushSupported]);
|
||||
|
||||
return {
|
||||
permission,
|
||||
enabled,
|
||||
pushSupported,
|
||||
pushSubscription,
|
||||
isStandalone: isStandalone(),
|
||||
serviceWorkerReady,
|
||||
requestPermission,
|
||||
toggleEnabled,
|
||||
sendTestNotification,
|
||||
isLoading: subscribeMutation.isPending || unsubscribeMutation.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungen",
|
||||
"enableBrowser": "Browser-Benachrichtigungen aktivieren",
|
||||
"enablePush": "Push-Benachrichtigungen",
|
||||
"description": "Erhalten Sie Warnungen für bevorstehende und überfällige Aufgaben.",
|
||||
"pushDescription": "Erhalten Sie Benachrichtigungen auch wenn die App geschlossen ist.",
|
||||
"enabledTitle": "Benachrichtigungen aktiviert",
|
||||
"enabledBody": "Sie erhalten nun Warnungen für Ihre Aufgaben.",
|
||||
"upcomingTitle": "Demnächst: {{task}}",
|
||||
@@ -20,7 +22,16 @@
|
||||
"request": "Zugriff anfordern"
|
||||
},
|
||||
"recent": "Aktuelle Warnungen",
|
||||
"empty": "Alles erledigt! Keine urgierten Warnungen."
|
||||
"empty": "Alles erledigt! Keine urgierten Warnungen.",
|
||||
"pushSupported": "Push unterstützt",
|
||||
"installedApp": "Installierte App",
|
||||
"permissionDenied": "Berechtigung vom Browser verweigert. Bitte setzen Sie die Website-Berechtigungen zurück.",
|
||||
"iosHintTitle": "Zum Home-Bildschirm hinzufügen",
|
||||
"iosHintDesc": "Um Push-Benachrichtigungen auf iOS zu erhalten, tippen Sie auf die Teilen-Schaltfläche und wählen Sie \"Zum Home-Bildschirm\", dann öffnen Sie die App von dort.",
|
||||
"sendTest": "Test-Benachrichtigung senden",
|
||||
"testSent": "Test gesendet",
|
||||
"testSentDesc": "Überprüfen Sie Ihr Gerät auf eine Benachrichtigung.",
|
||||
"testFailed": "Test-Benachrichtigung konnte nicht gesendet werden."
|
||||
},
|
||||
"unscheduled": {
|
||||
"title": "Ungeplante Aufgaben",
|
||||
@@ -51,11 +62,14 @@
|
||||
"listHeader": "Aufgabenliste",
|
||||
"calendarTitle": "Nächste {{count}} Tage",
|
||||
"filter": {
|
||||
"open": "Offen",
|
||||
"all": "Alle",
|
||||
"todo": "Zu erledigen",
|
||||
"inProgress": "In Bearbeitung",
|
||||
"done": "Erledigt",
|
||||
"overdue": "Überfällig"
|
||||
"overdue": "Überfällig",
|
||||
"planned": "Geplant",
|
||||
"unplanned": "Ungeplant"
|
||||
},
|
||||
"sort": {
|
||||
"dueDate": "Fälligkeitsdatum",
|
||||
@@ -626,6 +640,14 @@
|
||||
"loading": "Laden...",
|
||||
"error": "Ein Fehler ist aufgetreten"
|
||||
},
|
||||
"task": {
|
||||
"created": "Aufgabe erstellt",
|
||||
"createdDescription": "\"{{title}}\" wurde hinzugefügt."
|
||||
},
|
||||
"error": {
|
||||
"createTask": "Fehler",
|
||||
"createTaskDescription": "Aufgabe konnte nicht erstellt werden. Bitte versuchen Sie es erneut."
|
||||
},
|
||||
"deleteConfirmation": {
|
||||
"title": "Aufgabe löschen",
|
||||
"description": "Möchten Sie diese Aufgabe wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"enableBrowser": "Enable Browser Notifications",
|
||||
"enablePush": "Push Notifications",
|
||||
"description": "Get alerted about upcoming and overdue tasks.",
|
||||
"pushDescription": "Receive notifications even when the app is closed.",
|
||||
"enabledTitle": "Notifications Enabled",
|
||||
"enabledBody": "You will now receive alerts for your tasks.",
|
||||
"upcomingTitle": "Upcoming: {{task}}",
|
||||
@@ -20,7 +22,16 @@
|
||||
"request": "Request Permission"
|
||||
},
|
||||
"recent": "Recent Alerts",
|
||||
"empty": "All caught up! No urgent alerts."
|
||||
"empty": "All caught up! No urgent alerts.",
|
||||
"pushSupported": "Push Supported",
|
||||
"installedApp": "Installed App",
|
||||
"permissionDenied": "Permission denied by browser. Please reset site permissions.",
|
||||
"iosHintTitle": "Add to Home Screen",
|
||||
"iosHintDesc": "To receive push notifications on iOS, tap the Share button and select \"Add to Home Screen\", then open the app from there.",
|
||||
"sendTest": "Send Test Notification",
|
||||
"testSent": "Test Sent",
|
||||
"testSentDesc": "Check for a notification on your device.",
|
||||
"testFailed": "Failed to send test notification."
|
||||
},
|
||||
"unscheduled": {
|
||||
"title": "Unscheduled Tasks",
|
||||
@@ -51,6 +62,7 @@
|
||||
"listHeader": "Task List",
|
||||
"calendarTitle": "Next {{count}} Days",
|
||||
"filter": {
|
||||
"open": "Open",
|
||||
"all": "All",
|
||||
"todo": "To Do",
|
||||
"inProgress": "In Progress",
|
||||
@@ -607,6 +619,14 @@
|
||||
"loading": "Loading...",
|
||||
"error": "An error occurred"
|
||||
},
|
||||
"task": {
|
||||
"created": "Task Created",
|
||||
"createdDescription": "\"{{title}}\" has been added."
|
||||
},
|
||||
"error": {
|
||||
"createTask": "Error",
|
||||
"createTaskDescription": "Failed to create task. Please try again."
|
||||
},
|
||||
"deleteConfirmation": {
|
||||
"title": "Delete Task",
|
||||
"description": "Are you sure you want to delete this task? This action cannot be undone.",
|
||||
|
||||
@@ -22,32 +22,113 @@ import { DataExportCard } from '@/components/user/DataExportCard';
|
||||
|
||||
const NotificationSettings = () => {
|
||||
const { t } = useTranslation();
|
||||
const { enabled, toggleEnabled, permission, requestPermission } = useNotifications({ poll: false });
|
||||
const { toast } = useToast();
|
||||
const {
|
||||
enabled,
|
||||
toggleEnabled,
|
||||
permission,
|
||||
requestPermission,
|
||||
pushSupported,
|
||||
isStandalone,
|
||||
sendTestNotification,
|
||||
isLoading
|
||||
} = useNotifications({ poll: false });
|
||||
|
||||
const handleToggle = (checked: boolean) => {
|
||||
const handleToggle = async (checked: boolean) => {
|
||||
if (checked && permission !== 'granted') {
|
||||
requestPermission();
|
||||
const success = await requestPermission();
|
||||
if (success) {
|
||||
toast({
|
||||
title: t('notifications.enabledTitle'),
|
||||
description: t('notifications.enabledBody'),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
toggleEnabled(checked);
|
||||
await toggleEnabled(checked);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestNotification = async () => {
|
||||
const success = await sendTestNotification();
|
||||
if (success) {
|
||||
toast({
|
||||
title: t('notifications.testSent', 'Test Sent'),
|
||||
description: t('notifications.testSentDesc', 'Check for a notification on your device.'),
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t('common.error'),
|
||||
description: t('notifications.testFailed', 'Failed to send test notification.'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-medium">{t('notifications.enableBrowser')}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{permission === 'denied' ?
|
||||
<span className="text-destructive">Permission denied by browser. Please reset site permissions.</span> :
|
||||
t('notifications.description')
|
||||
}
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
{/* Push Support Status */}
|
||||
{pushSupported && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
|
||||
{t('notifications.pushSupported', 'Push Supported')}
|
||||
</span>
|
||||
{isStandalone && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-400">
|
||||
{t('notifications.installedApp', 'Installed App')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* iOS PWA Hint */}
|
||||
{!pushSupported && /iPhone|iPad|iPod/.test(navigator.userAgent) && !isStandalone && (
|
||||
<div className="p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
<strong>{t('notifications.iosHintTitle', 'Add to Home Screen')}</strong><br />
|
||||
{t('notifications.iosHintDesc', 'To receive push notifications on iOS, tap the Share button and select "Add to Home Screen", then open the app from there.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-medium">
|
||||
{pushSupported
|
||||
? t('notifications.enablePush', 'Push Notifications')
|
||||
: t('notifications.enableBrowser')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{permission === 'denied' ? (
|
||||
<span className="text-destructive">
|
||||
{t('notifications.permissionDenied', 'Permission denied by browser. Please reset site permissions.')}
|
||||
</span>
|
||||
) : pushSupported ? (
|
||||
t('notifications.pushDescription', 'Receive notifications even when the app is closed.')
|
||||
) : (
|
||||
t('notifications.description')
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={permission === 'denied' || isLoading}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={permission === 'denied'}
|
||||
/>
|
||||
|
||||
{/* Test Button */}
|
||||
{enabled && pushSupported && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleTestNotification}
|
||||
disabled={isLoading}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{t('notifications.sendTest', 'Send Test Notification')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user