Cursor-based pagination for /notifications
Switches `listForUser` from page-offset to cursor (`before` param,
opaque base64 of `{ts, id}`) ordered by `(created_at DESC, id DESC)`
for stable pagination even with simultaneous fan-out inserts.
Returns `{ rows, nextCursor }` instead of a bare array. Loader surfaces
`?before=<cursor>` on a "Load older" link at the bottom of the list,
shown only while `nextCursor !== null`. Default page size 50, capped
at 100. Malformed cursors fall back to "start from top" rather than
400ing — opaque cursors should not be a client validation surface.
Spec drift: delta spec adds three pagination scenarios (cursor pages
forward, tie-stable on identical `created_at`, malformed-cursor
graceful fallback). Design doc gets a new decision section explaining
the cursor choice over page-offset and why we don't compute totals.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2892cf9360
commit
b20c8cca39
9 changed files with 206 additions and 35 deletions
|
|
@ -14,7 +14,9 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||
const user = await getSessionUser(request);
|
||||
if (!user) throw redirect("/auth/login");
|
||||
|
||||
const rows = await listForUser(user.id, { page: 1 });
|
||||
const url = new URL(request.url);
|
||||
const before = url.searchParams.get("before") ?? undefined;
|
||||
const { rows, nextCursor } = await listForUser(user.id, { before });
|
||||
|
||||
// Renderer guard: drop activity_published rows whose subject is gone
|
||||
// or no longer public (visibility flipped from public → private/unlisted).
|
||||
|
|
@ -56,6 +58,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||
payload,
|
||||
};
|
||||
}),
|
||||
nextCursor,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -145,7 +148,7 @@ function NotificationItem({ row }: { row: Row }) {
|
|||
}
|
||||
|
||||
export default function Notifications({ loaderData }: Route.ComponentProps) {
|
||||
const { notifications } = loaderData;
|
||||
const { notifications, nextCursor } = loaderData;
|
||||
const { t } = useTranslation("journal");
|
||||
const markAll = useFetcher();
|
||||
|
||||
|
|
@ -171,11 +174,23 @@ export default function Notifications({ loaderData }: Route.ComponentProps) {
|
|||
{notifications.length === 0 ? (
|
||||
<p className="mt-12 text-center text-gray-500">{t("notifications.empty")}</p>
|
||||
) : (
|
||||
<ul className="mt-6 rounded-lg border border-gray-200 bg-white">
|
||||
{notifications.map((n) => (
|
||||
<NotificationItem key={n.id} row={n} />
|
||||
))}
|
||||
</ul>
|
||||
<>
|
||||
<ul className="mt-6 rounded-lg border border-gray-200 bg-white">
|
||||
{notifications.map((n) => (
|
||||
<NotificationItem key={n.id} row={n} />
|
||||
))}
|
||||
</ul>
|
||||
{nextCursor && (
|
||||
<div className="mt-4 text-center">
|
||||
<a
|
||||
href={`/notifications?before=${encodeURIComponent(nextCursor)}`}
|
||||
className="text-sm font-medium text-blue-600 hover:underline"
|
||||
>
|
||||
{t("notifications.loadOlder")}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue