From 84bfc218772d1b77925138dad430f2a397637663 Mon Sep 17 00:00:00 2001 From: gibbyb Date: Wed, 17 Sep 2025 15:25:23 -0500 Subject: [PATCH] starting to add stuff for lunch time reminders --- apps/next/src/app/layout.tsx | 13 +++- apps/next/src/components/providers/index.tsx | 2 + .../components/providers/lunch-reminder.tsx | 72 +++++++++++++++++++ .../providers/notification-permission.tsx | 51 +++++++++++++ apps/next/src/middleware.ts | 23 +++--- 5 files changed, 148 insertions(+), 13 deletions(-) create mode 100644 apps/next/src/components/providers/lunch-reminder.tsx create mode 100644 apps/next/src/components/providers/notification-permission.tsx 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