diff --git a/src/app/groups/[groupId]/activity/activity-list.tsx b/src/app/groups/[groupId]/activity/activity-list.tsx index 04717172..7dfe538a 100644 --- a/src/app/groups/[groupId]/activity/activity-list.tsx +++ b/src/app/groups/[groupId]/activity/activity-list.tsx @@ -9,6 +9,7 @@ import dayjs, { type Dayjs } from 'dayjs' import { useTranslations } from 'next-intl' import { forwardRef, useEffect } from 'react' import { useInView } from 'react-intersection-observer' +import { useCurrentGroup } from '../current-group-context' const PAGE_SIZE = 20 @@ -82,11 +83,9 @@ const ActivitiesLoading = forwardRef((_, ref) => { }) ActivitiesLoading.displayName = 'ActivitiesLoading' -export function ActivityList({ groupId }: { groupId: string }) { +export function ActivityList() { const t = useTranslations('Activity') - - const { data: groupData, isLoading: groupIsLoading } = - trpc.groups.get.useQuery({ groupId }) + const { group, groupId } = useCurrentGroup() const { data: activitiesData, @@ -105,7 +104,7 @@ export function ActivityList({ groupId }: { groupId: string }) { if (inView && hasMore && !isLoading) fetchNextPage() }, [fetchNextPage, hasMore, inView, isLoading]) - if (isLoading || !activities || !groupData) return + if (isLoading || !activities || !group) return const groupedActivitiesByDate = getGroupedActivitiesByDate(activities) @@ -131,7 +130,7 @@ export function ActivityList({ groupId }: { groupId: string }) { {groupActivities.map((activity) => { const participant = activity.participantId !== null - ? groupData.group.participants.find( + ? group.participants.find( (p) => p.id === activity.participantId, ) : undefined diff --git a/src/app/groups/[groupId]/activity/page.client.tsx b/src/app/groups/[groupId]/activity/page.client.tsx index 4f3318cd..3090bd3c 100644 --- a/src/app/groups/[groupId]/activity/page.client.tsx +++ b/src/app/groups/[groupId]/activity/page.client.tsx @@ -13,7 +13,7 @@ export const metadata: Metadata = { title: 'Activity', } -export function ActivityPageClient({ groupId }: { groupId: string }) { +export function ActivityPageClient() { const t = useTranslations('Activity') return ( @@ -24,7 +24,7 @@ export function ActivityPageClient({ groupId }: { groupId: string }) { {t('description')} - + diff --git a/src/app/groups/[groupId]/activity/page.tsx b/src/app/groups/[groupId]/activity/page.tsx index 43408214..e80e4983 100644 --- a/src/app/groups/[groupId]/activity/page.tsx +++ b/src/app/groups/[groupId]/activity/page.tsx @@ -5,10 +5,6 @@ export const metadata: Metadata = { title: 'Activity', } -export default async function ActivityPage({ - params: { groupId }, -}: { - params: { groupId: string } -}) { - return +export default async function ActivityPage() { + return } diff --git a/src/app/groups/[groupId]/balances/balances-and-reimbursements.tsx b/src/app/groups/[groupId]/balances/balances-and-reimbursements.tsx index 687f8879..461cfe3f 100644 --- a/src/app/groups/[groupId]/balances/balances-and-reimbursements.tsx +++ b/src/app/groups/[groupId]/balances/balances-and-reimbursements.tsx @@ -13,15 +13,12 @@ import { Skeleton } from '@/components/ui/skeleton' import { trpc } from '@/trpc/client' import { useTranslations } from 'next-intl' import { Fragment, useEffect } from 'react' +import { match } from 'ts-pattern' +import { useCurrentGroup } from '../current-group-context' -export default function BalancesAndReimbursements({ - groupId, -}: { - groupId: string -}) { +export default function BalancesAndReimbursements() { const utils = trpc.useUtils() - const { data: groupData, isLoading: groupIsLoading } = - trpc.groups.get.useQuery({ groupId }) + const { groupId, group } = useCurrentGroup() const { data: balancesData, isLoading: balancesAreLoading } = trpc.groups.balances.list.useQuery({ groupId, @@ -34,8 +31,7 @@ export default function BalancesAndReimbursements({ utils.groups.balances.invalidate() }, [utils]) - const isLoading = - balancesAreLoading || !balancesData || groupIsLoading || !groupData?.group + const isLoading = balancesAreLoading || !balancesData || !group return ( <> @@ -46,14 +42,12 @@ export default function BalancesAndReimbursements({ {isLoading ? ( - + ) : ( )} @@ -66,14 +60,14 @@ export default function BalancesAndReimbursements({ {isLoading ? ( ) : ( )} @@ -109,6 +103,12 @@ const BalancesLoading = ({ }: { participantCount?: number }) => { + const barWidth = (index: number) => + match(index % 3) + .with(0, () => 'w-1/3') + .with(1, () => 'w-2/3') + .otherwise(() => 'w-full') + return (
{Array(participantCount) @@ -120,17 +120,13 @@ const BalancesLoading = ({
- +
) : (
- +
diff --git a/src/app/groups/[groupId]/balances/page.tsx b/src/app/groups/[groupId]/balances/page.tsx index a9e7c813..456f40b2 100644 --- a/src/app/groups/[groupId]/balances/page.tsx +++ b/src/app/groups/[groupId]/balances/page.tsx @@ -1,19 +1,10 @@ -import { cached } from '@/app/cached-functions' import BalancesAndReimbursements from '@/app/groups/[groupId]/balances/balances-and-reimbursements' import { Metadata } from 'next' -import { notFound } from 'next/navigation' export const metadata: Metadata = { title: 'Balances', } -export default async function GroupPage({ - params: { groupId }, -}: { - params: { groupId: string } -}) { - const group = await cached.getGroup(groupId) - if (!group) notFound() - - return +export default async function GroupPage() { + return } diff --git a/src/app/groups/[groupId]/current-group-context.tsx b/src/app/groups/[groupId]/current-group-context.tsx new file mode 100644 index 00000000..b2a47991 --- /dev/null +++ b/src/app/groups/[groupId]/current-group-context.tsx @@ -0,0 +1,30 @@ +import { AppRouterOutput } from '@/trpc/routers/_app' +import { PropsWithChildren, createContext, useContext } from 'react' + +type Group = NonNullable + +type GroupContext = + | { isLoading: false; groupId: string; group: Group } + | { isLoading: true; groupId: string; group: undefined } + +const CurrentGroupContext = createContext(null) + +export const useCurrentGroup = () => { + const context = useContext(CurrentGroupContext) + if (!context) + throw new Error( + 'Missing context. Should be called inside a CurrentGroupProvider.', + ) + return context +} + +export const CurrentGroupProvider = ({ + children, + ...props +}: PropsWithChildren) => { + return ( + + {children} + + ) +} diff --git a/src/app/groups/[groupId]/edit/edit-group.tsx b/src/app/groups/[groupId]/edit/edit-group.tsx index 52cb4a1b..9189c895 100644 --- a/src/app/groups/[groupId]/edit/edit-group.tsx +++ b/src/app/groups/[groupId]/edit/edit-group.tsx @@ -2,9 +2,11 @@ import { GroupForm } from '@/components/group-form' import { trpc } from '@/trpc/client' +import { useCurrentGroup } from '../current-group-context' -export const EditGroup = ({ groupId }: { groupId: string }) => { - const { data, isLoading } = trpc.groups.get.useQuery({ groupId }) +export const EditGroup = () => { + const { groupId } = useCurrentGroup() + const { data, isLoading } = trpc.groups.getDetails.useQuery({ groupId }) const { mutateAsync } = trpc.groups.update.useMutation() const utils = trpc.useUtils() diff --git a/src/app/groups/[groupId]/edit/page.tsx b/src/app/groups/[groupId]/edit/page.tsx index 66b83ed4..aac1d928 100644 --- a/src/app/groups/[groupId]/edit/page.tsx +++ b/src/app/groups/[groupId]/edit/page.tsx @@ -5,10 +5,6 @@ export const metadata: Metadata = { title: 'Settings', } -export default async function EditGroupPage({ - params: { groupId }, -}: { - params: { groupId: string } -}) { - return +export default async function EditGroupPage() { + return } diff --git a/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx b/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx index cd8edfd9..34c42dee 100644 --- a/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx +++ b/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx @@ -34,14 +34,11 @@ import { getImageData, usePresignedUpload } from 'next-s3-upload' import Image from 'next/image' import { useRouter } from 'next/navigation' import { PropsWithChildren, ReactNode, useState } from 'react' +import { useCurrentGroup } from '../current-group-context' const MAX_FILE_SIZE = 5 * 1024 ** 2 -export function CreateFromReceiptButton({ groupId }: { groupId: string }) { - return -} - -function CreateFromReceiptButton_({ groupId }: { groupId: string }) { +export function CreateFromReceiptButton() { const t = useTranslations('CreateFromReceipt') const isDesktop = useMediaQuery('(min-width: 640px)') @@ -70,15 +67,14 @@ function CreateFromReceiptButton_({ groupId }: { groupId: string }) { } description={<>{t('Dialog.description')}} > - + ) } -function ReceiptDialogContent({ groupId }: { groupId: string }) { - const { data: groupData } = trpc.groups.get.useQuery({ groupId }) +function ReceiptDialogContent() { + const { group } = useCurrentGroup() const { data: categoriesData } = trpc.categories.list.useQuery() - const group = groupData?.group const categories = categoriesData?.categories const locale = useLocale() diff --git a/src/app/groups/[groupId]/expenses/expense-form.tsx b/src/app/groups/[groupId]/expenses/expense-form.tsx index 08f44363..b0bb6789 100644 --- a/src/app/groups/[groupId]/expenses/expense-form.tsx +++ b/src/app/groups/[groupId]/expenses/expense-form.tsx @@ -64,7 +64,7 @@ const enforceCurrencyPattern = (value: string) => .replace(/[^-\d.]/g, '') // remove all non-numeric characters const getDefaultSplittingOptions = ( - group: AppRouterOutput['groups']['get']['group'], + group: NonNullable, ) => { const defaultValue = { splitMode: 'EVENLY' as const, @@ -145,7 +145,7 @@ export function ExpenseForm({ onDelete, runtimeFeatureFlags, }: { - group: AppRouterOutput['groups']['get']['group'] + group: NonNullable categories: AppRouterOutput['categories']['list']['categories'] expense?: AppRouterOutput['groups']['expenses']['get']['expense'] onSubmit: (value: ExpenseFormValues, participantId?: string) => Promise @@ -250,7 +250,6 @@ export function ExpenseForm({ >(new Set()) const sExpense = isIncome ? 'Income' : 'Expense' - const sPaid = isIncome ? 'received' : 'paid' useEffect(() => { setManuallyEditedParticipants(new Set()) diff --git a/src/app/groups/[groupId]/expenses/expense-list.tsx b/src/app/groups/[groupId]/expenses/expense-list.tsx index 66ef409f..bd498a1e 100644 --- a/src/app/groups/[groupId]/expenses/expense-list.tsx +++ b/src/app/groups/[groupId]/expenses/expense-list.tsx @@ -11,6 +11,7 @@ import Link from 'next/link' import { forwardRef, useEffect, useMemo, useState } from 'react' import { useInView } from 'react-intersection-observer' import { useDebounce } from 'use-debounce' +import { useCurrentGroup } from '../current-group-context' const PAGE_SIZE = 20 @@ -56,12 +57,12 @@ function getGroupedExpensesByDate(expenses: ExpensesType) { }, {}) } -export function ExpenseList({ groupId }: { groupId: string }) { - const { data: groupData } = trpc.groups.get.useQuery({ groupId }) +export function ExpenseList() { + const { groupId, group } = useCurrentGroup() const [searchText, setSearchText] = useState('') const [debouncedSearchText] = useDebounce(searchText, 300) - const participants = groupData?.group.participants + const participants = group?.participants useEffect(() => { if (!participants) return @@ -103,6 +104,7 @@ const ExpenseListForSearch = ({ searchText: string }) => { const utils = trpc.useUtils() + const { group } = useCurrentGroup() useEffect(() => { // Until we use tRPC more widely and can invalidate the cache on expense @@ -124,11 +126,7 @@ const ExpenseListForSearch = ({ const expenses = data?.pages.flatMap((page) => page.expenses) const hasMore = data?.pages.at(-1)?.hasMore ?? false - const { data: groupData, isLoading: groupIsLoading } = - trpc.groups.get.useQuery({ groupId }) - - const isLoading = - expensesAreLoading || !expenses || groupIsLoading || !groupData + const isLoading = expensesAreLoading || !expenses || !group useEffect(() => { if (inView && hasMore && !isLoading) fetchNextPage() @@ -172,7 +170,7 @@ const ExpenseListForSearch = ({ ))} diff --git a/src/app/groups/[groupId]/expenses/page.client.tsx b/src/app/groups/[groupId]/expenses/page.client.tsx index 94827b12..2a9888e3 100644 --- a/src/app/groups/[groupId]/expenses/page.client.tsx +++ b/src/app/groups/[groupId]/expenses/page.client.tsx @@ -15,6 +15,7 @@ import { Download, Plus } from 'lucide-react' import { Metadata } from 'next' import { useTranslations } from 'next-intl' import Link from 'next/link' +import { useCurrentGroup } from '../current-group-context' export const revalidate = 3600 @@ -23,13 +24,12 @@ export const metadata: Metadata = { } export default function GroupExpensesPageClient({ - groupId, enableReceiptExtract, }: { - groupId: string enableReceiptExtract: boolean }) { const t = useTranslations('Expenses') + const { groupId } = useCurrentGroup() return ( <> @@ -50,9 +50,7 @@ export default function GroupExpensesPageClient({ - {enableReceiptExtract && ( - - )} + {enableReceiptExtract && } + diff --git a/src/app/groups/recent-group-list-card.tsx b/src/app/groups/recent-group-list-card.tsx index 30692102..8984e783 100644 --- a/src/app/groups/recent-group-list-card.tsx +++ b/src/app/groups/recent-group-list-card.tsx @@ -1,12 +1,7 @@ -'use client' -import { RecentGroupsState } from '@/app/groups/recent-group-list' import { RecentGroup, archiveGroup, deleteRecentGroup, - getArchivedGroups, - getStarredGroups, - saveRecentGroup, starGroup, unarchiveGroup, unstarGroup, @@ -19,46 +14,32 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Skeleton } from '@/components/ui/skeleton' -import { ToastAction } from '@/components/ui/toast' import { useToast } from '@/components/ui/use-toast' +import { AppRouterOutput } from '@/trpc/routers/_app' import { StarFilledIcon } from '@radix-ui/react-icons' import { Calendar, MoreHorizontal, Star, Users } from 'lucide-react' import { useLocale, useTranslations } from 'next-intl' import Link from 'next/link' import { useRouter } from 'next/navigation' -import { SetStateAction } from 'react' export function RecentGroupListCard({ group, - state, - setState, + groupDetail, + isStarred, + isArchived, + refreshGroupsFromStorage, }: { group: RecentGroup - state: RecentGroupsState - setState: (state: SetStateAction) => void + groupDetail?: AppRouterOutput['groups']['list']['groups'][number] + isStarred: boolean + isArchived: boolean + refreshGroupsFromStorage: () => void }) { const router = useRouter() const locale = useLocale() const toast = useToast() const t = useTranslations('Groups') - const details = - state.status === 'complete' - ? state.groupsDetails.find((d) => d.id === group.id) - : null - - if (state.status === 'pending') return null - - const refreshGroupsFromStorage = () => - setState({ - ...state, - starredGroups: getStarredGroups(), - archivedGroups: getArchivedGroups(), - }) - - const isStarred = state.starredGroups.includes(group.id) - const isArchived = state.archivedGroups.includes(group.id) - return (
  • - {details ? ( + {groupDetail ? (
    - {details._count.participants} + {groupDetail._count.participants}
    - {new Date(details.createdAt).toLocaleDateString(locale, { - dateStyle: 'medium', - })} + {new Date(groupDetail.createdAt).toLocaleDateString( + locale, + { + dateStyle: 'medium', + }, + )}
    diff --git a/src/app/groups/recent-group-list.tsx b/src/app/groups/recent-group-list.tsx index ad01d283..3d6465e6 100644 --- a/src/app/groups/recent-group-list.tsx +++ b/src/app/groups/recent-group-list.tsx @@ -1,5 +1,4 @@ 'use client' -import { getGroupsAction } from '@/app/groups/actions' import { AddGroupByUrlButton } from '@/app/groups/add-group-by-url-button' import { RecentGroups, @@ -9,10 +8,12 @@ import { } from '@/app/groups/recent-groups-helpers' import { Button } from '@/components/ui/button' import { getGroups } from '@/lib/api' +import { trpc } from '@/trpc/client' +import { AppRouterOutput } from '@/trpc/routers/_app' import { Loader2 } from 'lucide-react' import { useTranslations } from 'next-intl' import Link from 'next/link' -import { PropsWithChildren, SetStateAction, useEffect, useState } from 'react' +import { PropsWithChildren, useEffect, useState } from 'react' import { RecentGroupListCard } from './recent-group-list-card' export type RecentGroupsState = @@ -31,16 +32,22 @@ export type RecentGroupsState = archivedGroups: string[] } -function sortGroups( - state: RecentGroupsState & { status: 'complete' | 'partial' }, -) { +function sortGroups({ + groups, + starredGroups, + archivedGroups, +}: { + groups: RecentGroups + starredGroups: string[] + archivedGroups: string[] +}) { const starredGroupInfo = [] const groupInfo = [] const archivedGroupInfo = [] - for (const group of state.groups) { - if (state.starredGroups.includes(group.id)) { + for (const group of groups) { + if (starredGroups.includes(group.id)) { starredGroupInfo.push(group) - } else if (state.archivedGroups.includes(group.id)) { + } else if (archivedGroups.includes(group.id)) { archivedGroupInfo.push(group) } else { groupInfo.push(group) @@ -54,7 +61,6 @@ function sortGroups( } export function RecentGroupList() { - const t = useTranslations('Groups') const [state, setState] = useState({ status: 'pending' }) function loadGroups() { @@ -67,24 +73,43 @@ export function RecentGroupList() { starredGroups, archivedGroups, }) - getGroupsAction(groupsInStorage.map((g) => g.id)).then((groupsDetails) => { - setState({ - status: 'complete', - groups: groupsInStorage, - groupsDetails, - starredGroups, - archivedGroups, - }) - }) } useEffect(() => { loadGroups() }, []) - if (state.status === 'pending') { + if (state.status === 'pending') return null + + return ( + loadGroups()} + /> + ) +} + +function RecentGroupList_({ + groups, + starredGroups, + archivedGroups, + refreshGroupsFromStorage, +}: { + groups: RecentGroups + starredGroups: string[] + archivedGroups: string[] + refreshGroupsFromStorage: () => void +}) { + const t = useTranslations('Groups') + const { data, isLoading } = trpc.groups.list.useQuery({ + groupIds: groups.map((group) => group.id), + }) + + if (isLoading || !data) { return ( - +

    {' '} {t('loadingRecent')} @@ -93,9 +118,9 @@ export function RecentGroupList() { ) } - if (state.groups.length === 0) { + if (data.groups.length === 0) { return ( - +

    {t('NoRecent.description')}

    @@ -109,17 +134,23 @@ export function RecentGroupList() { ) } - const { starredGroupInfo, groupInfo, archivedGroupInfo } = sortGroups(state) + const { starredGroupInfo, groupInfo, archivedGroupInfo } = sortGroups({ + groups, + starredGroups, + archivedGroups, + }) return ( - + {starredGroupInfo.length > 0 && ( <>

    {t('starred')}

    )} @@ -127,7 +158,13 @@ export function RecentGroupList() { {groupInfo.length > 0 && ( <>

    {t('recent')}

    - + )} @@ -137,8 +174,10 @@ export function RecentGroupList() {
    @@ -149,12 +188,16 @@ export function RecentGroupList() { function GroupList({ groups, - state, - setState, + groupDetails, + starredGroups, + archivedGroups, + refreshGroupsFromStorage, }: { groups: RecentGroups - state: RecentGroupsState - setState: (state: SetStateAction) => void + groupDetails?: AppRouterOutput['groups']['list']['groups'] + starredGroups: string[] + archivedGroups: string[] + refreshGroupsFromStorage: () => void }) { return (
      @@ -162,8 +205,12 @@ function GroupList({ groupDetail.id === group.id, + )} + isStarred={starredGroups.includes(group.id)} + isArchived={archivedGroups.includes(group.id)} + refreshGroupsFromStorage={refreshGroupsFromStorage} /> ))}
    diff --git a/src/trpc/client.tsx b/src/trpc/client.tsx index 0c9fc506..7d9065f1 100644 --- a/src/trpc/client.tsx +++ b/src/trpc/client.tsx @@ -21,6 +21,8 @@ function getQueryClient() { return (clientQueryClientSingleton ??= makeQueryClient()) } +export const trpcClient = getQueryClient() + function getUrl() { const base = (() => { if (typeof window !== 'undefined') return '' diff --git a/src/trpc/routers/groups/get.procedure.ts b/src/trpc/routers/groups/get.procedure.ts index 02841ffd..331a6fc0 100644 --- a/src/trpc/routers/groups/get.procedure.ts +++ b/src/trpc/routers/groups/get.procedure.ts @@ -1,19 +1,10 @@ -import { getGroup, getGroupExpensesParticipants } from '@/lib/api' +import { getGroup } from '@/lib/api' import { baseProcedure } from '@/trpc/init' -import { TRPCError } from '@trpc/server' import { z } from 'zod' export const getGroupProcedure = 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.', - }) - } - - const participantsWithExpenses = await getGroupExpensesParticipants(groupId) - return { group, participantsWithExpenses } + return { group } }) diff --git a/src/trpc/routers/groups/getDetails.procedure.ts b/src/trpc/routers/groups/getDetails.procedure.ts new file mode 100644 index 00000000..831b6a85 --- /dev/null +++ b/src/trpc/routers/groups/getDetails.procedure.ts @@ -0,0 +1,19 @@ +import { getGroup, getGroupExpensesParticipants } from '@/lib/api' +import { baseProcedure } from '@/trpc/init' +import { TRPCError } from '@trpc/server' +import { z } from 'zod' + +export const getGroupDetailsProcedure = 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.', + }) + } + + const participantsWithExpenses = await getGroupExpensesParticipants(groupId) + return { group, participantsWithExpenses } + }) diff --git a/src/trpc/routers/groups/index.ts b/src/trpc/routers/groups/index.ts index c4f02d8e..13222883 100644 --- a/src/trpc/routers/groups/index.ts +++ b/src/trpc/routers/groups/index.ts @@ -6,6 +6,8 @@ import { groupExpensesRouter } from '@/trpc/routers/groups/expenses' import { getGroupProcedure } from '@/trpc/routers/groups/get.procedure' import { groupStatsRouter } from '@/trpc/routers/groups/stats' import { updateGroupProcedure } from '@/trpc/routers/groups/update.procedure' +import { getGroupDetailsProcedure } from './getDetails.procedure' +import { listGroupsProcedure } from './list.procedure' export const groupsRouter = createTRPCRouter({ expenses: groupExpensesRouter, @@ -14,6 +16,8 @@ export const groupsRouter = createTRPCRouter({ activities: activitiesRouter, get: getGroupProcedure, + getDetails: getGroupDetailsProcedure, + list: listGroupsProcedure, create: createGroupProcedure, update: updateGroupProcedure, }) diff --git a/src/trpc/routers/groups/list.procedure.ts b/src/trpc/routers/groups/list.procedure.ts new file mode 100644 index 00000000..557288aa --- /dev/null +++ b/src/trpc/routers/groups/list.procedure.ts @@ -0,0 +1,14 @@ +import { getGroups } from '@/lib/api' +import { baseProcedure } from '@/trpc/init' +import { z } from 'zod' + +export const listGroupsProcedure = baseProcedure + .input( + z.object({ + groupIds: z.array(z.string().min(1)), + }), + ) + .query(async ({ input: { groupIds } }) => { + const groups = await getGroups(groupIds) + return { groups } + })