Compare commits

...

96 commits

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
bc58d555c9 Enable querying budget and category sums for arbitrary date ranges
This only adds support via query params. Actual UI to use this needs to be built still
2022-07-02 13:06:59 -06:00
24c74a2dee delete vscode settings file 2022-07-01 18:15:10 -06:00
9de3a6fd76
Merge pull request #4 from wbrawner/dependabot/npm_and_yarn/minimist-1.2.6
Bump minimist from 1.2.5 to 1.2.6
2022-07-01 18:12:34 -06:00
16c9657b80 Fix npm security vulnerabilities 2022-07-01 18:11:59 -06:00
84cda20738 Fix compilation issues from dependency updates 2022-07-01 18:05:54 -06:00
66e5384fe9 Update angular material 2022-07-01 17:43:55 -06:00
f6178c8848 Update angular 2022-07-01 17:43:27 -06:00
4639fa3584 Update dependencies 2022-07-01 17:42:11 -06:00
dependabot[bot]
8c27aef40c
Bump minimist from 1.2.5 to 1.2.6
Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-31 10:27:50 +00:00
b6dfaef44b Update dependencies 2022-01-19 19:48:11 -07:00
d2f4d15bb9 Fix firebase deployments 2021-11-24 16:34:17 -07:00
cb3bce833b Fix firebase deployments 2021-11-24 16:30:54 -07:00
b6af459d44 Update API url in production environment 2021-11-24 16:27:04 -07:00
240833e8d6 Remove codeserver environment 2021-11-24 16:26:49 -07:00
7f731a627f Fix build issue 2021-11-24 16:26:34 -07:00
b752d5f708 Add firebase deployment workflow 2021-11-24 16:26:28 -07:00
a959736237 Update other dependencies and add update script 2021-11-19 17:25:47 -07:00
a3468c7781 Update Angular Material to 13 2021-11-19 17:16:17 -07:00
a31e921375 Update Angular to 13 2021-11-19 17:15:22 -07:00
27b5e80a2b Remove ng2-currency-mask
This was a cool feature but my wife didn't like it and it's been a huge
headache every time I need to update my angular versions so it's time
for it to go
2021-11-17 20:51:29 -07:00
037ade50c5 Update publish and package commands to use new syntax 2021-11-17 18:00:03 -07:00
072d2c1ae9 Use node:lts for docker images 2021-11-17 18:00:03 -07:00
ccf1acd21e Fix decimal multiplication weirdness by rounding
There's probably a better way to go about handling this but for now this
is good enough.
2021-11-17 18:00:03 -07:00
9a274591ac Fixes for new Ktor API 2021-08-16 17:22:58 -06:00
3e70e402ea Fix npm scripts for use on Windows 2021-07-08 18:36:10 -06:00
193faeb800 Fix transaction title updating
Signed-off-by: William Brawner <me@wbrawner.com>
2021-06-20 13:53:07 -06:00
a4925ee783 Update ng2-currency-mask 2021-06-11 11:12:09 -06:00
e836d306b8 Show transaction description in list 2021-02-28 16:44:48 -07:00
fd112cc096 Update README and add LICENSE 2021-02-27 06:51:13 -07:00
c1455df969 Remove explicit repository name for docker builds
Signed-off-by: William Brawner <me@wbrawner.com>
2021-02-25 12:04:15 -07:00
2f4d1e2a92 Remove explicit repository name for docker builds
Signed-off-by: William Brawner <me@wbrawner.com>
2021-02-25 11:45:53 -07:00
9dc85c21fe Tag docker builds with git tags
Signed-off-by: William Brawner <me@wbrawner.com>
2021-02-25 11:35:52 -07:00
9218bde745
Update docker-image.yml 2021-02-20 07:03:27 -07:00
41c7006c45
Create docker-image.yml 2021-02-20 06:59:20 -07:00
c139a3d33a Fix transaction list loading 2021-02-15 21:26:58 -07:00
d2b2c951a7 Change theme-color meta tag according to dark mode setting 2021-02-13 19:34:59 -07:00
a322bd9415 Fix delete button not showing while editing transactions 2021-02-10 11:14:00 -07:00
2c4df90d8d Remove budgets prefix from paths 2021-02-10 11:14:00 -07:00
ce945b8391 Retrieve budget balance from backend 2021-02-05 20:23:06 -07:00
ae14a33616 Use bearer instead of basic auth 2021-01-26 21:00:15 -07:00
84dae70b7f Update dependencies 2021-01-19 20:39:52 -07:00
6060f282b4 Use better random IDs 2021-01-19 20:29:39 -07:00
9622fa47c3 Add date range filtering for transactions
Signed-off-by: William Brawner <me@wbrawner.com>
2021-01-04 19:18:46 -07:00
92f93861e9 Use strings instead of numbers for id and generate client-side 2021-01-01 04:18:56 +00:00
06850c8b8e Reduce client-side load in calculating category balances 2020-12-03 17:29:44 -07:00
852aa1d6c5 Implement in-memory caching for budgets 2020-12-03 16:58:08 -07:00
271bd20707 Fix budget deletion not working 2020-12-03 16:54:22 -07:00
2a7fa456e4 Allow HTML in category descriptions 2020-10-08 02:54:47 +00:00
69ecb9334e Add descriptions to categories 2020-10-08 02:48:10 +00:00
2c48722dec Fix some styles and initial routing (again) 2020-10-07 00:03:38 +00:00
ff18e47036 Fix routing on initial load 2020-10-05 19:28:47 +00:00
25dd6e5ccc Implement budget editing
Well, partially at least. I still need to get around to the user management features
2020-10-02 17:16:37 +00:00
30a417ecf9 Fix occurences of ExpressionChangedAfterItHasBeenCheckedError 2020-10-02 14:57:06 +00:00
90b886cce5 Implement light theme
I also went ahead and fixed some buttonstyles that were inconsistent throughout the app
2020-10-02 14:32:26 +00:00
914f9a2a25 Remove firebase and resolve npm audit issues
Signed-off-by: William Brawner <me@wbrawner.com>
2020-09-15 19:29:24 -07:00
103 changed files with 28549 additions and 13359 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:

5
.firebaserc Normal file
View file

@ -0,0 +1,5 @@
{
"projects": {
"default": "budget-c7da5"
}
}

22
.github/workflows/docker-image.yml vendored Normal file
View file

@ -0,0 +1,22 @@
name: Publish Docker image
on:
push:
branches: main
tags:
- '*'
jobs:
push_to_registry:
name: Push Docker image to GitHub Packages
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v2
- name: Push to GitHub Packages
uses: docker/build-push-action@v1
with:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
repository: ${{ github.repository }}/${{ github.event.repository.name }}
registry: docker.pkg.github.com
tag_with_ref: true

View file

@ -0,0 +1,20 @@
# This file was auto-generated by the Firebase CLI
# https://github.com/firebase/firebase-tools
name: Deploy to Firebase Hosting on merge
'on':
push:
branches:
- main
jobs:
build_and_deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- run: npm ci && npm run package
- uses: FirebaseExtended/action-hosting-deploy@v0
with:
repoToken: '${{ secrets.GITHUB_TOKEN }}'
firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_BUDGET_C7DA5 }}'
channelId: live
projectId: budget-c7da5

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

2
.gitignore vendored
View file

@ -25,6 +25,7 @@
!.vscode/extensions.json !.vscode/extensions.json
# misc # misc
/.angular/cache
/.sass-cache /.sass-cache
/connect.lock /connect.lock
/coverage /coverage
@ -42,3 +43,4 @@ Thumbs.db
# Firebase # Firebase
.firebase/ .firebase/
.angular/

7
.vscode/launch.json vendored
View file

@ -4,6 +4,13 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{
"name": "Launch Chrome",
"request": "launch",
"type": "pwa-chrome",
"url": "http://localhost:4200",
"webRoot": "${workspaceFolder}"
},
{ {
"name": "ng serve", "name": "ng serve",
"type": "node", "type": "node",

12
.vscode/settings.json vendored
View file

@ -1,12 +0,0 @@
{
"database.connections": [
{
"type": "mysql",
"name": "root@captain.intra.wbrawner.com (MySql)",
"host": "captain.intra.wbrawner.com:3306",
"username": "root",
"database": null,
"password": "U7YE8YsmES8LHB2B39WXNjTQk4d48LzQEZG3cj6wSb2fgeRLEYtrrqTwiqAhrpR3"
}
]
}

View file

@ -1,4 +1,4 @@
FROM node:latest as builder FROM node:lts as builder
COPY . /app COPY . /app
WORKDIR /app WORKDIR /app
RUN npm install && \ RUN npm install && \

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2021 William Brawner
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View file

@ -1,27 +1,49 @@
# Budget # Twigs Web Client
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 6.1.5. # 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)
## Development 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)
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. ## Building
## Code scaffolding You'll need NodeJS and NPM, then run
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. npm run build
## Build If you would like to tinker with the site and have it hot reload, then run
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build. npm run start
## Running unit tests The app will be available at http://localhost:4200
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). ## Self-hosting
## Running end-to-end tests Eventually the plan is to ship this web app within the JAR for the server, but for now you'll need to run them separately. Before you build the app, be sure to change the `apiUrl` value in [src/environments/environment.prod.ts](src/environments/environment.prod.ts). Then you can run the following command to get an optimized version of the build for production deployments
Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/). npm run package
## Further help This will output the app in a folder called `dist/twigs`, which you can then serve directly with Apache or Nginx or any static file server.
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md). ## License
```
Copyright (c) 2021 William Brawner
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
```

View file

@ -13,7 +13,6 @@
"build": { "build": {
"builder": "@angular-devkit/build-angular:browser", "builder": "@angular-devkit/build-angular:browser",
"options": { "options": {
"aot": true,
"outputPath": "dist/twigs", "outputPath": "dist/twigs",
"index": "src/index.html", "index": "src/index.html",
"main": "src/main.ts", "main": "src/main.ts",
@ -32,7 +31,13 @@
"src/styles.css", "src/styles.css",
"src/styles.scss" "src/styles.scss"
], ],
"scripts": [] "scripts": [],
"vendorChunk": true,
"extractLicenses": false,
"buildOptimizer": false,
"sourceMap": true,
"optimization": false,
"namedChunks": true
}, },
"configurations": { "configurations": {
"production": { "production": {
@ -51,9 +56,7 @@
"optimization": true, "optimization": true,
"outputHashing": "all", "outputHashing": "all",
"sourceMap": false, "sourceMap": false,
"extractCss": true,
"namedChunks": false, "namedChunks": false,
"aot": true,
"extractLicenses": true, "extractLicenses": true,
"vendorChunk": false, "vendorChunk": false,
"buildOptimizer": true, "buildOptimizer": true,
@ -73,26 +76,27 @@
} }
] ]
} }
} },
"defaultConfiguration": ""
}, },
"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": {
@ -112,18 +116,6 @@
"src/manifest.json" "src/manifest.json"
] ]
} }
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"src/tsconfig.app.json",
"src/tsconfig.spec.json"
],
"exclude": [
"**/node_modules/**"
]
}
} }
} }
}, },
@ -142,20 +134,10 @@
"devServerTarget": "twigs:serve:production" "devServerTarget": "twigs:serve:production"
} }
} }
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": "e2e/tsconfig.e2e.json",
"exclude": [
"**/node_modules/**"
]
}
} }
} }
} }
}, },
"defaultProject": "twigs",
"cli": { "cli": {
"analytics": "b8304464-255e-47bb-976a-7ed81af63238" "analytics": "b8304464-255e-47bb-976a-7ed81af63238"
} }

16
firebase.json Normal file
View file

@ -0,0 +1,16 @@
{
"hosting": {
"public": "dist/twigs",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
]
}
}

38859
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -2,61 +2,57 @@
"name": "budget", "name": "budget",
"version": "0.0.0", "version": "0.0.0",
"scripts": { "scripts": {
"ng": "node_modules/@angular/cli/bin/ng", "ng": "ng",
"start": "node_modules/@angular/cli/bin/ng serve --host '0.0.0.0'", "start": "ng serve --configuration=production --host '0.0.0.0'",
"code-server": "node_modules/@angular/cli/bin/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": "node_modules/@angular/cli/bin/ng build", "build": "ng build",
"package": "node_modules/@angular/cli/bin/ng build --prod --service-worker", "package": "ng build --configuration=production --service-worker",
"publish": "node_modules/@angular/cli/bin/ng build --prod --service-worker && firebase deploy", "publish": "ng build --configuration=production --service-worker && firebase deploy",
"test": "node_modules/@angular/cli/bin/ng test", "test": "ng test",
"lint": "node_modules/@angular/cli/bin/ng lint", "lint": "ng lint",
"e2e": "node_modules/@angular/cli/bin/ng e2e" "e2e": "ng e2e",
"update": "ncu -u"
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "^10.0.1", "@angular/animations": "^17.2.3",
"@angular/cdk": "^10.0.1", "@angular/cdk": "^17.2.1",
"@angular/common": "^10.0.1", "@angular/common": "^17.2.3",
"@angular/compiler": "^10.0.1", "@angular/compiler": "^17.2.3",
"@angular/core": "^10.0.1", "@angular/core": "^17.2.3",
"@angular/forms": "^10.0.1", "@angular/forms": "^17.2.3",
"@angular/material": "^10.0.1", "@angular/material": "^16.2.0",
"@angular/platform-browser": "^10.0.1", "@angular/platform-browser": "^17.2.3",
"@angular/platform-browser-dynamic": "^10.0.1", "@angular/platform-browser-dynamic": "^17.2.3",
"@angular/router": "^10.0.1", "@angular/router": "^17.2.3",
"@angular/service-worker": "^10.0.1", "@angular/service-worker": "^17.2.3",
"chart.js": "^2.9.3", "chart.js": "^3.7.0",
"core-js": "^2.6.11", "core-js": "^3.20.3",
"dexie": "^2.0.4", "ng2-charts": "^3.0.8",
"firebase": "^7.15.5", "rxjs": "^7.5.2",
"ng2-charts": "^2.3.2", "tslib": "^2.3.1",
"ng2-currency-mask": "^9.0.2", "zone.js": "~0.14.4"
"ngx-cookie-service": "^2.4.0",
"rxjs": "^6.5.4",
"tslib": "^2.0.0",
"zone.js": "~0.10.3"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "~0.1000.0", "@angular-devkit/build-angular": "^17.2.2",
"@angular/cli": "^10.0.0", "@angular/cli": "^17.2.2",
"@angular/compiler-cli": "^10.0.1", "@angular/compiler-cli": "^17.2.3",
"@angular/language-service": "^10.0.1", "@angular/language-service": "^17.2.3",
"@types/jasmine": "^3.5.2", "@types/jasmine": "~3.10.3",
"@types/jasminewd2": "^2.0.8", "@types/jasminewd2": "^2.0.10",
"@types/node": "^12.11.1", "@types/node": "^17.0.10",
"codelyzer": "^5.1.2", "eslint": "^8.7.0",
"eslint": "^6.8.0", "jasmine-core": "~4.0.0",
"firebase-tools": "^7.12.1", "jasmine-spec-reporter": "~7.0.0",
"jasmine-core": "~3.5.0", "karma": "~6.3.11",
"jasmine-spec-reporter": "~5.0.0",
"karma": "~5.0.0",
"karma-chrome-launcher": "~3.1.0", "karma-chrome-launcher": "~3.1.0",
"karma-coverage-istanbul-reporter": "~3.0.2", "karma-coverage-istanbul-reporter": "~3.0.3",
"karma-jasmine": "~3.3.0", "karma-jasmine": "~4.0.1",
"karma-jasmine-html-reporter": "^1.5.0", "karma-jasmine-html-reporter": "^1.7.0",
"protractor": "~7.0.0", "npm-check-updates": "^15.0.1",
"ts-node": "~8.6.2", "protractor": "^7.0.0",
"tslint": "~6.1.0", "ts-node": "~10.4.0",
"typescript": "3.9.5" "tslint": "^6.1.3",
"typescript": "5.3.3"
} }
} }

View file

@ -10,6 +10,7 @@ import { LoginComponent } from './users/login/login.component';
import { RegisterComponent } from './users/register/register.component'; import { RegisterComponent } from './users/register/register.component';
import { BudgetsComponent } from './budgets/budget.component'; import { BudgetsComponent } from './budgets/budget.component';
import { NewBudgetComponent } from './budgets/new-budget/new-budget.component'; import { NewBudgetComponent } from './budgets/new-budget/new-budget.component';
import { EditBudgetComponent } from './budgets/edit-budget/edit-budget.component';
import { BudgetDetailsComponent } from './budgets/budget-details/budget-details.component'; import { BudgetDetailsComponent } from './budgets/budget-details/budget-details.component';
import { EditCategoryComponent } from './categories/edit-category/edit-category.component'; import { EditCategoryComponent } from './categories/edit-category/edit-category.component';
@ -20,18 +21,19 @@ const routes: Routes = [
{ path: 'budgets', component: BudgetsComponent }, { path: 'budgets', component: BudgetsComponent },
{ path: 'budgets/new', component: NewBudgetComponent }, { path: 'budgets/new', component: NewBudgetComponent },
{ path: 'budgets/:id', component: BudgetDetailsComponent }, { path: 'budgets/:id', component: BudgetDetailsComponent },
{ path: 'budgets/:budgetId/transactions', component: TransactionsComponent }, { path: 'budgets/:id/edit', component: EditBudgetComponent },
{ path: 'budgets/:budgetId/transactions/new', component: NewTransactionComponent }, { path: 'transactions', component: TransactionsComponent },
{ path: 'budgets/:budgetId/transactions/:id', component: TransactionDetailsComponent }, { path: 'transactions/new', component: NewTransactionComponent },
{ path: 'budgets/:budgetId/categories', component: CategoriesComponent }, { path: 'transactions/:id', component: TransactionDetailsComponent },
{ path: 'budgets/:budgetId/categories/new', component: NewCategoryComponent }, { path: 'categories', component: CategoriesComponent },
{ path: 'budgets/:budgetId/categories/:id', component: CategoryDetailsComponent }, { path: 'categories/new', component: NewCategoryComponent },
{ path: 'budgets/:budgetId/categories/:id/edit', component: EditCategoryComponent }, { path: 'categories/:id', component: CategoryDetailsComponent },
{ path: 'categories/:id/edit', component: EditCategoryComponent },
]; ];
@NgModule({ @NgModule({
imports: [ imports: [
RouterModule.forRoot(routes) RouterModule.forRoot(routes, {})
], ],
exports: [ exports: [
RouterModule RouterModule

View file

@ -1,11 +1,15 @@
mat-toolbar { mat-toolbar {
background-color: #303030; background-color: #fafafa;
box-shadow: none; box-shadow: none;
padding-left: 0.5em; padding-left: 0.5em;
padding-right: 0.5em; padding-right: 0.5em;
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 999999; z-index: 999999;
}
@media (prefers-color-scheme: dark) {
mat-toolbar {
background-color: #303030;
} }
}

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

@ -1,24 +1,24 @@
import { TestBed, async } from '@angular/core/testing'; import { TestBed, waitForAsync } from '@angular/core/testing';
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';
describe('AppComponent', () => { describe('AppComponent', () => {
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ declarations: [
AppComponent AppComponent
], ],
}).compileComponents(); }).compileComponents();
})); }));
it('should create the app', async(() => { it('should create the app', waitForAsync(() => {
const fixture = TestBed.createComponent(AppComponent); const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance; const app = fixture.debugElement.componentInstance;
expect(app).toBeTruthy(); expect(app).toBeTruthy();
})); }));
it(`should have as title 'budget'`, async(() => { it(`should have as title 'budget'`, waitForAsync(() => {
const fixture = TestBed.createComponent(AppComponent); const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance; const app = fixture.debugElement.componentInstance;
expect(app.title).toEqual('budget'); expect(app.title).toEqual('budget');
})); }));
it('should render title in a h1 tag', async(() => { it('should render title in a h1 tag', waitForAsync(() => {
const fixture = TestBed.createComponent(AppComponent); const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges(); fixture.detectChanges();
const compiled = fixture.debugElement.nativeElement; const compiled = fixture.debugElement.nativeElement;

View file

@ -1,8 +1,7 @@
import { Component, Inject, ApplicationRef } from '@angular/core'; import { Component, Inject, ApplicationRef, ChangeDetectorRef, OnInit } from '@angular/core';
import { Location } from '@angular/common'; import { DOCUMENT, Location } from '@angular/common';
import { User } from './users/user'; import { User } from './users/user';
import { TWIGS_SERVICE, TwigsService } from './shared/twigs.service'; import { TWIGS_SERVICE, TwigsService } from './shared/twigs.service';
import { CookieService } from 'ngx-cookie-service';
import { SwUpdate } from '@angular/service-worker'; import { SwUpdate } from '@angular/service-worker';
import { first, filter, map } from 'rxjs/operators'; import { first, filter, map } from 'rxjs/operators';
import { interval, concat, BehaviorSubject } from 'rxjs'; import { interval, concat, BehaviorSubject } from 'rxjs';
@ -14,7 +13,7 @@ import { Actionable, isActionable } from './shared/actionable';
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrls: ['./app.component.css'] styleUrls: ['./app.component.css']
}) })
export class AppComponent { export class AppComponent implements OnInit {
public title = 'Twigs'; public title = 'Twigs';
public backEnabled = false; public backEnabled = false;
public user = new BehaviorSubject<User>(null); public user = new BehaviorSubject<User>(null);
@ -26,38 +25,58 @@ export class AppComponent {
constructor( constructor(
@Inject(TWIGS_SERVICE) private twigsService: TwigsService, @Inject(TWIGS_SERVICE) private twigsService: TwigsService,
private location: Location, private location: Location,
private cookieService: CookieService,
private router: Router, private router: Router,
private activatedRoute: ActivatedRoute,
private appRef: ApplicationRef, private appRef: ApplicationRef,
private updates: SwUpdate, private updates: SwUpdate,
) { private changeDetector: ChangeDetectorRef,
if (this.cookieService.check('Authorization')) { private storage: Storage,
this.twigsService.getProfile().subscribe(user => { @Inject(DOCUMENT) private document: Document
this.user.next(user); ) { }
if (this.activatedRoute.pathFromRoot.length == 0) {
this.router.navigateByUrl("/budgets") ngOnInit(): void {
const unauthenticatedRoutes = [
'',
'/',
'/login',
'/register'
]
let auth = this.storage.getItem('Authorization');
let userId = this.storage.getItem('userId');
let savedUser = JSON.parse(this.storage.getItem('user')) as User;
if (auth && auth.length == 255 && userId) {
if (savedUser) {
this.user.next(savedUser);
}
this.twigsService.getProfile(userId).then(fetchedUser => {
this.storage.setItem('user', JSON.stringify(fetchedUser));
this.user.next(fetchedUser);
if (unauthenticatedRoutes.indexOf(this.location.path()) != -1) {
//TODO: Save last opened budget and redirect to there instead of the main list
this.router.navigateByUrl("/budgets");
} }
}); });
} else { } else if (unauthenticatedRoutes.indexOf(this.location.path()) == -1) {
this.router.navigateByUrl("/login") this.router.navigateByUrl(`/login?redirect=${this.location.path()}`);
} }
updates.available.subscribe(event => { this.updates.versionUpdates.subscribe(
console.log('current version is', event.current); event => {
console.log('available version is', event.available); if (event.type == "VERSION_READY") {
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
updates.activateUpdate(); this.updates.activateUpdate();
}); }
updates.activated.subscribe(event => { },
console.log('old version was', event.previous); err => {
console.log('new version is', event.current);
});
const appIsStable$ = appRef.isStable.pipe(first(isStable => isStable === true)); }
);
const appIsStable$ = this.appRef.isStable.pipe(first(isStable => isStable === true));
const everySixHours$ = interval(6 * 60 * 60 * 1000); const everySixHours$ = interval(6 * 60 * 60 * 1000);
const everySixHoursOnceAppIsStable$ = concat(appIsStable$, everySixHours$); const everySixHoursOnceAppIsStable$ = concat(appIsStable$, everySixHours$);
everySixHoursOnceAppIsStable$.subscribe(() => updates.checkForUpdate()); everySixHoursOnceAppIsStable$.subscribe(() => this.updates.checkForUpdate());
this.user.subscribe( this.user.subscribe(
user => { user => {
if (user) { if (user) {
@ -67,6 +86,9 @@ export class AppComponent {
} }
} }
) )
const darkMode = window.matchMedia('(prefers-color-scheme: dark)');
this.handleDarkModeChanges(darkMode);
darkMode.addEventListener('change', (e => this.handleDarkModeChanges(e)))
} }
getUsername(): String { getUsername(): String {
@ -78,8 +100,35 @@ export class AppComponent {
} }
logout(): void { logout(): void {
this.twigsService.logout().subscribe(_ => { this.twigsService.logout().then(_ => {
this.location.go('/'); this.location.go('/');
window.location.reload();
}); });
} }
setActionable(actionable: Actionable): void {
this.actionable = actionable;
this.changeDetector.detectChanges();
}
setBackEnabled(enabled: boolean): void {
this.backEnabled = enabled;
this.changeDetector.detectChanges();
}
setTitle(title: string) {
this.title = title;
this.changeDetector.detectChanges();
}
handleDarkModeChanges(darkMode: any) {
const themeColor = this.document.getElementsByName('theme-color')[0] as HTMLMetaElement;
let themeColorValue: string;
if (darkMode.matches) {
themeColorValue = '#333333';
} else {
themeColorValue = '#F1F1F1';
}
themeColor.content = themeColorValue;
}
} }

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';
@ -38,27 +38,16 @@ import { NewBudgetComponent } from './budgets/new-budget/new-budget.component';
import { BudgetDetailsComponent } from './budgets/budget-details/budget-details.component'; import { BudgetDetailsComponent } from './budgets/budget-details/budget-details.component';
import { environment } from 'src/environments/environment'; import { environment } from 'src/environments/environment';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { CurrencyMaskModule, CurrencyMaskConfig, CURRENCY_MASK_CONFIG } from 'ng2-currency-mask';
import { ServiceWorkerModule } from '@angular/service-worker'; import { ServiceWorkerModule } from '@angular/service-worker';
import { CategoryBreakdownComponent } from './categories/category-breakdown/category-breakdown.component'; import { CategoryBreakdownComponent } from './categories/category-breakdown/category-breakdown.component';
import { ChartsModule } from 'ng2-charts'; import { NgChartsModule } from 'ng2-charts';
import { TWIGS_SERVICE } from './shared/twigs.service'; import { TWIGS_SERVICE } from './shared/twigs.service';
import { AuthInterceptor } from './shared/auth.interceptor'; import { AuthInterceptor } from './shared/auth.interceptor';
import { TwigsHttpService } from './shared/twigs.http.service'; import { TwigsHttpService } from './shared/twigs.http.service';
import { TwigsLocalService } from './shared/twigs.local.service'; import { TwigsLocalService } from './shared/twigs.local.service';
import { CookieService } from 'ngx-cookie-service';
import { TransactionListComponent } from './transactions/transaction-list/transaction-list.component'; import { TransactionListComponent } from './transactions/transaction-list/transaction-list.component';
import { EditCategoryComponent } from './categories/edit-category/edit-category.component'; import { EditCategoryComponent } from './categories/edit-category/edit-category.component';
import { EditBudgetComponent } from './budgets/edit-budget/edit-budget.component';
export const CustomCurrencyMaskConfig: CurrencyMaskConfig = {
align: 'left',
precision: 2,
prefix: '',
thousands: ',',
decimal: '.',
suffix: '',
allowNegative: false,
};
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -83,6 +72,7 @@ export const CustomCurrencyMaskConfig: CurrencyMaskConfig = {
CategoryBreakdownComponent, CategoryBreakdownComponent,
TransactionListComponent, TransactionListComponent,
EditCategoryComponent, EditCategoryComponent,
EditBudgetComponent,
], ],
imports: [ imports: [
BrowserModule, BrowserModule,
@ -103,17 +93,15 @@ export const CustomCurrencyMaskConfig: CurrencyMaskConfig = {
FormsModule, FormsModule,
ServiceWorkerModule.register('ngsw-worker.js', { enabled: environment.production }), ServiceWorkerModule.register('ngsw-worker.js', { enabled: environment.production }),
HttpClientModule, HttpClientModule,
CurrencyMaskModule, NgChartsModule,
ChartsModule,
MatCheckboxModule, MatCheckboxModule,
MatCardModule, MatCardModule,
], ],
providers: [ providers: [
{ provide: CURRENCY_MASK_CONFIG, useValue: CustomCurrencyMaskConfig },
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }, { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
{ provide: TWIGS_SERVICE, useClass: TwigsHttpService }, { provide: TWIGS_SERVICE, useClass: TwigsHttpService },
{ provide: Storage, useValue: window.localStorage },
// { provide: TWIGS_SERVICE, useClass: TwigsLocalService }, // { provide: TWIGS_SERVICE, useClass: TwigsLocalService },
CookieService
], ],
bootstrap: [AppComponent] bootstrap: [AppComponent]
}) })

View file

@ -4,11 +4,11 @@
</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-button color="accent" (click)="save()">Save</button> <button mat-raised-button color="accent" (click)="save()">Save</button>
<button class="button-delete" mat-button color="warn" *ngIf="budget.id" (click)="delete()">Delete</button> <button class="button-delete" mat-raised-button color="warn" *ngIf="!create" (click)="delete()">Delete</button>
</div> </div>

View file

@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { AddEditBudgetComponent } from './add-edit-budget.component'; import { AddEditBudgetComponent } from './add-edit-budget.component';
@ -6,7 +6,7 @@ describe('AddEditBudgetComponent', () => {
let component: AddEditBudgetComponent; let component: AddEditBudgetComponent;
let fixture: ComponentFixture<AddEditBudgetComponent>; let fixture: ComponentFixture<AddEditBudgetComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ AddEditBudgetComponent ] declarations: [ AddEditBudgetComponent ]
}) })

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',
@ -12,48 +13,53 @@ import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service';
export class AddEditBudgetComponent { export class AddEditBudgetComponent {
@Input() title: string; @Input() title: string;
@Input() budget: Budget; @Input() budget: Budget;
@Input() create: boolean;
public users: UserPermission[]; public users: UserPermission[];
public searchedUsers: User[] = []; public searchedUsers: User[] = [];
public isLoading = false; public isLoading = false;
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.title = this.title; this.app.setTitle(this.title)
this.app.backEnabled = true; this.app.setBackEnabled(true);
this.users = [new UserPermission(this.app.user.value.id, Permission.OWNER)]; this.users = [new UserPermission(this.app.user.value.id, Permission.OWNER)];
} }
save(): void { save(): void {
let observable; let promise: Promise<Budget>;
this.isLoading = true; this.isLoading = true;
if (this.budget.id) { if (this.create) {
// This is an existing transaction, update it // This is a new budget, save it
observable = this.twigsService.updateBudget(this.budget.id, this.budget); promise = this.twigsService.createBudget(
} else { this.budget.id,
// This is a new transaction, save it
observable = this.twigsService.createBudget(
this.budget.name, this.budget.name,
this.budget.description, this.budget.description,
this.users this.users
); );
} else {
// This is an existing budget, update it
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();
}); });
} }
delete(): void { delete(): void {
this.isLoading = true; this.isLoading = true;
this.twigsService.deleteBudget(this.budget.id); this.twigsService.deleteBudget(this.budget.id)
this.app.goBack(); .then(() => {
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

@ -1,13 +1,13 @@
.dashboard { .dashboard {
color: #F1F1F1; color: #333333;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
padding: 1em; padding: 0 1em;
} }
.dashboard > mat-card { .dashboard>mat-card {
background: #212121; background: #FFFFFF;
display: inline-block; display: inline-block;
margin: 1em; margin: 1em;
padding: 1em; padding: 1em;
@ -15,55 +15,97 @@
position: relative; position: relative;
width: 100%; width: 100%;
align-self: flex-start; align-self: flex-start;
} }
.dashboard .dashboard-primary { .dashboard .dashboard-primary {
padding: 5em 1em; padding: 5em 1em;
text-align: center; text-align: center;
} }
.dashboard-primary > * { .dashboard-primary>* {
display: block; display: block;
} }
.dashboard div h2, .dashboard div h3 { .dashboard div h2,
.dashboard div h3 {
margin: 0; margin: 0;
} }
.dashboard p, .dashboard a { .dashboard p,
color: #F1F1F1; .dashboard a {
color: #333333;
text-align: center; text-align: center;
text-decoration: none; text-decoration: none;
} }
.dashboard-primary div { .dashboard-primary div {
bottom: 0.5em; bottom: 0.5em;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
left: 0.5em; left: 0.5em;
right: 0.5em; right: 0.5em;
position: absolute; position: absolute;
} }
.dashboard .no-categories { .dashboard .no-categories {
padding: 1em; padding: 1em;
text-align: center; text-align: center;
} }
.dashboard .no-categories a { .dashboard .no-categories a {
border-color: #333333;
display: inline-block; display: inline-block;
border: 1px dashed #F1F1F1; border: 1px dashed;
padding: 1em; padding: 1em;
} }
.dashboard .no-categories p { .dashboard .no-categories p {
line-height: normal; line-height: normal;
white-space: normal; white-space: normal;
} }
a.view-all { a.view-all {
position: absolute; position: absolute;
right: 0.5em; right: 0.5em;
top: 0.5em; top: 0.5em;
}
@media (min-width: 1160px) {
mat-card {
box-sizing: border-box;
} }
.category-info {
height: 313px;
overflow: auto;
}
}
@media (max-width: 610px) {
.dashboard {
padding: 0;
}
.dashboard>mat-card {
margin: 1em auto;
}
}
@media (prefers-color-scheme: dark) {
.dashboard {
color: #F1F1F1;
}
.dashboard>mat-card {
background: #212121;
}
.dashboard p,
.dashboard a {
color: #F1F1F1;
}
.dashboard .no-categories a {
border-color: #F1F1F1;
}
}

View file

@ -3,19 +3,19 @@
<h2 class="balance"> <h2 class="balance">
Current Balance: <br /> Current Balance: <br />
<span <span
[ngClass]="{'income': getBalance() > 0, 'expense': getBalance() < 0}">{{ getBalance() / 100 | currency }}</span> [ngClass]="{'income': budgetBalance > 0, 'expense': budgetBalance < 0}">{{ budgetBalance / 100 | currency }}</span>
</h2> </h2>
<app-category-breakdown [barChartLabels]="barChartLabels" [barChartData]="barChartData"> <app-category-breakdown [barChartLabels]="barChartLabels" [barChartData]="barChartData">
</app-category-breakdown> </app-category-breakdown>
<div class="transaction-navigation"> <div class="transaction-navigation">
<a mat-button routerLink="/budgets/{{ budget.id }}/transactions" *ngIf="budget">View Transactions</a> <a mat-button routerLink="/transactions" [queryParams]="{budgetIds: budget.id}" *ngIf="budget">View Transactions</a>
</div> </div>
</mat-card> </mat-card>
<mat-card class="dashboard-categories" [hidden]="!budget"> <mat-card class="dashboard-categories" [hidden]="!budget">
<h3 class="categories">Income</h3> <h3 class="categories">Income</h3>
<a mat-button routerLink="/budgets/{{ budget.id }}/categories/new" class="view-all" *ngIf="budget">Add Category</a> <a mat-button routerLink="/categories/new" [queryParams]="{budgetId: budget.id}" class="view-all" *ngIf="budget">Add Category</a>
<div class="no-categories" *ngIf="!income || income.length === 0"> <div class="no-categories" *ngIf="!income || income.length === 0">
<a mat-button routerLink="/budgets/{{ budget.id }}/categories/new" *ngIf="budget"> <a mat-button routerLink="/categories/new" [queryParams]="{budgetId: budget.id}" *ngIf="budget">
<mat-icon>add</mat-icon> <mat-icon>add</mat-icon>
<p>Add categories to gain more insights into your income.</p> <p>Add categories to gain more insights into your income.</p>
</a> </a>
@ -27,9 +27,9 @@
</mat-card> </mat-card>
<mat-card class="dashboard-categories" [hidden]="!budget"> <mat-card class="dashboard-categories" [hidden]="!budget">
<h3 class="categories">Expenses</h3> <h3 class="categories">Expenses</h3>
<a mat-button routerLink="/budgets/{{ budget.id }}/categories/new" class="view-all" *ngIf="budget">Add Category</a> <a mat-button routerLink="/categories/new" [queryParams]="{budgetId: budget.id, expense: true}" class="view-all" *ngIf="budget">Add Category</a>
<div class="no-categories" *ngIf="!expenses || expenses.length === 0"> <div class="no-categories" *ngIf="!expenses || expenses.length === 0">
<a mat-button routerLink="/budgets/{{ budget.id }}/categories/new" *ngIf="budget"> <a mat-button routerLink="/categories/new" [queryParams]="{budgetId: budget.id, expense: true}" *ngIf="budget">
<mat-icon>add</mat-icon> <mat-icon>add</mat-icon>
<p>Add categories to gain more insights into your expenses.</p> <p>Add categories to gain more insights into your expenses.</p>
</a> </a>
@ -40,6 +40,6 @@
</div> </div>
</mat-card> </mat-card>
</div> </div>
<a mat-fab routerLink="/budgets/{{ budget.id }}/transactions/new" *ngIf="budget"> <a mat-fab routerLink="/transactions/new" [queryParams]="{budgetId: budget.id}" *ngIf="budget">
<mat-icon aria-label="Add">add</mat-icon> <mat-icon aria-label="Add">add</mat-icon>
</a> </a>

View file

@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { BudgetDetailsComponent } from './budget-details.component'; import { BudgetDetailsComponent } from './budget-details.component';
@ -6,7 +6,7 @@ describe('BudgetDetailsComponent', () => {
let component: BudgetDetailsComponent; let component: BudgetDetailsComponent;
let fixture: ComponentFixture<BudgetDetailsComponent>; let fixture: ComponentFixture<BudgetDetailsComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ BudgetDetailsComponent ] declarations: [ BudgetDetailsComponent ]
}) })

View file

@ -1,53 +1,86 @@
import { Component, OnInit, Inject } from '@angular/core'; import { Component, OnInit, Inject, OnDestroy } from '@angular/core';
import { Budget } from '../budget'; import { Budget } from '../budget';
import { ActivatedRoute } from '@angular/router'; 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 { ChartDataset } from 'chart.js';
import { Label } from 'ng2-charts';
import { ChartDataSets } 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';
@Component({ @Component({
selector: 'app-budget-details', selector: 'app-budget-details',
templateUrl: './budget-details.component.html', templateUrl: './budget-details.component.html',
styleUrls: ['./budget-details.component.css'] styleUrls: ['./budget-details.component.css']
}) })
export class BudgetDetailsComponent implements OnInit { export class BudgetDetailsComponent implements OnInit, OnDestroy, Actionable {
budget: Budget; budget: Budget;
public budgetBalance: number;
public transactions: Transaction[]; public transactions: Transaction[];
public expenses: Category[] = []; public expenses: Category[] = [];
public income: Category[] = []; public income: Category[] = [];
categoryBalances: Map<number, number>; categoryBalances: Map<string, number>;
expectedIncome = 0; expectedIncome = 0;
actualIncome = 0; actualIncome = 0;
expectedExpenses = 0; expectedExpenses = 0;
actualExpenses = 0; actualExpenses = 0;
barChartLabels: Label[] = ['Income', 'Expenses']; barChartLabels: string[] = ['Income', 'Expenses'];
barChartData: ChartDataSets[] = [ barChartData: ChartDataset[] = [
{ data: [0, 0], label: 'Expected' }, { data: [0, 0], label: 'Expected' },
{ data: [0, 0], label: 'Actual' }, { data: [0, 0], label: 'Actual' },
]; ];
from: Date
to: Date
constructor( constructor(
private app: AppComponent, private app: AppComponent,
private route: ActivatedRoute, private route: ActivatedRoute,
@Inject(TWIGS_SERVICE) private twigsService: TwigsService, @Inject(TWIGS_SERVICE) private twigsService: TwigsService,
) { } private router: Router,
) {
let fromStr = this.route.snapshot.queryParamMap.get('from');
if (fromStr) {
let fromDate = new Date(fromStr);
if (!isNaN(fromDate.getTime())) {
this.from = fromDate;
}
}
if (!this.from) {
let date = new Date();
date.setHours(0);
date.setMinutes(0);
date.setSeconds(0);
date.setMilliseconds(0);
date.setDate(1);
this.from = date;
}
let toStr = this.route.snapshot.queryParamMap.get('to');
if (toStr) {
let toDate = new Date(toStr);
if (!isNaN(toDate.getTime())) {
this.to = toDate;
}
}
}
ngOnInit() { ngOnInit() {
this.getBudget(); this.getBudget();
this.app.backEnabled = false; this.app.setBackEnabled(false);
this.app.setActionable(this)
this.categoryBalances = new Map(); this.categoryBalances = new Map();
} }
ngOnDestroy() {
this.app.setActionable(null)
}
getBudget() { getBudget() {
const id = Number.parseInt(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.title = budget.name; this.app.setTitle(budget.name)
this.budget = budget; this.budget = budget;
this.getBalance(); this.getBalance();
this.getTransactions(); this.getTransactions();
@ -77,15 +110,12 @@ export class BudgetDetailsComponent implements OnInit {
]; ];
} }
getBalance(): number { getBalance(): void {
let totalBalance = 0; const id = this.route.snapshot.paramMap.get('id');
if (!this.categoryBalances) { this.twigsService.getBudgetBalance(id, this.from, this.to)
return 0; .then(balance => {
} this.budgetBalance = balance;
this.categoryBalances.forEach(balance => {
totalBalance += balance;
}); });
return totalBalance;
} }
getTransactions(): void { getTransactions(): void {
@ -96,14 +126,13 @@ export class BudgetDetailsComponent implements OnInit {
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<number, 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);
@ -112,8 +141,8 @@ export class BudgetDetailsComponent implements OnInit {
this.income.push(category); this.income.push(category);
this.expectedIncome += category.amount; this.expectedIncome += category.amount;
} }
this.getCategoryBalance(category.id).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;
@ -121,42 +150,24 @@ export class BudgetDetailsComponent implements OnInit {
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++;
} }
);
} }
});
} }
getCategoryBalance(category: number): Observable<number> { doAction(): void {
return Observable.create(subscriber => { this.router.navigateByUrl(this.router.routerState.snapshot.url + "/edit")
let date = new Date();
date.setHours(0);
date.setMinutes(0);
date.setSeconds(0);
date.setDate(1);
this.twigsService.getTransactions(this.budget.id, category, null, date).subscribe(transactions => {
let balance = 0;
for (const transaction of transactions) {
if (transaction.expense) {
balance -= transaction.amount;
} else {
balance += transaction.amount;
} }
}
subscriber.next(balance); getActionLabel(): string {
subscriber.complete(); return "Edit";
});
});
} }
} }

View file

@ -1,5 +1,4 @@
.dashboard { .dashboard {
color: #F1F1F1;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;

View file

@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { BudgetsComponent } from './budget.component'; import { BudgetsComponent } from './budget.component';
@ -6,7 +6,7 @@ describe('BudgetsComponent', () => {
let component: BudgetsComponent; let component: BudgetsComponent;
let fixture: ComponentFixture<BudgetsComponent>; let fixture: ComponentFixture<BudgetsComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ BudgetsComponent ] declarations: [ BudgetsComponent ]
}) })

View file

@ -20,27 +20,29 @@ export class BudgetsComponent implements OnInit {
) { } ) { }
ngOnInit() { ngOnInit() {
this.app.backEnabled = false; this.app.setBackEnabled(false);
this.app.title = 'Budgets';
this.app.user.subscribe( this.app.user.subscribe(
user => { user => {
if (!user) { if (!user) {
this.loading = false; this.loading = false;
this.loggedIn = false; this.loggedIn = false;
this.app.setTitle('Welcome')
return; return;
} }
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

@ -1,8 +1,9 @@
import { User } from '../users/user'; import { UserPermission } from '../users/user';
import { randomId } from '../shared/utils';
export class Budget { export class Budget {
id: number; id: string = randomId();
name: string; name: string;
description: string; description: string;
users: User[]; users: UserPermission[];
} }

View file

@ -0,0 +1 @@
<app-add-edit-budget [title]="'Edit Budget'" [budget]="budget" [create]="false"></app-add-edit-budget>

View file

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { EditBudgetComponent } from './edit-budget.component';
describe('EditBudgetComponent', () => {
let component: EditBudgetComponent;
let fixture: ComponentFixture<EditBudgetComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ EditBudgetComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EditBudgetComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,27 @@
import { Component, OnInit, Inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { TwigsService, TWIGS_SERVICE } from '../../shared/twigs.service';
import { Budget } from '../budget';
@Component({
selector: 'app-edit-budget',
templateUrl: './edit-budget.component.html',
styleUrls: ['./edit-budget.component.css']
})
export class EditBudgetComponent implements OnInit {
budget: Budget;
constructor(
private route: ActivatedRoute,
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
) { }
ngOnInit(): void {
const id = this.route.snapshot.paramMap.get('id');
this.twigsService.getBudget(id)
.then(budget => {
this.budget = budget;
});
}
}

View file

@ -1 +1 @@
<app-add-edit-budget [title]="'Add Budget'" [budget]="budget"></app-add-edit-budget> <app-add-edit-budget [title]="'Add Budget'" [budget]="budget" [create]="true"></app-add-edit-budget>

View file

@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { NewBudgetComponent } from './new-budget.component'; import { NewBudgetComponent } from './new-budget.component';
@ -6,7 +6,7 @@ describe('NewBudgetComponent', () => {
let component: NewBudgetComponent; let component: NewBudgetComponent;
let fixture: ComponentFixture<NewBudgetComponent>; let fixture: ComponentFixture<NewBudgetComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ NewBudgetComponent ] declarations: [ NewBudgetComponent ]
}) })

View file

@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { CategoriesComponent } from './categories.component'; import { CategoriesComponent } from './categories.component';
@ -6,7 +6,7 @@ describe('CategoriesComponent', () => {
let component: CategoriesComponent; let component: CategoriesComponent;
let fixture: ComponentFixture<CategoriesComponent>; let fixture: ComponentFixture<CategoriesComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ CategoriesComponent ] declarations: [ CategoriesComponent ]
}) })

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',
@ -14,9 +12,9 @@ import { TWIGS_SERVICE, TwigsService } from '../shared/twigs.service';
}) })
export class CategoriesComponent implements OnInit { export class CategoriesComponent implements OnInit {
budgetId: number; budgetId: string;
public categories: Category[]; public categories: Category[];
public categoryBalances: Map<number, number>; public categoryBalances: Map<string, number>;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
@ -25,25 +23,30 @@ export class CategoriesComponent implements OnInit {
) { } ) { }
ngOnInit() { ngOnInit() {
this.budgetId = Number.parseInt(this.route.snapshot.paramMap.get('budgetId')); this.budgetId = this.route.snapshot.paramMap.get('budgetId');
this.app.title = 'Categories'; this.app.setTitle('Categories')
this.app.backEnabled = true; this.app.setBackEnabled(true);
this.getCategories(); this.getCategories();
this.categoryBalances = new Map(); this.categoryBalances = new Map();
} }
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

@ -4,6 +4,6 @@
[options]="barChartOptions" [options]="barChartOptions"
[labels]="barChartLabels" [labels]="barChartLabels"
[legend]="barChartLegend" [legend]="barChartLegend"
[chartType]="barChartType"> [type]="barChartType">
</canvas> </canvas>
</div> </div>

View file

@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { CategoryBreakdownComponent } from './category-breakdown.component'; import { CategoryBreakdownComponent } from './category-breakdown.component';
@ -6,7 +6,7 @@ describe('CategoryBreakdownComponent', () => {
let component: CategoryBreakdownComponent; let component: CategoryBreakdownComponent;
let fixture: ComponentFixture<CategoryBreakdownComponent>; let fixture: ComponentFixture<CategoryBreakdownComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ CategoryBreakdownComponent ] declarations: [ CategoryBreakdownComponent ]
}) })

View file

@ -1,8 +1,8 @@
import { Component, OnInit, Input, OnChanges, SimpleChanges, SimpleChange, ViewChild } from '@angular/core'; import { Component, OnInit, Input, OnChanges, SimpleChanges, SimpleChange, ViewChild } from '@angular/core';
import { Category } from '../category'; import { Category } from '../category';
import { CategoriesComponent } from '../categories.component'; import { CategoriesComponent } from '../categories.component';
import { ChartOptions, ChartType, ChartDataSets } from 'chart.js'; import { ChartConfiguration, ChartType, ChartDataset } from 'chart.js';
import { BaseChartDirective, Label } from 'ng2-charts'; import { BaseChartDirective } from 'ng2-charts';
@Component({ @Component({
selector: 'app-category-breakdown', selector: 'app-category-breakdown',
@ -10,22 +10,24 @@ import { BaseChartDirective, Label } from 'ng2-charts';
styleUrls: ['./category-breakdown.component.css'] styleUrls: ['./category-breakdown.component.css']
}) })
export class CategoryBreakdownComponent implements OnInit, OnChanges { export class CategoryBreakdownComponent implements OnInit, OnChanges {
barChartOptions: ChartOptions = { barChartOptions: ChartConfiguration['options'] = {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
scales: { scales: {
xAxes: [{ x: {
ticks: { ticks: {
beginAtZero: true // beginAtZero: true
} }
}], yAxes: [{}]
}, },
y: {}
},
indexAxis: 'y'
}; };
@Input() barChartLabels: Label[]; @Input() barChartLabels: string[];
@Input() barChartData: ChartDataSets[] = [ @Input() barChartData: ChartDataset[] = [
{ data: [0, 0, 0, 0], label: '' }, { data: [0, 0, 0, 0], label: '' },
]; ];
barChartType: ChartType = 'horizontalBar'; barChartType: ChartType = 'bar';
barChartLegend = true; barChartLegend = true;
@ViewChild(BaseChartDirective) chart: BaseChartDirective; @ViewChild(BaseChartDirective) chart: BaseChartDirective;
@ -35,9 +37,9 @@ export class CategoryBreakdownComponent implements OnInit, OnChanges {
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
console.log(changes); console.log(changes);
if (changes.barChartLabels) { // if (changes.barChartLabels) {
this.barChartLabels = changes.barChartLabels.currentValue; // this.barChartLabels = changes.barChartLabels.currentValue;
} // }
if (changes.barChartData) { if (changes.barChartData) {
this.barChartData = changes.barChartData.currentValue; this.barChartData = changes.barChartData.currentValue;
} }

View file

@ -0,0 +1,4 @@
.category-description {
padding: 0 1em;
white-space: pre-wrap;
}

View file

@ -1,4 +1,5 @@
<app-transaction-list *ngIf="budgetId && category" [budgetId]="budgetId" [categoryId]="category.id"></app-transaction-list> <p class="category-description" *ngIf="category && category.description" [innerHtml]="category.description"></p>
<a mat-fab routerLink="/budgets/{{ budgetId }}/transactions/new"> <app-transaction-list *ngIf="budgetId && category" [budgetIds]="[budgetId]" [categoryIds]="[category.id]"></app-transaction-list>
<a mat-fab routerLink="/transactions/new" [queryParams]="{budgetId: budgetId}">
<mat-icon aria-label="Add">add</mat-icon> <mat-icon aria-label="Add">add</mat-icon>
</a> </a>

View file

@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { CategoryDetailsComponent } from './category-details.component'; import { CategoryDetailsComponent } from './category-details.component';
@ -6,7 +6,7 @@ describe('CategoryDetailsComponent', () => {
let component: CategoryDetailsComponent; let component: CategoryDetailsComponent;
let fixture: ComponentFixture<CategoryDetailsComponent>; let fixture: ComponentFixture<CategoryDetailsComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ CategoryDetailsComponent ] declarations: [ CategoryDetailsComponent ]
}) })

View file

@ -13,7 +13,7 @@ import { Actionable } from '../../shared/actionable';
}) })
export class CategoryDetailsComponent implements OnInit, OnDestroy, Actionable { export class CategoryDetailsComponent implements OnInit, OnDestroy, Actionable {
budgetId: number; budgetId: string;
category: Category; category: Category;
public transactions: Transaction[]; public transactions: Transaction[];
@ -33,21 +33,21 @@ export class CategoryDetailsComponent implements OnInit, OnDestroy, Actionable {
} }
ngOnInit() { ngOnInit() {
this.app.backEnabled = true; this.app.setBackEnabled(true);
this.app.actionable = this; this.app.setActionable(this)
this.getCategory(); this.getCategory();
} }
ngOnDestroy() { ngOnDestroy() {
this.app.actionable = null; this.app.setActionable(null)
} }
getCategory(): void { getCategory(): void {
const id = Number.parseInt(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.title = category.title; this.app.setTitle(category.title)
this.category = category; this.category = category;
this.budgetId = category.budgetId; this.budgetId = category.budgetId;
}); });

View file

@ -2,11 +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()">
<input matInput type="text" [(ngModel)]="currentCategory.amount" placeholder="Amount" required currencyMask> <textarea matInput [(ngModel)]="currentCategory.description" placeholder="Description" autocapitalize="sentences"></textarea>
</mat-form-field>
<mat-form-field (keyup.enter)="save()">
<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>
@ -18,6 +21,6 @@
<input type="color" matInput [(ngModel)]="currentCategory.color" placeholder="Color"> <input type="color" matInput [(ngModel)]="currentCategory.color" placeholder="Color">
</mat-form-field> </mat-form-field>
--> -->
<button mat-button color="accent" (click)="save()">Save</button> <button mat-raised-button color="accent" (click)="save()">Save</button>
<button class="button-delete" mat-button color="warn" *ngIf="currentCategory.id" (click)="delete()">Delete</button> <button class="button-delete" mat-raised-button color="warn" *ngIf="!create" (click)="delete()">Delete</button>
</div> </div>

View file

@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { CategoryFormComponent } from './category-form.component'; import { CategoryFormComponent } from './category-form.component';
@ -6,7 +6,7 @@ describe('CategoryFormComponent', () => {
let component: CategoryFormComponent; let component: CategoryFormComponent;
let fixture: ComponentFixture<CategoryFormComponent>; let fixture: ComponentFixture<CategoryFormComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ CategoryFormComponent ] declarations: [ CategoryFormComponent ]
}) })

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',
@ -10,9 +11,10 @@ import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service';
}) })
export class CategoryFormComponent implements OnInit { export class CategoryFormComponent implements OnInit {
@Input() budgetId: number; @Input() budgetId: string;
@Input() title: string; @Input() title: string;
@Input() currentCategory: Category; @Input() currentCategory: Category;
@Input() create: boolean;
constructor( constructor(
private app: AppComponent, private app: AppComponent,
@ -20,40 +22,40 @@ export class CategoryFormComponent implements OnInit {
) { } ) { }
ngOnInit() { ngOnInit() {
this.app.backEnabled = true; this.app.setBackEnabled(true);
this.app.title = this.title; this.app.setTitle(this.title)
} }
save(): void { save(): void {
let observable; let promise;
if (this.currentCategory.id) { this.currentCategory.amount = decimalToInteger(String(this.currentCategory.amount))
// This is an existing category, update it if (this.create) {
observable = this.twigsService.updateCategory(
this.budgetId,
this.currentCategory.id,
{
name: this.currentCategory.title,
amount: this.currentCategory.amount * 100,
expense: this.currentCategory.expense,
archived: this.currentCategory.archived
}
);
} else {
// 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.budgetId, this.budgetId,
this.currentCategory.title, this.currentCategory.title,
this.currentCategory.amount * 100, this.currentCategory.description,
this.currentCategory.amount,
this.currentCategory.expense this.currentCategory.expense
); );
} else {
// This is an existing category, update it
const updatedCategory: Category = {
...this.currentCategory,
} }
observable.subscribe(val => { promise = this.twigsService.updateCategory(
this.currentCategory.id,
this.currentCategory
);
}
promise.then(_ => {
this.app.goBack(); this.app.goBack();
}); });
} }
delete(): void { delete(): void {
this.twigsService.deleteCategory(this.budgetId, this.currentCategory.id).subscribe(() => { this.twigsService.deleteCategory(this.currentCategory.id).then(() => {
this.app.goBack(); this.app.goBack();
}); });
} }

View file

@ -1,12 +1,7 @@
.categories mat-progress-bar.mat-progress-bar { .categories mat-progress-bar.mat-progress-bar {
background-color: #BDBDBD;
margin-top: 0.5em; margin-top: 0.5em;
} }
::ng-deep .mat-progress-bar-buffer {
background-color: #BDBDBD;
}
p.mat-line.category-list-title { p.mat-line.category-list-title {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -26,5 +21,11 @@ p.mat-line.category-list-title .remaining {
} }
::ng-deep .mat-progress-bar-buffer { ::ng-deep .mat-progress-bar-buffer {
background-color: #333333 !important; background-color: #F1F1F1 !important;
}
@media (prefers-color-scheme: dark) {
::ng-deep .mat-progress-bar-buffer {
background-color: #333333 !important;
}
} }

View file

@ -1,5 +1,5 @@
<mat-nav-list class="categories"> <mat-nav-list class="categories">
<a mat-list-item *ngFor="let category of categories" routerLink="/budgets/{{ budgetId }}/categories/{{ category.id }}"> <a mat-list-item *ngFor="let category of categories" routerLink="/categories/{{ category.id }}">
<p matLine class="category-list-title"> <p matLine class="category-list-title">
<span> <span>
{{ category.title }} {{ category.title }}

View file

@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { CategoryListComponent } from './category-list.component'; import { CategoryListComponent } from './category-list.component';
@ -6,7 +6,7 @@ describe('CategoryListComponent', () => {
let component: CategoryListComponent; let component: CategoryListComponent;
let fixture: ComponentFixture<CategoryListComponent>; let fixture: ComponentFixture<CategoryListComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ CategoryListComponent ] declarations: [ CategoryListComponent ]
}) })

View file

@ -10,7 +10,7 @@ export class CategoryListComponent implements OnInit {
@Input() budgetId: string; @Input() budgetId: string;
@Input() categories: Category[]; @Input() categories: Category[];
@Input() categoryBalances: Map<number, number>; @Input() categoryBalances: Map<string, number>;
constructor() { } constructor() { }

View file

@ -1,8 +1,11 @@
import { randomId } from '../shared/utils';
export class Category { export class Category {
id: number; id: string = randomId();
title: string; title: string;
description: string;
amount: number; amount: number;
expense: boolean; expense: boolean;
archived: boolean; archived: boolean;
budgetId: number; budgetId: string;
} }

View file

@ -1 +1 @@
<app-category-form [title]="'Edit Category'" [budgetId]="budgetId" [currentCategory]="category"></app-category-form> <app-category-form [title]="'Edit Category'" [budgetId]="budgetId" [currentCategory]="category" [create]="false"></app-category-form>

View file

@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { EditCategoryComponent } from './edit-category.component'; import { EditCategoryComponent } from './edit-category.component';
@ -6,7 +6,7 @@ describe('EditCategoryComponent', () => {
let component: EditCategoryComponent; let component: EditCategoryComponent;
let fixture: ComponentFixture<EditCategoryComponent>; let fixture: ComponentFixture<EditCategoryComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ EditCategoryComponent ] declarations: [ EditCategoryComponent ]
}) })

View file

@ -11,7 +11,7 @@ import { TWIGS_SERVICE, TwigsService } from '../../shared/twigs.service';
}) })
export class EditCategoryComponent implements OnInit { export class EditCategoryComponent implements OnInit {
budgetId: number; budgetId: string;
category: Category; category: Category;
constructor( constructor(
@ -21,16 +21,16 @@ export class EditCategoryComponent implements OnInit {
) { } ) { }
ngOnInit(): void { ngOnInit(): void {
this.app.backEnabled = true; this.app.setBackEnabled(true);
this.getCategory(); this.getCategory();
} }
getCategory(): void { getCategory(): void {
const id = Number.parseInt(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.title = category.title; this.app.setTitle(category.title)
this.category = category; this.category = category;
this.budgetId = category.budgetId; this.budgetId = category.budgetId;
}); });

View file

@ -1 +1 @@
<app-category-form [title]="'Add Category'" [budgetId]="budgetId" [currentCategory]="category"></app-category-form> <app-category-form [title]="'Add Category'" [budgetId]="budgetId" [currentCategory]="category" [create]="true"></app-category-form>

View file

@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { NewCategoryComponent } from './new-category.component'; import { NewCategoryComponent } from './new-category.component';
@ -6,7 +6,7 @@ describe('NewCategoryComponent', () => {
let component: NewCategoryComponent; let component: NewCategoryComponent;
let fixture: ComponentFixture<NewCategoryComponent>; let fixture: ComponentFixture<NewCategoryComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ NewCategoryComponent ] declarations: [ NewCategoryComponent ]
}) })

View file

@ -17,7 +17,8 @@ export class NewCategoryComponent implements OnInit {
) { } ) { }
ngOnInit() { ngOnInit() {
this.budgetId = this.route.snapshot.paramMap.get('budgetId'); this.budgetId = this.route.snapshot.queryParamMap.get('budgetId');
console.log(`Creating category for budget ${this.budgetId}`)
this.category = new Category(); this.category = new Category();
// TODO: Set random color for category, improve color picker // TODO: Set random color for category, improve color picker
// this.category.color = // this.category.color =

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,22 +1,21 @@
import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent, HttpHeaders } from '@angular/common/http'; import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { CookieService } from 'ngx-cookie-service';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
@Injectable() @Injectable()
export class AuthInterceptor implements HttpInterceptor { export class AuthInterceptor implements HttpInterceptor {
constructor( constructor(
private cookieService: CookieService private storage: Storage
) { } ) { }
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (!this.cookieService.check('Authorization')) { let token = this.storage.getItem('Authorization')
if (!token) {
return next.handle(req); return next.handle(req);
} }
let headers = req.headers; let headers = req.headers;
headers = headers.append('Authorization', `Basic ${this.cookieService.get('Authorization')}`); headers = headers.append('Authorization', `Bearer ${token}`);
this.cookieService.set('Authorization', this.cookieService.get('Authorization'), 14, null, null, true);
return next.handle(req.clone({headers: headers})); return next.handle(req.clone({headers: headers}));
} }
} }

View file

@ -1,167 +1,221 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http'; import { User, UserPermission, Permission, AuthToken } from '../users/user';
import { Observable, Subscriber } from 'rxjs';
import { User, UserPermission, Permission } 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';
import { CookieService } from 'ngx-cookie-service';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class TwigsHttpService implements TwigsService { export class TwigsHttpService implements TwigsService {
constructor(
private http: HttpClient,
private cookieService: CookieService
) { }
private options = {
withCredentials: true
};
private apiUrl = environment.apiUrl; private apiUrl = environment.apiUrl;
// Auth constructor(
login(email: string, password: string): Observable<User> { private storage: Storage
// const params = { ) { }
// 'username': email,
// 'password': password async login(email: string, password: string): Promise<User> {
// }; const url = new URL('/api/users/login', this.apiUrl)
// return this.http.post<User>(this.apiUrl + '/users/login', params, this.options); const auth: AuthToken = await this.request(url, HttpMethod.POST, {
const credentials = btoa(`${email}:${password}`) 'username': email,
this.cookieService.set('Authorization', credentials, 14, null, null, true); 'password': password
return this.getProfile(); });
this.storage.setItem('Authorization', auth.token);
this.storage.setItem('userId', auth.userId);
return await this.getProfile(auth.userId);
} }
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', 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 this.http.post<void>(this.apiUrl + '/login?logout', this.options); this.storage.removeItem('Authorization');
this.storage.removeItem('userId');
return Promise.resolve()
// TODO: Implement this to revoke the token server-side as well
// return this.http.post<void>(this.apiUrl + '/login?logout', this.options);
} }
// Budgets // Budgets
getBudgets(): Observable<Budget[]> { getBudgets(): Promise<Budget[]> {
return this.http.get<Budget[]>(this.apiUrl + '/budgets', this.options); const url = new URL('/api/budgets', this.apiUrl)
return this.request(url, HttpMethod.GET)
} }
getBudget(id: number): Observable<Budget> { getBudgetBalance(
return this.http.get<Budget>(`${this.apiUrl}/budgets/${id}`, this.options); id: string,
from?: Date,
to?: Date
): Promise<number> {
const url = new URL('/api/transactions/sum', this.apiUrl)
url.searchParams.set('budgetId', id)
if (from) {
url.searchParams.set('from', from.toISOString());
}
if (to) {
url.searchParams.set('to', to.toISOString());
}
return this.request(url, HttpMethod.GET).then((res: any) => res.balance)
}
getBudget(id: string): Promise<Budget> {
const url = new URL(`/api/budgets/${id}`, this.apiUrl)
return this.request(url, HttpMethod.GET)
} }
createBudget( createBudget(
id: string,
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,
'name': name, 'name': name,
'description': description, 'description': description,
'users': users.map(user => { 'users': users.map(userPermission => {
return { return {
user: user.user, user: userPermission.user,
permission: Permission[user.permission] permission: Permission[userPermission.permission]
}; };
}) })
}; };
return this.http.post<Budget>(this.apiUrl + '/budgets', params, this.options); return this.request(url, HttpMethod.POST, body)
} }
updateBudget(id: number, changes: object): Observable<Budget> { updateBudget(id: string, budget: Budget): Promise<Budget> {
return this.http.put<Budget>(`${this.apiUrl}/budgets/${id}`, changes, this.options); const url = new URL(`/api/budgets/${id}`, this.apiUrl)
const body = {
'name': budget.name,
'description': budget.description,
'users': budget.users.map(userPermission => {
return {
user: userPermission.user,
permission: Permission[userPermission.permission]
};
})
};
return this.request(url, HttpMethod.PUT, body)
} }
deleteBudget(id: number): 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)
return this.request(url, HttpMethod.DELETE)
} }
// Categories // Categories
getCategories(budgetId: number, 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')
}; return this.request(url, HttpMethod.GET);
return this.http.get<Category[]>(`${this.apiUrl}/categories`, Object.assign(params, this.options));
} }
getCategory(id: number): 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);
} }
createCategory(budgetId: number, name: string, amount: number, isExpense: boolean): Observable<Category> { async getCategoryBalance(
const params = { id: string,
from?: Date,
to?: Date
): Promise<number> {
const url = new URL(`/api/transactions/sum`, this.apiUrl)
url.searchParams.set('categoryId', id)
if (from) {
url.searchParams.set('from', from.toISOString());
}
if (to) {
url.searchParams.set('to', to.toISOString());
}
const res: any = await this.request(url, HttpMethod.GET);
return res.balance;
}
createCategory(id: string, budgetId: string, name: string, description: string, amount: number, isExpense: boolean): Promise<Category> {
const url = new URL(`/api/categories`, this.apiUrl)
const body = {
'id': id,
'title': name, 'title': name,
'description': description,
'amount': amount, 'amount': amount,
'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(budgetId: number, id: number, 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(budgetId: number, id: number): 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?: number, budgetId?: string,
categoryId?: number, categoryId?: string,
count?: number, count?: number,
from?: Date from?: Date,
): Observable<Transaction[]> { to?: Date
let httpParams = new HttpParams(); ): Promise<Transaction[]> {
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());
} }
const params = { params: httpParams }; if (to) {
return this.http.get<Transaction[]>(`${this.apiUrl}/transactions`, Object.assign(params, this.options)) url.searchParams.set('to', to.toISOString());
.pipe(map(transactions => { }
const transactions: Transaction[] = await this.request(url, HttpMethod.GET)
transactions.forEach(transaction => { transactions.forEach(transaction => {
transaction.date = new Date(transaction.date); transaction.date = new Date(transaction.date);
}); })
return transactions; return transactions
}));
} }
getTransaction(id: number): 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(
budgetId: number, id: string,
budgetId: string,
name: string, name: string,
description: string, description: string,
amount: number, amount: number,
date: Date, date: Date,
expense: boolean, expense: boolean,
category: number category: string
): Observable<Transaction> { ): Promise<Transaction> {
const params = { const url = new URL(`/api/transactions`, this.apiUrl)
const body = {
'id': id,
'title': name, 'title': name,
'description': description, 'description': description,
'date': date.toISOString(), 'date': date.toISOString(),
@ -170,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(budgetId: number, id: number, 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(budgetId: number, id: number): 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(): Observable<User> { getProfile(id: string): Promise<User> {
return this.http.get<User>(`${this.apiUrl}/users/me`, 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 Observable.create(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,11 +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 { 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.
@ -17,89 +17,83 @@ import { Transaction } from '../transactions/transaction';
export class TwigsLocalService implements TwigsService { export class TwigsLocalService implements TwigsService {
constructor( constructor(
private http: HttpClient
) { } ) { }
private users: User[] = [new User(1, 'test', 'test@example.com')]; private users: User[] = [new User(randomId(), 'test', 'test@example.com')];
private budgets: Budget[] = []; private budgets: Budget[] = [];
private transactions: Transaction[] = []; private transactions: Transaction[] = [];
private categories: Category[] = []; private categories: Category[] = [];
// Auth // Auth
login(email: string, password: string): Observable<User> { login(email: string, password: string): Promise<User> {
return Observable.create(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 Observable.create(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 = this.users.length + 1; 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 Observable.create(subscriber => { return Promise.resolve()
subscriber.complete();
});
} }
// Budgets // Budgets
getBudgets(): Observable<Budget[]> { getBudgets(): Promise<Budget[]> {
return Observable.create(subscriber => { return Promise.resolve(this.budgets)
subscriber.next(this.budgets);
subscriber.complete();
});
} }
getBudget(id: number): Observable<Budget> { getBudgetBalance(id: string, from?: Date, to?: Date): Promise<number> {
return Observable.create(subscriber => { return Promise.resolve(200)
}
getBudget(id: string): Promise<Budget> {
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();
}); });
} }
createBudget( createBudget(
id: string,
name: string, name: string,
description: string, description: string,
users: UserPermission[], users: UserPermission[],
): Observable<Budget> { ): Promise<Budget> {
return Observable.create(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 = this.users.filter(user => { budget.users = users;
return users.map(userPerm => userPerm.user).indexOf(user.id) > -1; budget.id = id;
});
budget.id = this.budgets.length + 1;
this.budgets.push(budget); this.budgets.push(budget);
subscriber.next(budget); resolve(budget);
subscriber.complete();
}); });
} }
updateBudget(id: number, changes: object): Observable<Budget> { updateBudget(id: string, budget: Budget): Promise<Budget> {
return Observable.create(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];
@ -107,74 +101,71 @@ 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',
'users',
] ]
); );
if (changes['userIds']) {
budget.users = this.users.filter(user => {
return changes['userIds'].indexOf(user.id) > -1;
});
}
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: number): Observable<void> { deleteBudget(id: string): Promise<void> {
return Observable.create(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: number, count?: number): Observable<Category[]> { getCategories(budgetId: string, count?: number): Promise<Category[]> {
return Observable.create(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: number): Observable<Category> { getCategory(id: string): Promise<Category> {
return Observable.create(subscriber => { return new Promise((resolve, reject) => {
subscriber.next(this.findById(this.categories, id)); resolve(this.findById(this.categories, id));
subscriber.complete();
}); });
} }
createCategory(budgetId: number, name: string, amount: number, isExpense: boolean): Observable<Category> { getCategoryBalance(id: string, from?: Date, to?: Date): Promise<number> {
return Observable.create(subscriber => { return Promise.resolve(20);
}
createCategory(id: string, budgetId: string, name: string, description: string, amount: number, isExpense: boolean): Promise<Category> {
return new Promise((resolve, reject) => {
const category = new Category(); const category = new Category();
category.title = name; category.title = name;
category.description = description;
category.amount = amount; category.amount = amount;
category.expense = isExpense; category.expense = isExpense;
category.budgetId = budgetId; category.budgetId = budgetId;
category.id = this.categories.length + 1; category.id = id;
this.categories.push(category); this.categories.push(category);
subscriber.next(category); resolve(category);
subscriber.complete();
}); });
} }
updateCategory(budgetId: number, id: number, changes: object): Observable<Category> { updateCategory(id: string, changes: object): Promise<Category> {
return Observable.create(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);
@ -189,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(budgetId: number, id: number): Observable<void> { deleteCategory(id: string): Promise<void> {
return Observable.create(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?: number, categoryId?: number, count?: number): Observable<Transaction[]> { getTransactions(budgetId?: string, categoryId?: string, count?: number): Promise<Transaction[]> {
return Observable.create(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;
@ -223,27 +213,24 @@ export class TwigsLocalService implements TwigsService {
} }
return include; return include;
})); }));
subscriber.complete();
}); });
} }
getTransaction(id: number): Observable<Transaction> { getTransaction(id: string): Promise<Transaction> {
return Observable.create(subscriber => { return Promise.resolve(this.findById(this.transactions, id));
subscriber.next(this.findById(this.transactions, id));
subscriber.complete();
});
} }
createTransaction( createTransaction(
budgetId: number, id: string,
budgetId: string,
name: string, name: string,
description: string, description: string,
amount: number, amount: number,
date: Date, date: Date,
isExpense: boolean, isExpense: boolean,
category: number category: string
): Observable<Transaction> { ): Promise<Transaction> {
return Observable.create(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;
@ -252,15 +239,14 @@ export class TwigsLocalService implements TwigsService {
transaction.expense = isExpense; transaction.expense = isExpense;
transaction.categoryId = category; transaction.categoryId = category;
transaction.budgetId = budgetId; transaction.budgetId = budgetId;
transaction.id = this.transactions.length + 1; transaction.id = randomId();
this.transactions.push(transaction); this.transactions.push(transaction);
subscriber.next(transaction); resolve(transaction);
subscriber.complete();
}); });
} }
updateTransaction(budgetId: number, id: number, changes: object): Observable<Transaction> { updateTransaction(id: string, changes: object): Promise<Transaction> {
return Observable.create(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);
@ -279,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(budgetId: number, id: number): Observable<void> { deleteTransaction(id: string): Promise<void> {
return Observable.create(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(): Observable<User> { getProfile(id: string): Promise<User> {
return Observable.create(subscriber => { return Promise.reject("Not yet implemented");
subscriber.error("Not yet implemented")
});
} }
getUsersByUsername(username: string): Observable<User[]> { getUsersByUsername(username: string): Promise<User[]> {
return Observable.create(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[]) {
@ -321,7 +340,7 @@ export class TwigsLocalService implements TwigsService {
}); });
} }
private findById<T>(items: T[], id: number): T { private findById<T>(items: T[], id: string): T {
return items.filter(item => { return items.filter(item => {
return item['id'] === id; return item['id'] === id;
})[0]; })[0];

View file

@ -1,56 +1,85 @@
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: number): Observable<Budget>; getBudget(id: string): Promise<Budget>;
getBudgetBalance(id: string, from?: Date, to?: Date): Promise<number>;
createBudget( createBudget(
id: string,
name: string, name: string,
description: string, description: string,
users: UserPermission[], users: UserPermission[],
): Observable<Budget>; ): Promise<Budget>;
updateBudget(id: number, changes: object): Observable<Budget>; updateBudget(id: string, budget: Budget): Promise<Budget>;
deleteBudget(id: number): Observable<void>; deleteBudget(id: string): Promise<void>;
// Categories // Categories
getCategories(budgetId?: number, count?: number): Observable<Category[]>; getCategories(budgetId?: string, count?: number): Promise<Category[]>;
getCategory(id: number): Observable<Category>; getCategory(id: string): Promise<Category>;
createCategory(budgetId: number, name: string, amount: number, isExpense: boolean): Observable<Category>; getCategoryBalance(id: string, from?: Date, to?: Date): Promise<number>;
updateCategory(budgetId: number, id: number, changes: object): Observable<Category>; createCategory(id: string, budgetId: string, name: string, description: string, amount: number, isExpense: boolean): Promise<Category>;
deleteCategory(budgetId: number, id: number): Observable<void>; updateCategory(id: string, category: Category): Promise<Category>;
deleteCategory(id: string): Promise<void>;
// Transactions // Transactions
getTransactions( getTransactions(
budgetId?: number, budgetId?: string,
categoryId?: number, categoryId?: string,
count?: number, count?: number,
from?: Date from?: Date,
): Observable<Transaction[]>; to?: Date
getTransaction(id: number): Observable<Transaction>; ): Promise<Transaction[]>;
getTransaction(id: string): Promise<Transaction>;
createTransaction( createTransaction(
budgetId: number, id: string,
budgetId: string,
name: string, name: string,
description: string, description: string,
amount: number, amount: number,
date: Date, date: Date,
isExpense: boolean, isExpense: boolean,
category: number category: string
): Observable<Transaction>; ): Promise<Transaction>;
updateTransaction(budgetId: number, id: number, changes: object): Observable<Transaction>; updateTransaction(id: string, transaction: Transaction): Promise<Transaction>;
deleteTransaction(budgetId: number, id: number): Observable<void>; deleteTransaction(id: string): Promise<void>;
getProfile(): 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');

15
src/app/shared/utils.ts Normal file
View file

@ -0,0 +1,15 @@
const CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
export function randomId(): string {
var bytes = new Uint8Array(32)
window.crypto.getRandomValues(bytes)
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

@ -1,6 +1,5 @@
.transaction-form { .transaction-form {
padding: 1em; padding: 1em;
color: #F1F1F1;
} }
.transaction-form * { .transaction-form * {
@ -10,8 +9,3 @@
mat-radio-button { mat-radio-button {
padding-bottom: 15px; padding-bottom: 15px;
} }
button {
width: 100%;
margin: 1em 0;
}

View file

@ -3,16 +3,17 @@
</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 currencyMask> <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'" (ngModelChange)="transactionDate = $event" placeholder="Date" required> <input matInput type="date" [ngModel]="transactionDate | date:'yyyy-MM-dd'"
(ngModelChange)="transactionDate = $event" placeholder="Date" required>
</mat-form-field> </mat-form-field>
<mat-form-field> <mat-form-field>
<input matInput type="time" [(ngModel)]="currentTime" placeholder="Time" required> <input matInput type="time" [(ngModel)]="currentTime" placeholder="Time" required>
@ -29,5 +30,5 @@
</mat-select> </mat-select>
</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="currentTransaction.id" (click)="delete()">Delete</button> <button class="button-delete" mat-raised-button color="warn" *ngIf="!create" (click)="delete()">Delete</button>
</div> </div>

View file

@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { AddEditTransactionComponent } from './add-edit-transaction.component'; import { AddEditTransactionComponent } from './add-edit-transaction.component';
@ -6,7 +6,7 @@ describe('AddEditTransactionComponent', () => {
let component: AddEditTransactionComponent; let component: AddEditTransactionComponent;
let fixture: ComponentFixture<AddEditTransactionComponent>; let fixture: ComponentFixture<AddEditTransactionComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ AddEditTransactionComponent ] declarations: [ AddEditTransactionComponent ]
}) })

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',
@ -14,10 +15,10 @@ import { MatRadioChange } from '@angular/material/radio';
export class AddEditTransactionComponent implements OnInit, OnChanges { export class AddEditTransactionComponent implements OnInit, OnChanges {
@Input() title: string; @Input() title: string;
@Input() currentTransaction: Transaction; @Input() currentTransaction: Transaction;
@Input() budgetId: number; @Input() budgetId: string;
@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;
@ -27,8 +28,8 @@ export class AddEditTransactionComponent implements OnInit, OnChanges {
) { } ) { }
ngOnInit() { ngOnInit() {
this.app.title = this.title; this.app.setTitle(this.title)
this.app.backEnabled = true; this.app.setBackEnabled(true);
let d: Date, expense: boolean; let d: Date, expense: boolean;
if (this.currentTransaction) { if (this.currentTransaction) {
d = new Date(this.currentTransaction.date); d = new Date(this.currentTransaction.date);
@ -48,21 +49,20 @@ export class AddEditTransactionComponent implements OnInit, OnChanges {
} }
const d = new Date(changes.currentTransaction.currentValue.date * 1000); const d = new Date(changes.currentTransaction.currentValue.date * 1000);
this.transactionDate = d.toLocaleDateString(undefined, {year: 'numeric', month: '2-digit', day: '2-digit'}); this.transactionDate = d.toLocaleDateString(undefined, { year: 'numeric', month: '2-digit', day: '2-digit' });
this.currentTime = d.toLocaleTimeString(undefined, {hour: '2-digit', hour12: false, minute: '2-digit'}); this.currentTime = d.toLocaleTimeString(undefined, { hour: '2-digit', hour12: false, minute: '2-digit' });
} }
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));
@ -71,40 +71,36 @@ export class AddEditTransactionComponent implements OnInit, OnChanges {
const timeParts = this.currentTime.split(':'); const timeParts = this.currentTime.split(':');
this.currentTransaction.date.setHours(parseInt(timeParts[0], 10)); this.currentTransaction.date.setHours(parseInt(timeParts[0], 10));
this.currentTransaction.date.setMinutes(parseInt(timeParts[1], 10)); this.currentTransaction.date.setMinutes(parseInt(timeParts[1], 10));
if (this.currentTransaction.id) { if (this.create) {
// This is an existing transaction, update it
observable = this.twigsService.updateTransaction(
this.budgetId,
this.currentTransaction.id,
{
name: this.currentTransaction.title,
description: this.currentTransaction.description,
amount: this.currentTransaction.amount * 100,
date: this.currentTransaction.date,
categoryId: this.currentTransaction.categoryId,
expense: this.currentTransaction.expense
}
);
} else {
// 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.budgetId, this.budgetId,
this.currentTransaction.title, this.currentTransaction.title,
this.currentTransaction.description, this.currentTransaction.description,
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 {
// This is an existing transaction, update it
const updatedTransaction: Transaction = {
...this.currentTransaction,
}
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.budgetId, this.currentTransaction.id).subscribe(() => { this.twigsService.deleteTransaction(this.currentTransaction.id).then(() => {
this.app.goBack(); this.app.goBack();
}); });
} }

View file

@ -1 +1 @@
<app-add-edit-transaction [budgetId]="budgetId" [title]="'Add Transaction'" [currentTransaction]="transaction"></app-add-edit-transaction> <app-add-edit-transaction [budgetId]="budgetId" [title]="'Add Transaction'" [currentTransaction]="transaction" [create]="true"></app-add-edit-transaction>

View file

@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { NewTransactionComponent } from './new-transaction.component'; import { NewTransactionComponent } from './new-transaction.component';
@ -6,7 +6,7 @@ describe('NewTransactionComponent', () => {
let component: NewTransactionComponent; let component: NewTransactionComponent;
let fixture: ComponentFixture<NewTransactionComponent>; let fixture: ComponentFixture<NewTransactionComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ NewTransactionComponent ] declarations: [ NewTransactionComponent ]
}) })

View file

@ -17,7 +17,7 @@ export class NewTransactionComponent implements OnInit {
) { } ) { }
ngOnInit() { ngOnInit() {
this.budgetId = this.route.snapshot.paramMap.get('budgetId'); this.budgetId = this.route.snapshot.queryParamMap.get('budgetId');
this.transaction = new Transaction(); this.transaction = new Transaction();
} }

View file

@ -1 +1 @@
<app-add-edit-transaction [budgetId]="budgetId" [title]="'Edit Transaction'" [currentTransaction]="transaction" *ngIf="transaction"></app-add-edit-transaction> <app-add-edit-transaction [budgetId]="budgetId" [title]="'Edit Transaction'" [currentTransaction]="transaction" *ngIf="transaction" [create]="false"></app-add-edit-transaction>

View file

@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TransactionDetailsComponent } from './transaction-details.component'; import { TransactionDetailsComponent } from './transaction-details.component';
@ -6,7 +6,7 @@ describe('TransactionDetailsComponent', () => {
let component: TransactionDetailsComponent; let component: TransactionDetailsComponent;
let fixture: ComponentFixture<TransactionDetailsComponent>; let fixture: ComponentFixture<TransactionDetailsComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ TransactionDetailsComponent ] declarations: [ TransactionDetailsComponent ]
}) })

View file

@ -10,7 +10,7 @@ import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service';
}) })
export class TransactionDetailsComponent implements OnInit { export class TransactionDetailsComponent implements OnInit {
budgetId: number; budgetId: string;
transaction: Transaction; transaction: Transaction;
constructor( constructor(
@ -23,9 +23,9 @@ export class TransactionDetailsComponent implements OnInit {
} }
getTransaction(): void { getTransaction(): void {
const id = Number.parseInt(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

@ -1,5 +1,9 @@
.transactions .list-row-one { .transaction-details p {
display: flex; padding: 2px 0;
justify-content: space-between; }
padding-bottom: 0.2em;
.transaction-description {
font-style: italic;
text-overflow: ellipsis;
overflow: hidden;
} }

View file

@ -1,12 +1,13 @@
<mat-nav-list *ngIf="transactions" class="transactions"> <mat-nav-list *ngIf="transactions" class="transactions">
<a mat-list-item *ngFor="let transaction of transactions" <a mat-list-item class="transaction-list-item" *ngFor="let transaction of transactions"
routerLink="/budgets/{{ budgetId }}/transactions/{{ transaction.id }}"> routerLink="/transactions/{{ transaction.id }}">
<div matLine class="list-row-one"> <div matLine class="transaction-list-details">
<p>{{transaction.title}}</p> <p class="transaction-title">{{transaction.title}}</p>
<p class="transaction-description text-small" *ngIf="transaction.description">{{transaction.description }}</p>
<p matLine class="transaction-date text-small">{{ transaction.date | date }}</p>
</div>
<p class="amount" [class.expense]="transaction.expense" [class.income]="!transaction.expense"> <p class="amount" [class.expense]="transaction.expense" [class.income]="!transaction.expense">
{{ transaction.amount / 100 | currency }} {{ transaction.amount / 100 | currency }}
</p> </p>
</div>
<p matLine class="text-small">{{ transaction.date | date }}</p>
</a> </a>
</mat-nav-list> </mat-nav-list>

View file

@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TransactionListComponent } from './transaction-list.component'; import { TransactionListComponent } from './transaction-list.component';
@ -6,7 +6,7 @@ describe('TransactionListComponent', () => {
let component: TransactionListComponent; let component: TransactionListComponent;
let fixture: ComponentFixture<TransactionListComponent>; let fixture: ComponentFixture<TransactionListComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ TransactionListComponent ] declarations: [ TransactionListComponent ]
}) })

View file

@ -1,6 +1,7 @@
import { Component, OnInit, Input, Inject } from '@angular/core'; import { Component, OnInit, Input, Inject } from '@angular/core';
import { Transaction } from '../transaction'; import { Transaction } from '../transaction';
import { TWIGS_SERVICE, TwigsService } from '../../shared/twigs.service'; import { TWIGS_SERVICE, TwigsService } from '../../shared/twigs.service';
import { ActivatedRoute } from '@angular/router';
@Component({ @Component({
selector: 'app-transaction-list', selector: 'app-transaction-list',
@ -9,12 +10,13 @@ import { TWIGS_SERVICE, TwigsService } from '../../shared/twigs.service';
}) })
export class TransactionListComponent implements OnInit { export class TransactionListComponent implements OnInit {
@Input() budgetId: number; @Input() budgetIds: string[];
@Input() categoryId?: number; @Input() categoryIds?: string[];
public transactions: Transaction[]; public transactions: Transaction[];
constructor( constructor(
@Inject(TWIGS_SERVICE) private twigsService: TwigsService, @Inject(TWIGS_SERVICE) private twigsService: TwigsService,
private route: ActivatedRoute
) { } ) { }
ngOnInit(): void { ngOnInit(): void {
@ -22,13 +24,36 @@ export class TransactionListComponent implements OnInit {
} }
getTransactions(): void { getTransactions(): void {
let fromStr = this.route.snapshot.queryParamMap.get('from');
var from;
if (fromStr) {
let fromDate = new Date(fromStr);
if (!isNaN(fromDate.getTime())) {
from = fromDate;
}
}
if (!from) {
let date = new Date(); let date = new Date();
date.setHours(0); date.setHours(0);
date.setMinutes(0); date.setMinutes(0);
date.setSeconds(0); date.setSeconds(0);
date.setMilliseconds(0); date.setMilliseconds(0);
date.setDate(1); date.setDate(1);
this.twigsService.getTransactions(this.budgetId, this.categoryId, null, date).subscribe(transactions => { from = date;
}
let toStr = this.route.snapshot.queryParamMap.get('to');
let to: Date;
if (toStr) {
let toDate = new Date(toStr);
if (!isNaN(toDate.getTime())) {
to = toDate;
}
}
this.twigsService.getTransactions(this.budgetIds.join(','), this.categoryIds?.join(','), null, from, to)
.then(transactions => {
this.transactions = transactions; this.transactions = transactions;
}); });
} }

View file

@ -1,11 +1,13 @@
import { randomId } from '../shared/utils';
export class Transaction { export class Transaction {
id: number; id: string = randomId();
title: string; title: string;
description: string = null; description: string = null;
date: Date = new Date(); date: Date = new Date();
amount: number; amount: number;
expense = true; expense = true;
categoryId: number; categoryId: string;
budgetId: number; budgetId: string;
createdBy: number; createdBy: string;
} }

View file

@ -1,4 +1,4 @@
<app-transaction-list [budgetId]="budgetId" [categoryId]="categoryId"></app-transaction-list> <app-transaction-list [budgetIds]="[budgetId]" [categoryIds]="categoryId"></app-transaction-list>
<a mat-fab routerLink="/budgets/{{ budgetId }}/transactions/new"> <a mat-fab routerLink="/transactions/new" [queryParams]="{budgetId: budgetId}">
<mat-icon aria-label="Add">add</mat-icon> <mat-icon aria-label="Add">add</mat-icon>
</a> </a>

View file

@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TransactionsComponent } from './transactions.component'; import { TransactionsComponent } from './transactions.component';
@ -6,7 +6,7 @@ describe('ExpensesComponent', () => {
let component: TransactionsComponent; let component: TransactionsComponent;
let fixture: ComponentFixture<TransactionsComponent>; let fixture: ComponentFixture<TransactionsComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ TransactionsComponent ] declarations: [ TransactionsComponent ]
}) })

View file

@ -9,8 +9,8 @@ import { ActivatedRoute } from '@angular/router';
}) })
export class TransactionsComponent implements OnInit { export class TransactionsComponent implements OnInit {
budgetId: number; budgetId?: string;
categoryId?: number; categoryId?: string;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
@ -18,9 +18,9 @@ export class TransactionsComponent implements OnInit {
) { } ) { }
ngOnInit() { ngOnInit() {
this.budgetId = Number.parseInt(this.route.snapshot.paramMap.get('budgetId')); this.budgetId = this.route.snapshot.queryParamMap.get('budgetIds');
this.categoryId = Number.parseInt(this.route.snapshot.queryParamMap.get('categoryId')); this.categoryId = this.route.snapshot.queryParamMap.get('categoryIds');
this.app.backEnabled = true; this.app.setBackEnabled(true);
this.app.title = 'Transactions'; this.app.setTitle('Transactions')
} }
} }

View file

@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { EditProfileComponent } from './edit-profile.component'; import { EditProfileComponent } from './edit-profile.component';
@ -6,7 +6,7 @@ describe('EditProfileComponent', () => {
let component: EditProfileComponent; let component: EditProfileComponent;
let fixture: ComponentFixture<EditProfileComponent>; let fixture: ComponentFixture<EditProfileComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ EditProfileComponent ] declarations: [ EditProfileComponent ]
}) })

View file

@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { LoginComponent } from './login.component'; import { LoginComponent } from './login.component';
@ -6,7 +6,7 @@ describe('LoginComponent', () => {
let component: LoginComponent; let component: LoginComponent;
let fixture: ComponentFixture<LoginComponent>; let fixture: ComponentFixture<LoginComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ LoginComponent ] declarations: [ LoginComponent ]
}) })

View file

@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy, Inject, ChangeDetectorRef } from '@angula
import { TwigsService, TWIGS_SERVICE } from '../../shared/twigs.service'; import { TwigsService, TWIGS_SERVICE } from '../../shared/twigs.service';
import { User } from '../user'; import { User } from '../user';
import { AppComponent } from 'src/app/app.component'; import { AppComponent } from 'src/app/app.component';
import { Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
@Component({ @Component({
selector: 'app-login', selector: 'app-login',
@ -14,27 +14,31 @@ export class LoginComponent implements OnInit {
public isLoading = false; public isLoading = false;
public email: string; public email: string;
public password: string; public password: string;
private redirect: string;
constructor( constructor(
private app: AppComponent, private app: AppComponent,
@Inject(TWIGS_SERVICE) private twigsService: TwigsService, @Inject(TWIGS_SERVICE) private twigsService: TwigsService,
private router: Router, private router: Router,
private activatedRoute: ActivatedRoute
) { } ) { }
ngOnInit() { ngOnInit() {
this.app.title = 'Login'; this.app.setTitle('Login')
this.app.backEnabled = true; this.app.setBackEnabled(true);
this.redirect = this.activatedRoute.snapshot.queryParamMap.get('redirect');
} }
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.router.navigate([this.redirect || '/'])
}, })
error => { .catch(error => {
console.error(error) console.error(error)
// 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

@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { RegisterComponent } from './register.component'; import { RegisterComponent } from './register.component';
@ -6,7 +6,7 @@ describe('RegisterComponent', () => {
let component: RegisterComponent; let component: RegisterComponent;
let fixture: ComponentFixture<RegisterComponent>; let fixture: ComponentFixture<RegisterComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ RegisterComponent ] declarations: [ RegisterComponent ]
}) })

View file

@ -23,8 +23,8 @@ export class RegisterComponent implements OnInit {
) { } ) { }
ngOnInit() { ngOnInit() {
this.app.title = 'Register'; this.app.setTitle('Register')
this.app.backEnabled = true; this.app.setBackEnabled(true);
} }
register(): void { register(): void {
@ -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

@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { UserComponent } from './user.component'; import { UserComponent } from './user.component';
@ -6,7 +6,7 @@ describe('UserComponent', () => {
let component: UserComponent; let component: UserComponent;
let fixture: ComponentFixture<UserComponent>; let fixture: ComponentFixture<UserComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ UserComponent ] declarations: [ UserComponent ]
}) })

View file

@ -1,27 +1,36 @@
import { randomId } from "../shared/utils";
export class User { export class User {
id: number; id: string = randomId();
username: string; username: string;
email: string; email: string;
constructor(id?: number, username?: string, email?: string) { constructor(id?: string, username?: string, email?: string) {
this.id = id; this.id = id;
this.username = username; this.username = username;
this.email = email; this.email = email;
} }
} }
export class AuthToken {
userId: string;
token: string;
expiration: Date;
}
export class UserPermission { export class UserPermission {
user: number; user: string;
permission: Permission; permission: Permission;
constructor(user: number, permission: Permission) { constructor(user: string, permission: Permission) {
this.user = user; this.user = user;
this.permission = permission; this.permission = permission;
} }
} }
export enum Permission { export enum Permission {
READ, READ = "READ",
WRITE, WRITE = "WRITE",
OWNER MANAGE = "MANAGE",
OWNER = "OWNER"
} }

View file

@ -1,4 +0,0 @@
export const environment = {
production: false,
apiUrl: 'https://3000code.brawner.home'
};

View file

@ -1,4 +1,4 @@
export const environment = { export const environment = {
production: true, production: true,
apiUrl: 'https://api.twigs.brawner.dev' apiUrl: 'https://twigs.api.wbrawner.com/api'
}; };

View file

@ -4,7 +4,7 @@
export const environment = { export const environment = {
production: false, production: false,
apiUrl: 'http://localhost:8080' apiUrl: 'http://localhost:8080/api'
}; };
/* /*
@ -13,4 +13,4 @@ export const environment = {
* below file. Don't forget to comment it out in production mode * below file. Don't forget to comment it out in production mode
* because it will have a performance impact when errors are thrown * because it will have a performance impact when errors are thrown
*/ */
// import 'zone.js/dist/zone-error'; // Included with Angular CLI. import 'zone.js/plugins/zone-error'; // Included with Angular CLI.

View file

@ -13,7 +13,7 @@
<meta name="apple-mobile-web-app-title" content="Twigs"> <meta name="apple-mobile-web-app-title" content="Twigs">
<meta name="application-name" content="Twigs"> <meta name="application-name" content="Twigs">
<meta name="msapplication-TileColor" content="#81c784"> <meta name="msapplication-TileColor" content="#81c784">
<meta name="theme-color" content="#212121"> <meta name="theme-color" content="#FFFFFF">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
</head> </head>

View file

@ -18,41 +18,6 @@
* BROWSER POLYFILLS * BROWSER POLYFILLS
*/ */
/** IE9, IE10 and IE11 requires all of the following polyfills. **/
// import 'core-js/es6/symbol';
// import 'core-js/es6/object';
// import 'core-js/es6/function';
// import 'core-js/es6/parse-int';
// import 'core-js/es6/parse-float';
// import 'core-js/es6/number';
// import 'core-js/es6/math';
// import 'core-js/es6/string';
// import 'core-js/es6/date';
// import 'core-js/es6/array';
// import 'core-js/es6/regexp';
// import 'core-js/es6/map';
// import 'core-js/es6/weak-map';
// import 'core-js/es6/set';
/** IE10 and IE11 requires the following for NgClass support on SVG elements */
// import 'classlist.js'; // Run `npm install --save classlist.js`.
/** IE10 and IE11 requires the following for the Reflect API. */
import 'core-js/es6/reflect';
/** Evergreen browsers require these. **/
// Used for reflect-metadata in JIT. If you use AOT (and only Angular decorators), you can remove.
import 'core-js/es7/reflect';
/**
* Web Animations `@angular/platform-browser/animations`
* Only required if AnimationBuilder is used within the application and using IE/Edge or Safari.
* Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0).
**/
// import 'web-animations-js'; // Run `npm install --save web-animations-js`.
/** /**
* By default, zone.js will patch all possible macroTask and DomEvents * By default, zone.js will patch all possible macroTask and DomEvents
* user can disable parts of macroTask/DomEvents patch by setting following flags * user can disable parts of macroTask/DomEvents patch by setting following flags
@ -71,7 +36,7 @@ import 'core-js/es7/reflect';
/*************************************************************************************************** /***************************************************************************************************
* Zone JS is required by default for Angular itself. * Zone JS is required by default for Angular itself.
*/ */
import 'zone.js/dist/zone'; // Included with Angular CLI. import 'zone.js'; // Included with Angular CLI.

View file

@ -2,14 +2,14 @@
html, html,
body { body {
background: #333333; background: #F1F1F1;
font-family: Roboto, "Helvetica Neue", sans-serif; font-family: Roboto, "Helvetica Neue", sans-serif;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
p { p {
color: #F1F1F1; color: #333333;
margin: 0; margin: 0;
} }
@ -20,8 +20,8 @@ a.mat-fab {
} }
.text-small { .text-small {
font-size: 1em; font-size: 90%;
color: #BDBDBD color: #333333;
} }
mat-toolbar.mat-toolbar-row, mat-toolbar.mat-toolbar-row,
@ -29,21 +29,21 @@ mat-toolbar.mat-toolbar-single-row {
padding: 0; padding: 0;
} }
mat-toolbar > span { mat-toolbar>span {
display: flex; display: flex;
width: 33%; width: 33%;
; ;
} }
mat-toolbar > span:nth-child(1) { mat-toolbar>span:nth-child(1) {
justify-content: flex-start; justify-content: flex-start;
} }
mat-toolbar > span:nth-child(2) { mat-toolbar>span:nth-child(2) {
justify-content: center; justify-content: center;
} }
mat-toolbar > span:nth-child(3) { mat-toolbar>span:nth-child(3) {
justify-content: flex-end; justify-content: flex-end;
} }
@ -105,7 +105,7 @@ mat-sidenav {
.form { .form {
padding: 1em; padding: 1em;
color: #F1F1F1; color: #333333;
} }
.form .mat-form-field, .form .mat-form-field,
@ -116,3 +116,28 @@ mat-sidenav {
.form mat-radio-button { .form mat-radio-button {
padding-bottom: 15px; padding-bottom: 15px;
} }
.form button {
width: 100%;
margin: 1em 0;
}
@media (prefers-color-scheme: dark) {
html,
body {
background: #333333;
}
p {
color: #F1F1F1;
}
.text-small {
color: #BDBDBD;
}
.form {
color: #F1F1F1;
}
}

Some files were not shown because too many files have changed in this diff Show more