diff --git a/apps/next/src/app/layout.tsx b/apps/next/src/app/layout.tsx
index 54ce440..927925c 100644
--- a/apps/next/src/app/layout.tsx
+++ b/apps/next/src/app/layout.tsx
@@ -4,6 +4,8 @@ import '@/styles/globals.css';
import { ConvexAuthNextjsServerProvider } from '@convex-dev/auth/nextjs/server';
import {
ConvexClientProvider,
+ LunchReminder,
+ NotificationsPermission,
ThemeProvider,
TVModeProvider,
} from '@/components/providers';
@@ -22,11 +24,11 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = generateMetadata();
-export default function RootLayout({
+const RootLayout = async ({
children,
}: Readonly<{
children: React.ReactNode;
-}>) {
+}>) => {
return (
{children}
-
+
+
+
@@ -57,3 +63,4 @@ export default function RootLayout({
);
}
+export default RootLayout;
diff --git a/apps/next/src/components/providers/index.tsx b/apps/next/src/components/providers/index.tsx
index cd9677c..c4905f6 100644
--- a/apps/next/src/components/providers/index.tsx
+++ b/apps/next/src/components/providers/index.tsx
@@ -1,4 +1,6 @@
export { ConvexClientProvider } from './ConvexClientProvider';
+export { LunchReminder } from './lunch-reminder';
+export { NotificationsPermission } from './notification-permission';
export {
ThemeProvider,
ThemeToggle,
diff --git a/apps/next/src/components/providers/lunch-reminder.tsx b/apps/next/src/components/providers/lunch-reminder.tsx
new file mode 100644
index 0000000..2a96b0d
--- /dev/null
+++ b/apps/next/src/components/providers/lunch-reminder.tsx
@@ -0,0 +1,72 @@
+'use client';
+
+import { useEffect, useRef } from 'react';
+import { toast } from 'sonner';
+import { useMutation } from 'convex/react';
+import { api } from '~/convex/_generated/api';
+
+type Props = {
+ lunchTime: string;
+ enabled?: boolean;
+};
+
+const nextOccurrenceMs = (hhmm: string, from = new Date()): number => {
+ const [hStr, mStr] = hhmm.split(':');
+ const target = new Date(from);
+ target.setHours(Number(hStr), Number(mStr), 0, 0);
+ if (target <= from) target.setDate(target.getDate() + 1);
+ return target.getTime() - from.getTime();
+};
+
+export const LunchReminder = ({ lunchTime, enabled = true }: Props) => {
+ const setStatus = useMutation(api.statuses.create);
+ const timeoutRef = useRef(null);
+ console.log('LunchReminder is running!')
+
+ useEffect(() => {
+ if (!enabled) return;
+ const schedule = () => {
+ const ms = nextOccurrenceMs(lunchTime);
+ console.log('Ms = ', ms)
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+
+ timeoutRef.current = window.setTimeout(() => {
+ void (async () => {
+ try {
+ if (
+ 'Notification' in window &&
+ Notification.permission === 'granted'
+ ) {
+ new Notification('Lunch time?', {
+ body: 'Update your status to "At lunch"?',
+ tag: 'tech-tracker-lunch',
+ });
+ }
+ } catch {}
+
+ toast('Lunch time?', {
+ description:
+ 'Would you like to set your status to "At lunch"?',
+ action: {
+ label: 'Set to lunch',
+ onClick: () => void setStatus({ message: 'At lunch' }),
+ },
+ cancel: {
+ label: 'Not now',
+ onClick: () => console.log('User declined lunch suggestion'),
+ },
+ id: 'lunch-reminder',
+ });
+ schedule();
+ })();
+ }, ms);
+ };
+
+ schedule();
+ return () => {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ };
+ }, [enabled, lunchTime, setStatus]);
+
+ return null;
+};
diff --git a/apps/next/src/components/providers/notification-permission.tsx b/apps/next/src/components/providers/notification-permission.tsx
new file mode 100644
index 0000000..539f58c
--- /dev/null
+++ b/apps/next/src/components/providers/notification-permission.tsx
@@ -0,0 +1,51 @@
+'use client';
+
+import { useEffect } from 'react';
+import { toast } from 'sonner';
+
+const STORAGE_KEY = 'notif.permission.prompted.v1';
+
+export const NotificationsPermission = () => {
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+ if (!('Notification' in window)) return;
+
+ // Only ask once; tweak logic to your taste.
+ const prompted = localStorage.getItem(STORAGE_KEY) === '1';
+ if (prompted) return;
+
+ if (Notification.permission === 'default') {
+ toast('Enable system notifications?', {
+ id: 'enable-notifications',
+ description:
+ 'Get a native notification at lunch time (optional).',
+ action: {
+ label: 'Enable',
+ onClick: () => {
+ // Must be called during the click handler.
+ const p = Notification.requestPermission();
+ p.then((perm) => {
+ localStorage.setItem(STORAGE_KEY, '1');
+ if (perm === 'granted') {
+ toast.success('System notifications enabled');
+ } else {
+ toast('Okay, we will use in-app toasts instead.');
+ }
+ }).catch(() => {
+ localStorage.setItem(STORAGE_KEY, '1');
+ toast('Failed to request notification permission.');
+ });
+ },
+ },
+ cancel: {
+ label: 'Not now',
+ onClick: () => {
+ localStorage.setItem(STORAGE_KEY, '1');
+ },
+ },
+ });
+ }
+ }, []);
+
+ return null;
+};
diff --git a/apps/next/src/middleware.ts b/apps/next/src/middleware.ts
index fce771e..f8588f4 100644
--- a/apps/next/src/middleware.ts
+++ b/apps/next/src/middleware.ts
@@ -8,16 +8,19 @@ import { banSuspiciousIPs } from '@/lib/middleware/ban-suspicious-ips';
const isSignInPage = createRouteMatcher(['/signin']);
const isProtectedRoute = createRouteMatcher(['/', '/profile']);
-export default convexAuthNextjsMiddleware(async (request, { convexAuth }) => {
- const banResponse = banSuspiciousIPs(request);
- if (banResponse) return banResponse;
- if (isSignInPage(request) && (await convexAuth.isAuthenticated())) {
- return nextjsMiddlewareRedirect(request, '/');
- }
- if (isProtectedRoute(request) && !(await convexAuth.isAuthenticated())) {
- return nextjsMiddlewareRedirect(request, '/signin');
- }
-});
+export default convexAuthNextjsMiddleware(
+ async (request, { convexAuth }) => {
+ const banResponse = banSuspiciousIPs(request);
+ if (banResponse) return banResponse;
+ if (isSignInPage(request) && (await convexAuth.isAuthenticated())) {
+ return nextjsMiddlewareRedirect(request, '/');
+ }
+ if (isProtectedRoute(request) && !(await convexAuth.isAuthenticated())) {
+ return nextjsMiddlewareRedirect(request, '/signin');
+ }
+ },
+ { cookieConfig: { maxAge: 60 * 60 * 24 * 30 }},
+);
export const config = {
// The following matcher runs middleware on all routes