Compare commits

...

41 commits
server ... main

Author SHA1 Message Date
c3bded9f24 Update README.md 2024-04-07 10:38:14 -06:00
dependabot[bot]
b2c86452df Bump webpack-dev-middleware from 5.3.3 to 5.3.4
Bumps [webpack-dev-middleware](https://github.com/webpack/webpack-dev-middleware) from 5.3.3 to 5.3.4.
- [Release notes](https://github.com/webpack/webpack-dev-middleware/releases)
- [Changelog](https://github.com/webpack/webpack-dev-middleware/blob/v5.3.4/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack-dev-middleware/compare/v5.3.3...v5.3.4)

---
updated-dependencies:
- dependency-name: webpack-dev-middleware
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-07 10:36:36 -06:00
dependabot[bot]
1f6ab39382 Bump express from 4.18.3 to 4.19.2
Bumps [express](https://github.com/expressjs/express) from 4.18.3 to 4.19.2.
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/master/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.18.3...4.19.2)

---
updated-dependencies:
- dependency-name: express
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-29 08:47:41 -06:00
dependabot[bot]
c105e54631 Bump follow-redirects from 1.15.1 to 1.15.6
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.1 to 1.15.6.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.1...v1.15.6)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-27 22:28:56 -06:00
9498496ba8 Update to Angular Material 17 2024-03-03 10:46:16 -07:00
906c943ac8 Update to Angular Material 16 2024-03-03 10:46:16 -07:00
0442b6b3d6 Update to Angular 17 2024-03-03 10:42:59 -07:00
d0150def51 Bump to Angular 16 2024-03-03 10:42:59 -07:00
a38fafb451 Update to Angular Material 15 2024-03-03 10:42:59 -07:00
262da7ef92 Bump Angular to v15 2024-03-03 10:42:59 -07:00
674e330290 Fix GH Pages deploy workflow 2024-03-03 10:08:37 -07:00
86bc2f7c2e Add GH Pages workflow 2024-03-03 10:06:43 -07:00
dependabot[bot]
40ee188762
Bump word-wrap from 1.2.3 to 1.2.4 (#16)
Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.4.
- [Release notes](https://github.com/jonschlinkert/word-wrap/releases)
- [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.4)

---
updated-dependencies:
- dependency-name: word-wrap
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-11 12:13:53 -06:00
23e0df804e Fix enter handling on category form 2023-06-05 17:06:16 +00:00
3957651211 Fix amount handling in categories and transactions 2023-06-05 17:04:14 +00:00
dependabot[bot]
529a420c14
Bump yaml from 2.1.1 to 2.2.2 (#14)
Bumps [yaml](https://github.com/eemeli/yaml) from 2.1.1 to 2.2.2.
- [Release notes](https://github.com/eemeli/yaml/releases)
- [Commits](https://github.com/eemeli/yaml/compare/v2.1.1...v2.2.2)

---
updated-dependencies:
- dependency-name: yaml
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-05-23 22:29:42 -06:00
dependabot[bot]
2d5e1f8567
Bump socket.io-parser and socket.io (#15)
Bumps [socket.io-parser](https://github.com/socketio/socket.io-parser) and [socket.io](https://github.com/socketio/socket.io). These dependencies needed to be updated together.

Updates `socket.io-parser` from 4.0.5 to 4.2.3
- [Release notes](https://github.com/socketio/socket.io-parser/releases)
- [Changelog](https://github.com/socketio/socket.io-parser/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io-parser/compare/4.0.5...4.2.3)

Updates `socket.io` from 4.5.1 to 4.6.1
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/4.5.1...4.6.1)

---
updated-dependencies:
- dependency-name: socket.io-parser
  dependency-type: indirect
- dependency-name: socket.io
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-05-23 22:29:10 -06:00
b6b116863c Specify type for variable in transaction-list-component 2023-02-18 22:26:06 -07:00
d89f615fa0 Remove unused field on twigs.http.service 2023-02-18 22:26:06 -07:00
90e3f0c02b Simplify app bar links 2023-02-18 22:26:06 -07:00
d6fbe06cab Add recurring transaction methods to api service 2023-02-18 22:26:06 -07:00
484e0c8c75 Add models for recurring transactions 2023-02-18 22:26:06 -07:00
dependabot[bot]
a858fca6da
Bump cacheable-request and got (#13)
Bumps [cacheable-request](https://github.com/jaredwray/cacheable-request) and [got](https://github.com/sindresorhus/got). These dependencies needed to be updated together.

Updates `cacheable-request` from 7.0.2 to 10.2.7
- [Release notes](https://github.com/jaredwray/cacheable-request/releases)
- [Commits](https://github.com/jaredwray/cacheable-request/commits)

Updates `got` from 12.3.1 to 12.5.3
- [Release notes](https://github.com/sindresorhus/got/releases)
- [Commits](https://github.com/sindresorhus/got/compare/v12.3.1...v12.5.3)

---
updated-dependencies:
- dependency-name: cacheable-request
  dependency-type: indirect
- dependency-name: got
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-13 08:50:14 -07:00
dependabot[bot]
5654e830a9
Bump http-cache-semantics from 4.1.0 to 4.1.1 (#12)
Bumps [http-cache-semantics](https://github.com/kornelski/http-cache-semantics) from 4.1.0 to 4.1.1.
- [Release notes](https://github.com/kornelski/http-cache-semantics/releases)
- [Commits](https://github.com/kornelski/http-cache-semantics/compare/v4.1.0...v4.1.1)

---
updated-dependencies:
- dependency-name: http-cache-semantics
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-09 09:28:20 -07:00
dependabot[bot]
67bd92cf9b
Bump ua-parser-js from 0.7.31 to 0.7.33 (#11)
Bumps [ua-parser-js](https://github.com/faisalman/ua-parser-js) from 0.7.31 to 0.7.33.
- [Release notes](https://github.com/faisalman/ua-parser-js/releases)
- [Changelog](https://github.com/faisalman/ua-parser-js/blob/master/changelog.md)
- [Commits](https://github.com/faisalman/ua-parser-js/compare/0.7.31...0.7.33)

---
updated-dependencies:
- dependency-name: ua-parser-js
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-27 08:53:32 -07:00
dependabot[bot]
534db45389
Bump json5 from 2.2.1 to 2.2.3 (#10)
Bumps [json5](https://github.com/json5/json5) from 2.2.1 to 2.2.3.
- [Release notes](https://github.com/json5/json5/releases)
- [Changelog](https://github.com/json5/json5/blob/main/CHANGELOG.md)
- [Commits](https://github.com/json5/json5/compare/v2.2.1...v2.2.3)

---
updated-dependencies:
- dependency-name: json5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-09 09:54:35 -07:00
dependabot[bot]
6d66243ee1 Bump decode-uri-component from 0.2.0 to 0.2.2
Bumps [decode-uri-component](https://github.com/SamVerschueren/decode-uri-component) from 0.2.0 to 0.2.2.
- [Release notes](https://github.com/SamVerschueren/decode-uri-component/releases)
- [Commits](https://github.com/SamVerschueren/decode-uri-component/compare/v0.2.0...v0.2.2)

---
updated-dependencies:
- dependency-name: decode-uri-component
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-12-08 09:01:28 -07:00
dependabot[bot]
c6e6b7904f Bump engine.io from 6.2.0 to 6.2.1
Bumps [engine.io](https://github.com/socketio/engine.io) from 6.2.0 to 6.2.1.
- [Release notes](https://github.com/socketio/engine.io/releases)
- [Changelog](https://github.com/socketio/engine.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/engine.io/compare/6.2.0...6.2.1)

---
updated-dependencies:
- dependency-name: engine.io
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-22 10:30:36 -07:00
dependabot[bot]
88022b1074 Bump loader-utils from 2.0.3 to 2.0.4
Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.3 to 2.0.4.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.4/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v2.0.3...v2.0.4)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-16 08:54:51 -07:00
6cc063f776 Migrate transactions backend calls to promise api 2022-11-10 21:01:12 -07:00
ec47fc130d Migrate categories backend calls to promise api 2022-11-09 22:15:52 -07:00
87092be0f9
Merge pull request #6 from wbrawner/dependabot/npm_and_yarn/loader-utils-2.0.3
Bump loader-utils from 2.0.2 to 2.0.3
2022-11-09 08:08:34 -07:00
dependabot[bot]
ba26a378e3
Bump loader-utils from 2.0.2 to 2.0.3
Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.2 to 2.0.3.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.3/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v2.0.2...v2.0.3)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-09 10:21:52 +00:00
b3f24049ea Fix budget balance request URL 2022-11-08 20:54:02 -07:00
3981e575f2 Improve autocapitalization of form inputs for budgets, transactions, and categories 2022-11-08 20:36:05 -07:00
170214c1ca Migrate budgets backend calls to promise api 2022-11-09 03:16:23 +00:00
e11ffb741f Use promises api for user-related backend calls 2022-11-09 02:04:43 +00:00
447c1894d9 Use promises for register and logout 2022-11-09 01:48:30 +00:00
7fa6f2a1b9 Use fetch API for login and getProfile calls 2022-11-08 04:36:00 +00:00
a7ad95eff8 Add devcontainer 2022-11-08 04:34:21 +00:00
9e30452744 Update dependencies 2022-08-07 03:26:16 +00:00
36 changed files with 12343 additions and 8756 deletions

12
.devcontainer/Dockerfile Normal file
View file

@ -0,0 +1,12 @@
FROM mcr.microsoft.com/devcontainers/javascript-node:0-16-bullseye
# [Optional] Uncomment this section to install additional OS packages.
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
# && apt-get -y install --no-install-recommends <your-package-list-here>
# [Optional] Uncomment if you want to install an additional version of node using nvm
# ARG EXTRA_NODE_VERSION=10
# RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}"
# [Optional] Uncomment if you want to install more global node modules
# RUN su node -c "npm install -g <your-package-list-here>"

View file

@ -0,0 +1,23 @@
// Update the VARIANT arg in docker-compose.yml to pick a Node.js version
{
"name": "Twigs Web",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
// Features to add to the dev container. More info: https://containers.dev/implementors/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// This can be used to network with other containers or with the host.
"forwardPorts": [4200, "backend:8080"]
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "yarn install",
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

View file

@ -0,0 +1,46 @@
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
volumes:
- ../..:/workspaces:cached
# Overrides default command so things don't shut down after the process ends.
command: sleep infinity
# Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function.
network_mode: service:db
# Uncomment the next line to use a non-root user for all processes.
user: node
# Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
# (Adding the "ports" property to this file will not forward from a Codespace.)
backend:
image: ghcr.io/wbrawner/twigs-server:main
restart: unless-stopped
environment:
TWIGS_DB_HOST: db
TWIGS_DB_NAME: postgres
TWIGS_DB_USER: postgres
TWIGS_DB_PASS: postgres
db:
image: postgres:latest
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: postgres
# Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally.
# (Adding the "ports" property to this file will not forward from a Codespace.)
volumes:
postgres-data:

54
.github/workflows/gh-pages.yml vendored Normal file
View file

@ -0,0 +1,54 @@
name: Deploy to GitHub Pages
on:
push:
branches: ["main"]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
# Build job
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- name: Install dependencies with npm
run: npm ci
- name: Build with NPM
run: npm run package
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: dist/twigs
# Deployment job
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View file

@ -1,5 +1,7 @@
# Twigs Web Client # Twigs Web Client
# IMPORTANT: This repository is no longer maintained. The web version of Twigs has been replaced with a server-rendered implementation in the [backend repository](https://github.com/wbrawner/twigs)
Twigs is an open source budgeting app aimed at people who need to share a budget. This project serves as the web front end, and is powered by Angular. The main back end project can be found at [wbrawner/twigs-server](https://github.com/wbrawner/twigs-server) Twigs is an open source budgeting app aimed at people who need to share a budget. This project serves as the web front end, and is powered by Angular. The main back end project can be found at [wbrawner/twigs-server](https://github.com/wbrawner/twigs-server)
## Building ## Building

View file

@ -82,21 +82,21 @@
"serve": { "serve": {
"builder": "@angular-devkit/build-angular:dev-server", "builder": "@angular-devkit/build-angular:dev-server",
"options": { "options": {
"browserTarget": "twigs:build" "buildTarget": "twigs:build"
}, },
"configurations": { "configurations": {
"production": { "production": {
"browserTarget": "twigs:build:production" "buildTarget": "twigs:build:production"
}, },
"codeserver": { "codeserver": {
"browserTarget": "twigs:build:codeserver" "buildTarget": "twigs:build:codeserver"
} }
} }
}, },
"extract-i18n": { "extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n", "builder": "@angular-devkit/build-angular:extract-i18n",
"options": { "options": {
"browserTarget": "twigs:build" "buildTarget": "twigs:build"
} }
}, },
"test": { "test": {

19430
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@
"version": "0.0.0", "version": "0.0.0",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve --host '0.0.0.0'", "start": "ng serve --configuration=production --host '0.0.0.0'",
"code-server": "ng serve --configuration=codeserver --host \"0.0.0.0\" --disable-host-check --poll=2000", "code-server": "ng serve --configuration=codeserver --host \"0.0.0.0\" --disable-host-check --poll=2000",
"build": "ng build", "build": "ng build",
"package": "ng build --configuration=production --service-worker", "package": "ng build --configuration=production --service-worker",
@ -15,29 +15,29 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "^14.0.4", "@angular/animations": "^17.2.3",
"@angular/cdk": "^14.0.4", "@angular/cdk": "^17.2.1",
"@angular/common": "^14.0.4", "@angular/common": "^17.2.3",
"@angular/compiler": "^14.0.4", "@angular/compiler": "^17.2.3",
"@angular/core": "^14.0.4", "@angular/core": "^17.2.3",
"@angular/forms": "^14.0.4", "@angular/forms": "^17.2.3",
"@angular/material": "^14.0.4", "@angular/material": "^16.2.0",
"@angular/platform-browser": "^14.0.4", "@angular/platform-browser": "^17.2.3",
"@angular/platform-browser-dynamic": "^14.0.4", "@angular/platform-browser-dynamic": "^17.2.3",
"@angular/router": "^14.0.4", "@angular/router": "^17.2.3",
"@angular/service-worker": "^14.0.4", "@angular/service-worker": "^17.2.3",
"chart.js": "^3.7.0", "chart.js": "^3.7.0",
"core-js": "^3.20.3", "core-js": "^3.20.3",
"ng2-charts": "^3.0.8", "ng2-charts": "^3.0.8",
"rxjs": "^7.5.2", "rxjs": "^7.5.2",
"tslib": "^2.3.1", "tslib": "^2.3.1",
"zone.js": "~0.11.4" "zone.js": "~0.14.4"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "^14.0.4", "@angular-devkit/build-angular": "^17.2.2",
"@angular/cli": "^14.0.4", "@angular/cli": "^17.2.2",
"@angular/compiler-cli": "^14.0.4", "@angular/compiler-cli": "^17.2.3",
"@angular/language-service": "^14.0.4", "@angular/language-service": "^17.2.3",
"@types/jasmine": "~3.10.3", "@types/jasmine": "~3.10.3",
"@types/jasminewd2": "^2.0.10", "@types/jasminewd2": "^2.0.10",
"@types/node": "^17.0.10", "@types/node": "^17.0.10",
@ -53,6 +53,6 @@
"protractor": "^7.0.0", "protractor": "^7.0.0",
"ts-node": "~10.4.0", "ts-node": "~10.4.0",
"tslint": "^6.1.3", "tslint": "^6.1.3",
"typescript": "4.7.4" "typescript": "5.3.3"
} }
} }

View file

@ -33,7 +33,7 @@ const routes: Routes = [
@NgModule({ @NgModule({
imports: [ imports: [
RouterModule.forRoot(routes, { relativeLinkResolution: 'legacy' }) RouterModule.forRoot(routes, {})
], ],
exports: [ exports: [
RouterModule RouterModule

View file

@ -3,12 +3,14 @@
</p> </p>
<mat-sidenav-container *ngIf="online" class="sidenav-container"> <mat-sidenav-container *ngIf="online" class="sidenav-container">
<mat-sidenav #sidenav mode="over" closed> <mat-sidenav #sidenav mode="over" closed>
<mat-nav-list (click)="sidenav.close()"> <mat-nav-list (click)="sidenav.close()" *ngIf="loggedIn">
<a mat-list-item *ngIf="loggedIn" routerLink="">{{ getUsername() }}</a> <a mat-list-item routerLink="">{{ getUsername() }}</a>
<a mat-list-item *ngIf="loggedIn" routerLink="/budgets">Budgets</a> <a mat-list-item routerLink="/budgets">Budgets</a>
<a mat-list-item *ngIf="!loggedIn" routerLink="/login">Login</a> <a mat-list-item (click)="logout()">Logout</a>
<a mat-list-item *ngIf="!loggedIn" routerLink="/register">Register</a> </mat-nav-list>
<a mat-list-item *ngIf="loggedIn" (click)="logout()">Logout</a> <mat-nav-list (click)="sidenav.close()" *ngIf="!loggedIn">
<a mat-list-item routerLink="/login">Login</a>
<a mat-list-item routerLink="/register">Register</a>
</mat-nav-list> </mat-nav-list>
</mat-sidenav> </mat-sidenav>
<mat-sidenav-content> <mat-sidenav-content>

View file

@ -47,7 +47,7 @@ export class AppComponent implements OnInit {
if (savedUser) { if (savedUser) {
this.user.next(savedUser); this.user.next(savedUser);
} }
this.twigsService.getProfile(userId).subscribe(fetchedUser => { this.twigsService.getProfile(userId).then(fetchedUser => {
this.storage.setItem('user', JSON.stringify(fetchedUser)); this.storage.setItem('user', JSON.stringify(fetchedUser));
this.user.next(fetchedUser); this.user.next(fetchedUser);
if (unauthenticatedRoutes.indexOf(this.location.path()) != -1) { if (unauthenticatedRoutes.indexOf(this.location.path()) != -1) {
@ -59,21 +59,14 @@ export class AppComponent implements OnInit {
this.router.navigateByUrl(`/login?redirect=${this.location.path()}`); this.router.navigateByUrl(`/login?redirect=${this.location.path()}`);
} }
this.updates.available.subscribe( this.updates.versionUpdates.subscribe(
event => { event => {
console.log('current version is', event.current); if (event.type == "VERSION_READY") {
console.log('available version is', event.available); console.log('current version is', event.currentVersion);
console.log('available version is', event.latestVersion);
// TODO: Prompt user to click something to update // TODO: Prompt user to click something to update
this.updates.activateUpdate(); this.updates.activateUpdate();
},
err => {
} }
);
this.updates.activated.subscribe(
event => {
console.log('old version was', event.previous);
console.log('new version is', event.current);
}, },
err => { err => {
@ -107,7 +100,7 @@ export class AppComponent implements OnInit {
} }
logout(): void { logout(): void {
this.twigsService.logout().subscribe(_ => { this.twigsService.logout().then(_ => {
this.location.go('/'); this.location.go('/');
window.location.reload(); window.location.reload();
}); });

View file

@ -2,20 +2,20 @@ import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MatButtonModule } from '@angular/material/button'; import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatLegacyFormFieldModule as MatFormFieldModule } from '@angular/material/legacy-form-field';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatLegacyInputModule as MatInputModule } from '@angular/material/legacy-input';
import { MatListModule } from '@angular/material/list'; import { MatLegacyListModule as MatListModule } from '@angular/material/legacy-list';
import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatLegacyProgressBarModule as MatProgressBarModule } from '@angular/material/legacy-progress-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatLegacyProgressSpinnerModule as MatProgressSpinnerModule } from '@angular/material/legacy-progress-spinner';
import { MatRadioModule } from '@angular/material/radio'; import { MatLegacyRadioModule as MatRadioModule } from '@angular/material/legacy-radio';
import { MatSelectModule } from '@angular/material/select'; import { MatLegacySelectModule as MatSelectModule } from '@angular/material/legacy-select';
import { MatSidenavModule } from '@angular/material/sidenav'; import { MatSidenavModule } from '@angular/material/sidenav';
import { MatToolbarModule } from '@angular/material/toolbar'; import { MatToolbarModule } from '@angular/material/toolbar';
import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatLegacyCheckboxModule as MatCheckboxModule } from '@angular/material/legacy-checkbox';
import { MatCardModule } from '@angular/material/card'; import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card';
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';
import { TransactionsComponent } from './transactions/transactions.component'; import { TransactionsComponent } from './transactions/transactions.component';

View file

@ -4,10 +4,10 @@
</div> </div>
<div *ngIf="!isLoading && budget" class="form budget-form"> <div *ngIf="!isLoading && budget" class="form budget-form">
<mat-form-field> <mat-form-field>
<input matInput [(ngModel)]="budget.name" placeholder="Name" required> <input matInput [(ngModel)]="budget.name" placeholder="Name" required autocapitalize="words">
</mat-form-field> </mat-form-field>
<mat-form-field> <mat-form-field>
<textarea matInput [(ngModel)]="budget.description" placeholder="Description"></textarea> <textarea matInput [(ngModel)]="budget.description" placeholder="Description" autocapitalize="sentences"></textarea>
</mat-form-field> </mat-form-field>
<button mat-raised-button color="accent" (click)="save()">Save</button> <button mat-raised-button color="accent" (click)="save()">Save</button>
<button class="button-delete" mat-raised-button color="warn" *ngIf="!create" (click)="delete()">Delete</button> <button class="button-delete" mat-raised-button color="warn" *ngIf="!create" (click)="delete()">Delete</button>

View file

@ -3,6 +3,7 @@ import { Budget } from '../budget';
import { AppComponent } from 'src/app/app.component'; import { AppComponent } from 'src/app/app.component';
import { User, UserPermission, Permission } from 'src/app/users/user'; import { User, UserPermission, Permission } from 'src/app/users/user';
import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service'; import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service';
import { Router } from '@angular/router';
@Component({ @Component({
selector: 'app-add-edit-budget', selector: 'app-add-edit-budget',
@ -19,6 +20,7 @@ export class AddEditBudgetComponent {
constructor( constructor(
private app: AppComponent, private app: AppComponent,
private router: Router,
@Inject(TWIGS_SERVICE) private twigsService: TwigsService, @Inject(TWIGS_SERVICE) private twigsService: TwigsService,
) { ) {
this.app.setTitle(this.title) this.app.setTitle(this.title)
@ -27,11 +29,11 @@ export class AddEditBudgetComponent {
} }
save(): void { save(): void {
let observable; let promise: Promise<Budget>;
this.isLoading = true; this.isLoading = true;
if (this.create) { if (this.create) {
// This is a new budget, save it // This is a new budget, save it
observable = this.twigsService.createBudget( promise = this.twigsService.createBudget(
this.budget.id, this.budget.id,
this.budget.name, this.budget.name,
this.budget.description, this.budget.description,
@ -39,10 +41,10 @@ export class AddEditBudgetComponent {
); );
} else { } else {
// This is an existing budget, update it // This is an existing budget, update it
observable = this.twigsService.updateBudget(this.budget.id, this.budget); promise = this.twigsService.updateBudget(this.budget.id, this.budget);
} }
// TODO: Check if it was actually successful or not // TODO: Check if it was actually successful or not
observable.subscribe(val => { promise.then(_ => {
this.app.goBack(); this.app.goBack();
}); });
} }
@ -50,14 +52,14 @@ export class AddEditBudgetComponent {
delete(): void { delete(): void {
this.isLoading = true; this.isLoading = true;
this.twigsService.deleteBudget(this.budget.id) this.twigsService.deleteBudget(this.budget.id)
.subscribe(() => { .then(() => {
this.app.goBack(); this.router.navigateByUrl("/budgets");
}); });
} }
// TODO: Implement a search box with suggestions to add users // TODO: Implement a search box with suggestions to add users
searchUsers(username: string) { searchUsers(username: string) {
this.twigsService.getUsersByUsername(username).subscribe(users => { this.twigsService.getUsersByUsername(username).then(users => {
this.searchedUsers = users; this.searchedUsers = users;
}); });
} }

View file

@ -4,8 +4,6 @@ import { ActivatedRoute, Router } from '@angular/router';
import { AppComponent } from 'src/app/app.component'; import { AppComponent } from 'src/app/app.component';
import { Transaction } from 'src/app/transactions/transaction'; import { Transaction } from 'src/app/transactions/transaction';
import { Category } from 'src/app/categories/category'; import { Category } from 'src/app/categories/category';
import { Observable } from 'rxjs';
// import { Label } from 'ng2-charts';
import { ChartDataset } from 'chart.js'; import { ChartDataset } from 'chart.js';
import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service'; import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service';
import { Actionable } from '../../shared/actionable'; import { Actionable } from '../../shared/actionable';
@ -16,7 +14,6 @@ import { Actionable } from '../../shared/actionable';
styleUrls: ['./budget-details.component.css'] styleUrls: ['./budget-details.component.css']
}) })
export class BudgetDetailsComponent implements OnInit, OnDestroy, Actionable { export class BudgetDetailsComponent implements OnInit, OnDestroy, Actionable {
budget: Budget; budget: Budget;
public budgetBalance: number; public budgetBalance: number;
public transactions: Transaction[]; public transactions: Transaction[];
@ -82,7 +79,7 @@ export class BudgetDetailsComponent implements OnInit, OnDestroy, Actionable {
getBudget() { getBudget() {
const id = this.route.snapshot.paramMap.get('id'); const id = this.route.snapshot.paramMap.get('id');
this.twigsService.getBudget(id) this.twigsService.getBudget(id)
.subscribe(budget => { .then(budget => {
this.app.setTitle(budget.name) this.app.setTitle(budget.name)
this.budget = budget; this.budget = budget;
this.getBalance(); this.getBalance();
@ -115,7 +112,8 @@ export class BudgetDetailsComponent implements OnInit, OnDestroy, Actionable {
getBalance(): void { getBalance(): void {
const id = this.route.snapshot.paramMap.get('id'); const id = this.route.snapshot.paramMap.get('id');
this.twigsService.getBudgetBalance(id, this.from, this.to).subscribe(balance => { this.twigsService.getBudgetBalance(id, this.from, this.to)
.then(balance => {
this.budgetBalance = balance; this.budgetBalance = balance;
}); });
} }
@ -128,14 +126,13 @@ export class BudgetDetailsComponent implements OnInit, OnDestroy, Actionable {
date.setMilliseconds(0); date.setMilliseconds(0);
date.setDate(1); date.setDate(1);
this.twigsService.getTransactions(this.budget.id, null, 5, date) this.twigsService.getTransactions(this.budget.id, null, 5, date)
.subscribe(transactions => this.transactions = <Transaction[]>transactions); .then(transactions => this.transactions = <Transaction[]>transactions);
} }
getCategories(): void { async getCategories() {
this.twigsService.getCategories(this.budget.id).subscribe(categories => { const categories = await this.twigsService.getCategories(this.budget.id)
const categoryBalances = new Map<string, number>(); const categoryBalances = new Map<string, number>();
let categoryBalancesCount = 0; let categoryBalancesCount = 0;
console.log(categories);
for (const category of categories) { for (const category of categories) {
if (category.expense) { if (category.expense) {
this.expenses.push(category); this.expenses.push(category);
@ -144,8 +141,8 @@ export class BudgetDetailsComponent implements OnInit, OnDestroy, Actionable {
this.income.push(category); this.income.push(category);
this.expectedIncome += category.amount; this.expectedIncome += category.amount;
} }
this.twigsService.getCategoryBalance(category.id, this.from, this.to).subscribe( try {
balance => { const balance = await this.twigsService.getCategoryBalance(category.id, this.from, this.to)
console.log(balance); console.log(balance);
if (category.expense) { if (category.expense) {
this.actualExpenses += balance * -1; this.actualExpenses += balance * -1;
@ -153,21 +150,17 @@ export class BudgetDetailsComponent implements OnInit, OnDestroy, Actionable {
this.actualIncome += balance; this.actualIncome += balance;
} }
categoryBalances.set(category.id, balance); categoryBalances.set(category.id, balance);
categoryBalancesCount++; if (categoryBalancesCount === categories.length - 1) {
},
error => { categoryBalancesCount++; },
() => {
// This weird workaround is to force the OnChanges callback to be fired. // This weird workaround is to force the OnChanges callback to be fired.
// Angular needs the reference to the object to change in order for it to // Angular needs the reference to the object to change in order for it to
// work. // work.
if (categoryBalancesCount === categories.length) {
this.categoryBalances = categoryBalances; this.categoryBalances = categoryBalances;
this.updateBarChart(); this.updateBarChart();
} }
} finally {
categoryBalancesCount++;
} }
);
} }
});
} }
doAction(): void { doAction(): void {

View file

@ -32,16 +32,17 @@ export class BudgetsComponent implements OnInit {
this.app.setTitle('Budgets') this.app.setTitle('Budgets')
this.loggedIn = true; this.loggedIn = true;
this.loading = true; this.loading = true;
this.twigsService.getBudgets().subscribe( this.twigsService.getBudgets()
.then(
budgets => { budgets => {
console.log(budgets) console.log(budgets)
this.budgets = budgets; this.budgets = budgets;
this.loading = false; this.loading = false;
}, })
error => { .catch(error => {
console.log(error)
this.loading = false; this.loading = false;
} });
);
}, },
error => { error => {
this.loading = false; this.loading = false;

View file

@ -20,7 +20,7 @@ export class EditBudgetComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
const id = this.route.snapshot.paramMap.get('id'); const id = this.route.snapshot.paramMap.get('id');
this.twigsService.getBudget(id) this.twigsService.getBudget(id)
.subscribe(budget => { .then(budget => {
this.budget = budget; this.budget = budget;
}); });
} }

View file

@ -1,11 +1,9 @@
import { Component, OnInit, Input, Inject } from '@angular/core'; import { Component, OnInit, Inject } from '@angular/core';
import { Category } from './category'; import { Category } from './category';
import { AppComponent } from '../app.component'; import { AppComponent } from '../app.component';
import { Observable } from 'rxjs';
import { TransactionType } from '../transactions/transaction.type';
import { Budget } from '../budgets/budget';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { TWIGS_SERVICE, TwigsService } from '../shared/twigs.service'; import { TWIGS_SERVICE, TwigsService } from '../shared/twigs.service';
import { Transaction } from '../transactions/transaction';
@Component({ @Component({
selector: 'app-categories', selector: 'app-categories',
@ -33,17 +31,22 @@ export class CategoriesComponent implements OnInit {
} }
getCategories(): void { getCategories(): void {
this.twigsService.getCategories(this.budgetId).subscribe(categories => { this.twigsService.getCategories(this.budgetId).then(categories => {
this.categories = categories; this.categories = categories;
for (const category of this.categories) { for (const category of this.categories) {
this.getCategoryBalance(category).subscribe(balance => this.categoryBalances.set(category.id, balance)); this.getCategoryBalance(category).then(balance => this.categoryBalances.set(category.id, balance));
} }
}); });
} }
getCategoryBalance(category: Category): Observable<number> { getCategoryBalance(category: Category): Promise<number> {
return Observable.create(subscriber => { return new Promise(async (resolve, reject) => {
this.twigsService.getTransactions(this.budgetId, category.id).subscribe(transactions => { let transactions: Transaction[]
try {
transactions = await this.twigsService.getTransactions(this.budgetId, category.id)
} catch(e) {
reject(e)
}
let balance = 0; let balance = 0;
for (const transaction of transactions) { for (const transaction of transactions) {
if (transaction.expense) { if (transaction.expense) {
@ -52,8 +55,7 @@ export class CategoriesComponent implements OnInit {
balance += transaction.amount; balance += transaction.amount;
} }
} }
subscriber.next(balance); resolve(balance);
});
}); });
} }
} }

View file

@ -45,7 +45,7 @@ export class CategoryDetailsComponent implements OnInit, OnDestroy, Actionable {
getCategory(): void { getCategory(): void {
const id = this.route.snapshot.paramMap.get('id'); const id = this.route.snapshot.paramMap.get('id');
this.twigsService.getCategory(id) this.twigsService.getCategory(id)
.subscribe(category => { .then(category => {
category.amount /= 100; category.amount /= 100;
this.app.setTitle(category.title) this.app.setTitle(category.title)
this.category = category; this.category = category;

View file

@ -2,14 +2,14 @@
<p>Select a category from the list to view details about it or edit it.</p> <p>Select a category from the list to view details about it or edit it.</p>
</div> </div>
<div *ngIf="currentCategory" class="form category-form"> <div *ngIf="currentCategory" class="form category-form">
<mat-form-field (keyup.enter)="doAction()"> <mat-form-field (keyup.enter)="save()">
<input matInput [(ngModel)]="currentCategory.title" placeholder="Name" required> <input matInput [(ngModel)]="currentCategory.title" placeholder="Name" required autocapitalize="words">
</mat-form-field> </mat-form-field>
<mat-form-field (keyup.enter)="doAction()"> <mat-form-field (keyup.enter)="save()">
<textarea matInput [(ngModel)]="currentCategory.description" placeholder="Description"></textarea> <textarea matInput [(ngModel)]="currentCategory.description" placeholder="Description" autocapitalize="sentences"></textarea>
</mat-form-field> </mat-form-field>
<mat-form-field (keyup.enter)="doAction()"> <mat-form-field (keyup.enter)="save()">
<input matInput type="text" [(ngModel)]="currentCategory.amount" placeholder="Amount" required> <input matInput type="input" [(ngModel)]="currentCategory.amount" placeholder="Amount" required step="0.01">
</mat-form-field> </mat-form-field>
<mat-radio-group [(ngModel)]="currentCategory.expense"> <mat-radio-group [(ngModel)]="currentCategory.expense">
<mat-radio-button [value]="true">Expense</mat-radio-button> <mat-radio-button [value]="true">Expense</mat-radio-button>

View file

@ -1,7 +1,8 @@
import { Component, OnInit, Input, OnDestroy, Inject } from '@angular/core'; import { Component, OnInit, Input, Inject } from '@angular/core';
import { Category } from '../category'; import { Category } from '../category';
import { AppComponent } from 'src/app/app.component'; import { AppComponent } from 'src/app/app.component';
import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service'; import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service';
import { decimalToInteger } from 'src/app/shared/utils';
@Component({ @Component({
selector: 'app-category-form', selector: 'app-category-form',
@ -26,37 +27,35 @@ export class CategoryFormComponent implements OnInit {
} }
save(): void { save(): void {
let observable; let promise;
this.currentCategory.amount = decimalToInteger(String(this.currentCategory.amount))
if (this.create) { if (this.create) {
// This is a new category, save it // This is a new category, save it
observable = this.twigsService.createCategory( promise = this.twigsService.createCategory(
this.currentCategory.id, this.currentCategory.id,
this.budgetId, this.budgetId,
this.currentCategory.title, this.currentCategory.title,
this.currentCategory.description, this.currentCategory.description,
this.currentCategory.amount * 100, this.currentCategory.amount,
this.currentCategory.expense this.currentCategory.expense
); );
} else { } else {
// This is an existing category, update it // This is an existing category, update it
observable = this.twigsService.updateCategory( const updatedCategory: Category = {
this.currentCategory.id, ...this.currentCategory,
{
name: this.currentCategory.title,
description: this.currentCategory.description,
amount: this.currentCategory.amount * 100,
expense: this.currentCategory.expense,
archived: this.currentCategory.archived
} }
promise = this.twigsService.updateCategory(
this.currentCategory.id,
this.currentCategory
); );
} }
observable.subscribe(val => { promise.then(_ => {
this.app.goBack(); this.app.goBack();
}); });
} }
delete(): void { delete(): void {
this.twigsService.deleteCategory(this.currentCategory.id).subscribe(() => { this.twigsService.deleteCategory(this.currentCategory.id).then(() => {
this.app.goBack(); this.app.goBack();
}); });
} }

View file

@ -28,7 +28,7 @@ export class EditCategoryComponent implements OnInit {
getCategory(): void { getCategory(): void {
const id = this.route.snapshot.paramMap.get('id'); const id = this.route.snapshot.paramMap.get('id');
this.twigsService.getCategory(id) this.twigsService.getCategory(id)
.subscribe(category => { .then(category => {
category.amount /= 100; category.amount /= 100;
this.app.setTitle(category.title) this.app.setTitle(category.title)
this.category = category; this.category = category;

View file

@ -0,0 +1,221 @@
export class RecurringTransaction {
id: string = '';
title: string;
description?: string = null;
frequency: Frequency;
start: Date = new Date();
end?: Date;
amount: number;
expense = true;
categoryId: string;
budgetId: string;
createdBy: string;
}
export class Frequency {
unit: FrequencyUnit;
count: number;
time: Time;
amount?: (void | Set<DayOfWeek> | DayOfMonth | DayOfYear);
private constructor(unit: FrequencyUnit, count: number, time: Time, amount?: (void | Set<DayOfWeek> | DayOfMonth | DayOfYear)) {
this.unit = unit;
this.count = count;
this.time = time;
this.amount = amount;
}
static Daily(count: number, time: Time): Frequency {
return new Frequency(FrequencyUnit.DAILY, count, time);
}
static Weekly(count: number, time: Time, daysOfWeek: Set<DayOfWeek>): Frequency {
return new Frequency(FrequencyUnit.WEEKLY, count, time, daysOfWeek)
}
static Monthly(count: number, time: Time, dayOfMonth: DayOfMonth): Frequency {
return new Frequency(FrequencyUnit.MONTHLY, count, time, dayOfMonth)
}
static Yearly(count: number, time: Time, dayOfYear: DayOfYear): Frequency {
return new Frequency(FrequencyUnit.YEARLY, count, time, dayOfYear)
}
static parse(s: string): Frequency {
const parts = s.split(';');
let count: number, time: Time;
switch (parts[0]) {
case 'D':
count = Number.parseInt(parts[1]);
time = Time.parse(parts[2]);
return this.Daily(count, time);
case 'W':
count = Number.parseInt(parts[1]);
time = Time.parse(parts[3]);
const daysOfWeek = new Set(parts[2].split(',').map(day => DayOfWeek[day]));
return this.Weekly(count, time, daysOfWeek);
case 'M':
count = Number.parseInt(parts[1]);
time = Time.parse(parts[3]);
const dayOfMonth = DayOfMonth.parse(parts[2]);
return this.Monthly(count, time, dayOfMonth);
case 'Y':
count = Number.parseInt(parts[1]);
time = Time.parse(parts[3]);
const dayOfYear = DayOfYear.parse(parts[2]);
return this.Yearly(count, time, dayOfYear);
default:
throw new Error(`Invalid Frequency format: ${s}`);
}
}
toString(): string {
let parts = [this.unit.toString()]
parts.push(this.count.toString())
if (this.amount) {
if (this.unit === FrequencyUnit.WEEKLY) {
parts.push(Array.from(this.amount as Set<DayOfWeek>).join(','))
} else {
parts.push(this.amount.toString())
}
}
parts.push(this.time.toString())
return parts.join(';')
}
}
export enum FrequencyUnit {
DAILY = 'D',
WEEKLY = 'W',
MONTHLY = 'M',
YEARLY = 'Y',
}
export class Time {
hours: number;
minutes: number;
seconds: number;
constructor(hours: number, minutes: number, seconds: number) {
this.hours = hours;
this.minutes = minutes;
this.seconds = seconds;
}
toString(): string {
return [
String(this.hours).padStart(2, '0'),
String(this.minutes).padStart(2, '0'),
String(this.seconds).padStart(2, '0'),
].join(':')
}
static parse(s: string): Time {
if (!s.match(/[0-9]{2}:[0-9]{2}:[0-9]{2}/)) {
throw new Error('Invalid time format. Time must be formatted as HH:mm:ss');
}
const parts = s.split(':').map(part => Number.parseInt(part));
return new Time(parts[0], parts[1], parts[2]);
}
}
export enum Position {
DAY = 'DAY',
FIRST = 'FIRST',
SECOND = 'SECOND',
THIRD = 'THIRD',
FOURTH = 'FOURTH',
LAST = 'LAST',
}
export enum DayOfWeek {
MONDAY = 'MONDAY',
TUESDAY = 'TUESDAY',
WEDNESDAY = 'WEDNESDAY',
THURSDAY = 'THURSDAY',
FRIDAY = 'FRIDAY',
SATURDAY = 'SATURDAY',
SUNDAY = 'SUNDAY',
}
export class DayOfMonth {
position: Position;
day: (number | DayOfWeek);
private constructor(position: Position, day: (number | DayOfWeek)) {
this.position = position;
this.day = day;
}
static Each(day: number): DayOfMonth {
if (day < 1 || day > 31) {
throw new Error('Day must be between 1 and 31');
}
return new DayOfMonth(Position.DAY, day);
}
static PositionalDayOfWeek(position: Position, day: DayOfWeek): DayOfMonth {
if (position === Position.DAY) {
throw new Error('Use DayOfMonth.Each() to create a monthly recurring transaction on the same calendar day');
}
return new DayOfMonth(position, day)
}
static parse(s: string): DayOfMonth {
const parts = s.split('-');
const position = Position[parts[0]];
if (position === Position.DAY) {
return DayOfMonth.Each(Number.parseInt(parts[1]));
} else {
return DayOfMonth.PositionalDayOfWeek(position, DayOfWeek[parts[1]]);
}
}
toString(): string {
return `${this.position}-${this.day}`
}
}
export class DayOfYear {
month: number;
day: number;
constructor(month: number, day: number) {
this.month = month;
this.day = day;
}
static parse(s: string): DayOfYear {
if (!s.match(/[0-9]{2}-[0-9]{2}/)) {
throw new Error(`Invalid format for DayOfYear: ${s}`)
}
const parts = s.split('-').map(part => Number.parseInt(part));
if (parts[0] < 1 || parts[0] > 12) {
throw new Error(`Invalid month for DayOfYear: ${parts[0]}`);
}
let maxDay: number;
switch (parts[0]) {
case 2:
maxDay = 29;
break;
case 4:
case 6:
case 9:
case 11:
maxDay = 30;
break;
default:
maxDay = 31;
}
if (parts[1] < 1 || parts[1] > maxDay) {
throw new Error(`Invalid day for DayOfYear: ${parts[0]}`);
}
return new DayOfYear(parts[0], parts[1]);
}
toString(): string {
const monthString = this.month.toString().padStart(2, '0')
const dayString = this.day.toString().padStart(2, '0')
return `${monthString}-${dayString}`
}
}

View file

@ -1,121 +1,77 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http';
import { BehaviorSubject, Observable, pipe, Subscriber } from 'rxjs';
import { User, UserPermission, Permission, AuthToken } from '../users/user'; import { User, UserPermission, Permission, AuthToken } from '../users/user';
import { TwigsService } from './twigs.service'; import { TwigsService } from './twigs.service';
import { Budget } from '../budgets/budget'; import { Budget } from '../budgets/budget';
import { Category } from '../categories/category'; import { Category } from '../categories/category';
import { Transaction } from '../transactions/transaction'; import { Transaction } from '../transactions/transaction';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
import { map } from 'rxjs/operators'; import { Frequency, RecurringTransaction } from '../recurringtransactions/recurringtransaction';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class TwigsHttpService implements TwigsService { export class TwigsHttpService implements TwigsService {
private options = {
withCredentials: true
};
private apiUrl = environment.apiUrl; private apiUrl = environment.apiUrl;
private budgets: BehaviorSubject<Budget[]> = new BehaviorSubject(null);
constructor( constructor(
private http: HttpClient,
private storage: Storage private storage: Storage
) { } ) { }
login(email: string, password: string): Observable<User> { async login(email: string, password: string): Promise<User> {
return new Observable(emitter => { const url = new URL('/api/users/login', this.apiUrl)
const params = { const auth: AuthToken = await this.request(url, HttpMethod.POST, {
'username': email, 'username': email,
'password': password 'password': password
}; });
this.http.post<AuthToken>(this.apiUrl + '/users/login', params, this.options)
.subscribe(
auth => {
// TODO: Use token expiration to determine cookie expiration
this.storage.setItem('Authorization', auth.token); this.storage.setItem('Authorization', auth.token);
this.storage.setItem('userId', auth.userId); this.storage.setItem('userId', auth.userId);
this.getProfile(auth.userId).subscribe(user => emitter.next(user), error => emitter.error(error)); return await this.getProfile(auth.userId);
},
error => emitter.error(error)
);
});
} }
register(username: string, email: string, password: string): Observable<User> { register(username: string, email: string, password: string): Promise<User> {
const params = { const body = {
'username': username, 'username': username,
'email': email, 'email': email,
'password': password 'password': password
}; };
return this.http.post<User>(this.apiUrl + '/users/register', params, this.options); const url = new URL('/api/users/register', this.apiUrl)
return this.request<User>(url, HttpMethod.POST, body);
} }
logout(): Observable<void> { logout(): Promise<void> {
return new Observable(emitter => {
this.storage.removeItem('Authorization'); this.storage.removeItem('Authorization');
this.storage.removeItem('userId'); this.storage.removeItem('userId');
emitter.next(); return Promise.resolve()
emitter.complete(); // TODO: Implement this to revoke the token server-side as well
})
// TODO: Implement this when JWT auth is implemented
// return this.http.post<void>(this.apiUrl + '/login?logout', this.options); // return this.http.post<void>(this.apiUrl + '/login?logout', this.options);
} }
// Budgets // Budgets
getBudgets(): Observable<Budget[]> { getBudgets(): Promise<Budget[]> {
this.http.get<Budget[]>(this.apiUrl + '/budgets', this.options) const url = new URL('/api/budgets', this.apiUrl)
.subscribe(budgets => { return this.request(url, HttpMethod.GET)
this.budgets.next(budgets);
});
return this.budgets;
} }
getBudgetBalance( getBudgetBalance(
id: string, id: string,
from?: Date, from?: Date,
to?: Date to?: Date
): Observable<number> { ): Promise<number> {
let httpParams = new HttpParams(); const url = new URL('/api/transactions/sum', this.apiUrl)
url.searchParams.set('budgetId', id)
if (from) { if (from) {
httpParams = httpParams.set('from', from.toISOString()); url.searchParams.set('from', from.toISOString());
} }
if (to) { if (to) {
httpParams = httpParams.set('to', to.toISOString()); url.searchParams.set('to', to.toISOString());
} }
const params = { params: httpParams }; return this.request(url, HttpMethod.GET).then((res: any) => res.balance)
return this.http.get<any>(`${this.apiUrl}/transactions/sum?budgetId=${id}`, { ...this.options, ...params })
.pipe(map(obj => obj.balance));
} }
getBudget(id: string): Observable<Budget> { getBudget(id: string): Promise<Budget> {
return new Observable(emitter => { const url = new URL(`/api/budgets/${id}`, this.apiUrl)
var cachedBudget: Budget return this.request(url, HttpMethod.GET)
if (this.budgets.value) {
cachedBudget = this.budgets.value.find(budget => {
return budget.id === id;
});
}
if (cachedBudget) {
emitter.next(cachedBudget);
emitter.complete();
} else {
this.http.get<Budget>(`${this.apiUrl}/budgets/${id}`, this.options)
.subscribe(budget => {
var oldBudgets = JSON.parse(JSON.stringify(this.budgets.value));
if (!oldBudgets) {
oldBudgets = [];
}
oldBudgets.push(budget);
oldBudgets.sort();
this.budgets.next(oldBudgets);
emitter.next(budget);
emitter.complete();
})
}
})
} }
createBudget( createBudget(
@ -123,8 +79,9 @@ export class TwigsHttpService implements TwigsService {
name: string, name: string,
description: string, description: string,
users: UserPermission[], users: UserPermission[],
): Observable<Budget> { ): Promise<Budget> {
const params = { const url = new URL('/api/budgets', this.apiUrl)
const body = {
'id': id, 'id': id,
'name': name, 'name': name,
'description': description, 'description': description,
@ -135,19 +92,12 @@ export class TwigsHttpService implements TwigsService {
}; };
}) })
}; };
return this.http.post<Budget>(this.apiUrl + '/budgets', params, this.options) return this.request(url, HttpMethod.POST, body)
.pipe(map(budget => {
var updatedBudgets = JSON.parse(JSON.stringify(this.budgets.value));
updatedBudgets.push(budget);
updatedBudgets.sort();
this.budgets.next(updatedBudgets);
return budget
}))
} }
updateBudget(id: string, changes: object): Observable<Budget> { updateBudget(id: string, budget: Budget): Promise<Budget> {
let budget = changes as Budget; const url = new URL(`/api/budgets/${id}`, this.apiUrl)
const params = { const body = {
'name': budget.name, 'name': budget.name,
'description': budget.description, 'description': budget.description,
'users': budget.users.map(userPermission => { 'users': budget.users.map(userPermission => {
@ -157,67 +107,47 @@ export class TwigsHttpService implements TwigsService {
}; };
}) })
}; };
return this.http.put<Budget>(`${this.apiUrl}/budgets/${id}`, params, this.options) return this.request(url, HttpMethod.PUT, body)
.pipe(map(budget => {
var updatedBudgets: Budget[] = JSON.parse(JSON.stringify(this.budgets.value));
var index = updatedBudgets.findIndex(oldBudget => oldBudget.id === id);
if (index > -1) {
updatedBudgets.splice(index, 1);
}
updatedBudgets.push(budget);
updatedBudgets.sort();
this.budgets.next(updatedBudgets);
return budget
}));
} }
deleteBudget(id: String): Observable<void> { deleteBudget(id: String): Promise<void> {
return this.http.delete<void>(`${this.apiUrl}/budgets/${id}`, this.options) const url = new URL(`/api/budgets/${id}`, this.apiUrl)
.pipe(map(() => { return this.request(url, HttpMethod.DELETE)
var updatedBudgets: Budget[] = JSON.parse(JSON.stringify(this.budgets.value));
var index = updatedBudgets.findIndex(oldBudget => oldBudget.id === id);
if (index > -1) {
updatedBudgets.splice(index, 1);
}
updatedBudgets.sort();
this.budgets.next(updatedBudgets);
return;
}));
} }
// Categories // Categories
getCategories(budgetId: string, count?: number): Observable<Category[]> { getCategories(budgetId: string, count?: number): Promise<Category[]> {
const params = { const url = new URL(`/api/categories`, this.apiUrl)
params: new HttpParams() url.searchParams.set('budgetIds', budgetId)
.set('budgetIds', `${budgetId}`) url.searchParams.set('archived', 'false')
.set('archived', false) return this.request(url, HttpMethod.GET);
};
return this.http.get<Category[]>(`${this.apiUrl}/categories`, Object.assign(params, this.options));
} }
getCategory(id: string): Observable<Category> { getCategory(id: string): Promise<Category> {
return this.http.get<Category>(`${this.apiUrl}/categories/${id}`, this.options); const url = new URL(`/api/categories/${id}`, this.apiUrl)
return this.request(url, HttpMethod.GET);
} }
getCategoryBalance( async getCategoryBalance(
id: string, id: string,
from?: Date, from?: Date,
to?: Date to?: Date
): Observable<number> { ): Promise<number> {
let httpParams = new HttpParams(); const url = new URL(`/api/transactions/sum`, this.apiUrl)
url.searchParams.set('categoryId', id)
if (from) { if (from) {
httpParams = httpParams.set('from', from.toISOString()); url.searchParams.set('from', from.toISOString());
} }
if (to) { if (to) {
httpParams = httpParams.set('to', to.toISOString()); url.searchParams.set('to', to.toISOString());
} }
const params = { params: httpParams }; const res: any = await this.request(url, HttpMethod.GET);
return this.http.get<any>(`${this.apiUrl}/transactions/sum?categoryId=${id}`, { ...this.options, ...params }) return res.balance;
.pipe(map(obj => obj.balance));
} }
createCategory(id: string, budgetId: string, name: string, description: string, amount: number, isExpense: boolean): Observable<Category> { createCategory(id: string, budgetId: string, name: string, description: string, amount: number, isExpense: boolean): Promise<Category> {
const params = { const url = new URL(`/api/categories`, this.apiUrl)
const body = {
'id': id, 'id': id,
'title': name, 'title': name,
'description': description, 'description': description,
@ -225,57 +155,55 @@ export class TwigsHttpService implements TwigsService {
'expense': isExpense, 'expense': isExpense,
'budgetId': budgetId 'budgetId': budgetId
}; };
return this.http.post<Category>(this.apiUrl + '/categories', params, this.options); return this.request(url, HttpMethod.POST, body);
} }
updateCategory(id: string, changes: object): Observable<Category> { updateCategory(id: string, changes: object): Promise<Category> {
return this.http.put<Category>(`${this.apiUrl}/categories/${id}`, changes, this.options); const url = new URL(`/api/categories/${id}`, this.apiUrl)
return this.request(url, HttpMethod.PUT, changes);
} }
deleteCategory(id: string): Observable<void> { deleteCategory(id: string): Promise<void> {
return this.http.delete<void>(`${this.apiUrl}/categories/${id}`, this.options); const url = new URL(`/api/categories/${id}`, this.apiUrl)
return this.request(url, HttpMethod.DELETE);
} }
// Transactions // Transactions
getTransactions( async getTransactions(
budgetId?: string, budgetId?: string,
categoryId?: string, categoryId?: string,
count?: number, count?: number,
from?: Date, from?: Date,
to?: Date to?: Date
): Observable<Transaction[]> { ): Promise<Transaction[]> {
let httpParams = new HttpParams(); const url = new URL(`/api/transactions`, this.apiUrl)
if (budgetId) { if (budgetId) {
httpParams = httpParams.set('budgetIds', `${budgetId}`); url.searchParams.set('budgetIds', budgetId);
} }
if (categoryId) { if (categoryId) {
httpParams = httpParams.set('categoryIds', `${categoryId}`); url.searchParams.set('categoryIds', categoryId);
} }
if (from) { if (from) {
httpParams = httpParams.set('from', from.toISOString()); url.searchParams.set('from', from.toISOString());
} }
if (to) { if (to) {
httpParams = httpParams.set('to', to.toISOString()); url.searchParams.set('to', to.toISOString());
} }
const params = { params: httpParams }; const transactions: Transaction[] = await this.request(url, HttpMethod.GET)
return this.http.get<Transaction[]>(`${this.apiUrl}/transactions`, Object.assign(params, this.options))
.pipe(map(transactions => {
transactions.forEach(transaction => { transactions.forEach(transaction => {
transaction.date = new Date(transaction.date); transaction.date = new Date(transaction.date);
}); })
return transactions; return transactions
}));
} }
getTransaction(id: string): Observable<Transaction> { async getTransaction(id: string): Promise<Transaction> {
return this.http.get<Transaction>(`${this.apiUrl}/transactions/${id}`, this.options) const url = new URL(`/api/transactions/${id}`, this.apiUrl)
.pipe(map(transaction => { const transaction: Transaction = await this.request(url, HttpMethod.GET)
transaction.date = new Date(transaction.date); transaction.date = new Date(transaction.date)
return transaction; return transaction
}));
} }
createTransaction( async createTransaction(
id: string, id: string,
budgetId: string, budgetId: string,
name: string, name: string,
@ -284,8 +212,9 @@ export class TwigsHttpService implements TwigsService {
date: Date, date: Date,
expense: boolean, expense: boolean,
category: string category: string
): Observable<Transaction> { ): Promise<Transaction> {
const params = { const url = new URL(`/api/transactions`, this.apiUrl)
const body = {
'id': id, 'id': id,
'title': name, 'title': name,
'description': description, 'description': description,
@ -295,25 +224,147 @@ export class TwigsHttpService implements TwigsService {
'categoryId': category, 'categoryId': category,
'budgetId': budgetId 'budgetId': budgetId
}; };
return this.http.post<Transaction>(this.apiUrl + '/transactions', params, this.options); const transaction: Transaction = await this.request(url, HttpMethod.POST, body)
transaction.date = new Date(transaction.date)
return transaction
} }
updateTransaction(id: string, changes: object): Observable<Transaction> { async updateTransaction(id: string, transaction: Transaction): Promise<Transaction> {
return this.http.put<Transaction>(`${this.apiUrl}/transactions/${id}`, changes, this.options); const body: any = transaction;
body.date = transaction.date.toISOString()
const url = new URL(`/api/transactions/${id}`, this.apiUrl)
const updatedTransaction: Transaction = await this.request(url, HttpMethod.PUT, body)
updatedTransaction.date = new Date(updatedTransaction.date)
return updatedTransaction
} }
deleteTransaction(id: string): Observable<void> { deleteTransaction(id: string): Promise<void> {
return this.http.delete<void>(`${this.apiUrl}/transactions/${id}`, this.options); const url = new URL(`/api/transactions/${id}`, this.apiUrl)
return this.request(url, HttpMethod.DELETE)
}
// Recurring Transactions
async getRecurringTransactions(
budgetId?: string,
categoryId?: string,
count?: number,
from?: Date,
to?: Date
): Promise<RecurringTransaction[]> {
const url = new URL(`/api/recurringtransactions`, this.apiUrl)
if (budgetId) {
url.searchParams.set('budgetIds', budgetId);
}
if (categoryId) {
url.searchParams.set('categoryIds', categoryId);
}
if (from) {
url.searchParams.set('from', from.toISOString());
}
if (to) {
url.searchParams.set('to', to.toISOString());
}
const transactions: RecurringTransaction[] = await this.request(url, HttpMethod.GET)
transactions.forEach(transaction => {
transaction.frequency = Frequency.parse(transaction.frequency as any)
})
return transactions
}
async getRecurringTransaction(id: string): Promise<RecurringTransaction> {
const url = new URL(`/api/recurringtransactions/${id}`, this.apiUrl)
const transaction: RecurringTransaction = await this.request(url, HttpMethod.GET)
transaction.frequency = Frequency.parse(transaction.frequency as any)
return transaction
}
async createRecurringTransaction(
id: string,
budgetId: string,
name: string,
description: string,
amount: number,
frequency: Frequency,
start: Date,
expense: boolean,
category: string,
end?: Date,
): Promise<RecurringTransaction> {
const url = new URL(`/api/transactions`, this.apiUrl)
const body = {
'id': id,
'title': name,
'description': description,
'frequency': frequency.toString(),
'start': start.toISOString(),
'finish': end?.toISOString(),
'amount': amount,
'expense': expense,
'categoryId': category,
'budgetId': budgetId
};
const transaction: RecurringTransaction = await this.request(url, HttpMethod.POST, body)
transaction.frequency = Frequency.parse(transaction.frequency as any)
return transaction
}
async updateRecurringTransaction(id: string, transaction: RecurringTransaction): Promise<RecurringTransaction> {
const body: any = transaction;
body.frequency = transaction.frequency.toString()
const url = new URL(`/api/transactions/${id}`, this.apiUrl)
const updatedTransaction: RecurringTransaction = await this.request(url, HttpMethod.PUT, body)
updatedTransaction.frequency = Frequency.parse(updatedTransaction.frequency as any)
return updatedTransaction
}
deleteRecurringTransaction(id: string): Promise<void> {
const url = new URL(`/api/recurringtransactions/${id}`, this.apiUrl)
return this.request(url, HttpMethod.DELETE)
} }
// Users // Users
getProfile(id: string): Observable<User> { getProfile(id: string): Promise<User> {
return this.http.get<User>(`${this.apiUrl}/users/${id}`, this.options); const url = new URL(`/api/users/${id}`, this.apiUrl)
return this.request(url, HttpMethod.GET)
} }
getUsersByUsername(username: string): Observable<User[]> { getUsersByUsername(username: string): Promise<User[]> {
return new Observable(subscriber => { return Promise.reject("Not yet implemented")
subscriber.error("Not yet implemented") }
});
private async request<T>(url: URL, method: HttpMethod, body?: any): Promise<T> {
const headers = {
'content-type': 'application/json'
}
const token = this.storage.getItem('Authorization')
if (token) {
headers['authorization'] = `Bearer ${token}`
}
let jsonBody: string;
if (body) {
jsonBody = JSON.stringify(body)
}
const res = await fetch(url, {
credentials: 'include',
headers: headers,
method: method,
body: jsonBody
})
if (res.status === 204) {
// No content
return
}
return res.json()
} }
} }
enum HttpMethod {
GET = "GET",
POST = "POST",
PUT = "PUT",
DELETE = "DELETE",
}

View file

@ -1,12 +1,11 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http';
import { Observable, Subscriber } from 'rxjs';
import { User, UserPermission } from '../users/user'; import { User, UserPermission } from '../users/user';
import { TwigsService } from './twigs.service'; import { TwigsService } from './twigs.service';
import { Budget } from '../budgets/budget'; import { Budget } from '../budgets/budget';
import { Category } from '../categories/category'; import { Category } from '../categories/category';
import { Transaction } from '../transactions/transaction'; import { Transaction } from '../transactions/transaction';
import { randomId } from '../shared/utils'; import { randomId } from '../shared/utils';
import { Frequency, RecurringTransaction } from '../recurringtransactions/recurringtransaction';
/** /**
* This is intended to be a very simple implementation of the TwigsService used for testing out the UI and quickly iterating on it. * This is intended to be a very simple implementation of the TwigsService used for testing out the UI and quickly iterating on it.
@ -18,7 +17,6 @@ import { randomId } from '../shared/utils';
export class TwigsLocalService implements TwigsService { export class TwigsLocalService implements TwigsService {
constructor( constructor(
private http: HttpClient
) { } ) { }
private users: User[] = [new User(randomId(), 'test', 'test@example.com')]; private users: User[] = [new User(randomId(), 'test', 'test@example.com')];
@ -27,63 +25,53 @@ export class TwigsLocalService implements TwigsService {
private categories: Category[] = []; private categories: Category[] = [];
// Auth // Auth
login(email: string, password: string): Observable<User> { login(email: string, password: string): Promise<User> {
return new Observable(subscriber => { return new Promise((resolve, reject) => {
const filteredUsers = this.users.filter(user => { const filteredUsers = this.users.filter(user => {
return (user.email === email || user.username === email); return (user.email === email || user.username === email);
}); });
if (filteredUsers.length !== 0) { if (filteredUsers.length !== 0) {
subscriber.next(filteredUsers[0]); resolve(filteredUsers[0]);
} else { } else {
subscriber.error('No users found'); reject('No users found');
} }
}); });
} }
register(username: string, email: string, password: string): Observable<User> { register(username: string, email: string, password: string): Promise<User> {
return new Observable(subscriber => { return new Promise((resolve, reject) => {
const user = new User(); const user = new User();
user.username = username; user.username = username;
user.email = email; user.email = email;
user.id = randomId(); user.id = randomId();
this.users.push(user); this.users.push(user);
subscriber.next(user); resolve(user);
subscriber.complete();
}); });
} }
logout(): Observable<void> { logout(): Promise<void> {
return new Observable(subscriber => { return Promise.resolve()
subscriber.complete();
});
} }
// Budgets // Budgets
getBudgets(): Observable<Budget[]> { getBudgets(): Promise<Budget[]> {
return new Observable(subscriber => { return Promise.resolve(this.budgets)
subscriber.next(this.budgets);
subscriber.complete();
});
} }
getBudgetBalance(id: string, from?: Date, to?: Date): Observable<number> { getBudgetBalance(id: string, from?: Date, to?: Date): Promise<number> {
return new Observable(emitter => { return Promise.resolve(200)
emitter.next(200);
emitter.complete()
})
} }
getBudget(id: string): Observable<Budget> { getBudget(id: string): Promise<Budget> {
return new Observable(subscriber => { return new Promise((resolve, reject) => {
const budget = this.budgets.filter(it => { const budget = this.budgets.filter(it => {
return it.id === id; return it.id === id;
})[0]; })[0];
if (budget) { if (budget) {
subscriber.next(budget); resolve(budget);
} else { } else {
subscriber.error('No budget found for given id'); reject('No budget found for given id');
} }
subscriber.complete();
}); });
} }
@ -92,21 +80,20 @@ export class TwigsLocalService implements TwigsService {
name: string, name: string,
description: string, description: string,
users: UserPermission[], users: UserPermission[],
): Observable<Budget> { ): Promise<Budget> {
return new Observable(subscriber => { return new Promise((resolve, reject) => {
const budget = new Budget(); const budget = new Budget();
budget.name = name; budget.name = name;
budget.description = description; budget.description = description;
budget.users = users; budget.users = users;
budget.id = id; budget.id = id;
this.budgets.push(budget); this.budgets.push(budget);
subscriber.next(budget); resolve(budget);
subscriber.complete();
}); });
} }
updateBudget(id: string, changes: object): Observable<Budget> { updateBudget(id: string, budget: Budget): Promise<Budget> {
return new Observable(subscriber => { return new Promise((resolve, reject) => {
const budget = this.budgets.filter(it => { const budget = this.budgets.filter(it => {
return it.id === id; return it.id === id;
})[0]; })[0];
@ -114,7 +101,7 @@ export class TwigsLocalService implements TwigsService {
const index = this.budgets.indexOf(budget); const index = this.budgets.indexOf(budget);
this.updateValues( this.updateValues(
budget, budget,
changes, budget,
[ [
'name', 'name',
'description', 'description',
@ -122,55 +109,49 @@ export class TwigsLocalService implements TwigsService {
] ]
); );
this.budgets[index] = budget; this.budgets[index] = budget;
subscriber.next(budget); resolve(budget);
} else { } else {
subscriber.error('No budget found for given id'); reject('No budget found for given id');
} }
subscriber.complete();
}); });
} }
deleteBudget(id: string): Observable<void> { deleteBudget(id: string): Promise<void> {
return new Observable(subscriber => { return new Promise((resolve, reject) => {
const budget = this.budgets.filter(it => { const budget = this.budgets.filter(it => {
return budget.id === id; return budget.id === id;
})[0]; })[0];
if (budget) { if (budget) {
const index = this.budgets.indexOf(budget); const index = this.budgets.indexOf(budget);
delete this.budgets[index]; delete this.budgets[index];
subscriber.complete(); resolve();
} else { } else {
subscriber.error('No budget found for given id'); reject('No budget found for given id');
} }
}); });
} }
// Categories // Categories
getCategories(budgetId: string, count?: number): Observable<Category[]> { getCategories(budgetId: string, count?: number): Promise<Category[]> {
return new Observable(subscriber => { return new Promise((resolve, reject) => {
subscriber.next(this.categories.filter(category => { resolve(this.categories.filter(category => {
return category.budgetId === budgetId; return category.budgetId === budgetId;
})); }));
subscriber.complete();
}); });
} }
getCategory(id: string): Observable<Category> { getCategory(id: string): Promise<Category> {
return new Observable(subscriber => { return new Promise((resolve, reject) => {
subscriber.next(this.findById(this.categories, id)); resolve(this.findById(this.categories, id));
subscriber.complete();
}); });
} }
getCategoryBalance(id: string, from?: Date, to?: Date): Observable<number> { getCategoryBalance(id: string, from?: Date, to?: Date): Promise<number> {
return new Observable(emitter => { return Promise.resolve(20);
emitter.next(20);
emitter.complete()
})
} }
createCategory(id: string, budgetId: string, name: string, description: string, amount: number, isExpense: boolean): Observable<Category> { createCategory(id: string, budgetId: string, name: string, description: string, amount: number, isExpense: boolean): Promise<Category> {
return Observable.create(subscriber => { return new Promise((resolve, reject) => {
const category = new Category(); const category = new Category();
category.title = name; category.title = name;
category.description = description; category.description = description;
@ -179,13 +160,12 @@ export class TwigsLocalService implements TwigsService {
category.budgetId = budgetId; category.budgetId = budgetId;
category.id = id; category.id = id;
this.categories.push(category); this.categories.push(category);
subscriber.next(category); resolve(category);
subscriber.complete();
}); });
} }
updateCategory(id: string, changes: object): Observable<Category> { updateCategory(id: string, changes: object): Promise<Category> {
return new Observable(subscriber => { return new Promise((resolve, reject) => {
const category = this.findById(this.categories, id); const category = this.findById(this.categories, id);
if (category) { if (category) {
const index = this.categories.indexOf(category); const index = this.categories.indexOf(category);
@ -200,31 +180,30 @@ export class TwigsLocalService implements TwigsService {
] ]
); );
this.categories[index] = category; this.categories[index] = category;
subscriber.next(category); resolve(category);
} else { } else {
subscriber.error('No category found for given id'); reject('No category found for given id');
} }
subscriber.complete();
}); });
} }
deleteCategory(id: string): Observable<void> { deleteCategory(id: string): Promise<void> {
return new Observable(subscriber => { return new Promise((resolve, reject) => {
const category = this.findById(this.categories, id); const category = this.findById(this.categories, id);
if (category) { if (category) {
const index = this.categories.indexOf(category); const index = this.categories.indexOf(category);
delete this.transactions[index]; delete this.transactions[index];
subscriber.complete(); resolve();
} else { } else {
subscriber.error('No category found for given id'); reject('No category found for given id');
} }
}); });
} }
// Transactions // Transactions
getTransactions(budgetId?: string, categoryId?: string, count?: number): Observable<Transaction[]> { getTransactions(budgetId?: string, categoryId?: string, count?: number): Promise<Transaction[]> {
return new Observable(subscriber => { return new Promise((resolve, reject) => {
subscriber.next(this.transactions.filter(transaction => { resolve(this.transactions.filter(transaction => {
let include = true; let include = true;
if (budgetId) { if (budgetId) {
include = transaction.budgetId === budgetId; include = transaction.budgetId === budgetId;
@ -234,15 +213,11 @@ export class TwigsLocalService implements TwigsService {
} }
return include; return include;
})); }));
subscriber.complete();
}); });
} }
getTransaction(id: string): Observable<Transaction> { getTransaction(id: string): Promise<Transaction> {
return new Observable(subscriber => { return Promise.resolve(this.findById(this.transactions, id));
subscriber.next(this.findById(this.transactions, id));
subscriber.complete();
});
} }
createTransaction( createTransaction(
@ -254,8 +229,8 @@ export class TwigsLocalService implements TwigsService {
date: Date, date: Date,
isExpense: boolean, isExpense: boolean,
category: string category: string
): Observable<Transaction> { ): Promise<Transaction> {
return new Observable(subscriber => { return new Promise((resolve, reject) => {
const transaction = new Transaction(); const transaction = new Transaction();
transaction.title = name; transaction.title = name;
transaction.description = description; transaction.description = description;
@ -266,13 +241,12 @@ export class TwigsLocalService implements TwigsService {
transaction.budgetId = budgetId; transaction.budgetId = budgetId;
transaction.id = randomId(); transaction.id = randomId();
this.transactions.push(transaction); this.transactions.push(transaction);
subscriber.next(transaction); resolve(transaction);
subscriber.complete();
}); });
} }
updateTransaction(id: string, changes: object): Observable<Transaction> { updateTransaction(id: string, changes: object): Promise<Transaction> {
return new Observable(subscriber => { return new Promise((resolve, reject) => {
const transaction = this.findById(this.transactions, id); const transaction = this.findById(this.transactions, id);
if (transaction) { if (transaction) {
const index = this.transactions.indexOf(transaction); const index = this.transactions.indexOf(transaction);
@ -291,38 +265,71 @@ export class TwigsLocalService implements TwigsService {
] ]
); );
this.transactions[index] = transaction; this.transactions[index] = transaction;
subscriber.next(transaction); resolve(transaction);
} else { } else {
subscriber.error('No transaction found for given id'); reject('No transaction found for given id');
} }
subscriber.complete();
}); });
} }
deleteTransaction(id: string): Observable<void> { deleteTransaction(id: string): Promise<void> {
return new Observable(subscriber => { return new Promise((resolve, reject) => {
const transaction = this.findById(this.transactions, id); const transaction = this.findById(this.transactions, id);
if (transaction) { if (transaction) {
const index = this.transactions.indexOf(transaction); const index = this.transactions.indexOf(transaction);
delete this.transactions[index]; delete this.transactions[index];
subscriber.complete(); resolve();
} else { } else {
subscriber.error('No transaction found for given id'); reject('No transaction found for given id');
} }
}); });
} }
// Recurring Transactions
getRecurringTransactions(
budgetId?: string,
categoryId?: string,
count?: number,
from?: Date,
to?: Date
): Promise<RecurringTransaction[]> {
return Promise.reject("Not yet implemented")
}
getRecurringTransaction(id: string): Promise<RecurringTransaction> {
return Promise.reject("Not yet implemented")
}
createRecurringTransaction(
id: string,
budgetId: string,
name: string,
description: string,
amount: number,
frequency: Frequency,
start: Date,
expense: boolean,
category: string,
end?: Date,
): Promise<RecurringTransaction> {
return Promise.reject("Not yet implemented")
}
updateRecurringTransaction(id: string, transaction: RecurringTransaction): Promise<RecurringTransaction> {
return Promise.reject("Not yet implemented")
}
deleteRecurringTransaction(id: string): Promise<void> {
return Promise.reject("Not yet implemented")
}
// Users // Users
getProfile(id: string): Observable<User> { getProfile(id: string): Promise<User> {
return new Observable(subscriber => { return Promise.reject("Not yet implemented");
subscriber.error("Not yet implemented")
});
} }
getUsersByUsername(username: string): Observable<User[]> { getUsersByUsername(username: string): Promise<User[]> {
return new Observable(subscriber => { return Promise.resolve(this.users.filter(user => user.username.indexOf(username) > -1))
subscriber.next(this.users.filter(user => user.username.indexOf(username) > -1 ));
});
} }
private updateValues(old: object, changes: object, keys: string[]) { private updateValues(old: object, changes: object, keys: string[]) {

View file

@ -1,36 +1,36 @@
import { InjectionToken } from '@angular/core'; import { InjectionToken } from '@angular/core';
import { Observable } from 'rxjs';
import { User, UserPermission } from '../users/user'; import { User, UserPermission } from '../users/user';
import { Budget } from '../budgets/budget'; import { Budget } from '../budgets/budget';
import { Category } from '../categories/category'; import { Category } from '../categories/category';
import { RecurringTransaction, Frequency } from '../recurringtransactions/recurringtransaction';
import { Transaction } from '../transactions/transaction'; import { Transaction } from '../transactions/transaction';
export interface TwigsService { export interface TwigsService {
// Auth // Auth
login(email: string, password: string): Observable<User>; login(email: string, password: string): Promise<User>;
register(username: string, email: string, password: string): Observable<User>; register(username: string, email: string, password: string): Promise<User>;
logout(): Observable<void>; logout(): Promise<void>;
// Budgets // Budgets
getBudgets(): Observable<Budget[]>; getBudgets(): Promise<Budget[]>;
getBudget(id: string): Observable<Budget>; getBudget(id: string): Promise<Budget>;
getBudgetBalance(id: string, from?: Date, to?: Date): Observable<number>; getBudgetBalance(id: string, from?: Date, to?: Date): Promise<number>;
createBudget( createBudget(
id: string, id: string,
name: string, name: string,
description: string, description: string,
users: UserPermission[], users: UserPermission[],
): Observable<Budget>; ): Promise<Budget>;
updateBudget(id: string, changes: object): Observable<Budget>; updateBudget(id: string, budget: Budget): Promise<Budget>;
deleteBudget(id: string): Observable<void>; deleteBudget(id: string): Promise<void>;
// Categories // Categories
getCategories(budgetId?: string, count?: number): Observable<Category[]>; getCategories(budgetId?: string, count?: number): Promise<Category[]>;
getCategory(id: string): Observable<Category>; getCategory(id: string): Promise<Category>;
getCategoryBalance(id: string, from?: Date, to?: Date): Observable<number>; getCategoryBalance(id: string, from?: Date, to?: Date): Promise<number>;
createCategory(id: string, budgetId: string, name: string, description: string, amount: number, isExpense: boolean): Observable<Category>; createCategory(id: string, budgetId: string, name: string, description: string, amount: number, isExpense: boolean): Promise<Category>;
updateCategory(id: string, changes: object): Observable<Category>; updateCategory(id: string, category: Category): Promise<Category>;
deleteCategory(id: string): Observable<void>; deleteCategory(id: string): Promise<void>;
// Transactions // Transactions
getTransactions( getTransactions(
@ -39,8 +39,8 @@ export interface TwigsService {
count?: number, count?: number,
from?: Date, from?: Date,
to?: Date to?: Date
): Observable<Transaction[]>; ): Promise<Transaction[]>;
getTransaction(id: string): Observable<Transaction>; getTransaction(id: string): Promise<Transaction>;
createTransaction( createTransaction(
id: string, id: string,
budgetId: string, budgetId: string,
@ -50,12 +50,36 @@ export interface TwigsService {
date: Date, date: Date,
isExpense: boolean, isExpense: boolean,
category: string category: string
): Observable<Transaction>; ): Promise<Transaction>;
updateTransaction(id: string, changes: object): Observable<Transaction>; updateTransaction(id: string, transaction: Transaction): Promise<Transaction>;
deleteTransaction(id: string): Observable<void>; deleteTransaction(id: string): Promise<void>;
getProfile(id: string): Observable<User>; // Recurring Transactions
getUsersByUsername(username: string): Observable<User[]>; getRecurringTransactions(
budgetId?: string,
categoryId?: string,
count?: number,
from?: Date,
to?: Date
): Promise<RecurringTransaction[]>;
getRecurringTransaction(id: string): Promise<RecurringTransaction>;
createRecurringTransaction(
id: string,
budgetId: string,
name: string,
description: string,
amount: number,
frequency: Frequency,
start: Date,
expense: boolean,
category: string,
end?: Date,
): Promise<RecurringTransaction>;
updateRecurringTransaction(id: string, transaction: RecurringTransaction): Promise<RecurringTransaction>;
deleteRecurringTransaction(id: string): Promise<void>;
getProfile(id: string): Promise<User>;
getUsersByUsername(username: string): Promise<User[]>;
} }
export let TWIGS_SERVICE = new InjectionToken<TwigsService>('twigs.service'); export let TWIGS_SERVICE = new InjectionToken<TwigsService>('twigs.service');

View file

@ -5,3 +5,11 @@ export function randomId(): string {
window.crypto.getRandomValues(bytes) window.crypto.getRandomValues(bytes)
return Array.from(bytes, (byte) => CHARACTERS[byte % CHARACTERS.length]).join('') return Array.from(bytes, (byte) => CHARACTERS[byte % CHARACTERS.length]).join('')
} }
export function decimalToInteger(amount: string): number {
if (amount[amount.length - 3] === "." || amount[amount.length - 3] === ",") {
return Number(amount.replace(/[,.]/g, ""))
} else {
return Number(amount + "00")
}
}

View file

@ -3,13 +3,13 @@
</div> </div>
<div [hidden]="!currentTransaction" *ngIf="currentTransaction" class="form transaction-form"> <div [hidden]="!currentTransaction" *ngIf="currentTransaction" class="form transaction-form">
<mat-form-field> <mat-form-field>
<input matInput [(ngModel)]="currentTransaction.title" placeholder="Name" required> <input matInput [(ngModel)]="currentTransaction.title" placeholder="Name" required autocapitalize="words">
</mat-form-field> </mat-form-field>
<mat-form-field> <mat-form-field>
<textarea matInput [(ngModel)]="currentTransaction.description" placeholder="Description"></textarea> <textarea matInput [(ngModel)]="currentTransaction.description" placeholder="Description" autocapitalize="sentences"></textarea>
</mat-form-field> </mat-form-field>
<mat-form-field> <mat-form-field>
<input matInput type="text" [(ngModel)]="currentTransaction.amount" placeholder="Amount" required> <input matInput type="number" [(ngModel)]="currentTransaction.amount" placeholder="Amount" required step="0.01">
</mat-form-field> </mat-form-field>
<mat-form-field> <mat-form-field>
<input matInput type="date" [ngModel]="transactionDate | date:'yyyy-MM-dd'" <input matInput type="date" [ngModel]="transactionDate | date:'yyyy-MM-dd'"

View file

@ -4,7 +4,8 @@ import { TransactionType } from '../transaction.type';
import { Category } from 'src/app/categories/category'; import { Category } from 'src/app/categories/category';
import { AppComponent } from 'src/app/app.component'; import { AppComponent } from 'src/app/app.component';
import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service'; import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service';
import { MatRadioChange } from '@angular/material/radio'; import { MatLegacyRadioChange as MatRadioChange } from '@angular/material/legacy-radio';
import { decimalToInteger } from 'src/app/shared/utils';
@Component({ @Component({
selector: 'app-add-edit-transaction', selector: 'app-add-edit-transaction',
@ -18,7 +19,6 @@ export class AddEditTransactionComponent implements OnInit, OnChanges {
@Input() create: boolean @Input() create: boolean
public transactionType = TransactionType; public transactionType = TransactionType;
public categories: Category[]; public categories: Category[];
public rawAmount: string;
public currentTime: string; public currentTime: string;
public transactionDate: string; public transactionDate: string;
@ -55,15 +55,14 @@ export class AddEditTransactionComponent implements OnInit, OnChanges {
updateCategories(change: MatRadioChange) { updateCategories(change: MatRadioChange) {
this.twigsService.getCategories(this.budgetId) this.twigsService.getCategories(this.budgetId)
.subscribe(newCategories => { .then(newCategories => {
this.categories = newCategories.filter(category => category.expense === change.value) this.categories = newCategories.filter(category => category.expense === change.value)
}) })
} }
save(): void { save(): void {
// The amount will be input as a decimal value so we need to convert it let promise;
// to an integer this.currentTransaction.amount = decimalToInteger(String(this.currentTransaction.amount))
let observable;
this.currentTransaction.date = new Date(); this.currentTransaction.date = new Date();
const dateParts = this.transactionDate.split('-'); const dateParts = this.transactionDate.split('-');
this.currentTransaction.date.setFullYear(parseInt(dateParts[0], 10)); this.currentTransaction.date.setFullYear(parseInt(dateParts[0], 10));
@ -74,38 +73,34 @@ export class AddEditTransactionComponent implements OnInit, OnChanges {
this.currentTransaction.date.setMinutes(parseInt(timeParts[1], 10)); this.currentTransaction.date.setMinutes(parseInt(timeParts[1], 10));
if (this.create) { if (this.create) {
// This is a new transaction, save it // This is a new transaction, save it
observable = this.twigsService.createTransaction( promise = this.twigsService.createTransaction(
this.currentTransaction.id, this.currentTransaction.id,
this.budgetId, this.budgetId,
this.currentTransaction.title, this.currentTransaction.title,
this.currentTransaction.description, this.currentTransaction.description,
Math.round(this.currentTransaction.amount * 100), this.currentTransaction.amount,
this.currentTransaction.date, this.currentTransaction.date,
this.currentTransaction.expense, this.currentTransaction.expense,
this.currentTransaction.categoryId, this.currentTransaction.categoryId,
); );
} else { } else {
// This is an existing transaction, update it // This is an existing transaction, update it
observable = this.twigsService.updateTransaction( const updatedTransaction: Transaction = {
this.currentTransaction.id, ...this.currentTransaction,
{
title: this.currentTransaction.title,
description: this.currentTransaction.description,
amount: Math.round(this.currentTransaction.amount * 100),
date: this.currentTransaction.date,
categoryId: this.currentTransaction.categoryId,
expense: this.currentTransaction.expense
} }
promise = this.twigsService.updateTransaction(
this.currentTransaction.id,
updatedTransaction
); );
} }
observable.subscribe(val => { promise.then(() => {
this.app.goBack(); this.app.goBack();
}); });
} }
delete(): void { delete(): void {
this.twigsService.deleteTransaction(this.currentTransaction.id).subscribe(() => { this.twigsService.deleteTransaction(this.currentTransaction.id).then(() => {
this.app.goBack(); this.app.goBack();
}); });
} }

View file

@ -25,7 +25,7 @@ export class TransactionDetailsComponent implements OnInit {
getTransaction(): void { getTransaction(): void {
const id = this.route.snapshot.paramMap.get('id'); const id = this.route.snapshot.paramMap.get('id');
this.twigsService.getTransaction(id) this.twigsService.getTransaction(id)
.subscribe(transaction => { .then(transaction => {
transaction.amount /= 100; transaction.amount /= 100;
this.transaction = transaction; this.transaction = transaction;
this.budgetId = transaction.budgetId; this.budgetId = transaction.budgetId;

View file

@ -44,7 +44,7 @@ export class TransactionListComponent implements OnInit {
} }
let toStr = this.route.snapshot.queryParamMap.get('to'); let toStr = this.route.snapshot.queryParamMap.get('to');
var to; let to: Date;
if (toStr) { if (toStr) {
let toDate = new Date(toStr); let toDate = new Date(toStr);
if (!isNaN(toDate.getTime())) { if (!isNaN(toDate.getTime())) {
@ -52,7 +52,8 @@ export class TransactionListComponent implements OnInit {
} }
} }
this.twigsService.getTransactions(this.budgetIds.join(','), this.categoryIds?.join(','), null, from, to).subscribe(transactions => { this.twigsService.getTransactions(this.budgetIds.join(','), this.categoryIds?.join(','), null, from, to)
.then(transactions => {
this.transactions = transactions; this.transactions = transactions;
}); });
} }

View file

@ -32,13 +32,13 @@ export class LoginComponent implements OnInit {
login(): void { login(): void {
this.isLoading = true; this.isLoading = true;
this.twigsService.login(this.email, this.password) this.twigsService.login(this.email, this.password)
.subscribe(user => { .then(user => {
this.app.user.next(user); this.app.user.next(user);
this.router.navigate([this.redirect || '/']) this.router.navigate([this.redirect || '/'])
}, })
error => { .catch(error => {
console.error(error) console.error(error)
//TODO: Replace this with an in-app dialog // TODO: Replace this with an in-app dialog
alert("Login failed. Please verify you have the correct credentials"); alert("Login failed. Please verify you have the correct credentials");
this.isLoading = false; this.isLoading = false;
}) })

View file

@ -33,10 +33,10 @@ export class RegisterComponent implements OnInit {
return; return;
} }
this.isLoading = true; this.isLoading = true;
this.twigsService.register(this.username, this.email, this.password).subscribe(user => { this.twigsService.register(this.username, this.email, this.password).then(user => {
console.log(user); console.log(user);
this.router.navigate(['/']) this.router.navigate(['/'])
}, error => { }).catch(error => {
console.error(error); console.error(error);
alert("Registration failed!") alert("Registration failed!")
this.isLoading = false; this.isLoading = false;

View file

@ -29,8 +29,8 @@ export class UserPermission {
} }
export enum Permission { export enum Permission {
READ, READ = "READ",
WRITE, WRITE = "WRITE",
MANAGE, MANAGE = "MANAGE",
OWNER OWNER = "OWNER"
} }

View file

@ -4,7 +4,16 @@
// Include the common styles for Angular Material. We include this here so that you only // Include the common styles for Angular Material. We include this here so that you only
// have to load a single css file for Angular Material in your app. // have to load a single css file for Angular Material in your app.
// Be sure that you only ever include this mixin once! // Be sure that you only ever include this mixin once!
@include mat.core(); // TODO(v15): As of v15 mat.legacy-core no longer includes default typography styles.
// The following line adds:
// 1. Default typography styles for all components
// 2. Styles for typography hierarchy classes (e.g. .mat-headline-1)
// If you specify typography styles for the components you use elsewhere, you should delete this line.
// If you don't need the default component typographies but still want the hierarchy styles,
// you can delete this line and instead use:
// `@include mat.legacy-typography-hierarchy(mat.define-legacy-typography-config());`
@include mat.all-legacy-component-typographies();
@include mat.legacy-core();
// Define the palettes for your theme using the Material Design palettes available in palette.scss // Define the palettes for your theme using the Material Design palettes available in palette.scss
// (imported above). For each palette, you can optionally specify a default, lighter, and darker // (imported above). For each palette, you can optionally specify a default, lighter, and darker
@ -23,8 +32,8 @@ $budget-dark-theme: mat.define-dark-theme($budget-app-primary, $budget-app-accen
// Alternatively, you can import and @include the theme mixins for each component // Alternatively, you can import and @include the theme mixins for each component
// that you are using. // that you are using.
@include mat.all-component-themes($budget-app-theme); @include mat.all-legacy-component-themes($budget-app-theme);
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@include mat.all-component-themes($budget-dark-theme); @include mat.all-legacy-component-themes($budget-dark-theme);
} }

View file

@ -7,8 +7,6 @@ import {
platformBrowserDynamicTesting platformBrowserDynamicTesting
} from '@angular/platform-browser-dynamic/testing'; } from '@angular/platform-browser-dynamic/testing';
declare const require: any;
// First, initialize the Angular testing environment. // First, initialize the Angular testing environment.
getTestBed().initTestEnvironment( getTestBed().initTestEnvironment(
BrowserDynamicTestingModule, BrowserDynamicTestingModule,
@ -16,7 +14,3 @@ getTestBed().initTestEnvironment(
teardown: { destroyAfterEach: false } teardown: { destroyAfterEach: false }
} }
); );
// Then we find all the tests.
const context = require.context('./', true, /\.spec\.ts$/);
// And load the modules.
context.keys().map(context);