Node.jsで家計簿アプリを作る

家計簿アプリ、作ってみっか……

一年くらい前にクソみたいな家計簿アプリを作りましたが、今ならもっと使い易い物が作れるのでは?と思いました。今回はNode.jsで作ってみたいと思います。

  • 電卓ページ
    • トップページ
    • 上半分:その日何を買ったか
    • 下半分:電卓
  • 参照ページ
    • 2020年
      • 05月
        • 01日
        • 02日
  • 閲覧ページ
    • 日別詳細ページ
      • カテゴリ:何円
      • カテゴリ:何円
    • 月別円グラフページ
      • カテゴリ別円グラフ

イメージは上みたいな感じです。

環境準備

expressでRESTfulで実装します。Node.jsをインストールします。

リポジトリ追加
# curl -sL https://rpm.nodesource.com/setup_13.x | bash
インストール
# yum -y install nodejs
# node -v
v13.14.0
# npm init
  ※全部エンター
# npm install express --save
# npm install ejs --save
# npm install jquery --save
# npm install mysql --save

DBインストール&設定。

MySQLインストール
# yum install -y mariadb mariadb-server
# mysql -V
mysql  Ver 15.1 Distrib 5.5.65-MariaDB, for Linux (x86_64) using readline 5.1
# systemctl start mariadb
# systemctl enable mariadb
初期設定ウィザード
# mysql_secure_installation
Enter current password for root (enter for none): # Enter
Set root password? [Y/n] # Enter
New password: # 新しいパスワードの入力
Re-enter new password: # 新しいパスワードを再入力
Remove anonymous users? [Y/n] # Enter
Disallow root login remotely? [Y/n] # Enter
Remove test database and access to it? [Y/n]  # Enter
Reload privilege tables now? [Y/n] # Enter
[mariadb]
character-set-server = utf8mb4
[client-mariadb]
default-character-set = utf8mb4

再起動してログイン。

MariaDB [(none)]> CREATE DATABASE kakeibo;
Query OK, 1 row affected (0.00 sec)

MariaDB [(none)]> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| kakeibo            |
| mysql              |
| performance_schema |
+--------------------+
4 rows in set (0.00 sec)
MariaDB [(none)]>
MariaDB [(none)]> USE kakeibo
Database changed
MariaDB [kakeibo]> 
MariaDB [kakeibo]>CREATE TABLE kakeibo_tbl(
	id INT(11) AUTO_INCREMENT NOT NULL, 
	nedan INT(11) NOT NULL ,
	category VARCHAR(30) NOT NULL,
	time VARCHAR(30) NOT NULL,
	memo VARCHAR(30),
	PRIMARY KEY (id));

Query OK, 0 rows affected (0.00 sec)

MariaDB [kakeibo]> show tables;
+-------------------+
| Tables_in_kakeibo |
+-------------------+
| kakeibo_tbl       |
+-------------------+
1 row in set (0.00 sec)

MariaDB [kakeibo]> desc kakeibo_tbl;
+----------+-------------+------+-----+---------+----------------+
| Field    | Type        | Null | Key | Default | Extra          |
+----------+-------------+------+-----+---------+----------------+
| id       | int(11)     | NO   | PRI | NULL    | auto_increment |
| nedan    | int(11)     | NO   |     | NULL    |                |
| category | varchar(30) | NO   |     | NULL    |                |
| time     | varchar(30) | NO   |     | NULL    |                |
| memo     | varchar(30) | NO   |     | NULL    |                |
+----------+-------------+------+-----+---------+----------------+
5 rows in set (0.00 sec)

MariaDB [kakeibo]> 
MariaDB [kakeibo]> quit

コーディング

ejsコンポーネントとcssファイル用のディレクトリを作成。

# mkdir views
# mkdir public

電卓UIのhtml/css/jsはこちらを拝借しました。

# cat index.js
const express = require('express')
const bodyParser = require('body-parser')
const $ = require('jquery');
const mysql = require('mysql');
const connection = mysql.createConnection({
	host     : 'localhost',
	user     : 'root',
	password : 'password',
	database : 'kakeibo'
});

const app = express()
app.use(bodyParser.urlencoded({extended: false}));
app.use(bodyParser.json())
app.use(express.static('public'))
app.set("view engine", "ejs");
app.use("/jquery", express.static(__dirname + "/node_modules/jquery/dist/"));

app.post('/post', function(req, res) {
	// 挿入ロジック
	nedan = req.body.nedan;
	category = req.body.category;
	time = req.body.time;
	connection.query('INSERT INTO kakeibo_tbl set ?', {nedan:nedan, category:category, time:time}, function (error, results, fields) {
		if (error) throw error;
	});
})

app.get('/ref/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]', function(req, res) {
	// 日別用クエリ
	var date = req.url.substr( 5, 15 );
	var query = "SELECT * FROM kakeibo_tbl WHERE time LIKE \'" + date + "%\'";
	connection.query(query, function (error, results, fields) {
		if (error) throw error;
		res.render("index4.ejs", {data:results});
	});
})

app.get('/ref/[0-9][0-9][0-9][0-9]-[0-9][0-9]', function(req, res) {
	// 月別用円グラフ用クエリ
	var month = req.url.substr( 5, 12 );
	var query ="SELECT SUM(tmp.nedan) AS total, category FROM (SELECT * FROM kakeibo_tbl WHERE time LIKE " + "\'" + month + "%\'" + " ORDER BY category) AS tmp GROUP BY tmp.category";
	connection.query(query, function (error, results, fields) {
		if (error) throw error;
		var zptotal = 0;
		var skhtotal = 0;
		var lfgtotal = 0;
		for ( i=0; results[i]; i++) {
			if (results[i].category == 'lifegood') {
				lfgtotal = results[i].total;
			}
			if (results[i].category == 'syokuhi') {
				skhtotal = results[i].total;
			}
			if (results[i].category == 'zappi') {
				zptotal = results[i].total;
			}
		}
		res.render("index3.ejs", {skhtotal:skhtotal, zptotal:zptotal, lfgtotal:lfgtotal, month:month});
	});
})

app.get('/ref', function(req, res) {
	// 過去月取得クエリ
	var query = "SELECT DISTINCT SUBSTRING(time, 1,7) as month FROM kakeibo_tbl";
	connection.query(query, function (error, results, fields) {
		if (error) throw error;
		var data = results;
		res.render("index2.ejs", {data:data});
	});
})

app.get('/', function(req, res) {
	// 1桁の数字を0埋めで2桁にする
	var toDoubleDigits = function(num) {
		num += "";
		if (num.length === 1) {
			num = "0" + num;
		}
		return num;     
	};
	// 時間取得
	var now = new Date();
	var Year = now.getFullYear();
	var Month = toDoubleDigits(now.getMonth()+1);
	var Hi = toDoubleDigits(now.getDate());
	var Hour = toDoubleDigits(now.getHours());
	var Min = toDoubleDigits(now.getMinutes());
	var today = "\'" + Year + "-" + Month + "-" + Hi + "%" + "\'";
	// 当日分抽出クエリ
	var query = "SELECT * FROM kakeibo_tbl WHERE time LIKE " + today;
	connection.query(query, function (error, results, fields) {
		if (error) throw error;
		var data = results;
		res.render("index.ejs", {data:data});
	});
})

app.listen(process.env.PORT || 3000)
# cat views/index.ejs
<!DOCTYPE html>
<html>
	<head>
		<meta charset="utf-8">
		<title>入力</title>
		<link rel="stylesheet" href="./css/style.css">
		<script src="/jquery/jquery.js"></script>
	</head>
	<body>
		<div class = "container2">
		<ul class = "category">
			<a href="/" text-decoration="none"><li class="toplist">入力</li></a>
			<a href="/ref" text-decoration="none"><li class="toplist">参照</li></a>
			<li class="toplist">??</li>
		</ul>
		</div>
		<%- include("today.ejs", data) %>
		<!--             電卓 -->
		<%- include("calc.ejs") %>
	</body>
</html>
# cat views/today.ejs
<table border="1" align="center">
<tr>
<th>ID</th><th>値段</th><th>カテゴリ</th><th>日時</th><th>メモ</th>
</tr>
<% for (let item of data) { %>
    <tr>
    <td><%- item.id %></td><td><%= item.nedan %></td><td><%= item.category %></td><td><%= item.time %></td><td><%= item.memo %></td>
    </tr>
<% } %>
</table>
# cat views/calc.ejs
<!--             電卓 -->
<div class = "container">
	<!--      計算結果を出力する場所-->
	<div id = "output_total" class="output ">0</div>
	<!--      計算過程を出力する -->
	<div id = "output_sub" class="output active">0</div>
	<!--      ボタンを配置する場所-->
	<div class = 'input'>
		<section class = 'row'>
			<div id = "clear">C</div>
			<div class = "num_bth"></div>
			<div class = "num_bth" data-index-id = 'bs' id="bs">BS</div>       
			<div class = "num_bth " data-index-id= '/'>÷</div>
		</section>
		<section class = 'row'>
			<div class = "num_bth " data-index-id = 9 >9</div>
			<div class = "num_bth " data-index-id = 8 >8</div>
			<div class = "num_bth " data-index-id = 7 >7</div>
			<div class = "num_bth " data-index-id = '*'>×</div>
		</section>
		<section class = 'row'>
			<div class = "num_bth " data-index-id = 6 >6</div>
			<div class = "num_bth " data-index-id = 5 >5</div>
			<div class = "num_bth " data-index-id = 4 >4</div>
			<div class = "num_bth " data-index-id = '-'>-</div>     
		</section>
		<section class = 'row'>
			<div class = "num_bth " data-index-id = 3 >3</div>
			<div class = "num_bth " data-index-id = 2 >2</div>
			<div class = "num_bth " data-index-id = 1 >1</div>
			<div class = "num_bth " data-index-id = '+'>+</div>
		</section>
		<section class = 'row'>
			<div class = "num_bth" data-index-id = 00 >00</div>
			<div class = "num_bth" data-index-id = 0 >0</div>
			<div class = "num_bth" data-index-id = . >.</div>
			<div id = 'equal_btn'>=</div>       
		</section>        
	</div>
</div>
<!--  カテゴリ・アイテム入力-->
<div class = "container2">
	<span>※一つだけ選択</span>
	<ul class = "category">
		<li class="ctglist" id="syokuhi" data-index-id="syokuhi">食費</li>
		<li class="ctglist" id="lifegood" data-index-id="lifegood">生活用品費</li>
		<li class="ctglist" id="zappi" data-index-id="zappi">雑費</li>
	</ul>
</div>
<!--  送信-->
<div class = "container3">
	<div id="push" class="push">送信</div>
</div>
<script src="./scripts/script.js"></script>
# cat public/css/style.css
.container {
  width: 200px;
  margin: 50px auto;
  border: 2px solid black;
}

.output {
  width: 200px;
  height: 50px;
  line-height: 50px;
  text-align: right;
  padding: 5px;
  margin-bottom: 5px;
  border:2px solid black;
  box-sizing: border-box;
}

.input {
  width:200px;
  border: 2px solid black; 
}

.row {
  display: flex;
  justify-content: space-between;
  border: 2px solid red;
  width: 200px;
}
/*rowの範囲がわかりやすいように赤の枠を作っています。*/

.num_bth, #clear, #equal_btn{
  width: 60px;
  height: 30px;
  text-align: center;
  line-height: 30px;
  border-radius: 30%;
  background: lightgray;
  cursor: pointer;
  margin: 5px;
}

.container2 {
  margin: 50px auto;
  text-align: center;
}

.category {
  list-style-type: none;
  padding-left: 0;

  width: 300px;
  margin: auto;
  overflow: hidden;
}

.ctglist {
  background: #CCC;
  width: 50px;
  height: 50px;
  padding: 10px;
  margin: 10px;
  text-align: center;

  border-style: solid;
  border-color: transparent;
  float: left;
}

.toplist {
  background: #CCFFCC;
  width: 50px;
  padding: 10px;
  margin: 10px;
  text-align: center;

  border-style: solid;
  border-color: transparent;
  float: left;
}

.container3 {
  text-align: center;
  margin-bottom: 50px;
}

.push {
  background: #CCFFCC;
  width: 100px;
  margin: 0 auto;
}
# cat public/scripts/script.js
num_bth = document.querySelectorAll('.num_bth');
// 計算過程を表示する場所
let output_sub = document.getElementById('output_sub');
// 計算結果を表示する場所
const output_total = document.getElementById('output_total');
const bs = document.getElementById('bs')
let total = 0;
let category = "";

// 数字・記号押下
num_bth.forEach(index => {
	index.addEventListener('click', () => {
		if (index.dataset.indexId !== 'bs') {
			if(total === 0) {
				total = index.dataset.indexId;
			}else{
				total += index.dataset.indexId;
			}
			output_sub.textContent = total;
		}
	})
})

// イコール押下
const equal_btn = document.getElementById('equal_btn');
equal_btn.addEventListener('click',() =>{
	output_total.textContent = eval(total);
});
//BSボタン(バックスペース)を押した時の処理
bs.addEventListener('click', () => {
	// 一文字目から、最後から二文字目までをtotalに代入(最後の一文字を除きtotalに代入する)
	total = output_sub.textContent.slice(0, -1);
	output_sub.textContent = total;
})
// Cボタン(リセットボタン)を押した時の処理
const clear = document.getElementById('clear')
clear.addEventListener('click', () => {
	reset();
})
// リセットを行う関数
function reset() {
	total = 0; 
	output_sub.textContent = 0;
	output_total.textContent = 0;
}

// カテゴリクリック時
ctg_btn = document.querySelectorAll('.ctglist');
ctg_btn.forEach(index => {
	index.addEventListener('click', () => {
		el = document.getElementById(index.dataset.indexId);
		el.style.borderColor = "red";
		category = index.dataset.indexId;
	})
})

// 送信クリック時
var url = "http://【ホスト名】:3000/post";
// var url = "/post";
push_btn = document.getElementById('push');
push_btn.addEventListener('click', () => {
	// 1桁の数字を0埋めで2桁にする
	var toDoubleDigits = function(num) {
		num += "";
		if (num.length === 1) {
			num = "0" + num;
		}
		return num;     
	};
	// 時間取得
	var now = new Date();
	var Year = now.getFullYear();
	var Month = toDoubleDigits(now.getMonth()+1);
	var Hi = toDoubleDigits(now.getDate());
	var Hour = toDoubleDigits(now.getHours());
	var Min = toDoubleDigits(now.getMinutes());
	var time = Year + "-" + Month + "-" + Hi + " " + Hour + ":" + Min;
	console.log(time);

	nedan = eval(total);
	$.post(url,
		{
			'nedan': nedan,
			'category': category,
			'time': time
		}
	);
	alert('送信しました');
})
# cat views/index2.ejs
<!DOCTYPE html>
<html>
	<head>
		<meta charset="utf-8">
		<title>参照</title>
		<link rel="stylesheet" href="/css/style.css">
		<script src="/jquery/jquery.js"></script>
	</head>
	<body>
		<div class = "container2">
		<ul class = "category">
			<a href="/" text-decoration="none"><li class="toplist">入力</li></a>
			<a href="/ref" text-decoration="none"><li class="toplist">参照</li></a>
			<li class="toplist">??</li>
		</ul>
		</div>
		<%- include("month.ejs", data) %>
	</body>
</html>
# cat views/month.ejs
<div align="center">
<% for (let item of data) { %>
	<a href="ref/<%- item.month %>" text-decoration-style="none"><%- item.month %></a>
	<br>
<% } %>
</div>
# cat views/index3.ejs
<!DOCTYPE html>
<html>
	<head>
		<meta charset="utf-8">
		<title>月別</title>
		<link rel="stylesheet" href="/css/style.css">
		<script src="/jquery/jquery.js"></script>
	</head>
	<body>
		<div class = "container2">
		<ul class = "category">
			<a href="/" text-decoration="none"><li class="toplist">入力</li></a>
			<a href="/ref" text-decoration="none"><li class="toplist">参照</li></a>
			<li class="toplist">??</li>
		</ul>
		</div>
		<%- include("circle.ejs", {skhtotal:skhtotal, lfgtotal:lfgtotal, zptotal:zptotal, month:month}); %>
	</body>
</html>
# cat views/circle.ejs
<span style="display:none"><%= total = skhtotal + lfgtotal + zptotal %></span>
<div align="center">
	<table border="1">
		<tr><th>カテゴリ</th><th>金額</th><th>割合</th></tr>
		<tr><td>食費</td><td><%= skhtotal.toLocaleString() %>円</td><td><%= Math.floor((skhtotal / total) * 100) %>%</td></tr>
		<tr><td>生活費</td><td><%= lfgtotal.toLocaleString() %>円</td><td><%= Math.floor((lfgtotal / total) * 100) %>%</td></tr>
		<tr><td>雑費</td><td><%= zptotal.toLocaleString() %>円</td><td><%= Math.floor((zptotal / total) * 100) %>%</td></tr>
		<tr><td>合計</td><td><%= total.toLocaleString() %>円</td><td style="text-align:center">/</td></tr>
	</table>
</div>
<div align="center" style="margin: 50px auto">
<% for (i=1; i<=31; i++) { %>
	<% if (i < 10) { %>
		<a href="<%= month %>-0<%= i %>"><%= month %>-0<%= i %></a>
	<% } else { %>
		<a href="<%= month %>-<%= i %>"><%= month %>-<%= i %></a>
	<% } %>
	<br>
<% } %>
</div>
# cat views/index4.ejs
<!DOCTYPE html>
<html>
	<head>
		<meta charset="utf-8">
		<title>月別</title>
		<link rel="stylesheet" href="/css/style.css">
		<script src="/jquery/jquery.js"></script>
	</head>
	<body>
		<div class = "container2">
		<ul class = "category">
			<a href="/" text-decoration="none"><li class="toplist">入力</li></a>
			<a href="/ref" text-decoration="none"><li class="toplist">参照</li></a>
			<li class="toplist">??</li>
		</ul>
		</div>
		<%- include("date.ejs", data) %>
	</body>
</html>
# cat views/date.ejs
<div align="center">
<table border="1">
<tr><th>ID</th><th>値段</th><th>カテゴリ</th><th>日時</th><th>メモ</th></tr>
<% for (let item of data) { %>
<tr><td><%= item.id %></td><td><%= item.nedan %></td><td><%= item.category %></td><td><%= item.time %></td><td><%= item.memo %></td></tr>
<% } %>
</table>
</div>

表示画面

使用した金額とカテゴリを入力して送信をクリックします。トップページにはその日入力した物が表示されます。

参照ボタンから遷移していくと月別や日別の使用分を確認出来ます。

pm2常駐

pm2で監視して常駐させたいと思います。pm2をインストールします。

# npm install pm2 -g --save
# pm2 start index.js --name kakeibo
# pm2 list
kakeiboがonlineになっていれば成功

終わりに

家計簿Webアプリと言っても、上記のように簡単な機能しか実装していません。今後使っていく中で機能を追加していきたいと思います。まずは編集機能や円グラフ表示、推移予想などですかね……

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

CAPTCHA