0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Spring Boot+Thymeleaf】家計簿アプリにカレンダー表示を実装するのが、思ったより難しかった話

Last updated at Posted at 2025-05-01

はじめに

 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}">
                        &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から始まるため。

<!-- カレンダー -->
<!-- 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}">
  &nbsp;
</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>

表示完了!

スクリーンショット 2025-05-01 150829.png

また、金額表示のパフォーマンスを上げる方法

 筆者は試しましたが、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>

まとめ

今回は基本的なカレンダー表示の実装を紹介しました。
今後、パフォーマンスを改善した実装が完成した際には、改めて記事に
まとめたいと思います。

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?