-
1.env
-
26.eslintrc.json
-
23.gitignore
-
21LICENSE
-
64README.md
-
30deploy.sh
-
BINdoc/add-expense.gif
-
BINdoc/delete-expense.gif
-
BINdoc/edit-expense.gif
-
BINdoc/expenses.png
-
BINdoc/zapier.png
-
39293package-lock.json
-
32package.json
-
BINpublic/android-chrome-192x192.png
-
BINpublic/android-chrome-512x512.png
-
BINpublic/apple-touch-icon.png
-
9public/browserconfig.xml
-
BINpublic/favicon-16x16.png
-
BINpublic/favicon-32x32.png
-
BINpublic/favicon.ico
-
29public/index.html
-
20public/manifest.json
-
BINpublic/mstile-144x144.png
-
BINpublic/mstile-150x150.png
-
BINpublic/mstile-310x150.png
-
BINpublic/mstile-310x310.png
-
BINpublic/mstile-70x70.png
-
24public/safari-pinned-tab.svg
-
9public/web.config
-
50src/App.css
-
375src/App.js
-
8src/App.test.js
-
130src/components/LoadingBar.js
-
18src/components/expense-form/ExpenseForm.css
-
191src/components/expense-form/ExpenseForm.js
-
BINsrc/components/expense-list/.ExpenseList.css.swp
-
32src/components/expense-list/ExpenseDetail.js
-
55src/components/expense-list/ExpenseIcon.js
-
44src/components/expense-list/ExpenseList.css
-
22src/components/expense-list/ExpenseList.js
-
9src/components/index.js
-
6src/index.css
-
10src/index.js
-
51src/registerServiceWorker.js
-
11627yarn.lock
@ -0,0 +1 @@ |
|||
PORT=8080 |
|||
@ -0,0 +1,26 @@ |
|||
{ |
|||
"env": { |
|||
"browser": true, |
|||
"commonjs": true, |
|||
"es6": true, |
|||
"node": true |
|||
}, |
|||
"parserOptions": { |
|||
"ecmaFeatures": { |
|||
"jsx": true |
|||
}, |
|||
"sourceType": "module" |
|||
}, |
|||
"rules": { |
|||
"no-const-assign": "warn", |
|||
"no-this-before-super": "warn", |
|||
"no-undef": "warn", |
|||
"no-unreachable": "warn", |
|||
"no-unused-vars": "warn", |
|||
"constructor-super": "warn", |
|||
"valid-typeof": "warn" |
|||
}, |
|||
"plugins": [ |
|||
"react" |
|||
] |
|||
} |
|||
@ -0,0 +1,23 @@ |
|||
# See https://help.github.com/ignore-files/ for more about ignoring files. |
|||
|
|||
# dependencies |
|||
/node_modules |
|||
|
|||
# testing |
|||
/coverage |
|||
|
|||
# production |
|||
/build |
|||
/production |
|||
/demo |
|||
|
|||
# misc |
|||
.DS_Store |
|||
.env.local |
|||
.env.development.local |
|||
.env.test.local |
|||
.env.production.local |
|||
|
|||
npm-debug.log* |
|||
yarn-debug.log* |
|||
yarn-error.log* |
|||
@ -0,0 +1,21 @@ |
|||
MIT License |
|||
|
|||
Copyright (c) 2017 Jakub Chodounsky |
|||
|
|||
Permission is hereby granted, free of charge, to any person obtaining a copy |
|||
of this software and associated documentation files (the "Software"), to deal |
|||
in the Software without restriction, including without limitation the rights |
|||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
|||
copies of the Software, and to permit persons to whom the Software is |
|||
furnished to do so, subject to the following conditions: |
|||
|
|||
The above copyright notice and this permission notice shall be included in all |
|||
copies or substantial portions of the Software. |
|||
|
|||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
|||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
|||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
|||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
|||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
|||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
|||
SOFTWARE. |
|||
@ -0,0 +1,64 @@ |
|||
💰Expenses is a [progressive web application](https://developers.google.com/web/progressive-web-apps/) on top of [Google Sheets](https://developers.google.com/sheets/) 📉 written in [React](https://facebook.github.io/react/) ⚛️. It is only a static HTML that works great on mobile 📱 and can be deployed anywhere. |
|||
|
|||
Check out the [demo](https://demo-expenses.chodounsky.net) but please be considerate and don't break it for others. |
|||
|
|||
 |
|||
|
|||
It was inspired by the [expense-manager](https://github.com/mitul45/expense-manager) by mitul45 and it uses the [material web components](https://material.io/components/) and [material icons](https://material.io/icons/). |
|||
|
|||
## Features |
|||
|
|||
* Multiple accounts |
|||
* Checking, savings, joint, etc. |
|||
* Categories |
|||
* [Google Sheet](https://docs.google.com/spreadsheets/d/1Lz1_gHIgCKPKhJpFerq9PoNy-TIst7eLZ5plQi5Prv0/edit?usp=sharing) as a backend |
|||
* Great privacy and access control. |
|||
* Don't share sensitive data with 3rd party. |
|||
* Unlimited analysis up to your sheet skill. |
|||
* Works great on mobile |
|||
* Progressive Web App. Loads quickly and works as a standalone app. |
|||
* Beautiful material design |
|||
* Better than native ;) |
|||
* Recurring expenses |
|||
* Totally doable with [Zapier](http://zapier.com/). |
|||
* Monthly summary |
|||
* This month. Last month. You immediately know how you doing. |
|||
|
|||
## Get started |
|||
|
|||
You will need a somewhat recent version of [Node](https://nodejs.org/en/) and a place to deploy static HTML under a custom domain (doesn't have to be top level). To get the full offline support with service workers you'll need HTTPS – [CloudFlare](cloudflare.com) works fine or you can use your own certificate. |
|||
|
|||
1) make a copy of [Expense Sheet](https://docs.google.com/spreadsheets/d/1Lz1_gHIgCKPKhJpFerq9PoNy-TIst7eLZ5plQi5Prv0/edit?usp=sharing) to your drive `File -> Make a copy...` |
|||
2) note the id of your new sheet (it's part of the URL) |
|||
3) clone, install dependencies and build the app: |
|||
|
|||
|
|||
``` |
|||
npm i && REACT_APP_SHEET_ID=<replace with your sheet id> npm run build |
|||
``` |
|||
|
|||
4) copy the content of `build` folder to your server |
|||
|
|||
### Recurring Expenses |
|||
|
|||
Zapier is a service for connecting apps and automating your workflows. And it can be used to add recurring expenses with the [Google Sheets Integrations](https://zapier.com/zapbook/google-sheets/). |
|||
|
|||
Select a trigger – it could be every month, week, or based on anything else. |
|||
|
|||
Use the `Create Spreadsheet Row` integration and select your expense sheet and fill it with the desired values. Easy. |
|||
|
|||
 |
|||
|
|||
### Sharing |
|||
|
|||
Adding another person (for example your partner) to the app is easy – you just give them access to the expense sheet in Google Sheets. |
|||
|
|||
After that, they have the same access as you are and can add expenses through the same URL. |
|||
|
|||
--- |
|||
|
|||
If you like this project – you might also enjoy [React Digest](https://reactdigest.net/) newsletter 🗞. Subscribe to get the top 5 news from React community every week. |
|||
|
|||
## Install |
|||
|
|||
rm -rf node_modules && npm i && REACT_APP_SHEET_ID=1TT4oJ7B_Lq4quyizxK2DxGR_qhYtaG4Mn40j2MNpB6E npm run build && cd build && rm ../expenses.tgz && tar cvzf ../expenses.tgz * && scp ../expenses.tgz jumper@144spokes.com:/tmp/ && cd .. |
|||
@ -0,0 +1,30 @@ |
|||
#!/bin/sh |
|||
|
|||
if [ $# -eq 0 ] |
|||
then |
|||
echo "Add a commit message" |
|||
return 1 |
|||
fi |
|||
|
|||
git add . |
|||
git commit -m "$1" |
|||
git push origin master |
|||
|
|||
# expenses.chodounsky.net |
|||
REACT_APP_SHEET_ID=18uwYwUAVw0H5bhszMgAORmvAN2APxAtJI3FB-XH7Dzk npm run build |
|||
cp -a build/. production/ |
|||
cd production |
|||
git add . |
|||
git commit -m "$1" |
|||
git push origin production |
|||
cd .. |
|||
|
|||
# github pages |
|||
|
|||
npm run build |
|||
cp -a build/. demo/ |
|||
cd demo |
|||
git add . |
|||
git commit -m "$1" |
|||
git push origin gh-pages |
|||
cd .. |
|||
|
After Width: 409 | Height: 726 | Size: 161 KiB |
|
After Width: 409 | Height: 726 | Size: 113 KiB |
|
After Width: 409 | Height: 726 | Size: 106 KiB |
|
After Width: 826 | Height: 1468 | Size: 153 KiB |
|
After Width: 2600 | Height: 1766 | Size: 379 KiB |
39293
package-lock.json
File diff suppressed because it is too large
View File
@ -0,0 +1,32 @@ |
|||
{ |
|||
"name": "expenses", |
|||
"version": "0.1.0", |
|||
"private": true, |
|||
"dependencies": { |
|||
"material-components-web": "^0.12.1", |
|||
"material-icons": "^0.1.0", |
|||
"react": "^15.5.4", |
|||
"react-dom": "^15.5.4" |
|||
}, |
|||
"devDependencies": { |
|||
"react-scripts": "^3.4.1" |
|||
}, |
|||
"scripts": { |
|||
"start": "react-scripts start", |
|||
"build": "react-scripts build", |
|||
"test": "react-scripts test --env=jsdom", |
|||
"eject": "react-scripts eject" |
|||
}, |
|||
"browserslist": { |
|||
"production": [ |
|||
">0.2%", |
|||
"not dead", |
|||
"not op_mini all" |
|||
], |
|||
"development": [ |
|||
"last 1 chrome version", |
|||
"last 1 firefox version", |
|||
"last 1 safari version" |
|||
] |
|||
} |
|||
} |
|||
|
After Width: 192 | Height: 192 | Size: 1.0 KiB |
|
After Width: 512 | Height: 512 | Size: 3.6 KiB |
|
After Width: 180 | Height: 180 | Size: 1.1 KiB |
@ -0,0 +1,9 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<browserconfig> |
|||
<msapplication> |
|||
<tile> |
|||
<square150x150logo src="/mstile-150x150.png"/> |
|||
<TileColor>#50d29d</TileColor> |
|||
</tile> |
|||
</msapplication> |
|||
</browserconfig> |
|||
|
After Width: 16 | Height: 16 | Size: 530 B |
|
After Width: 32 | Height: 32 | Size: 617 B |
@ -0,0 +1,29 @@ |
|||
<!doctype html> |
|||
<html lang="en"> |
|||
|
|||
<head> |
|||
<meta charset="utf-8"> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> |
|||
<meta name="theme-color" content="#27AE60"> |
|||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json"> |
|||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons"> |
|||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico"> |
|||
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png"> |
|||
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.png"> |
|||
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png"> |
|||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json"> |
|||
<link rel="mask-icon" href="%PUBLIC_URL%/safari-pinned-tab.svg" color="#5bbad5"> |
|||
<meta name="theme-color" content="#50d29d"> |
|||
<script src="https://apis.google.com/js/api.js"></script> |
|||
|
|||
<title>Expenses</title> |
|||
</head> |
|||
|
|||
<body> |
|||
<noscript> |
|||
You need to enable JavaScript to run this app. |
|||
</noscript> |
|||
<div id="root"></div> |
|||
</body> |
|||
|
|||
</html> |
|||
@ -0,0 +1,20 @@ |
|||
{ |
|||
"short_name": "Expenses", |
|||
"name": "Expense Manager", |
|||
"icons": [ |
|||
{ |
|||
"src": "/android-chrome-192x192.png", |
|||
"sizes": "192x192", |
|||
"type": "image/png" |
|||
}, |
|||
{ |
|||
"src": "/android-chrome-512x512.png", |
|||
"sizes": "512x512", |
|||
"type": "image/png" |
|||
} |
|||
], |
|||
"theme_color": "#50d29d", |
|||
"background_color": "#50d29d", |
|||
"display": "standalone", |
|||
"start_url": "." |
|||
} |
|||
|
After Width: 144 | Height: 144 | Size: 1.2 KiB |
|
After Width: 270 | Height: 270 | Size: 1.5 KiB |
|
After Width: 558 | Height: 270 | Size: 1.6 KiB |
|
After Width: 558 | Height: 558 | Size: 2.9 KiB |
|
After Width: 128 | Height: 128 | Size: 1.0 KiB |
@ -0,0 +1,24 @@ |
|||
<?xml version="1.0" standalone="no"?> |
|||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" |
|||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd"> |
|||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg" |
|||
width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000" |
|||
preserveAspectRatio="xMidYMid meet"> |
|||
<metadata> |
|||
Created by potrace 1.11, written by Peter Selinger 2001-2013 |
|||
</metadata> |
|||
<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)" |
|||
fill="#000000" stroke="none"> |
|||
<path d="M1405 6122 c-150 -20 -254 -70 -356 -171 -82 -82 -134 -174 -160 |
|||
-281 -20 -83 -21 -4211 -1 -4320 40 -221 220 -408 442 -461 83 -20 4211 -21 |
|||
4320 -1 211 38 389 200 452 412 16 53 20 101 22 258 l2 192 -1345 0 c-1315 0 |
|||
-1348 1 -1426 20 -226 57 -406 260 -432 488 -4 31 -6 604 -6 1272 0 1142 1 |
|||
1219 18 1280 46 165 149 295 296 373 25 13 57 27 70 32 13 4 26 8 29 9 5 3 79 |
|||
19 95 21 6 1 616 3 1356 3 l1345 2 -2 193 c-2 165 -5 202 -24 262 -65 210 |
|||
-239 367 -452 411 -30 7 -4199 13 -4243 6z"/> |
|||
<path d="M3500 3500 l0 -1167 1453 2 c798 1 1455 3 1458 3 4 2 5 2305 1 2323 |
|||
-1 3 -657 5 -1457 5 l-1455 0 0 -1166z m1290 418 c187 -59 311 -225 311 -418 |
|||
-1 -387 -463 -579 -740 -308 -90 88 -132 199 -126 328 5 121 41 203 124 286 |
|||
111 111 288 157 431 112z"/> |
|||
</g> |
|||
</svg> |
|||
@ -0,0 +1,9 @@ |
|||
<?xml version="1.0"?> |
|||
|
|||
<configuration> |
|||
<system.webServer> |
|||
<staticContent> |
|||
<mimeMap fileExtension=".json" mimeType="application/json" /> |
|||
</staticContent> |
|||
</system.webServer> |
|||
</configuration> |
|||
@ -0,0 +1,50 @@ |
|||
.app-fab--absolute { |
|||
position: fixed; |
|||
bottom: 1rem; |
|||
right: 1rem; |
|||
} |
|||
|
|||
header.mdc-toolbar { |
|||
background-color: #50d29d; |
|||
} |
|||
button.mdc-toolbar__icon { |
|||
display: inline-block; |
|||
color: white; |
|||
} |
|||
.toolbar-adjusted-content { |
|||
margin-top: 70px; |
|||
} |
|||
.mdc-card { |
|||
margin: 0.5em; |
|||
background: white; |
|||
} |
|||
.mdc-card__title { |
|||
margin: 0.5em 0; |
|||
font-size: 200%; |
|||
color: #20b1d6; |
|||
} |
|||
.mdc-card__supporting-text { |
|||
color: #7a7a7a; |
|||
} |
|||
.mdc-card__supporting-text:last-child { |
|||
padding-bottom: 8px; |
|||
} |
|||
.center { |
|||
text-align: center; |
|||
} |
|||
button.sign-in { |
|||
margin: 2em auto; |
|||
} |
|||
a.material-icons { |
|||
cursor: pointer; |
|||
} |
|||
.mdc-fab { |
|||
display: inline-block; |
|||
} |
|||
|
|||
@media (min-width: 1024px) { |
|||
.app-fab--absolute { |
|||
bottom: 3rem; |
|||
right: 5rem; |
|||
} |
|||
} |
|||
@ -0,0 +1,375 @@ |
|||
import React, { Component } from "react"; |
|||
import { ExpenseList, ExpenseForm, LoadingBar } from "./components/index"; |
|||
import { MDCSnackbar } from "@material/snackbar/dist/mdc.snackbar.js"; |
|||
|
|||
import "@material/fab/dist/mdc.fab.css"; |
|||
import "@material/button/dist/mdc.button.css"; |
|||
import "@material/toolbar/dist/mdc.toolbar.css"; |
|||
import "@material/snackbar/dist/mdc.snackbar.css"; |
|||
import "@material/card/dist/mdc.card.css"; |
|||
|
|||
import "./App.css"; |
|||
|
|||
class App extends Component { |
|||
constructor() { |
|||
super(); |
|||
|
|||
this.clientId = |
|||
"199291003615-nmv1fbsh6cqt1dt7m92a1r3tb6ljf5rf.apps.googleusercontent.com"; |
|||
this.spreadsheetId = |
|||
process.env.REACT_APP_SHEET_ID || |
|||
"1eYrQf0xhs2mTSWEzQRfSM-MD-tCcx1r0NVEacLg3Jrc"; |
|||
|
|||
this.state = { |
|||
signedIn: undefined, |
|||
accounts: [], |
|||
categories: [], |
|||
expenses: [], |
|||
processing: true, |
|||
expense: {}, |
|||
currentMonth: undefined, |
|||
previousMonth: undefined, |
|||
showExpenseForm: false |
|||
}; |
|||
|
|||
} |
|||
|
|||
componentDidMount() { |
|||
window.gapi.load("client:auth2", () => { |
|||
window.gapi.client |
|||
.init({ |
|||
discoveryDocs: [ |
|||
"https://sheets.googleapis.com/$discovery/rest?version=v4" |
|||
], |
|||
clientId: this.clientId, |
|||
scope: |
|||
"https://www.googleapis.com/auth/spreadsheets https://www.googleapis.com/auth/drive.metadata.readonly" |
|||
}) |
|||
.then(() => { |
|||
window.gapi.auth2 |
|||
.getAuthInstance() |
|||
.isSignedIn.listen(this.signedInChanged); |
|||
this.signedInChanged( |
|||
window.gapi.auth2.getAuthInstance().isSignedIn.get() |
|||
); |
|||
}); |
|||
}); |
|||
document.addEventListener("keyup", this.onKeyPressed.bind(this)); |
|||
} |
|||
|
|||
onKeyPressed = (e) => { |
|||
if (this.state.signedIn === true) { |
|||
if (this.state.showExpenseForm === false) { |
|||
if (e.keyCode === 65) { // a
|
|||
this.onExpenseNew() |
|||
} |
|||
} else { |
|||
if (e.keyCode === 27) { // escape
|
|||
this.handleExpenseCancel() |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
signedInChanged = (signedIn) => { |
|||
this.setState({ signedIn: signedIn }); |
|||
if (this.state.signedIn) { |
|||
this.load(); |
|||
} |
|||
} |
|||
|
|||
handleExpenseSubmit = () => { |
|||
this.setState({ processing: true, showExpenseForm: false }); |
|||
const submitAction = (this.state.expense.id |
|||
? this.update |
|||
: this.append).bind(this); |
|||
submitAction(this.state.expense).then( |
|||
response => { |
|||
this.snackbar.show({ |
|||
message: `Expense ${this.state.expense.id ? "updated" : "added"}!` |
|||
}); |
|||
this.load(); |
|||
}, |
|||
response => { |
|||
console.error("Something went wrong"); |
|||
console.error(response); |
|||
this.setState({ loading: false }); |
|||
} |
|||
); |
|||
} |
|||
|
|||
handleExpenseChange = (attribute, value) => { |
|||
this.setState({ |
|||
expense: Object.assign({}, this.state.expense, { [attribute]: value }) |
|||
}); |
|||
} |
|||
|
|||
handleExpenseDelete = (expense) => { |
|||
this.setState({ processing: true, showExpenseForm: false }); |
|||
const expenseRow = expense.id.substring(10); |
|||
window.gapi.client.sheets.spreadsheets |
|||
.batchUpdate({ |
|||
spreadsheetId: this.spreadsheetId, |
|||
resource: { |
|||
requests: [ |
|||
{ |
|||
deleteDimension: { |
|||
range: { |
|||
sheetId: 0, |
|||
dimension: "ROWS", |
|||
startIndex: expenseRow - 1, |
|||
endIndex: expenseRow |
|||
} |
|||
} |
|||
} |
|||
] |
|||
} |
|||
}) |
|||
.then( |
|||
response => { |
|||
this.snackbar.show({ message: "Expense deleted!" }); |
|||
this.load(); |
|||
}, |
|||
response => { |
|||
console.error("Something went wrong"); |
|||
console.error(response); |
|||
this.setState({ loading: false }); |
|||
} |
|||
); |
|||
} |
|||
|
|||
handleExpenseSelect = (expense) => { |
|||
this.setState({ expense: expense, showExpenseForm: true }); |
|||
} |
|||
|
|||
handleExpenseCancel = () => { |
|||
this.setState({ showExpenseForm: false }); |
|||
} |
|||
|
|||
onExpenseNew() { |
|||
const now = new Date(); |
|||
this.setState({ |
|||
showExpenseForm: true, |
|||
expense: { |
|||
amount: "", |
|||
description: "", |
|||
date: `${now.getFullYear()}-${now.getMonth() < 9 |
|||
? "0" + (now.getMonth() + 1) |
|||
: now.getMonth() + 1}-${now.getDate() < 10 |
|||
? "0" + now.getDate() |
|||
: now.getDate()}`,
|
|||
category: this.state.categories[0], |
|||
account: this.state.accounts[0] |
|||
} |
|||
}); |
|||
} |
|||
|
|||
parseExpense(value, index) { |
|||
return { |
|||
id: `Expenses!A${index + 2}`, |
|||
date: value[0], |
|||
description: value[1], |
|||
category: value[3], |
|||
amount: value[4].replace(",", ""), |
|||
account: value[2] |
|||
}; |
|||
} |
|||
|
|||
formatExpense(expense) { |
|||
return [ |
|||
`=DATE(${expense.date.substr(0, 4)}, ${expense.date.substr( |
|||
5, |
|||
2 |
|||
)}, ${expense.date.substr(-2)})`,
|
|||
expense.description, |
|||
expense.account, |
|||
expense.category, |
|||
expense.amount |
|||
]; |
|||
} |
|||
|
|||
append(expense) { |
|||
return window.gapi.client.sheets.spreadsheets.values.append({ |
|||
spreadsheetId: this.spreadsheetId, |
|||
range: "Expenses!A1", |
|||
valueInputOption: "USER_ENTERED", |
|||
insertDataOption: "INSERT_ROWS", |
|||
values: [this.formatExpense(expense)] |
|||
}); |
|||
} |
|||
|
|||
update(expense) { |
|||
return window.gapi.client.sheets.spreadsheets.values.update({ |
|||
spreadsheetId: this.spreadsheetId, |
|||
range: expense.id, |
|||
valueInputOption: "USER_ENTERED", |
|||
values: [this.formatExpense(expense)] |
|||
}); |
|||
} |
|||
|
|||
load() { |
|||
window.gapi.client.sheets.spreadsheets.values |
|||
.batchGet({ |
|||
spreadsheetId: this.spreadsheetId, |
|||
ranges: [ |
|||
"Data!A2:A50", |
|||
"Data!E2:E50", |
|||
"Expenses!A2:F", |
|||
"Current!H1", |
|||
"Previous!H1" |
|||
] |
|||
}) |
|||
.then(response => { |
|||
const accounts = response.result.valueRanges[0].values.map( |
|||
items => items[0] |
|||
); |
|||
const categories = response.result.valueRanges[1].values.map( |
|||
items => items[0] |
|||
); |
|||
this.setState({ |
|||
accounts: accounts, |
|||
categories: categories, |
|||
expenses: (response.result.valueRanges[2].values || []) |
|||
.map(this.parseExpense) |
|||
.reverse() |
|||
.slice(0, 30), |
|||
processing: false, |
|||
currentMonth: response.result.valueRanges[3].values[0][0], |
|||
previousMonth: response.result.valueRanges[4].values[0][0] |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
render() { |
|||
return ( |
|||
<div> |
|||
<header className="mdc-toolbar mdc-toolbar--fixed"> |
|||
<div className="mdc-toolbar__row"> |
|||
<section className="mdc-toolbar__section mdc-toolbar__section--align-start"> |
|||
<span className="mdc-toolbar__title">Expenses</span> |
|||
</section> |
|||
<section |
|||
className="mdc-toolbar__section mdc-toolbar__section--align-end" |
|||
role="toolbar" |
|||
> |
|||
{this.state.signedIn === false && |
|||
<a |
|||
className="material-icons mdc-toolbar__icon" |
|||
aria-label="Sign in" |
|||
alt="Sign in" |
|||
onClick={e => { |
|||
e.preventDefault(); |
|||
window.gapi.auth2.getAuthInstance().signIn(); |
|||
}} |
|||
> |
|||
perm_identity |
|||
</a>} |
|||
{this.state.signedIn && |
|||
<a |
|||
className="material-icons mdc-toolbar__icon" |
|||
aria-label="Sign out" |
|||
alt="Sign out" |
|||
onClick={e => { |
|||
e.preventDefault(); |
|||
window.gapi.auth2.getAuthInstance().signOut(); |
|||
}} |
|||
> |
|||
exit_to_app |
|||
</a>} |
|||
</section> |
|||
</div> |
|||
</header> |
|||
<div className="toolbar-adjusted-content"> |
|||
{this.state.signedIn === undefined && <LoadingBar />} |
|||
{this.state.signedIn === false && |
|||
<div className="center"> |
|||
<button |
|||
className="mdc-button sign-in" |
|||
aria-label="Sign in" |
|||
onClick={() => { |
|||
window.gapi.auth2.getAuthInstance().signIn(); |
|||
}} |
|||
> |
|||
Sign In |
|||
</button> |
|||
</div>} |
|||
{this.state.signedIn && this.renderBody()} |
|||
</div> |
|||
<div |
|||
ref={el => { |
|||
if (el) { |
|||
this.snackbar = new MDCSnackbar(el); |
|||
} |
|||
}} |
|||
className="mdc-snackbar" |
|||
aria-live="assertive" |
|||
aria-atomic="true" |
|||
aria-hidden="true" |
|||
> |
|||
<div className="mdc-snackbar__text" /> |
|||
<div className="mdc-snackbar__action-wrapper"> |
|||
<button |
|||
type="button" |
|||
className="mdc-button mdc-snackbar__action-button" |
|||
aria-hidden="true" |
|||
/> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
renderBody() { |
|||
if (this.state.processing) return <LoadingBar />; |
|||
else |
|||
return ( |
|||
<div className="content"> |
|||
{this.renderExpenses()} |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
renderExpenses() { |
|||
if (this.state.showExpenseForm) |
|||
return ( |
|||
<ExpenseForm |
|||
categories={this.state.categories} |
|||
accounts={this.state.accounts} |
|||
expense={this.state.expense} |
|||
onSubmit={this.handleExpenseSubmit} |
|||
onCancel={this.handleExpenseCancel} |
|||
onDelete={this.handleExpenseDelete} |
|||
onChange={this.handleExpenseChange} |
|||
/> |
|||
); |
|||
else |
|||
return ( |
|||
<div> |
|||
<div className="mdc-card"> |
|||
<section className="mdc-card__primary"> |
|||
<h2 className="mdc-card__subtitle">This month you've spent:</h2> |
|||
<h1 className="mdc-card__title mdc-card__title--large center"> |
|||
{this.state.currentMonth} |
|||
</h1> |
|||
</section> |
|||
<section className="mdc-card__supporting-text"> |
|||
Previous month: {this.state.previousMonth} |
|||
</section> |
|||
</div> |
|||
<ExpenseList |
|||
expenses={this.state.expenses} |
|||
onSelect={this.handleExpenseSelect} |
|||
/> |
|||
<button |
|||
onClick={() => this.onExpenseNew()} |
|||
className="mdc-fab app-fab--absolute material-icons" |
|||
aria-label="Add expense" |
|||
> |
|||
<span className="mdc-fab__icon">add</span> |
|||
</button> |
|||
</div> |
|||
); |
|||
} |
|||
} |
|||
|
|||
export default App; |
|||
@ -0,0 +1,8 @@ |
|||
import React from "react"; |
|||
import ReactDOM from "react-dom"; |
|||
import App from "./App"; |
|||
|
|||
it("renders without crashing", () => { |
|||
const div = document.createElement("div"); |
|||
ReactDOM.render(<App />, div); |
|||
}); |
|||
@ -0,0 +1,130 @@ |
|||
import React, { Component } from "react"; |
|||
|
|||
class LoadingBar extends Component { |
|||
render() { |
|||
return ( |
|||
<div className="center"> |
|||
<svg |
|||
width="80px" |
|||
height="80px" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
viewBox="0 0 100 100" |
|||
preserveAspectRatio="xMidYMid" |
|||
> |
|||
<circle cx="84" cy="50" r="0" fill="#c0f6d2"> |
|||
<animate |
|||
attributeName="r" |
|||
values="11;0;0;0;0" |
|||
keyTimes="0;0.25;0.5;0.75;1" |
|||
keySplines="0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1" |
|||
calcMode="spline" |
|||
dur="1s" |
|||
repeatCount="indefinite" |
|||
begin="0s" |
|||
/> |
|||
<animate |
|||
attributeName="cx" |
|||
values="84;84;84;84;84" |
|||
keyTimes="0;0.25;0.5;0.75;1" |
|||
keySplines="0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1" |
|||
calcMode="spline" |
|||
dur="1s" |
|||
repeatCount="indefinite" |
|||
begin="0s" |
|||
/> |
|||
</circle> |
|||
<circle cx="40.0957" cy="50" r="11" fill="#ff7c81"> |
|||
<animate |
|||
attributeName="r" |
|||
values="0;11;11;11;0" |
|||
keyTimes="0;0.25;0.5;0.75;1" |
|||
keySplines="0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1" |
|||
calcMode="spline" |
|||
dur="1s" |
|||
repeatCount="indefinite" |
|||
begin="-0.5s" |
|||
/> |
|||
<animate |
|||
attributeName="cx" |
|||
values="16;16;50;84;84" |
|||
keyTimes="0;0.25;0.5;0.75;1" |
|||
keySplines="0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1" |
|||
calcMode="spline" |
|||
dur="1s" |
|||
repeatCount="indefinite" |
|||
begin="-0.5s" |
|||
/> |
|||
</circle> |
|||
<circle cx="16" cy="50" r="7.79567" fill="#fac090"> |
|||
<animate |
|||
attributeName="r" |
|||
values="0;11;11;11;0" |
|||
keyTimes="0;0.25;0.5;0.75;1" |
|||
keySplines="0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1" |
|||
calcMode="spline" |
|||
dur="1s" |
|||
repeatCount="indefinite" |
|||
begin="-0.25s" |
|||
/> |
|||
<animate |
|||
attributeName="cx" |
|||
values="16;16;50;84;84" |
|||
keyTimes="0;0.25;0.5;0.75;1" |
|||
keySplines="0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1" |
|||
calcMode="spline" |
|||
dur="1s" |
|||
repeatCount="indefinite" |
|||
begin="-0.25s" |
|||
/> |
|||
</circle> |
|||
<circle cx="84" cy="50" r="3.20433" fill="#ffffcb"> |
|||
<animate |
|||
attributeName="r" |
|||
values="0;11;11;11;0" |
|||
keyTimes="0;0.25;0.5;0.75;1" |
|||
keySplines="0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1" |
|||
calcMode="spline" |
|||
dur="1s" |
|||
repeatCount="indefinite" |
|||
begin="0s" |
|||
/> |
|||
<animate |
|||
attributeName="cx" |
|||
values="16;16;50;84;84" |
|||
keyTimes="0;0.25;0.5;0.75;1" |
|||
keySplines="0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1" |
|||
calcMode="spline" |
|||
dur="1s" |
|||
repeatCount="indefinite" |
|||
begin="0s" |
|||
/> |
|||
</circle> |
|||
<circle cx="74.0957" cy="50" r="11" fill="#c0f6d2"> |
|||
<animate |
|||
attributeName="r" |
|||
values="0;0;11;11;11" |
|||
keyTimes="0;0.25;0.5;0.75;1" |
|||
keySplines="0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1" |
|||
calcMode="spline" |
|||
dur="1s" |
|||
repeatCount="indefinite" |
|||
begin="0s" |
|||
/> |
|||
<animate |
|||
attributeName="cx" |
|||
values="16;16;16;50;84" |
|||
keyTimes="0;0.25;0.5;0.75;1" |
|||
keySplines="0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1" |
|||
calcMode="spline" |
|||
dur="1s" |
|||
repeatCount="indefinite" |
|||
begin="0s" |
|||
/> |
|||
</circle> |
|||
</svg> |
|||
</div> |
|||
); |
|||
} |
|||
} |
|||
|
|||
export default LoadingBar; |
|||
@ -0,0 +1,18 @@ |
|||
form { |
|||
padding: 0 2em; |
|||
max-width: 400px; |
|||
margin: 0 auto; |
|||
} |
|||
input, select { |
|||
min-width: 200px; |
|||
} |
|||
.mdc-form-field { |
|||
display: block; |
|||
} |
|||
.mdc-select, .mdc-form-submit { |
|||
margin-top: 24px; |
|||
} |
|||
.mdc-form-submit { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
} |
|||
@ -0,0 +1,191 @@ |
|||
import React, { Component } from "react"; |
|||
import { MDCTextfield } from "@material/textfield/dist/mdc.textfield.js"; |
|||
import { MDCDialog } from "@material/dialog/dist/mdc.dialog.js"; |
|||
|
|||
import "@material/form-field/dist/mdc.form-field.css"; |
|||
import "@material/select/dist/mdc.select.css"; |
|||
import "@material/textfield/dist/mdc.textfield.css"; |
|||
import "@material/button/dist/mdc.button.css"; |
|||
import "@material/dialog/dist/mdc.dialog.css"; |
|||
|
|||
import "./ExpenseForm.css"; |
|||
|
|||
class ExpenseForm extends Component { |
|||
constructor(props) { |
|||
super(props); |
|||
|
|||
this.state = { isValid: false }; |
|||
} |
|||
|
|||
handleInputChange = (event) => { |
|||
const target = event.target; |
|||
|
|||
target.reportValidity(); |
|||
this.setState({ isValid: this.form.checkValidity() }); |
|||
this.props.onChange(target.name, target.value); |
|||
} |
|||
|
|||
componentDidMount() { |
|||
document.querySelectorAll(".mdc-textfield").forEach(selector => { |
|||
new MDCTextfield(selector); |
|||
}); |
|||
if (this.props.expense.id === undefined) { |
|||
this.amountInput.focus(); |
|||
} |
|||
} |
|||
|
|||
handleSubmit = (event) => { |
|||
event.preventDefault(); |
|||
this.props.onSubmit(); |
|||
} |
|||
|
|||
initializeDeleteModal = (element) => { |
|||
if (element) { |
|||
this.dialog = new MDCDialog(element); |
|||
this.dialog.listen("MDCDialog:accept", () => { |
|||
// a fix for not closing the modal dialog properly
|
|||
document.body.className = document.body.className.replace( |
|||
"mdc-dialog-scroll-lock", |
|||
"" |
|||
); |
|||
this.props.onDelete(this.props.expense); |
|||
}); |
|||
} |
|||
} |
|||
|
|||
render() { |
|||
return ( |
|||
<form |
|||
onSubmit={this.handleSubmit} |
|||
ref={form => { |
|||
this.form = form; |
|||
}} |
|||
noValidate |
|||
> |
|||
<aside className="mdc-dialog" ref={this.initializeDeleteModal}> |
|||
<div className="mdc-dialog__surface"> |
|||
<header className="mdc-dialog__header"> |
|||
<h2 className="mdc-dialog__header__title"> |
|||
Are you sure? |
|||
</h2> |
|||
</header> |
|||
<section className="mdc-dialog__body"> |
|||
Do you really want to delete the expense? |
|||
</section> |
|||
<footer className="mdc-dialog__footer"> |
|||
<button |
|||
type="button" |
|||
className="mdc-button mdc-dialog__footer__button mdc-dialog__footer__button--cancel" |
|||
> |
|||
Cancel |
|||
</button> |
|||
<button |
|||
type="button" |
|||
className="mdc-button mdc-dialog__footer__button mdc-dialog__footer__button--accept" |
|||
> |
|||
Delete |
|||
</button> |
|||
</footer> |
|||
</div> |
|||
</aside> |
|||
<div className="mdc-form-field"> |
|||
<div className="mdc-textfield"> |
|||
<input |
|||
name="amount" |
|||
className="mdc-textfield__input" |
|||
ref={el => { |
|||
this.amountInput = el; |
|||
}} |
|||
value={this.props.expense.amount} |
|||
onChange={this.handleInputChange} |
|||
type="number" |
|||
step="0.01" |
|||
min="0" |
|||
required |
|||
/> |
|||
<label className="mdc-textfield__label">Amount</label> |
|||
</div> |
|||
</div> |
|||
|
|||
<div className="mdc-form-field"> |
|||
<select |
|||
name="category" |
|||
className="mdc-select" |
|||
value={this.props.expense.category} |
|||
onChange={this.handleInputChange} |
|||
required |
|||
> |
|||
{this.props.categories.map(category => |
|||
<option value={category} key={category}>{category}</option> |
|||
)} |
|||
</select> |
|||
</div> |
|||
|
|||
<div className="mdc-form-field"> |
|||
<div className="mdc-textfield"> |
|||
<input |
|||
name="description" |
|||
className="mdc-textfield__input" |
|||
value={this.props.expense.description} |
|||
onChange={this.handleInputChange} |
|||
type="text" |
|||
/> |
|||
<label className="mdc-textfield__label">Description</label> |
|||
</div> |
|||
</div> |
|||
|
|||
<div className="mdc-form-field"> |
|||
<div className="mdc-textfield"> |
|||
<input |
|||
name="date" |
|||
className="mdc-textfield__input" |
|||
value={this.props.expense.date} |
|||
onChange={this.handleInputChange} |
|||
type="date" |
|||
required |
|||
/> |
|||
<label className="mdc-textfield__label">Date</label> |
|||
</div> |
|||
</div> |
|||
|
|||
<div className="mdc-form-field"> |
|||
<select |
|||
name="account" |
|||
className="mdc-select" |
|||
value={this.props.expense.account} |
|||
onChange={this.handleInputChange} |
|||
required |
|||
> |
|||
{this.props.accounts.map(account => |
|||
<option value={account} key={account}>{account}</option> |
|||
)} |
|||
</select> |
|||
</div> |
|||
|
|||
<div className="mdc-form-field mdc-form-submit"> |
|||
<input |
|||
type="submit" |
|||
className="mdc-button" |
|||
value={this.props.expense.id ? "Update" : "Add"} |
|||
disabled={!this.state.isValid} |
|||
/> |
|||
{this.props.expense.id && |
|||
<input |
|||
type="button" |
|||
className="mdc-button" |
|||
onClick={() => this.dialog.show()} |
|||
value="Delete" |
|||
/>} |
|||
<input |
|||
type="button" |
|||
className="mdc-button" |
|||
onClick={() => this.props.onCancel()} |
|||
value="Close" |
|||
/> |
|||
</div> |
|||
</form> |
|||
); |
|||
} |
|||
} |
|||
|
|||
export default ExpenseForm; |
|||
@ -0,0 +1,32 @@ |
|||
import React, { Component } from "react"; |
|||
import ExpenseIcon from "./ExpenseIcon"; |
|||
|
|||
export default class ExpenseDetail extends Component { |
|||
formatDate(date) { |
|||
const dateParts = date.split("-"); |
|||
return `${dateParts[2]}/${dateParts[1]}/${dateParts[0]}`; |
|||
} |
|||
|
|||
render() { |
|||
return ( |
|||
<li |
|||
className="mdc-list-item" |
|||
onClick={() => this.props.onSelect(this.props.expense)} |
|||
> |
|||
<ExpenseIcon category={this.props.expense.category} /> |
|||
<span className="mdc-list-item__text"> |
|||
{this.props.expense.category} |
|||
<span className="mdc-list-item__text__secondary"> |
|||
{this.formatDate(this.props.expense.date)} |
|||
{this.props.expense.description |
|||
? ` ${this.props.expense.description.replace(/^(.{14}).+/, "$1…")}` |
|||
: ""} |
|||
</span> |
|||
</span> |
|||
<span className="mdc-list-item__end-detail"> |
|||
${this.props.expense.amount} |
|||
</span> |
|||
</li> |
|||
); |
|||
} |
|||
} |
|||
@ -0,0 +1,55 @@ |
|||
import React, { Component } from 'react'; |
|||
|
|||
export default class ExpenseIcon extends Component { |
|||
iconFrom(category) { |
|||
switch (category) { |
|||
case "Lebensmittel": |
|||
return "local_grocery_store"; |
|||
case "Restaurant": |
|||
return "local_restaurant"; |
|||
case "Auto": |
|||
return "directions_car"; |
|||
case "Tanken": |
|||
return "local_gas_station"; |
|||
case "Hobbies": |
|||
return "pedal_bike"; |
|||
case "Kleidung": |
|||
return "checkroom"; |
|||
case "Shopping": |
|||
return "shopping_cart"; |
|||
case "Gesundheit": |
|||
return "local_hospital"; |
|||
case "Drogerie": |
|||
return "clean_hands"; |
|||
case "Unterhaltung": |
|||
return "subscriptions"; |
|||
case "Transport": |
|||
return "directions_subway"; |
|||
case "Übernachtung": |
|||
return "local_hotel"; |
|||
case "Yvonne": |
|||
return "face"; |
|||
case "Haustiere": |
|||
return "pets"; |
|||
case "Fixkosten": |
|||
return "payments"; |
|||
case "Sonstiges": |
|||
return "compost"; |
|||
default: |
|||
return "attach_money"; |
|||
} |
|||
} |
|||
|
|||
render() { |
|||
return ( |
|||
<span |
|||
className={`mdc-list-item__start-detail ${this.props.category}`} |
|||
role="presentation" |
|||
> |
|||
<i className="material-icons" aria-hidden="true"> |
|||
{this.iconFrom(this.props.category)} |
|||
</i> |
|||
</span> |
|||
); |
|||
} |
|||
} |
|||
@ -0,0 +1,44 @@ |
|||
.mdc-list-item { |
|||
cursor: pointer; |
|||
} |
|||
.mdc-list-item__start-detail { |
|||
background: rgba(0, 0, 0, .26); |
|||
color: white; |
|||
display: inline-flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
} |
|||
.mdc-list-item__end-detail { |
|||
width: auto; |
|||
} |
|||
|
|||
.Lebensmittel { |
|||
background: rgb(85, 178, 35); |
|||
} |
|||
.Restaurant { |
|||
background: rgb(242, 60, 0); |
|||
} |
|||
.Auto, .Transport, .Tanken { |
|||
background: rgb(166, 2, 221); |
|||
} |
|||
.Hobbies { |
|||
background: rgb(38, 94, 4); |
|||
} |
|||
.Kleidung, .Shopping, Drogerie { |
|||
background: rgb(0, 183, 224); |
|||
} |
|||
.Übernachtung { |
|||
background: rgb(214, 214, 0); |
|||
} |
|||
.Gesundheit { |
|||
background: rgb(252, 169, 5); |
|||
} |
|||
.Unterhaltung { |
|||
background: rgb(245, 151, 29); |
|||
} |
|||
.Yvonne, .Haustiere { |
|||
background: rgb(252, 194, 246) |
|||
} |
|||
.Fixkosten, .Sonstiges { |
|||
background: rgb(146, 148, 142) |
|||
} |
|||
@ -0,0 +1,22 @@ |
|||
import React, { Component } from "react"; |
|||
import ExpenseDetail from "./ExpenseDetail.js" |
|||
import "@material/list/dist/mdc.list.css"; |
|||
import "./ExpenseList.css"; |
|||
|
|||
class ExpenseList extends Component { |
|||
render() { |
|||
return ( |
|||
<ul className="mdc-list mdc-list--two-line mdc-list--avatar-list"> |
|||
{this.props.expenses.map(expense => |
|||
<ExpenseDetail |
|||
key={expense.id} |
|||
expense={expense} |
|||
onSelect={this.props.onSelect} |
|||
/> |
|||
)} |
|||
</ul> |
|||
); |
|||
} |
|||
} |
|||
|
|||
export default ExpenseList; |
|||
@ -0,0 +1,9 @@ |
|||
import ExpenseList from "./expense-list/ExpenseList"; |
|||
import ExpenseForm from "./expense-form/ExpenseForm"; |
|||
import LoadingBar from "./LoadingBar"; |
|||
|
|||
export { |
|||
ExpenseList, |
|||
ExpenseForm, |
|||
LoadingBar |
|||
}; |
|||
@ -0,0 +1,6 @@ |
|||
body { |
|||
margin: 0; |
|||
padding: 0; |
|||
font-family: sans-serif; |
|||
background: #fafafa; |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
import React from "react"; |
|||
import ReactDOM from "react-dom"; |
|||
|
|||
import App from "./App"; |
|||
import registerServiceWorker from "./registerServiceWorker"; |
|||
import "material-icons/css/material-icons.css"; |
|||
import "./index.css"; |
|||
|
|||
ReactDOM.render(<App />, document.getElementById("root")); |
|||
registerServiceWorker(); |
|||
@ -0,0 +1,51 @@ |
|||
// In production, we register a service worker to serve assets from local cache.
|
|||
|
|||
// This lets the app load faster on subsequent visits in production, and gives
|
|||
// it offline capabilities. However, it also means that developers (and users)
|
|||
// will only see deployed updates on the "N+1" visit to a page, since previously
|
|||
// cached resources are updated in the background.
|
|||
|
|||
// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
|
|||
// This link also includes instructions on opting out of this behavior.
|
|||
|
|||
export default function register() { |
|||
if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) { |
|||
window.addEventListener("load", () => { |
|||
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; |
|||
navigator.serviceWorker |
|||
.register(swUrl) |
|||
.then(registration => { |
|||
registration.onupdatefound = () => { |
|||
const installingWorker = registration.installing; |
|||
installingWorker.onstatechange = () => { |
|||
if (installingWorker.state === "installed") { |
|||
if (navigator.serviceWorker.controller) { |
|||
// At this point, the old content will have been purged and
|
|||
// the fresh content will have been added to the cache.
|
|||
// It's the perfect time to display a "New content is
|
|||
// available; please refresh." message in your web app.
|
|||
console.log("New content is available; please refresh."); |
|||
} else { |
|||
// At this point, everything has been precached.
|
|||
// It's the perfect time to display a
|
|||
// "Content is cached for offline use." message.
|
|||
console.log("Content is cached for offline use."); |
|||
} |
|||
} |
|||
}; |
|||
}; |
|||
}) |
|||
.catch(error => { |
|||
console.error("Error during service worker registration:", error); |
|||
}); |
|||
}); |
|||
} |
|||
} |
|||
|
|||
export function unregister() { |
|||
if ("serviceWorker" in navigator) { |
|||
navigator.serviceWorker.ready.then(registration => { |
|||
registration.unregister(); |
|||
}); |
|||
} |
|||
} |
|||