Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

IITM-11 - paid by member per day #2

Merged
merged 4 commits into from
Mar 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading