Compare commits

..

15 commits
main ... server

Author SHA1 Message Date
590fda99fd Check session expiration and update if still valid 2022-07-12 22:06:53 -06:00
53b4036fcb Implement login and registration 2022-07-12 22:06:53 -06:00
ab4f1e495d Install missing dependencies 2022-07-12 22:06:51 -06:00
3c429e2123 Update publish and package commands to use new syntax 2022-07-12 22:04:27 -06:00
270359d73b Fixes for new Ktor API 2022-07-12 22:03:56 -06:00
eacc18e461 Finish implementing /api/budget routes
Signed-off-by: William Brawner <me@wbrawner.com>
2022-07-12 22:03:56 -06:00
9bc86d620a Check session expiration and update if still valid 2022-07-12 22:03:56 -06:00
f5f4dbe7e1 Implement login and registration 2022-07-12 22:03:55 -06:00
5a8613c701 Set up database migrations to run automatically on server startup 2022-07-12 22:03:12 -06:00
a272c420cd Install missing dependencies 2022-07-12 22:03:10 -06:00
31af400281 Fix sqlite migration 0-1 syntax 2022-07-12 22:00:01 -06:00
4643f00e15 WIP: setup database migrations
Signed-off-by: William Brawner <me@wbrawner.com>
2022-07-12 22:00:01 -06:00
1b2a5e220c Add sqlite3
Signed-off-by: William Brawner <me@wbrawner.com>
2022-07-12 22:00:00 -06:00
621782a3a8 WIP: Implement backend in express
Signed-off-by: William Brawner <me@wbrawner.com>
2022-07-12 21:52:21 -06:00
03bfd1bed3 Move angular code to src/client
Signed-off-by: William Brawner <me@wbrawner.com>
2022-07-12 21:52:20 -06:00
169 changed files with 10367 additions and 12566 deletions

View file

@ -1,12 +0,0 @@
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

@ -1,23 +0,0 @@
// 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

@ -1,46 +0,0 @@
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:

View file

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

View file

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

@ -5,7 +5,7 @@
"projects": {
"twigs": {
"root": "",
"sourceRoot": "src",
"sourceRoot": "src/client",
"projectType": "application",
"prefix": "app",
"schematics": {},
@ -13,23 +13,23 @@
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/twigs",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.app.json",
"outputPath": "dist/public",
"index": "src/client/index.html",
"main": "src/client/main.ts",
"polyfills": "src/client/polyfills.ts",
"tsConfig": "src/client/tsconfig.app.json",
"assets": [
"src/browserconfig.xml",
"src/favicon-16x16.png",
"src/favicon-32x32.png",
"src/favicon-96x96.png",
"src/favicon.ico",
"src/assets",
"src/manifest.json"
"src/client/browserconfig.xml",
"src/client/favicon-16x16.png",
"src/client/favicon-32x32.png",
"src/client/favicon-96x96.png",
"src/client/favicon.ico",
"src/client/assets",
"src/client/manifest.json"
],
"styles": [
"src/styles.css",
"src/styles.scss"
"src/client/styles.css",
"src/client/styles.scss"
],
"scripts": [],
"vendorChunk": true,
@ -49,8 +49,8 @@
],
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
"replace": "src/client/environments/environment.ts",
"with": "src/client/environments/environment.prod.ts"
}
],
"optimization": true,
@ -71,8 +71,8 @@
],
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.codeserver.ts"
"replace": "src/client/environments/environment.ts",
"with": "src/client/environments/environment.codeserver.ts"
}
]
}
@ -82,38 +82,38 @@
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"buildTarget": "twigs:build"
"browserTarget": "twigs:build"
},
"configurations": {
"production": {
"buildTarget": "twigs:build:production"
"browserTarget": "twigs:build:production"
},
"codeserver": {
"buildTarget": "twigs:build:codeserver"
"browserTarget": "twigs:build:codeserver"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"buildTarget": "twigs:build"
"browserTarget": "twigs:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.spec.json",
"karmaConfig": "src/karma.conf.js",
"main": "src/client/test.ts",
"polyfills": "src/client/polyfills.ts",
"tsConfig": "src/client/tsconfig.spec.json",
"karmaConfig": "src/client/karma.conf.js",
"styles": [
"src/styles.css"
"src/client/styles.css"
],
"scripts": [],
"assets": [
"src/favicon.ico",
"src/assets",
"src/manifest.json"
"src/client/favicon.ico",
"src/client/assets",
"src/client/manifest.json"
]
}
}
@ -141,4 +141,4 @@
"cli": {
"analytics": "b8304464-255e-47bb-976a-7ed81af63238"
}
}
}

View file

@ -6,7 +6,7 @@ const { SpecReporter } = require('jasmine-spec-reporter');
exports.config = {
allScriptsTimeout: 11000,
specs: [
'./src/**/*.e2e-spec.ts'
'./src/client/**/*.e2e-spec.ts'
],
capabilities: {
'browserName': 'chrome'

20047
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -2,12 +2,16 @@
"name": "budget",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve --configuration=production --host '0.0.0.0'",
"prestart": "npm run build",
"start": "node dist/index.js",
"code-server": "ng serve --configuration=codeserver --host \"0.0.0.0\" --disable-host-check --poll=2000",
"build": "ng build",
"package": "ng build --configuration=production --service-worker",
"publish": "ng build --configuration=production --service-worker && firebase deploy",
"build:client": "ng build",
"build:server": "tsc -p src/server",
"build": "npm run build:client && npm run build:server",
"watch:client": "ng build --watch",
"watch:server": "nodemon -w src/server -e ts --exec \"npm run build:server && node dist/index.js\"",
"watch": "concurrently -k \"npm run watch:client\" \"npm run watch:server\"",
"package": "ng build --configuration=prod --service-worker",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e",
@ -15,33 +19,41 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^17.2.3",
"@angular/cdk": "^17.2.1",
"@angular/common": "^17.2.3",
"@angular/compiler": "^17.2.3",
"@angular/core": "^17.2.3",
"@angular/forms": "^17.2.3",
"@angular/material": "^16.2.0",
"@angular/platform-browser": "^17.2.3",
"@angular/platform-browser-dynamic": "^17.2.3",
"@angular/router": "^17.2.3",
"@angular/service-worker": "^17.2.3",
"@angular/animations": "^14.0.4",
"@angular/cdk": "^14.0.4",
"@angular/common": "^14.0.4",
"@angular/compiler": "^14.0.4",
"@angular/core": "^14.0.4",
"@angular/forms": "^14.0.4",
"@angular/material": "^14.0.4",
"@angular/platform-browser": "^14.0.4",
"@angular/platform-browser-dynamic": "^14.0.4",
"@angular/router": "^14.0.4",
"@angular/service-worker": "^14.0.4",
"chart.js": "^3.7.0",
"core-js": "^3.20.3",
"ng2-charts": "^3.0.8",
"rxjs": "^7.5.2",
"sqlite3": "^5.0.1",
"tslib": "^2.3.1",
"zone.js": "~0.14.4"
"zone.js": "~0.11.4"
},
"devDependencies": {
"@angular-devkit/build-angular": "^17.2.2",
"@angular/cli": "^17.2.2",
"@angular/compiler-cli": "^17.2.3",
"@angular/language-service": "^17.2.3",
"@angular-devkit/build-angular": "^14.0.4",
"@angular/cli": "^14.0.4",
"@angular/compiler-cli": "^14.0.4",
"@angular/language-service": "^14.0.4",
"@types/bcrypt": "^3.0.0",
"@types/express": "^4.17.11",
"@types/express-serve-static-core": "^4.17.18",
"@types/jasmine": "~3.10.3",
"@types/jasminewd2": "^2.0.10",
"@types/node": "^17.0.10",
"@types/sqlite3": "^3.1.7",
"bcrypt": "^5.0.0",
"concurrently": "^5.3.0",
"eslint": "^8.7.0",
"express": "^4.17.1",
"jasmine-core": "~4.0.0",
"jasmine-spec-reporter": "~7.0.0",
"karma": "~6.3.11",
@ -49,10 +61,12 @@
"karma-coverage-istanbul-reporter": "~3.0.3",
"karma-jasmine": "~4.0.1",
"karma-jasmine-html-reporter": "^1.7.0",
"nodemon": "^2.0.7",
"npm-check-updates": "^15.0.1",
"protractor": "^7.0.0",
"sqlite3": "^5.0.1",
"ts-node": "~10.4.0",
"tslint": "^6.1.3",
"typescript": "5.3.3"
"typescript": "4.7.4"
}
}
}

View file

@ -1,134 +0,0 @@
import { Component, Inject, ApplicationRef, ChangeDetectorRef, OnInit } from '@angular/core';
import { DOCUMENT, Location } from '@angular/common';
import { User } from './users/user';
import { TWIGS_SERVICE, TwigsService } from './shared/twigs.service';
import { SwUpdate } from '@angular/service-worker';
import { first, filter, map } from 'rxjs/operators';
import { interval, concat, BehaviorSubject } from 'rxjs';
import { Router, ActivationEnd, ActivatedRoute } from '@angular/router';
import { Actionable, isActionable } from './shared/actionable';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
public title = 'Twigs';
public backEnabled = false;
public user = new BehaviorSubject<User>(null);
public online = window.navigator.onLine;
public currentVersion = '';
public actionable: Actionable;
public loggedIn = false;
constructor(
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
private location: Location,
private router: Router,
private appRef: ApplicationRef,
private updates: SwUpdate,
private changeDetector: ChangeDetectorRef,
private storage: Storage,
@Inject(DOCUMENT) private document: Document
) { }
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 if (unauthenticatedRoutes.indexOf(this.location.path()) == -1) {
this.router.navigateByUrl(`/login?redirect=${this.location.path()}`);
}
this.updates.versionUpdates.subscribe(
event => {
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
this.updates.activateUpdate();
}
},
err => {
}
);
const appIsStable$ = this.appRef.isStable.pipe(first(isStable => isStable === true));
const everySixHours$ = interval(6 * 60 * 60 * 1000);
const everySixHoursOnceAppIsStable$ = concat(appIsStable$, everySixHours$);
everySixHoursOnceAppIsStable$.subscribe(() => this.updates.checkForUpdate());
this.user.subscribe(
user => {
if (user) {
this.loggedIn = true;
} else {
this.loggedIn = false;
}
}
)
const darkMode = window.matchMedia('(prefers-color-scheme: dark)');
this.handleDarkModeChanges(darkMode);
darkMode.addEventListener('change', (e => this.handleDarkModeChanges(e)))
}
getUsername(): String {
return this.user.value.username;
}
goBack(): void {
this.location.back();
}
logout(): void {
this.twigsService.logout().then(_ => {
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

@ -1,221 +0,0 @@
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,370 +0,0 @@
import { Injectable } from '@angular/core';
import { User, UserPermission, Permission, AuthToken } from '../users/user';
import { TwigsService } from './twigs.service';
import { Budget } from '../budgets/budget';
import { Category } from '../categories/category';
import { Transaction } from '../transactions/transaction';
import { environment } from '../../environments/environment';
import { Frequency, RecurringTransaction } from '../recurringtransactions/recurringtransaction';
@Injectable({
providedIn: 'root'
})
export class TwigsHttpService implements TwigsService {
private apiUrl = environment.apiUrl;
constructor(
private storage: Storage
) { }
async login(email: string, password: string): Promise<User> {
const url = new URL('/api/users/login', this.apiUrl)
const auth: AuthToken = await this.request(url, HttpMethod.POST, {
'username': email,
'password': password
});
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): Promise<User> {
const body = {
'username': username,
'email': email,
'password': password
};
const url = new URL('/api/users/register', this.apiUrl)
return this.request<User>(url, HttpMethod.POST, body);
}
logout(): Promise<void> {
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
getBudgets(): Promise<Budget[]> {
const url = new URL('/api/budgets', this.apiUrl)
return this.request(url, HttpMethod.GET)
}
getBudgetBalance(
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(
id: string,
name: string,
description: string,
users: UserPermission[],
): Promise<Budget> {
const url = new URL('/api/budgets', this.apiUrl)
const body = {
'id': id,
'name': name,
'description': description,
'users': users.map(userPermission => {
return {
user: userPermission.user,
permission: Permission[userPermission.permission]
};
})
};
return this.request(url, HttpMethod.POST, body)
}
updateBudget(id: string, budget: Budget): Promise<Budget> {
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: String): Promise<void> {
const url = new URL(`/api/budgets/${id}`, this.apiUrl)
return this.request(url, HttpMethod.DELETE)
}
// Categories
getCategories(budgetId: string, count?: number): Promise<Category[]> {
const url = new URL(`/api/categories`, this.apiUrl)
url.searchParams.set('budgetIds', budgetId)
url.searchParams.set('archived', 'false')
return this.request(url, HttpMethod.GET);
}
getCategory(id: string): Promise<Category> {
const url = new URL(`/api/categories/${id}`, this.apiUrl)
return this.request(url, HttpMethod.GET);
}
async getCategoryBalance(
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,
'description': description,
'amount': amount,
'expense': isExpense,
'budgetId': budgetId
};
return this.request(url, HttpMethod.POST, body);
}
updateCategory(id: string, changes: object): Promise<Category> {
const url = new URL(`/api/categories/${id}`, this.apiUrl)
return this.request(url, HttpMethod.PUT, changes);
}
deleteCategory(id: string): Promise<void> {
const url = new URL(`/api/categories/${id}`, this.apiUrl)
return this.request(url, HttpMethod.DELETE);
}
// Transactions
async getTransactions(
budgetId?: string,
categoryId?: string,
count?: number,
from?: Date,
to?: Date
): Promise<Transaction[]> {
const url = new URL(`/api/transactions`, 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: Transaction[] = await this.request(url, HttpMethod.GET)
transactions.forEach(transaction => {
transaction.date = new Date(transaction.date);
})
return transactions
}
async getTransaction(id: string): Promise<Transaction> {
const url = new URL(`/api/transactions/${id}`, this.apiUrl)
const transaction: Transaction = await this.request(url, HttpMethod.GET)
transaction.date = new Date(transaction.date)
return transaction
}
async createTransaction(
id: string,
budgetId: string,
name: string,
description: string,
amount: number,
date: Date,
expense: boolean,
category: string
): Promise<Transaction> {
const url = new URL(`/api/transactions`, this.apiUrl)
const body = {
'id': id,
'title': name,
'description': description,
'date': date.toISOString(),
'amount': amount,
'expense': expense,
'categoryId': category,
'budgetId': budgetId
};
const transaction: Transaction = await this.request(url, HttpMethod.POST, body)
transaction.date = new Date(transaction.date)
return transaction
}
async updateTransaction(id: string, transaction: Transaction): Promise<Transaction> {
const body: any = transaction;
body.date = transaction.date.toISOString()
const url = new URL(`/api/transactions/${id}`, this.apiUrl)
const updatedTransaction: Transaction = await this.request(url, HttpMethod.PUT, body)
updatedTransaction.date = new Date(updatedTransaction.date)
return updatedTransaction
}
deleteTransaction(id: string): Promise<void> {
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
getProfile(id: string): Promise<User> {
const url = new URL(`/api/users/${id}`, this.apiUrl)
return this.request(url, HttpMethod.GET)
}
getUsersByUsername(username: string): Promise<User[]> {
return Promise.reject("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,85 +0,0 @@
import { InjectionToken } from '@angular/core';
import { User, UserPermission } from '../users/user';
import { Budget } from '../budgets/budget';
import { Category } from '../categories/category';
import { RecurringTransaction, Frequency } from '../recurringtransactions/recurringtransaction';
import { Transaction } from '../transactions/transaction';
export interface TwigsService {
// Auth
login(email: string, password: string): Promise<User>;
register(username: string, email: string, password: string): Promise<User>;
logout(): Promise<void>;
// Budgets
getBudgets(): Promise<Budget[]>;
getBudget(id: string): Promise<Budget>;
getBudgetBalance(id: string, from?: Date, to?: Date): Promise<number>;
createBudget(
id: string,
name: string,
description: string,
users: UserPermission[],
): Promise<Budget>;
updateBudget(id: string, budget: Budget): Promise<Budget>;
deleteBudget(id: string): Promise<void>;
// Categories
getCategories(budgetId?: string, count?: number): Promise<Category[]>;
getCategory(id: string): Promise<Category>;
getCategoryBalance(id: string, from?: Date, to?: Date): Promise<number>;
createCategory(id: string, budgetId: string, name: string, description: string, amount: number, isExpense: boolean): Promise<Category>;
updateCategory(id: string, category: Category): Promise<Category>;
deleteCategory(id: string): Promise<void>;
// Transactions
getTransactions(
budgetId?: string,
categoryId?: string,
count?: number,
from?: Date,
to?: Date
): Promise<Transaction[]>;
getTransaction(id: string): Promise<Transaction>;
createTransaction(
id: string,
budgetId: string,
name: string,
description: string,
amount: number,
date: Date,
isExpense: boolean,
category: string
): Promise<Transaction>;
updateTransaction(id: string, transaction: Transaction): Promise<Transaction>;
deleteTransaction(id: string): Promise<void>;
// Recurring Transactions
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');

View file

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

View file

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

View file

@ -0,0 +1,141 @@
import { Component, Inject, ApplicationRef, ChangeDetectorRef, OnInit } from '@angular/core';
import { DOCUMENT, Location } from '@angular/common';
import { User } from './users/user';
import { TWIGS_SERVICE, TwigsService } from './shared/twigs.service';
import { SwUpdate } from '@angular/service-worker';
import { first, filter, map } from 'rxjs/operators';
import { interval, concat, BehaviorSubject } from 'rxjs';
import { Router } from '@angular/router';
import { Actionable, isActionable } from './shared/actionable';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
public title = 'Twigs';
public backEnabled = false;
public user = new BehaviorSubject<User>(null);
public online = window.navigator.onLine;
public currentVersion = '';
public actionable: Actionable;
public loggedIn = false;
constructor(
@Inject(TWIGS_SERVICE) private twigsService: TwigsService,
private location: Location,
private router: Router,
private appRef: ApplicationRef,
private updates: SwUpdate,
private changeDetector: ChangeDetectorRef,
private storage: Storage,
@Inject(DOCUMENT) private document: Document
) { }
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) {
if (savedUser) {
this.user.next(savedUser);
}
this.twigsService.getProfile(userId).subscribe(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 if (unauthenticatedRoutes.indexOf(this.location.path()) == -1) {
this.router.navigateByUrl(`/login?redirect=${this.location.path()}`);
}
this.updates.available.subscribe(
event => {
console.log('current version is', event.current);
console.log('available version is', event.available);
// TODO: Prompt user to click something to update
this.updates.activateUpdate();
},
err => {
}
);
this.updates.activated.subscribe(
event => {
console.log('old version was', event.previous);
console.log('new version is', event.current);
},
err => {
}
);
const appIsStable$ = this.appRef.isStable.pipe(first(isStable => isStable === true));
const everySixHours$ = interval(6 * 60 * 60 * 1000);
const everySixHoursOnceAppIsStable$ = concat(appIsStable$, everySixHours$);
everySixHoursOnceAppIsStable$.subscribe(() => this.updates.checkForUpdate());
this.user.subscribe(
user => {
if (user) {
this.loggedIn = true;
} else {
this.loggedIn = false;
}
}
)
const darkMode = window.matchMedia('(prefers-color-scheme: dark)');
this.handleDarkModeChanges(darkMode);
darkMode.addEventListener('change', (e => this.handleDarkModeChanges(e)))
}
getUsername(): String {
return this.user.value.username;
}
goBack(): void {
this.location.back();
}
logout(): void {
this.twigsService.logout().subscribe(_ => {
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 { FormsModule } from '@angular/forms';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatButtonModule } from '@angular/material/button';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatLegacyFormFieldModule as MatFormFieldModule } from '@angular/material/legacy-form-field';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatLegacyInputModule as MatInputModule } from '@angular/material/legacy-input';
import { MatLegacyListModule as MatListModule } from '@angular/material/legacy-list';
import { MatLegacyProgressBarModule as MatProgressBarModule } from '@angular/material/legacy-progress-bar';
import { MatLegacyProgressSpinnerModule as MatProgressSpinnerModule } from '@angular/material/legacy-progress-spinner';
import { MatLegacyRadioModule as MatRadioModule } from '@angular/material/legacy-radio';
import { MatLegacySelectModule as MatSelectModule } from '@angular/material/legacy-select';
import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatRadioModule } from '@angular/material/radio';
import { MatSelectModule } from '@angular/material/select';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatLegacyCheckboxModule as MatCheckboxModule } from '@angular/material/legacy-checkbox';
import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatCardModule } from '@angular/material/card';
import { AppComponent } from './app.component';
import { TransactionsComponent } from './transactions/transactions.component';
@ -36,7 +36,7 @@ import { EditProfileComponent } from './users/edit-profile/edit-profile.componen
import { UserComponent } from './users/user.component';
import { NewBudgetComponent } from './budgets/new-budget/new-budget.component';
import { BudgetDetailsComponent } from './budgets/budget-details/budget-details.component';
import { environment } from 'src/environments/environment';
import { environment } from 'src/client/environments/environment';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { ServiceWorkerModule } from '@angular/service-worker';
import { CategoryBreakdownComponent } from './categories/category-breakdown/category-breakdown.component';

View file

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

View file

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

View file

@ -1,11 +1,13 @@
import { Component, OnInit, Inject, OnDestroy } from '@angular/core';
import { Budget } from '../budget';
import { ActivatedRoute, Router } from '@angular/router';
import { AppComponent } from 'src/app/app.component';
import { Transaction } from 'src/app/transactions/transaction';
import { Category } from 'src/app/categories/category';
import { AppComponent } from 'src/client/app/app.component';
import { Transaction } from 'src/client/app/transactions/transaction';
import { Category } from 'src/client/app/categories/category';
import { Observable } from 'rxjs';
// import { Label } from 'ng2-charts';
import { ChartDataset } from 'chart.js';
import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service';
import { TWIGS_SERVICE, TwigsService } from 'src/client/app/shared/twigs.service';
import { Actionable } from '../../shared/actionable';
@Component({
@ -14,6 +16,7 @@ import { Actionable } from '../../shared/actionable';
styleUrls: ['./budget-details.component.css']
})
export class BudgetDetailsComponent implements OnInit, OnDestroy, Actionable {
budget: Budget;
public budgetBalance: number;
public transactions: Transaction[];
@ -79,7 +82,7 @@ export class BudgetDetailsComponent implements OnInit, OnDestroy, Actionable {
getBudget() {
const id = this.route.snapshot.paramMap.get('id');
this.twigsService.getBudget(id)
.then(budget => {
.subscribe(budget => {
this.app.setTitle(budget.name)
this.budget = budget;
this.getBalance();
@ -112,10 +115,9 @@ export class BudgetDetailsComponent implements OnInit, OnDestroy, Actionable {
getBalance(): void {
const id = this.route.snapshot.paramMap.get('id');
this.twigsService.getBudgetBalance(id, this.from, this.to)
.then(balance => {
this.budgetBalance = balance;
});
this.twigsService.getBudgetBalance(id, this.from, this.to).subscribe(balance => {
this.budgetBalance = balance;
});
}
getTransactions(): void {
@ -126,41 +128,46 @@ export class BudgetDetailsComponent implements OnInit, OnDestroy, Actionable {
date.setMilliseconds(0);
date.setDate(1);
this.twigsService.getTransactions(this.budget.id, null, 5, date)
.then(transactions => this.transactions = <Transaction[]>transactions);
.subscribe(transactions => this.transactions = <Transaction[]>transactions);
}
async getCategories() {
const categories = await this.twigsService.getCategories(this.budget.id)
const categoryBalances = new Map<string, number>();
let categoryBalancesCount = 0;
for (const category of categories) {
if (category.expense) {
this.expenses.push(category);
this.expectedExpenses += category.amount;
} else {
this.income.push(category);
this.expectedIncome += category.amount;
}
try {
const balance = await this.twigsService.getCategoryBalance(category.id, this.from, this.to)
console.log(balance);
getCategories(): void {
this.twigsService.getCategories(this.budget.id).subscribe(categories => {
const categoryBalances = new Map<string, number>();
let categoryBalancesCount = 0;
console.log(categories);
for (const category of categories) {
if (category.expense) {
this.actualExpenses += balance * -1;
this.expenses.push(category);
this.expectedExpenses += category.amount;
} else {
this.actualIncome += balance;
this.income.push(category);
this.expectedIncome += category.amount;
}
categoryBalances.set(category.id, balance);
if (categoryBalancesCount === categories.length - 1) {
// 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
// work.
this.categoryBalances = categoryBalances;
this.updateBarChart();
}
} finally {
categoryBalancesCount++;
this.twigsService.getCategoryBalance(category.id, this.from, this.to).subscribe(
balance => {
console.log(balance);
if (category.expense) {
this.actualExpenses += balance * -1;
} else {
this.actualIncome += balance;
}
categoryBalances.set(category.id, balance);
categoryBalancesCount++;
},
error => { categoryBalancesCount++; },
() => {
// 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
// work.
if (categoryBalancesCount === categories.length) {
this.categoryBalances = categoryBalances;
this.updateBarChart();
}
}
);
}
}
});
}
doAction(): void {

View file

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

View file

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

View file

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

View file

@ -28,7 +28,7 @@ export class CategoryDetailsComponent implements OnInit, OnDestroy, Actionable {
this.router.navigateByUrl(this.router.routerState.snapshot.url + "/edit")
}
getActionLabel(): string {
getActionLabel(): string {
return "Edit";
}
@ -45,7 +45,7 @@ export class CategoryDetailsComponent implements OnInit, OnDestroy, Actionable {
getCategory(): void {
const id = this.route.snapshot.paramMap.get('id');
this.twigsService.getCategory(id)
.then(category => {
.subscribe(category => {
category.amount /= 100;
this.app.setTitle(category.title)
this.category = category;

View file

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

View file

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

View file

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

View file

@ -0,0 +1,306 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http';
import { BehaviorSubject, Observable, pipe, Subscriber } from 'rxjs';
import { User, UserPermission, Permission, AuthToken } from '../users/user';
import { TwigsService } from './twigs.service';
import { Budget } from '../budgets/budget';
import { Category } from '../categories/category';
import { Transaction } from '../transactions/transaction';
import { environment } from '../../environments/environment';
import { map } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class TwigsHttpService implements TwigsService {
private options = {
withCredentials: true
};
private apiUrl = environment.apiUrl;
private budgets: BehaviorSubject<Budget[]> = new BehaviorSubject(null);
constructor(
private http: HttpClient,
private storage: Storage
) { }
login(email: string, password: string): Observable<User> {
return new Observable(emitter => {
const params = {
'username': email,
'password': password
};
this.http.post<AuthToken>(this.apiUrl + '/users/login', params, this.options)
.subscribe(
auth => {
// TODO: Use token expiration to determine cookie expiration
this.storage.setItem('Authorization', auth.token);
this.storage.setItem('userId', auth.userId);
this.getProfile(auth.userId).subscribe(user => emitter.next(user), error => emitter.error(error));
},
error => emitter.error(error)
);
});
}
register(username: string, email: string, password: string): Observable<User> {
const params = {
'username': username,
'email': email,
'password': password
};
return this.http.post<User>(this.apiUrl + '/users/register', params, this.options);
}
logout(): Observable<void> {
return new Observable(emitter => {
this.storage.removeItem('Authorization');
this.storage.removeItem('userId');
emitter.next();
emitter.complete();
})
// TODO: Implement this when JWT auth is implemented
// return this.http.post<void>(this.apiUrl + '/login?logout', this.options);
}
// Budgets
getBudgets(): Observable<Budget[]> {
this.http.get<Budget[]>(this.apiUrl + '/budgets', this.options)
.subscribe(budgets => {
this.budgets.next(budgets);
});
return this.budgets;
}
getBudgetBalance(
id: string,
from?: Date,
to?: Date
): Observable<number> {
let httpParams = new HttpParams();
if (from) {
httpParams = httpParams.set('from', from.toISOString());
}
if (to) {
httpParams = httpParams.set('to', to.toISOString());
}
const params = { params: httpParams };
return this.http.get<any>(`${this.apiUrl}/transactions/sum?budgetId=${id}`, { ...this.options, ...params })
.pipe(map(obj => obj.balance));
}
getBudget(id: string): Observable<Budget> {
return new Observable(emitter => {
var cachedBudget: Budget
if (this.budgets.value) {
cachedBudget = this.budgets.value.find(budget => {
return budget.id === id;
});
}
if (cachedBudget) {
emitter.next(cachedBudget);
emitter.complete();
} else {
this.http.get<Budget>(`${this.apiUrl}/budgets/${id}`, this.options)
.subscribe(budget => {
var oldBudgets = JSON.parse(JSON.stringify(this.budgets.value));
if (!oldBudgets) {
oldBudgets = [];
}
oldBudgets.push(budget);
oldBudgets.sort();
this.budgets.next(oldBudgets);
emitter.next(budget);
emitter.complete();
})
}
})
}
createBudget(
id: string,
name: string,
description: string,
): Observable<Budget> {
const params = {
'id': id,
'name': name,
'description': description,
};
return this.http.post<Budget>(this.apiUrl + '/budgets', params, this.options)
.pipe(map(budget => {
var updatedBudgets = JSON.parse(JSON.stringify(this.budgets.value));
updatedBudgets.push(budget);
updatedBudgets.sort();
this.budgets.next(updatedBudgets);
return budget
}))
}
updateBudget(id: string, changes: object): Observable<Budget> {
let budget = changes as Budget;
const params = {
'name': budget.name,
'description': budget.description,
};
return this.http.put<Budget>(`${this.apiUrl}/budgets/${id}`, params, this.options)
.pipe(map(budget => {
var updatedBudgets: Budget[] = JSON.parse(JSON.stringify(this.budgets.value));
var index = updatedBudgets.findIndex(oldBudget => oldBudget.id === id);
if (index > -1) {
updatedBudgets.splice(index, 1);
}
updatedBudgets.push(budget);
updatedBudgets.sort();
this.budgets.next(updatedBudgets);
return budget
}));
}
deleteBudget(id: String): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/budgets/${id}`, this.options)
.pipe(map(() => {
var updatedBudgets: Budget[] = JSON.parse(JSON.stringify(this.budgets.value));
var index = updatedBudgets.findIndex(oldBudget => oldBudget.id === id);
if (index > -1) {
updatedBudgets.splice(index, 1);
}
updatedBudgets.sort();
this.budgets.next(updatedBudgets);
return;
}));
}
// Categories
getCategories(budgetId: string, count?: number): Observable<Category[]> {
const params = {
params: new HttpParams()
.set('budgetIds', `${budgetId}`)
.set('archived', false)
};
return this.http.get<Category[]>(`${this.apiUrl}/categories`, Object.assign(params, this.options));
}
getCategory(id: string): Observable<Category> {
return this.http.get<Category>(`${this.apiUrl}/categories/${id}`, this.options);
}
getCategoryBalance(
id: string,
from?: Date,
to?: Date
): Observable<number> {
let httpParams = new HttpParams();
if (from) {
httpParams = httpParams.set('from', from.toISOString());
}
if (to) {
httpParams = httpParams.set('to', to.toISOString());
}
const params = { params: httpParams };
return this.http.get<any>(`${this.apiUrl}/transactions/sum?categoryId=${id}`, { ...this.options, ...params })
.pipe(map(obj => obj.balance));
}
createCategory(id: string, budgetId: string, name: string, description: string, amount: number, isExpense: boolean): Observable<Category> {
const params = {
'id': id,
'title': name,
'description': description,
'amount': amount,
'expense': isExpense,
'budgetId': budgetId
};
return this.http.post<Category>(this.apiUrl + '/categories', params, this.options);
}
updateCategory(id: string, changes: object): Observable<Category> {
return this.http.put<Category>(`${this.apiUrl}/categories/${id}`, changes, this.options);
}
deleteCategory(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/categories/${id}`, this.options);
}
// Transactions
getTransactions(
budgetId?: string,
categoryId?: string,
count?: number,
from?: Date,
to?: Date
): Observable<Transaction[]> {
let httpParams = new HttpParams();
if (budgetId) {
httpParams = httpParams.set('budgetIds', `${budgetId}`);
}
if (categoryId) {
httpParams = httpParams.set('categoryIds', `${categoryId}`);
}
if (from) {
httpParams = httpParams.set('from', from.toISOString());
}
if (to) {
httpParams = httpParams.set('to', to.toISOString());
}
const params = { params: httpParams };
return this.http.get<Transaction[]>(`${this.apiUrl}/transactions`, Object.assign(params, this.options))
.pipe(map(transactions => {
transactions.forEach(transaction => {
transaction.date = new Date(transaction.date);
});
return transactions;
}));
}
getTransaction(id: string): Observable<Transaction> {
return this.http.get<Transaction>(`${this.apiUrl}/transactions/${id}`, this.options)
.pipe(map(transaction => {
transaction.date = new Date(transaction.date);
return transaction;
}));
}
createTransaction(
id: string,
budgetId: string,
name: string,
description: string,
amount: number,
date: Date,
expense: boolean,
category: string
): Observable<Transaction> {
const params = {
'id': id,
'title': name,
'description': description,
'date': date.toISOString(),
'amount': amount,
'expense': expense,
'categoryId': category,
'budgetId': budgetId
};
return this.http.post<Transaction>(this.apiUrl + '/transactions', params, this.options);
}
updateTransaction(id: string, changes: object): Observable<Transaction> {
return this.http.put<Transaction>(`${this.apiUrl}/transactions/${id}`, changes, this.options);
}
deleteTransaction(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/transactions/${id}`, this.options);
}
// Users
getProfile(id: string): Observable<User> {
return this.http.get<User>(`${this.apiUrl}/users/${id}`, this.options);
}
getUsersByUsername(username: string): Observable<User[]> {
return new Observable(subscriber => {
subscriber.error("Not yet implemented")
});
}
}

View file

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

View file

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

View file

@ -5,11 +5,3 @@ export function randomId(): string {
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

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

View file

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

View file

@ -1,7 +1,7 @@
import { Component, OnInit, Input, Inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Transaction } from '../transaction';
import { TWIGS_SERVICE, TwigsService } from 'src/app/shared/twigs.service';
import { TWIGS_SERVICE, TwigsService } from 'src/client/app/shared/twigs.service';
@Component({
selector: 'app-transaction-details',
@ -25,7 +25,7 @@ export class TransactionDetailsComponent implements OnInit {
getTransaction(): void {
const id = this.route.snapshot.paramMap.get('id');
this.twigsService.getTransaction(id)
.then(transaction => {
.subscribe(transaction => {
transaction.amount /= 100;
this.transaction = transaction;
this.budgetId = transaction.budgetId;

View file

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

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