はじめに
Spring BootとThymeleafを使って家計簿アプリのポートフォリオを作っている中で、カレンダー形式で支出を表示したいと思い立ちました。
しかし私は、このカレンダーのロジックを理解するのに苦戦しました。
そこで、この記事では、カレンダー画面の構築方法を紹介します。
「日々の支出が一目でわかるカレンダー表示」が欲しい方の参考になれば嬉しいです。
対象読者:
- Spring BootとThymeleafに触れたことがある人
- 日付を扱うロジックに興味がある人
完成イメージ
- 月ごとにカレンダー形式で支出を表示
- 日付と対応する支出の「カテゴリ」「金額」を表示
- 前月・次月に移動可能
エンティティ
今回は、1件の支出データを以下のようなエンティティ(Javaクラス)で表現しています:
package com.ozeken.expensecalendar.entity;
import java.time.LocalDate;
import lombok.Data;
@Data
public class Expense {
// ID
private Long id;
// 日付
private LocalDate date;
// カテゴリ
private String category;
// 金額
private Integer amount;
// 説明
private String description;
}
コントローラー
コントローラーで必要なデータを計算・取得します。
package com.ozeken.expensecalendar.controller;
import java.time.LocalDate;
import java.util.List;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import com.ozeken.expensecalendar.entity.Expense;
import com.ozeken.expensecalendar.service.ExpenseService;
import lombok.RequiredArgsConstructor;
/**
* 家計簿カレンダー表示専用コントローラ
*/
@Controller
@RequestMapping("/expenses/calendar")
@RequiredArgsConstructor
public class CalendarViewController {
private final ExpenseService expenseService;
@GetMapping
public String showCalendar(
@RequestParam(value = "year", required = false) Integer year,
@RequestParam(value = "month", required = false) Integer month,
Model model) {
//nullの場合は,今日の年月を取得
LocalDate today = LocalDate.now();
int currentYear = (year != null) ? year : today.getYear();
int currentMonth = (month != null) ? month : today.getMonthValue();
/** 前月・次月計算用 */
//指定された年月の初日を取得
LocalDate firstDay = LocalDate.of(currentYear, currentMonth, 1);
//指定された年月の前月・次月を取得
LocalDate prevMonth = firstDay.minusMonths(1);
LocalDate nextMonth = firstDay.plusMonths(1);
//指定された年月の家計簿を取得
List<Expense> expenses = expenseService.findByMonth(currentYear, currentMonth);
//月初めの曜日を取得し、日曜始まりへと変換
int firstDayOfWeek = firstDay.getDayOfWeek().getValue();
firstDayOfWeek = (firstDayOfWeek == 7) ? 0 : firstDayOfWeek;
//その月が何日あるか計算
int daysInMonth = firstDay.lengthOfMonth();
model.addAttribute("year", currentYear);
model.addAttribute("month", currentMonth);
model.addAttribute("expenses", expenses);
model.addAttribute("firstDayOfWeek", firstDayOfWeek);
model.addAttribute("daysInMonth", daysInMonth);
// 前月・次月の年月を渡す
model.addAttribute("prevYear", prevMonth.getYear());
model.addAttribute("prevMonth", prevMonth.getMonthValue());
model.addAttribute("nextYear", nextMonth.getYear());
model.addAttribute("nextMonth", nextMonth.getMonthValue());
return "expenses/calendar";
}
}
コントローラーの重要ポイント1
テンプレート側からパラメータを受け取るときnullならば、今日の日付のカレンダーが表示される。nullでないときは、受け取ったパラメータのカレンダーを表示する。
(つまり、最初にカレンダーを表示したときnullなのでその時のためのロジックです。)
@GetMapping
public String showCalendar(
@RequestParam(value = "year", required = false) Integer year,
@RequestParam(value = "month", required = false) Integer month,
Model model) {
//nullの場合は,今日の年月を取得
LocalDate today = LocalDate.now();
int currentYear = (year != null) ? year : today.getYear();
int currentMonth = (month != null) ? month : today.getMonthValue();
コントローラーの重要ポイント2
指定された月の家計簿をListで取得しています。
//指定された年月の家計簿を取得
List<Expense> expenses = expenseService.findByMonth(currentYear, currentMonth);
コントローラーの重要ポイント3
javaで月曜日を1、日曜日を7 とする決まりとなっているため、
日曜始まりのカレンダーにするために日曜の時は0にするロジックです。
//月初めの曜日を取得し,日曜始まりへと変換
int firstDayOfWeek = firstDay.getDayOfWeek().getValue();
firstDayOfWeek = (firstDayOfWeek == 7) ? 0 : firstDayOfWeek;
calendar.html
コントローラーのデータを受け取り、月別(カレンダー)で表示します。
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>カレンダー型家計簿</title>
<link rel="stylesheet" th:href="@{/css/ress.min.css}">
<link rel="stylesheet" th:href="@{/css/calendar.css}">
</head>
<body>
<h1 th:text="${year} + '年' + ${month} + '月の家計簿'"></h1>
<div>
<a th:href="@{/expenses/calendar(year=${prevYear}, month=${prevMonth})}">← 前の月</a>
|
<a th:href="@{/expenses/calendar(year=${nextYear}, month=${nextMonth})}">次の月 →</a>
</div>
<table border="1">
<thead>
<tr>
<th style="color: red;">日</th>
<th>月</th>
<th>火</th>
<th>水</th>
<th>木</th>
<th>金</th>
<th style="color: blue;">土</th>
</tr>
</thead>
<tbody>
<!-- カレンダー -->
<!-- 42個のセルを作成-->
<!-- cellIndex:0から41までの42個のセル-->
<!-- dateNum:表示する日付-->
<tr th:each="week : ${#numbers.sequence(1, 6)}">
<td th:each="dayOfWeek : ${#numbers.sequence(0, 6)}"
th:with="cellIndex=${(week - 1) * 7 + dayOfWeek},
dateNum=${cellIndex - firstDayOfWeek + 1}">
<!-- 空白セル -->
<div th:if="${cellIndex < firstDayOfWeek or dateNum > daysInMonth}">
</div>
<!-- 日付と支出 -->
<div th:if="${cellIndex >= firstDayOfWeek and dateNum <= daysInMonth}">
<span th:text="${dateNum}"></span><br>
<!-- 金額表示 -->
<span th:each="expense : ${expenses}"
th:if="${expense.date.dayOfMonth == (dateNum)}"
th:text="${expense.category} + '¥' + ${expense.amount}"
class="amount">
</span>
</div>
</td>
</tr>
</tbody>
</table>
<br>
<a th:href="@{/expenses}">一覧に戻る</a>
</body>
</html>
calendar.htmlの重要ポイント1
・1か月の日数や月初めの曜日は月ごとに異なるため、42個のセル(6週 × 7日)を用意しています。
・cellIndex:0から41までの42個のセル。
(週-1) * 7 + 曜日
(週-1)をするのはインデックスが0から始まるため。
・dateNum:各セルに表示する日付。
セル-月初め+1
0や負の数も存在する。
+1をするのは日にちが1から始まるため。
<!-- カレンダー -->
<!-- 42個のセルを作成-->
<!-- cellIndex:0から41までの42個のセル-->
<!-- dateNum:表示する日付-->
<tr th:each="week : ${#numbers.sequence(1, 6)}">
<td th:each="dayOfWeek : ${#numbers.sequence(0, 6)}"
th:with="cellIndex=${(week - 1) * 7 + dayOfWeek},
dateNum=${cellIndex - firstDayOfWeek + 1}">
calendar.htmlの重要ポイント2
・空白セル:月始めより前と、月終わりより後に表示しないようにする処理
(例:2025/05の場合→0~3と35~41が空白)
・日付と支出:空白セルの全く逆の処理
<!-- 空白セル -->
<div th:if="${cellIndex < firstDayOfWeek or dateNum > daysInMonth}">
</div>
<!-- 日付と支出 -->
<div th:if="${cellIndex >= firstDayOfWeek and dateNum <= daysInMonth}">
<span th:text="${dateNum}"></span><br>
calendar.htmlの重要ポイント3
expenses の中から、その日(dateNum)に発生した支出だけを表示しています。
※ただしこの方法では、毎日分のセルごとに全件の expenses をループしているため、パフォーマンスの面では効率が悪くなります。この問題を解決するためには、Java側で日付ごとに支出をまとめたMap形式に変換しておく方法がおすすめです。
筆者はうまく実装できていませんが、記事の最後でご紹介します。
<!-- 金額表示 -->
<span th:each="expense : ${expenses}"
th:if="${expense.date.dayOfMonth == (dateNum)}"
th:text="${expense.category} + '¥' + ${expense.amount}"
class="amount">
</span>
表示完了!
また、金額表示のパフォーマンスを上げる方法
筆者は試しましたが、java側とうまくマッピングしなく表示されませんでした。
うまく表示する方法をご存じの方がいたら、コメントいただけると嬉しいです。
1.サービスクラス
月ごとにListで取得し、日にちをキーとしたMapにして、コントローラーでexpensesByDayという名前でモデルに渡す。
@Override
public Map<Integer, List<Expense>> groupByDayOfMonth(int year, int month) {
List<Expense> expenses = findByMonth(year, month);
return expenses.stream()
.collect(Collectors.groupingBy(exp -> exp.getDate().getDayOfMonth()));
}
2.calendar.htmlで必要な日の支出だけループ
ここで、javaの値をうまく受け取ることができずに、表示されませんでした。
(おそらく、dateNumがキーとして正しく評価されないためです。もう少し記述方法を工夫する必要がありそうです。)
<div th:if="${expensesByDay[dateNum] != null}">
<div th:each="expense : ${expensesByDay[dateNum]}">
<span th:text="${expense.category} + '¥' + ${expense.amount}" class="amount"></span><br>
</div>
</div>
まとめ
今回は基本的なカレンダー表示の実装を紹介しました。
今後、パフォーマンスを改善した実装が完成した際には、改めて記事に
まとめたいと思います。
最後まで読んでいただき、ありがとうございました!