From df873a2be270961cd6e0c8d63c670cc3663074a9 Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 5 Aug 2026 12:54:27 +1000 Subject: [PATCH 1/4] feat: improve messaging, chat unread states, and social discovery - Add live unread badges for direct and group chats - Streamline friend/group controls and messaging entry points - Calculate daily and period study totals using Australian time - Add secure profile discoverability settings - Add Supabase support for notification delivery and unread counts --- src/app/(app)/app/profile/page.tsx | 2 + src/components/app-workspace.tsx | 13 +- src/components/friends/direct-messages.tsx | 21 ++ src/components/friends/friends-dashboard.tsx | 295 +++++++++++------- src/components/groups/group-chat.tsx | 274 ++++++++-------- src/components/groups/groups-dashboard.tsx | 202 ++++++++---- .../profile/discoverability-setting.tsx | 104 ++++++ src/components/profile/profile-dashboard.tsx | 11 + src/components/timer/timer-dashboard.tsx | 65 +++- src/lib/supabase/app-data.ts | 141 ++++++--- src/lib/supabase/group-chat-read-receipts.ts | 18 ++ src/lib/supabase/profile.ts | 3 +- src/lib/timer.ts | 102 +++++- ...ssage_notification_service_permissions.sql | 16 + ...0260730060000_group_chat_unread_counts.sql | 37 +++ ...20260731010000_profile_discoverability.sql | 104 ++++++ 16 files changed, 1040 insertions(+), 368 deletions(-) create mode 100644 src/components/profile/discoverability-setting.tsx create mode 100644 supabase/migrations/20260730050000_group_message_notification_service_permissions.sql create mode 100644 supabase/migrations/20260730060000_group_chat_unread_counts.sql create mode 100644 supabase/migrations/20260731010000_profile_discoverability.sql diff --git a/src/app/(app)/app/profile/page.tsx b/src/app/(app)/app/profile/page.tsx index 764ff75..859e34b 100644 --- a/src/app/(app)/app/profile/page.tsx +++ b/src/app/(app)/app/profile/page.tsx @@ -8,6 +8,8 @@ export default async function ProfilePage() { return ( ); diff --git a/src/components/app-workspace.tsx b/src/components/app-workspace.tsx index cdbd904..e6694ea 100644 --- a/src/components/app-workspace.tsx +++ b/src/components/app-workspace.tsx @@ -38,6 +38,12 @@ export function AppWorkspace({ : "Student"; const username = authState.mode === "authenticated" ? authState.profile.username : null; + const userId = + authState.mode === "authenticated" ? authState.profile.id : null; + const isDiscoverable = + authState.mode === "authenticated" + ? authState.profile.is_discoverable + : true; if (!activeView) { return fallback; @@ -85,7 +91,12 @@ export function AppWorkspace({ id="profile" key={`profile:${resetKeys["/app/profile"] ?? 0}`} > - + ); diff --git a/src/components/friends/direct-messages.tsx b/src/components/friends/direct-messages.tsx index 73e13e5..c0a3c4b 100644 --- a/src/components/friends/direct-messages.tsx +++ b/src/components/friends/direct-messages.tsx @@ -68,12 +68,16 @@ export function DirectMessages({ friends, initialFriendId, onConversationClosed, + onConversationOpenChange, + onUnreadCountChange, remoteClient, }: { currentUserId: string | null; friends: SocialFriend[]; initialFriendId: string | null; onConversationClosed: () => void; + onConversationOpenChange?: (open: boolean) => void; + onUnreadCountChange?: (count: number) => void; remoteClient: SupabaseClient | null; }) { const [conversations, setConversations] = useState([]); @@ -173,6 +177,21 @@ export function DirectMessages({ friendsRef.current = friends; }, [friends]); + useEffect(() => { + onConversationOpenChange?.(Boolean(selectedFriend)); + }, [onConversationOpenChange, selectedFriend]); + + useEffect(() => { + if (!hasLoadedConversationsRef.current) return; + + onUnreadCountChange?.( + conversations.reduce( + (total, conversation) => total + conversation.unreadCount, + 0, + ), + ); + }, [conversations, isLoadingConversations, onUnreadCountChange]); + const markConversationRead = useCallback( async (friendId: string) => { if (!remoteClient || !currentUserId) return; @@ -449,6 +468,7 @@ export function DirectMessages({ } function openConversation(friendId: string) { + onConversationOpenChange?.(true); setSelectedFriendId(friendId); setMessages([]); setFeedback(null); @@ -466,6 +486,7 @@ export function DirectMessages({ aria-label="Back to messages" className="mac-focus inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md text-[var(--color-text-muted)]" onClick={() => { + onConversationOpenChange?.(false); setSelectedFriendId(null); setMessages([]); setFeedback(null); diff --git a/src/components/friends/friends-dashboard.tsx b/src/components/friends/friends-dashboard.tsx index 11dbec4..1d90dc5 100644 --- a/src/components/friends/friends-dashboard.tsx +++ b/src/components/friends/friends-dashboard.tsx @@ -18,6 +18,7 @@ import { ChevronRight, CircleHelp, Clock3, + MessageCircle, Plus, Send, Users, @@ -43,6 +44,7 @@ import { } from "@/lib/client-cache"; import { addRemoteFriend, + fetchRemoteDirectMessageUnreadCount, fetchRemoteGlobalNudgeMutes, fetchRemoteSocialSnapshot, inviteRemoteFriendToGroup, @@ -60,15 +62,15 @@ import { createSupabaseBrowserClient } from "@/lib/supabase/browser"; import { NudgePill } from "@/components/social/nudge-pill"; import { useNudgeQueue } from "@/components/social/use-nudge-queue"; import { TransientToast } from "@/components/transient-toast"; -import { formatDuration, getLocalDateKey } from "@/lib/timer"; +import { addDateKeyDays, formatDuration, getLocalDateKey } from "@/lib/timer"; import { cn } from "@/lib/utils"; const emptySocialState: SocialState = { friends: [], groups: [] }; const friendTimeOptions = [ { label: "Today", value: "today" }, - { label: "Last week", value: "lastWeek" }, - { label: "Last month", value: "lastMonth" }, - { label: "Last year", value: "lastYear" }, + { label: "This week", value: "thisWeek" }, + { label: "This month", value: "thisMonth" }, + { label: "This year", value: "thisYear" }, { label: "All time", value: "allTime" }, ] as const; @@ -103,6 +105,9 @@ export function FriendsDashboard() { "friends" | "messages" | "requests" >("friends"); const [messageFriendId, setMessageFriendId] = useState(null); + const [isDirectConversationOpen, setIsDirectConversationOpen] = + useState(false); + const [directMessageUnreadCount, setDirectMessageUnreadCount] = useState(0); const [busyKey, setBusyKey] = useState(null); const [feedback, setFeedback] = useState(null); const [toastMessage, setToastMessage] = useState(null); @@ -123,6 +128,8 @@ export function FriendsDashboard() { "back" | "forward" >("forward"); const [now, setNow] = useState(() => new Date()); + const studyDateKey = getLocalDateKey(now); + const previousStudyDateKeyRef = useRef(studyDateKey); const pendingFriendRequestIdsRef = useRef(new Set()); const pendingCancelledRequestsRef = useRef(new Map()); const nudgeQueue = useNudgeQueue(Boolean(remoteClient)); @@ -170,6 +177,21 @@ export function FriendsDashboard() { } }, []); + const refreshDirectMessageUnreadCount = useCallback( + async (supabase: SupabaseClient, userId: string | null) => { + if (!userId) return; + + try { + setDirectMessageUnreadCount( + await fetchRemoteDirectMessageUnreadCount({ supabase, userId }), + ); + } catch { + // Keep the last known count when realtime or the network is unavailable. + } + }, + [], + ); + useEffect(() => { if (activeTab !== "friends") return; @@ -178,6 +200,15 @@ export function FriendsDashboard() { return () => window.clearInterval(interval); }, [activeTab]); + useEffect(() => { + if (previousStudyDateKeyRef.current === studyDateKey) return; + + previousStudyDateKeyRef.current = studyDateKey; + if (remoteClient) { + window.queueMicrotask(() => void refreshRemoteSocial(remoteClient)); + } + }, [refreshRemoteSocial, remoteClient, studyDateKey]); + useEffect(() => { if (!remoteClient) return; @@ -227,6 +258,10 @@ export function FriendsDashboard() { setFriendRequests(snapshot.friendRequests ?? []); setSuperNudges(snapshot.superNudges ?? []); setIsLoaded(true); + void refreshDirectMessageUnreadCount( + supabase, + snapshot.currentUserId, + ); return; } } catch { @@ -267,7 +302,7 @@ export function FriendsDashboard() { return () => { cancelled = true; }; - }, []); + }, [refreshDirectMessageUnreadCount]); useEffect(() => { if (!isLoaded || remoteClient) { @@ -285,10 +320,20 @@ export function FriendsDashboard() { return; } - return subscribeToRemoteAppChanges(remoteClient, () => { + return subscribeToRemoteAppChanges(remoteClient, (table) => { + if (table === "direct_messages") { + void refreshDirectMessageUnreadCount(remoteClient, currentUserId); + return; + } + void refreshRemoteSocial(remoteClient); }); - }, [refreshRemoteSocial, remoteClient]); + }, [ + currentUserId, + refreshDirectMessageUnreadCount, + refreshRemoteSocial, + remoteClient, + ]); useEffect(() => { const params = new URLSearchParams(window.location.search); @@ -324,8 +369,7 @@ export function FriendsDashboard() { socialState.friends .filter((friend) => friend.id !== selfId) .sort( - (first, second) => - Number(second.studying) - Number(first.studying), + (first, second) => Number(second.studying) - Number(first.studying), ), [selfId, socialState.friends], ); @@ -345,7 +389,6 @@ export function FriendsDashboard() { }); }, [friendList]); - const studyingCount = friendList.filter((friend) => friend.studying).length; const incomingRequests = friendRequests.filter( (request) => request.direction === "incoming", ); @@ -356,6 +399,8 @@ export function FriendsDashboard() { (request) => request.direction === "incoming" && request.status === "pending", ); + const directConversationVisible = + isDirectConversationOpen || Boolean(messageFriendId); const outgoingSuperNudges = superNudges.filter( (request) => request.direction === "outgoing" && request.status === "pending", @@ -914,6 +959,19 @@ export function FriendsDashboard() { ) : null} + +
- ) : null} -
+
+ + +
+
+ + {friendList.length ? ( + + ) : null} +
+ -
-
- - -
- -
+ + ) : null} - {feedback ? ( + {feedback && !directConversationVisible ? (

setMessageFriendId(null)} + onConversationOpenChange={setIsDirectConversationOpen} + onUnreadCountChange={setDirectMessageUnreadCount} remoteClient={remoteClient} /> ) : ( @@ -1804,17 +1869,6 @@ function GroupInviteDialog({ ); } -function SummaryStat({ label, value }: { label: string; value: string }) { - return ( -

-

{value}

-

- {label} -

-
- ); -} - function formatCompactStudyTime(totalSeconds: number) { const seconds = Math.max(0, Math.round(totalSeconds)); if (seconds < 60) return `${seconds}s`; @@ -1842,30 +1896,27 @@ function getFriendTimeSeconds( return getLiveRankingSeconds(friend, "day", now); } - if (range === "lastWeek") { + if (range === "thisWeek") { return getLiveRankingSeconds(friend, "week", now); } - if (range === "lastMonth") { + if (range === "thisMonth") { return getLiveRankingSeconds(friend, "month", now); } return getLiveRankingSeconds(friend, "allTime", now); } - const days = - range === "today" - ? 1 - : range === "lastWeek" - ? 7 - : range === "lastMonth" - ? 30 - : 365; - const start = new Date(now); - start.setHours(0, 0, 0, 0); - start.setDate(start.getDate() - (days - 1)); - const startKey = getLocalDateKey(start); const todayKey = getLocalDateKey(now); + const calendarDay = new Date(`${todayKey}T00:00:00Z`).getUTCDay(); + const startKey = + range === "today" + ? todayKey + : range === "thisWeek" + ? addDateKeyDays(todayKey, -((calendarDay + 6) % 7)) + : range === "thisMonth" + ? `${todayKey.slice(0, 7)}-01` + : `${todayKey.slice(0, 4)}-01-01`; const storedSeconds = Object.entries(dailySeconds).reduce( (total, [dateKey, seconds]) => dateKey >= startKey && dateKey <= todayKey ? total + seconds : total, @@ -1906,6 +1957,14 @@ function ProfileBadge({ ); } +function UnreadBadge({ count }: { count: number }) { + return ( + + {count > 9 ? "9+" : count} + + ); +} + function getInitials(value: string) { return value .split(/\s+/) diff --git a/src/components/groups/group-chat.tsx b/src/components/groups/group-chat.tsx index ae49f64..ee6fd68 100644 --- a/src/components/groups/group-chat.tsx +++ b/src/components/groups/group-chat.tsx @@ -84,6 +84,7 @@ export function GroupChat({ groupName, members, onBack, + onRead, remoteClient, }: { currentUserId: string | null; @@ -91,6 +92,7 @@ export function GroupChat({ groupName: string; members: SocialFriend[]; onBack: () => void; + onRead?: (groupId: string) => void; remoteClient: SupabaseClient | null; }) { const [messages, setMessages] = useState(() => @@ -226,11 +228,12 @@ export function GroupChat({ if (receipt) { setReadReceipts((current) => mergeReadReceipts(current, [receipt])); + onRead?.(groupId); } } catch { // Read receipts are best-effort and should never interrupt chat. } - }, [currentUserId, groupId, remoteClient]); + }, [currentUserId, groupId, onRead, remoteClient]); useEffect(() => { onBackRef.current = onBack; @@ -377,13 +380,34 @@ export function GroupChat({ if (!isInitialPosition && !shouldScrollToBottomRef.current) return; - messageList.scrollTo({ - behavior: isInitialPosition ? "auto" : "smooth", - top: messageList.scrollHeight, - }); - shouldScrollToBottomRef.current = false; + const positionAtBottom = (behavior: ScrollBehavior = "auto") => { + messageList.scrollTo({ + behavior, + top: messageList.scrollHeight, + }); + }; + + positionAtBottom(isInitialPosition ? "auto" : "smooth"); isNearBottomRef.current = true; setUnreadCount(0); + + if (isInitialPosition) { + let secondFrame = 0; + const firstFrame = window.requestAnimationFrame(() => { + positionAtBottom(); + secondFrame = window.requestAnimationFrame(() => { + positionAtBottom(); + shouldScrollToBottomRef.current = false; + }); + }); + + return () => { + window.cancelAnimationFrame(firstFrame); + if (secondFrame) window.cancelAnimationFrame(secondFrame); + }; + } + + shouldScrollToBottomRef.current = false; }, [isReady, messages, pendingMessages]); useEffect(() => { @@ -944,136 +968,140 @@ export function GroupChat({ isOwn ? "items-end" : "items-start", )} > -
event.preventDefault()} - onPointerCancel={cancelMessageHold} - onPointerDown={(event) => - beginMessageHold(event, message) - } - onPointerMove={moveMessageHold} - onPointerUp={cancelMessageHold} - > - {!isOwn && startsSenderGroup ? ( -

- {sender?.handle ?? "@member"} -

- ) : null} - {message.replyToId ? ( -
-

- {replySender?.handle ?? "Message"} -

-

- {replyTarget - ? replyTarget.body || "Photo" - : "Message unavailable"} -

-
- ) : null} - {message.imageUrl ? ( - - {/* Private signed URLs cannot use the static Next image loader. */} - {`Photo - - ) : message.imagePath ? ( -
- Photo unavailable -
- ) : null} -
- {message.body ? ( -

- {message.body} -

- ) : null} -
-
- {!pending ? ( +
+ {!pending ? ( + - ) : null} + + {canDelete ? ( + + ) : null} +
+ ) : null} + + ) : null} +
([]); const [requestBusyKey, setRequestBusyKey] = useState(null); const [requestFeedback, setRequestFeedback] = useState(null); + const [groupUnreadCounts, setGroupUnreadCounts] = useState< + Record + >({}); const [remoteClient, setRemoteClient] = useState(null); const [currentUserId, setCurrentUserId] = useState(null); const [now, setNow] = useState(() => new Date()); + const studyDateKey = getLocalDateKey(now); + const previousStudyDateKeyRef = useRef(studyDateKey); const nudgeQueue = useNudgeQueue(Boolean(remoteClient)); const refreshRemoteSocial = useCallback(async (supabase: SupabaseClient) => { @@ -141,12 +148,38 @@ export function GroupsDashboard() { } }, []); + const refreshGroupUnreadCounts = useCallback( + async (supabase: SupabaseClient) => { + const counts = await fetchGroupChatUnreadCounts(supabase); + setGroupUnreadCounts(counts); + }, + [], + ); + const clearGroupUnreadCount = useCallback((groupId: string) => { + setGroupUnreadCounts((current) => ({ + ...current, + [groupId]: 0, + })); + }, []); + useEffect(() => { const interval = window.setInterval(() => setNow(new Date()), 1000); return () => window.clearInterval(interval); }, []); + useEffect(() => { + if (previousStudyDateKeyRef.current === studyDateKey) return; + + previousStudyDateKeyRef.current = studyDateKey; + if (remoteClient) { + window.queueMicrotask(() => { + void refreshRemoteSocial(remoteClient); + void refreshRemoteTimer(remoteClient); + }); + } + }, [refreshRemoteSocial, refreshRemoteTimer, remoteClient, studyDateKey]); + useEffect(() => { let cancelled = false; @@ -172,9 +205,10 @@ export function GroupsDashboard() { if (!cancelled) { setRemoteClient(supabase); } - const [snapshot, timerState] = await Promise.all([ + const [snapshot, timerState, unreadCounts] = await Promise.all([ fetchRemoteSocialSnapshot(supabase), fetchRemoteTimerState(supabase), + fetchGroupChatUnreadCounts(supabase).catch(() => ({})), ]); if (!cancelled && snapshot) { @@ -182,6 +216,7 @@ export function GroupsDashboard() { setCurrentUserId(snapshot.currentUserId); setSocialState(snapshot.socialState); setGroupInvites(snapshot.groupInvites ?? []); + setGroupUnreadCounts(unreadCounts); if (timerState) { cacheRemoteTimerState(timerState); setTimerSubjects(timerState.subjects); @@ -248,11 +283,24 @@ export function GroupsDashboard() { return; } - return subscribeToRemoteAppChanges(remoteClient, () => { + return subscribeToRemoteAppChanges(remoteClient, (table) => { + if ( + table === "group_chat_messages" || + table === "group_chat_read_receipts" + ) { + void refreshGroupUnreadCounts(remoteClient); + return; + } + void refreshRemoteSocial(remoteClient); void refreshRemoteTimer(remoteClient); }); - }, [refreshRemoteSocial, refreshRemoteTimer, remoteClient]); + }, [ + refreshGroupUnreadCounts, + refreshRemoteSocial, + refreshRemoteTimer, + remoteClient, + ]); const selectedGroup = socialState.groups.find( (group) => group.id === selectedGroupId, @@ -264,10 +312,12 @@ export function GroupsDashboard() { return; } - setSelectedGroupId(groupId); - if (searchParams.get("view") === "chat") { - setGroupView("chat"); - } + window.queueMicrotask(() => { + setSelectedGroupId(groupId); + if (searchParams.get("view") === "chat") { + setGroupView("chat"); + } + }); const url = new URL(window.location.href); url.searchParams.delete("group"); url.searchParams.delete("view"); @@ -299,8 +349,10 @@ export function GroupsDashboard() { useEffect(() => { if (new URLSearchParams(window.location.search).get("tab") === "requests") { - setSelectedGroupId(null); - setActiveTab("requests"); + window.queueMicrotask(() => { + setSelectedGroupId(null); + setActiveTab("requests"); + }); } const openRequests = () => { @@ -724,6 +776,7 @@ export function GroupsDashboard() { .filter((invite) => invite.group.id === selectedGroup.id) .map((invite) => invite.user.id), ); + const selectedGroupUnreadCount = groupUnreadCounts[selectedGroup.id] ?? 0; if (groupView === "chat") { return ( @@ -734,6 +787,7 @@ export function GroupsDashboard() { key={selectedGroup.id} members={members} onBack={() => setGroupView("class")} + onRead={clearGroupUnreadCount} remoteClient={remoteClient} /> ); @@ -805,7 +859,15 @@ export function GroupsDashboard() { } type="button" > - {view.label} + + {view.id === "chat" ? ( + + ) : null} + {view.label} + {view.id === "chat" && selectedGroupUnreadCount ? ( + + ) : null} + ))}
@@ -817,8 +879,8 @@ export function GroupsDashboard() { className={cn( "mac-focus h-11 rounded px-3 text-xs font-semibold transition", rankingWindow === window.id - ? "bg-[var(--color-surface-raised)] text-[var(--color-text)]" - : "text-[var(--color-text-muted)]", + ? "border border-[var(--color-mac-yellow)] bg-[rgb(255_227_48/0.08)] text-[var(--color-mac-yellow)]" + : "border border-transparent text-[var(--color-text-muted)]", )} key={window.id} onClick={() => setRankingWindow(window.id)} @@ -1027,61 +1089,50 @@ export function GroupsDashboard() {
-

- {socialState.groups.length - ? `${socialState.groups.length} ${socialState.groups.length === 1 ? "group" : "groups"}` - : "No groups yet"} -

- {socialState.groups.length ? ( + {activeTab === "requests" ? ( - ) : null} -
- -
- - + {socialState.groups.length ? ( + ) : null} - +
{requestFeedback ? ( @@ -1111,9 +1162,20 @@ export function GroupsDashboard() { type="button" >
-

- {group.name} -

+
+

+ {group.name} +

+ {groupUnreadCounts[group.id] ? ( + + + + + ) : null} +
{activeNow} active
@@ -1428,6 +1490,14 @@ function SummaryStat({ label, value }: { label: string; value: string }) { ); } +function UnreadBadge({ count }: { count: number }) { + return ( + + {count > 9 ? "9+" : count} + + ); +} + function ProfileBadge({ friend }: { friend: SocialFriend }) { return ( (null); + + useEffect(() => { + let cancelled = false; + const supabase = createSupabaseBrowserClient(); + + void supabase + .from("profiles") + .select("is_discoverable") + .eq("id", userId) + .maybeSingle<{ is_discoverable: boolean }>() + .then(({ data }) => { + if (!cancelled && data) setEnabled(data.is_discoverable); + }); + + return () => { + cancelled = true; + }; + }, [userId]); + + async function toggleDiscoverability() { + if (saving) return; + + const previous = enabled; + const next = !previous; + setEnabled(next); + setSaving(true); + setError(null); + + try { + const supabase = createSupabaseBrowserClient(); + const { error: updateError } = await supabase.rpc( + "set_profile_discoverability", + { next_is_discoverable: next }, + ); + + if (updateError) throw updateError; + } catch { + setEnabled(previous); + setError("Could not update discoverability."); + } finally { + setSaving(false); + } + } + + return ( +
+
+ + + + + Discoverable + + Let people find you when adding friends. + + + +
+ {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/src/components/profile/profile-dashboard.tsx b/src/components/profile/profile-dashboard.tsx index 049029c..9dd29b2 100644 --- a/src/components/profile/profile-dashboard.tsx +++ b/src/components/profile/profile-dashboard.tsx @@ -1,11 +1,16 @@ import { LogOut, PencilLine, UserRound } from "lucide-react"; +import { DiscoverabilitySetting } from "@/components/profile/discoverability-setting"; import { PushNotificationSettings } from "@/components/pwa/push-notification-settings"; export function ProfileDashboard({ displayName, + initialDiscoverable, + userId, username, }: { displayName: string; + initialDiscoverable: boolean; + userId: string | null; username: string | null; }) { const handle = username ? `@${username}` : "@set_username"; @@ -41,6 +46,12 @@ export function ProfileDashboard({

Settings

+ {userId ? ( + + ) : null} window.clearInterval(interval); }, []); - const elapsedSeconds = activeSession - ? getElapsedSeconds(activeSession.startedAt, now) - : 0; const todayKey = getLocalDateKey(now); - - const todaySessions = useMemo( + const todayStart = useMemo( + () => getAustralianDateStart(todayKey), + [todayKey], + ); + const todayEnd = useMemo( + () => getAustralianDateStart(addDateKeyDays(todayKey, 1)), + [todayKey], + ); + const completedToday = useMemo( () => - sessions.filter( - (session) => getLocalDateKey(new Date(session.endedAt)) === todayKey, + sessions.reduce( + (total, session) => + total + + getIntervalOverlapSeconds( + session.startedAt, + session.endedAt, + todayStart, + todayEnd, + ), + 0, ), - [sessions, todayKey], + [sessions, todayEnd, todayStart], ); - const subjectTotals = groupSessionsBySubject(todaySessions); - const completedToday = sumCompletedSeconds(todaySessions); - const totalToday = completedToday + elapsedSeconds; + const subjectTotals = useMemo( + () => + sessions.reduce>((totals, session) => { + if (!session.subjectId) return totals; + + totals[session.subjectId] = + (totals[session.subjectId] ?? 0) + + getIntervalOverlapSeconds( + session.startedAt, + session.endedAt, + todayStart, + todayEnd, + ); + return totals; + }, {}), + [sessions, todayEnd, todayStart], + ); + const activeToday = activeSession + ? getIntervalOverlapSeconds( + activeSession.startedAt, + now, + todayStart, + todayEnd, + ) + : 0; + const totalToday = completedToday + activeToday; const sortedSessions = useMemo( () => [...sessions].sort( @@ -648,7 +683,7 @@ export function TimerDashboard() { const isActive = activeSession?.subjectId === subject.id; const subjectSeconds = (subjectTotals[subject.id] ?? 0) + - (isActive ? elapsedSeconds : 0); + (isActive ? activeToday : 0); return (
onChange("friend_requests"), ) + .on( + "postgres_changes", + { event: "*", schema: "public", table: "direct_messages" }, + () => onChange("direct_messages"), + ) .on( "postgres_changes", { event: "*", schema: "public", table: "super_nudge_requests" }, @@ -1804,6 +1816,16 @@ export function subscribeToRemoteAppChanges( { event: "*", schema: "public", table: "app_notifications" }, () => onChange("app_notifications"), ) + .on( + "postgres_changes", + { event: "*", schema: "public", table: "group_chat_messages" }, + () => onChange("group_chat_messages"), + ) + .on( + "postgres_changes", + { event: "*", schema: "public", table: "group_chat_read_receipts" }, + () => onChange("group_chat_read_receipts"), + ) .on( "postgres_changes", { event: "*", schema: "public", table: "groups" }, @@ -1846,6 +1868,24 @@ export function subscribeToRemoteAppChanges( }; } +export async function fetchRemoteDirectMessageUnreadCount({ + supabase, + userId, +}: { + supabase: SupabaseClient; + userId: string; +}) { + const { count, error } = await supabase + .from("direct_messages") + .select("id", { count: "exact", head: true }) + .eq("recipient_id", userId) + .is("read_at", null); + + if (error) throw error; + + return count ?? 0; +} + async function fetchRemoteSubjects(supabase: SupabaseClient, userId: string) { const { data: existing, error: fetchError } = await supabase .from("subjects") @@ -2006,7 +2046,8 @@ function friendFromProfile( ); const activeSession = userSessions.find((session) => session.status === "active") ?? null; - const totals = getSessionTotals(userSessions, now); + const dailyStudySeconds = getDailySessionTotals(userSessions, now); + const totals = getSessionTotals(userSessions, dailyStudySeconds, now); return { id: profile.id, @@ -2023,50 +2064,51 @@ function friendFromProfile( weekSeconds: totals.week, monthSeconds: totals.month, allTimeSeconds: totals.allTime, - dailyStudySeconds: getDailySessionTotals(userSessions, now), + dailyStudySeconds, activeStartedAt: activeSession?.started_at ?? null, activeUpdatedAt: activeSession ? now.toISOString() : null, subjectSeconds: {}, }; } -function getSessionTotals(sessions: SessionRow[], now = new Date()) { - const todayKey = now.toISOString().slice(0, 10); - const weekStart = new Date(now); - weekStart.setDate(now.getDate() - 6); - weekStart.setHours(0, 0, 0, 0); - const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); - - return sessions.reduce( - (totals, session) => { - if (session.status === "voided") { - return totals; - } - - const startedAt = new Date(session.started_at); - const seconds = session.ended_at - ? (session.duration_seconds ?? - getElapsedSeconds(session.started_at, new Date(session.ended_at))) - : getElapsedSeconds(session.started_at, now); - - totals.allTime += seconds; - - if (startedAt.toISOString().slice(0, 10) === todayKey) { - totals.day += seconds; - } - - if (startedAt >= weekStart) { - totals.week += seconds; +function getSessionTotals( + sessions: SessionRow[], + dailyStudySeconds: Record, + now = new Date(), +) { + const todayKey = getLocalDateKey(now); + const calendarDay = new Date(`${todayKey}T00:00:00Z`).getUTCDay(); + const weekStartKey = addDateKeyDays(todayKey, -((calendarDay + 6) % 7)); + const monthStartKey = `${todayKey.slice(0, 7)}-01`; + const totals = Object.entries(dailyStudySeconds).reduce( + (result, [dateKey, seconds]) => { + if (dateKey >= weekStartKey && dateKey <= todayKey) { + result.week += seconds; } - if (startedAt >= monthStart) { - totals.month += seconds; + if (dateKey >= monthStartKey && dateKey <= todayKey) { + result.month += seconds; } - return totals; + return result; + }, + { + day: dailyStudySeconds[todayKey] ?? 0, + month: 0, + week: 0, }, - { allTime: 0, day: 0, month: 0, week: 0 }, ); + + return { + ...totals, + allTime: sessions.reduce( + (total, session) => + session.status === "voided" + ? total + : total + getSessionDurationSeconds(session, now), + 0, + ), + }; } function getDailySessionTotals(sessions: SessionRow[], now = new Date()) { @@ -2075,17 +2117,36 @@ function getDailySessionTotals(sessions: SessionRow[], now = new Date()) { return totals; } - const seconds = session.ended_at - ? (session.duration_seconds ?? - getElapsedSeconds(session.started_at, new Date(session.ended_at))) - : getElapsedSeconds(session.started_at, now); - const key = getLocalDateKey(new Date(session.started_at)); + const sessionEnd = session.ended_at + ? new Date(session.ended_at) + : new Date(now); + let cursor = new Date(session.started_at); + + while (cursor < sessionEnd) { + const key = getLocalDateKey(cursor); + const nextDay = getAustralianDateStart(addDateKeyDays(key, 1)); + const segmentEnd = nextDay < sessionEnd ? nextDay : new Date(sessionEnd); + const seconds = Math.max( + 0, + Math.floor((segmentEnd.getTime() - cursor.getTime()) / 1000), + ); + + totals[key] = (totals[key] ?? 0) + seconds; + if (segmentEnd <= cursor) break; + cursor = segmentEnd; + } - totals[key] = (totals[key] ?? 0) + seconds; return totals; }, {}); } +function getSessionDurationSeconds(session: SessionRow, now: Date) { + return session.ended_at + ? (session.duration_seconds ?? + getElapsedSeconds(session.started_at, new Date(session.ended_at))) + : getElapsedSeconds(session.started_at, now); +} + function normalizeGroupIcon(icon: string | null | undefined): GroupIconKey { return GROUP_ICON_KEYS.includes(icon as GroupIconKey) ? (icon as GroupIconKey) diff --git a/src/lib/supabase/group-chat-read-receipts.ts b/src/lib/supabase/group-chat-read-receipts.ts index cd0dcc2..7839c09 100644 --- a/src/lib/supabase/group-chat-read-receipts.ts +++ b/src/lib/supabase/group-chat-read-receipts.ts @@ -12,6 +12,24 @@ type GroupChatReadReceiptRow = { user_id: string; }; +type GroupChatUnreadCountRow = { + group_id: string; + unread_count: number | string; +}; + +export async function fetchGroupChatUnreadCounts(supabase: SupabaseClient) { + const { data, error } = await supabase.rpc("list_group_chat_unread_counts"); + + if (error) throw error; + + return Object.fromEntries( + ((data ?? []) as GroupChatUnreadCountRow[]).map((row) => [ + row.group_id, + Number(row.unread_count) || 0, + ]), + ) as Record; +} + export async function fetchGroupChatReadReceipts( supabase: SupabaseClient, groupId: string, diff --git a/src/lib/supabase/profile.ts b/src/lib/supabase/profile.ts index f383494..6f06791 100644 --- a/src/lib/supabase/profile.ts +++ b/src/lib/supabase/profile.ts @@ -10,6 +10,7 @@ export type Profile = { course: string | null; study_icon: string; profile_color: string; + is_discoverable: boolean; access_status: AccessStatus; access_granted_at: string | null; created_at: string; @@ -27,7 +28,7 @@ export async function getProfileById( const { data, error } = await supabase .from("profiles") .select( - "id, display_name, username, avatar_url, course, study_icon, profile_color, access_status, access_granted_at, created_at, updated_at", + "id, display_name, username, avatar_url, course, study_icon, profile_color, is_discoverable, access_status, access_granted_at, created_at, updated_at", ) .eq("id", userId) .maybeSingle(); diff --git a/src/lib/timer.ts b/src/lib/timer.ts index 8cf78c8..8276aa8 100644 --- a/src/lib/timer.ts +++ b/src/lib/timer.ts @@ -4,6 +4,19 @@ type CompletedSession = { endedAt: string; }; +export const STUDY_TIME_ZONE = "Australia/Sydney"; + +const australianDateTimeFormatter = new Intl.DateTimeFormat("en-AU", { + day: "2-digit", + hour: "2-digit", + hourCycle: "h23", + minute: "2-digit", + month: "2-digit", + second: "2-digit", + timeZone: STUDY_TIME_ZONE, + year: "numeric", +}); + export function getElapsedSeconds(startedAt: string, now = new Date()) { const started = new Date(startedAt).getTime(); return Math.max(0, Math.floor((now.getTime() - started) / 1000)); @@ -20,11 +33,68 @@ export function formatDuration(totalSeconds: number) { } export function getLocalDateKey(date: Date) { - const year = date.getFullYear(); - const month = `${date.getMonth() + 1}`.padStart(2, "0"); - const day = `${date.getDate()}`.padStart(2, "0"); + const parts = getAustralianDateTimeParts(date); + + return `${parts.year}-${`${parts.month}`.padStart(2, "0")}-${`${parts.day}`.padStart(2, "0")}`; +} + +export function addDateKeyDays(dateKey: string, days: number) { + const { day, month, year } = parseDateKey(dateKey); + const next = new Date(Date.UTC(year, month - 1, day + days)); + + return [ + next.getUTCFullYear(), + `${next.getUTCMonth() + 1}`.padStart(2, "0"), + `${next.getUTCDate()}`.padStart(2, "0"), + ].join("-"); +} + +export function getAustralianDayRange(date = new Date()) { + const dateKey = getLocalDateKey(date); + + return { + end: getAustralianDateStart(addDateKeyDays(dateKey, 1)), + start: getAustralianDateStart(dateKey), + }; +} + +export function getAustralianDateStart(dateKey: string) { + const { day, month, year } = parseDateKey(dateKey); + const desiredWallClock = Date.UTC(year, month - 1, day); + let candidate = desiredWallClock; + + for (let attempt = 0; attempt < 3; attempt += 1) { + const parts = getAustralianDateTimeParts(new Date(candidate)); + const representedWallClock = Date.UTC( + parts.year, + parts.month - 1, + parts.day, + parts.hour, + parts.minute, + parts.second, + ); + const adjustment = desiredWallClock - representedWallClock; - return `${year}-${month}-${day}`; + candidate += adjustment; + if (!adjustment) break; + } + + return new Date(candidate); +} + +export function getIntervalOverlapSeconds( + intervalStart: Date | string, + intervalEnd: Date | string, + rangeStart: Date, + rangeEnd: Date, +) { + const start = Math.max( + new Date(intervalStart).getTime(), + rangeStart.getTime(), + ); + const end = Math.min(new Date(intervalEnd).getTime(), rangeEnd.getTime()); + + return Math.max(0, Math.floor((end - start) / 1000)); } export function getSessionSeconds(session: CompletedSession) { @@ -57,3 +127,27 @@ export function groupSessionsBySubject(sessions: CompletedSession[]) { export function isLongSession(startedAt: string, now = new Date()) { return getElapsedSeconds(startedAt, now) >= 6 * 60 * 60; } + +function getAustralianDateTimeParts(date: Date) { + const values = Object.fromEntries( + australianDateTimeFormatter + .formatToParts(date) + .filter((part) => part.type !== "literal") + .map((part) => [part.type, Number(part.value)]), + ); + + return { + day: values.day, + hour: values.hour, + minute: values.minute, + month: values.month, + second: values.second, + year: values.year, + }; +} + +function parseDateKey(dateKey: string) { + const [year, month, day] = dateKey.split("-").map(Number); + + return { day, month, year }; +} diff --git a/supabase/migrations/20260730050000_group_message_notification_service_permissions.sql b/supabase/migrations/20260730050000_group_message_notification_service_permissions.sql new file mode 100644 index 0000000..9a8839c --- /dev/null +++ b/supabase/migrations/20260730050000_group_message_notification_service_permissions.sql @@ -0,0 +1,16 @@ +-- Allow server-only notification delivery code to read recipients and +-- subscriptions, then create and mark notification records as delivered. + +grant select on table + public.groups, + public.group_members, + public.profiles, + public.user_group_notification_settings, + public.user_notification_preferences, + public.push_subscriptions +to service_role; + +grant select, insert, update on table public.app_notifications +to service_role; + +notify pgrst, 'reload schema'; diff --git a/supabase/migrations/20260730060000_group_chat_unread_counts.sql b/supabase/migrations/20260730060000_group_chat_unread_counts.sql new file mode 100644 index 0000000..2e19c1f --- /dev/null +++ b/supabase/migrations/20260730060000_group_chat_unread_counts.sql @@ -0,0 +1,37 @@ +-- Efficient unread group-message counts for the signed-in member. + +create or replace function public.list_group_chat_unread_counts() +returns table ( + group_id uuid, + unread_count bigint +) +language sql +stable +security definer +set search_path = public +as $$ + select + membership.group_id, + count(message.id)::bigint as unread_count + from public.group_members as membership + left join public.group_chat_read_receipts as receipt + on receipt.group_id = membership.group_id + and receipt.user_id = membership.user_id + left join public.group_chat_messages as message + on message.group_id = membership.group_id + and message.user_id <> membership.user_id + and message.deleted_at is null + and message.created_at > coalesce( + receipt.last_read_at, + membership.joined_at + ) + where membership.user_id = auth.uid() + and membership.status = 'active' + group by membership.group_id; +$$; + +revoke all on function public.list_group_chat_unread_counts() from public; +grant execute on function public.list_group_chat_unread_counts() +to authenticated; + +notify pgrst, 'reload schema'; diff --git a/supabase/migrations/20260731010000_profile_discoverability.sql b/supabase/migrations/20260731010000_profile_discoverability.sql new file mode 100644 index 0000000..f2ac5b6 --- /dev/null +++ b/supabase/migrations/20260731010000_profile_discoverability.sql @@ -0,0 +1,104 @@ +alter table public.profiles +add column if not exists is_discoverable boolean not null default true; + +grant select (is_discoverable) on table public.profiles to authenticated; + +create or replace function public.set_profile_discoverability( + next_is_discoverable boolean +) +returns boolean +language plpgsql +security definer +set search_path = public +as $$ +begin + if auth.uid() is null then + raise exception 'AUTH_REQUIRED'; + end if; + + update public.profiles + set is_discoverable = coalesce(next_is_discoverable, true) + where id = auth.uid(); + + if not found then + raise exception 'PROFILE_NOT_FOUND'; + end if; + + return coalesce(next_is_discoverable, true); +end; +$$; + +revoke all on function public.set_profile_discoverability(boolean) from public; +grant execute on function public.set_profile_discoverability(boolean) +to authenticated; + +create or replace function public.list_friend_candidates() +returns table ( + user_id uuid, + display_name text, + username text, + avatar_url text, + study_icon text, + profile_color text, + mutual_friend_count bigint, + request_direction text +) +language sql +security definer +set search_path = public +stable +as $$ + select + candidate.id as user_id, + candidate.display_name, + candidate.username, + candidate.avatar_url, + candidate.study_icon, + candidate.profile_color, + ( + select count(*) + from public.friendships mine + join public.friendships theirs + on theirs.friend_id = mine.friend_id + where mine.user_id = auth.uid() + and theirs.user_id = candidate.id + ) as mutual_friend_count, + pending.direction as request_direction + from public.profiles candidate + left join lateral ( + select + case + when request.sender_id = auth.uid() then 'outgoing' + else 'incoming' + end as direction + from public.friend_requests request + where request.status = 'pending' + and ( + ( + request.sender_id = auth.uid() + and request.recipient_id = candidate.id + ) + or + ( + request.sender_id = candidate.id + and request.recipient_id = auth.uid() + ) + ) + order by request.created_at desc + limit 1 + ) pending on true + where candidate.id <> auth.uid() + and coalesce(candidate.is_discoverable, true) + and not exists ( + select 1 + from public.friendships friendship + where friendship.user_id = auth.uid() + and friendship.friend_id = candidate.id + ) + order by mutual_friend_count desc, candidate.display_name, candidate.username; +$$; + +revoke all on function public.list_friend_candidates() from public; +grant execute on function public.list_friend_candidates() to authenticated; + +notify pgrst, 'reload schema'; From e9f5286a0ef0d71474699c7c696d5cbd73ca367c Mon Sep 17 00:00:00 2001 From: Steven Date: Fri, 7 Aug 2026 14:56:35 +1000 Subject: [PATCH 2/4] feat: add study reminders and notification onboarding Add atomic Supabase reminder claiming and authenticated push delivery for active study sessions. Add reminder interval controls, check-in prompts, PWA install onboarding, notification gating, navigation unread indicators, and clearer nudge limits. Document scheduler configuration and the cron secret. --- .env.example | 1 + README.md | 14 ++ src/app/api/study-reminders/run/route.ts | 78 +++++++ src/components/app-shell.tsx | 79 ++++++- src/components/app-workspace.tsx | 8 +- src/components/friends/friends-dashboard.tsx | 24 ++- src/components/groups/groups-dashboard.tsx | 23 ++- src/components/pwa/install-onboarding.tsx | 155 ++++++++++++++ .../pwa/notification-onboarding.tsx | 12 +- src/components/social/nudge-pill.tsx | 18 +- src/components/social/use-nudge-queue.ts | 53 ++++- src/components/timer/timer-dashboard.tsx | 194 ++++++++++++++++++ src/lib/push/send-web-push.ts | 10 +- src/lib/supabase/app-data.ts | 19 +- ...20260805010000_study_session_reminders.sql | 116 +++++++++++ 15 files changed, 771 insertions(+), 33 deletions(-) create mode 100644 src/app/api/study-reminders/run/route.ts create mode 100644 src/components/pwa/install-onboarding.tsx create mode 100644 supabase/migrations/20260805010000_study_session_reminders.sql diff --git a/.env.example b/.env.example index 2438289..69004ca 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,4 @@ NEXT_PUBLIC_SITE_URL=http://localhost:3000 NEXT_PUBLIC_MAC_AUTH_URL=https://auth.monashcoding.com VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= +STUDY_REMINDER_CRON_SECRET= diff --git a/README.md b/README.md index df3cc98..c8d77ca 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,20 @@ NEXT_PUBLIC_MAC_AUTH_URL=https://auth.monashcoding.com The signing JWK is server-only. Never prefix it with `NEXT_PUBLIC_`, expose it to browser code, or commit it to Git. +### Study reminder scheduler + +Study reminders are claimed atomically by `claim_due_study_reminders` and sent +by `POST /api/study-reminders/run`. After applying +`20260805010000_study_session_reminders.sql`, configure a once-per-minute +Supabase Cron or Dokploy job to call that endpoint with: + +```text +Authorization: Bearer +``` + +The scheduler secret is server-only. Do not add it to a migration or browser +environment variable. + ## Scripts ```bash diff --git a/src/app/api/study-reminders/run/route.ts b/src/app/api/study-reminders/run/route.ts new file mode 100644 index 0000000..fbacb02 --- /dev/null +++ b/src/app/api/study-reminders/run/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from "next/server"; +import { sendWebPush } from "@/lib/push/send-web-push"; +import { createSupabaseAdminClient } from "@/lib/supabase/server"; + +export const runtime = "nodejs"; + +type DueStudyReminder = { + reminder_interval_minutes: number; + session_id: string; + started_at: string; + user_id: string; +}; + +export async function POST(request: Request) { + if (!isAuthorized(request)) { + return NextResponse.json({ message: "Unauthorized." }, { status: 401 }); + } + + const admin = createSupabaseAdminClient(); + if (!admin) { + return NextResponse.json( + { message: "Supabase is not configured." }, + { status: 503 }, + ); + } + + const { data, error } = await admin.rpc("claim_due_study_reminders", { + batch_size: 100, + }); + + if (error) { + return NextResponse.json({ message: error.message }, { status: 500 }); + } + + const reminders = (data ?? []) as DueStudyReminder[]; + const deliveries = await Promise.allSettled( + reminders.map((reminder) => + sendWebPush({ + body: `Your timer has been running for ${formatElapsed(reminder.started_at)}. Tap to check in.`, + category: "study_reminder", + tag: `mac-study-reminder-${reminder.session_id}`, + title: "Still studying?", + url: "/app?study-reminder=check", + userId: reminder.user_id, + }), + ), + ); + const delivered = deliveries.reduce( + (count, delivery) => + count + (delivery.status === "fulfilled" ? delivery.value.sent : 0), + 0, + ); + + return NextResponse.json({ + claimed: reminders.length, + delivered, + }); +} + +function isAuthorized(request: Request) { + const secret = process.env.STUDY_REMINDER_CRON_SECRET; + if (!secret) return false; + + return request.headers.get("authorization") === `Bearer ${secret}`; +} + +function formatElapsed(startedAt: string) { + const elapsedMinutes = Math.max( + 1, + Math.floor((Date.now() - new Date(startedAt).getTime()) / 60_000), + ); + + if (elapsedMinutes < 60) return `${elapsedMinutes} minutes`; + + const hours = Math.floor(elapsedMinutes / 60); + const minutes = elapsedMinutes % 60; + return minutes ? `${hours} hr ${minutes} min` : `${hours} hr`; +} diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 3d9f404..7174fd4 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -27,6 +27,7 @@ import { import { createSupabaseBrowserClient } from "@/lib/supabase/browser"; import { AppWorkspace } from "@/components/app-workspace"; import { AppHeaderDetailProvider } from "@/components/app-header-detail"; +import { InstallOnboarding } from "@/components/pwa/install-onboarding"; import { NotificationOnboarding } from "@/components/pwa/notification-onboarding"; import { AppNotifications } from "@/components/social/app-notifications"; import { NudgeNotifications } from "@/components/social/nudge-notifications"; @@ -95,6 +96,9 @@ export function AppShell({ const [workspaceResetKeys, setWorkspaceResetKeys] = useState< Record >({}); + const [navUnread, setNavUnread] = useState({ friends: false, groups: false }); + const [installOnboardingComplete, setInstallOnboardingComplete] = + useState(false); const scrollContainerRef = useRef(null); const scrollPositionsRef = useRef>({}); const currentNav = @@ -125,6 +129,9 @@ export function AppShell({ }, [], ); + const handleInstallOnboardingComplete = useCallback(() => { + setInstallOnboardingComplete(true); + }, []); useEffect(() => { const frame = window.requestAnimationFrame(() => { @@ -303,6 +310,11 @@ export function AppShell({ {navItems.map((item) => ( + setNavUnread((current) => + current.friends === hasUnread + ? current + : { ...current, friends: hasUnread }, + ) + } + onGroupChatUnreadChange={(hasUnread) => + setNavUnread((current) => + current.groups === hasUnread + ? current + : { ...current, groups: hasUnread }, + ) + } resetKeys={workspaceResetKeys} />
@@ -389,6 +415,10 @@ export function AppShell({ {navItems.map((item) => { const Icon = item.icon; const active = isActive(displayPathname, item.href); + const hasUnread = + item.href === "/app/friends" + ? navUnread.friends + : item.href === "/app/groups" && navUnread.groups; return ( warmRoute(item.href)} prefetch > - + + + {hasUnread ? : null} + {"mobileLabel" in item ? item.mobileLabel : item.label} @@ -422,7 +455,14 @@ export function AppShell({ <> - + + ) : null} @@ -466,6 +506,7 @@ function LogoMark({ size = "md" }: { size?: "sm" | "md" }) { function NavLink({ href, + hasUnread, icon: Icon, isActive, label, @@ -473,6 +514,7 @@ function NavLink({ onNavigate, }: { href: string; + hasUnread: boolean; icon: React.ComponentType<{ size?: number; "aria-hidden"?: boolean }>; isActive: boolean; label: string; @@ -495,15 +537,18 @@ function NavLink({ onPointerEnter={() => onIntent(href)} prefetch > - - + + + + + {hasUnread ? : null} {label} + + Unread messages + + ); +} + function DesktopAccount({ handle, mode, diff --git a/src/components/app-workspace.tsx b/src/components/app-workspace.tsx index e6694ea..666022b 100644 --- a/src/components/app-workspace.tsx +++ b/src/components/app-workspace.tsx @@ -24,11 +24,15 @@ export function AppWorkspace({ activePathname, authState, fallback, + onDirectMessageUnreadChange, + onGroupChatUnreadChange, resetKeys, }: { activePathname: string; authState: AppAuthState; fallback: React.ReactNode; + onDirectMessageUnreadChange: (hasUnread: boolean) => void; + onGroupChatUnreadChange: (hasUnread: boolean) => void; resetKeys: Record; }) { const activeView = getWorkspaceView(activePathname); @@ -63,14 +67,14 @@ export function AppWorkspace({ id="groups" key={`groups:${resetKeys["/app/groups"] ?? 0}`} > - + - + void; +}) { const [socialState, setSocialState] = useState(emptySocialState); const [isLoaded, setIsLoaded] = useState(false); const [isAdding, setIsAdding] = useState(false); @@ -134,6 +138,10 @@ export function FriendsDashboard() { const pendingCancelledRequestsRef = useRef(new Map()); const nudgeQueue = useNudgeQueue(Boolean(remoteClient)); + useEffect(() => { + onUnreadChange?.(directMessageUnreadCount > 0); + }, [directMessageUnreadCount, onUnreadChange]); + const refreshRemoteSocial = useCallback(async (supabase: SupabaseClient) => { const snapshot = await fetchRemoteSocialSnapshot(supabase); @@ -658,9 +666,10 @@ export function FriendsDashboard() { } } - function nudgeFriend(friendId: string) { + function nudgeFriend(friendId: string, superNudgeMode: boolean) { nudgeQueue.enqueue({ key: friendId, + maxPerMinute: superNudgeMode ? 10 : 1, recipientId: friendId, }); } @@ -844,10 +853,15 @@ export function FriendsDashboard() {
nudgeFriend(selectedFriend.id)} + onClick={() => nudgeFriend(selectedFriend.id, superNudgeMode)} pendingCount={nudgeState.pending} /> diff --git a/src/components/groups/groups-dashboard.tsx b/src/components/groups/groups-dashboard.tsx index b3067a9..94b88d4 100644 --- a/src/components/groups/groups-dashboard.tsx +++ b/src/components/groups/groups-dashboard.tsx @@ -93,7 +93,11 @@ const emptySocialState: SocialState = { friends: [], groups: [] }; const TIMER_STORAGE_KEY = "mac-study-demo-state"; const fallbackStudySubjects: RemoteSubject[] = []; -export function GroupsDashboard() { +export function GroupsDashboard({ + onUnreadChange, +}: { + onUnreadChange?: (hasUnread: boolean) => void; +}) { const [socialState, setSocialState] = useState(emptySocialState); const [timerSubjects, setTimerSubjects] = useState( fallbackStudySubjects, @@ -127,6 +131,10 @@ export function GroupsDashboard() { const previousStudyDateKeyRef = useRef(studyDateKey); const nudgeQueue = useNudgeQueue(Boolean(remoteClient)); + useEffect(() => { + onUnreadChange?.(Object.values(groupUnreadCounts).some((count) => count > 0)); + }, [groupUnreadCounts, onUnreadChange]); + const refreshRemoteSocial = useCallback(async (supabase: SupabaseClient) => { const snapshot = await fetchRemoteSocialSnapshot(supabase); @@ -943,6 +951,7 @@ export function GroupsDashboard() { } group={selectedGroup} member={selectedMember} + nudgeAtLimit={selectedMemberNudgeState?.atLimit ?? false} nudgeFeedback={selectedMemberNudgeState?.feedback ?? null} now={now} onClose={() => { @@ -1639,6 +1648,7 @@ function GroupMemberDialog({ canNudge, group, member, + nudgeAtLimit, now, nudgeFeedback, onClose, @@ -1649,6 +1659,7 @@ function GroupMemberDialog({ canNudge: boolean; group: SocialGroup; member: SocialFriend; + nudgeAtLimit: boolean; now: Date; nudgeFeedback: string | null; onClose: () => void; @@ -1717,8 +1728,14 @@ function GroupMemberDialog({
diff --git a/src/components/pwa/install-onboarding.tsx b/src/components/pwa/install-onboarding.tsx new file mode 100644 index 0000000..4185427 --- /dev/null +++ b/src/components/pwa/install-onboarding.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Download, Share } from "lucide-react"; +import { AppDialog } from "@/components/app-dialog"; + +type BeforeInstallPromptEvent = Event & { + prompt: () => Promise; + userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; +}; + +export function InstallOnboarding({ + onComplete, + userId, +}: { + onComplete: () => void; + userId: string; +}) { + const [isOpen, setIsOpen] = useState(false); + const [isInstalling, setIsInstalling] = useState(false); + const [canInstall, setCanInstall] = useState(false); + const deferredPromptRef = useRef(null); + const storageKey = `mac-install-onboarding:${userId}`; + + useEffect(() => { + const standalone = + window.matchMedia("(display-mode: standalone)").matches || + ("standalone" in navigator && + (navigator as Navigator & { standalone?: boolean }).standalone === + true); + const mobile = window.matchMedia("(max-width: 63.999rem)").matches; + + if ( + standalone || + !mobile || + window.localStorage.getItem(storageKey) === "seen" + ) { + onComplete(); + return; + } + + function captureInstallPrompt(event: Event) { + event.preventDefault(); + deferredPromptRef.current = event as BeforeInstallPromptEvent; + setCanInstall(true); + } + + function handleInstalled() { + window.localStorage.setItem(storageKey, "seen"); + setIsOpen(false); + onComplete(); + } + + window.addEventListener("beforeinstallprompt", captureInstallPrompt); + window.addEventListener("appinstalled", handleInstalled); + const openingFrame = window.requestAnimationFrame(() => setIsOpen(true)); + + return () => { + window.cancelAnimationFrame(openingFrame); + window.removeEventListener("beforeinstallprompt", captureInstallPrompt); + window.removeEventListener("appinstalled", handleInstalled); + }; + }, [onComplete, storageKey]); + + function dismiss() { + window.localStorage.setItem(storageKey, "seen"); + setIsOpen(false); + onComplete(); + } + + async function install() { + const deferredPrompt = deferredPromptRef.current; + if (!deferredPrompt) return; + + setIsInstalling(true); + try { + await deferredPrompt.prompt(); + const choice = await deferredPrompt.userChoice; + if (choice.outcome === "accepted") { + window.localStorage.setItem(storageKey, "seen"); + setIsOpen(false); + onComplete(); + } + } finally { + deferredPromptRef.current = null; + setCanInstall(false); + setIsInstalling(false); + } + } + + if (!isOpen) return null; + + return ( + + {canInstall ? ( + + ) : null} + +
+ } + maxWidthClassName="max-w-sm" + onClose={dismiss} + title="Put MAC Study on your phone" + > +

+ Open MAC Study from your Home Screen for the full app experience. +

+
    +
  1. + + 1 + + Open this page in Safari on iPhone. +
  2. +
  3. + + 2 + + + Tap Share then Add to Home Screen. + +
  4. +
  5. + + 3 + + Turn on Open as Web App, then tap Add. Bang. +
  6. +
+

+ On Android, use Chrome’s three-dot menu, then Add to Home screen and + Install. +

+ + ); +} diff --git a/src/components/pwa/notification-onboarding.tsx b/src/components/pwa/notification-onboarding.tsx index dd9cc2d..f15db33 100644 --- a/src/components/pwa/notification-onboarding.tsx +++ b/src/components/pwa/notification-onboarding.tsx @@ -9,13 +9,21 @@ import { supportsPushNotifications, } from "@/lib/push/client"; -export function NotificationOnboarding({ userId }: { userId: string }) { +export function NotificationOnboarding({ + enabled = true, + userId, +}: { + enabled?: boolean; + userId: string; +}) { const [isOpen, setIsOpen] = useState(false); const [isEnabling, setIsEnabling] = useState(false); const [feedback, setFeedback] = useState(null); const storageKey = `mac-notification-onboarding:${userId}`; useEffect(() => { + if (!enabled) return; + const isMobile = window.matchMedia("(max-width: 63.999rem)").matches; const alreadySeen = window.localStorage.getItem(storageKey) === "seen"; @@ -27,7 +35,7 @@ export function NotificationOnboarding({ userId }: { userId: string }) { ) { setIsOpen(true); } - }, [storageKey]); + }, [enabled, storageKey]); function dismiss() { window.localStorage.setItem(storageKey, "seen"); diff --git a/src/components/social/nudge-pill.tsx b/src/components/social/nudge-pill.tsx index 379e3a0..a6a3cfd 100644 --- a/src/components/social/nudge-pill.tsx +++ b/src/components/social/nudge-pill.tsx @@ -3,32 +3,42 @@ import { BellRing } from "lucide-react"; export function NudgePill({ + burstCount = 0, disabled = false, disabledLabel, mode = "standard", onClick, pendingCount = 0, }: { + burstCount?: number; disabled?: boolean; disabledLabel?: string; mode?: "standard" | "super"; onClick: () => void; pendingCount?: number; }) { - const label = disabledLabel ?? (pendingCount ? "Sending..." : "Nudge"); + const label = + disabledLabel ?? + (mode === "super" && burstCount + ? `Nudge ×${burstCount}` + : pendingCount + ? "Sending..." + : "Nudge"); return ( + {activeSession ? ( + + ) : null}
@@ -782,6 +878,100 @@ export function TimerDashboard() { /> ) : null} + {isReminderDialogOpen && activeSession ? ( + void updateStudyReminder(null)} + type="button" + > + Turn reminders off + + } + maxWidthClassName="max-w-sm" + onClose={() => setIsReminderDialogOpen(false)} + title="Study reminder" + > +

+ Get a quick check-in while this session is still running. The first + reminder arrives after the interval you choose. +

+
+ {REMINDER_INTERVALS.map((interval) => { + const selected = + activeSession.reminderIntervalMinutes === interval; + + return ( + + ); + })} +
+ {pushStatus && pushStatus.state !== "enabled" ? ( +

+ Device alerts will be enabled when you choose an interval. +

+ ) : null} + {reminderFeedback ? ( +

+ {reminderFeedback} +

+ ) : null} +
+ ) : null} + + {isStudyCheckInOpen && activeSession ? ( + + + + + } + maxWidthClassName="max-w-sm" + onClose={() => setIsStudyCheckInOpen(false)} + title="Still studying?" + > +

+ Your study timer is still running. Keep it going or stop the + session now. +

+
+ ) : null} + {isSessionHistoryOpen ? ( setIsSessionHistoryOpen(false)} @@ -827,6 +1017,10 @@ export function TimerDashboard() { ); } +function formatReminderInterval(minutes: number) { + return minutes % 60 === 0 ? `${minutes / 60} hr` : `${minutes} min`; +} + const GENERAL_SESSION_SUBJECT = "__general__"; function SessionHistoryDialog({ diff --git a/src/lib/push/send-web-push.ts b/src/lib/push/send-web-push.ts index 5534b51..ad46016 100644 --- a/src/lib/push/send-web-push.ts +++ b/src/lib/push/send-web-push.ts @@ -2,7 +2,11 @@ import webpush from "web-push"; import { getOptionalWebPushEnv } from "@/lib/supabase/env"; import { createSupabaseAdminClient } from "@/lib/supabase/server"; -export type NotificationCategory = "friend" | "nudge" | "other"; +export type NotificationCategory = + | "friend" + | "nudge" + | "other" + | "study_reminder"; type PushSubscriptionRow = { auth: string; @@ -64,7 +68,9 @@ export async function sendWebPush({ ? preferences?.friend_notifications : category === "nudge" ? preferences?.nudge_notifications - : preferences?.other_notifications; + : category === "other" + ? preferences?.other_notifications + : true; if (enabled === false) { return { sent: 0, skipped: "disabled" }; diff --git a/src/lib/supabase/app-data.ts b/src/lib/supabase/app-data.ts index 69d3a18..3964496 100644 --- a/src/lib/supabase/app-data.ts +++ b/src/lib/supabase/app-data.ts @@ -45,6 +45,7 @@ export type RemoteUnitState = { export type RemoteActiveSession = { subjectId: string | null; groupId?: string | null; + reminderIntervalMinutes?: number | null; startedAt: string; }; @@ -311,6 +312,7 @@ type SessionRow = { user_id: string; subject_id: string | null; group_id: string | null; + reminder_interval_minutes: number | null; started_at: string; ended_at: string | null; status: "active" | "completed" | "needs_confirmation" | "voided"; @@ -368,7 +370,7 @@ export async function fetchRemoteTimerState( supabase .from("study_sessions") .select( - "id, user_id, subject_id, group_id, started_at, ended_at, status, source, duration_seconds", + "id, user_id, subject_id, group_id, started_at, ended_at, status, source, duration_seconds, reminder_interval_minutes", ) .eq("user_id", userId) .is("deleted_at", null) @@ -405,6 +407,7 @@ export async function fetchRemoteTimerState( ? { subjectId: activeRow.subject_id, groupId: activeRow.group_id, + reminderIntervalMinutes: activeRow.reminder_interval_minutes, startedAt: activeRow.started_at, } : null, @@ -454,6 +457,20 @@ export async function startRemoteStudySession({ } } +export async function setRemoteActiveStudyReminder({ + intervalMinutes, + supabase, +}: { + intervalMinutes: number | null; + supabase: SupabaseClient; +}) { + const { error } = await supabase.rpc("set_active_study_reminder", { + next_interval_minutes: intervalMinutes, + }); + + if (error) throw error; +} + export async function stopRemoteStudySession(supabase: SupabaseClient) { const userId = await getRemoteUserId(); diff --git a/supabase/migrations/20260805010000_study_session_reminders.sql b/supabase/migrations/20260805010000_study_session_reminders.sql new file mode 100644 index 0000000..14894ff --- /dev/null +++ b/supabase/migrations/20260805010000_study_session_reminders.sql @@ -0,0 +1,116 @@ +-- Per-session study reminders. A service-role scheduler claims due rows before +-- delivery so retries and overlapping job runs cannot duplicate notifications. + +alter table public.study_sessions + add column if not exists reminder_interval_minutes integer, + add column if not exists reminder_last_sent_at timestamptz; + +alter table public.study_sessions + drop constraint if exists study_sessions_reminder_interval_minutes_check; + +alter table public.study_sessions + add constraint study_sessions_reminder_interval_minutes_check + check ( + reminder_interval_minutes is null + or reminder_interval_minutes between 25 and 1440 + ); + +create index if not exists study_sessions_due_reminder_idx +on public.study_sessions (reminder_last_sent_at) +where reminder_interval_minutes is not null + and ended_at is null + and deleted_at is null + and status = 'active'; + +create or replace function public.set_active_study_reminder( + next_interval_minutes integer default null +) +returns boolean +language plpgsql +security definer +set search_path = public +as $$ +begin + if auth.uid() is null then + raise exception 'NOT_AUTHENTICATED'; + end if; + + if next_interval_minutes is not null + and (next_interval_minutes < 25 or next_interval_minutes > 1440) then + raise exception 'REMINDER_INTERVAL_OUT_OF_RANGE'; + end if; + + update public.study_sessions + set + reminder_interval_minutes = next_interval_minutes, + reminder_last_sent_at = case + when next_interval_minutes is null then null + else clock_timestamp() + end + where user_id = auth.uid() + and ended_at is null + and deleted_at is null + and status = 'active'; + + if not found then + raise exception 'NO_ACTIVE_STUDY_SESSION'; + end if; + + return true; +end; +$$; + +create or replace function public.claim_due_study_reminders( + batch_size integer default 100 +) +returns table ( + session_id uuid, + user_id uuid, + started_at timestamptz, + reminder_interval_minutes integer +) +language plpgsql +security definer +set search_path = public +as $$ +begin + if auth.role() <> 'service_role' then + raise exception 'SERVICE_ROLE_REQUIRED'; + end if; + + return query + with due_sessions as ( + select session.id + from public.study_sessions as session + where session.reminder_interval_minutes is not null + and session.ended_at is null + and session.deleted_at is null + and session.status = 'active' + and coalesce(session.reminder_last_sent_at, session.started_at) + <= clock_timestamp() + - make_interval(mins => session.reminder_interval_minutes) + order by coalesce(session.reminder_last_sent_at, session.started_at) + for update skip locked + limit least(greatest(coalesce(batch_size, 100), 1), 250) + ) + update public.study_sessions as session + set reminder_last_sent_at = clock_timestamp() + from due_sessions + where session.id = due_sessions.id + returning + session.id, + session.user_id, + session.started_at, + session.reminder_interval_minutes; +end; +$$; + +revoke all on function public.set_active_study_reminder(integer) from public; +grant execute on function public.set_active_study_reminder(integer) +to authenticated; + +revoke all on function public.claim_due_study_reminders(integer) from public; +grant execute on function public.claim_due_study_reminders(integer) +to service_role; + +notify pgrst, 'reload schema'; From 46e878c9edf93b7031d30458a0c96a400ef55382 Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 10 Aug 2026 13:14:03 +1000 Subject: [PATCH 3/4] test --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index c8d77ca..435266f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +TEST # MAC Study MAC Study is a mobile-first PWA for Monash Association of Coding students to From dc98dc06bfeeccd961bcfa7933e3700c9b05d171 Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 10 Aug 2026 15:13:53 +1000 Subject: [PATCH 4/4] fix(scrolling): Removed scrolling on pages that do not need it. Better UX --- .env.example | 7 + next-env.d.ts | 2 +- src/components/app-shell.tsx | 32 +- src/components/app-workspace.tsx | 7 +- src/components/friends/friends-dashboard.tsx | 476 ++++++++++--------- src/components/timer/timer-dashboard.tsx | 19 +- 6 files changed, 291 insertions(+), 252 deletions(-) diff --git a/.env.example b/.env.example index 69004ca..7cd8a7b 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,10 @@ +NODE_ENV=production +PORT=3000 + +#Nixpacks chooses node 18 but next.js needs node 20.9+. +NIXPACKS_NODE_VERSION=22 + + NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= SUPABASE_SERVICE_ROLE_KEY= diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 7174fd4..3e52c99 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -110,6 +110,9 @@ export function AppShell({ (isActive(displayPathname, "/app/groups") || isActive(displayPathname, "/app/units")); const currentTitle = isNestedDetail ? headerDetail : currentNav.title; + const isFriendsView = isActive(displayPathname, "/app/friends"); + const isShortMobileView = + displayPathname === "/app/statistics" || displayPathname === "/app/profile"; const accountName = authState.mode === "authenticated" ? authState.profile.display_name?.trim() || "Student" @@ -297,7 +300,10 @@ export function AppShell({ <>
-
+
@@ -383,8 +395,20 @@ export function AppShell({
-
-
+
+
+
nudgeFriend(selectedFriend.id, superNudgeMode)} pendingCount={nudgeState.pending} @@ -1133,9 +1129,9 @@ export function FriendsDashboard({ } return ( -
+
{!directConversationVisible ? ( - <> +
) : null} - +
) : null} - {feedback && !directConversationVisible ? ( -

- {feedback} -

- ) : null} +
+ {feedback && !directConversationVisible ? ( +

+ {feedback} +

+ ) : null} - {activeTab === "friends" ? ( -
- {friendList.length ? ( - ( -
- + + +
+ )} + resetKey="friends" + /> + ) : ( + void toggleFriendNudgeMute(friend)} + className="mac-focus inline-flex h-10 w-full items-center justify-center gap-2 rounded-md bg-[var(--color-mac-yellow)] px-4 text-sm font-semibold text-[#141414] sm:w-auto" + onClick={() => setIsAdding(true)} type="button" > - {mutedFriendIds.has(friend.id) ? ( - - ) : ( - - )} + + Add friend -
- )} - resetKey="friends" - /> - ) : ( - setIsAdding(true)} - type="button" - > - - Add friend - - } - description="Find someone by username and send a request." - icon={} - title="Build your study circle" - /> - )} -
- ) : activeTab === "messages" ? ( - setMessageFriendId(null)} - onConversationOpenChange={setIsDirectConversationOpen} - onUnreadCountChange={setDirectMessageUnreadCount} - remoteClient={remoteClient} - /> - ) : ( -
- {incomingSuperNudges.length ? ( - - {incomingSuperNudges.map((request) => { - const friend = friendList.find( - (item) => item.id === request.friendId, - ); - - return friend ? ( - void changeSuperNudge(request, "accept")} - onSecondary={() => - void changeSuperNudge(request, "decline") - } - secondaryLabel="Decline" - /> - ) : null; - })} - - ) : null} - - {incomingRequests.length ? ( - - ( - - void updateFriendRequest(request, action) - } - request={request} - /> - )} - resetKey="incoming" - /> - - ) : null} - - {outgoingRequests.length ? ( - - ( - - void updateFriendRequest(request, action) - } - request={request} - /> - )} - resetKey="outgoing" + } + description="Find someone by username and send a request." + icon={} + title="Build your study circle" /> - - ) : null} + )} +
+ ) : activeTab === "messages" ? ( + setMessageFriendId(null)} + onConversationOpenChange={setIsDirectConversationOpen} + onUnreadCountChange={setDirectMessageUnreadCount} + remoteClient={remoteClient} + /> + ) : ( +
+ {incomingSuperNudges.length ? ( + + {incomingSuperNudges.map((request) => { + const friend = friendList.find( + (item) => item.id === request.friendId, + ); + + return friend ? ( + void changeSuperNudge(request, "accept")} + onSecondary={() => + void changeSuperNudge(request, "decline") + } + secondaryLabel="Decline" + /> + ) : null; + })} + + ) : null} + + {incomingRequests.length ? ( + + ( + + void updateFriendRequest(request, action) + } + request={request} + /> + )} + resetKey="incoming" + /> + + ) : null} + + {outgoingRequests.length ? ( + + ( + + void updateFriendRequest(request, action) + } + request={request} + /> + )} + resetKey="outgoing" + /> + + ) : null} - {outgoingSuperNudges.length ? ( - - {outgoingSuperNudges.map((request) => { - const friend = friendList.find( - (item) => item.id === request.friendId, - ); - - return friend ? ( - void changeSuperNudge(request, "cancel")} - secondaryLabel="Cancel" - /> - ) : null; - })} - - ) : null} + {outgoingSuperNudges.length ? ( + + {outgoingSuperNudges.map((request) => { + const friend = friendList.find( + (item) => item.id === request.friendId, + ); + + return friend ? ( + + void changeSuperNudge(request, "cancel") + } + secondaryLabel="Cancel" + /> + ) : null; + })} + + ) : null} + + {!friendRequests.length && + !incomingSuperNudges.length && + !outgoingSuperNudges.length ? ( +
+

No friend requests

+

+ Incoming and sent requests will appear here. +

+
+ ) : null} +
+ )} - {!friendRequests.length && - !incomingSuperNudges.length && - !outgoingSuperNudges.length ? ( -
-

No friend requests

-

- Incoming and sent requests will appear here. -

-
- ) : null} - - )} + {isAdding ? ( + + void addRemoteFriendFromCandidate(friendId) + } + onClose={() => { + setIsAdding(false); + setFriendName(""); + setFriendHandle(""); + setFriendColor(PROFILE_COLORS[1]); + }} + onColorChange={setFriendColor} + onHandleChange={setFriendHandle} + onNameChange={setFriendName} + onShowRequests={() => { + setIsAdding(false); + setActiveTab("requests"); + }} + remoteCandidates={remoteClient ? availableFriends : null} + /> + ) : null} - {isAdding ? ( - - void addRemoteFriendFromCandidate(friendId) - } - onClose={() => { - setIsAdding(false); - setFriendName(""); - setFriendHandle(""); - setFriendColor(PROFILE_COLORS[1]); - }} - onColorChange={setFriendColor} - onHandleChange={setFriendHandle} - onNameChange={setFriendName} - onShowRequests={() => { - setIsAdding(false); - setActiveTab("requests"); - }} - remoteCandidates={remoteClient ? availableFriends : null} + setToastMessage(null)} /> - ) : null} - - setToastMessage(null)} - /> + ); } diff --git a/src/components/timer/timer-dashboard.tsx b/src/components/timer/timer-dashboard.tsx index 84e9a64..6a46b3d 100644 --- a/src/components/timer/timer-dashboard.tsx +++ b/src/components/timer/timer-dashboard.tsx @@ -493,7 +493,9 @@ export function TimerDashboard() { supabase: remoteClient, }); setActiveSession((current) => - current ? { ...current, reminderIntervalMinutes: intervalMinutes } : current, + current + ? { ...current, reminderIntervalMinutes: intervalMinutes } + : current, ); setPushStatus(status ?? (await getPushStatus())); setIsReminderDialogOpen(false); @@ -778,8 +780,7 @@ export function TimerDashboard() { renderItem={(subject) => { const isActive = activeSession?.subjectId === subject.id; const subjectSeconds = - (subjectTotals[subject.id] ?? 0) + - (isActive ? activeToday : 0); + (subjectTotals[subject.id] ?? 0) + (isActive ? activeToday : 0); return (

- Your study timer is still running. Keep it going or stop the - session now. + Your study timer is still running. Keep it going or stop the session + now.

) : null} @@ -1535,12 +1536,12 @@ function SubjectEditor({ ) : ( (
@@ -1553,7 +1554,7 @@ function SubjectEditor({