# frozen_string_literal: true class Review < ApplicationRecord belongs_to :word EASE_MIN = 1.3 EASE_START = 2.5 # status: new | known | learning | skipped scope :due, -> { where(status: "learning").where("due_at <= ?", Time.current).order(:due_at) } scope :upcoming, -> { where(status: "learning").order(:due_at) } def mark_known! update!(status: "known", last_reviewed_at: Time.current, due_at: nil) end def mark_learning! update!( status: "learning", interval: 1, repetitions: 0, ease_factor: EASE_START, due_at: 1.day.from_now, last_reviewed_at: Time.current ) end def mark_skipped! update!(status: "skipped", last_reviewed_at: Time.current) end # SM-2 algorithm: quality 0-5 # 0-2 = forgot/hard 3-5 = remembered def sm2_update!(quality) quality = quality.to_i.clamp(0, 5) new_ease = [EASE_MIN, ease_factor + 0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02)].max.round(3) if quality < 3 update!( status: "learning", repetitions: 0, interval: 1, ease_factor: new_ease, due_at: 1.day.from_now, last_reviewed_at: Time.current ) else new_reps = repetitions + 1 new_interval = case repetitions when 0 then 1 when 1 then 6 else [ (interval * new_ease).ceil, 1 ].max end update!( status: "learning", repetitions: new_reps, interval: new_interval, ease_factor: new_ease, due_at: new_interval.days.from_now, last_reviewed_at: Time.current ) end end def due_label return "Now" unless due_at return "Now" if due_at <= Time.current days = ((due_at - Time.current) / 1.day).ceil case days when 0 then "Today" when 1 then "Tomorrow" else "#{days} days" end end end