Skip to content

Commit

Permalink
IITM-11 - paid by member per day (#2)
Browse files Browse the repository at this point in the history
* feat(groups): charts

Fixes IITM-10, IITM-11, IITM-12, IITM-13, and IITM-14.

* fix(groups): move buttons bar to the bottom of the group details card

* Revert "style(colours): use primary colour for the theme"

This reverts commit 4664422.

* fix(theme): something broke the settlement pop-up when switching to 'primary'

I don't know what broke it, but I reverted the change and did it again. I know
I did not run the text substitutions in the best way possible, so I just did it
again doing what I think is the best way, and this time it seems nothing broke.

---------

Co-authored-by: Ricard Mallafre <[email protected]>
  • Loading branch information
github-actions[bot] and nikensss authored Mar 31, 2024
1 parent d1e1984 commit 17fe71e
Show file tree
Hide file tree
Showing 10 changed files with 201 additions and 14 deletions.
2 changes: 1 addition & 1 deletion src/app/dashboard/charts/charts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ function getTabsTrigger(value: string) {
<span className="compact animate-pulse">
<BarChart3 />
</span>
<span className="full capitalize">{value.replace(/-/g, ' ')}</span>
<span className="full first-letter:uppercase">{value.replace(/-/g, ' ')}</span>
</TabsTrigger>
);
}
4 changes: 2 additions & 2 deletions src/app/dashboard/month-and-year-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ function YearSelector({ month, year, router, pathname }: MonthAndYearSelectorChi
router.push(pathname + '?' + params.toString());
}}
>
<ChevronLeft size={24} className="md:group-hover:-tranprimary-x-1 transition" />
<ChevronLeft size={24} className="transition md:group-hover:-translate-x-1" />
</div>
<div>{year}</div>
<div
Expand All @@ -61,7 +61,7 @@ function YearSelector({ month, year, router, pathname }: MonthAndYearSelectorChi
router.push(pathname + '?' + params.toString());
}}
>
<ChevronRight size={24} className="md:group-hover:tranprimary-x-1 transition" />
<ChevronRight size={24} className="transition md:group-hover:translate-x-1" />
</div>
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion src/app/dashboard/recent-transactions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ function DashboardRecentTransactionsCard({
<h2 className="relative mb-2 text-center text-lg font-bold">
<Link
href={href}
className="md:after:hover:tranprimary-x-2 relative md:after:absolute md:after:right-[-1.5rem] md:after:top-0 md:after:ml-0.5 md:after:block md:after:opacity-0 md:after:transition-all md:after:content-[url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWFycm93LXJpZ2h0Ij48cGF0aCBkPSJNNSAxMmgxNCIvPjxwYXRoIGQ9Im0xMiA1IDcgNy03IDciLz48L3N2Zz4=')] md:hover:underline md:after:hover:opacity-100"
className="relative md:after:absolute md:after:right-[-1.5rem] md:after:top-0 md:after:ml-0.5 md:after:block md:after:opacity-0 md:after:transition-all md:after:content-[url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWFycm93LXJpZ2h0Ij48cGF0aCBkPSJNNSAxMmgxNCIvPjxwYXRoIGQ9Im0xMiA1IDcgNy03IDciLz48L3N2Zz4=')] md:hover:underline md:after:hover:translate-x-2 md:after:hover:opacity-100"
>
{title}
</Link>
Expand Down
188 changes: 188 additions & 0 deletions src/app/groups/[groupId]/group-charts.tsx
Original file line number Diff line number Diff line change
@@ -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<RouterOutputs['groups']['get'], null>;
}) {
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 (
<div className="border-primary-200 flex flex-col rounded-md border p-2">
<header className="bg-primary-900 my-0.5 mb-1.5 flex h-12 flex-col items-center justify-center rounded-md">
<h2 className="text-primary-200 text-lg font-bold first-letter:uppercase">Charts</h2>
</header>
<Tabs defaultValue="paid-by-day" className="mt-4 h-full w-full">
<TabsList className="flex w-full justify-between md:grid md:grid-cols-4">
{['paid-by-day', 'owed-by-day', 'sent-by-day', 'received-by-day'].map(getTabsTrigger)}
</TabsList>

<TabsContent value="paid-by-day">
<BarChart labels={labels} datasets={paidByDay} />
</TabsContent>

<TabsContent value="owed-by-day">
<BarChart labels={labels} datasets={owedByDay} />
</TabsContent>

<TabsContent value="sent-by-day">
<BarChart labels={labels} datasets={sentByDay} />
</TabsContent>

<TabsContent value="received-by-day">
<BarChart labels={labels} datasets={receivedByDay} />
</TabsContent>
</Tabs>
</div>
);
}

function getTabsTrigger(value: string) {
return (
<TabsTrigger key={value} value={value} className="responsive-tab-trigger">
<span className="compact animate-pulse">
<BarChart3 />
</span>
<span className="full first-letter:uppercase">{value.replace(/-/g, ' ')}</span>
</TabsTrigger>
);
}

type GetDatasetsProps = {
users: Exclude<RouterOutputs['groups']['get'], null>['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<string, Map<number, number>>();
const debtsByUser = new Map<string, Map<number, number>>();
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<number, number>();
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<number, number>();
const currentDebt = userDebts.get(day) ?? 0;
userDebts.set(day, currentDebt + split.owed / 100);
debtsByUser.set(split.userId, userDebts);
}
}

const sentSettlementsByUser = new Map<string, Map<number, number>>();
const receivedSettlementsByUser = new Map<string, Map<number, number>>();
for (const settlement of settlements) {
const day = parseInt(formatInTimeZone(settlement.date, timezone, 'dd'));
const userSentSettlements = sentSettlementsByUser.get(settlement.fromId) ?? new Map<number, number>();
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<number, number>();
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<string, string>();
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 };
}
4 changes: 2 additions & 2 deletions src/app/groups/[groupId]/group-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@ export default async function GroupDetails({
<header className="bg-primary-900 my-0.5 mb-1.5 flex h-12 flex-col items-center justify-center rounded-md">
<h2 className="text-primary-200 text-lg font-bold first-letter:uppercase">Details</h2>
</header>
<main>
<main className="flex grow flex-col">
<p className="text-center text-lg font-bold first-letter:uppercase">{group.description}</p>
<p className="text-center text-lg">Members</p>
<div className="mb-2 flex flex-col gap-2">
{users.map((u) => {
return <UserBannerClient key={u.id} user={u} isSelf={u.id === user?.id} />;
})}
</div>
<div className="flex items-center justify-center gap-2">
<div className="mt-auto flex items-center justify-center gap-2">
<Button asChild variant="outline" className="w-full">
<Link href={`/groups/${group.id}/edit`}>Edit</Link>
</Button>
Expand Down
2 changes: 2 additions & 0 deletions src/app/groups/[groupId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -32,6 +33,7 @@ export default async function GroupPage({ params: { groupId } }: { params: { gro
</div>
<div className="flex flex-col gap-2 lg:grid lg:grid-cols-2 lg:grid-rows-1">
<GroupDetails {...{ group, user }} />
<GroupCharts {...{ group, user }} />
</div>
<div className="grid grid-cols-1 grid-rows-2 gap-2 lg:grid-cols-2 lg:grid-rows-1">
<Button variant="outline" asChild>
Expand Down
2 changes: 1 addition & 1 deletion src/app/settings/settings-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export default function SettingsForm({ username, timezone, currency, weekStartsO
})(),
)}
>
<div className="flex w-9 items-center justify-center rounded-l-md bg-primary-100 px-1">
<div className="bg-primary-100 flex w-9 items-center justify-center rounded-l-md px-1">
<Dot
className={cn(
'animate-ping',
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
'border-primary-200 dark:border-primary-800 tranprimary-x-[-50%] tranprimary-y-[-50%] dark:bg-primary-950 fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg gap-4 border bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
'border-primary-200 dark:border-primary-800 dark:bg-primary-950 fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
Expand Down
7 changes: 2 additions & 5 deletions src/components/ui/table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<
<th
ref={ref}
className={cn(
'text-primary-500 dark:text-primary-400 [&>[role=checkbox]]:tranprimary-y-[2px] h-10 px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0',
'text-primary-500 dark:text-primary-400 h-10 px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
Expand All @@ -66,10 +66,7 @@ const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<
({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
'[&>[role=checkbox]]:tranprimary-y-[2px] p-2 align-middle [&:has([role=checkbox])]:pr-0',
className,
)}
className={cn('p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', className)}
{...props}
/>
),
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const ToastViewport = React.forwardRef<
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;

const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border border-primary-200 p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:tranprimary-x-0 data-[swipe=end]:tranprimary-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:tranprimary-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full dark:border-primary-800',
'group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border border-primary-200 p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full dark:border-primary-800',
{
variants: {
variant: {
Expand Down

0 comments on commit 17fe71e

Please sign in to comment.