diff --git a/README.md b/README.md index 4cb3ffd..652c7c5 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# Up Bank Transaction Tagger +# Unofficial Up Bank Web App -Web app using [Up Bank API](https://developer.up.com.au/) to allow bulk tagging of -transactions. Everything happens in your browser, so a refresh will wipe any loaded -transactions. +Web app using [Up Bank API](https://developer.up.com.au/), currently primarily to +allow bulk tagging of transactions. Everything happens in your browser, so a +refresh will wipe any loaded transactions. Access the live version on GitHub Pages! [erfanio.github.io/up-transaction-tagger](https://erfanio.github.io/up-transaction-tagger/) @@ -10,25 +10,31 @@ Access the live version on GitHub Pages! [erfanio.github.io/up-transaction-tagge ## FAQ -### What's an API key? +### What's an API or Perosnal Access Token? -Your API key uniquely identifies you with Up Bank. It allows anyone that knows your -API key to read your transactions and add/update tags and categories. +Up Bank provides an interface for third-party applications (such as this one) to +read your transactions and add/update categories or tags to them. This is called +an application programming interface (API) and your **personal access token** is +a unique identifier that allows applications to access your trasaction data +using the API. -Go to [api.up.com.au](http://api.up.com.au) to get your API key! +You can get your personal access token from [api.up.com.au](http://api.up.com.au). +You can revoke the previous token by generating a new one any time. ### I see "Covered from X" or "Forwarded to X" transactions... what gives? -Covers and fowards are transactions under the hood. The Up app hides these transactions -under pretty UI but they're still there. +Covers and fowards are transactions under the hood. The Up app hides these +transactions under pretty UI but they're still there. -These transactions show up in the API but they don't mark which transaction they're -covering. I have an [open GitHub issue](https://github.com/up-banking/api/issues/99) -with Up to add this feature. +These transactions show up in the API but they don't mark which transaction +they're covering. I have an +[open GitHub issue](https://github.com/up-banking/api/issues/99) with Up to add +this feature. -For now, this web app uses heuristics to find likely covers/forwards and displays them a similar -UI to the app. +For now, this web app uses heuristics to find likely covers/forwards and displays +them a similar UI to the app. ### Can I shift select? -YES! The web app supports selecting multiple transactions in a row if you hold the shift key. +YES! The web app supports selecting multiple transactions in a row if you hold the +shift key. diff --git a/public/index.html b/public/index.html index efbc007..c7249ed 100644 --- a/public/index.html +++ b/public/index.html @@ -7,36 +7,13 @@ content="width=device-width, initial-scale=1, shrink-to-fit=no" /> - - - - React App + + Unofficial Up Bank Web App
- diff --git a/src/Accounts.tsx b/src/Accounts.tsx index 0223f85..c2a6b23 100644 --- a/src/Accounts.tsx +++ b/src/Accounts.tsx @@ -1,8 +1,7 @@ -import { accountsQuery } from './api_client'; +import { accountsQuery } from './data/accounts'; import { useRecoilValue } from 'recoil'; import React, { useState } from 'react'; import Transactions from './Transactions'; - import './Accounts.css'; const TRIANGLE_DOWN = '▾'; diff --git a/src/ActionBar.tsx b/src/ActionBar.tsx index 17f2b80..27851a6 100644 --- a/src/ActionBar.tsx +++ b/src/ActionBar.tsx @@ -1,17 +1,15 @@ import { selectedTransactionsState, selectedTransactionsQuery, -} from './global_state'; +} from './data/selectedTransactions'; +import { tagsQuery, tagTransactions } from './data/tags'; +import { accountsQuery } from './data/accounts'; import { - tagsQuery, - tagTransactions, - accountsQuery, paginatedTransactionsState, refreshTransactions, -} from './api_client'; +} from './data/transactions'; import { useRecoilValue, useRecoilCallback, useRecoilState } from 'recoil'; import React, { useState } from 'react'; - import './ActionBar.css'; function AddTag({ closePopup }: { closePopup: () => void }) { diff --git a/src/ApiKeyForm.css b/src/ApiKeyForm.css new file mode 100644 index 0000000..2f5017f --- /dev/null +++ b/src/ApiKeyForm.css @@ -0,0 +1,9 @@ +.api-form label > input { + margin-left: 10px; +} + +.api-form input[type='submit'] { + margin-top: 20px; + display: block; + background-color: #ff7a64; +} diff --git a/src/ApiKeyForm.tsx b/src/ApiKeyForm.tsx new file mode 100644 index 0000000..1d5e44a --- /dev/null +++ b/src/ApiKeyForm.tsx @@ -0,0 +1,33 @@ +import React, { useState } from 'react'; +import './ApiKeyForm.css'; + +type ApiKeyFormProps = { + onSubmit: (apiKey: string) => void; +}; +export default function ApiKeyForm({ onSubmit }: ApiKeyFormProps) { + const [apiKey, setApiKey] = useState(''); + const handleChange = (event: React.ChangeEvent) => { + setApiKey(event.target.value); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + onSubmit(apiKey); + }; + + return ( +
+ + +
+ ); +} diff --git a/src/App.css b/src/App.css index b066ca9..c9d92b8 100644 --- a/src/App.css +++ b/src/App.css @@ -6,3 +6,11 @@ .topbar { display: flex; } + +.card.text p { + margin: 1em 0 1em 0; +} + +.error pre { + text-wrap: auto; +} diff --git a/src/App.tsx b/src/App.tsx index cd0ab40..c2b8df5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,70 +1,124 @@ -import { apiKeyState } from './api_client'; -import { useRecoilState } from 'recoil'; -import React, { useState } from 'react'; +import React, { Component } from 'react'; import Accounts from './Accounts'; import ActionBar from './ActionBar'; import Filters from './Filters'; import Search from './Search'; - +import ApiKeyForm from './ApiKeyForm'; +import { apiKeyState } from './data/apiKey'; +import { useRecoilState } from 'recoil'; import './App.css'; -type ApiKeyFormProps = { - onSubmit: (apiKey: string) => void; +type DisplayErrorsProps = { + setApiKey: (apiKey: string) => void; + children: React.ReactElement; }; -function ApiKeyForm({ onSubmit }: ApiKeyFormProps) { - const [apiKey, setApiKey] = useState(''); - const handleChange = (event: React.ChangeEvent) => { - setApiKey(event.target.value); - }; +type DisplayErrorsState = { + caughtError?: any; +}; +class DisplayErrors extends Component { + state: DisplayErrorsState = { caughtError: undefined }; - const handleSubmit = (event: React.FormEvent) => { - event.preventDefault(); - onSubmit(apiKey); - }; + static getDerivedStateFromError(error: any) { + return { caughtError: error }; + } - return ( -
- - -
- ); -} + onApiKeySubmit = (apiKey: string) => { + this.setState({ caughtError: undefined }); + this.props.setApiKey(apiKey); + } -type WithApiKeyProps = { - children: React.ReactElement; -}; -function WithApiKey({ children }: WithApiKeyProps) { - const [apiKey, setApiKey] = useRecoilState(apiKeyState); + render() { + if (this.state.caughtError) { + if (this.state.caughtError.status == '401') { + return ( +
+

Auth Error

+

+ Uh oh, looks like your personal access token isn't working. Up API + returned this error. +

+
{JSON.stringify(this.state.caughtError, null, 2)}
+

Update Your Personal Access Token

+

+ Maybe there was a typo, try setting your personal access token + again. If that still doesn't work, generate a new one from{' '} + + api.up.com.au + + . +

+ +
+ ); + } - const handleSubmit = (newApiKey: string) => { - setApiKey(newApiKey); - }; + return ( +
+

Unknown Error

+

Uh oh, something went wrong. Refresh this page to reset.

+

+ Maybe to take screenshot of this error and create an issue on Github,{' '} + + github.com/erfanio/up-transaction-tagger/issues + +

+
{JSON.stringify(this.state.caughtError, null, 2)}
+
+ ); + } - if (apiKey) { - return children; + return this.props.children; } - return ; } export default function App() { + const [apiKey, setApiKey] = useRecoilState(apiKeyState); + return (
- - Loading accounts...

}> -
- - + + {!apiKey ? ( +
+

Unofficial Up Bank Web App

+

+ The official app is amazing but it doesn't have every feature. + This unofficial app uses the Up Bank API to access your + transaction data and add some features that @erfanio wanted + to have :) +

+

How Does It Work?

+

+ Up Bank offers a Application Programming Interface (API) which is a + convenient way to allow third-party apps to only access your + transaction data and do minor changes like adding tags and categories. +

+

This app is fully local, your data never leaves your browser.

+

Get Your Personal Access Token

+

+ Personal access token authenticates this app with Up Bank, and + allows this app to list your accounts, transactions, and tags + and assign tags and categories to transactions. +

+

+ You can generate a new personal access token by going to{' '} + + api.up.com.au + + . Once you have it, enter here to get started. +

+
- - - - + ) : ( + Loading accounts...

}> +
+ + +
+ + +
+ )} +
); } diff --git a/src/Filters.tsx b/src/Filters.tsx index f4e510c..e787366 100644 --- a/src/Filters.tsx +++ b/src/Filters.tsx @@ -1,10 +1,10 @@ -import { accountsQuery, categoriesQuery } from './api_client'; -import { filtersState, NOT_COVERED_ID, UNCATEGORIZED_ID } from './global_state'; +import { accountsQuery } from './data/accounts'; +import { categoriesQuery } from './data/categories'; +import { filtersState, NOT_COVERED_ID, UNCATEGORIZED_ID } from './data/filters'; import { useRecoilState, useRecoilValue } from 'recoil'; import React, { useState } from 'react'; -import { ReactComponent as ChevronRight } from './chevron-right.svg'; +import { ReactComponent as ChevronRight } from './svg/chevron-right.svg'; import Overlay from './Overlay'; - import './Filters.css'; const TRIANGLE_DOWN = '▾'; diff --git a/src/Overlay.tsx b/src/Overlay.tsx index fe0c75f..b44027e 100644 --- a/src/Overlay.tsx +++ b/src/Overlay.tsx @@ -1,6 +1,5 @@ import React, { useEffect } from 'react'; -import { ReactComponent as CloseIcon } from './close.svg'; - +import { ReactComponent as CloseIcon } from './svg/close.svg'; import './Overlay.css'; type OverlayProps = { @@ -10,7 +9,6 @@ type OverlayProps = { }; export default function Overlay({ open, setOpen, children }: OverlayProps) { useEffect(() => { - console.log('effect happened!!'); if (open) { document.body.classList.add('overlay-open'); } else { diff --git a/src/Search.tsx b/src/Search.tsx index 31a2772..d59a12c 100644 --- a/src/Search.tsx +++ b/src/Search.tsx @@ -1,9 +1,7 @@ import { useRecoilState } from 'recoil'; -import { searchState } from './global_state'; +import { searchState } from './data/filters'; import React from 'react'; - -import { ReactComponent as SearchIcon } from './search.svg'; - +import { ReactComponent as SearchIcon } from './svg/search.svg'; import './Search.css'; export default function Search() { diff --git a/src/Transactions.tsx b/src/Transactions.tsx index be255a3..dabad37 100644 --- a/src/Transactions.tsx +++ b/src/Transactions.tsx @@ -1,17 +1,14 @@ +import { accountNameQuery } from './data/accounts'; import { loadMoreTransactions, - accountNameQuery, paginatedTransactionsState, - categoryLookupQuery, -} from './api_client'; -import { - filteredTransactionsQuery, - selectedTransactionsState, -} from './global_state'; +} from './data/transactions'; +import { categoryLookupQuery } from './data/categories'; +import { filteredTransactionsQuery } from './data/filters'; +import { selectedTransactionsState } from './data/selectedTransactions'; import { useRecoilValue, useRecoilState } from 'recoil'; import React, { useState, useCallback, useMemo } from 'react'; import classnames from 'classnames'; - import './Transactions.css'; function Category({ category }: { category: any }) { diff --git a/src/api_client.tsx b/src/api_client.tsx deleted file mode 100644 index 8d65bf0..0000000 --- a/src/api_client.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import { atom, selector, atomFamily, selectorFamily } from 'recoil'; - -const LOCALSTORAGE_KEY = 'up-tagger-api-key'; - -export const apiKeyState = atom({ - key: 'api-key', - default: window.localStorage.getItem(LOCALSTORAGE_KEY), - effects: [ - ({ onSet }) => { - onSet((apiKey) => window.localStorage.setItem(LOCALSTORAGE_KEY, apiKey!)); - }, - ], -}); - -export const accountsQuery = selector>({ - key: 'accounts', - get: async ({ get }) => { - const resp = await fetch('https://api.up.com.au/api/v1/accounts', { - method: 'GET', - headers: { - Authorization: `Bearer ${get(apiKeyState)}`, - }, - }); - const json: { data: Array } = await resp.json(); - return json.data; - }, -}); - -export const accountNameQuery = selectorFamily({ - key: 'accountName', - get: - (accountId) => - ({ get }) => { - if (accountId === null) { - return null; - } - const accounts = get(accountsQuery); - const match = accounts.find((account) => account.id === accountId); - if (match === undefined) { - return 'UNKNOWN'; - } - return match.attributes.displayName; - }, -}); - -const findCovers = (transactions: Array) => { - const covers = new Map>(); - const newTransactions = []; - for (const transaction of transactions) { - const { description, amount, isCategorizable } = transaction.attributes; - const newTransaction = { ...transaction }; - // Ignore already matched transactions - if (transaction.coverTransaction || transaction.originalTransactionId) { - newTransactions.push(transaction); - } else if ( - description.indexOf('Cover from') === 0 || - description.indexOf('Forward to') === 0 - ) { - const normalisedAmount = Math.ceil(amount.valueInBaseUnits / 100); - // if is a cover transaction - if (covers.has(normalisedAmount)) { - covers.get(normalisedAmount)!.push(newTransaction); - } else { - covers.set(normalisedAmount, [newTransaction]); - } - newTransactions.push(newTransaction); - } else if (isCategorizable) { - const normalisedAmount = Math.ceil(-amount.valueInBaseUnits / 100); - // if is a normal transaction - if (covers.has(normalisedAmount)) { - // if there is a matching cover transaction - const matchedCovers = covers.get(normalisedAmount); - const poppedCover = matchedCovers!.shift(); - if (matchedCovers!.length === 0) { - covers.delete(normalisedAmount); - } - - // To prevent cyclic references, only store the ID in the cover, but - // store a reference in the orignal for easy access - Object.assign(poppedCover, { originalTransactionId: transaction.id }); - newTransactions.push( - Object.assign(newTransaction, { coverTransaction: poppedCover }), - ); - } else { - // if no matching cover transaction - newTransactions.push(newTransaction); - } - } else { - // if neither a cover or a normal transaction - newTransactions.push(newTransaction); - } - } - - return newTransactions; -}; - -type PaginatedTransactions = { - list: Array; - nextUrl: string | null; -}; -export const paginatedTransactionsState = atomFamily< - PaginatedTransactions, - string ->({ - key: 'paginated-transactions', - default: async (accountId) => { - const apiKey = window.localStorage.getItem(LOCALSTORAGE_KEY); - const resp = await fetch( - `https://api.up.com.au/api/v1/accounts/${accountId}/transactions?page[size]=100`, - { - method: 'GET', - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }, - ); - - const json = await resp.json(); - const withCoverMetadata = findCovers(json.data); - return { - list: withCoverMetadata, - nextUrl: json.links.next, - }; - }, -}); - -export const loadMoreTransactions = async ( - paginatedTransactions: PaginatedTransactions, -): Promise => { - if (!paginatedTransactions.nextUrl) { - return paginatedTransactions; - } - - const apiKey = window.localStorage.getItem(LOCALSTORAGE_KEY); - const resp = await fetch(paginatedTransactions.nextUrl, { - method: 'GET', - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }); - - const json = await resp.json(); - const withCoverMetadata = findCovers([ - ...paginatedTransactions.list, - ...json.data, - ]); - return { - list: withCoverMetadata, - nextUrl: json.links.next, - }; -}; - -export const refreshTransactions = async ( - accountId: string, - transactionsToLoad: number, -) => { - const apiKey = window.localStorage.getItem(LOCALSTORAGE_KEY); - let allTransactions: Array = []; - let nextUrl = `https://api.up.com.au/api/v1/accounts/${accountId}/transactions?page[size]=100`; - while (transactionsToLoad > 0) { - transactionsToLoad -= 100; - const resp = await fetch(nextUrl, { - method: 'GET', - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }); - const json = await resp.json(); - allTransactions = [...allTransactions, ...json.data]; - nextUrl = json.links.next; - } - const withCoverMetadata = findCovers(allTransactions); - return { - list: withCoverMetadata, - nextUrl, - }; -}; - -type ChildCategory = { - id: string; - relationships: any; - attributes: { name: string }; -}; -type Category = { - id: string; - relationships: any; - attributes: { name: string }; - childCategories: Array; -}; -const makeCategoryTree = (categories: Array): Array => { - const tree = new Map(); - // First pass get all the parent categories - for (const category of categories) { - if (category.relationships.parent.data == null) { - tree.set(category.id, { ...category, childCategories: [] }); - } - } - // Second pass add children - for (const category of categories) { - if (category.relationships.parent.data != null) { - const parentId = category.relationships.parent.data.id; - tree.get(parentId)?.childCategories.push(category); - } - } - return Array.from(tree.values()); -}; -export const categoriesQuery = selector>({ - key: 'categories', - get: async ({ get }) => { - const resp = await fetch('https://api.up.com.au/api/v1/categories', { - method: 'GET', - headers: { - Authorization: `Bearer ${get(apiKeyState)}`, - }, - }); - const json: { data: Array } = await resp.json(); - return makeCategoryTree(json.data); - }, -}); - -export const categoryLookupQuery = selectorFamily({ - key: 'category-lookup', - get: - (categoryId) => - ({ get }) => { - const categoryTree = get(categoriesQuery); - for (const parentCategory of categoryTree) { - for (const category of parentCategory.childCategories) { - if (category.id === categoryId) { - return category; - } - } - } - return null; - }, -}); - -export const tagsQuery = selector>({ - key: 'tags', - get: async ({ get }) => { - const resp = await fetch('https://api.up.com.au/api/v1/tags', { - method: 'GET', - headers: { - Authorization: `Bearer ${get(apiKeyState)}`, - }, - }); - const json: { data: Array } = await resp.json(); - return json.data; - }, -}); - -export const tagTransactions = ( - transactionIds: Array, - tagId: string, -) => { - const apiKey = window.localStorage.getItem(LOCALSTORAGE_KEY); - const requests = transactionIds.map((tid) => { - return fetch( - `https://api.up.com.au/api/v1/transactions/${tid}/relationships/tags`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ data: [{ type: 'tags', id: tagId }] }), - }, - ); - }); - - return Promise.all(requests); -}; diff --git a/src/data/accounts.ts b/src/data/accounts.ts new file mode 100644 index 0000000..7959986 --- /dev/null +++ b/src/data/accounts.ts @@ -0,0 +1,32 @@ +import { selector, selectorFamily } from 'recoil'; +import { apiKeyState } from './apiKey'; +import { get as fetchGet, genericListResponse } from './fetch'; + +export const accountsQuery = selector>({ + key: 'accounts', + get: async ({ get }) => { + const json = await fetchGet({ + apiKey: get(apiKeyState), + url: 'https://api.up.com.au/api/v1/accounts', + errorMessage: 'Error retrieving a list of accounts:', + }); + return json.data; + }, +}); + +export const accountNameQuery = selectorFamily({ + key: 'accountName', + get: + (accountId) => + ({ get }) => { + if (accountId === null) { + return null; + } + const accounts = get(accountsQuery); + const match = accounts.find((account) => account.id === accountId); + if (match === undefined) { + return 'UNKNOWN'; + } + return match.attributes.displayName; + }, +}); diff --git a/src/data/apiKey.ts b/src/data/apiKey.ts new file mode 100644 index 0000000..a887610 --- /dev/null +++ b/src/data/apiKey.ts @@ -0,0 +1,15 @@ +import { atom } from 'recoil'; + +export const LOCALSTORAGE_KEY = 'up-tagger-api-key'; + +export const apiKeyState = atom({ + key: 'api-key', + default: window.localStorage.getItem(LOCALSTORAGE_KEY) || '', + effects: [ + ({ onSet }) => { + onSet((apiKey) => { + window.localStorage.setItem(LOCALSTORAGE_KEY, apiKey!); + }); + }, + ], +}); diff --git a/src/data/categories.ts b/src/data/categories.ts new file mode 100644 index 0000000..6f1b109 --- /dev/null +++ b/src/data/categories.ts @@ -0,0 +1,60 @@ +import { selector, selectorFamily } from 'recoil'; +import { apiKeyState } from './apiKey'; +import { get as fetchGet, genericListResponse } from './fetch'; + +type ChildCategory = { + id: string; + relationships: any; + attributes: { name: string }; +}; +type Category = { + id: string; + relationships: any; + attributes: { name: string }; + childCategories: Array; +}; +const makeCategoryTree = (categories: Array): Array => { + const tree = new Map(); + // First pass get all the parent categories + for (const category of categories) { + if (category.relationships.parent.data == null) { + tree.set(category.id, { ...category, childCategories: [] }); + } + } + // Second pass add children + for (const category of categories) { + if (category.relationships.parent.data != null) { + const parentId = category.relationships.parent.data.id; + tree.get(parentId)?.childCategories.push(category); + } + } + return Array.from(tree.values()); +}; +export const categoriesQuery = selector>({ + key: 'categories', + get: async ({ get }) => { + const json = await fetchGet({ + apiKey: get(apiKeyState), + url: 'https://api.up.com.au/api/v1/categories', + errorMessage: 'Error retrieving a list of categories:', + }); + return makeCategoryTree(json.data); + }, +}); + +export const categoryLookupQuery = selectorFamily({ + key: 'category-lookup', + get: + (categoryId) => + ({ get }) => { + const categoryTree = get(categoriesQuery); + for (const parentCategory of categoryTree) { + for (const category of parentCategory.childCategories) { + if (category.id === categoryId) { + return category; + } + } + } + return null; + }, +}); diff --git a/src/data/fetch.ts b/src/data/fetch.ts new file mode 100644 index 0000000..bfbbccf --- /dev/null +++ b/src/data/fetch.ts @@ -0,0 +1,31 @@ +export type genericListResponse = { + data: Array; + links: { prev?: string; next?: string }; +}; + +type getArgs = { + apiKey: string; + url: string; + errorMessage?: string; +}; +export const get = async ({ + apiKey, + url, + errorMessage = 'Error retriving data from Up API:', +}: getArgs): Promise => { + const resp = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + if (!resp.ok) { + const json: { errors: Array } = await resp.json(); + if (json && json.errors && json.errors.length > 0) { + throw json.errors[0]; + } + throw 'Unknown error! Failed to send requests to Up API.'; + } + const json: T = await resp.json(); + return json; +}; diff --git a/src/global_state.tsx b/src/data/filters.ts similarity index 71% rename from src/global_state.tsx rename to src/data/filters.ts index b7e0918..667feec 100644 --- a/src/global_state.tsx +++ b/src/data/filters.ts @@ -1,9 +1,7 @@ -import { atom, selector, selectorFamily, waitForAll } from 'recoil'; -import { - accountsQuery, - categoriesQuery, - paginatedTransactionsState, -} from './api_client'; +import { atom, selector, selectorFamily } from 'recoil'; +import { accountsQuery } from './accounts'; +import { categoriesQuery } from './categories'; +import { paginatedTransactionsState } from './transactions'; export const UNCATEGORIZED_ID = 'uncategorized'; export const NOT_COVERED_ID = 'none'; @@ -81,27 +79,3 @@ export const filteredTransactionsQuery = selectorFamily({ return filtered; }, }); - -export const selectedTransactionsState = atom>({ - key: 'selected-transactions', - default: new Set(), -}); - -export const selectedTransactionsQuery = selector>({ - key: 'selected-transactions-query', - get: async ({ get }) => { - const accounts = await get(accountsQuery); - const accountsTransactionLists = get( - waitForAll( - accounts.map((account) => filteredTransactionsQuery(account.id)), - ), - ); - const transactions = []; - for (const accountTransactionList of accountsTransactionLists) { - transactions.push(...accountTransactionList); - } - - const selectedTransactionIds = get(selectedTransactionsState); - return transactions.filter((t) => selectedTransactionIds.has(t.id)); - }, -}); diff --git a/src/data/selectedTransactions.ts b/src/data/selectedTransactions.ts new file mode 100644 index 0000000..c4a3a91 --- /dev/null +++ b/src/data/selectedTransactions.ts @@ -0,0 +1,27 @@ +import { atom, selector, waitForAll } from 'recoil'; +import { accountsQuery } from './accounts'; +import { filteredTransactionsQuery } from './filters'; + +export const selectedTransactionsState = atom>({ + key: 'selected-transactions', + default: new Set(), +}); + +export const selectedTransactionsQuery = selector>({ + key: 'selected-transactions-query', + get: async ({ get }) => { + const accounts = await get(accountsQuery); + const accountsTransactionLists = get( + waitForAll( + accounts.map((account) => filteredTransactionsQuery(account.id)), + ), + ); + const transactions = []; + for (const accountTransactionList of accountsTransactionLists) { + transactions.push(...accountTransactionList); + } + + const selectedTransactionIds = get(selectedTransactionsState); + return transactions.filter((t) => selectedTransactionIds.has(t.id)); + }, +}); diff --git a/src/data/tags.ts b/src/data/tags.ts new file mode 100644 index 0000000..e40d917 --- /dev/null +++ b/src/data/tags.ts @@ -0,0 +1,37 @@ +import { selector } from 'recoil'; +import { LOCALSTORAGE_KEY, apiKeyState } from './apiKey'; +import { get as fetchGet, genericListResponse } from './fetch'; + +export const tagsQuery = selector>({ + key: 'tags', + get: async ({ get }) => { + const json = await fetchGet({ + apiKey: get(apiKeyState), + url: 'https://api.up.com.au/api/v1/tags', + errorMessage: 'Error retrieving a list of tags:', + }); + return json.data; + }, +}); + +export const tagTransactions = ( + transactionIds: Array, + tagId: string, +) => { + const apiKey = window.localStorage.getItem(LOCALSTORAGE_KEY); + const requests = transactionIds.map((tid) => { + return fetch( + `https://api.up.com.au/api/v1/transactions/${tid}/relationships/tags`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ data: [{ type: 'tags', id: tagId }] }), + }, + ); + }); + + return Promise.all(requests); +}; diff --git a/src/data/transactions.ts b/src/data/transactions.ts new file mode 100644 index 0000000..b14757c --- /dev/null +++ b/src/data/transactions.ts @@ -0,0 +1,130 @@ +import { atomFamily } from 'recoil'; +import { LOCALSTORAGE_KEY } from './apiKey'; +import { get as fetchGet, genericListResponse } from './fetch'; + +const findCovers = (transactions: Array) => { + const covers = new Map>(); + const newTransactions = []; + for (const transaction of transactions) { + const { description, amount, isCategorizable } = transaction.attributes; + const newTransaction = { ...transaction }; + // Ignore already matched transactions + if (transaction.coverTransaction || transaction.originalTransactionId) { + newTransactions.push(transaction); + } else if ( + description.indexOf('Cover from') === 0 || + description.indexOf('Forward to') === 0 + ) { + const normalisedAmount = Math.ceil(amount.valueInBaseUnits / 100); + // if is a cover transaction + if (covers.has(normalisedAmount)) { + covers.get(normalisedAmount)!.push(newTransaction); + } else { + covers.set(normalisedAmount, [newTransaction]); + } + newTransactions.push(newTransaction); + } else if (isCategorizable) { + const normalisedAmount = Math.ceil(-amount.valueInBaseUnits / 100); + // if is a normal transaction + if (covers.has(normalisedAmount)) { + // if there is a matching cover transaction + const matchedCovers = covers.get(normalisedAmount); + const poppedCover = matchedCovers!.shift(); + if (matchedCovers!.length === 0) { + covers.delete(normalisedAmount); + } + + // To prevent cyclic references, only store the ID in the cover, but + // store a reference in the orignal for easy access + Object.assign(poppedCover, { originalTransactionId: transaction.id }); + newTransactions.push( + Object.assign(newTransaction, { coverTransaction: poppedCover }), + ); + } else { + // if no matching cover transaction + newTransactions.push(newTransaction); + } + } else { + // if neither a cover or a normal transaction + newTransactions.push(newTransaction); + } + } + + return newTransactions; +}; + +type PaginatedTransactions = { + list: Array; + nextUrl?: string; +}; +export const paginatedTransactionsState = atomFamily< + PaginatedTransactions, + string +>({ + key: 'paginated-transactions', + default: async (accountId) => { + const apiKey = window.localStorage.getItem(LOCALSTORAGE_KEY) || ''; + const json = await fetchGet({ + apiKey: apiKey, + url: `https://api.up.com.au/api/v1/accounts/${accountId}/transactions?page[size]=100`, + errorMessage: 'Error retrieving a list of tags:', + }); + const withCoverMetadata = findCovers(json.data); + return { + list: withCoverMetadata, + nextUrl: json.links.next, + }; + }, +}); + +export const loadMoreTransactions = async ( + paginatedTransactions: PaginatedTransactions, +): Promise => { + if (!paginatedTransactions.nextUrl) { + return paginatedTransactions; + } + + const apiKey = window.localStorage.getItem(LOCALSTORAGE_KEY); + const resp = await fetch(paginatedTransactions.nextUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + + const json = await resp.json(); + const withCoverMetadata = findCovers([ + ...paginatedTransactions.list, + ...json.data, + ]); + return { + list: withCoverMetadata, + nextUrl: json.links.next, + }; +}; + +export const refreshTransactions = async ( + accountId: string, + transactionsToLoad: number, +) => { + const apiKey = window.localStorage.getItem(LOCALSTORAGE_KEY); + let allTransactions: Array = []; + let nextUrl = `https://api.up.com.au/api/v1/accounts/${accountId}/transactions?page[size]=100`; + while (transactionsToLoad > 0) { + transactionsToLoad -= 100; + const resp = await fetch(nextUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + const json = await resp.json(); + allTransactions = [...allTransactions, ...json.data]; + nextUrl = json.links.next; + } + const withCoverMetadata = findCovers(allTransactions); + return { + list: withCoverMetadata, + nextUrl, + }; +}; diff --git a/src/index.css b/src/index.css index 60cf719..1c8761f 100644 --- a/src/index.css +++ b/src/index.css @@ -21,7 +21,8 @@ p { margin: 0; } -button { +button, +input[type='submit'] { display: inline-block; border: none; text-decoration: none; @@ -36,3 +37,9 @@ button { padding: 10px 20px; border-radius: 3px; } + +.card { + padding: 20px; + background: #fff; + border-radius: 3px; +} diff --git a/src/index.tsx b/src/index.tsx index 44d4885..db23393 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,7 +2,6 @@ import * as ReactDOMClient from 'react-dom/client'; import App from './App'; import { RecoilRoot } from 'recoil'; import { StrictMode } from 'react'; - import './index.css'; import './vendor/normalize.css'; diff --git a/src/chevron-right.svg b/src/svg/chevron-right.svg similarity index 100% rename from src/chevron-right.svg rename to src/svg/chevron-right.svg diff --git a/src/close.svg b/src/svg/close.svg similarity index 100% rename from src/close.svg rename to src/svg/close.svg diff --git a/src/search.svg b/src/svg/search.svg similarity index 100% rename from src/search.svg rename to src/svg/search.svg