diff --git a/src/app/dashboard/charts/charts.tsx b/src/app/dashboard/charts/charts.tsx index 70cb1f8..81ed4ed 100644 --- a/src/app/dashboard/charts/charts.tsx +++ b/src/app/dashboard/charts/charts.tsx @@ -62,7 +62,7 @@ function getTabsTrigger(value: string) { - {value.replace(/-/g, ' ')} + {value.replace(/-/g, ' ')} ); } diff --git a/src/app/dashboard/month-and-year-selector.tsx b/src/app/dashboard/month-and-year-selector.tsx index 0d23304..7dff51b 100644 --- a/src/app/dashboard/month-and-year-selector.tsx +++ b/src/app/dashboard/month-and-year-selector.tsx @@ -49,7 +49,7 @@ function YearSelector({ month, year, router, pathname }: MonthAndYearSelectorChi router.push(pathname + '?' + params.toString()); }} > - +
{year}
- +
); diff --git a/src/app/dashboard/recent-transactions.tsx b/src/app/dashboard/recent-transactions.tsx index fae9055..fe6b066 100644 --- a/src/app/dashboard/recent-transactions.tsx +++ b/src/app/dashboard/recent-transactions.tsx @@ -66,7 +66,7 @@ function DashboardRecentTransactionsCard({

{title} diff --git a/src/app/groups/[groupId]/group-charts.tsx b/src/app/groups/[groupId]/group-charts.tsx new file mode 100644 index 0000000..ea70642 --- /dev/null +++ b/src/app/groups/[groupId]/group-charts.tsx @@ -0,0 +1,188 @@ +import type { ChartDataset } from 'chart.js'; +import { addDays, endOfMonth, isAfter, startOfMonth } from 'date-fns'; +import { formatInTimeZone, getTimezoneOffset } from 'date-fns-tz'; +import { BarChart3 } from 'lucide-react'; +import tailwindConfig from 'tailwind.config'; +import resolveConfig from 'tailwindcss/resolveConfig'; +import BarChart from '~/app/dashboard/charts/bar-chart.client'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '~/components/ui/tabs'; +import { api } from '~/trpc/server'; +import type { RouterOutputs } from '~/trpc/shared'; + +export default async function GroupCharts({ + group, + user, +}: { + user: RouterOutputs['users']['get']; + group: Exclude; +}) { + const timezone = user.timezone ?? 'Europe/Amsterdam'; + const users = group.UserGroup.map((e) => e.user); + + const time = new Date(); + const preferredTimezoneOffset = getTimezoneOffset(timezone); + const localeTimezoneOffset = new Date().getTimezoneOffset() * 60 * 1000; + const from = new Date(startOfMonth(time).getTime() - preferredTimezoneOffset - localeTimezoneOffset); + const to = new Date(endOfMonth(time).getTime() - preferredTimezoneOffset - localeTimezoneOffset); + + const [expenses, settlements] = await Promise.all([ + api.groups.expenses.period.query({ groupId: group.id, from, to }), + api.groups.settlements.period.query({ groupId: group.id, from, to }), + ]); + + const { + labels, + datasets: { paidByDay, owedByDay, sentByDay, receivedByDay }, + } = getDatasets({ users, timezone, expenses, settlements, from, to }); + + return ( +
+
+

Charts

+
+ + + {['paid-by-day', 'owed-by-day', 'sent-by-day', 'received-by-day'].map(getTabsTrigger)} + + + + + + + + + + + + + + + + + + +
+ ); +} + +function getTabsTrigger(value: string) { + return ( + + + + + {value.replace(/-/g, ' ')} + + ); +} + +type GetDatasetsProps = { + users: Exclude['UserGroup'][number]['user'][]; + timezone: string; + expenses: RouterOutputs['groups']['expenses']['period']; + settlements: RouterOutputs['groups']['settlements']['period']; + from: Date; + to: Date; +}; + +type GetDatasetsOutput = { + labels: number[]; + datasets: Record<'paidByDay' | 'owedByDay' | 'sentByDay' | 'receivedByDay', ChartDataset<'bar', number[]>[]>; +}; + +function getDatasets({ users, timezone, expenses, settlements, from, to }: GetDatasetsProps): GetDatasetsOutput { + const labels: number[] = []; + for (let i = from; !isAfter(i, to); i = addDays(i, 1)) { + labels.push(parseInt(formatInTimeZone(i, timezone, 'dd'))); + } + + const paymentsByUser = new Map>(); + const debtsByUser = new Map>(); + for (const expense of expenses) { + const day = parseInt(formatInTimeZone(expense.transaction.date, timezone, 'dd')); + for (const split of expense.TransactionSplit) { + const userPayments = paymentsByUser.get(split.userId) ?? new Map(); + const currentPayment = userPayments.get(day) ?? 0; + userPayments.set(day, currentPayment + split.paid / 100); + paymentsByUser.set(split.userId, userPayments); + + const userDebts = debtsByUser.get(split.userId) ?? new Map(); + const currentDebt = userDebts.get(day) ?? 0; + userDebts.set(day, currentDebt + split.owed / 100); + debtsByUser.set(split.userId, userDebts); + } + } + + const sentSettlementsByUser = new Map>(); + const receivedSettlementsByUser = new Map>(); + for (const settlement of settlements) { + const day = parseInt(formatInTimeZone(settlement.date, timezone, 'dd')); + const userSentSettlements = sentSettlementsByUser.get(settlement.fromId) ?? new Map(); + const currentSentSettlements = userSentSettlements.get(day) ?? 0; + userSentSettlements.set(day, currentSentSettlements + settlement.amount / 100); + sentSettlementsByUser.set(settlement.fromId, userSentSettlements); + + const userReceivedSettlements = receivedSettlementsByUser.get(settlement.toId) ?? new Map(); + const currentReceivedSettlements = userReceivedSettlements.get(day) ?? 0; + userReceivedSettlements.set(day, currentReceivedSettlements + settlement.amount / 100); + receivedSettlementsByUser.set(settlement.toId, userReceivedSettlements); + } + + const colors = resolveConfig(tailwindConfig).theme.colors; + const unwantedColors = ['white', 'black', 'transparent', 'current', 'inherit', 'primary']; + const availableColors = Object.keys(colors).filter((c) => !unwantedColors.includes(c)); + const userColors = new Map(); + for (const user of users) { + const index = Math.floor(Math.random() * Object.keys(availableColors).length); + const key = (availableColors.splice(index, 1) ?? 'primary') as unknown as keyof typeof colors; + userColors.set(user.id, colors[key]?.[500]); + } + const datasets: Record<'paidByDay' | 'owedByDay' | 'sentByDay' | 'receivedByDay', ChartDataset<'bar', number[]>[]> = { + paidByDay: [], + owedByDay: [], + sentByDay: [], + receivedByDay: [], + }; + + for (const [userId, userPayments] of paymentsByUser.entries()) { + const user = users.find((u) => u.id === userId); + + datasets.paidByDay.push({ + backgroundColor: userColors.get(userId) ?? colors.primary[500], + label: `${user?.firstName} ${user?.lastName}`, + data: labels.map((d) => userPayments.get(d) ?? 0), + }); + } + + for (const [userId, userDebts] of debtsByUser.entries()) { + const user = users.find((u) => u.id === userId); + + datasets.owedByDay.push({ + backgroundColor: userColors.get(userId) ?? colors.primary[500], + label: `${user?.firstName} ${user?.lastName}`, + data: labels.map((d) => userDebts.get(d) ?? 0), + }); + } + + for (const [userId, userSentSettlements] of sentSettlementsByUser.entries()) { + const user = users.find((u) => u.id === userId); + + datasets.sentByDay.push({ + backgroundColor: userColors.get(userId) ?? colors.primary[500], + label: `${user?.firstName} ${user?.lastName}`, + data: labels.map((d) => userSentSettlements.get(d) ?? 0), + }); + } + + for (const [userId, userReceivedSettlements] of receivedSettlementsByUser.entries()) { + const user = users.find((u) => u.id === userId); + + datasets.receivedByDay.push({ + backgroundColor: userColors.get(userId) ?? colors.primary[500], + label: `${user?.firstName} ${user?.lastName}`, + data: labels.map((d) => userReceivedSettlements.get(d) ?? 0), + }); + } + + return { labels, datasets }; +} diff --git a/src/app/groups/[groupId]/group-details.tsx b/src/app/groups/[groupId]/group-details.tsx index 55f5796..0194da9 100644 --- a/src/app/groups/[groupId]/group-details.tsx +++ b/src/app/groups/[groupId]/group-details.tsx @@ -18,7 +18,7 @@ export default async function GroupDetails({

Details

-
+

{group.description}

Members

@@ -26,7 +26,7 @@ export default async function GroupDetails({ return ; })}
-
+
diff --git a/src/app/groups/[groupId]/page.tsx b/src/app/groups/[groupId]/page.tsx index db12219..fe56e37 100644 --- a/src/app/groups/[groupId]/page.tsx +++ b/src/app/groups/[groupId]/page.tsx @@ -6,6 +6,7 @@ import RecentGroupActivity from '~/app/groups/[groupId]/group-activity'; import RegisterSettlement from '~/app/groups/[groupId]/register-settlement.client'; import { Button } from '~/components/ui/button'; import { api } from '~/trpc/server'; +import GroupCharts from '~/app/groups/[groupId]/group-charts'; export default async function GroupPage({ params: { groupId } }: { params: { groupId: string } }) { const group = await api.groups.get.query({ id: groupId }).catch(() => null); @@ -32,6 +33,7 @@ export default async function GroupPage({ params: { groupId } }: { params: { gro
+