From ccdda9ba079b940b96cd2a0e82abc80cbf5e286e Mon Sep 17 00:00:00 2001 From: Dylan Anctil <134968796+danctila@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:26:21 -0400 Subject: [PATCH 01/13] mobile notifs first pass --- clients/mobile/app/_layout.tsx | 2 + clients/mobile/app/notifications.tsx | 81 +++++++++ clients/mobile/app/task/[id].tsx | 125 ++++++++++++++ .../notifications/notification-item.tsx | 155 ++++++++++++++++++ .../mobile/components/tasks/tasks-header.tsx | 16 +- 5 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 clients/mobile/app/notifications.tsx create mode 100644 clients/mobile/app/task/[id].tsx create mode 100644 clients/mobile/components/notifications/notification-item.tsx diff --git a/clients/mobile/app/_layout.tsx b/clients/mobile/app/_layout.tsx index d45635bf4..b470a60d7 100644 --- a/clients/mobile/app/_layout.tsx +++ b/clients/mobile/app/_layout.tsx @@ -41,6 +41,8 @@ function AppLayout() { + + | null>(null); + + useEffect(() => { + if (notifications.length > 0 && initialUnreadIds.current === null) { + initialUnreadIds.current = new Set( + notifications.filter((n) => !n.read_at).map((n) => n.id), + ); + markAllRead(); + } + }, [notifications, markAllRead]); + + return ( + + {/* Header */} + + router.back()} hitSlop={8}> + + + + Notifications + + + + + + {/* Content */} + {isLoading ? ( + + + + ) : ( + + data={notifications} + keyExtractor={(item) => item.id} + renderItem={({ item }) => ( + + )} + showsVerticalScrollIndicator={false} + ListEmptyComponent={ + + + No notifications + + + } + /> + )} + + ); +} diff --git a/clients/mobile/app/task/[id].tsx b/clients/mobile/app/task/[id].tsx new file mode 100644 index 000000000..c58629f3c --- /dev/null +++ b/clients/mobile/app/task/[id].tsx @@ -0,0 +1,125 @@ +import Feather from "@expo/vector-icons/Feather"; +import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useLocalSearchParams, useRouter } from "expo-router"; + +import { useGetRequest } from "@shared/api/requests"; +import { PriorityTag } from "@/components/tasks/priority-tag"; + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +function DetailRow({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +export default function TaskDetailScreen() { + const { id } = useLocalSearchParams<{ id: string }>(); + const router = useRouter(); + const { data: task, isLoading, isError } = useGetRequest(id); + + return ( + + {/* Header */} + + router.back()} hitSlop={8}> + + + + Task Detail + + + + + + {isLoading && ( + + + + )} + + {isError && ( + + + Could not load task. + + + )} + + {task && ( + + {/* Name + priority */} + + + + {task.name} + + {task.request_category && ( + + + + {task.request_category} + + + )} + + + {/* Details */} + + + + {task.department && ( + + )} + {task.estimated_completion_time != null && ( + + )} + {task.scheduled_time && ( + + )} + + + + {/* Description */} + {task.description && ( + + Description + + {task.description} + + + )} + + {/* Notes */} + {task.notes && ( + + Notes + + {task.notes} + + + )} + + )} + + ); +} diff --git a/clients/mobile/components/notifications/notification-item.tsx b/clients/mobile/components/notifications/notification-item.tsx new file mode 100644 index 000000000..e8b169a60 --- /dev/null +++ b/clients/mobile/components/notifications/notification-item.tsx @@ -0,0 +1,155 @@ +import Feather from "@expo/vector-icons/Feather"; +import { Pressable, Text, View } from "react-native"; +import { useRouter } from "expo-router"; + +import type { Notification } from "@shared/types/notifications"; +import { NotificationType } from "@shared/types/notifications"; + +function formatTimestamp(iso: string, showUnreadDot: boolean): string { + const date = new Date(iso); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60_000); + const diffHours = Math.floor(diffMs / 3_600_000); + + if (showUnreadDot) { + if (diffMins < 60) return `Added ${diffMins}m ago`; + if (diffHours < 24) return `Added ${diffHours}h ago`; + } + + return date.toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + }); +} + +function formatDueTime(dueAt?: unknown): string | null { + if (typeof dueAt !== "string") return null; + const due = new Date(dueAt); + const now = new Date(); + const diffMs = due.getTime() - now.getTime(); + if (diffMs <= 0) return "Overdue"; + const diffHours = Math.floor(diffMs / 3_600_000); + const diffMins = Math.floor((diffMs % 3_600_000) / 60_000); + if (diffHours >= 1) return `Due in ${diffHours} hour${diffHours > 1 ? "s" : ""}`; + return `Due in ${diffMins}m`; +} + +function NewTasksBadge() { + return ( + + + new tasks + + ); +} + +function UrgentTaskBadge() { + return ( + + + urgent task + + ); +} + +type NotificationItemProps = { + notification: Notification; + showUnreadDot: boolean; +}; + +export function NotificationItem({ notification, showUnreadDot }: NotificationItemProps) { + const router = useRouter(); + const isUrgent = notification.type === NotificationType.HighPriorityTask; + + const taskId = + notification.data && typeof notification.data === "object" + ? (notification.data as Record).task_id + : undefined; + + function handlePress() { + if (typeof taskId === "string") { + router.push(`/task/${taskId}`); + } else { + router.push("/(tabs)/tasks"); + } + } + + if (isUrgent) { + const dueTime = formatDueTime(notification.data?.due_at); + + return ( + + {/* Title */} + + {showUnreadDot && } + + {notification.title} + + + + {/* Due time */} + {dueTime !== null && ( + + + {dueTime} + + )} + + {/* Body */} + + An + + + {" "}for your department needs your attention. Claim it now! + + + + {/* CTA */} + + Claim Now! + + + ); + } + + // Split "New Tasks Assigned for Monday" → bold prefix + regular suffix + const forIndex = notification.title.indexOf(" for "); + const titleBold = forIndex >= 0 ? notification.title.slice(0, forIndex) : notification.title; + const titleSuffix = forIndex >= 0 ? notification.title.slice(forIndex) : ""; + const timestamp = formatTimestamp(notification.created_at, showUnreadDot); + + return ( + + + {/* Title */} + + {showUnreadDot && } + + {titleBold} + {titleSuffix} + + + + {/* Timestamp */} + {timestamp} + + {/* Body */} + + Your + + + have been assigned for the day + + + + + ); +} diff --git a/clients/mobile/components/tasks/tasks-header.tsx b/clients/mobile/components/tasks/tasks-header.tsx index d21ed55fe..36875b5e9 100644 --- a/clients/mobile/components/tasks/tasks-header.tsx +++ b/clients/mobile/components/tasks/tasks-header.tsx @@ -1,7 +1,14 @@ import Feather from "@expo/vector-icons/Feather"; import { Pressable, Text, View } from "react-native"; +import { useRouter } from "expo-router"; + +import { useGetNotifications } from "@shared/api/notifications"; export function TasksHeader() { + const router = useRouter(); + const { data: notifications } = useGetNotifications(); + const unreadCount = notifications?.filter((n) => !n.read_at).length ?? 0; + return ( Tasks @@ -12,8 +19,15 @@ export function TasksHeader() { {}}> - {}}> + router.push("/notifications")} className="relative"> + {unreadCount > 0 && ( + + + {unreadCount > 9 ? "9+" : unreadCount} + + + )} From 7858a2d8815e335bb0803b1420841acd0c283888 Mon Sep 17 00:00:00 2001 From: Dylan Anctil <134968796+danctila@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:32:07 -0400 Subject: [PATCH 02/13] refactor --- clients/mobile/app/notifications.tsx | 25 +++---------------- clients/mobile/app/task/[id].tsx | 24 ++++++------------ .../notifications/notification-item.tsx | 15 ++++++----- .../mobile/components/ui/screen-header.tsx | 24 ++++++++++++++++++ 4 files changed, 44 insertions(+), 44 deletions(-) create mode 100644 clients/mobile/components/ui/screen-header.tsx diff --git a/clients/mobile/app/notifications.tsx b/clients/mobile/app/notifications.tsx index 67baefaa8..95cab380b 100644 --- a/clients/mobile/app/notifications.tsx +++ b/clients/mobile/app/notifications.tsx @@ -1,14 +1,6 @@ import { useEffect, useRef } from "react"; -import { - ActivityIndicator, - FlatList, - Pressable, - Text, - View, -} from "react-native"; +import { ActivityIndicator, FlatList, Text, View } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; -import { useRouter } from "expo-router"; -import Feather from "@expo/vector-icons/Feather"; import { useGetNotifications, @@ -16,9 +8,9 @@ import { } from "@shared/api/notifications"; import type { Notification } from "@shared/types/notifications"; import { NotificationItem } from "@/components/notifications/notification-item"; +import { ScreenHeader } from "@/components/ui/screen-header"; export default function NotificationsScreen() { - const router = useRouter(); const { data: notifications = [], isLoading } = useGetNotifications(); const { mutate: markAllRead } = useMarkAllNotificationsRead(); @@ -37,19 +29,8 @@ export default function NotificationsScreen() { return ( - {/* Header */} - - router.back()} hitSlop={8}> - - - - Notifications - - - - + - {/* Content */} {isLoading ? ( diff --git a/clients/mobile/app/task/[id].tsx b/clients/mobile/app/task/[id].tsx index c58629f3c..6313af287 100644 --- a/clients/mobile/app/task/[id].tsx +++ b/clients/mobile/app/task/[id].tsx @@ -1,10 +1,13 @@ import Feather from "@expo/vector-icons/Feather"; -import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native"; +import { ActivityIndicator, ScrollView, Text, View } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; -import { useLocalSearchParams, useRouter } from "expo-router"; +import { useLocalSearchParams } from "expo-router"; import { useGetRequest } from "@shared/api/requests"; import { PriorityTag } from "@/components/tasks/priority-tag"; +import { ScreenHeader } from "@/components/ui/screen-header"; + +const TEXT_SECONDARY = "#5d5d5d"; function formatDate(iso: string): string { return new Date(iso).toLocaleDateString("en-US", { @@ -27,22 +30,11 @@ function DetailRow({ label, value }: { label: string; value: string }) { export default function TaskDetailScreen() { const { id } = useLocalSearchParams<{ id: string }>(); - const router = useRouter(); const { data: task, isLoading, isError } = useGetRequest(id); return ( - {/* Header */} - - router.back()} hitSlop={8}> - - - - Task Detail - - - - + {isLoading && ( @@ -72,8 +64,8 @@ export default function TaskDetailScreen() { {task.request_category && ( - - + + {task.request_category} diff --git a/clients/mobile/components/notifications/notification-item.tsx b/clients/mobile/components/notifications/notification-item.tsx index e8b169a60..1ede44342 100644 --- a/clients/mobile/components/notifications/notification-item.tsx +++ b/clients/mobile/components/notifications/notification-item.tsx @@ -5,6 +5,9 @@ import { useRouter } from "expo-router"; import type { Notification } from "@shared/types/notifications"; import { NotificationType } from "@shared/types/notifications"; +const PRIMARY = "#15502c"; +const PRIORITY_HIGH = "#a21313"; + function formatTimestamp(iso: string, showUnreadDot: boolean): string { const date = new Date(iso); const now = new Date(); @@ -39,7 +42,7 @@ function formatDueTime(dueAt?: unknown): string | null { function NewTasksBadge() { return ( - + new tasks ); @@ -48,7 +51,7 @@ function NewTasksBadge() { function UrgentTaskBadge() { return ( - + urgent task ); @@ -80,7 +83,7 @@ export function NotificationItem({ notification, showUnreadDot }: NotificationIt const dueTime = formatDueTime(notification.data?.due_at); return ( - + {/* Title */} {showUnreadDot && } @@ -92,8 +95,8 @@ export function NotificationItem({ notification, showUnreadDot }: NotificationIt {/* Due time */} {dueTime !== null && ( - - {dueTime} + + {dueTime} )} @@ -126,7 +129,7 @@ export function NotificationItem({ notification, showUnreadDot }: NotificationIt return ( {/* Title */} diff --git a/clients/mobile/components/ui/screen-header.tsx b/clients/mobile/components/ui/screen-header.tsx new file mode 100644 index 000000000..d7bcad01e --- /dev/null +++ b/clients/mobile/components/ui/screen-header.tsx @@ -0,0 +1,24 @@ +import Feather from "@expo/vector-icons/Feather"; +import { Pressable, Text, View } from "react-native"; +import { useRouter } from "expo-router"; + +type ScreenHeaderProps = { + title: string; +}; + +export function ScreenHeader({ title }: ScreenHeaderProps) { + const router = useRouter(); + return ( + <> + + router.back()} hitSlop={8}> + + + + {title} + + + + + ); +} From beeae88e34841f180d9d597504e8e9439c3485ea Mon Sep 17 00:00:00 2001 From: Dylan Anctil <134968796+danctila@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:36:27 -0400 Subject: [PATCH 03/13] Update tasks-header.tsx --- .../mobile/components/tasks/tasks-header.tsx | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/clients/mobile/components/tasks/tasks-header.tsx b/clients/mobile/components/tasks/tasks-header.tsx index 36875b5e9..97686b20f 100644 --- a/clients/mobile/components/tasks/tasks-header.tsx +++ b/clients/mobile/components/tasks/tasks-header.tsx @@ -10,19 +10,30 @@ export function TasksHeader() { const unreadCount = notifications?.filter((n) => !n.read_at).length ?? 0; return ( - - Tasks - - {}}> - + + + Tasks + + + {}} + className="w-[34px] h-[34px] items-center justify-center rounded" + > + - {}}> - + {}} + className="w-[34px] h-[34px] items-center justify-center rounded" + > + - router.push("/notifications")} className="relative"> - + router.push("/notifications")} + className="w-[34px] h-[34px] items-center justify-center rounded relative" + > + {unreadCount > 0 && ( - + {unreadCount > 9 ? "9+" : unreadCount} From 2b292fb50d6c70f3f4cde2ef9464624c5ec06c79 Mon Sep 17 00:00:00 2001 From: Dylan Anctil <134968796+danctila@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:49:39 -0400 Subject: [PATCH 04/13] lint --- .../notifications/notification-item.tsx | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/clients/mobile/components/notifications/notification-item.tsx b/clients/mobile/components/notifications/notification-item.tsx index 1ede44342..057287e54 100644 --- a/clients/mobile/components/notifications/notification-item.tsx +++ b/clients/mobile/components/notifications/notification-item.tsx @@ -35,7 +35,8 @@ function formatDueTime(dueAt?: unknown): string | null { if (diffMs <= 0) return "Overdue"; const diffHours = Math.floor(diffMs / 3_600_000); const diffMins = Math.floor((diffMs % 3_600_000) / 60_000); - if (diffHours >= 1) return `Due in ${diffHours} hour${diffHours > 1 ? "s" : ""}`; + if (diffHours >= 1) + return `Due in ${diffHours} hour${diffHours > 1 ? "s" : ""}`; return `Due in ${diffMins}m`; } @@ -52,7 +53,9 @@ function UrgentTaskBadge() { return ( - urgent task + + urgent task + ); } @@ -62,7 +65,10 @@ type NotificationItemProps = { showUnreadDot: boolean; }; -export function NotificationItem({ notification, showUnreadDot }: NotificationItemProps) { +export function NotificationItem({ + notification, + showUnreadDot, +}: NotificationItemProps) { const router = useRouter(); const isUrgent = notification.type === NotificationType.HighPriorityTask; @@ -86,7 +92,9 @@ export function NotificationItem({ notification, showUnreadDot }: NotificationIt {/* Title */} - {showUnreadDot && } + {showUnreadDot && ( + + )} {notification.title} @@ -96,16 +104,21 @@ export function NotificationItem({ notification, showUnreadDot }: NotificationIt {dueTime !== null && ( - {dueTime} + + {dueTime} + )} {/* Body */} - An + + An + - {" "}for your department needs your attention. Claim it now! + {" "} + for your department needs your attention. Claim it now! @@ -122,7 +135,8 @@ export function NotificationItem({ notification, showUnreadDot }: NotificationIt // Split "New Tasks Assigned for Monday" → bold prefix + regular suffix const forIndex = notification.title.indexOf(" for "); - const titleBold = forIndex >= 0 ? notification.title.slice(0, forIndex) : notification.title; + const titleBold = + forIndex >= 0 ? notification.title.slice(0, forIndex) : notification.title; const titleSuffix = forIndex >= 0 ? notification.title.slice(forIndex) : ""; const timestamp = formatTimestamp(notification.created_at, showUnreadDot); @@ -142,11 +156,15 @@ export function NotificationItem({ notification, showUnreadDot }: NotificationIt {/* Timestamp */} - {timestamp} + + {timestamp} + {/* Body */} - Your + + Your + have been assigned for the day From 5cc3c301d6fd3631b40f8a64f3a80a355f15cd35 Mon Sep 17 00:00:00 2001 From: Dylan Anctil <134968796+danctila@users.noreply.github.com> Date: Wed, 15 Apr 2026 10:51:29 -0400 Subject: [PATCH 05/13] rm task detail --- clients/mobile/app/_layout.tsx | 1 - clients/mobile/app/task/[id].tsx | 117 ------------------------------- 2 files changed, 118 deletions(-) delete mode 100644 clients/mobile/app/task/[id].tsx diff --git a/clients/mobile/app/_layout.tsx b/clients/mobile/app/_layout.tsx index b470a60d7..7d8877578 100644 --- a/clients/mobile/app/_layout.tsx +++ b/clients/mobile/app/_layout.tsx @@ -42,7 +42,6 @@ function AppLayout() { - - {label} - {value} - - ); -} - -export default function TaskDetailScreen() { - const { id } = useLocalSearchParams<{ id: string }>(); - const { data: task, isLoading, isError } = useGetRequest(id); - - return ( - - - - {isLoading && ( - - - - )} - - {isError && ( - - - Could not load task. - - - )} - - {task && ( - - {/* Name + priority */} - - - - {task.name} - - {task.request_category && ( - - - - {task.request_category} - - - )} - - - {/* Details */} - - - - {task.department && ( - - )} - {task.estimated_completion_time != null && ( - - )} - {task.scheduled_time && ( - - )} - - - - {/* Description */} - {task.description && ( - - Description - - {task.description} - - - )} - - {/* Notes */} - {task.notes && ( - - Notes - - {task.notes} - - - )} - - )} - - ); -} From 7ab9d9291296c0af548fc65e76eeedcb37ad22cd Mon Sep 17 00:00:00 2001 From: Dylan Anctil <134968796+danctila@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:13:00 -0400 Subject: [PATCH 06/13] fix type --- clients/mobile/components/tasks/tasks-header.tsx | 9 ++++----- .../hooks/__tests__/use-push-notifications.test.ts | 3 ++- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/clients/mobile/components/tasks/tasks-header.tsx b/clients/mobile/components/tasks/tasks-header.tsx index c1085f111..872521872 100644 --- a/clients/mobile/components/tasks/tasks-header.tsx +++ b/clients/mobile/components/tasks/tasks-header.tsx @@ -4,16 +4,15 @@ import { useRouter } from "expo-router"; import { useGetNotifications } from "@shared/api/notifications"; -export function TasksHeader() { - const router = useRouter(); - const { data: notifications } = useGetNotifications(); - const unreadCount = notifications?.filter((n) => !n.read_at).length ?? 0; - type TasksHeaderProps = { onFilterPress?: () => void; }; export function TasksHeader({ onFilterPress }: TasksHeaderProps) { + const router = useRouter(); + const { data: notifications } = useGetNotifications(); + const unreadCount = notifications?.filter((n) => !n.read_at).length ?? 0; + return ( diff --git a/clients/mobile/hooks/__tests__/use-push-notifications.test.ts b/clients/mobile/hooks/__tests__/use-push-notifications.test.ts index 63f4dae7c..273030f7d 100644 --- a/clients/mobile/hooks/__tests__/use-push-notifications.test.ts +++ b/clients/mobile/hooks/__tests__/use-push-notifications.test.ts @@ -38,8 +38,9 @@ const createWrapper = () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); - return ({ children }: { children: React.ReactNode }) => + const Wrapper = ({ children }: { children: React.ReactNode }) => React.createElement(QueryClientProvider, { client: queryClient }, children); + return Wrapper; }; describe("registerForPushNotificationsAsync", () => { From 8c3496824965afe6336ae8d0b55ecd6932d3caa7 Mon Sep 17 00:00:00 2001 From: Dylan Anctil <134968796+danctila@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:15:06 -0400 Subject: [PATCH 07/13] merge --- clients/mobile/components/tasks/tasks-header.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/clients/mobile/components/tasks/tasks-header.tsx b/clients/mobile/components/tasks/tasks-header.tsx index 872521872..e241d7058 100644 --- a/clients/mobile/components/tasks/tasks-header.tsx +++ b/clients/mobile/components/tasks/tasks-header.tsx @@ -6,9 +6,10 @@ import { useGetNotifications } from "@shared/api/notifications"; type TasksHeaderProps = { onFilterPress?: () => void; + filterActive?: boolean; }; -export function TasksHeader({ onFilterPress }: TasksHeaderProps) { +export function TasksHeader({ onFilterPress, filterActive }: TasksHeaderProps) { const router = useRouter(); const { data: notifications } = useGetNotifications(); const unreadCount = notifications?.filter((n) => !n.read_at).length ?? 0; @@ -28,8 +29,13 @@ export function TasksHeader({ onFilterPress }: TasksHeaderProps) { - + router.push("/notifications")} From 332f2d15a5fe7e553a1886a468322e0713b35a60 Mon Sep 17 00:00:00 2001 From: Dylan Anctil <134968796+danctila@users.noreply.github.com> Date: Wed, 15 Apr 2026 22:00:36 -0400 Subject: [PATCH 08/13] pagination --- backend/internal/handler/notifications.go | 18 +++++++++-- .../internal/handler/notifications_test.go | 14 ++++----- backend/internal/repository/notifications.go | 30 ++++++++++++++----- clients/mobile/app/notifications.tsx | 26 ++++++++++++++-- clients/shared/src/api/notifications.ts | 16 ++++++++-- 5 files changed, 81 insertions(+), 23 deletions(-) diff --git a/backend/internal/handler/notifications.go b/backend/internal/handler/notifications.go index c2d10c8d6..b0803e215 100644 --- a/backend/internal/handler/notifications.go +++ b/backend/internal/handler/notifications.go @@ -4,6 +4,7 @@ import ( "context" "errors" "log/slog" + "time" "github.com/generate/selfserve/internal/errs" "github.com/generate/selfserve/internal/httpx" @@ -12,7 +13,7 @@ import ( ) type NotificationsRepository interface { - FindByUserID(ctx context.Context, userID string) ([]*models.Notification, error) + FindByUserID(ctx context.Context, userID string, before *time.Time) ([]*models.Notification, error) MarkRead(ctx context.Context, id, userID string) error MarkAllRead(ctx context.Context, userID string) error UpsertDeviceToken(ctx context.Context, userID, token, platform string) error @@ -28,17 +29,28 @@ func NewNotificationsHandler(repo NotificationsRepository) *NotificationsHandler // ListNotifications godoc // @Summary List notifications -// @Description Returns the most recent notifications for the authenticated user +// @Description Returns the most recent notifications for the authenticated user, paginated by cursor // @Tags notifications // @Produce json +// @Param before query string false "Cursor: return notifications created before this RFC3339 timestamp" // @Success 200 {array} models.Notification +// @Failure 400 {object} errs.HTTPError // @Failure 500 {object} errs.HTTPError // @Security BearerAuth // @Router /notifications [get] func (h *NotificationsHandler) ListNotifications(c *fiber.Ctx) error { userID := c.Locals("userId").(string) - notifications, err := h.repo.FindByUserID(c.Context(), userID) + var before *time.Time + if raw := c.Query("before"); raw != "" { + t, err := time.Parse(time.RFC3339Nano, raw) + if err != nil { + return errs.BadRequest("before must be a valid RFC3339 timestamp") + } + before = &t + } + + notifications, err := h.repo.FindByUserID(c.Context(), userID, before) if err != nil { slog.Error("failed to list notifications", "err", err) return errs.InternalServerError() diff --git a/backend/internal/handler/notifications_test.go b/backend/internal/handler/notifications_test.go index 3ad18b05e..424c37bc5 100644 --- a/backend/internal/handler/notifications_test.go +++ b/backend/internal/handler/notifications_test.go @@ -19,15 +19,15 @@ import ( const testUserID = "user_test_123" type mockNotificationsRepository struct { - findByUserIDFunc func(ctx context.Context, userID string) ([]*models.Notification, error) + findByUserIDFunc func(ctx context.Context, userID string, before *time.Time) ([]*models.Notification, error) markReadFunc func(ctx context.Context, id, userID string) error markAllReadFunc func(ctx context.Context, userID string) error upsertDeviceTokenFunc func(ctx context.Context, userID, token, platform string) error } -func (m *mockNotificationsRepository) FindByUserID(ctx context.Context, userID string) ([]*models.Notification, error) { +func (m *mockNotificationsRepository) FindByUserID(ctx context.Context, userID string, before *time.Time) ([]*models.Notification, error) { if m.findByUserIDFunc != nil { - return m.findByUserIDFunc(ctx, userID) + return m.findByUserIDFunc(ctx, userID, before) } return nil, nil } @@ -76,7 +76,7 @@ func TestNotificationsHandler_ListNotifications(t *testing.T) { readAt := time.Now() mock := &mockNotificationsRepository{ - findByUserIDFunc: func(ctx context.Context, userID string) ([]*models.Notification, error) { + findByUserIDFunc: func(ctx context.Context, userID string, before *time.Time) ([]*models.Notification, error) { return []*models.Notification{ { ID: "notif-1", @@ -105,7 +105,7 @@ func TestNotificationsHandler_ListNotifications(t *testing.T) { t.Parallel() mock := &mockNotificationsRepository{ - findByUserIDFunc: func(ctx context.Context, userID string) ([]*models.Notification, error) { + findByUserIDFunc: func(ctx context.Context, userID string, before *time.Time) ([]*models.Notification, error) { return nil, nil }, } @@ -124,7 +124,7 @@ func TestNotificationsHandler_ListNotifications(t *testing.T) { var capturedUserID string mock := &mockNotificationsRepository{ - findByUserIDFunc: func(ctx context.Context, userID string) ([]*models.Notification, error) { + findByUserIDFunc: func(ctx context.Context, userID string, before *time.Time) ([]*models.Notification, error) { capturedUserID = userID return nil, nil }, @@ -142,7 +142,7 @@ func TestNotificationsHandler_ListNotifications(t *testing.T) { t.Parallel() mock := &mockNotificationsRepository{ - findByUserIDFunc: func(ctx context.Context, userID string) ([]*models.Notification, error) { + findByUserIDFunc: func(ctx context.Context, userID string, before *time.Time) ([]*models.Notification, error) { return nil, errors.New("db error") }, } diff --git a/backend/internal/repository/notifications.go b/backend/internal/repository/notifications.go index c29f728b4..5c62c0990 100644 --- a/backend/internal/repository/notifications.go +++ b/backend/internal/repository/notifications.go @@ -3,10 +3,12 @@ package repository import ( "context" "encoding/json" + "time" "github.com/generate/selfserve/internal/errs" "github.com/generate/selfserve/internal/models" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) @@ -37,14 +39,26 @@ func (r *NotificationsRepository) InsertNotification(ctx context.Context, userID return n, nil } -func (r *NotificationsRepository) FindByUserID(ctx context.Context, userID string) ([]*models.Notification, error) { - rows, err := r.db.Query(ctx, ` - SELECT id, user_id, type, title, body, data, read_at, created_at - FROM public.notifications - WHERE user_id = $1 - ORDER BY created_at DESC - LIMIT 50 - `, userID) +func (r *NotificationsRepository) FindByUserID(ctx context.Context, userID string, before *time.Time) ([]*models.Notification, error) { + var rows pgx.Rows + var err error + if before != nil { + rows, err = r.db.Query(ctx, ` + SELECT id, user_id, type, title, body, data, read_at, created_at + FROM public.notifications + WHERE user_id = $1 AND created_at < $2 + ORDER BY created_at DESC + LIMIT 50 + `, userID, before) + } else { + rows, err = r.db.Query(ctx, ` + SELECT id, user_id, type, title, body, data, read_at, created_at + FROM public.notifications + WHERE user_id = $1 + ORDER BY created_at DESC + LIMIT 50 + `, userID) + } if err != nil { return nil, err } diff --git a/clients/mobile/app/notifications.tsx b/clients/mobile/app/notifications.tsx index 95cab380b..816ca7f09 100644 --- a/clients/mobile/app/notifications.tsx +++ b/clients/mobile/app/notifications.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { ActivityIndicator, FlatList, Text, View } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; @@ -11,9 +11,20 @@ import { NotificationItem } from "@/components/notifications/notification-item"; import { ScreenHeader } from "@/components/ui/screen-header"; export default function NotificationsScreen() { - const { data: notifications = [], isLoading } = useGetNotifications(); + const { + data, + isLoading, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + } = useGetNotifications(); const { mutate: markAllRead } = useMarkAllNotificationsRead(); + const notifications = useMemo( + () => data?.pages.flat() ?? [], + [data], + ); + // Snapshot which IDs were unread when the screen first loaded so dots remain // visible while user is reading — markAllRead fires immediately in the bg. const initialUnreadIds = useRef | null>(null); @@ -47,6 +58,17 @@ export default function NotificationsScreen() { } /> )} + onEndReached={() => { + if (hasNextPage && !isFetchingNextPage) fetchNextPage(); + }} + onEndReachedThreshold={0.3} + ListFooterComponent={ + isFetchingNextPage ? ( + + + + ) : null + } showsVerticalScrollIndicator={false} ListEmptyComponent={ diff --git a/clients/shared/src/api/notifications.ts b/clients/shared/src/api/notifications.ts index 2212e48c7..b0ef3b09f 100644 --- a/clients/shared/src/api/notifications.ts +++ b/clients/shared/src/api/notifications.ts @@ -1,4 +1,4 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import type { Notification, RegisterDeviceTokenInput } from "../types/notifications"; import { useAPIClient } from "./client"; @@ -6,9 +6,19 @@ export const NOTIFICATIONS_QUERY_KEY = ["notifications"] as const; export const useGetNotifications = () => { const api = useAPIClient(); - return useQuery({ + return useInfiniteQuery({ queryKey: NOTIFICATIONS_QUERY_KEY, - queryFn: () => api.get("/notifications"), + queryFn: ({ pageParam }: { pageParam: string | undefined }) => { + const url = pageParam + ? `/notifications?before=${encodeURIComponent(pageParam)}` + : "/notifications"; + return api.get(url); + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage: Notification[]) => { + if (lastPage.length < 50) return undefined; + return lastPage[lastPage.length - 1].created_at; + }, staleTime: 30_000, }); }; From 10f64780851a94123aa602bb6256fadf9ea0184f Mon Sep 17 00:00:00 2001 From: Dylan Anctil <134968796+danctila@users.noreply.github.com> Date: Wed, 15 Apr 2026 22:07:23 -0400 Subject: [PATCH 09/13] prettier --- clients/mobile/app/notifications.tsx | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/clients/mobile/app/notifications.tsx b/clients/mobile/app/notifications.tsx index 816ca7f09..06f7fc946 100644 --- a/clients/mobile/app/notifications.tsx +++ b/clients/mobile/app/notifications.tsx @@ -11,19 +11,11 @@ import { NotificationItem } from "@/components/notifications/notification-item"; import { ScreenHeader } from "@/components/ui/screen-header"; export default function NotificationsScreen() { - const { - data, - isLoading, - isFetchingNextPage, - hasNextPage, - fetchNextPage, - } = useGetNotifications(); + const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = + useGetNotifications(); const { mutate: markAllRead } = useMarkAllNotificationsRead(); - const notifications = useMemo( - () => data?.pages.flat() ?? [], - [data], - ); + const notifications = useMemo(() => data?.pages.flat() ?? [], [data]); // Snapshot which IDs were unread when the screen first loaded so dots remain // visible while user is reading — markAllRead fires immediately in the bg. From ee0eec6b45658ad8f4cf8507ac0b7b903b463cfe Mon Sep 17 00:00:00 2001 From: Dylan Anctil <134968796+danctila@users.noreply.github.com> Date: Wed, 15 Apr 2026 22:07:44 -0400 Subject: [PATCH 10/13] fix: type --- clients/mobile/components/tasks/tasks-header.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/clients/mobile/components/tasks/tasks-header.tsx b/clients/mobile/components/tasks/tasks-header.tsx index e241d7058..29f7b54c9 100644 --- a/clients/mobile/components/tasks/tasks-header.tsx +++ b/clients/mobile/components/tasks/tasks-header.tsx @@ -11,8 +11,8 @@ type TasksHeaderProps = { export function TasksHeader({ onFilterPress, filterActive }: TasksHeaderProps) { const router = useRouter(); - const { data: notifications } = useGetNotifications(); - const unreadCount = notifications?.filter((n) => !n.read_at).length ?? 0; + const { data } = useGetNotifications(); + const unreadCount = data?.pages.flat().filter((n) => !n.read_at).length ?? 0; return ( From 964c71b4bdb58d8b6d6d20b2369ca4595ab328be Mon Sep 17 00:00:00 2001 From: Dylan Anctil <134968796+danctila@users.noreply.github.com> Date: Wed, 15 Apr 2026 22:08:07 -0400 Subject: [PATCH 11/13] fix: interface satisfaction --- backend/internal/service/storage/postgres/repo_types.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/internal/service/storage/postgres/repo_types.go b/backend/internal/service/storage/postgres/repo_types.go index 2b7b391e6..ee70894a3 100644 --- a/backend/internal/service/storage/postgres/repo_types.go +++ b/backend/internal/service/storage/postgres/repo_types.go @@ -9,7 +9,7 @@ import ( type NotificationsRepository interface { InsertNotification(ctx context.Context, userID string, notifType models.NotificationType, title, body string) (*models.Notification, error) - FindByUserID(ctx context.Context, userID string) ([]*models.Notification, error) + FindByUserID(ctx context.Context, userID string, before *time.Time) ([]*models.Notification, error) MarkRead(ctx context.Context, id, userID string) error MarkAllRead(ctx context.Context, userID string) error UpsertDeviceToken(ctx context.Context, userID, token, platform string) error From 97407f9bd3b8b844e1846e5269e9c022a2802abd Mon Sep 17 00:00:00 2001 From: Dylan Anctil <134968796+danctila@users.noreply.github.com> Date: Wed, 15 Apr 2026 22:44:41 -0400 Subject: [PATCH 12/13] Update notification-item.tsx --- .../notifications/notification-item.tsx | 36 +++++++------------ 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/clients/mobile/components/notifications/notification-item.tsx b/clients/mobile/components/notifications/notification-item.tsx index 057287e54..921568add 100644 --- a/clients/mobile/components/notifications/notification-item.tsx +++ b/clients/mobile/components/notifications/notification-item.tsx @@ -49,16 +49,6 @@ function NewTasksBadge() { ); } -function UrgentTaskBadge() { - return ( - - - - urgent task - - - ); -} type NotificationItemProps = { notification: Notification; @@ -95,7 +85,7 @@ export function NotificationItem({ {showUnreadDot && ( )} - + {notification.title} @@ -104,30 +94,30 @@ export function NotificationItem({ {dueTime !== null && ( - + {dueTime} )} {/* Body */} - - - An - - - - {" "} - for your department needs your attention. Claim it now! - - + + {"An "} + + + + {"urgent task"} + + + {" for your department needs your attention. Claim it now!"} + {/* CTA */} - Claim Now! + Claim Now! ); From e08ba17d3a5311bdc00e1943a472bc78cbaf8755 Mon Sep 17 00:00:00 2001 From: Dylan Anctil <134968796+danctila@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:15:13 -0400 Subject: [PATCH 13/13] lint --- .../mobile/components/notifications/notification-item.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/clients/mobile/components/notifications/notification-item.tsx b/clients/mobile/components/notifications/notification-item.tsx index 921568add..06fd21682 100644 --- a/clients/mobile/components/notifications/notification-item.tsx +++ b/clients/mobile/components/notifications/notification-item.tsx @@ -49,7 +49,6 @@ function NewTasksBadge() { ); } - type NotificationItemProps = { notification: Notification; showUnreadDot: boolean; @@ -103,7 +102,10 @@ export function NotificationItem({ {/* Body */} {"An "} - + {"urgent task"}