Skip to content

Commit 4086b78

Browse files
committed
I guess you could call that an app
1 parent abe71c4 commit 4086b78

28 files changed

Lines changed: 1483 additions & 201 deletions

.github/workflows/deploy.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
name: Deploy to GitHub Pages
2+
3+
on:
4+
push:
5+
branches: [master]
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read
10+
pages: write
11+
id-token: write
12+
13+
concurrency:
14+
group: pages
15+
cancel-in-progress: true
16+
17+
jobs:
18+
build:
19+
runs-on: ubuntu-latest
20+
steps:
21+
- uses: actions/checkout@v4
22+
- uses: pnpm/action-setup@v4
23+
- uses: actions/setup-node@v4
24+
with:
25+
node-version: 24
26+
cache: pnpm
27+
- run: pnpm install --frozen-lockfile
28+
- run: pnpm generate
29+
env:
30+
NUXT_APP_BASE_URL: /spent/
31+
# SPA fallback: serve the app shell on unknown paths so client-side routing actually works in SPA.
32+
- run: cp .output/public/index.html .output/public/404.html
33+
- uses: actions/upload-pages-artifact@v3
34+
with:
35+
path: .output/public
36+
37+
deploy:
38+
needs: build
39+
runs-on: ubuntu-latest
40+
environment:
41+
name: github-pages
42+
url: ${{ steps.deployment.outputs.page_url }}
43+
steps:
44+
- id: deployment
45+
uses: actions/deploy-pages@v4

README.md

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,26 @@
1-
# This is a template
1+
# Spent
22

3-
Feel free to delete everything in `./app` but keep:
3+
## Purpose
44

5-
- Imports in `assets/css/main.css`
6-
- `app.vue`, obviously, with at least something of your preference
5+
Core purpose - to analyze why you are always broke.
76

8-
## More granular trim
7+
- **NOT a planner.**
8+
- Tag-based, not categorized expenses.
9+
- Offline-first.
10+
- Just write, then lookup (search, filter, sort, visualize).
911

10-
### TS Scripts & tsx
12+
> There was a perfect Google Play app, but perfection is incompatible with job security, so they had to ruin it. And added fullscreen popup ads.
1113
12-
Purpose: Putting .ts scripts plain into here dissatisfies TS very much.
14+
## Roadmap
1315

14-
Usage: `pnpm tsx scripts/example.ts`
16+
- [x] Add and see expenses and tags.
17+
- [x] Export & Import data.
18+
- [ ] Filter & sort expenses.
19+
- [ ] Search expenses.
20+
- [x] Visualize expenses as table.
21+
- [ ] Visualize expenses as charts.
22+
- [ ] Partial tag sharing.
1523

16-
Body:
24+
## License
1725

18-
- [scripts](./scripts) dir
19-
- `tsx` package in [package.json](./package.json)
20-
21-
### i18n
22-
23-
Body:
24-
25-
- (optional) [vue-i18n.patch](./vue-i18n.patch) patch & [pnpm-workspace.yaml](./pnpm-workspace.yaml):`patchedDependencies`:`vue-i18n` entry - to enforce strict keys
26-
- [i18n](./i18n) dir
27-
- `i18n` section & `"@nuxtjs/i18n"` module & `"./i18n/const"` import @ [nuxt.config.ts](./nuxt.config.ts)
28-
- Search for `$i18n`, `i18n` and `$t`
26+
AGPL-3.0-only. See [LICENSE](./LICENSE).

app/app.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
<NuxtPage />
44
</NuxtLayout>
55
<PToast />
6+
<DialogCreateTag />
7+
<DialogExpense />
8+
<DialogTagStats />
69
</template>
710

811
<script setup lang="ts">
@@ -12,4 +15,7 @@ useHead({
1215
class: layoutStore.darkMode ? "dark" : "",
1316
},
1417
});
18+
19+
const mainStore = useMainStore();
20+
await mainStore.fetchTags();
1521
</script>

app/assets/css/main.css

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
}
2424

2525
--animate-spin: spin 2s linear infinite;
26-
--color-temp: #ff0000;
26+
--color-danger: var(--p-red-500);
27+
--color-warn: var(--p-orange-500);
2728

2829
--radius: 4px;
2930
--radius-1: calc(var(--radius) * 1);

app/components/AppTopbar.vue

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<template>
2+
<PMenubar>
3+
<template #start>
4+
<PBreadcrumb :home="{ icon: PrimeIcons.HOME, route: '/' }" :model="breadcrumbs">
5+
<template #item="{ item }">
6+
<NuxtLink :to="item.route" class="font-semibold opacity-50">
7+
<i v-if="item.icon" :class="item.icon" />
8+
{{ item.label }}
9+
</NuxtLink>
10+
</template>
11+
</PBreadcrumb>
12+
</template>
13+
<template #end>
14+
<div class="flex gap-1">
15+
<PButton
16+
:icon="PrimeIcons.SAVE"
17+
outlined
18+
rounded
19+
severity="secondary"
20+
size="small"
21+
title="Export database"
22+
@click="mainStore.exportDb()"
23+
/>
24+
<PButton
25+
:icon="PrimeIcons.FOLDER_OPEN"
26+
outlined
27+
rounded
28+
severity="secondary"
29+
size="small"
30+
title="Import database"
31+
@click="triggerImport"
32+
/>
33+
<input ref="fileInput" type="file" accept=".json" hidden @change="onFileSelected" />
34+
<PButton
35+
:icon="PrimeIcons.CALCULATOR"
36+
outlined
37+
rounded
38+
severity="secondary"
39+
size="small"
40+
title="Tag statistics"
41+
@click="dialogsStore.tagStats = true"
42+
/>
43+
<PButton
44+
:icon="layoutStore.darkMode ? PrimeIcons.MOON : PrimeIcons.SUN"
45+
outlined
46+
rounded
47+
severity="secondary"
48+
size="small"
49+
@click="layoutStore.darkMode = !layoutStore.darkMode"
50+
/>
51+
</div>
52+
</template>
53+
</PMenubar>
54+
</template>
55+
56+
<script setup lang="ts">
57+
import { PrimeIcons } from "@primevue/core/api";
58+
import { format } from "date-fns";
59+
import type { MenuItem } from "primevue/menuitem";
60+
const layoutStore = useLayoutStore();
61+
const mainStore = useMainStore();
62+
const dialogsStore = useDialogsStore();
63+
const route = useRoute();
64+
65+
const fileInput = ref<HTMLInputElement>();
66+
const triggerImport = () => fileInput.value?.click();
67+
const onFileSelected = async (e: Event) => {
68+
const file = (e.target as HTMLInputElement).files?.[0];
69+
if (!file) return;
70+
await mainStore.importDb(file);
71+
if (fileInput.value) fileInput.value.value = "";
72+
};
73+
74+
type Breadcrumb = MenuItem & {
75+
route: string;
76+
};
77+
const breadcrumbs = computed<Breadcrumb[]>(() => {
78+
const result: Breadcrumb[] = [];
79+
if (!route.params.year) return result;
80+
result.push({
81+
label: `${route.params.year}`,
82+
route: `/year-${route.params.year}`,
83+
});
84+
if (!route.params.month) return result;
85+
result.push({
86+
label: format(new Date(Number(route.params.year), Number(route.params.month), 1), "MMMM"),
87+
route: `/year-${route.params.year}/month-${route.params.month}`,
88+
});
89+
if (!route.params.day) return result;
90+
result.push({
91+
label: `${route.params.day}`,
92+
route: `/year-${route.params.year}/month-${route.params.month}/day-${route.params.day}`,
93+
});
94+
return result;
95+
});
96+
</script>
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<template>
2+
<PDialog
3+
v-model:visible="dialogsStore.createTag"
4+
modal
5+
:closable="!submitting_createTag"
6+
:close-on-escape="!submitting_createTag"
7+
header="Create tag"
8+
>
9+
<form class="flex-stack gap-4" @submit.prevent="createTag">
10+
<PIftaLabel>
11+
<!-- TODO Replace PrimeVue inputs and buttons with J where possible across the project -->
12+
<PInputText v-model="tagName" autofocus :disabled="submitting_createTag" />
13+
<label>Tag name</label>
14+
</PIftaLabel>
15+
<PButton
16+
label="Create tag"
17+
type="submit"
18+
:loading="submitting_createTag"
19+
:disabled="submitting_createTag || !tagName.trim()"
20+
/>
21+
</form>
22+
</PDialog>
23+
</template>
24+
25+
<script setup lang="ts">
26+
const tagName = ref<string>("");
27+
const submitting_createTag = ref(false);
28+
const mainStore = useMainStore();
29+
const dialogsStore = useDialogsStore();
30+
const createTag = async () => {
31+
submitting_createTag.value = true;
32+
try {
33+
await mainStore.createTag({ name: tagName.value });
34+
tagName.value = "";
35+
dialogsStore.createTag = false;
36+
} catch (error) {
37+
console.error(error);
38+
}
39+
submitting_createTag.value = false;
40+
};
41+
</script>

app/components/Dialog/Expense.vue

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<template>
2+
<PDialog
3+
v-model:visible="visible"
4+
modal
5+
header="Expense details"
6+
class="max-w-120"
7+
:close-on-escape="!editing"
8+
@hide="onHide"
9+
>
10+
<PDrawer
11+
v-model:visible="editing"
12+
header="Edit expense"
13+
position="full"
14+
@hide="editExpenseForm?.reset()"
15+
@show="editExpenseForm?.fill(expense!)"
16+
>
17+
<ExpenseForm ref="editExpenseForm" context="edit" :submitting="submitting_editExpense" @submit="editExpense" />
18+
</PDrawer>
19+
<table v-if="expense" class="w-full" style="border-collapse: separate; border-spacing: 8px">
20+
<tbody>
21+
<tr>
22+
<td>Name</td>
23+
<td>{{ expense.name }}</td>
24+
</tr>
25+
<tr>
26+
<td>Amount</td>
27+
<td class="font-mono">{{ expense.amount.toFixed(2) }}</td>
28+
</tr>
29+
<tr>
30+
<td>Date</td>
31+
<td>{{ format(expense.date, "d MMM yyyy") }}</td>
32+
</tr>
33+
<tr>
34+
<td>Tags</td>
35+
<td>
36+
<div class="flex flex-wrap gap-2">
37+
<TagChip v-for="tag in expense.tags" :key="tag.id" :tag="tag" />
38+
</div>
39+
</td>
40+
</tr>
41+
<tr class="italic">
42+
<td>Created at</td>
43+
<td class="opacity-50">{{ format(expense.createdAt, "d MMM yyyy HH:mm:ss") }}</td>
44+
</tr>
45+
</tbody>
46+
</table>
47+
<PMessage v-else severity="error">No data</PMessage>
48+
<template #footer>
49+
<div class="grid w-full grid-cols-2 gap-2">
50+
<JButton label="Edit" theme="warn-outlined" :icon="PrimeIcons.PENCIL" @click="editing = true" />
51+
<JHoldButton label="Delete" theme="default-outlined" :icon="PrimeIcons.TRASH" @click="deleteExpense" />
52+
</div>
53+
</template>
54+
</PDialog>
55+
</template>
56+
57+
<script setup lang="ts">
58+
import { format } from "date-fns";
59+
import { PrimeIcons } from "@primevue/core/api";
60+
import type ExpenseForm from "~/components/ExpenseForm.vue";
61+
import type { ExpenseFormInputs } from "~/components/ExpenseForm.vue";
62+
import type { ToastMessageOptions } from "primevue";
63+
const dialogsStore = useDialogsStore();
64+
const mainStore = useMainStore();
65+
const toast = useToast();
66+
const visible = computed<boolean>({
67+
get() {
68+
return !!dialogsStore.expense;
69+
},
70+
set(value) {
71+
if (!value) dialogsStore.expense = null;
72+
else console.warn("Expense dialog is not implemented");
73+
},
74+
});
75+
const expense = computed<ExpenseWithTags | null>(() =>
76+
dialogsStore.expense ? (mainStore.expensesFlat.find((e) => e.id === dialogsStore.expense) ?? null) : null,
77+
);
78+
79+
const editExpenseForm = ref<InstanceType<typeof ExpenseForm>>();
80+
const editing = ref(false);
81+
82+
const submitting_editExpense = ref(false);
83+
const editExpense = async (inputs: ExpenseFormInputs) => {
84+
submitting_editExpense.value = true;
85+
try {
86+
await mainStore.updateExpense(expense.value!, inputs);
87+
editing.value = false;
88+
} catch (error) {
89+
console.error(error);
90+
}
91+
submitting_editExpense.value = false;
92+
};
93+
const deleteOnHide = ref<number | null>(null);
94+
const onHide = () => {
95+
if (deleteOnHide.value !== null) {
96+
const _id = deleteOnHide.value;
97+
deleteOnHide.value = null;
98+
const deletingToast: ToastMessageOptions = {
99+
severity: "info",
100+
summary: "Deleting...",
101+
};
102+
toast.add(deletingToast);
103+
mainStore
104+
.deleteExpense(_id)
105+
.then(() => {
106+
toast.add({ severity: "success", summary: "Deleted", life: 3000 });
107+
})
108+
.catch((error) => {
109+
console.error(error);
110+
toast.add({ severity: "error", summary: "Error", life: 3000 });
111+
})
112+
.finally(() => {
113+
toast.remove(deletingToast);
114+
});
115+
}
116+
};
117+
const deleteExpense = () => {
118+
deleteOnHide.value = expense.value!.id;
119+
visible.value = false;
120+
};
121+
</script>
122+
123+
<style scoped>
124+
@reference "tailwindcss";
125+
tr td:first-child {
126+
@apply font-semibold opacity-50;
127+
}
128+
</style>

0 commit comments

Comments
 (0)