【Spring Boot＋Thymeleaf】家計簿アプリにカレンダー表示の実装方法

## はじめに

　Spring BootとThymeleafを使って家計簿アプリのポートフォリオを作っている中で、**カレンダー形式で支出を表示**したいと思い立ちました。
しかし私は、このカレンダーのロジックを理解するのに苦戦しました。

そこで、この記事では、カレンダー画面の構築方法を紹介します。  
「日々の支出が一目でわかるカレンダー表示」が欲しい方の参考になれば嬉しいです。

対象読者：  
- Spring BootとThymeleafに触れたことがある人
- 日付を扱うロジックに興味がある人



## 完成イメージ
- 月ごとにカレンダー形式で支出を表示
- 日付と対応する支出の「カテゴリ」「金額」を表示
- 前月・次月に移動可能（移動の写真は後掲します）
![スクリーンショット 2025-05-03 134201.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4041306/99be2171-954f-4e0e-a12b-8bf0a5ede474.png)



## エンティティ

今回は、1件の支出データを以下のようなエンティティ（Javaクラス）で表現しています：

```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;

}

```


## コントローラー

コントローラーで必要なデータを計算・取得します。

```java
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なのでその時のためのロジックです。）

```java
    @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で取得しています。
```java
//指定された年月の家計簿を取得
List<Expense> expenses = expenseService.findByMonth(currentYear, currentMonth);
```

### 重要ポイント3
javaで月曜日を1、日曜日を7 とする決まりとなっているため、
日曜始まりのカレンダーにするために日曜の時は0にするロジックです。
```java
 //月初めの曜日を取得し,日曜始まりへと変換
 int firstDayOfWeek = firstDay.getDayOfWeek().getValue();
 firstDayOfWeek = (firstDayOfWeek == 7) ? 0 : firstDayOfWeek;
```
---


## calendar.html

コントローラーのデータを受け取り、月別（カレンダー）で表示します。

```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}">
                        &nbsp;
                    </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から始まるため。
 
```html
<!-- カレンダー -->
<!-- 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が空白）
・日付と支出：空白セルの全く逆の処理
```html
<!-- 空白セル -->
<div th:if="${cellIndex < firstDayOfWeek or dateNum > daysInMonth}">
  &nbsp;
</div>

<!-- 日付と支出 -->
<div th:if="${cellIndex >= firstDayOfWeek and dateNum <= daysInMonth}">
  <span th:text="${dateNum}"></span><br>
```

### calendar.htmlの重要ポイント3
expenses の中から、その日（dateNum）に発生した支出だけを表示しています。
※ただしこの方法では、毎日分のセルごとに全件の expenses をループしているため、パフォーマンスの面では効率が悪くなります。この問題を解決するためには、Java側で日付ごとに支出をまとめたMap形式に変換しておく方法がおすすめです。
　筆者はうまく実装できていませんが、記事の最後でご紹介します。
```html
<!-- 金額表示 -->
<span th:each="expense : ${expenses}"
  th:if="${expense.date.dayOfMonth == (dateNum)}"
  th:text="${expense.category} + '￥' + ${expense.amount}"
  class="amount">
</span>
```
---

## 表示完了！
![スクリーンショット 2025-05-03 134201.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4041306/99be2171-954f-4e0e-a12b-8bf0a5ede474.png)
### 前月・次月の移動
問題なく動作します
![スクリーンショット 2025-05-03 134623.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4041306/1c1ab65d-747a-4fba-ac9e-4ebc19fa497c.png)
![スクリーンショット 2025-05-03 134650.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4041306/9ed2dbb5-8b58-4664-8f48-108de77e4f9f.png)

## また、金額表示のパフォーマンスを上げる方法
 　筆者は試しましたが、java側とうまくマッピングしなく表示されませんでした。
 うまく表示する方法をご存じの方がいたら、コメントいただけると嬉しいです。

 
### 1.サービスクラス
月ごとにListで取得し、日にちをキーとしたMapにして、コントローラーでexpensesByDayという名前でモデルに渡す。
```java
@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がキーとして正しく評価されないためです。もう少し記述方法を工夫する必要がありそうです。）
```html
<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>
```
## まとめ
今回は基本的なカレンダー表示の実装を紹介しました。
今後、パフォーマンスを改善した実装が完成した際には、改めて記事に
まとめたいと思います。

最後まで読んでいただき、ありがとうございました！