diff --git a/package-lock.json b/package-lock.json index f4c3e5f0..ec87fac3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,12 @@ "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.5", "@tailwindcss/typography": "^0.5.10", + "@tanstack/react-query": "^5.59.15", + "@trpc/client": "^11.0.0-rc.586", + "@trpc/react-query": "^11.0.0-rc.586", + "@trpc/server": "^11.0.0-rc.586", "class-variance-authority": "^0.7.0", + "client-only": "^0.0.1", "clsx": "^2.0.0", "cmdk": "^0.2.0", "content-disposition": "^0.5.4", @@ -47,13 +52,16 @@ "react-dom": "^18.3.1", "react-hook-form": "^7.47.0", "react-intersection-observer": "^9.8.0", + "server-only": "^0.0.1", "sharp": "^0.33.2", + "superjson": "^2.2.1", "tailwind-merge": "^1.14.0", "tailwindcss-animate": "^1.0.7", "ts-pattern": "^5.0.6", + "use-debounce": "^10.0.4", "uuid": "^9.0.1", "vaul": "^0.8.0", - "zod": "^3.22.4" + "zod": "^3.23.8" }, "devDependencies": { "@testing-library/dom": "^10.4.0", @@ -8962,6 +8970,32 @@ "node": ">=4" } }, + "node_modules/@tanstack/query-core": { + "version": "5.59.13", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.59.13.tgz", + "integrity": "sha512-Oou0bBu/P8+oYjXsJQ11j+gcpLAMpqW42UlokQYEz4dE7+hOtVO9rVuolJKgEccqzvyFzqX4/zZWY+R/v1wVsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.59.15", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.59.15.tgz", + "integrity": "sha512-QbVlAkTI78wB4Mqgf2RDmgC0AOiJqer2c5k9STOOSXGv1S6ZkY37r/6UpE8DbQ2Du0ohsdoXgFNEyv+4eDoPEw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.59.13" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", @@ -9063,6 +9097,43 @@ "integrity": "sha512-AqlrT8YA1o7Ff5wPfMOL0pvL+1X+sw60NN6CcOCqs658emD6RfiXhF7Gu9QcfKBH7ELY2nInLhKSCWVoNL70MQ==", "dev": true }, + "node_modules/@trpc/client": { + "version": "11.0.0-rc.586", + "resolved": "https://registry.npmjs.org/@trpc/client/-/client-11.0.0-rc.586.tgz", + "integrity": "sha512-shCIpBzT+SzEbVXbCdpbSrPogG4c9J6hXh+xh5pidY1MTYcBHkeZVBLjy/fVSX+fB9wRoZXNaaoXO+ijYAZBcQ==", + "funding": [ + "https://trpc.io/sponsor" + ], + "license": "MIT", + "peerDependencies": { + "@trpc/server": "11.0.0-rc.586+3388c9691" + } + }, + "node_modules/@trpc/react-query": { + "version": "11.0.0-rc.586", + "resolved": "https://registry.npmjs.org/@trpc/react-query/-/react-query-11.0.0-rc.586.tgz", + "integrity": "sha512-fYIo9Y9lM2tqTBY9NBT5ZPX4R++SaauOl6qjvnSwmIBupboiueLMMWfMh+cmJiAVim1Hg0OvgoS6WRFIYMlFYg==", + "funding": [ + "https://trpc.io/sponsor" + ], + "license": "MIT", + "peerDependencies": { + "@tanstack/react-query": "^5.59.15", + "@trpc/client": "11.0.0-rc.586+3388c9691", + "@trpc/server": "11.0.0-rc.586+3388c9691", + "react": ">=18.2.0", + "react-dom": ">=18.2.0" + } + }, + "node_modules/@trpc/server": { + "version": "11.0.0-rc.586", + "resolved": "https://registry.npmjs.org/@trpc/server/-/server-11.0.0-rc.586.tgz", + "integrity": "sha512-G0713HRFYyBLjN58DYq88hTH4kfKNZt9GXR0/TkVD7rENpOUBk6LKorqSDQ0y0/8aqu11HdDHsn6vBTWK3D44Q==", + "funding": [ + "https://trpc.io/sponsor" + ], + "license": "MIT" + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -10788,6 +10859,21 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/copy-anything": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", + "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", + "license": "MIT", + "dependencies": { + "is-what": "^4.1.8" + }, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -13155,6 +13241,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-what": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", + "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", + "license": "MIT", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -16298,6 +16396,12 @@ "semver": "bin/semver.js" } }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", @@ -16811,6 +16915,18 @@ "node": ">=8" } }, + "node_modules/superjson": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.1.tgz", + "integrity": "sha512-8iGv75BYOa0xRJHK5vRLEjE2H/i4lulTjzpUXic3Eg8akftYjkmQDa8JARQ42rlczXyFR3IeRoeFCc7RxHsYZA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^3.0.2" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -17319,6 +17435,18 @@ } } }, + "node_modules/use-debounce": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.4.tgz", + "integrity": "sha512-6Cf7Yr7Wk7Kdv77nnJMf6de4HuDE4dTxKij+RqE9rufDsI6zsbjyAxcH5y2ueJCQAnfgKbzXbZHYlkFwmBlWkw==", + "license": "MIT", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/use-intl": { "version": "3.17.2", "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-3.17.2.tgz", @@ -17819,9 +17947,9 @@ } }, "node_modules/zod": { - "version": "3.22.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", - "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 9a79f3b7..842e03e2 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,12 @@ "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.5", "@tailwindcss/typography": "^0.5.10", + "@tanstack/react-query": "^5.59.15", + "@trpc/client": "^11.0.0-rc.586", + "@trpc/react-query": "^11.0.0-rc.586", + "@trpc/server": "^11.0.0-rc.586", "class-variance-authority": "^0.7.0", + "client-only": "^0.0.1", "clsx": "^2.0.0", "cmdk": "^0.2.0", "content-disposition": "^0.5.4", @@ -54,13 +59,16 @@ "react-dom": "^18.3.1", "react-hook-form": "^7.47.0", "react-intersection-observer": "^9.8.0", + "server-only": "^0.0.1", "sharp": "^0.33.2", + "superjson": "^2.2.1", "tailwind-merge": "^1.14.0", "tailwindcss-animate": "^1.0.7", "ts-pattern": "^5.0.6", + "use-debounce": "^10.0.4", "uuid": "^9.0.1", "vaul": "^0.8.0", - "zod": "^3.22.4" + "zod": "^3.23.8" }, "devDependencies": { "@testing-library/dom": "^10.4.0", diff --git a/src/app/api/trpc/[trpc]/route.ts b/src/app/api/trpc/[trpc]/route.ts new file mode 100644 index 00000000..8d9ca8e6 --- /dev/null +++ b/src/app/api/trpc/[trpc]/route.ts @@ -0,0 +1,13 @@ +import { createTRPCContext } from '@/trpc/init' +import { appRouter } from '@/trpc/routers/_app' +import { fetchRequestHandler } from '@trpc/server/adapters/fetch' + +const handler = (req: Request) => + fetchRequestHandler({ + endpoint: '/api/trpc', + req, + router: appRouter, + createContext: createTRPCContext, + }) + +export { handler as GET, handler as POST } diff --git a/src/app/groups/[groupId]/balances/balances-and-reimbursements.tsx b/src/app/groups/[groupId]/balances/balances-and-reimbursements.tsx new file mode 100644 index 00000000..adf1fed8 --- /dev/null +++ b/src/app/groups/[groupId]/balances/balances-and-reimbursements.tsx @@ -0,0 +1,138 @@ +'use client' + +import { BalancesList } from '@/app/groups/[groupId]/balances-list' +import { ReimbursementList } from '@/app/groups/[groupId]/reimbursement-list' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Skeleton } from '@/components/ui/skeleton' +import { getGroup } from '@/lib/api' +import { trpc } from '@/trpc/client' +import { useTranslations } from 'next-intl' +import { Fragment, useEffect } from 'react' + +export default function BalancesAndReimbursements({ + group, +}: { + group: NonNullable>> +}) { + const utils = trpc.useUtils() + + useEffect(() => { + // Until we use tRPC more widely and can invalidate the cache on expense + // update, it's easier and safer to invalidate the cache on page load. + utils.groups.balances.invalidate() + }, [utils]) + + const t = useTranslations('Balances') + + const { data, isLoading } = trpc.groups.balances.list.useQuery({ + groupId: group.id, + }) + + return ( + <> + + + {t('title')} + {t('description')} + + + {isLoading || !data ? ( + + ) : ( + + )} + + + + + {t('Reimbursements.title')} + {t('Reimbursements.description')} + + + {isLoading || !data ? ( + + ) : ( + + )} + + + + ) +} + +const ReimbursementsLoading = ({ + participantCount, +}: { + participantCount: number +}) => { + return ( +
+ {Array(participantCount - 1) + .fill(undefined) + .map((_, index) => ( +
+
+ + +
+ +
+ ))} +
+ ) +} + +const BalancesLoading = ({ + participantCount, +}: { + participantCount: number +}) => { + return ( +
+ {Array(participantCount) + .fill(undefined) + .map((_, index) => + index % 2 === 0 ? ( + +
+ +
+
+ +
+
+ ) : ( + +
+ +
+
+ +
+
+ ), + )} +
+ ) +} diff --git a/src/app/groups/[groupId]/balances/page.tsx b/src/app/groups/[groupId]/balances/page.tsx index 615f033c..0a403aa4 100644 --- a/src/app/groups/[groupId]/balances/page.tsx +++ b/src/app/groups/[groupId]/balances/page.tsx @@ -1,21 +1,6 @@ import { cached } from '@/app/cached-functions' -import { BalancesList } from '@/app/groups/[groupId]/balances-list' -import { ReimbursementList } from '@/app/groups/[groupId]/reimbursement-list' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card' -import { getGroupExpenses } from '@/lib/api' -import { - getBalances, - getPublicBalances, - getSuggestedReimbursements, -} from '@/lib/balances' +import BalancesAndReimbursements from '@/app/groups/[groupId]/balances/balances-and-reimbursements' import { Metadata } from 'next' -import { getTranslations } from 'next-intl/server' import { notFound } from 'next/navigation' export const metadata: Metadata = { @@ -27,44 +12,8 @@ export default async function GroupPage({ }: { params: { groupId: string } }) { - const t = await getTranslations('Balances') const group = await cached.getGroup(groupId) if (!group) notFound() - const expenses = await getGroupExpenses(groupId) - const balances = getBalances(expenses) - const reimbursements = getSuggestedReimbursements(balances) - const publicBalances = getPublicBalances(reimbursements) - - return ( - <> - - - {t('title')} - {t('description')} - - - - - - - - {t('Reimbursements.title')} - {t('Reimbursements.description')} - - - - - - - ) + return } diff --git a/src/app/groups/[groupId]/expenses/expense-list.tsx b/src/app/groups/[groupId]/expenses/expense-list.tsx index 4009bb6d..6af0ec94 100644 --- a/src/app/groups/[groupId]/expenses/expense-list.tsx +++ b/src/app/groups/[groupId]/expenses/expense-list.tsx @@ -4,21 +4,22 @@ import { getGroupExpensesAction } from '@/app/groups/[groupId]/expenses/expense- import { Button } from '@/components/ui/button' import { SearchBar } from '@/components/ui/search-bar' import { Skeleton } from '@/components/ui/skeleton' -import { normalizeString } from '@/lib/utils' +import { trpc } from '@/trpc/client' import { Participant } from '@prisma/client' import dayjs, { type Dayjs } from 'dayjs' import { useTranslations } from 'next-intl' import Link from 'next/link' -import { useEffect, useMemo, useState } from 'react' +import { forwardRef, useEffect, useMemo, useState } from 'react' import { useInView } from 'react-intersection-observer' +import { useDebounce } from 'use-debounce' + +const PAGE_SIZE = 200 type ExpensesType = NonNullable< Awaited> > type Props = { - expensesFirstPage: ExpensesType - expenseCount: number participants: Participant[] currency: string groupId: string @@ -62,22 +63,9 @@ function getGroupedExpensesByDate(expenses: ExpensesType) { }, {}) } -export function ExpenseList({ - expensesFirstPage, - expenseCount, - currency, - participants, - groupId, -}: Props) { - const firstLen = expensesFirstPage.length +export function ExpenseList({ currency, participants, groupId }: Props) { 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() - const t = useTranslations('Expenses') + const [debouncedSearchText] = useDebounce(searchText, 300) useEffect(() => { const activeUser = localStorage.getItem('newGroup-activeUser') @@ -98,57 +86,74 @@ export function ExpenseList({ } }, [groupId, participants]) + return ( + <> + setSearchText(value)} /> + + + ) +} + +const ExpenseListForSearch = ({ + currency, + groupId, + searchText, +}: { + currency: string + groupId: string + searchText: string +}) => { + const utils = trpc.useUtils() + 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)) - } + // Until we use tRPC more widely and can invalidate the cache on expense + // update, it's easier and safer to invalidate the cache on page load. + utils.groups.expenses.invalidate() + }, [utils]) - setTimeout(() => setIsFetching(false), 500) - } + const t = useTranslations('Expenses') + const { ref: loadingRef, inView } = useInView() - if (inView && hasMoreData && !isFetching) fetchNextPage() - }, [ - dataIndex, - dataLen, - expenseCount, - expenses, - groupId, - hasMoreData, - inView, - isFetching, - ]) + const { data, isLoading, isError, fetchNextPage } = + trpc.groups.expenses.list.useInfiniteQuery( + { groupId, limit: PAGE_SIZE, filter: searchText }, + { getNextPageParam: ({ nextCursor }) => nextCursor }, + ) + const expenses = data?.pages.flatMap((page) => page.expenses) + const hasMore = data?.pages.at(-1)?.hasMore ?? false + + useEffect(() => { + if (inView && hasMore && !isLoading) fetchNextPage() + }, [fetchNextPage, hasMore, inView, isLoading]) const groupedExpensesByDate = useMemo( - () => getGroupedExpensesByDate(expenses), + () => (expenses ? getGroupedExpensesByDate(expenses) : {}), [expenses], ) - return expenses.length > 0 ? ( + if (isLoading) return + + if (!expenses || expenses?.length === 0) + return ( +

+ {t('noExpenses')}{' '} + +

+ ) + + return ( <> - setSearchText(normalizeString(value))} - /> {Object.values(EXPENSE_GROUPS).map((expenseGroup: string) => { let groupExpenses = groupedExpensesByDate[expenseGroup] - if (!groupExpenses) return null - - groupExpenses = groupExpenses.filter(({ title }) => - normalizeString(title).includes(searchText), - ) - - if (groupExpenses.length === 0) return null + if (!groupExpenses || groupExpenses.length === 0) return null return (
@@ -170,31 +175,34 @@ export function ExpenseList({
) })} - {expenses.length < expenseCount && - [0, 1, 2].map((i) => ( -
-
- - -
-
- -
-
- ))} + {hasMore && } - ) : ( -

- {t('noExpenses')}{' '} - -

) } + +const ExpensesLoading = forwardRef((_, ref) => { + return ( +
+ + {[0, 1, 2].map((i) => ( +
+
+ +
+
+ + +
+
+ + +
+
+ ))} +
+ ) +}) +ExpensesLoading.displayName = 'ExpensesLoading' diff --git a/src/app/groups/[groupId]/expenses/page.tsx b/src/app/groups/[groupId]/expenses/page.tsx index 068d46ff..35ca7e7c 100644 --- a/src/app/groups/[groupId]/expenses/page.tsx +++ b/src/app/groups/[groupId]/expenses/page.tsx @@ -10,19 +10,13 @@ import { CardHeader, CardTitle, } from '@/components/ui/card' -import { Skeleton } from '@/components/ui/skeleton' -import { - getCategories, - getGroupExpenseCount, - getGroupExpenses, -} from '@/lib/api' +import { getCategories } from '@/lib/api' import { env } from '@/lib/env' import { Download, Plus } from 'lucide-react' import { Metadata } from 'next' import { getTranslations } from 'next-intl/server' import Link from 'next/link' import { notFound } from 'next/navigation' -import { Suspense } from 'react' export const revalidate = 3600 @@ -79,24 +73,11 @@ export default async function GroupExpensesPage({ - ( -
-
- - -
-
- -
-
- ))} - > - -
+
@@ -104,26 +85,3 @@ export default async function GroupExpensesPage({ ) } - -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 ( - - ) -} diff --git a/src/app/groups/[groupId]/information/group-information.tsx b/src/app/groups/[groupId]/information/group-information.tsx new file mode 100644 index 00000000..bdb6f02f --- /dev/null +++ b/src/app/groups/[groupId]/information/group-information.tsx @@ -0,0 +1,52 @@ +'use client' + +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Skeleton } from '@/components/ui/skeleton' +import { trpc } from '@/trpc/client' +import { Pencil } from 'lucide-react' +import { useTranslations } from 'next-intl' +import Link from 'next/link' + +export default function GroupInformation({ groupId }: { groupId: string }) { + const t = useTranslations('Information') + const { data, isLoading } = trpc.groups.information.get.useQuery({ groupId }) + + return ( + <> + + + + {t('title')} + + + + {t('description')} + + + + {isLoading || !data ? ( +
+ + +
+ ) : ( + data.information || ( +

{t('empty')}

+ ) + )} +
+
+ + ) +} diff --git a/src/app/groups/[groupId]/information/page.tsx b/src/app/groups/[groupId]/information/page.tsx index 4d5cb985..c4c00264 100644 --- a/src/app/groups/[groupId]/information/page.tsx +++ b/src/app/groups/[groupId]/information/page.tsx @@ -1,54 +1,14 @@ -import { cached } from '@/app/cached-functions' -import { Button } from '@/components/ui/button' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card' -import { Pencil } from 'lucide-react' +import GroupInformation from '@/app/groups/[groupId]/information/group-information' import { Metadata } from 'next' -import { getTranslations } from 'next-intl/server' -import Link from 'next/link' -import { notFound } from 'next/navigation' export const metadata: Metadata = { - title: 'Totals', + title: 'Group Information', } -export default async function InformationPage({ +export default function InformationPage({ params: { groupId }, }: { params: { groupId: string } }) { - const group = await cached.getGroup(groupId) - if (!group) notFound() - - const t = await getTranslations('Information') - - return ( - <> - - - - {t('title')} - - - - {t('description')} - - - - {group.information || ( -

{t('empty')}

- )} -
-
- - ) + return } diff --git a/src/app/groups/[groupId]/reimbursement-list.tsx b/src/app/groups/[groupId]/reimbursement-list.tsx index 43bf4f61..6a7ca529 100644 --- a/src/app/groups/[groupId]/reimbursement-list.tsx +++ b/src/app/groups/[groupId]/reimbursement-list.tsx @@ -28,7 +28,7 @@ export function ReimbursementList({ return (
{reimbursements.map((reimbursement, index) => ( -
+
{t.rich('owes', { diff --git a/src/app/groups/[groupId]/stats/totals.tsx b/src/app/groups/[groupId]/stats/totals.tsx index aa320374..fb188363 100644 --- a/src/app/groups/[groupId]/stats/totals.tsx +++ b/src/app/groups/[groupId]/stats/totals.tsx @@ -15,7 +15,6 @@ export function Totals({ totalGroupSpendings: number }) { const activeUser = useActiveUser(group.id) - console.log('activeUser', activeUser) return ( <> diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 29958020..4431a909 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,6 +6,7 @@ import { ThemeToggle } from '@/components/theme-toggle' import { Button } from '@/components/ui/button' import { Toaster } from '@/components/ui/toaster' import { env } from '@/lib/env' +import { TRPCProvider } from '@/trpc/client' import type { Metadata, Viewport } from 'next' import { NextIntlClientProvider, useTranslations } from 'next-intl' import { getLocale, getMessages } from 'next-intl/server' @@ -65,7 +66,7 @@ export const viewport: Viewport = { function Content({ children }: { children: React.ReactNode }) { const t = useTranslations() return ( - <> +
- + ) } diff --git a/src/lib/api.ts b/src/lib/api.ts index 92eaefc5..2f8efacf 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -267,7 +267,7 @@ export async function getCategories() { export async function getGroupExpenses( groupId: string, - options?: { offset: number; length: number }, + options?: { offset?: number; length?: number; filter?: string }, ) { return prisma.expense.findMany({ select: { @@ -287,7 +287,12 @@ export async function getGroupExpenses( splitMode: true, title: true, }, - where: { groupId }, + where: { + groupId, + title: options?.filter + ? { contains: options.filter, mode: 'insensitive' } + : undefined, + }, orderBy: [{ expenseDate: 'desc' }, { createdAt: 'desc' }], skip: options && options.offset, take: options && options.length, diff --git a/src/trpc/client.tsx b/src/trpc/client.tsx new file mode 100644 index 00000000..0c9fc506 --- /dev/null +++ b/src/trpc/client.tsx @@ -0,0 +1,60 @@ +'use client' // <-- to make sure we can mount the Provider from a server component +import type { QueryClient } from '@tanstack/react-query' +import { QueryClientProvider } from '@tanstack/react-query' +import { httpBatchLink } from '@trpc/client' +import { createTRPCReact } from '@trpc/react-query' +import { useState } from 'react' +import superjson from 'superjson' +import { makeQueryClient } from './query-client' +import type { AppRouter } from './routers/_app' + +export const trpc = createTRPCReact() + +let clientQueryClientSingleton: QueryClient + +function getQueryClient() { + if (typeof window === 'undefined') { + // Server: always make a new query client + return makeQueryClient() + } + // Browser: use singleton pattern to keep the same query client + return (clientQueryClientSingleton ??= makeQueryClient()) +} + +function getUrl() { + const base = (() => { + if (typeof window !== 'undefined') return '' + if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}` + return 'http://localhost:3000' + })() + return `${base}/api/trpc` +} + +export function TRPCProvider( + props: Readonly<{ + children: React.ReactNode + }>, +) { + // NOTE: Avoid useState when initializing the query client if you don't + // have a suspense boundary between this and the code that may + // suspend because React will throw away the client on the initial + // render if it suspends and there is no boundary + const queryClient = getQueryClient() + const [trpcClient] = useState(() => + trpc.createClient({ + links: [ + httpBatchLink({ + transformer: superjson, + url: getUrl(), + }), + ], + }), + ) + return ( + + + {props.children} + + + ) +} diff --git a/src/trpc/init.ts b/src/trpc/init.ts new file mode 100644 index 00000000..3011b119 --- /dev/null +++ b/src/trpc/init.ts @@ -0,0 +1,25 @@ +import { initTRPC } from '@trpc/server' +import { cache } from 'react' +import superjson from 'superjson' + +export const createTRPCContext = cache(async () => { + /** + * @see: https://trpc.io/docs/server/context + */ + return {} +}) + +// Avoid exporting the entire t-object +// since it's not very descriptive. +// For instance, the use of a t variable +// is common in i18n libraries. +const t = initTRPC.create({ + /** + * @see https://trpc.io/docs/server/data-transformers + */ + transformer: superjson, +}) + +// Base router and procedure helpers +export const createTRPCRouter = t.router +export const baseProcedure = t.procedure diff --git a/src/trpc/query-client.ts b/src/trpc/query-client.ts new file mode 100644 index 00000000..20774c1c --- /dev/null +++ b/src/trpc/query-client.ts @@ -0,0 +1,21 @@ +import { defaultShouldDehydrateQuery, QueryClient } from '@tanstack/react-query' +import superjson from 'superjson' + +export function makeQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30 * 1000, + }, + dehydrate: { + serializeData: superjson.serialize, + shouldDehydrateQuery: (query) => + defaultShouldDehydrateQuery(query) || + query.state.status === 'pending', + }, + hydrate: { + deserializeData: superjson.deserialize, + }, + }, + }) +} diff --git a/src/trpc/routers/_app.ts b/src/trpc/routers/_app.ts new file mode 100644 index 00000000..78add24f --- /dev/null +++ b/src/trpc/routers/_app.ts @@ -0,0 +1,10 @@ +import { groupsRouter } from '@/trpc/routers/groups' +import { inferRouterOutputs } from '@trpc/server' +import { createTRPCRouter } from '../init' + +export const appRouter = createTRPCRouter({ + groups: groupsRouter, +}) + +export type AppRouter = typeof appRouter +export type AppRouterOutput = inferRouterOutputs diff --git a/src/trpc/routers/groups/balances/index.ts b/src/trpc/routers/groups/balances/index.ts new file mode 100644 index 00000000..626ed64e --- /dev/null +++ b/src/trpc/routers/groups/balances/index.ts @@ -0,0 +1,6 @@ +import { createTRPCRouter } from '@/trpc/init' +import { listGroupBalancesProcedure } from '@/trpc/routers/groups/balances/list.procedure' + +export const groupBalancesRouter = createTRPCRouter({ + list: listGroupBalancesProcedure, +}) diff --git a/src/trpc/routers/groups/balances/list.procedure.ts b/src/trpc/routers/groups/balances/list.procedure.ts new file mode 100644 index 00000000..b851a1af --- /dev/null +++ b/src/trpc/routers/groups/balances/list.procedure.ts @@ -0,0 +1,19 @@ +import { getGroupExpenses } from '@/lib/api' +import { + getBalances, + getPublicBalances, + getSuggestedReimbursements, +} from '@/lib/balances' +import { baseProcedure } from '@/trpc/init' +import { z } from 'zod' + +export const listGroupBalancesProcedure = baseProcedure + .input(z.object({ groupId: z.string().min(1) })) + .query(async ({ input: { groupId } }) => { + const expenses = await getGroupExpenses(groupId) + const balances = getBalances(expenses) + const reimbursements = getSuggestedReimbursements(balances) + const publicBalances = getPublicBalances(reimbursements) + + return { balances: publicBalances, reimbursements } + }) diff --git a/src/trpc/routers/groups/expenses/index.ts b/src/trpc/routers/groups/expenses/index.ts new file mode 100644 index 00000000..d457ade2 --- /dev/null +++ b/src/trpc/routers/groups/expenses/index.ts @@ -0,0 +1,6 @@ +import { createTRPCRouter } from '@/trpc/init' +import { listGroupExpensesProcedure } from '@/trpc/routers/groups/expenses/list.procedure' + +export const groupExpensesRouter = createTRPCRouter({ + list: listGroupExpensesProcedure, +}) diff --git a/src/trpc/routers/groups/expenses/list.procedure.ts b/src/trpc/routers/groups/expenses/list.procedure.ts new file mode 100644 index 00000000..d2f5dbf1 --- /dev/null +++ b/src/trpc/routers/groups/expenses/list.procedure.ts @@ -0,0 +1,29 @@ +import { getGroupExpenses } from '@/lib/api' +import { baseProcedure } from '@/trpc/init' +import { z } from 'zod' + +export const listGroupExpensesProcedure = baseProcedure + .input( + z.object({ + groupId: z.string().min(1), + cursor: z.number().optional(), + limit: z.number().optional(), + filter: z.string().optional(), + }), + ) + .query(async ({ input: { groupId, cursor = 0, limit = 10, filter } }) => { + const expenses = await getGroupExpenses(groupId, { + offset: cursor, + length: limit + 1, + filter, + }) + return { + expenses: expenses.slice(0, limit).map((expense) => ({ + ...expense, + createdAt: new Date(expense.createdAt), + expenseDate: new Date(expense.expenseDate), + })), + hasMore: !!expenses[limit], + nextCursor: cursor + limit, + } + }) diff --git a/src/trpc/routers/groups/index.ts b/src/trpc/routers/groups/index.ts new file mode 100644 index 00000000..107c7511 --- /dev/null +++ b/src/trpc/routers/groups/index.ts @@ -0,0 +1,10 @@ +import { createTRPCRouter } from '@/trpc/init' +import { groupBalancesRouter } from '@/trpc/routers/groups/balances' +import { groupExpensesRouter } from '@/trpc/routers/groups/expenses' +import { groupInformationRouter } from '@/trpc/routers/groups/information' + +export const groupsRouter = createTRPCRouter({ + expenses: groupExpensesRouter, + balances: groupBalancesRouter, + information: groupInformationRouter, +}) diff --git a/src/trpc/routers/groups/information/get.procedure.ts b/src/trpc/routers/groups/information/get.procedure.ts new file mode 100644 index 00000000..3b81ead2 --- /dev/null +++ b/src/trpc/routers/groups/information/get.procedure.ts @@ -0,0 +1,21 @@ +import { getGroup } from '@/lib/api' +import { baseProcedure } from '@/trpc/init' +import { TRPCError } from '@trpc/server' +import { z } from 'zod' + +export const getGroupInformationProcedure = baseProcedure + .input( + z.object({ + groupId: z.string().min(1), + }), + ) + .query(async ({ input: { groupId } }) => { + const group = await getGroup(groupId) + if (!group) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Group not found.', + }) + } + return { information: group.information ?? '' } + }) diff --git a/src/trpc/routers/groups/information/index.ts b/src/trpc/routers/groups/information/index.ts new file mode 100644 index 00000000..f7d39489 --- /dev/null +++ b/src/trpc/routers/groups/information/index.ts @@ -0,0 +1,6 @@ +import { createTRPCRouter } from '@/trpc/init' +import { getGroupInformationProcedure } from '@/trpc/routers/groups/information/get.procedure' + +export const groupInformationRouter = createTRPCRouter({ + get: getGroupInformationProcedure, +})