starting to add stuff for lunch time reminders

This commit is contained in:
2025-09-17 15:25:23 -05:00
parent b737fa22c3
commit 84bfc21877
5 changed files with 148 additions and 13 deletions

View File

@@ -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 (
<ConvexAuthNextjsServerProvider>
<PlausibleProvider
@@ -47,7 +49,11 @@ export default function RootLayout({
<TVModeProvider>
<Header />
{children}
<Toaster />
<NotificationsPermission />
<LunchReminder
lunchTime='15:24'
/>
<Toaster richColors />
</TVModeProvider>
</ConvexClientProvider>
</ThemeProvider>
@@ -57,3 +63,4 @@ export default function RootLayout({
</ConvexAuthNextjsServerProvider>
);
}
export default RootLayout;

View File

@@ -1,4 +1,6 @@
export { ConvexClientProvider } from './ConvexClientProvider';
export { LunchReminder } from './lunch-reminder';
export { NotificationsPermission } from './notification-permission';
export {
ThemeProvider,
ThemeToggle,

View File

@@ -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<number | null>(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;
};

View File

@@ -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;
};

View File

@@ -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