Compare commits

..

No commits in common. "4761f0c25cad709aeb7ae7f417a17265c8b99ccf" and "a34358bfa019534dff510a0a83b2ba314d5bb0f9" have entirely different histories.

20 changed files with 133 additions and 1011 deletions

View file

@ -26,7 +26,7 @@ jobs:
run: |
yarn install
PORT=5001 yarn build
yarn prisma generate
yarn prisma db push
- name: 🚀 Deploy
run: |

View file

@ -1,25 +1,3 @@
### [1.1.1](https://github.com/nzambello/explit/compare/v1.1.0...v1.1.1) (2022-02-22)
### Maintenance
* disable gh action ([96c66b0](https://github.com/nzambello/explit/commit/96c66b0d1a6425cf278889536323e3173b10b468))
## [1.1.0](https://github.com/nzambello/explit/compare/v1.0.0...v1.1.0) (2022-02-22)
### Features
* add expenses list + filters ([6740469](https://github.com/nzambello/explit/commit/6740469f9470c412ca76a2c3bf865ab2f5f1f9a7))
* add income based balances ([60b5518](https://github.com/nzambello/explit/commit/60b551801a2a2cd5070f23e5e0439b857b075a72))
* add statistics ([b6674bc](https://github.com/nzambello/explit/commit/b6674bccd7a96983d2e66a988a759b89016d1d5f))
* edit expense, fix navigation on expense forms ([0fabc1e](https://github.com/nzambello/explit/commit/0fabc1edcc198d49602e9075d597674bfc6b36c9))
### Bug Fixes
* wf prisma generation ([0c269f6](https://github.com/nzambello/explit/commit/0c269f6e433e7879c38e8c6a979bd120e0b7e6c7))
## 1.0.0 (2022-02-21)

View file

@ -4,54 +4,10 @@ Track and split shared expenses with friends and family.
![Explit](https://github.com/nzambello/explit/raw/main/public/explit.png)
## Features
### User management
When signing up, you can choose to create a new user or to sign in with an existing user.
You can choose an icon, which can be an emoji or a letter to represent your user.
When entering the team, you can choose to create a new team or to join an existing team by its name.
Once logged in, in `/account/preferences` you can select a theme for the app and in `/account/manage` you can change your team, icon, and password.
### Track expenses
Once logged in, you can see a list of all your expenses and the balance of the team.
From the homepage, you can add an expense or transfer money to another user.
For every expense, you can see the amount, the date, the user who paid, and a description.
From the expense page, you can edit the amount and the description or delete the item.
### Balance equality
The goal of the app is to split equally the expenses among the team.
When you add an expense, the amount is added to the balance of the team.
Then, the balance of the team is calculated to show the amount of money each user should have or give.
#### Balance based on income
If enabled, the balance of the amount of money each user should have or give is based on each user's income.
There's an option in the `/team` page to enable this feature.
To have an equal split based on everyone's income, you can select
this option. If a team of two people, one earns 1.5 times as much as the other, then his or her share in the common expenses will be 1.5 times as much as the other's.
Otherwise, every user will have the same base amount of money.
### Statistics
The statistics page can show you how much you're spending currently and how much you've spent in previous months.
- [Remix Docs](https://remix.run/docs)
## Development
- [Remix Docs](https://remix.run/docs)
From your terminal:
```sh

View file

@ -1,55 +1,27 @@
const Filter = ({
className,
active = false,
}: {
className?: string;
active?: boolean;
}) => (
const Filter = () => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
{active ? (
<>
<path
d="M20.7206 18.24L19.7806 17.3C20.2706 16.56 20.5606 15.67 20.5606 14.71C20.5606 12.11 18.4506 10 15.8506 10C13.2506 10 11.1406 12.11 11.1406 14.71C11.1406 17.31 13.2506 19.42 15.8506 19.42C16.8106 19.42 17.6906 19.13 18.4406 18.64L19.3806 19.58C19.5706 19.77 19.8106 19.86 20.0606 19.86C20.3106 19.86 20.5506 19.77 20.7406 19.58C21.0906 19.22 21.0906 18.62 20.7206 18.24Z"
fill="currentColor"
/>
<path
d="M19.5799 4.02V6.24C19.5799 7.05 19.0799 8.06 18.5799 8.57L18.3999 8.73C18.2599 8.86 18.0499 8.89 17.8699 8.83C17.6699 8.76 17.4699 8.71 17.2699 8.66C16.8299 8.55 16.3599 8.5 15.8799 8.5C12.4299 8.5 9.62992 11.3 9.62992 14.75C9.62992 15.89 9.93992 17.01 10.5299 17.97C11.0299 18.81 11.7299 19.51 12.4899 19.98C12.7199 20.13 12.8099 20.45 12.6099 20.63C12.5399 20.69 12.4699 20.74 12.3999 20.79L10.9999 21.7C9.69992 22.51 7.90992 21.6 7.90992 19.98V14.63C7.90992 13.92 7.50992 13.01 7.10992 12.51L3.31992 8.47C2.81992 7.96 2.41992 7.05 2.41992 6.45V4.12C2.41992 2.91 3.31992 2 4.40992 2H17.5899C18.6799 2 19.5799 2.91 19.5799 4.02Z"
fill="currentColor"
/>
</>
) : (
<>
<path
d="M14.3201 19.07C14.3201 19.68 13.92 20.48 13.41 20.79L12.0001 21.7C10.6901 22.51 8.87006 21.6 8.87006 19.98V14.63C8.87006 13.92 8.47006 13.01 8.06006 12.51L4.22003 8.47C3.71003 7.96 3.31006 7.06001 3.31006 6.45001V4.13C3.31006 2.92 4.22008 2.01001 5.33008 2.01001H18.67C19.78 2.01001 20.6901 2.92 20.6901 4.03V6.25C20.6901 7.06 20.1801 8.07001 19.6801 8.57001"
stroke="currentColor"
strokeWidth="1.5"
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M16.07 16.52C17.8373 16.52 19.27 15.0873 19.27 13.32C19.27 11.5527 17.8373 10.12 16.07 10.12C14.3027 10.12 12.87 11.5527 12.87 13.32C12.87 15.0873 14.3027 16.52 16.07 16.52Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M19.87 17.12L18.87 16.12"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</>
)}
<path
d="M5.40002 2.09998H18.6C19.7 2.09998 20.6 2.99998 20.6 4.09998V6.29998C20.6 7.09998 20.1 8.09998 19.6 8.59998L15.3 12.4C14.7 12.9 14.3 13.9 14.3 14.7V19C14.3 19.6 13.9 20.4 13.4 20.7L12 21.6C10.7 22.4 8.90002 21.5 8.90002 19.9V14.6C8.90002 13.9 8.50002 13 8.10002 12.5L4.30002 8.49998C3.80002 7.99998 3.40002 7.09998 3.40002 6.49998V4.19998C3.40002 2.99998 4.30002 2.09998 5.40002 2.09998Z"
stroke="currentColor"
strokeWidth="1.5"
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10.93 2.09998L6 9.99998"
stroke="#292D32"
strokeWidth="1.5"
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);

View file

@ -61,13 +61,6 @@ function validateTeamId(teamId: unknown) {
}
}
function validateIncome(income: unknown) {
if (typeof income !== "number") {
console.log(income, typeof income);
return "Income must be a positive number or zero";
}
}
type ActionData = {
formError?: string;
success?: string;
@ -76,14 +69,12 @@ type ActionData = {
teamId: string | undefined;
password: string | undefined;
confirmPassword: string | undefined;
avgIncome: string | undefined;
};
fields?: {
password?: string;
confirmPassword?: string;
teamId?: string;
icon?: string;
avgIncome?: number;
};
};
@ -100,7 +91,6 @@ export const action: ActionFunction = async ({ request }) => {
const confirmPassword = form.get("confirmPassword");
const icon = form.get("icon") ?? (user ? user.username[0] : undefined);
const teamId = form.get("teamId");
const avgIncome = form.get("avgIncome");
const fields = {
icon: typeof icon === "string" ? icon : undefined,
@ -108,12 +98,6 @@ export const action: ActionFunction = async ({ request }) => {
confirmPassword:
typeof confirmPassword === "string" ? confirmPassword : undefined,
teamId: typeof teamId === "string" ? teamId : undefined,
avgIncome:
typeof avgIncome === "number"
? Math.round(avgIncome)
: typeof avgIncome === "string"
? parseInt(avgIncome, 10)
: undefined,
};
const fieldErrors = {
password: validatePassword(password),
@ -123,19 +107,14 @@ export const action: ActionFunction = async ({ request }) => {
),
icon: validateIcon(icon),
teamId: validateTeamId(teamId),
avgIncome: validateIncome(fields.avgIncome),
};
if (Object.values(fieldErrors).some(Boolean))
return badRequest({ fieldErrors, fields });
const nonEmptyFields = Object.entries(fields).reduce((acc, [key, value]) => {
if (
(typeof value === "string" && key !== "confirmPassword") ||
(typeof value === "number" && key === "avgIncome")
)
if (typeof value === "string" && key !== "confirmPassword")
return { ...acc, [key]: value ?? undefined };
return acc;
else return acc;
}, {});
const userUpdated = await updateUser({
id: user.id,
@ -320,48 +299,6 @@ export default function AccountPreferencesRoute() {
</div>
)}
</div>
<div className="form-control mb-3">
<label htmlFor="avgIncome-input" className="label">
<span className="label-text">Average monthly income</span>
</label>
<input
type="number"
id="avgIncome-input"
name="avgIncome"
className={`input input-bordered${
Boolean(actionData?.fieldErrors?.avgIncome) ? " input-error" : ""
}`}
defaultValue={
actionData?.fields?.avgIncome ?? data.user?.avgIncome ?? 0
}
aria-invalid={Boolean(actionData?.fieldErrors?.avgIncome)}
aria-describedby={
actionData?.fieldErrors?.avgIncome ? "avgIncome-error" : undefined
}
/>
{actionData?.fieldErrors?.avgIncome && (
<div className="alert alert-error mt-2" role="alert">
<div className="flex-1">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="w-6 h-6 mx-2 stroke-current"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
></path>
</svg>
<label id="avgIncome-error">
{actionData?.fieldErrors.avgIncome}
</label>
</div>
</div>
)}
</div>
<div className="form-control mb-3">
<label htmlFor="teamId-input" className="label">
<span className="label-text">Team</span>
@ -373,7 +310,7 @@ export default function AccountPreferencesRoute() {
className={`input input-bordered${
Boolean(actionData?.fieldErrors?.teamId) ? " input-error" : ""
}`}
defaultValue={actionData?.fields?.teamId ?? data.user?.teamId}
defaultValue={actionData?.fields?.teamId}
aria-invalid={Boolean(actionData?.fieldErrors?.teamId)}
aria-describedby={
actionData?.fieldErrors?.teamId ? "teamid-error" : undefined

View file

@ -30,7 +30,7 @@ export default function ExpensesRoute() {
return (
<>
<Header user={data.user} route="/expenses" />
<main className="container mx-auto px-2">
<main>
<Outlet />
</main>
</>

View file

@ -134,17 +134,11 @@ export default function ExpenseRoute() {
{data.isOwner && (
<>
<div className="flex justify-center align-center mt-6">
<Link
to={`/expenses/${data.expense.id}/edit`}
className="btn btn-default mr-4"
>
Edit
</Link>
<label
htmlFor="delete-expense-modal"
className="btn btn-error modal-button"
>
Delete
Delete expense
</label>
</div>
<input

View file

@ -1,255 +0,0 @@
import { Expense } from "@prisma/client";
import type { ActionFunction, LoaderFunction } from "remix";
import {
useActionData,
redirect,
json,
useCatch,
Link,
Form,
useTransition,
useLoaderData,
} from "remix";
import { db } from "~/utils/db.server";
import { requireUserId, getUser } from "~/utils/session.server";
function validateExpenseDescription(description: string) {
if (description.length < 2) {
return `That expense's description is too short`;
}
}
type ActionData = {
formError?: string;
fieldErrors?: {
description: string | undefined;
amount?: string | undefined;
user?: string | undefined;
};
fields?: {
description: string;
amount: number;
};
};
type LoaderData = {
userId: string;
expense: Expense;
};
const badRequest = (data: ActionData) => json(data, { status: 400 });
export const loader: LoaderFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
if (!userId) {
throw new Response("Unauthorized", { status: 401 });
}
const expense = await db.expense.findUnique({
where: { id: params.expenseId },
});
if (!expense) {
throw new Response("What an expense! Not found.", { status: 404 });
}
const data: LoaderData = { userId, expense };
return data;
};
export const action: ActionFunction = async ({ request }) => {
const userId = await requireUserId(request);
const user = await getUser(request);
const form = await request.formData();
const description = form.get("description");
const amount = parseInt(form.get("amount")?.toString() || "0", 10);
if (
typeof description !== "string" ||
typeof amount !== "number" ||
user === null
) {
return badRequest({
formError: `Form not submitted correctly.`,
});
}
const fieldErrors = {
description: validateExpenseDescription(description),
};
const fields = { description, amount };
if (Object.values(fieldErrors).some(Boolean)) {
return badRequest({ fieldErrors, fields });
}
const expense = await db.expense.create({
data: { ...fields, userId: userId, teamId: user.teamId },
});
if (!expense) {
return badRequest({
formError: `Could not create expense.`,
});
}
return redirect(`/expenses/${expense.id}`);
};
export default function EditExpenseRoute() {
const data = useLoaderData<LoaderData>();
const actionData = useActionData<ActionData>();
const transition = useTransition();
if (transition.submission) {
const description = transition.submission.formData.get("description");
const amount = transition.submission.formData.get("content");
if (
typeof description === "string" &&
typeof amount === "number" &&
!validateExpenseDescription(description)
) {
return (
<div>
<p>Description: {description}</p>
<p>Amount: {amount}</p>
<p>User: {data.userId}</p>
</div>
);
}
}
return (
<>
<div className="container mx-auto min-h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="card bg-base-200 w-full shadow-lg max-w-lg">
<div className="card-body w-full">
<h1 className="card-title">Edit expense</h1>
<Form
method="post"
className="mt-5"
aria-describedby={
actionData?.formError ? "form-error-message" : undefined
}
>
<div className="form-control mb-3">
<label className="label" htmlFor="description-input">
<span className="label-text">Description</span>
</label>
<input
type="text"
className="input input-bordered"
name="description"
id="description-input"
defaultValue={
actionData?.fields?.description ?? data.expense.description
}
aria-invalid={
Boolean(actionData?.fieldErrors?.description) || undefined
}
aria-describedby={
actionData?.fieldErrors?.description
? "description-error"
: undefined
}
/>
{actionData?.fieldErrors?.description && (
<div className="alert alert-error mt-3" role="alert">
<div className="flex-1">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="w-6 h-6 mx-2 stroke-current"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
></path>
</svg>
<label id="description-error">
{actionData?.fieldErrors.description}
</label>
</div>
</div>
)}
</div>
<div className="form-control mb-3">
<label className="label" htmlFor="amount-input">
<span className="label-text">Amount</span>
</label>
<input
type="number"
className="input input-bordered"
id="amount-input"
name="amount"
defaultValue={
actionData?.fields?.amount ?? data.expense.amount
}
/>
</div>
{actionData?.formError && (
<div className="alert alert-error mt-5" role="alert">
<div className="flex-1">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="w-6 h-6 mx-2 stroke-current"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
></path>
</svg>
<label id="form-error-message">
{actionData?.formError}
</label>
</div>
</div>
)}
<div className="text-center max-w-xs mx-auto mt-10">
<button type="submit" className="btn btn-primary btn-block">
Save
</button>
</div>
</Form>
</div>
</div>
</div>
<ul
className="menu px-3 menu-horizontal rounded-box max-w-xs mx-auto flex items-center justify-evenly"
role="navigation"
>
<li>
<Link
to={`/expenses/${data.expense.id}`}
className="btn btn-outline btn-accent"
>
Back
</Link>
</li>
</ul>
</>
);
}
export function CatchBoundary() {
const caught = useCatch();
if (caught.status === 401) {
return (
<div className="error-container">
<p>You must be logged in to submit an expense.</p>
<Link to="/login">Login</Link>
</div>
);
}
}
export function ErrorBoundary() {
return (
<div className="error-container">
Something unexpected went wrong. Sorry about that.
</div>
);
}

View file

@ -15,7 +15,6 @@ type LoaderData = {
count: number;
spent: number;
dueAmount: number;
avgIncome: number;
}[];
totalExpenses: {
count: number;
@ -53,33 +52,19 @@ export const loader: LoaderFunction = async ({ request }) => {
icon: m.icon,
count: teamExpenses.find((e) => e.userId === m.id)?._count?._all ?? 0,
spent: teamExpenses.find((e) => e.userId === m.id)?._sum?.amount ?? 0,
avgIncome: m.avgIncome ?? 0,
dueAmount: 0,
}));
let totalExpenses = expensesByUser.reduce(
(acc, { count, spent, avgIncome }) => ({
(acc, { count, spent }) => ({
count: acc.count + count,
amount: acc.amount + spent,
incomes: acc.incomes + avgIncome,
}),
{ count: 0, amount: 0, incomes: 0 }
{ count: 0, amount: 0 }
);
const avgPerUser = totalExpenses.amount / user.team.members.length;
const quotaPerUser = expensesByUser.reduce(
(acc: { [key: string]: number }, userData) => {
acc[userData.id] =
(userData.avgIncome / totalExpenses.incomes) * totalExpenses.amount;
return acc;
},
{}
);
let teamCounts = expensesByUser.map((userData) => ({
...userData,
dueAmount: user.team.balanceByIncome
? quotaPerUser[userData.id] - userData.spent
: avgPerUser - userData.spent,
dueAmount: avgPerUser - userData.spent,
}));
const data: LoaderData = {
@ -138,7 +123,7 @@ export default function ExpensesIndexRoute() {
</div>
<div className="col-span-2 md:col-span-1 card shadow-lg compact side bg-base-100">
<div className="flex-column items-center card-body !py-6">
<h2 className="card-title">Team balance</h2>
<h2 className="card-title">Who needs to pay who</h2>
<div className="w-full shadow stats grid-cols-2 grid-flow-row-dense">
{data.teamCounts?.map((user) => (
<div className="stat col-span-1" key={user.id}>
@ -148,14 +133,11 @@ export default function ExpensesIndexRoute() {
</div>
</div>
<div
className={`stat-value text-2xl ${
className={`stat-value ${
user.dueAmount > 0 ? "text-error" : "text-success"
}`}
>
{user.dueAmount !== Math.round(user.dueAmount)
? Math.abs(user.dueAmount).toFixed(2)
: Math.abs(user.dueAmount)}{" "}
{Math.abs(user.dueAmount)}
</div>
<div className="stat-title text-lg">{user.username}</div>
<div className="stat-desc text-info">

View file

@ -1,262 +1,33 @@
import type { User, Team, Expense } from "@prisma/client";
import type { LoaderFunction } from "remix";
import { redirect, useLoaderData, useCatch, Link, Form } from "remix";
import Filter from "~/icons/Filter";
import { db } from "~/utils/db.server";
import { getUser, requireUserId } from "~/utils/session.server";
type LoaderData = {
user: (User & { team: Team & { members: User[] } }) | null;
expenses: (Expense & { user: User & { team: Team } })[];
expensesCount: number;
page: number;
filters: {
description: string | null | undefined;
dateFrom: string | null | undefined;
dateTo: string | null | undefined;
user: string | null | undefined;
};
};
export const loader: LoaderFunction = async ({ request }) => {
const userId = requireUserId(request);
const user = await getUser(request);
if (!user?.id || !userId) {
return redirect("/login");
}
const expensesCount = await db.expense.count({
where: { teamId: user.teamId },
});
const searchParams = new URL(request.url)?.searchParams;
const page = parseInt(searchParams.get("page") || "1", 10);
const description = searchParams.get("description");
const dateFrom = searchParams.get("dateFrom");
const dateTo = searchParams.get("dateTo");
const userIdParam = searchParams.get("user");
const filters = {
description:
description && description.length > 0 ? description : undefined,
dateFrom: dateFrom && dateFrom.length > 0 ? dateFrom : undefined,
dateTo: dateTo && dateTo.length > 0 ? dateTo : undefined,
user: userIdParam && userIdParam?.length > 0 ? userIdParam : undefined,
};
const expensesFilters = {
...(filters.description && {
description: { contains: filters.description },
}),
...((filters.dateFrom || filters.dateTo) && {
createdAt: {
...(filters.dateFrom && {
gte: new Date(`${filters.dateFrom}T00:00:00+0100`),
}),
...(filters.dateTo && {
lte: new Date(`${filters.dateTo}T00:00:00+0100`),
}),
},
}),
...(filters.user && { userId: filters.user }),
};
console.log("FILTERS", filters);
const expenses = await db.expense.findMany({
where: {
teamId: user.teamId,
...expensesFilters,
},
take: 10,
skip: (page - 1) * 10,
orderBy: {
createdAt: "desc",
},
include: {
user: {
include: {
team: true,
},
},
},
});
const data: LoaderData = {
user,
expenses,
expensesCount,
page,
filters,
};
return data;
};
import { Link } from "remix";
export default function ListExpensesRoute() {
const data = useLoaderData<LoaderData>();
const hasFilters = Object.values(data.filters).some(
(value) => value !== undefined && value !== null
);
return (
<>
<h1 className="mb-6 mt-6 text-4xl font-bold">List expenses</h1>
<label htmlFor="filters-modal" className="btn modal-button">
<Filter
className={`w-6 h-6 mr-2${hasFilters ? " text-primary" : ""}`}
active={hasFilters}
/>
Filters
</label>
<input type="checkbox" id="filters-modal" className="modal-toggle" />
<div className="modal">
<div className="modal-box">
<h2 className="font-bold text-lg">Filters</h2>
<Form className="my-4">
<div className="form-control w-full max-w-xs mb-4">
<label className="label" htmlFor="filter-description">
<span className="label-text">Text</span>
</label>
<input
type="text"
id="filter-description"
name="description"
placeholder="Search by description"
className="input input-bordered w-full max-w-xs"
defaultValue={data.filters.description ?? ""}
/>
</div>
<div className="form-control w-full max-w-xs mb-4">
<label className="label" htmlFor="filter-dateFrom">
<span className="label-text">Date from</span>
</label>
<input
type="date"
id="filter-dateFrom"
name="dateFrom"
placeholder={new Intl.DateTimeFormat("it", {
dateStyle: "short",
}).format(new Date())}
className="input input-bordered w-full max-w-xs"
defaultValue={data.filters.dateFrom ?? ""}
/>
</div>
<div className="form-control w-full max-w-xs mb-4">
<label className="label" htmlFor="filter-dateTo">
<span className="label-text">Date to</span>
</label>
<input
type="date"
id="filter-dateTo"
name="dateTo"
placeholder={new Intl.DateTimeFormat("it", {
dateStyle: "short",
}).format(new Date())}
className="input input-bordered w-full max-w-xs"
defaultValue={data.filters.dateTo ?? ""}
/>
</div>
<div className="form-control w-full max-w-xs mb-4">
<label className="label" htmlFor="filter-user">
<span className="label-text">User</span>
</label>
<select
name="user"
id="filter-user"
className="select select-bordered w-full max-w-xs"
defaultValue={data.filters.user ?? ""}
>
<option value="">Choose an user</option>
{data.user?.team?.members?.map((user) => (
<option key={user.id} value={user.id}>
{user.username}
</option>
))}
</select>
</div>
<div className="mt-6 text-center">
<label htmlFor="filters-modal" className="btn btn-default mr-4">
Close
</label>
<Link to={`?page=${data.page}`} className="btn btn-default mr-4">
Clear
</Link>
<button type="submit" className="btn btn-primary">
Apply
</button>
</div>
</Form>
<div className="hero py-40 bg-base-200 my-8 rounded-box">
<div className="text-center hero-content">
<div className="max-w-md">
<h1 className="mb-5 text-5xl font-bold">Work in progress</h1>
<p className="mb-5">
<button className="btn btn-lg loading"></button>
This page is under construction.
</p>
<Link to="/expenses" className="btn btn-primary">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="inline-block w-6 h-6 mr-2 stroke-current rotate-180"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
></path>
</svg>
Back
</Link>
</div>
</div>
<div className="overflow-x-auto bg-base-100 shadow-xl rounded-box mt-6 mb-10">
<table className="table table-zebra w-full">
<thead>
<tr>
<th></th>
<th>Date</th>
<th>User</th>
<th>Amount</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{data.expenses?.map((exp) => (
<tr key={exp.id}>
<td className="sticky left-0 z-10 shadow-lg">
<Link
to={`/expenses/${exp.id}`}
className="btn btn-sm btn-primary"
>
See
</Link>
</td>
<td>
{new Intl.DateTimeFormat("it", {
dateStyle: "short",
}).format(new Date(exp.createdAt))}
</td>
<td>{exp.user.username}</td>
<td>{exp.amount} </td>
<td>{exp.description}</td>
</tr>
))}
</tbody>
</table>
</div>
{data.expensesCount > 10 && (
<div className="btn-group justify-center my-8">
{[...new Array(Math.ceil(data.expensesCount / 10)).keys()].map(
(p) => (
<Link
to={`?page=${p + 1}`}
key={p}
className={`btn${data.page === p + 1 ? " btn-active" : ""}`}
>
{p + 1}
</Link>
)
)}
</div>
)}
</>
</div>
);
}
export function CatchBoundary() {
const caught = useCatch();
if (caught.status === 401) {
return redirect("/login");
}
if (caught.status === 404) {
return <div className="error-container">There is no data to display.</div>;
}
throw new Error(`Unexpected caught response with status: ${caught.status}`);
}
export function ErrorBoundary() {
return <div className="error-container">I did a whoopsies.</div>;
}

View file

@ -202,10 +202,7 @@ export default function NewExpenseRoute() {
</div>
</div>
</div>
<ul
className="menu px-3 menu-horizontal rounded-box max-w-xs mx-auto flex items-center justify-evenly"
role="navigation"
>
<ul className="menu px-3 menu-horizontal rounded-box max-w-xs mx-auto flex items-center justify-evenly">
<li>
<Link to="/expenses" className="btn btn-outline btn-accent">
Back

View file

@ -86,10 +86,6 @@ export const action: ActionFunction = async ({ request }) => {
teamId: user.teamId,
},
});
if (!expenseFrom || !expenseTo)
return json("Internal Server Error", { status: 500 });
return redirect(`/expenses`);
};
@ -185,10 +181,7 @@ export default function NewExpenseRoute() {
</div>
</div>
</div>
<ul
className="menu px-3 menu-horizontal rounded-box max-w-xs mx-auto flex items-center justify-evenly"
role="navigation"
>
<ul className="menu px-3 menu-horizontal rounded-box max-w-xs mx-auto flex items-center justify-evenly">
<li>
<Link to="/expenses" className="btn btn-outline btn-accent">
Back

View file

@ -34,7 +34,7 @@ function validateConfirmPassword(confirmPassword: unknown, password: string) {
}
function validateIcon(icon: unknown) {
if (typeof icon !== "string") {
if (typeof icon !== "string" || icon.length > 2) {
return `Icons must be a single character, e.g. "A" or "😎"`;
}
}
@ -70,7 +70,9 @@ export const action: ActionFunction = async ({ request }) => {
const username = form.get("username");
const password = form.get("password");
const confirmPassword = form.get("confirmPassword");
const icon = form.get("icon");
const icon =
form.get("icon") ??
(typeof username === "string" ? username[0] : undefined);
const teamId = form.get("teamId");
const redirectTo = form.get("redirectTo") || "/expenses";
if (
@ -78,7 +80,6 @@ export const action: ActionFunction = async ({ request }) => {
typeof password !== "string" ||
typeof confirmPassword !== "string" ||
typeof teamId !== "string" ||
typeof icon !== "string" ||
typeof redirectTo !== "string"
) {
return badRequest({
@ -86,12 +87,12 @@ export const action: ActionFunction = async ({ request }) => {
});
}
const fields = { username, password, icon, confirmPassword, teamId };
const fields = { username, password, confirmPassword, teamId };
const fieldErrors = {
username: validateUsername(username),
password: validatePassword(password),
confirmPassword: validateConfirmPassword(confirmPassword, password),
icon: validateIcon(icon ?? ""),
icon: validateIcon(icon),
teamId: validateTeamId(teamId),
};
if (Object.values(fieldErrors).some(Boolean))

View file

@ -1,97 +1,21 @@
import type { User, Team, Expense } from "@prisma/client";
import type { User, Team } from "@prisma/client";
import type { LoaderFunction } from "remix";
import { redirect, useLoaderData, useCatch, Link } from "remix";
import { db } from "~/utils/db.server";
import { getUser, requireUserId } from "~/utils/session.server";
import { redirect, Link, useLoaderData, useCatch } from "remix";
import { getUser } from "~/utils/session.server";
import Header from "../components/Header";
type LoaderData = {
user: (User & { team: Team & { members: User[] } }) | null;
thisMonth: {
count: number;
amount: number;
};
count: number;
avg: number;
statsByMonth: {
[month: string]: {
count: number;
amount: number;
};
};
};
export const loader: LoaderFunction = async ({ request }) => {
const userId = requireUserId(request);
const user = await getUser(request);
if (!user?.id || !userId) {
if (!user?.id) {
return redirect("/login");
}
const expenses = await db.expense.aggregate({
_avg: {
amount: true,
},
_count: {
_all: true,
},
where: { userId: user.id },
orderBy: {
createdAt: "asc",
},
});
let thisMonth = new Date();
thisMonth.setDate(0);
const thisMonthExp = await db.expense.aggregate({
_avg: {
amount: true,
},
_count: {
_all: true,
},
where: { userId: user.id, createdAt: { gt: thisMonth } },
orderBy: {
createdAt: "asc",
},
});
const allExpenses = await db.expense.findMany({
where: { userId: user.id },
});
const statsByMonth = allExpenses.reduce(
(
acc: { [key: string]: { count: number; amount: number } },
exp: Expense
) => {
const month = new Intl.DateTimeFormat("it", {
month: "2-digit",
year: "numeric",
}).format(new Date(exp.createdAt));
if (!acc[month]) {
acc[month] = {
count: 0,
amount: 0,
};
}
acc[month].count += 1;
acc[month].amount += exp.amount;
return acc;
},
{}
);
const data: LoaderData = {
user,
thisMonth: {
count: thisMonthExp?._count?._all ?? 0,
amount: thisMonthExp?._avg?.amount ?? 0,
},
count: expenses._count._all ?? 0,
avg: expenses._avg?.amount ?? 0,
statsByMonth,
};
return data;
};
@ -102,54 +26,33 @@ export default function ListExpensesRoute() {
return (
<>
<Header user={data.user} route="/expenses" />
<main className="container mx-auto">
<h1 className="mb-10 mt-6 text-4xl font-bold">Statistics</h1>
<div className="shadow-xl flex flex-wrap w-full rounded-box bg-base-100 overflow-hidden">
<div className="stat w-full sm:w-1/2 md:w-1/3">
<div className="stat-title">Expenses count</div>
<div className="stat-value">{data.count}</div>
<div className="stat-desc"></div>
</div>
<div className="stat w-full sm:w-1/2 md:w-1/3">
<div className="stat-title">Average per month</div>
<div className="stat-value">{data.avg.toFixed(2)} </div>
</div>
<div className="stat w-full sm:w-1/2 md:w-1/3">
<div className="stat-title">This month</div>
<div className="stat-value">
{data.thisMonth.amount.toFixed(2)}
</div>
<div className="stat-desc">{data.thisMonth.count} expenses</div>
<div className="hero py-40 bg-base-200 my-8 rounded-box">
<div className="text-center hero-content">
<div className="max-w-md">
<h1 className="mb-5 text-5xl font-bold">Work in progress</h1>
<p className="mb-5">
<button className="btn btn-lg loading"></button>
This page is under construction.
</p>
<Link to="/expenses" className="btn btn-primary">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="inline-block w-6 h-6 mr-2 stroke-current rotate-180"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
></path>
</svg>
Back
</Link>
</div>
</div>
<h2 className="mt-12 mb-8 text-2xl font-bold">Expenses by month</h2>
<div className="shadow-xl bg-base-100 rounded-box overflow-hidden">
<div className="overflow-x-auto">
<table className="table table-zebra w-full">
<thead>
<tr>
<th>Month</th>
<th>Total</th>
<th>Count</th>
</tr>
</thead>
<tbody>
{Object.keys(data.statsByMonth)?.map((month) => (
<tr key={month}>
<td className="capitalize">{month}</td>
<td>{data.statsByMonth[month].amount} </td>
<td>{data.statsByMonth[month].count}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</main>
</div>
</>
);
}

View file

@ -1,9 +1,8 @@
import type { LoaderFunction, ActionFunction } from "remix";
import { useLoaderData, useCatch, redirect, Link, Form } from "remix";
import type { LoaderFunction } from "remix";
import { useLoaderData, useCatch, redirect } from "remix";
import type { Team, User } from "@prisma/client";
import { getUser } from "~/utils/session.server";
import Header from "~/components/Header";
import { db } from "~/utils/db.server";
import { requireUserId, getUser } from "~/utils/session.server";
type LoaderData = {
user: User & {
@ -22,43 +21,9 @@ export const loader: LoaderFunction = async ({ request }) => {
return data;
};
export const action: ActionFunction = async ({ request }) => {
const form = await request.formData();
if (form.get("_method") === "patch") {
const userId = await requireUserId(request);
const user = await getUser(request);
const balanceByIncomeField = form.get("balanceByIncome");
const balanceByIncome =
typeof balanceByIncomeField === "boolean"
? balanceByIncomeField
: typeof balanceByIncomeField === "string"
? Boolean(balanceByIncomeField)
: false;
const team = await db.team.findUnique({
where: { id: user?.teamId },
});
if (!team) {
throw new Response("Can't update what does not exist", { status: 404 });
}
if (user?.teamId !== team.id) {
throw new Response("Pssh, nice try. That's not your expense", {
status: 401,
});
}
await db.team.update({ where: { id: team.id }, data: { balanceByIncome } });
return redirect("/expenses");
}
};
export default function JokesIndexRoute() {
const data = useLoaderData<LoaderData>();
const allUsersHaveSetAvgIncome = data.user.team.members?.every(
(m) => m.avgIncome && m.avgIncome > 0
);
return (
<>
<Header user={data.user} route="/account" />
@ -87,64 +52,6 @@ export default function JokesIndexRoute() {
</div>
))
)}
<div className="mt-10">
<h2 className="mb-4 text-xl">Balance based on income</h2>
<p>
To have an equal split based on everyone's income, you can select
this option. If of two people, one earns 1.5 times as much as the
other, then his or her share in the common expenses will be 1.5
times as much as the other's.
</p>
<Form method="post">
<input type="hidden" name="_method" value="patch" />
<fieldset disabled={!allUsersHaveSetAvgIncome}>
<div className="form-control mt-4">
<label className="cursor-pointer label justify-start">
<input
type="checkbox"
name="balanceByIncome"
defaultChecked={data.user.team.balanceByIncome ?? false}
className="checkbox checkbox-primary"
/>
<span className="label-text ml-2">
Enable balance based on income
</span>
</label>
</div>
</fieldset>
{!allUsersHaveSetAvgIncome ? (
<div className="alert shadow-lg">
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="stroke-info-content flex-shrink-0 w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span>
Not all users from this team set their average income.
</span>
</div>
<div className="flex-none">
<Link to="/account/manage" className="btn btn-sm">
Check in the Account page
</Link>
</div>
</div>
) : (
<button type="submit" className="mt-6 btn btn-primary">
Save
</button>
)}
</Form>
</div>
</main>
</>
);

View file

@ -10,7 +10,7 @@ type LoginForm = {
type RegisterForm = {
username: string;
password: string;
icon?: string;
icon: string;
teamId: string;
};
@ -19,7 +19,6 @@ type UpdateUserForm = {
password?: string;
icon?: string;
teamId?: string;
avgIncome?: number;
};
export async function register({
@ -39,12 +38,7 @@ export async function register({
});
}
const user = await db.user.create({
data: {
username,
passwordHash,
icon: icon && icon.length > 0 ? icon : username[0],
teamId,
},
data: { username, passwordHash, icon: icon ?? username[0], teamId },
});
return user;
}
@ -68,7 +62,6 @@ export async function updateUser({ id, ...data }: UpdateUserForm) {
...(data?.icon ? { icon: data.icon } : {}),
...(data?.password ? { passwordHash } : {}),
...(data?.teamId ? { teamId: data.teamId } : {}),
...(data?.avgIncome ? { avgIncome: data.avgIncome } : {}),
},
where: { id },
});

View file

@ -8,9 +8,9 @@
"scripts": {
"build": "npm run build:css && npm run build:worker && remix build",
"build:css": "tailwindcss -o ./app/tailwind.css",
"build:worker": "esbuild ./app/entry.worker.tsx --outfile=./build/entry.worker.js --minify --bundle --format=esm --define:process.env.NODE_ENV='\"production\"'",
"build:worker": "esbuild ./app/entry.worker.tsx --outfile=./public/entry.worker.js --minify --bundle --format=esm --define:process.env.NODE_ENV='\"production\"'",
"dev": "concurrently \"npm run dev:css\" \"npm run dev:worker\" \"remix dev\"",
"dev:worker": "esbuild ./app/entry.worker.tsx --outfile=./build/entry.worker.js --bundle --format=esm --define:process.env.NODE_ENV='\"development\"' --watch",
"dev:worker": "esbuild ./app/entry.worker.tsx --outfile=./public/entry.worker.js --bundle --format=esm --define:process.env.NODE_ENV='\"development\"' --watch",
"dev:css": "tailwindcss -o ./app/tailwind.css --watch",
"postinstall": "remix setup node",
"prepare": "husky install",
@ -47,5 +47,5 @@
"node": ">=14"
},
"sideEffects": false,
"version": "1.1.1"
"version": "1.0.0"
}

View file

@ -1,5 +0,0 @@
-- AlterTable
ALTER TABLE "Team" ADD COLUMN "balanceByIncome" BOOLEAN DEFAULT false;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "avgIncome" INTEGER;

View file

@ -11,11 +11,10 @@ datasource db {
}
model Team {
id String @id
icon String
description String?
members User[]
balanceByIncome Boolean? @default(value: false)
id String @id
icon String
description String?
members User[]
}
model User {
@ -29,7 +28,6 @@ model User {
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
expenses Expense[]
theme String?
avgIncome Int?
}
model Expense {

View file

@ -2,29 +2,29 @@ import { PrismaClient } from "@prisma/client";
const db = new PrismaClient();
async function seed() {
const team = await db.team.create({
const famiglia = await db.team.create({
data: {
id: "Family",
description: "My family",
id: "Famiglia",
description: "La mia famiglia",
icon: "♥️",
},
});
const user1 = await db.user.create({
const nicola = await db.user.create({
data: {
username: "user1",
username: "nicola",
passwordHash:
"$2b$10$K7L1OJ45/4Y2nIvhRVpCe.FSmhDdWoXehVzJptJ/op0lSsvqNu/1u", // twixrox
teamId: team.id,
"$2b$10$K7L1OJ45/4Y2nIvhRVpCe.FSmhDdWoXehVzJptJ/op0lSsvqNu/1u",
teamId: famiglia.id,
icon: "🧑‍💻",
theme: "dark",
},
});
const user2 = await db.user.create({
const shahra = await db.user.create({
data: {
username: "user2",
username: "shahra",
passwordHash:
"$2b$10$K7L1OJ45/4Y2nIvhRVpCe.FSmhDdWoXehVzJptJ/op0lSsvqNu/1u",
teamId: team.id,
teamId: famiglia.id,
icon: "💃",
theme: "emerald",
},
@ -32,43 +32,43 @@ async function seed() {
const expenses = [
{
description: "Groceries",
description: "Spesa",
amount: 100,
userId: user1.id,
teamId: team.id,
userId: nicola.id,
teamId: famiglia.id,
},
{
description: "Groceries",
description: "Spesa",
amount: 70,
userId: user2.id,
teamId: team.id,
userId: shahra.id,
teamId: famiglia.id,
},
{
description: "Rent",
description: "Affitto",
amount: 500,
userId: user2.id,
teamId: team.id,
userId: shahra.id,
teamId: famiglia.id,
},
// transaction between users
{
description: "Rent",
description: "Affitto",
amount: 250,
userId: user1.id,
teamId: team.id,
userId: nicola.id,
teamId: famiglia.id,
},
{
description: "Rent",
description: "Affitto",
amount: -250,
userId: user2.id,
teamId: team.id,
userId: shahra.id,
teamId: famiglia.id,
},
{
description: "Dinner out",
description: "Cena",
amount: 50,
userId: user1.id,
teamId: team.id,
userId: nicola.id,
teamId: famiglia.id,
},
];