Basic API consumption support and refactored UI to use material (#1)

Co-authored-by: Joe Arndt <jmarndt@users.noreply.github.com>
Reviewed-on: #1
This commit is contained in:
Joe 2026-02-23 21:16:31 +00:00
parent bd18fecdff
commit f2226b2bfd
34 changed files with 726 additions and 55 deletions

View file

@ -1,6 +1,5 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {

View file

@ -1,14 +1,14 @@
import { Routes } from '@angular/router';
import { Expenses } from './pages/expenses/expenses';
import { Home } from './pages/home/home';
import { ExpensePage } from './pages/expenses/expense-page.component';
import { HomePage } from './pages/home/home-page.component';
export const routes: Routes = [
{
path: '',
component: Home
component: HomePage
},
{
path: 'expenses',
component: Expenses
component: ExpensePage
}
];

View file

@ -1,3 +1,7 @@
.app-content {
padding: 0.5rem;
}
h1 {
font-size: 3.125rem;
line-height: 100%;

View file

@ -0,0 +1,6 @@
<div class="expense-list-container">
<h2>Expenses</h2>
@for (expense of expenses(); track expense.id) {
<app-expense [expense]="expense" />
}
</div>

View file

@ -0,0 +1,4 @@
.expense-list-container {
display: grid;
gap: 1rem;
}

View file

@ -0,0 +1,17 @@
import { Component, computed } from '@angular/core';
import { ExpenseService } from '../../services/expense.service';
import { MatTableModule } from '@angular/material/table';
import { MatCardModule } from '@angular/material/card';
import { ExpenseComponent } from '../expense/expense.component';
@Component({
selector: 'app-expense-list',
imports: [MatTableModule, MatCardModule, ExpenseComponent],
templateUrl: './expense-list.component.html',
styleUrl: './expense-list.component.scss'
})
export class ExpenseListComponent {
protected expenses = computed(() => this.expensesService.expenses())
public constructor(private readonly expensesService: ExpenseService) { }
}

View file

@ -1,3 +0,0 @@
<div class="expense-list-container">
<p>expense-list works!</p>
</div>

View file

@ -1,16 +0,0 @@
import { Component, OnInit } from '@angular/core';
import { Expenses } from '../../services/expenses';
@Component({
selector: 'app-expense-list',
imports: [],
templateUrl: './expense-list.html',
styleUrl: './expense-list.scss',
})
export class ExpenseList implements OnInit {
public constructor(private readonly expenses: Expenses) { }
public ngOnInit() {
void this.expenses.getExpenses();
}
}

View file

@ -0,0 +1,47 @@
<div class="expense-form-container">
<mat-form-field appearance="outline">
<mat-label>Date</mat-label>
<input matInput [matDatepicker]="expenseDatePicker" [formField]="form.date">
<mat-datepicker-toggle matIconSuffix [for]="expenseDatePicker"></mat-datepicker-toggle>
<mat-datepicker #expenseDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Cents</mat-label>
<input type="number" matInput [formField]="form.cents">
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Category</mat-label>
<input type="text" matInput [matAutocomplete]="categoryAuto" [formField]="form.category">
<mat-autocomplete [displayWith]="autocompleteDisplay" autoActiveFirstOption #categoryAuto="matAutocomplete">
@for (category of categories(); track category.id) {
<mat-option [value]="category">{{ category.name }}</mat-option>
}
</mat-autocomplete>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Merchant</mat-label>
<input type="text" matInput [matAutocomplete]="merchantAuto" [formField]="form.merchant">
<mat-autocomplete [displayWith]="autocompleteDisplay" autoActiveFirstOption #merchantAuto="matAutocomplete">
@for (merchant of merchants(); track merchant.id) {
<mat-option [value]="merchant">{{ merchant.name }}</mat-option>
}
</mat-autocomplete>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Note</mat-label>
<textarea rows="1" matInput [formField]="form.note"></textarea>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Tags</mat-label>
<mat-select disableRipple multiple [formField]="form.tags">
@for (tag of tags(); track tag.id) {
<mat-option [value]="tag">{{ tag.name }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>

View file

@ -0,0 +1,20 @@
.expense-form-container {
display: grid;
mat-form-field {
width: 100%;
}
}
@media (min-width: 550px) {
.expense-form-container {
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
}
@media (min-width: 800px) {
.expense-form-container {
grid-template-columns: 1fr 1fr 1fr;
}
}

View file

@ -0,0 +1,124 @@
import { Component, computed, effect, input, OnInit, output, signal } from '@angular/core';
import { min, required, disabled, form, FormField, Schema, schema } from '@angular/forms/signals';
import { Expense } from '../../../services/expense.service';
import { Category, CategoryService } from '../../../services/category.service';
import { Merchant, MerchantService } from '../../../services/merchant.service';
import { Tag, TagService } from '../../../services/tag.service';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { provideNativeDateAdapter } from '@angular/material/core';
export interface ExpenseForm {
date: Date;
cents: number;
category: Category;
merchant: Merchant;
note: string;
tags: Tag[];
}
@Component({
selector: 'app-expense-form',
imports: [MatAutocompleteModule, MatDatepickerModule, MatInputModule, MatSelectModule, FormField],
providers: [provideNativeDateAdapter()],
templateUrl: './expense-form.component.html',
styleUrl: './expense-form.component.scss'
})
export class ExpenseFormComponent implements OnInit {
public expense = input<Expense>();
public disabled = input<boolean>(false);
public valid = output<boolean>();
public dirty = output<boolean>()
public value = output<ExpenseForm>();
private lastDate = signal<Date | undefined>(undefined);
private formValid = computed(() => this.form().valid() && this.form().dirty());
private formDirty = computed(() => this.form().dirty() || this.form().touched());
private formData = signal(this.buildForm(this.expense()));
private formModel = signal<ExpenseForm>(this.formData());
protected categories = computed(() => this.categoryService.categories());
protected merchants = computed(() => this.merchantService.merchants());
protected tags = computed(() => this.tagService.tags());
protected form = form(this.formModel, this.buildFormOptions());
constructor(private readonly categoryService: CategoryService,
private readonly merchantService: MerchantService,
private readonly tagService: TagService) {
effect(() => {
const valid = this.formValid();
const dirty = this.formDirty();
const value = this.form().value();
this.valid.emit(valid);
this.dirty.emit(dirty);
this.value.emit(value);
this.lastDate.set(value.date);
});
}
public ngOnInit(): void {
this.initialize();
}
public reset(clearDate = false): void {
if (clearDate) {
this.lastDate.set(undefined);
}
this.initialize();
}
public refresh(expense: Expense): void {
this.initialize();
this.formData.set(this.buildForm(expense));
this.formModel.set(this.formData());
this.form().reset(this.formModel());
}
public autocompleteDisplay(value: Merchant | Category) {
return value?.name ?? null;
}
private initialize(): void {
this.formData.set(this.buildForm(this.expense()));
this.formModel.set(this.formData());
this.form().reset(this.formModel());
}
private buildForm(expense?: Expense): ExpenseForm {
let formData = { date: this.lastDate() ?? '', cents: NaN, category: {}, merchant: {}, note: '', tags: [] } as unknown as ExpenseForm
if (expense) {
const date = expense.date.toString().split('-');
const day = Number(date[2]);
const month = Number(date[1]) - 1;
const year = Number(date[0]);
formData = {
date: new Date(year, month, day),
cents: expense.cents,
category: expense.category,
merchant: expense.merchant ?? {},
note: expense.note ?? '',
tags: expense.tags ?? []
} as ExpenseForm;
}
return formData;
}
private buildFormOptions(): Schema<ExpenseForm> {
return schema<ExpenseForm>((schema) => {
required(schema.date);
required(schema.cents);
required(schema.category);
min(schema.cents, 1);
disabled(schema.date, () => this.disabled());
disabled(schema.cents, () => this.disabled());
disabled(schema.category, () => this.disabled());
disabled(schema.merchant, () => this.disabled());
disabled(schema.note, () => this.disabled());
disabled(schema.tags, () => this.disabled());
});
}
}

View file

@ -0,0 +1,30 @@
<div class="expense-container">
<mat-card appearance="outlined">
<mat-card-header>
<div class="expense-header">
@if (!expense()) {
<mat-card-title>Add new Expense</mat-card-title>
<button matIconButton [disabled]="!formDirty() || savingExpense()" (click)="resetAddClick()"><mat-icon>replay</mat-icon></button>
<button matIconButton [disabled]="!formValid() || savingExpense()" (click)="addClick()"><mat-icon>add</mat-icon></button>
} @else {
@if (!editingExpense()) {
<mat-card-title>{{ expense()?.date?.toString() | date }}: {{ expense()?.cents! / 100 | currency }}</mat-card-title>
<button matIconButton (click)="editClick()"><mat-icon>edit</mat-icon></button>
} @else {
<mat-card-title>Update Expense</mat-card-title>
<button matIconButton [disabled]="savingExpense()" (click)="cancelUpdateClick()"><mat-icon>undo</mat-icon></button>
<button matIconButton [disabled]="!formValid() || savingExpense()" (click)="updateClick()"><mat-icon>save</mat-icon></button>
}
}
</div>
</mat-card-header>
<mat-card-content>
<app-expense-form [expense]="expense()"
[disabled]="savingExpense() || !!expense() && !editingExpense()"
(valid)="formValid.set($event)"
(dirty)="formDirty.set($event)"
(value)="formData.set($event)" />
</mat-card-content>
</mat-card>
</div>

View file

@ -0,0 +1,22 @@
.expense-container {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
mat-card {
max-width: 800px;
width: 100%;
}
}
mat-card-header {
padding-bottom: 1rem;
.expense-header {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
}
}

View file

@ -0,0 +1,109 @@
import { Component, model, signal, viewChild } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { MatButtonModule } from '@angular/material/button';
import { CreateExpense, Expense, ExpenseService, UpdateExpense } from '../../services/expense.service';
import { MatIcon } from '@angular/material/icon';
import { ExpenseForm, ExpenseFormComponent } from './expense-form/expense-form.component';
import { CurrencyPipe, DatePipe } from '@angular/common';
import { Temporal } from '@js-temporal/polyfill';
import { SnackBarService } from '../../services/snack-bar';
@Component({
selector: 'app-expense',
imports: [MatCardModule, MatButtonModule, MatIcon, ExpenseFormComponent, DatePipe, CurrencyPipe],
providers: [],
templateUrl: './expense.component.html',
styleUrl: './expense.component.scss'
})
export class ExpenseComponent {
private form = viewChild(ExpenseFormComponent);
public editingExpense = signal(false);
public savingExpense = signal(false);
public expense = model<Expense>();
public formValid = model(false);
public formDirty = model(false);
public formData = model<ExpenseForm>();
public constructor(private readonly expenseService: ExpenseService,
private readonly snackBar: SnackBarService) { }
public editClick(): void {
this.editingExpense.set(true);
}
public async addClick(): Promise<void> {
const form = this.formData()!;
const postExpense: CreateExpense = {
date: this.dateToPlainDate(form.date),
cents: form.cents,
categoryId: form.category.id,
note: !!form.note ? form.note : undefined,
merchantId: !!form.merchant ? form.merchant.id : undefined,
tagIds: form.tags.map(tag => tag.id)
};
this.savingExpense.set(true);
const snackId = this.snackBar.staticBar('Tracking new expense...');
try {
await this.expenseService.postExpense(postExpense);
this.resetForm();
this.snackBar.dismiss(snackId);
this.snackBar.autoBar('Expense tracked!');
}
catch (error) {
this.snackBar.dismiss(snackId);
this.snackBar.dismissableBar('Error: '); // TODO
}
finally {
this.savingExpense.set(false);
}
}
public resetAddClick(): void {
this.resetForm(true);
}
public async updateClick(): Promise<void> {
const form = this.formData()!;
const putExpense: UpdateExpense = {
id: this.expense()!.id,
date: this.dateToPlainDate(form.date),
cents: form.cents,
categoryId: form.category.id,
note: !!form.note ? form.note : undefined,
merchantId: !!form.merchant ? form.merchant.id : undefined,
tagIds: form.tags.map(tag => tag.id)
};
this.savingExpense.set(true);
const snackId = this.snackBar.staticBar('Updating expense...');
try {
const expense = await this.expenseService.updateExpense(putExpense);
this.expense.set(expense);
this.form()?.refresh(expense);
this.editingExpense.set(false);
this.snackBar.dismiss(snackId);
this.snackBar.autoBar('Expense updated!');
}
catch (error) {
this.snackBar.dismiss(snackId);
this.snackBar.dismissableBar('Error: '); // TODO
}
finally {
this.savingExpense.set(false);
}
}
public cancelUpdateClick(): void {
this.resetForm();
}
private resetForm(clearDate = false): void {
this.form()?.reset(clearDate);
this.editingExpense.set(false);
}
private dateToPlainDate(date: Date): Temporal.PlainDate {
return new Temporal.PlainDate(date.getFullYear(), date.getMonth() + 1, date.getDate());
}
}

View file

@ -1,3 +1,4 @@
<div class="expenses-container">
<app-expense />
<app-expense-list />
</div>

View file

@ -0,0 +1,5 @@
.expenses-container {
display: flex;
flex-direction: column;
gap: 1rem;
}

View file

@ -0,0 +1,14 @@
import { Component } from '@angular/core';
import { ExpenseListComponent } from '../../components/expense-list/expense-list.component';
import { ExpenseComponent } from '../../components/expense/expense.component';
@Component({
selector: 'app-expenses',
imports: [
ExpenseListComponent,
ExpenseComponent
],
templateUrl: './expense-page.component.html',
styleUrl: './expense-page.component.scss'
})
export class ExpensePage { }

View file

@ -1,12 +0,0 @@
import { Component } from '@angular/core';
import { ExpenseList } from '../../components/expense-list/expense-list';
@Component({
selector: 'app-expenses',
imports: [
ExpenseList
],
templateUrl: './expenses.html',
styleUrl: './expenses.scss'
})
export class Expenses { }

View file

@ -1,7 +1,7 @@
<div class="home-container">
<ul>
<li>
<a routerLink="expenses">Expenses</a>
<a routerLink="expenses">ExpenseService</a>
</li>
</ul>
</div>

View file

@ -6,7 +6,7 @@ import { RouterLink } from '@angular/router';
imports: [
RouterLink
],
templateUrl: './home.html',
styleUrl: './home.scss',
templateUrl: './home-page.component.html',
styleUrl: './home-page.component.scss',
})
export class Home { }
export class HomePage { }

View file

@ -0,0 +1,24 @@
import { Injectable, signal } from '@angular/core';
import { HttpService } from './http.service';
@Injectable({
providedIn: 'root'
})
export class CategoryService {
private internalCategories = signal<Category[]>([]);
public readonly categories = this.internalCategories.asReadonly();
public readonly categoryPath = 'http://localhost:3000/common-cents/categories';
public constructor(private readonly http: HttpService) {
void this.fetchCategories();
}
public async fetchCategories(): Promise<void> {
this.internalCategories.set(await this.http.get<Category[]>(this.categoryPath));
}
}
export interface Category {
id: string;
name: string;
}

View file

@ -0,0 +1,57 @@
import { Injectable, signal } from '@angular/core';
import { Category } from './category.service';
import { Merchant } from './merchant.service';
import { Tag } from './tag.service';
import { HttpService } from './http.service';
import { Temporal } from '@js-temporal/polyfill';
@Injectable({
providedIn: 'root'
})
export class ExpenseService {
private internalExpenses = signal<Expense[]>([]);
public readonly expenses = this.internalExpenses.asReadonly();
public readonly expensePath = 'http://localhost:3000/common-cents/expenses'; // TODO: refactor
public constructor(private readonly http: HttpService) {
void this.fetchExpenses();
}
public async fetchExpenses(): Promise<void> {
this.internalExpenses.set(await this.http.get(this.expensePath));
}
public async postExpense(createExpense: CreateExpense): Promise<Expense> {
const createdExpense = await this.http.post<Expense>(this.expensePath, createExpense);
await this.fetchExpenses();
return createdExpense;
}
public async updateExpense(updateExpense: UpdateExpense): Promise<Expense> {
return await this.http.put<Expense>(this.expensePath, updateExpense);
}
}
export interface Expense {
id: string;
date: Date;
cents: number;
category: Category;
note?: string;
merchant?: Merchant;
tags: Tag[];
}
export interface CreateExpense {
date: Temporal.PlainDate;
cents: number;
categoryId: string;
note?: string;
merchantId?: string;
tagIds?: string[];
}
export interface UpdateExpense extends CreateExpense {
id: string;
}

View file

@ -1,12 +0,0 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class Expenses {
public static readonly BASE_URL = 'http://localhost:3000/common-cents/expenses';
public async getExpenses(): Promise<void> {
console.log('getExpenses called');
}
}

View file

@ -0,0 +1,40 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class HttpService {
public constructor(private httpClient: HttpClient) { }
public async get<T>(url: string): Promise<T> {
return this.request<T>(url, 'get');
}
public async post<T>(url: string, body?: any): Promise<T> {
return this.request<T>(url, 'post', body);
}
public async put<T>(url: string, body?: any): Promise<T> {
return this.request<T>(url, 'put', body);
}
public async delete<T>(url: string): Promise<T> {
return this.request<T>(url, 'delete');
}
private async request<T>(url: string, method: 'get' | 'post' | 'put' | 'delete', body?: any): Promise<T> {
const headers = { 'Accept': 'application/json', 'Content-Type': 'application/json' };
switch (method) {
case 'post':
return firstValueFrom(this.httpClient.post<T>(url, body, { headers }));
case 'put':
return firstValueFrom(this.httpClient.put<T>(url, body, { headers }));
case 'delete':
return firstValueFrom(this.httpClient.delete<T>(url, { headers }));
default:
return firstValueFrom(this.httpClient.get<T>(url, { headers }));
}
}
}

View file

@ -0,0 +1,24 @@
import { Injectable, signal } from '@angular/core';
import { HttpService } from './http.service';
@Injectable({
providedIn: 'root'
})
export class MerchantService {
private internalMerchants = signal<Merchant[]>([]);
public readonly merchants = this.internalMerchants.asReadonly();
public readonly merchantPath = 'http://localhost:3000/common-cents/merchants';
public constructor(private readonly http: HttpService) {
void this.fetchMerchants();
}
public async fetchMerchants(): Promise<void> {
this.internalMerchants.set(await this.http.get<Merchant[]>(this.merchantPath));
}
}
export interface Merchant {
id: string;
name: string;
}

View file

@ -0,0 +1,44 @@
import { Injectable, signal } from '@angular/core';
import { MatSnackBar, MatSnackBarRef, TextOnlySnackBar } from '@angular/material/snack-bar';
interface SnackBar {
id: string;
ref: MatSnackBarRef<TextOnlySnackBar>;
}
@Injectable({
providedIn: 'root'
})
export class SnackBarService {
protected TIMEOUT = 3000;
private snackBars = signal<SnackBar[]>([]);
public constructor(private snackBar: MatSnackBar) { }
public autoBar(msg: string, milliseconds = this.TIMEOUT): void {
const timeout = milliseconds ?? this.TIMEOUT;
this.snackBar.open(msg, undefined, { duration: timeout });
}
public dismissableBar(msg: string, dismissMsg = 'Dismiss'): void {
this.snackBar.open(msg, dismissMsg);
}
public staticBar(msg: string): string {
const snackBar: SnackBar = {
id: crypto.randomUUID().toString(),
ref: this.snackBar.open(msg)
};
this.snackBars.update((bars) => [...bars, snackBar]);
return snackBar.id;
}
public dismiss(id: string): void {
const snackBar = this.snackBars().find((snackBar) => snackBar.id === id);
if (snackBar) {
snackBar.ref.dismiss();
this.snackBars.update((bars) => bars.filter((bar) => bar.id !== snackBar.id));
}
}
}

View file

@ -0,0 +1,24 @@
import { Injectable, signal } from '@angular/core';
import { HttpService } from './http.service';
@Injectable({
providedIn: 'root'
})
export class TagService {
private internalTags = signal<Tag[]>([]);
public readonly tags = this.internalTags.asReadonly();
public readonly tagPath = 'http://localhost:3000/common-cents/tags';
public constructor(private readonly http: HttpService) {
void this.fetchTags();
}
public async fetchTags(): Promise<void> {
this.internalTags.set(await this.http.get<Tag[]>(this.tagPath));
}
}
export interface Tag {
id: string;
name: string;
}

View file

@ -6,6 +6,10 @@
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body>
<app-root></app-root>

View file

@ -1 +1,38 @@
// Include theming for Angular Material with `mat.theme()`.
// This Sass mixin will define CSS variables that are used for styling Angular Material
// components according to the Material 3 design spec.
// Learn more about theming and how to use it for your application's
// custom components at https://material.angular.dev/guide/theming
@use '@angular/material' as mat;
html {
height: 100%;
@include mat.theme((
color: (
primary: mat.$cyan-palette,
tertiary: mat.$orange-palette,
),
typography: Roboto,
density: 0
));
}
body {
// Default the application to a light color theme. This can be changed to
// `dark` to enable the dark color theme, or to `light dark` to defer to the
// user's system settings.
color-scheme: light dark;
// Set a default background, font and text colors for the application using
// Angular Material's system-level CSS variables. Learn more about these
// variables at https://material.angular.dev/guide/system-variables
background-color: var(--mat-sys-surface);
color: var(--mat-sys-on-surface);
font: var(--mat-sys-body-medium);
// Reset the user agent margin.
margin: 0;
height: 100%;
}
/* You can add global styles to this file, and also import other style files */