mirror of
https://github.com/siwa-net/my-finance-pal.git
synced 2024-11-10 00:51:56 +01:00
Adding schema validation, detail page init setup.
This commit is contained in:
parent
3b5e82d31c
commit
58f8194e5f
24 changed files with 295 additions and 64 deletions
11
package-lock.json
generated
11
package-lock.json
generated
|
@ -26,7 +26,8 @@
|
|||
"react-dom": "18.2.0",
|
||||
"react-sweet-progress": "^1.1.2",
|
||||
"sass": "^1.59.3",
|
||||
"typescript": "5.0.2"
|
||||
"typescript": "5.0.2",
|
||||
"zod": "^3.21.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
|
@ -3856,6 +3857,14 @@
|
|||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.21.4",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz",
|
||||
"integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,7 +29,8 @@
|
|||
"react-dom": "18.2.0",
|
||||
"react-sweet-progress": "^1.1.2",
|
||||
"sass": "^1.59.3",
|
||||
"typescript": "5.0.2"
|
||||
"typescript": "5.0.2",
|
||||
"zod": "^3.21.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
|
|
|
@ -1,3 +1,43 @@
|
|||
.Container {
|
||||
|
||||
.Headline1 {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.ArrowBack {
|
||||
font-size: 1.5rem;
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.Details {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
}
|
||||
|
||||
.DetailsContainer {
|
||||
display: flex;
|
||||
gap: var(--grid-gap);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.DetailsContainerLeft {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--grid-gap);
|
||||
flex-basis: 300px;
|
||||
}
|
||||
|
||||
.DetailsContainerRight {
|
||||
flex: 0;
|
||||
flex-basis: 300px;
|
||||
align-self: stretch;
|
||||
|
||||
& > *:first-child:last-child {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
|
@ -1,9 +1,15 @@
|
|||
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons/faArrowLeft';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import is from '@sindresorhus/is';
|
||||
import Link from 'next/link';
|
||||
import { FunctionComponent } from 'react';
|
||||
|
||||
import styles from './BudgetDetail.module.scss';
|
||||
import { datesToDayRange, dateToFormattedDay } from '../../helpers/date';
|
||||
import { useBudgetSummaryQuery } from '../../hooks/useBudgetSummaryQuery';
|
||||
import { Button } from '../_design/Button/Button';
|
||||
import { Card } from '../_design/Card/Card';
|
||||
import { DetailWithTitle } from '../_design/DetailWithTitle/DetailWithTitle';
|
||||
|
||||
type BudgetDetailProps = {
|
||||
id: string;
|
||||
|
@ -13,19 +19,48 @@ export const BudgetDetail: FunctionComponent<BudgetDetailProps> = ({ id }) => {
|
|||
const budgetQuery = useBudgetSummaryQuery(id);
|
||||
const budget = budgetQuery.data;
|
||||
|
||||
if (is.undefined(budget) || budgetQuery.isFetching) {
|
||||
if (is.nullOrUndefined(budget) || budgetQuery.isFetching) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { startDate, endDate, name, limit, transactions } = budget;
|
||||
|
||||
const isValidDateRange = is.date(startDate) && is.date(endDate) && startDate < endDate;
|
||||
|
||||
return (
|
||||
<section className={styles.DetailsContainer}>
|
||||
<div className={styles.DetailsContainerLeft}>
|
||||
<Card>
|
||||
<h1>{budget.name} </h1>
|
||||
<div className={styles.ProgressBar}>
|
||||
<div className={styles.ProgressPercentage}></div>
|
||||
<h1 className={styles.Headline1}>
|
||||
<Link href={'/'}>
|
||||
<FontAwesomeIcon icon={faArrowLeft} className={styles.ArrowBack} />
|
||||
</Link>
|
||||
{name}
|
||||
</h1>
|
||||
<div className={styles.Details}>
|
||||
<DetailWithTitle title={'Limit'}>{limit}</DetailWithTitle>
|
||||
{isValidDateRange && (
|
||||
<DetailWithTitle title={'Period'}>{datesToDayRange(startDate, endDate)}</DetailWithTitle>
|
||||
)}
|
||||
</div>
|
||||
<span>Amount</span>
|
||||
<span>Date- start -end</span>
|
||||
<button onClick={() => null}>View transactions</button>
|
||||
</Card>
|
||||
<Card>
|
||||
<h3>Transactions</h3>
|
||||
<ul>
|
||||
{transactions.map((transaction) => (
|
||||
<li key={transaction.id}>
|
||||
{dateToFormattedDay(transaction.date)} - {transaction.description}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
<div className={styles.DetailsContainerRight}>
|
||||
<Card>
|
||||
<h3>New Transaction</h3>
|
||||
<Button onClick={() => {}}>Add Transaction</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,25 +1,25 @@
|
|||
import { faCoins } from '@fortawesome/free-solid-svg-icons/faCoins';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import format from 'date-fns/format';
|
||||
import is from '@sindresorhus/is';
|
||||
import Link from 'next/link';
|
||||
import { FC } from 'react';
|
||||
import { Progress } from 'react-sweet-progress';
|
||||
import 'react-sweet-progress/lib/style.css';
|
||||
|
||||
import styles from './BudgetItem.module.scss';
|
||||
import { Budget } from '../../generated/openapi';
|
||||
import { datesToDayRange } from '../../helpers/date';
|
||||
import { BudgetModel } from '../../models/budget';
|
||||
import { Card } from '../_design/Card/Card';
|
||||
import { DetailWithTitle } from '../_design/DetailWithTitle/DetailWithTitle';
|
||||
import { ProgressBar } from '../_design/ProgressBar/ProgressBar';
|
||||
|
||||
type BudgetItemProps = { budget: Budget };
|
||||
|
||||
const DATE_FORMAT = 'dd.MM.yy';
|
||||
type BudgetItemProps = { budget: BudgetModel };
|
||||
|
||||
export const BudgetItem: FC<BudgetItemProps> = ({ budget }) => {
|
||||
const { id, spent, name, limit, startDate, endDate } = budget;
|
||||
|
||||
const spentPercentage = Math.trunc((100 * spent) / limit);
|
||||
const startDateFormatted = format(new Date(startDate ?? ''), DATE_FORMAT);
|
||||
const endDateFormatted = format(new Date(endDate ?? ''), DATE_FORMAT);
|
||||
|
||||
const isValidDateRange = is.date(startDate) && is.date(endDate) && endDate > startDate;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
|
@ -27,17 +27,16 @@ export const BudgetItem: FC<BudgetItemProps> = ({ budget }) => {
|
|||
<h3 className={styles.Title}>
|
||||
{name} <FontAwesomeIcon icon={faCoins} />
|
||||
</h3>
|
||||
<div>
|
||||
<small>Remaining Budget</small>
|
||||
<Progress percent={spentPercentage} theme={{ active: { color: '#84844c' } }} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<small>Period</small>
|
||||
<p>
|
||||
{startDateFormatted} - {endDateFormatted}
|
||||
</p>
|
||||
</div>
|
||||
<DetailWithTitle title={'Remaining Budget'}>
|
||||
<ProgressBar percentage={spentPercentage} />
|
||||
</DetailWithTitle>
|
||||
|
||||
{isValidDateRange && (
|
||||
<DetailWithTitle title={'Period'}>
|
||||
<p>{datesToDayRange(startDate, endDate)}</p>
|
||||
</DetailWithTitle>
|
||||
)}
|
||||
|
||||
<Link className={styles.Details} href={`/budget/${id}`}>
|
||||
Show Details
|
||||
|
|
|
@ -6,6 +6,6 @@
|
|||
|
||||
.ListItems {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
gap: var(--grid-gap);
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
}
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
.Detail {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
14
src/components/_design/DetailWithTitle/DetailWithTitle.tsx
Normal file
14
src/components/_design/DetailWithTitle/DetailWithTitle.tsx
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { FunctionComponent, PropsWithChildren } from 'react';
|
||||
|
||||
import styles from './DetailWithTitle.module.scss';
|
||||
|
||||
export interface DetailWithTitleProps extends PropsWithChildren {
|
||||
readonly title: string;
|
||||
}
|
||||
|
||||
export const DetailWithTitle: FunctionComponent<DetailWithTitleProps> = ({ children, title }) => (
|
||||
<div className={styles.Detail}>
|
||||
<small>{title}</small>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
10
src/components/_design/ProgressBar/ProgressBar.tsx
Normal file
10
src/components/_design/ProgressBar/ProgressBar.tsx
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { FunctionComponent } from 'react';
|
||||
import { Progress } from 'react-sweet-progress';
|
||||
|
||||
export interface ProgressBarProps {
|
||||
readonly percentage: number;
|
||||
}
|
||||
|
||||
export const ProgressBar: FunctionComponent<ProgressBarProps> = ({ percentage }) => (
|
||||
<Progress percent={percentage} theme={{ active: { color: '#84844c' } }} />
|
||||
);
|
7
src/helpers/date.ts
Normal file
7
src/helpers/date.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import format from 'date-fns/format';
|
||||
|
||||
const DAY_FORMAT = 'dd.MM.yy';
|
||||
|
||||
export const dateToFormattedDay = (date: Date): string => format(date, DAY_FORMAT);
|
||||
|
||||
export const datesToDayRange = (start: Date, end: Date) => dateToFormattedDay(start) + ' – ' + dateToFormattedDay(end);
|
|
@ -1,13 +1,14 @@
|
|||
import { useQuery, UseQueryResult } from '@tanstack/react-query';
|
||||
|
||||
import { BudgetsService, BudgetSummary } from '../generated/openapi';
|
||||
import { BudgetsService } from '../generated/openapi';
|
||||
import { BudgetSummaryModel, mapToBudgetSummaryModel } from '../models/budget-summary';
|
||||
|
||||
const queryKey = Symbol('getBudgetSummary');
|
||||
|
||||
export const useBudgetSummaryQuery = (id: string): UseQueryResult<BudgetSummary> => {
|
||||
return useQuery({
|
||||
export const useBudgetSummaryQuery = (id: string): UseQueryResult<BudgetSummaryModel | null> =>
|
||||
useQuery({
|
||||
queryKey: [queryKey, id],
|
||||
queryFn: () => BudgetsService.getBudgetSummary(id),
|
||||
keepPreviousData: true,
|
||||
select: mapToBudgetSummaryModel,
|
||||
});
|
||||
};
|
||||
|
|
|
@ -1,11 +1,20 @@
|
|||
import { useQuery, UseQueryResult } from '@tanstack/react-query';
|
||||
|
||||
import { Budget, BudgetsService } from '../generated/openapi';
|
||||
import { BudgetModel, mapToBudgetModel } from '../models/budget';
|
||||
|
||||
const budgetSymbol = Symbol('getBudgets');
|
||||
export const useBudgets = (): UseQueryResult<Budget[]> => {
|
||||
return useQuery({
|
||||
|
||||
const selectValidBudgetModelsFromBudgetDtos = (budgetDtos: Budget[]) =>
|
||||
budgetDtos.reduce<BudgetModel[]>((budgetModels, budgetDto) => {
|
||||
const mappedBudget = mapToBudgetModel(budgetDto);
|
||||
|
||||
return mappedBudget ? [...budgetModels, mappedBudget] : budgetModels;
|
||||
}, []);
|
||||
|
||||
export const useBudgets = (): UseQueryResult<BudgetModel[]> =>
|
||||
useQuery({
|
||||
queryKey: [budgetSymbol],
|
||||
queryFn: () => BudgetsService.getBudgets(),
|
||||
select: selectValidBudgetModelsFromBudgetDtos,
|
||||
});
|
||||
};
|
||||
|
|
8
src/models/_generic-schemas.ts
Normal file
8
src/models/_generic-schemas.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import is from '@sindresorhus/is';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const optionalDateSchema = z
|
||||
.string()
|
||||
.datetime()
|
||||
.optional()
|
||||
.transform((dataString) => (!is.undefined(dataString) ? new Date(dataString) : dataString));
|
24
src/models/budget-summary.ts
Normal file
24
src/models/budget-summary.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { optionalDateSchema } from './_generic-schemas';
|
||||
import { transactionSchema } from './transaction';
|
||||
|
||||
export const budgetSummaryModelSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
limit: z.number().positive(),
|
||||
transactions: z.array(transactionSchema),
|
||||
startDate: optionalDateSchema,
|
||||
endDate: optionalDateSchema,
|
||||
});
|
||||
|
||||
export type BudgetSummaryModel = z.infer<typeof budgetSummaryModelSchema>;
|
||||
|
||||
export const mapToBudgetSummaryModel = (data: unknown): BudgetSummaryModel | null => {
|
||||
try {
|
||||
return budgetSummaryModelSchema.parse(data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
};
|
23
src/models/budget.ts
Normal file
23
src/models/budget.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { optionalDateSchema } from './_generic-schemas';
|
||||
|
||||
export const budgetModelSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
limit: z.number().positive(),
|
||||
spent: z.number().positive(),
|
||||
startDate: optionalDateSchema,
|
||||
endDate: optionalDateSchema,
|
||||
});
|
||||
|
||||
export type BudgetModel = z.infer<typeof budgetModelSchema>;
|
||||
|
||||
export const mapToBudgetModel = (data: unknown): BudgetModel | null => {
|
||||
try {
|
||||
return budgetModelSchema.parse(data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
};
|
13
src/models/transaction.ts
Normal file
13
src/models/transaction.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const transactionSchema = z.object({
|
||||
id: z.string(),
|
||||
description: z.string(),
|
||||
amount: z.number().positive(),
|
||||
date: z
|
||||
.string()
|
||||
.datetime()
|
||||
.transform((dateTime) => new Date(dateTime)),
|
||||
});
|
||||
|
||||
export type TransactionModel = z.infer<typeof transactionSchema>;
|
|
@ -15,7 +15,7 @@ import type { AppProps } from 'next/app';
|
|||
config.autoAddCss = false;
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false } } });
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
|
|
|
@ -1,19 +1,19 @@
|
|||
import is from '@sindresorhus/is';
|
||||
|
||||
import { Budget } from '../../../../generated/openapi';
|
||||
import { budgetList } from '../index';
|
||||
import { BudgetSummary } from '../../../../generated/openapi';
|
||||
import { budgetSummaryFactory } from '../../factories/budget.factories';
|
||||
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse<Budget>) {
|
||||
const budgetSummaryMock = budgetSummaryFactory.buildList(8);
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse<BudgetSummary>) {
|
||||
const { query, method } = req;
|
||||
const id = query.id as string;
|
||||
|
||||
switch (method) {
|
||||
case 'GET':
|
||||
const targetBudget = budgetList.find(({ id: budgetId }) => budgetId === id);
|
||||
|
||||
console.log(budgetList.map(({ id }) => id));
|
||||
const targetBudget = budgetSummaryMock.find(({ id: budgetId }) => budgetId === id);
|
||||
|
||||
if (is.undefined(targetBudget)) {
|
||||
res.status(404).end();
|
||||
|
|
|
@ -4,7 +4,6 @@ import { budgetFactory } from '../factories/budget.factories';
|
|||
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
// TODO fix this setup as on HMR this is created as well again which results in invalid data on detail pages (404)
|
||||
export const budgetList = budgetFactory.buildList(8);
|
||||
budgetFactory.rewindSequence();
|
||||
|
||||
|
|
|
@ -1,14 +1,13 @@
|
|||
import { randomUUID } from 'crypto';
|
||||
|
||||
import { Factory } from 'fishery';
|
||||
|
||||
import { Budget } from '../../../generated/openapi';
|
||||
import { transactionFactory } from './transaction.factories';
|
||||
import { Budget, BudgetSummary } from '../../../generated/openapi';
|
||||
|
||||
export const budgetFactory = Factory.define<Budget>(({ sequence }) => {
|
||||
const limit = 200 + (sequence - 1) * 50;
|
||||
|
||||
return {
|
||||
id: randomUUID(),
|
||||
id: `budget-mock-id-${sequence}`,
|
||||
startDate: '2023-02-21T18:49:13.620Z',
|
||||
endDate: '2023-03-21T18:49:13.620Z',
|
||||
name: 'Budget-' + sequence,
|
||||
|
@ -16,3 +15,19 @@ export const budgetFactory = Factory.define<Budget>(({ sequence }) => {
|
|||
spent: limit - Math.min(limit, sequence * 50),
|
||||
};
|
||||
});
|
||||
|
||||
export const budgetSummaryFactory = Factory.define<BudgetSummary>(({ sequence }) => {
|
||||
const limit = 200 + (sequence - 1) * 50;
|
||||
|
||||
const transactions = transactionFactory.buildList(5);
|
||||
transactionFactory.rewindSequence();
|
||||
|
||||
return {
|
||||
id: `budget-mock-id-${sequence}`,
|
||||
startDate: '2023-02-21T18:49:13.620Z',
|
||||
endDate: '2023-03-21T18:49:13.620Z',
|
||||
name: 'Budget-' + sequence,
|
||||
limit,
|
||||
transactions: transactions,
|
||||
};
|
||||
});
|
||||
|
|
10
src/pages/api/factories/transaction.factories.ts
Normal file
10
src/pages/api/factories/transaction.factories.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { Factory } from 'fishery';
|
||||
|
||||
import { Transaction } from '../../../generated/openapi';
|
||||
|
||||
export const transactionFactory = Factory.define<Transaction>(({ sequence }) => ({
|
||||
id: `transaction-id-${sequence}`,
|
||||
amount: 5 * sequence,
|
||||
date: '2023-03-21T18:49:13.620Z',
|
||||
description: `Transaction description for transaction ${sequence}`,
|
||||
}));
|
|
@ -5,16 +5,7 @@ import { BudgetDetail } from '../../components/BudgetDetail/BudgetDetail';
|
|||
|
||||
export default function BudgetPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const { id } = router.query;
|
||||
|
||||
if (is.nonEmptyString(id)) {
|
||||
return (
|
||||
<>
|
||||
<BudgetDetail id={id} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
return !is.string(id) ? null : <BudgetDetail id={id} />;
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
--padding-inline: var(--padding-container);
|
||||
|
||||
@media (min-width: 980px) {
|
||||
--content-width: clamp(980px, 60vw, var(--max-width));
|
||||
--content-width: clamp(980px, 70vw, var(--max-width));
|
||||
--padding-inline: 0px;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
:root {
|
||||
--max-width: 1100px;
|
||||
--max-width: 1080px;
|
||||
--border-radius: 4px;
|
||||
--font-mono: ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono',
|
||||
'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace', 'Source Code Pro',
|
||||
|
@ -49,6 +49,7 @@
|
|||
|
||||
/* Custom Dimensions */
|
||||
--container-padding: 32px;
|
||||
--grid-gap: 16px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
|
@ -107,6 +108,22 @@ body {
|
|||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h4 {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
|
|
Loading…
Reference in a new issue