Skip to content
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
56 changes: 56 additions & 0 deletions app/src/app/styles/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@

--color-info: #6366f1;
--color-info-soft: #eef2ff;

/* Animations */
--animate-modal-in: modal-in 0.2s cubic-bezier(0.16, 1, 0.3, 1);
--animate-backdrop-in: backdrop-in 0.15s ease-out;
--animate-drawer-in-right: drawer-in-right 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}

/* Dark theme overrides */
Expand Down Expand Up @@ -105,3 +110,54 @@ select,
textarea {
font: inherit;
}

@keyframes modal-in {
from {
opacity: 0;
transform: scale(0.97) translateY(8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}

@keyframes backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

@keyframes drawer-in-right {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}

@keyframes shimmer {
from {
background-position: 200% center;
}
to {
background-position: -200% center;
}
}

@utility animate-shimmer {
animation: shimmer 1.6s linear infinite;
background-image: linear-gradient(
90deg,
var(--color-border) 0%,
var(--color-surface-hover) 50%,
var(--color-border) 100%
);
background-size: 200% 100%;
}
60 changes: 37 additions & 23 deletions app/src/features/create-transaction/ui/CreateTransactionModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { DatePicker } from '@shared/ui/date-picker';
import { Input } from '@shared/ui/input';
import { localStorageService } from '@shared/lib/storage/local-storage.service';
import { Modal } from '@shared/ui/modal';
import { Select } from '@shared/ui/select';
import { cn } from '@shared/lib/classnames/cn';

import { mapFormToTransaction } from '../lib/map-form-to-transaction';
import { createTransactionFormSchema } from '../model/transaction-form.schema';
Expand Down Expand Up @@ -144,6 +144,16 @@ export function CreateTransactionModal({
);
const [formError, setFormError] = useState<string | null>(null);

function switchKind(kind: 'income' | 'expense') {
setValues({
...values,
categoryId: selectedCategory?.kind === kind ? values.categoryId : '',
categoryName: selectedCategory?.kind === kind ? values.categoryName : '',
kind,
});
setSelectedCategory((current) => (current?.kind === kind ? current : null));
}

function selectCategory(category: Category) {
setSelectedCategory(category);
setValues({
Expand Down Expand Up @@ -222,28 +232,32 @@ export function CreateTransactionModal({
{visibleError}
</div>
) : null}
<Select
id="transaction-kind"
label="Type"
onChange={(nextKind) => {
const kind = nextKind as 'income' | 'expense';

setValues({
...values,
categoryId: selectedCategory?.kind === kind ? values.categoryId : '',
categoryName: selectedCategory?.kind === kind ? values.categoryName : '',
kind,
});
setSelectedCategory((currentCategory) =>
currentCategory?.kind === kind ? currentCategory : null,
);
}}
options={[
{ label: 'Expense', value: 'expense' },
{ label: 'Income', value: 'income' },
]}
value={values.kind}
/>
<div className="grid grid-cols-2 rounded-xl bg-surface-muted p-1">
<button
className={cn(
'rounded-lg py-2 text-sm font-medium transition-all',
values.kind === 'expense'
? 'bg-surface text-danger shadow-sm'
: 'text-text-soft hover:text-text-muted',
)}
onClick={() => switchKind('expense')}
type="button"
>
Expense
</button>
<button
className={cn(
'rounded-lg py-2 text-sm font-medium transition-all',
values.kind === 'income'
? 'bg-surface text-success shadow-sm'
: 'text-text-soft hover:text-text-muted',
)}
onClick={() => switchKind('income')}
type="button"
>
Income
</button>
</div>
<div className="grid gap-4 md:grid-cols-2">
<DatePicker
id="transaction-date"
Expand Down
185 changes: 174 additions & 11 deletions app/src/pages/dashboard/ui/DashboardPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';

import type { Transaction } from '@entities/transaction';
import { CreateTransactionModal } from '@features/create-transaction';
Expand All @@ -12,10 +12,12 @@ import {
import { AppLayout } from '@widgets/app-layout';
import { DashboardAnalytics } from '@widgets/dashboard-analytics';
import { TransactionsHistory } from '@widgets/transactions-history';
import { Area, AreaChart, ResponsiveContainer } from 'recharts';
import { Button } from '@shared/ui/button';
import { Card } from '@shared/ui/card';
import { ConfirmModal } from '@shared/ui/confirm-modal';
import { PageHeader } from '@shared/ui/page-header';
import { cn } from '@shared/lib/classnames/cn';

function formatMoney(amount: number, currency: string) {
return `${amount.toLocaleString('en-US', {
Expand All @@ -24,20 +26,165 @@ function formatMoney(amount: number, currency: string) {
})} ${currency}`;
}

function computeSparklineData(transactions: Transaction[], currency: string) {
const filtered = [...transactions]
.filter((t) => t.currency === currency)
.sort((a, b) => a.date.localeCompare(b.date));

let running = 0;
const points = filtered.map((t) => {
running += t.signedAmount;
return { value: running };
});

return points.slice(-20);
}

function computeMonthDelta(transactions: Transaction[], currency: string) {
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
const thisMonth = `${now.getFullYear()}-${pad(now.getMonth() + 1)}`;

return transactions
.filter((t) => t.currency === currency && t.date.startsWith(thisMonth))
.reduce((sum, t) => sum + t.signedAmount, 0);
}

type BalanceHeroCardProps = {
className?: string;
summaries: CurrencySummary[];
transactions: Transaction[];
};

function BalanceHeroCard({ className, summaries, transactions }: BalanceHeroCardProps) {
const primary = summaries[0];

const sparklineData = useMemo(
() => (primary ? computeSparklineData(transactions, primary.currency) : []),
// eslint-disable-next-line react-hooks/exhaustive-deps
[transactions, primary?.currency],
);

const monthDelta = useMemo(
() => (primary ? computeMonthDelta(transactions, primary.currency) : 0),
// eslint-disable-next-line react-hooks/exhaustive-deps
[transactions, primary?.currency],
);

const isUp = monthDelta >= 0;
const sparkColor = isUp ? 'var(--color-success)' : 'var(--color-danger)';

return (
<Card
className={cn(
'relative flex flex-col overflow-hidden hover:-translate-y-0.5 hover:shadow-2xl',
className,
)}
variant="elevated"
>
<div className="absolute -right-8 -top-8 h-40 w-40 rounded-full bg-brand-soft blur-3xl" />
<p className="relative text-sm text-text-soft">Balance</p>
{!primary ? (
<p className="relative mt-3 text-4xl font-bold tracking-tight text-text-soft">—</p>
) : (
<>
<p className="relative mt-3 text-3xl font-bold tracking-tight text-text sm:text-4xl md:text-5xl">
{formatMoney(primary.balance, primary.currency)}
</p>
{summaries.length > 1 && (
<ul className="relative mt-1 space-y-0.5">
{summaries.slice(1).map((s) => (
<li className="text-sm text-text-soft" key={s.currency}>
{formatMoney(s.balance, s.currency)}
</li>
))}
</ul>
)}
<div className="relative mt-3">
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium',
isUp ? 'bg-success-soft text-success' : 'bg-danger-soft text-danger',
)}
>
{isUp ? '↑' : '↓'}
{formatMoney(Math.abs(monthDelta), primary.currency)} this month
</span>
</div>
</>
)}
{sparklineData.length > 2 && (
<div className="-mx-6 -mb-6 mt-auto pt-6">
<ResponsiveContainer height={64} width="100%">
<AreaChart data={sparklineData} margin={{ bottom: 0, left: 0, right: 0, top: 4 }}>
<defs>
<linearGradient id="balanceSparkGradient" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={sparkColor} stopOpacity={0.25} />
<stop offset="100%" stopColor={sparkColor} stopOpacity={0} />
</linearGradient>
</defs>
<Area
dataKey="value"
dot={false}
fill="url(#balanceSparkGradient)"
isAnimationActive={false}
stroke={sparkColor}
strokeWidth={1.5}
type="monotone"
/>
</AreaChart>
</ResponsiveContainer>
</div>
)}
</Card>
);
}

const summaryColorMap = {
income: {
icon: '↑',
iconClass: 'bg-success-soft text-success',
orb: 'bg-success-soft',
},
expense: {
icon: '↓',
iconClass: 'bg-danger-soft text-danger',
orb: 'bg-danger-soft',
},
} as const;

type SummaryColor = keyof typeof summaryColorMap;

type SummaryCardProps = {
label: string;
color: SummaryColor;
getValue: (entry: CurrencySummary) => number;
label: string;
summaries: CurrencySummary[];
};

function SummaryCard({ label, getValue, summaries }: SummaryCardProps) {
function SummaryCard({ color, getValue, label, summaries }: SummaryCardProps) {
const { icon, iconClass, orb } = summaryColorMap[color];
return (
<Card>
<p className="text-sm text-text-soft">{label}</p>
<Card
className="relative overflow-hidden hover:-translate-y-0.5 hover:shadow-md"
variant="metric"
>
<div className={cn('absolute -right-4 -top-4 h-20 w-20 rounded-full blur-2xl', orb)} />
<div className="relative flex items-center justify-between">
<p className="text-sm text-text-soft">{label}</p>
<span
className={cn(
'inline-flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold',
iconClass,
)}
>
{icon}
</span>
</div>
{summaries.length === 0 ? (
<p className="mt-2 text-xl font-semibold text-text-soft md:text-2xl">—</p>
<p className="relative mt-2 text-xl font-semibold text-text-soft md:text-2xl">—</p>
) : (
<ul className="mt-2 space-y-1">
<ul className="relative mt-2 space-y-1">
{summaries.map((entry) => (
<li
className="break-words text-xl font-semibold text-text md:text-2xl"
Expand Down Expand Up @@ -94,10 +241,26 @@ export function DashboardPage() {
description="Track income, expenses and balance from your Google Sheets ledger."
title="Dashboard"
/>
<section className="grid gap-3 sm:grid-cols-3 md:gap-4">
<SummaryCard getValue={(entry) => entry.income} label="Income" summaries={summaries} />
<SummaryCard getValue={(entry) => entry.expense} label="Expense" summaries={summaries} />
<SummaryCard getValue={(entry) => entry.balance} label="Balance" summaries={summaries} />
<section className="grid gap-3 md:gap-4 lg:grid-cols-3">
<BalanceHeroCard
className="lg:col-span-2"
summaries={summaries}
transactions={activeTransactions}
/>
<div className="grid gap-3 sm:grid-cols-2 md:gap-4 lg:grid-cols-1">
<SummaryCard
color="income"
getValue={(entry) => entry.income}
label="Income"
summaries={summaries}
/>
<SummaryCard
color="expense"
getValue={(entry) => entry.expense}
label="Expense"
summaries={summaries}
/>
</div>
</section>
<DashboardAnalytics transactions={activeTransactions} />
<TransactionsHistory
Expand Down
Loading
Loading