Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
13 commits
Select commit Hold shift + click to select a range
ed399dd
UserStudyStreakクラスを新規作成し、学習継続期間と最長期間を計算するロジックを実装
jun-kondo Sep 12, 2025
5aa10fe
Userモデルにreports_with_learning_timesメソッドを追加
jun-kondo Sep 12, 2025
9d2e257
UsersControllerに学習継続期間を計算するための処理を追加
jun-kondo Sep 12, 2025
d283a19
学習連続記録を表示するUserStudyStreakTrackerComponentを新規作成
jun-kondo Sep 12, 2025
82bcd57
ユーザー詳細ページで学習連続記録を表示するコンポーネントのレンダリングを追加
jun-kondo Sep 12, 2025
82b5c03
学習連続記録に関連するモデル・コンポーネントの単体テストを実装
jun-kondo Sep 12, 2025
5cc2f75
連続学習記録の用のスタイル追加(たたき台)
jun-kondo Sep 11, 2025
79ce2d6
学習連続記録を日本語にしデザインを調整、ダッシュボードにも表示するようにした。位置も変更した。
machida Oct 1, 2025
3b39936
slim のlintの指摘を対応
machida Oct 9, 2025
13559af
連続学習記録機能のデザイン変更に伴うテスト修正
machida Oct 9, 2025
88c1530
学習連続記録の日付フォーマットを現在の年と過去の年で区別するロジックを追加
jun-kondo Oct 10, 2025
7abe0f4
`UserStudyStreak`を`StudyStreak`にリネームし、関連する全ファイルを更新
jun-kondo Nov 14, 2025
03aa758
`find_current_period`メソッドを削除し、`streak_periods.last`を直接使用するよう変更
jun-kondo Nov 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
.a-card.streak-card
.card-header.is-sm
h2.card-header__title
| 学習連続記録
hr.a-border-tint
.card-body
.streak-container
.streak-item
.streak-item__content
.streak-item__label
| 現在の連続記録
.streak-item__days
.streak-item__number
= current_streak_days
.streak-item__unit
| 日
- if current_streak?
.streak-item__period
= current_streak_period
.streak-item
.streak-item__content
.streak-item__label
| 連続最高記録
.streak-item__days
.streak-item__number
= longest_streak_days
.streak-item__unit
| 日
- if longest_streak?
.streak-item__period
= longest_streak_period
52 changes: 52 additions & 0 deletions app/components/study_streak/study_streak_tracker_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# frozen_string_literal: true

class StudyStreak::StudyStreakTrackerComponent < ViewComponent::Base
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

componentとして作っているのがいいですね!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ありがとうございます。View Componentは使ったことがなかったので触ってみたいと思っていました。
プレゼンテーションロジックをコンポーネントのクラスにまとめられたことや単体テストが出来てテストが容易であることが利点だなと感じました👀

def initialize(study_streak:)
@study_streak = study_streak
end

def current_streak?
@study_streak.current_days.to_i.positive?
end

def longest_streak?
@study_streak.longest_days.to_i.positive?
end

def current_streak_days
@study_streak.current_days.to_i
end

def longest_streak_days
@study_streak.longest_days.to_i
end

def current_streak_period
format_period(
days: @study_streak.current_days,
start_on: @study_streak.current_start_on,
end_on: @study_streak.current_end_on
)
end

def longest_streak_period
format_period(
days: @study_streak.longest_days,
start_on: @study_streak.longest_start_on,
end_on: @study_streak.longest_end_on
)
end

private

def format_period(days:, start_on:, end_on:)
return '' if days.to_i.zero? || start_on.blank? || end_on.blank?

format = if start_on.year == end_on.year && start_on.year == Time.zone.today.year
:sm
else
:short
end
"#{I18n.l(start_on, format:)} 〜 #{I18n.l(end_on, format:)}"
end
end
1 change: 1 addition & 0 deletions app/controllers/home_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ def display_dashboard
@target_end_date = GrassDateParameter.new(params[:end_date]).target_end_date
@times = Grass.times(current_user, @target_end_date)
@users_for_time_slot = User.currently_learning_except(current_user)
@study_streak = StudyStreak.new(current_user.reports_with_learning_times, include_wip: false)
end

def display_events_on_dashboard
Expand Down
3 changes: 3 additions & 0 deletions app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ def show
@target_end_date = GrassDateParameter.new(params[:end_date]).target_end_date
@times = Grass.times(@user, @target_end_date)

reports = @user.reports_with_learning_times
@study_streak = StudyStreak.new(reports, include_wip: false)

if logged_in?
render :show
else
Expand Down
1 change: 1 addition & 0 deletions app/javascript/stylesheets/application.sass
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
@use "application/blocks/user/user-profile"
@use "application/blocks/user/user-secret-attributes"
@use "application/blocks/user/users-item"
@use "application/blocks/user/user-study-streak-tracker"

@use "application/blocks/coding-test/code-editor"
@use "application/blocks/coding-test/io-sample"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
@use "../../../config/mixins/break-points"
@use "../../../config/mixins/text"

.streak-card
margin-bottom: 1.5rem

.streak-container
display: flex
justify-content: center
+break-points.media-breakpoint-up(md)
gap: 1rem
+break-points.media-breakpoint-down(sm)
gap: .5rem

.streak-item
flex: 1
max-width: 10rem
display: flex
justify-content: center
align-items: center
+break-points.media-breakpoint-up(md)
padding-block: 1rem
+break-points.media-breakpoint-down(sm)
padding-block: .75rem

.streak-item__content
display: flex
flex-direction: column
align-items: center

.streak-item__days
display: flex
align-items: baseline

.streak-item__number
font-weight: 700
color: #f7941d
+break-points.media-breakpoint-up(md)
+text.text-block(2.5rem 1)
+break-points.media-breakpoint-down(sm)
+text.text-block(2rem 1)

.streak-item__label
font-weight: 600
text-align: center
+break-points.media-breakpoint-up(md)
+text.text-block(1rem 1.4)
+break-points.media-breakpoint-down(sm)
+text.text-block(.875rem 1.4)

.streak-item__unit
text-align: center
color: var(--muted-text)
+break-points.media-breakpoint-up(md)
+text.text-block(1rem 1.4)
+break-points.media-breakpoint-down(sm)
+text.text-block(.75rem 1.4)

.streak-item__period
margin-top: .125rem
text-align: center
+break-points.media-breakpoint-up(md)
+text.text-block(.875rem 1.4)
+break-points.media-breakpoint-down(sm)
+text.text-block(.75rem 1.4)
44 changes: 44 additions & 0 deletions app/models/study_streak.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# frozen_string_literal: true

class StudyStreak
attr_reader :current_start_on, :current_end_on, :current_days, :longest_start_on, :longest_end_on, :longest_days

def initialize(reports, include_wip: false)
@include_wip = include_wip
@study_dates = report_dates(reports)

current_period = streak_periods.last
@current_start_on = current_period&.[](:start_on)
@current_end_on = current_period&.[](:end_on)
@current_days = current_period&.[](:days)

longest_period = find_longest_period
@longest_start_on = longest_period&.[](:start_on)
@longest_end_on = longest_period&.[](:end_on)
@longest_days = longest_period&.[](:days)
end

private

attr_reader :study_dates, :include_wip

def report_dates(reports)
reports = reports.not_wip unless include_wip
reports.order(reported_on: :asc).pluck(:reported_on)
end

def streak_periods
@streak_periods ||= begin
return [] if study_dates.empty?

study_dates.chunk_while { |a, b| b == a.next_day }.map do |chunk|
{ start_on: chunk.first, end_on: chunk.last, days: chunk.size }
end
end
end

def find_longest_period
# days が最大、同点ならより新しい end_on を優先
streak_periods.max_by { |p| [p[:days], p[:end_on]] }
end
end
4 changes: 4 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -953,6 +953,10 @@ def self.ransackable_associations(_auth_object = nil)
%w[company course discord_profile]
end

def reports_with_learning_times
reports.joins(:learning_times).distinct.order(reported_on: :asc)
end

private

def password_required?
Expand Down
2 changes: 2 additions & 0 deletions app/views/home/index.html.slim
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
= render 'required_field', user: current_user
- if current_user.after_graduation_hope?
= render 'after_graduation_hope', user: current_user
- if current_user.total_learning_time.positive?
= render(StudyStreak::StudyStreakTrackerComponent.new(study_streak: @study_streak))
- if current_user.student_or_trainee? && cookies[:user_grass] != current_user.id.to_s
= render(Grass::GrassComponent.new(user: current_user, times: @times, target_end_date: @target_end_date, path: :root_path))
- if current_user.github_account.present?
Expand Down
42 changes: 22 additions & 20 deletions app/views/users/show.html.slim
Original file line number Diff line number Diff line change
Expand Up @@ -64,26 +64,8 @@
= render 'users/metas', user: @user, user_course_practice: UserCoursePractice.new(@user)

.col-xs-12(class="#{visible_learning_time_frames?(@user) ? 'col-lg-4 col-xxl-4' : 'col-lg-6 col-xxl-6'}")
- if admin_login?
.a-card.is-only-mentor
.card-header.is-sm
h2.card-header__title
| 卒業後の進路
| (#{t("activerecord.enums.user.career_path.#{@user.career_path}")})
hr.a-border-tint
.card-body
.card-body__description
- if @user.career_memo.present?
.a-long-text.is-sm
= simple_format(@user.career_memo)
- else
.o-empty-message
.o-empty-message__icon
i.fa-regular.fa-sad-tear
.o-empty-message__text
| 進路メモはまだありません。
- if admin_or_mentor_login?
= render 'users/user_mentor_memo', user_id: @user.id
- if @user.student_or_trainee? && @user.total_learning_time.positive?
= render(StudyStreak::StudyStreakTrackerComponent.new(study_streak: @study_streak))
- unless @user.total_learning_time.zero? || @user.mentor?
= render(Grass::GrassComponent.new(user: @user, times: @times, target_end_date: @target_end_date, path: :user_path))
- if @user.student_or_trainee?
Expand Down Expand Up @@ -116,6 +98,26 @@
.user-statuses__delete
= link_to 'このユーザーを削除する', admin_user_path(@user), method: :delete, id: "delete-#{@user.id}", class: 'a-muted-text-link',
data: { confirm: '本当によろしいですか?この操作はデータを削除するため元に戻すことができません。' }
- if admin_login?
.a-card.is-only-mentor
.card-header.is-sm
h2.card-header__title
| 卒業後の進路
| (#{t("activerecord.enums.user.career_path.#{@user.career_path}")})
hr.a-border-tint
.card-body
.card-body__description
- if @user.career_memo.present?
.a-long-text.is-sm
= simple_format(@user.career_memo)
- else
.o-empty-message
.o-empty-message__icon
i.fa-regular.fa-sad-tear
.o-empty-message__text
| 進路メモはまだありません。
- if admin_or_mentor_login?
= render 'users/user_mentor_memo', user_id: @user.id

- if visible_learning_time_frames?(@user)
.col-xs-12.col-lg-4.col-xxl-3
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# frozen_string_literal: true

require 'test_helper'
require 'supports/report_helper'

class StudyStreak::StudyStreakTrackerComponentTest < ViewComponent::TestCase
include ReportHelper

setup do
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

他の場所にあわせるならば上に1行空行をあけるといいかも

Copy link
Copy Markdown
Contributor Author

@jun-kondo jun-kondo Nov 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

こちらリベースで修正致しました。
以下のファイルでincludeの行とsetupの行の間に空行を一行入れましたー

test/components/study_streak/study_streak_tracker_component_test.rb
test/models/study_streak_test.rb

travel_to Time.zone.local(2024, 9, 1)

@user = users(:kimura)

LearningTime.joins(:report).where(reports: { user_id: @user.id }).delete_all
Report.where(user: @user, reported_on: Date.new(2024, 8, 1)..Time.zone.today).delete_all

submitted_dates = %w[
2024-08-07 2024-08-08 2024-08-09
2024-08-20 2024-08-21 2024-08-22
]
submitted_dates.each { |d| create_report_data_with_learning_times(user: @user, on: d) }
reports = @user.reports_with_learning_times
@study_streak = StudyStreak.new(reports)
end

teardown do
travel_back
end
Comment on lines +26 to +28
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

teardownは最後の処理なので、setupの次に書くか、ファイルの最後に書くのがわかりやすいとおもいます。(他のテストもほとんどそうなっているとおもいます)

Copy link
Copy Markdown
Contributor Author

@jun-kondo jun-kondo Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

すみません。setupのブロックが長くなっていて分かり辛いのですが、このテストファイルではsetupの次にteardownを書いています。
他のファイル(例えばtest/system/announcements/notification_test.rb)でも同様にsetupの次に書かれていて、そのような書き方に揃えたつもりなのですが...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jun-kondo すみません、読み間違っていました。


test 'renders current streak information' do
render_inline(StudyStreak::StudyStreakTrackerComponent.new(study_streak: @study_streak))

# 現在の連続学習日数と期間を表示する
assert_selector '.streak-container'
assert_text '日'
assert_selector '.streak-item__number', text: '3', count: 2
assert_selector '.streak-item__period', text: '08/20 〜 08/22'
end

test 'renders longest streak information (ties resolved by most recent)' do
render_inline(StudyStreak::StudyStreakTrackerComponent.new(study_streak: @study_streak))

# 最長連続日数も3日で、同率の場合は最新の期間を選ぶ
assert_selector '.streak-item__number', text: '3'
assert_selector '.streak-item__period', text: '08/20 〜 08/22'
end

test 'renders zero streak when no learning days' do
user_without_learning = users(:hatsuno) # learning_times を含むレポートがないユーザー
reports = user_without_learning.reports_with_learning_times
study_streak = StudyStreak.new(reports, include_wip: false)
render_inline(StudyStreak::StudyStreakTrackerComponent.new(study_streak:))

# 0日を表示し、期間テキストは表示しない
assert_selector '.streak-item__number', text: '0', count: 2

# 期間要素は連続日数が1以上のときのみ条件付きでレンダリングされる
refute_selector '.streak-item__period'
end

test 'structure has required CSS classes' do
render_inline(StudyStreak::StudyStreakTrackerComponent.new(study_streak: @study_streak))

assert_selector '.streak-container'
assert_selector '.streak-item', count: 2
assert_selector '.streak-item__content', count: 2
assert_selector '.streak-item__number', count: 2
assert_selector '.streak-item__unit', text: '日', count: 2
assert_selector '.streak-item__label', count: 2
end

test 'date format follows "mm/dd 〜 mm/dd" pattern for the current year' do
render_inline(StudyStreak::StudyStreakTrackerComponent.new(study_streak: @study_streak))

assert_selector '.streak-item__period', text: '08/20 〜 08/22'
end

test 'date format follows "yyyy/mm/dd 〜 yyyy/mm/dd" pattern for a past year' do
travel_to Time.zone.local(2025, 1, 1)
render_inline(StudyStreak::StudyStreakTrackerComponent.new(study_streak: @study_streak))
assert_selector '.streak-item__period', text: '2024/08/20 〜 2024/08/22'
end

test 'date format follows "yyyy/mm/dd 〜 yyyy/mm/dd" pattern across different years' do
user = users(:machida)
LearningTime.joins(:report).where(reports: { user_id: user.id }).delete_all
Report.where(user:).delete_all
submitted_dates = %w[2023-12-30 2023-12-31 2024-01-01 2024-01-02]
submitted_dates.each { |date| create_report_data_with_learning_times(user:, on: date) }
reports = user.reports_with_learning_times
study_streak = StudyStreak.new(reports)

render_inline(StudyStreak::StudyStreakTrackerComponent.new(study_streak:))
assert_selector '.streak-item__period', text: '2023/12/30 〜 2024/01/02'
end
end
Loading