Use tRPC for recent groups page (#253)

* Use tRPC for recent groups page

* Use tRPC for adding group by URL

* Use tRPC for saving visited group

* Group context
This commit is contained in:
Sebastien Castiel
2024-10-20 17:43:42 -04:00
parent 39c1a2ffc6
commit 4db788680e
31 changed files with 314 additions and 260 deletions

View File

@@ -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<HTMLDivElement>((_, 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 <ActivitiesLoading />
if (isLoading || !activities || !group) return <ActivitiesLoading />
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

View File

@@ -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 }) {
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col space-y-4">
<ActivityList groupId={groupId} />
<ActivityList />
</CardContent>
</Card>
</>

View File

@@ -5,10 +5,6 @@ export const metadata: Metadata = {
title: 'Activity',
}
export default async function ActivityPage({
params: { groupId },
}: {
params: { groupId: string }
}) {
return <ActivityPageClient groupId={groupId} />
export default async function ActivityPage() {
return <ActivityPageClient />
}

View File

@@ -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({
</CardHeader>
<CardContent>
{isLoading ? (
<BalancesLoading
participantCount={groupData?.group.participants.length}
/>
<BalancesLoading participantCount={group?.participants.length} />
) : (
<BalancesList
balances={balancesData.balances}
participants={groupData.group.participants}
currency={groupData.group.currency}
participants={group?.participants}
currency={group?.currency}
/>
)}
</CardContent>
@@ -66,14 +60,14 @@ export default function BalancesAndReimbursements({
<CardContent>
{isLoading ? (
<ReimbursementsLoading
participantCount={groupData?.group.participants.length}
participantCount={group?.participants.length}
/>
) : (
<ReimbursementList
reimbursements={balancesData.reimbursements}
participants={groupData.group.participants}
currency={groupData.group.currency}
groupId={groupData.group.id}
participants={group?.participants}
currency={group?.currency}
groupId={groupId}
/>
)}
</CardContent>
@@ -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 (
<div className="grid grid-cols-2 py-1 gap-y-2">
{Array(participantCount)
@@ -120,17 +120,13 @@ const BalancesLoading = ({
<Skeleton className="h-3 w-16" />
</div>
<div className="self-start">
<Skeleton
className={`h-7 w-${(index % 3) + 1}/3 rounded-l-none`}
/>
<Skeleton className={`h-7 ${barWidth(index)} rounded-l-none`} />
</div>
</Fragment>
) : (
<Fragment key={index}>
<div className="flex items-center justify-end">
<Skeleton
className={`h-7 w-${(index % 3) + 1}/3 rounded-r-none`}
/>
<Skeleton className={`h-7 ${barWidth(index)} rounded-r-none`} />
</div>
<div className="flex items-center pl-2">
<Skeleton className="h-3 w-16" />

View File

@@ -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 <BalancesAndReimbursements groupId={groupId} />
export default async function GroupPage() {
return <BalancesAndReimbursements />
}

View File

@@ -0,0 +1,30 @@
import { AppRouterOutput } from '@/trpc/routers/_app'
import { PropsWithChildren, createContext, useContext } from 'react'
type Group = NonNullable<AppRouterOutput['groups']['get']['group']>
type GroupContext =
| { isLoading: false; groupId: string; group: Group }
| { isLoading: true; groupId: string; group: undefined }
const CurrentGroupContext = createContext<GroupContext | null>(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<GroupContext>) => {
return (
<CurrentGroupContext.Provider value={props}>
{children}
</CurrentGroupContext.Provider>
)
}

View File

@@ -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()

View File

@@ -5,10 +5,6 @@ export const metadata: Metadata = {
title: 'Settings',
}
export default async function EditGroupPage({
params: { groupId },
}: {
params: { groupId: string }
}) {
return <EditGroup groupId={groupId} />
export default async function EditGroupPage() {
return <EditGroup />
}

View File

@@ -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 <CreateFromReceiptButton_ groupId={groupId} />
}
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')}</>}
>
<ReceiptDialogContent groupId={groupId} />
<ReceiptDialogContent />
</DialogOrDrawer>
)
}
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()

View File

@@ -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<AppRouterOutput['groups']['get']['group']>,
) => {
const defaultValue = {
splitMode: 'EVENLY' as const,
@@ -145,7 +145,7 @@ export function ExpenseForm({
onDelete,
runtimeFeatureFlags,
}: {
group: AppRouterOutput['groups']['get']['group']
group: NonNullable<AppRouterOutput['groups']['get']['group']>
categories: AppRouterOutput['categories']['list']['categories']
expense?: AppRouterOutput['groups']['expenses']['get']['expense']
onSubmit: (value: ExpenseFormValues, participantId?: string) => Promise<void>
@@ -250,7 +250,6 @@ export function ExpenseForm({
>(new Set())
const sExpense = isIncome ? 'Income' : 'Expense'
const sPaid = isIncome ? 'received' : 'paid'
useEffect(() => {
setManuallyEditedParticipants(new Set())

View File

@@ -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 = ({
<ExpenseCard
key={expense.id}
expense={expense}
currency={groupData.group.currency}
currency={group.currency}
groupId={groupId}
/>
))}

View File

@@ -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({
<Download className="w-4 h-4" />
</Link>
</Button>
{enableReceiptExtract && (
<CreateFromReceiptButton groupId={groupId} />
)}
{enableReceiptExtract && <CreateFromReceiptButton />}
<Button asChild size="icon">
<Link
href={`/groups/${groupId}/expenses/create`}
@@ -65,7 +63,7 @@ export default function GroupExpensesPageClient({
</div>
<CardContent className="p-0 pt-2 pb-4 sm:pb-6 flex flex-col gap-4 relative">
<ExpenseList groupId={groupId} />
<ExpenseList />
</CardContent>
</Card>

View File

@@ -8,14 +8,9 @@ export const metadata: Metadata = {
title: 'Expenses',
}
export default async function GroupExpensesPage({
params: { groupId },
}: {
params: { groupId: string }
}) {
export default async function GroupExpensesPage() {
return (
<GroupExpensesPageClient
groupId={groupId}
enableReceiptExtract={env.NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT}
/>
)

View File

@@ -3,27 +3,27 @@
import { GroupTabs } from '@/app/groups/[groupId]/group-tabs'
import { ShareButton } from '@/app/groups/[groupId]/share-button'
import { Skeleton } from '@/components/ui/skeleton'
import { trpc } from '@/trpc/client'
import Link from 'next/link'
import { useCurrentGroup } from './current-group-context'
export const GroupHeader = ({ groupId }: { groupId: string }) => {
const { data, isLoading } = trpc.groups.get.useQuery({ groupId })
export const GroupHeader = () => {
const { isLoading, groupId, group } = useCurrentGroup()
return (
<div className="flex flex-col justify-between gap-3">
<h1 className="font-bold text-2xl">
<Link href={`/groups/${groupId}`}>
{isLoading || !data ? (
{isLoading ? (
<Skeleton className="mt-1.5 mb-1.5 h-5 w-32" />
) : (
<div className="flex">{data.group.name}</div>
<div className="flex">{group.name}</div>
)}
</Link>
</h1>
<div className="flex gap-2 justify-between">
<GroupTabs groupId={groupId} />
{data?.group && <ShareButton group={data.group} />}
{group && <ShareButton group={group} />}
</div>
</div>
)

View File

@@ -9,14 +9,14 @@ import {
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'
import { useCurrentGroup } from '../current-group-context'
export default function GroupInformation({ groupId }: { groupId: string }) {
const t = useTranslations('Information')
const { data, isLoading } = trpc.groups.get.useQuery({ groupId })
const { isLoading, group } = useCurrentGroup()
return (
<>
@@ -35,13 +35,13 @@ export default function GroupInformation({ groupId }: { groupId: string }) {
</CardDescription>
</CardHeader>
<CardContent className="prose prose-sm sm:prose-base max-w-full whitespace-break-spaces">
{isLoading || !data ? (
{isLoading ? (
<div className="py-1 flex flex-col gap-2">
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
) : data.group.information ? (
<p className="text-foreground">{data.group.information}</p>
) : group.information ? (
<p className="text-foreground">{group.information}</p>
) : (
<p className="text-muted-foreground text-sm">{t('empty')}</p>
)}

View File

@@ -0,0 +1,49 @@
'use client'
import { useToast } from '@/components/ui/use-toast'
import { trpc } from '@/trpc/client'
import { useTranslations } from 'next-intl'
import { PropsWithChildren, useEffect } from 'react'
import { CurrentGroupProvider } from './current-group-context'
import { GroupHeader } from './group-header'
import { SaveGroupLocally } from './save-recent-group'
export function GroupLayoutClient({
groupId,
children,
}: PropsWithChildren<{ groupId: string }>) {
const { data, isLoading } = trpc.groups.get.useQuery({ groupId })
const t = useTranslations('Groups.NotFound')
const { toast } = useToast()
useEffect(() => {
if (data && !data.group) {
toast({
description: t('text'),
variant: 'destructive',
})
}
}, [data])
const props =
isLoading || !data?.group
? { isLoading: true as const, groupId, group: undefined }
: { isLoading: false as const, groupId, group: data.group }
if (isLoading) {
return (
<CurrentGroupProvider {...props}>
<GroupHeader />
{children}
</CurrentGroupProvider>
)
}
return (
<CurrentGroupProvider {...props}>
<GroupHeader />
{children}
<SaveGroupLocally />
</CurrentGroupProvider>
)
}

View File

@@ -1,9 +1,7 @@
import { cached } from '@/app/cached-functions'
import { GroupHeader } from '@/app/groups/[groupId]/group-header'
import { SaveGroupLocally } from '@/app/groups/[groupId]/save-recent-group'
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { PropsWithChildren } from 'react'
import { GroupLayoutClient } from './layout.client'
type Props = {
params: {
@@ -24,20 +22,9 @@ export async function generateMetadata({
}
}
export default async function GroupLayout({
export default function GroupLayout({
children,
params: { groupId },
}: PropsWithChildren<Props>) {
const group = await cached.getGroup(groupId)
if (!group) notFound()
return (
<>
<GroupHeader groupId={groupId} />
{children}
<SaveGroupLocally group={{ id: group.id, name: group.name }} />
</>
)
return <GroupLayoutClient groupId={groupId}>{children}</GroupLayoutClient>
}

View File

@@ -1,17 +1,13 @@
'use client'
import {
RecentGroup,
saveRecentGroup,
} from '@/app/groups/recent-groups-helpers'
import { saveRecentGroup } from '@/app/groups/recent-groups-helpers'
import { useEffect } from 'react'
import { useCurrentGroup } from './current-group-context'
type Props = {
group: RecentGroup
}
export function SaveGroupLocally() {
const { group } = useCurrentGroup()
export function SaveGroupLocally({ group }: Props) {
useEffect(() => {
saveRecentGroup(group)
if (group) saveRecentGroup({ id: group.id, name: group.name })
}, [group])
return null

View File

@@ -8,7 +8,7 @@ import {
} from '@/components/ui/card'
import { useTranslations } from 'next-intl'
export function TotalsPageClient({ groupId }: { groupId: string }) {
export function TotalsPageClient() {
const t = useTranslations('Stats')
return (
@@ -19,7 +19,7 @@ export function TotalsPageClient({ groupId }: { groupId: string }) {
<CardDescription>{t('Totals.description')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col space-y-4">
<Totals groupId={groupId} />
<Totals />
</CardContent>
</Card>
</>

View File

@@ -5,10 +5,6 @@ export const metadata: Metadata = {
title: 'Totals',
}
export default async function TotalsPage({
params: { groupId },
}: {
params: { groupId: string }
}) {
return <TotalsPageClient groupId={groupId} />
export default async function TotalsPage() {
return <TotalsPageClient />
}

View File

@@ -5,16 +5,17 @@ import { TotalsYourSpendings } from '@/app/groups/[groupId]/stats/totals-your-sp
import { Skeleton } from '@/components/ui/skeleton'
import { useActiveUser } from '@/lib/hooks'
import { trpc } from '@/trpc/client'
import { useCurrentGroup } from '../current-group-context'
export function Totals({ groupId }: { groupId: string }) {
export function Totals() {
const { groupId, group } = useCurrentGroup()
const activeUser = useActiveUser(groupId)
const participantId =
activeUser && activeUser !== 'None' ? activeUser : undefined
const { data } = trpc.groups.stats.get.useQuery({ groupId, participantId })
const { data: groupData } = trpc.groups.get.useQuery({ groupId })
if (!data || !groupData)
if (!data || !group)
return (
<div className="flex flex-col gap-7">
{[0, 1, 2].map((index) => (
@@ -31,7 +32,6 @@ export function Totals({ groupId }: { groupId: string }) {
totalParticipantShare,
totalParticipantSpendings,
} = data
const { group } = groupData
return (
<>

View File

@@ -1,7 +0,0 @@
'use server'
import { getGroups } from '@/lib/api'
export async function getGroupsAction(groupIds: string[]) {
'use server'
return getGroups(groupIds)
}

View File

@@ -1,8 +0,0 @@
'use server'
import { getGroup } from '@/lib/api'
export async function getGroupInfoAction(groupId: string) {
'use server'
return getGroup(groupId)
}

View File

@@ -1,4 +1,3 @@
import { getGroupInfoAction } from '@/app/groups/add-group-by-url-button-actions'
import { saveRecentGroup } from '@/app/groups/recent-groups-helpers'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -8,6 +7,7 @@ import {
PopoverTrigger,
} from '@/components/ui/popover'
import { useMediaQuery } from '@/lib/hooks'
import { trpc } from '@/trpc/client'
import { Loader2, Plus } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { useState } from 'react'
@@ -23,14 +23,12 @@ export function AddGroupByUrlButton({ reload }: Props) {
const [error, setError] = useState(false)
const [open, setOpen] = useState(false)
const [pending, setPending] = useState(false)
const utils = trpc.useUtils()
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="secondary">
{/* <Plus className="w-4 h-4 mr-2" /> */}
{t('button')}
</Button>
<Button variant="secondary">{t('button')}</Button>
</PopoverTrigger>
<PopoverContent
align={isDesktop ? 'end' : 'start'}
@@ -47,15 +45,17 @@ export function AddGroupByUrlButton({ reload }: Props) {
new RegExp(`${window.location.origin}/groups/([^/]+)`),
) ?? []
setPending(true)
const group = groupId ? await getGroupInfoAction(groupId) : null
setPending(false)
if (!group) {
setError(true)
} else {
const { group } = await utils.groups.get.fetch({
groupId: groupId,
})
if (group) {
saveRecentGroup({ id: group.id, name: group.name })
reload()
setUrl('')
setOpen(false)
} else {
setError(true)
setPending(false)
}
}}
>

View File

@@ -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<RecentGroupsState>) => 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 (
<li key={group.id}>
<Button
@@ -116,27 +97,11 @@ export function RecentGroupListCard({
onClick={(event) => {
event.stopPropagation()
deleteRecentGroup(group)
setState({
...state,
groups: state.groups.filter((g) => g.id !== group.id),
})
refreshGroupsFromStorage()
toast.toast({
title: t('RecentRemovedToast.title'),
description: t('RecentRemovedToast.description'),
action: (
<ToastAction
altText={t('RecentRemovedToast.undoAlt')}
onClick={() => {
saveRecentGroup(group)
setState({
...state,
groups: state.groups,
})
}}
>
{t('RecentRemovedToast.undo')}
</ToastAction>
),
})
}}
>
@@ -161,18 +126,21 @@ export function RecentGroupListCard({
</span>
</div>
<div className="text-muted-foreground font-normal text-xs">
{details ? (
{groupDetail ? (
<div className="w-full flex items-center justify-between">
<div className="flex items-center">
<Users className="w-3 h-3 inline mr-1" />
<span>{details._count.participants}</span>
<span>{groupDetail._count.participants}</span>
</div>
<div className="flex items-center">
<Calendar className="w-3 h-3 inline mx-1" />
<span>
{new Date(details.createdAt).toLocaleDateString(locale, {
dateStyle: 'medium',
})}
{new Date(groupDetail.createdAt).toLocaleDateString(
locale,
{
dateStyle: 'medium',
},
)}
</span>
</div>
</div>

View File

@@ -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<RecentGroupsState>({ 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 (
<RecentGroupList_
groups={state.groups}
starredGroups={state.starredGroups}
archivedGroups={state.archivedGroups}
refreshGroupsFromStorage={() => 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 (
<GroupsPage reload={loadGroups}>
<GroupsPage reload={refreshGroupsFromStorage}>
<p>
<Loader2 className="w-4 m-4 mr-2 inline animate-spin" />{' '}
{t('loadingRecent')}
@@ -93,9 +118,9 @@ export function RecentGroupList() {
)
}
if (state.groups.length === 0) {
if (data.groups.length === 0) {
return (
<GroupsPage reload={loadGroups}>
<GroupsPage reload={refreshGroupsFromStorage}>
<div className="text-sm space-y-2">
<p>{t('NoRecent.description')}</p>
<p>
@@ -109,17 +134,23 @@ export function RecentGroupList() {
)
}
const { starredGroupInfo, groupInfo, archivedGroupInfo } = sortGroups(state)
const { starredGroupInfo, groupInfo, archivedGroupInfo } = sortGroups({
groups,
starredGroups,
archivedGroups,
})
return (
<GroupsPage reload={loadGroups}>
<GroupsPage reload={refreshGroupsFromStorage}>
{starredGroupInfo.length > 0 && (
<>
<h2 className="mb-2">{t('starred')}</h2>
<GroupList
groups={starredGroupInfo}
state={state}
setState={setState}
groupDetails={data.groups}
archivedGroups={archivedGroups}
starredGroups={starredGroups}
refreshGroupsFromStorage={refreshGroupsFromStorage}
/>
</>
)}
@@ -127,7 +158,13 @@ export function RecentGroupList() {
{groupInfo.length > 0 && (
<>
<h2 className="mt-6 mb-2">{t('recent')}</h2>
<GroupList groups={groupInfo} state={state} setState={setState} />
<GroupList
groups={groupInfo}
groupDetails={data.groups}
archivedGroups={archivedGroups}
starredGroups={starredGroups}
refreshGroupsFromStorage={refreshGroupsFromStorage}
/>
</>
)}
@@ -137,8 +174,10 @@ export function RecentGroupList() {
<div className="opacity-50">
<GroupList
groups={archivedGroupInfo}
state={state}
setState={setState}
groupDetails={data.groups}
archivedGroups={archivedGroups}
starredGroups={starredGroups}
refreshGroupsFromStorage={refreshGroupsFromStorage}
/>
</div>
</>
@@ -149,12 +188,16 @@ export function RecentGroupList() {
function GroupList({
groups,
state,
setState,
groupDetails,
starredGroups,
archivedGroups,
refreshGroupsFromStorage,
}: {
groups: RecentGroups
state: RecentGroupsState
setState: (state: SetStateAction<RecentGroupsState>) => void
groupDetails?: AppRouterOutput['groups']['list']['groups']
starredGroups: string[]
archivedGroups: string[]
refreshGroupsFromStorage: () => void
}) {
return (
<ul className="grid gap-2 sm:grid-cols-2">
@@ -162,8 +205,12 @@ function GroupList({
<RecentGroupListCard
key={group.id}
group={group}
state={state}
setState={setState}
groupDetail={groupDetails?.find(
(groupDetail) => groupDetail.id === group.id,
)}
isStarred={starredGroups.includes(group.id)}
isArchived={archivedGroups.includes(group.id)}
refreshGroupsFromStorage={refreshGroupsFromStorage}
/>
))}
</ul>

View File

@@ -21,6 +21,8 @@ function getQueryClient() {
return (clientQueryClientSingleton ??= makeQueryClient())
}
export const trpcClient = getQueryClient()
function getUrl() {
const base = (() => {
if (typeof window !== 'undefined') return ''

View File

@@ -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 }
})

View File

@@ -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 }
})

View File

@@ -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,
})

View File

@@ -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 }
})