From d3fd8027a5df6ca163a2280b8d361d4f3d8e7370 Mon Sep 17 00:00:00 2001 From: Stefan Hynst Date: Thu, 30 May 2024 03:36:07 +0200 Subject: [PATCH] Implement "infinite scroll" for expenses (#95) * Extract ExpenseCard vom ExpenseList * Implement simple pagination of expenses (see #30) - display only this year's entries by default - a "Show more" button reveals all expenses * Turn getPrisma() into constant "prisma" - getPrisma() is not async and doesn't need to be awaited - turn getPrisma() into exported constant "prisma" * Select fields to be returned by getGroupExpenses() - make JSON more concise and less redundant - some properties were removed (i.e.instead of "expense.paidById" use "expense.paidBy.id") * Remove "participants" from ExpenseCard - no need to search for participant by id to get it's name - name property is already present in expense * Add option to fetch a slice of group expenses - specify offset and length to get expenses for [offset, offset+length[ - add function to get total number of group expenses * Add api route for client to fetch group expenses * Remove "Show more" button from expense list * Implement infinite scroll - in server component Page - only load first 200 expenses max - pass preloaded expenses and total count - in client component ExpenseList, if there are more expenses to show - test if there are more expenses - append preloading "skeletons" to end of list - fetch more expenses when last item in list comes into view - after each fetch increase fetch-length by factor 1.5 - rationale: db fetch usually is not the issue here, the longer the list gets, the longer react needs to redraw * Use server action instead of api endpoint * Fixes --------- Co-authored-by: Sebastien Castiel --- package-lock.json | 15 ++ package.json | 1 + .../[groupId]/expenses/expense-card.tsx | 78 ++++++++ .../expenses/expense-list-fetch-action.ts | 16 ++ .../[groupId]/expenses/expense-list.tsx | 177 +++++++++--------- .../[groupId]/expenses/export/json/route.ts | 3 +- src/app/groups/[groupId]/expenses/page.tsx | 26 ++- src/lib/api.ts | 47 +++-- src/lib/balances.ts | 8 +- src/lib/prisma.ts | 27 +-- src/lib/totals.ts | 2 +- src/scripts/migrate.ts | 4 +- 12 files changed, 266 insertions(+), 138 deletions(-) create mode 100644 src/app/groups/[groupId]/expenses/expense-card.tsx create mode 100644 src/app/groups/[groupId]/expenses/expense-list-fetch-action.ts diff --git a/package-lock.json b/package-lock.json index 52b0e543..77993967 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-hook-form": "^7.47.0", + "react-intersection-observer": "^9.8.0", "sharp": "^0.33.2", "tailwind-merge": "^1.14.0", "tailwindcss-animate": "^1.0.7", @@ -7510,6 +7511,20 @@ "react": "^16.8.0 || ^17 || ^18" } }, + "node_modules/react-intersection-observer": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.8.0.tgz", + "integrity": "sha512-wXHvMQUsTagh3X0Z6jDtGkIXc3VVCd2tjDRYR9kII3GKrZr0XF0xtpfdamo2n8BSF+zzfeeBVOTjxZWpBp9X0g==", + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index 077f3b0d..91227ac3 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-hook-form": "^7.47.0", + "react-intersection-observer": "^9.8.0", "sharp": "^0.33.2", "tailwind-merge": "^1.14.0", "tailwindcss-animate": "^1.0.7", diff --git a/src/app/groups/[groupId]/expenses/expense-card.tsx b/src/app/groups/[groupId]/expenses/expense-card.tsx new file mode 100644 index 00000000..8ea6063b --- /dev/null +++ b/src/app/groups/[groupId]/expenses/expense-card.tsx @@ -0,0 +1,78 @@ +'use client' +import { ActiveUserBalance } from '@/app/groups/[groupId]/expenses/active-user-balance' +import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon' +import { Button } from '@/components/ui/button' +import { getGroupExpenses } from '@/lib/api' +import { cn, formatCurrency, formatExpenseDate } from '@/lib/utils' +import { ChevronRight } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { Fragment } from 'react' + +type Props = { + expense: Awaited>[number] + currency: string + groupId: string +} + +export function ExpenseCard({ expense, currency, groupId }: Props) { + const router = useRouter() + + return ( +
{ + router.push(`/groups/${groupId}/expenses/${expense.id}/edit`) + }} + > + +
+
+ {expense.title} +
+
+ Paid by {expense.paidBy.name} for{' '} + {expense.paidFor.map((paidFor, index) => ( + + {index !== 0 && <>, } + {paidFor.participant.name} + + ))} +
+
+ +
+
+
+
+ {formatCurrency(currency, expense.amount)} +
+
+ {formatExpenseDate(expense.expenseDate)} +
+
+ +
+ ) +} diff --git a/src/app/groups/[groupId]/expenses/expense-list-fetch-action.ts b/src/app/groups/[groupId]/expenses/expense-list-fetch-action.ts new file mode 100644 index 00000000..6b1cdce3 --- /dev/null +++ b/src/app/groups/[groupId]/expenses/expense-list-fetch-action.ts @@ -0,0 +1,16 @@ +'use server' + +import { getGroupExpenses } from '@/lib/api' + +export async function getGroupExpensesAction( + groupId: string, + options?: { offset: number; length: number }, +) { + 'use server' + + try { + return getGroupExpenses(groupId, options) + } catch { + return null + } +} diff --git a/src/app/groups/[groupId]/expenses/expense-list.tsx b/src/app/groups/[groupId]/expenses/expense-list.tsx index 63f7abe5..40674fd6 100644 --- a/src/app/groups/[groupId]/expenses/expense-list.tsx +++ b/src/app/groups/[groupId]/expenses/expense-list.tsx @@ -1,19 +1,22 @@ 'use client' -import { ActiveUserBalance } from '@/app/groups/[groupId]/expenses/active-user-balance' -import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon' +import { ExpenseCard } from '@/app/groups/[groupId]/expenses/expense-card' +import { getGroupExpensesAction } from '@/app/groups/[groupId]/expenses/expense-list-fetch-action' import { Button } from '@/components/ui/button' import { SearchBar } from '@/components/ui/search-bar' -import { getGroupExpenses } from '@/lib/api' -import { cn, formatCurrency, formatExpenseDate } from '@/lib/utils' -import { Expense, Participant } from '@prisma/client' +import { Skeleton } from '@/components/ui/skeleton' +import { Participant } from '@prisma/client' import dayjs, { type Dayjs } from 'dayjs' -import { ChevronRight } from 'lucide-react' import Link from 'next/link' -import { useRouter } from 'next/navigation' -import { Fragment, useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' +import { useInView } from 'react-intersection-observer' + +type ExpensesType = NonNullable< + Awaited> +> type Props = { - expenses: Awaited> + expensesFirstPage: ExpensesType + expenseCount: number participants: Participant[] currency: string groupId: string @@ -47,28 +50,32 @@ function getExpenseGroup(date: Dayjs, today: Dayjs) { } } -function getGroupedExpensesByDate( - expenses: Awaited>, -) { +function getGroupedExpensesByDate(expenses: ExpensesType) { const today = dayjs() - return expenses.reduce( - (result: { [key: string]: Expense[] }, expense: Expense) => { - const expenseGroup = getExpenseGroup(dayjs(expense.expenseDate), today) - result[expenseGroup] = result[expenseGroup] ?? [] - result[expenseGroup].push(expense) - return result - }, - {}, - ) + return expenses.reduce((result: { [key: string]: ExpensesType }, expense) => { + const expenseGroup = getExpenseGroup(dayjs(expense.expenseDate), today) + result[expenseGroup] = result[expenseGroup] ?? [] + result[expenseGroup].push(expense) + return result + }, {}) } export function ExpenseList({ - expenses, + expensesFirstPage, + expenseCount, currency, participants, groupId, }: Props) { + const firstLen = expensesFirstPage.length const [searchText, setSearchText] = useState('') + const [dataIndex, setDataIndex] = useState(firstLen) + const [dataLen, setDataLen] = useState(firstLen) + const [hasMoreData, setHasMoreData] = useState(expenseCount > firstLen) + const [isFetching, setIsFetching] = useState(false) + const [expenses, setExpenses] = useState(expensesFirstPage) + const { ref, inView } = useInView() + useEffect(() => { const activeUser = localStorage.getItem('newGroup-activeUser') const newUser = localStorage.getItem(`${groupId}-newUser`) @@ -88,10 +95,43 @@ export function ExpenseList({ } }, [groupId, participants]) - const getParticipant = (id: string) => participants.find((p) => p.id === id) - const router = useRouter() + useEffect(() => { + const fetchNextPage = async () => { + setIsFetching(true) + + const newExpenses = await getGroupExpensesAction(groupId, { + offset: dataIndex, + length: dataLen, + }) + + if (newExpenses !== null) { + const exp = expenses.concat(newExpenses) + setExpenses(exp) + setHasMoreData(exp.length < expenseCount) + setDataIndex(dataIndex + dataLen) + setDataLen(Math.ceil(1.5 * dataLen)) + } + + setTimeout(() => setIsFetching(false), 500) + } + + if (inView && hasMoreData && !isFetching) fetchNextPage() + }, [ + dataIndex, + dataLen, + expenseCount, + expenses, + groupId, + hasMoreData, + inView, + isFetching, + ]) + + const groupedExpensesByDate = useMemo( + () => getGroupedExpensesByDate(expenses), + [expenses], + ) - const groupedExpensesByDate = getGroupedExpensesByDate(expenses) return expenses.length > 0 ? ( <> setSearchText(value)} /> @@ -114,76 +154,33 @@ export function ExpenseList({ > {expenseGroup} - {groupExpenses.map((expense: any) => ( -
( + { - router.push(`/groups/${groupId}/expenses/${expense.id}/edit`) - }} - > - -
-
- {expense.title} -
-
- Paid by{' '} - {getParticipant(expense.paidById)?.name}{' '} - for{' '} - {expense.paidFor.map((paidFor: any, index: number) => ( - - {index !== 0 && <>, } - - { - participants.find( - (p) => p.id === paidFor.participantId, - )?.name - } - - - ))} -
-
- -
-
-
-
- {formatCurrency(currency, expense.amount)} -
-
- {formatExpenseDate(expense.expenseDate)} -
-
- -
+ expense={expense} + currency={currency} + groupId={groupId} + /> ))} ) })} + {expenses.length < expenseCount && + [0, 1, 2].map((i) => ( +
+
+ + +
+
+ +
+
+ ))} ) : (

diff --git a/src/app/groups/[groupId]/expenses/export/json/route.ts b/src/app/groups/[groupId]/expenses/export/json/route.ts index ff14ae79..ce31c1d2 100644 --- a/src/app/groups/[groupId]/expenses/export/json/route.ts +++ b/src/app/groups/[groupId]/expenses/export/json/route.ts @@ -1,4 +1,4 @@ -import { getPrisma } from '@/lib/prisma' +import { prisma } from '@/lib/prisma' import contentDisposition from 'content-disposition' import { NextResponse } from 'next/server' @@ -6,7 +6,6 @@ export async function GET( req: Request, { params: { groupId } }: { params: { groupId: string } }, ) { - const prisma = await getPrisma() const group = await prisma.group.findUnique({ where: { id: groupId }, select: { diff --git a/src/app/groups/[groupId]/expenses/page.tsx b/src/app/groups/[groupId]/expenses/page.tsx index 8876e68a..d421fc2f 100644 --- a/src/app/groups/[groupId]/expenses/page.tsx +++ b/src/app/groups/[groupId]/expenses/page.tsx @@ -11,7 +11,11 @@ import { CardTitle, } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' -import { getCategories, getGroupExpenses } from '@/lib/api' +import { + getCategories, + getGroupExpenseCount, + getGroupExpenses, +} from '@/lib/api' import { env } from '@/lib/env' import { Download, Plus } from 'lucide-react' import { Metadata } from 'next' @@ -91,7 +95,7 @@ export default async function GroupExpensesPage({ ))} > - + @@ -101,14 +105,22 @@ export default async function GroupExpensesPage({ ) } -async function Expenses({ groupId }: { groupId: string }) { - const group = await cached.getGroup(groupId) - if (!group) notFound() - const expenses = await getGroupExpenses(group.id) +type Props = { + group: NonNullable>> +} + +async function Expenses({ group }: Props) { + const expenseCount = await getGroupExpenseCount(group.id) + + const expenses = await getGroupExpenses(group.id, { + offset: 0, + length: 200, + }) return ( [ - e.paidById, - ...e.paidFor.map((pf) => pf.participantId), + e.paidBy.id, + ...e.paidFor.map((pf) => pf.participant.id), ]), ), ) } export async function getGroups(groupIds: string[]) { - const prisma = await getPrisma() return ( await prisma.group.findMany({ where: { id: { in: groupIds } }, @@ -129,7 +125,6 @@ export async function updateExpense( throw new Error(`Invalid participant ID: ${participant}`) } - const prisma = await getPrisma() return prisma.expense.update({ where: { id: expenseId }, data: { @@ -198,7 +193,6 @@ export async function updateGroup( const existingGroup = await getGroup(groupId) if (!existingGroup) throw new Error('Invalid group ID') - const prisma = await getPrisma() return prisma.group.update({ where: { id: groupId }, data: { @@ -230,7 +224,6 @@ export async function updateGroup( } export async function getGroup(groupId: string) { - const prisma = await getPrisma() return prisma.group.findUnique({ where: { id: groupId }, include: { participants: true }, @@ -238,25 +231,43 @@ export async function getGroup(groupId: string) { } export async function getCategories() { - const prisma = await getPrisma() return prisma.category.findMany() } -export async function getGroupExpenses(groupId: string) { - const prisma = await getPrisma() +export async function getGroupExpenses( + groupId: string, + options?: { offset: number; length: number }, +) { return prisma.expense.findMany({ - where: { groupId }, - include: { - paidFor: { include: { participant: true } }, - paidBy: true, + select: { + amount: true, category: true, + createdAt: true, + expenseDate: true, + id: true, + isReimbursement: true, + paidBy: { select: { id: true, name: true } }, + paidFor: { + select: { + participant: { select: { id: true, name: true } }, + shares: true, + }, + }, + splitMode: true, + title: true, }, + where: { groupId }, orderBy: [{ expenseDate: 'desc' }, { createdAt: 'desc' }], + skip: options && options.offset, + take: options && options.length, }) } +export async function getGroupExpenseCount(groupId: string) { + return prisma.expense.count({ where: { groupId } }) +} + export async function getExpense(groupId: string, expenseId: string) { - const prisma = await getPrisma() return prisma.expense.findUnique({ where: { id: expenseId }, include: { paidBy: true, paidFor: true, category: true, documents: true }, diff --git a/src/lib/balances.ts b/src/lib/balances.ts index aa8d26f4..74eae781 100644 --- a/src/lib/balances.ts +++ b/src/lib/balances.ts @@ -19,7 +19,7 @@ export function getBalances( const balances: Balances = {} for (const expense of expenses) { - const paidBy = expense.paidById + const paidBy = expense.paidBy.id const paidFors = expense.paidFor if (!balances[paidBy]) balances[paidBy] = { paid: 0, paidFor: 0, total: 0 } @@ -31,8 +31,8 @@ export function getBalances( ) let remaining = expense.amount paidFors.forEach((paidFor, index) => { - if (!balances[paidFor.participantId]) - balances[paidFor.participantId] = { paid: 0, paidFor: 0, total: 0 } + if (!balances[paidFor.participant.id]) + balances[paidFor.participant.id] = { paid: 0, paidFor: 0, total: 0 } const isLast = index === paidFors.length - 1 @@ -47,7 +47,7 @@ export function getBalances( ? remaining : (expense.amount * shares) / totalShares remaining -= dividedAmount - balances[paidFor.participantId].paidFor += dividedAmount + balances[paidFor.participant.id].paidFor += dividedAmount }) } diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 08ec6242..9c965fb2 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -1,20 +1,21 @@ import { PrismaClient } from '@prisma/client' -let prisma: PrismaClient +declare const global: Global & { prisma?: PrismaClient } -export async function getPrisma() { +export let p: PrismaClient = undefined as any as PrismaClient + +if (typeof window === 'undefined') { // await delay(1000) - if (!prisma) { - if (process.env.NODE_ENV === 'production') { - prisma = new PrismaClient() - } else { - if (!(global as any).prisma) { - ;(global as any).prisma = new PrismaClient({ - // log: [{ emit: 'stdout', level: 'query' }], - }) - } - prisma = (global as any).prisma + if (process.env['NODE_ENV'] === 'production') { + p = new PrismaClient() + } else { + if (!global.prisma) { + global.prisma = new PrismaClient({ + // log: [{ emit: 'stdout', level: 'query' }], + }) } + p = global.prisma } - return prisma } + +export const prisma = p diff --git a/src/lib/totals.ts b/src/lib/totals.ts index 6d5e794a..196ef3da 100644 --- a/src/lib/totals.ts +++ b/src/lib/totals.ts @@ -34,7 +34,7 @@ export function getTotalActiveUserShare( const paidFors = expense.paidFor const userPaidFor = paidFors.find( - (paidFor) => paidFor.participantId === activeUserId, + (paidFor) => paidFor.participant.id === activeUserId, ) if (!userPaidFor) { diff --git a/src/scripts/migrate.ts b/src/scripts/migrate.ts index 903f1193..164ae3d7 100644 --- a/src/scripts/migrate.ts +++ b/src/scripts/migrate.ts @@ -1,6 +1,6 @@ // @ts-nocheck import { randomId } from '@/lib/api' -import { getPrisma } from '@/lib/prisma' +import { prisma } from '@/lib/prisma' import { Prisma } from '@prisma/client' import { Client } from 'pg' @@ -8,8 +8,6 @@ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' async function main() { withClient(async (client) => { - const prisma = await getPrisma() - // console.log('Deleting all groups…') // await prisma.group.deleteMany({})