From e9f5d8ece259a8391ee7021fdbc44ce879f8f827 Mon Sep 17 00:00:00 2001 From: Fibe Agent Date: Wed, 22 Apr 2026 15:02:45 +0000 Subject: [PATCH] Lexivo: vocabulary learning app with SM-2 spaced repetition - Word import (multi-format: word, word-def, word:def, word|def) - Flashcard filter UI (swipe + keyboard arrows + Space to flip) - SM-2 spaced repetition review queue - Stimulus flashcard controller with 3D flip animation --- app/assets/stylesheets/application.css | 310 ++++++++++++++++++ app/controllers/filter_controller.rb | 24 ++ app/controllers/reviews_controller.rb | 23 ++ app/controllers/words_controller.rb | 55 ++++ .../controllers/flashcard_controller.js | 125 +++++++ app/models/review.rb | 78 +++++ app/models/word.rb | 21 ++ app/views/filter/show.html.erb | 75 +++++ app/views/layouts/application.html.erb | 14 +- app/views/reviews/show.html.erb | 77 +++++ app/views/words/index.html.erb | 85 +++++ config/routes.rb | 22 +- db/migrate/20260422000001_create_words.rb | 11 + db/migrate/20260422000002_create_reviews.rb | 16 + 14 files changed, 924 insertions(+), 12 deletions(-) create mode 100644 app/controllers/filter_controller.rb create mode 100644 app/controllers/reviews_controller.rb create mode 100644 app/controllers/words_controller.rb create mode 100644 app/javascript/controllers/flashcard_controller.js create mode 100644 app/models/review.rb create mode 100644 app/models/word.rb create mode 100644 app/views/filter/show.html.erb create mode 100644 app/views/reviews/show.html.erb create mode 100644 app/views/words/index.html.erb create mode 100644 db/migrate/20260422000001_create_words.rb create mode 100644 db/migrate/20260422000002_create_reviews.rb diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 7e37425..f5b53cd 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -121,3 +121,313 @@ h1 { color: #b91c1c; font-size: 0.9rem; } + +/* ── Lexivo ── */ + +body { background: #f0f0f7; } + +.topnav { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 24px; + height: 52px; + background: #1a1a2e; + color: #fff; +} + +.nav-brand { + font-weight: 700; + font-size: 1.1rem; + color: #a5b4fc; + text-decoration: none; + letter-spacing: 0.05em; +} + +.nav-links { display: flex; gap: 4px; } + +.nav-link { + color: #cbd5e1; + text-decoration: none; + padding: 6px 12px; + border-radius: 6px; + font-size: 0.9rem; + transition: background 0.15s, color 0.15s; +} +.nav-link:hover { background: #2d2d4e; color: #fff; } + +main .container { max-width: 700px; margin: 32px auto; padding: 0 20px; } + +/* Stats */ +.stats-grid { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 24px; +} + +.stat-card { + flex: 1 1 90px; + background: #fff; + border-radius: 10px; + padding: 16px 12px; + text-align: center; + border-top: 3px solid transparent; + box-shadow: 0 1px 4px rgba(0,0,0,.06); +} + +.stat-number { font-size: 2rem; font-weight: 700; line-height: 1; } +.stat-label { font-size: 0.75rem; color: #6b7280; margin-top: 4px; } + +.stat-total { border-color: #6366f1; } +.stat-unseen { border-color: #94a3b8; } +.stat-learning { border-color: #f59e0b; } +.stat-known { border-color: #22c55e; } +.stat-due { border-color: #ef4444; } + +.stat-total .stat-number { color: #6366f1; } +.stat-due .stat-number { color: #ef4444; } +.stat-learning .stat-number { color: #f59e0b; } +.stat-known .stat-number { color: #22c55e; } + +.action-row { + display: flex; + gap: 10px; + margin-bottom: 28px; + flex-wrap: wrap; +} + +.btn-success { background: #22c55e; color: #fff; } +.btn-outline { + background: transparent; + color: #6366f1; + border: 1.5px solid #6366f1; +} + +/* Import */ +.section { margin-bottom: 36px; } +.section h2 { font-size: 1.1rem; font-weight: 600; margin-bottom: 8px; color: #111; } +.hint { font-size: 0.82rem; color: #6b7280; margin-bottom: 10px; } +.hint code { background: #e5e7eb; padding: 1px 4px; border-radius: 3px; font-family: monospace; } + +.import-textarea { + width: 100%; + padding: 10px 12px; + border: 1px solid #d1d5db; + border-radius: 8px; + font-family: monospace; + font-size: 0.9rem; + resize: vertical; + margin-bottom: 8px; + outline: none; + transition: border-color 0.15s; +} +.import-textarea:focus { border-color: #6366f1; } + +/* Word table */ +.word-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; } +.word-row { border-bottom: 1px solid #e5e7eb; } +.word-row:hover { background: #f9fafb; } +.word-text { padding: 8px 6px; font-weight: 600; width: 28%; } +.word-def { padding: 8px 6px; color: #4b5563; width: 50%; } +.word-status { padding: 8px 6px; width: 14%; } +.word-actions { padding: 8px 4px; text-align: right; width: 8%; } + +.badge { + display: inline-block; + padding: 2px 7px; + border-radius: 99px; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; +} +.badge-new { background: #e5e7eb; color: #374151; } +.badge-known { background: #dcfce7; color: #166534; } +.badge-learning { background: #fef3c7; color: #92400e; } +.badge-skipped { background: #f1f5f9; color: #64748b; } + +.due-info { font-size: 0.72rem; color: #ef4444; margin-left: 4px; } + +.btn-del { + background: none; + border: none; + color: #9ca3af; + cursor: pointer; + font-size: 0.9rem; + padding: 2px 6px; +} +.btn-del:hover { color: #ef4444; } + +/* Flash */ +.flash { + padding: 10px 14px; + border-radius: 8px; + margin-bottom: 16px; + font-size: 0.9rem; +} +.flash-notice { background: #dcfce7; color: #166534; } +.flash-alert { background: #fef3c7; color: #92400e; } + +/* ── Flashcard ── */ + +.filter-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + font-size: 0.85rem; + color: #6b7280; +} +.link-muted { color: #9ca3af; text-decoration: none; } +.link-muted:hover { color: #6b7280; } + +.flashcard-wrap { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + padding: 12px 0 32px; +} + +/* 3D card container */ +.flashcard { + width: 100%; + max-width: 480px; + height: 240px; + perspective: 1000px; + cursor: pointer; + position: relative; + transform-style: preserve-3d; + transition: transform 0.38s ease, opacity 0.22s ease, translate 0.22s ease; +} + +.flashcard.is-flipped { transform: rotateY(180deg); } + +.flashcard.slide-right { + translate: 120% 0; + opacity: 0; +} +.flashcard.slide-left { + translate: -120% 0; + opacity: 0; +} +.flashcard.slide-up { + translate: 0 -80px; + opacity: 0; +} + +.card-face { + position: absolute; + inset: 0; + backface-visibility: hidden; + -webkit-backface-visibility: hidden; + border-radius: 14px; + background: #fff; + box-shadow: 0 4px 24px rgba(0,0,0,.1); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 28px 24px; + text-align: center; + user-select: none; +} + +.card-back { transform: rotateY(180deg); } + +.card-word { + font-size: 2rem; + font-weight: 700; + color: #1a1a2e; + margin-bottom: 10px; +} + +.card-definition { + font-size: 1.1rem; + color: #374151; + line-height: 1.5; +} +.card-definition.muted { color: #9ca3af; font-style: italic; } + +.card-example { + margin-top: 10px; + font-size: 0.9rem; + color: #6b7280; +} + +.card-interval { + font-size: 0.75rem; + color: #9ca3af; + margin-bottom: 8px; +} + +.flip-hint { + position: absolute; + bottom: 12px; + font-size: 0.72rem; + color: #d1d5db; + pointer-events: none; +} + +/* Action buttons */ +.card-actions { + display: flex; + gap: 10px; + justify-content: center; + flex-wrap: wrap; +} + +.inline-form { display: inline; } + +.btn-learning { background: #6366f1; color: #fff; min-width: 110px; justify-content: center; } +.btn-known { background: #22c55e; color: #fff; min-width: 110px; justify-content: center; } +.btn-skip { background: #e5e7eb; color: #374151; min-width: 90px; justify-content: center; } + +/* Keyboard hint row */ +.key-hint { + font-size: 0.8rem; + color: #9ca3af; +} +kbd { + display: inline-block; + padding: 1px 6px; + border: 1px solid #d1d5db; + border-radius: 4px; + background: #f9fafb; + font-size: 0.75rem; + font-family: monospace; + color: #374151; +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 60px 20px; +} +.empty-icon { + font-family: monospace; + font-size: 2rem; + color: #d1d5db; + margin-bottom: 16px; +} +.empty-state h2 { margin-bottom: 10px; } +.empty-state p { color: #6b7280; margin-bottom: 24px; } + +/* Upcoming list */ +.upcoming-list { + max-width: 320px; + margin: 0 auto 24px; + text-align: left; +} +.upcoming-item { + display: flex; + justify-content: space-between; + padding: 6px 0; + border-bottom: 1px solid #e5e7eb; + font-size: 0.9rem; +} +.upcoming-word { font-weight: 600; } +.upcoming-when { color: #9ca3af; } + +.progress-text { font-size: 0.85rem; color: #6b7280; } + diff --git a/app/controllers/filter_controller.rb b/app/controllers/filter_controller.rb new file mode 100644 index 0000000..624ed4e --- /dev/null +++ b/app/controllers/filter_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class FilterController < ApplicationController + def show + @word = Word.unseen.order(:id).first + @remaining = Word.unseen.count + @total = Word.count + end + + def known + Word.find(params[:id]).review.mark_known! + redirect_to filter_path + end + + def learning + Word.find(params[:id]).review.mark_learning! + redirect_to filter_path + end + + def skip + Word.find(params[:id]).review.mark_skipped! + redirect_to filter_path + end +end diff --git a/app/controllers/reviews_controller.rb b/app/controllers/reviews_controller.rb new file mode 100644 index 0000000..e153b2f --- /dev/null +++ b/app/controllers/reviews_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class ReviewsController < ApplicationController + def show + @review = Review.due.includes(:word).first + @due_count = Review.due.count + @upcoming = Review.upcoming.includes(:word).limit(5) + @upcoming_count = Review.upcoming.count + end + + def rate + review = Review.find(params[:id]) + quality = case params[:rating] + when "easy" then 5 + when "good" then 4 + when "hard" then 3 + when "forgot" then 1 + else params[:quality].to_i + end + review.sm2_update!(quality) + redirect_to review_path + end +end diff --git a/app/controllers/words_controller.rb b/app/controllers/words_controller.rb new file mode 100644 index 0000000..bb6959a --- /dev/null +++ b/app/controllers/words_controller.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +class WordsController < ApplicationController + def index + @stats = { + total: Word.count, + unseen: Review.where(status: "new").count, + known: Review.where(status: "known").count, + learning: Review.where(status: "learning").count, + skipped: Review.where(status: "skipped").count, + due: Review.due.count + } + @words = Word.includes(:review).by_text + end + + def create + raw = params[:word_list].to_s.strip + lines = raw.split("\n").map(&:strip).reject { |l| l.blank? || l.start_with?("#") } + + imported = 0 + skipped = 0 + + lines.each do |line| + text, definition = parse_line(line) + next if text.blank? + + w = Word.new(text: text.strip, definition: definition&.strip.presence) + if w.save + imported += 1 + else + skipped += 1 + end + end + + flash[:notice] = "Imported #{imported} word#{"s" unless imported == 1}." + flash[:alert] = "Skipped #{skipped} (duplicates or invalid)." if skipped > 0 + redirect_to words_path + end + + def destroy + Word.find(params[:id]).destroy + redirect_to words_path, notice: "Removed." + end + + private + + def parse_line(line) + # "word - definition", "word: definition", "word | definition", "word\tdefinition" + if (m = line.match(/\A(.+?)\s*(?:\t|-{1,2}|:{1,2}|\|)\s*(.+)\z/)) + [ m[1], m[2] ] + else + [ line, nil ] + end + end +end diff --git a/app/javascript/controllers/flashcard_controller.js b/app/javascript/controllers/flashcard_controller.js new file mode 100644 index 0000000..7ef1945 --- /dev/null +++ b/app/javascript/controllers/flashcard_controller.js @@ -0,0 +1,125 @@ +import { Controller } from "@hotwired/stimulus" + +// Handles flashcard flip + keyboard + swipe for both filter and review pages. +// Expected DOM: +// data-controller="flashcard" +// data-flashcard-target="card" (the 3D flippable element) +// +// Hidden forms in the same element: +// id="form-right" — known / easy +// id="form-left" — learning / forgot +// id="form-skip" — skip (filter only) +// id="form-q3" — hard (review only) + +export default class extends Controller { + static targets = ["card"] + + connect() { + this.flipped = false + this.animating = false + this.touchStartX = 0 + this.touchStartY = 0 + + this._keydown = this.handleKeydown.bind(this) + document.addEventListener("keydown", this._keydown) + } + + disconnect() { + document.removeEventListener("keydown", this._keydown) + } + + // ── Keyboard ────────────────────────────────────────────────────────────── + + handleKeydown(e) { + if (this.animating) return + + switch (e.key) { + case " ": + e.preventDefault() + this.flip() + break + case "ArrowDown": + e.preventDefault() + this.flip() + break + case "ArrowRight": + e.preventDefault() + this.slideAndSubmit("slide-right", "form-right") + break + case "ArrowLeft": + e.preventDefault() + this.slideAndSubmit("slide-left", "form-left") + break + case "s": + case "S": + case "ArrowUp": + e.preventDefault() + this.slideAndSubmit("slide-up", "form-skip") + break + case "1": + e.preventDefault() + this.slideAndSubmit("slide-left", "form-left") + break + case "2": + e.preventDefault() + this.slideAndSubmit("slide-up", "form-q3") + break + case "3": + e.preventDefault() + this.slideAndSubmit("slide-right", "form-right") + break + } + } + + // ── Touch / swipe ───────────────────────────────────────────────────────── + + touchstart(e) { + const t = e.changedTouches[0] + this.touchStartX = t.clientX + this.touchStartY = t.clientY + } + + touchend(e) { + if (this.animating) return + const t = e.changedTouches[0] + const dx = t.clientX - this.touchStartX + const dy = t.clientY - this.touchStartY + + // Small movement = tap, handled by click -> flip + if (Math.abs(dx) < 20 && Math.abs(dy) < 20) return + + e.preventDefault() // prevent the click event that follows + + if (Math.abs(dx) >= Math.abs(dy) && Math.abs(dx) > 40) { + dx > 0 + ? this.slideAndSubmit("slide-right", "form-right") + : this.slideAndSubmit("slide-left", "form-left") + } + } + + // ── Card flip ───────────────────────────────────────────────────────────── + + flip(e) { + if (e) e.stopPropagation() + this.flipped = !this.flipped + this.cardTarget.classList.toggle("is-flipped", this.flipped) + } + + // ── Form submission ─────────────────────────────────────────────────────── + + slideAndSubmit(animClass, formId) { + this.animating = true + this.cardTarget.classList.add(animClass) + + setTimeout(() => { + const form = this.element.querySelector(`#${formId}`) + if (form) { + form.requestSubmit() + } else { + // form not present on this page variant — ignore silently + this.animating = false + this.cardTarget.classList.remove(animClass) + } + }, 220) + } +} diff --git a/app/models/review.rb b/app/models/review.rb new file mode 100644 index 0000000..53e8361 --- /dev/null +++ b/app/models/review.rb @@ -0,0 +1,78 @@ +# 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 diff --git a/app/models/word.rb b/app/models/word.rb new file mode 100644 index 0000000..1799baa --- /dev/null +++ b/app/models/word.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class Word < ApplicationRecord + has_one :review, dependent: :destroy + + validates :text, presence: true, uniqueness: { case_sensitive: false } + validates :text, length: { maximum: 255 } + + after_create :create_initial_review + + scope :unseen, -> { joins(:review).where(reviews: { status: "new" }) } + scope :known, -> { joins(:review).where(reviews: { status: "known" }) } + scope :learning, -> { joins(:review).where(reviews: { status: "learning" }) } + scope :by_text, -> { order(:text) } + + private + + def create_initial_review + create_review!(status: "new") + end +end diff --git a/app/views/filter/show.html.erb b/app/views/filter/show.html.erb new file mode 100644 index 0000000..073fb1e --- /dev/null +++ b/app/views/filter/show.html.erb @@ -0,0 +1,75 @@ +<% content_for :title, "Filter — Lexivo" %> + +
+ <% if @word.nil? %> +
+
--o
+

All words filtered!

+

+ Nothing left to sort. + <% learning_count = Review.where(status: "learning").count %> + <% if learning_count > 0 %> + You have <%= learning_count %> word<%= "s" unless learning_count == 1 %> in the learning queue. + <%= link_to "Go to review", review_path %> + <% end %> +

+ <%= link_to "Back to words", words_path, class: "btn btn-outline" %> +
+ <% else %> +
+ + <%= @remaining %> remaining + <% if @total > 0 %> +  ·  + <%= ((@total - @remaining).to_f / @total * 100).round %>% done + <% end %> + + <%= link_to "word list", words_path, class: "link-muted" %> +
+ +
+
+
+
<%= @word.text %>
+
Space / tap to flip
+
+
+
<%= @word.text %>
+ <% if @word.definition.present? %> +
<%= @word.definition %>
+ <% else %> +
No definition
+ <% end %> + <% if @word.example.present? %> +
<%= @word.example %>
+ <% end %> +
+
+ +
+ <%= form_with url: filter_learning_path(@word), method: :post, id: "form-left", class: "inline-form", + data: { turbo_frame: "_top" } do |f| %> + <%= f.submit "Learning ←", class: "btn btn-learning" %> + <% end %> + + <%= form_with url: filter_skip_path(@word), method: :post, id: "form-skip", class: "inline-form", + data: { turbo_frame: "_top" } do |f| %> + <%= f.submit "Skip S", class: "btn btn-skip" %> + <% end %> + + <%= form_with url: filter_known_path(@word), method: :post, id: "form-right", class: "inline-form", + data: { turbo_frame: "_top" } do |f| %> + <%= f.submit "Known →", class: "btn btn-known" %> + <% end %> +
+ +
+ Learning    + Space Flip    + Known    + S Skip +
+
+ <% end %> +
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 9329df5..c59e3b1 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,7 +1,7 @@ - <%= content_for(:title) || "Starter" %> + <%= content_for(:title) || "Lexivo" %> @@ -42,6 +42,16 @@ - <%= yield %> + +
+ <%= yield %> +
diff --git a/app/views/reviews/show.html.erb b/app/views/reviews/show.html.erb new file mode 100644 index 0000000..8892035 --- /dev/null +++ b/app/views/reviews/show.html.erb @@ -0,0 +1,77 @@ +<% content_for :title, "Review — Lexivo" %> + +
+ <% if @review.nil? %> +
+
zz
+

Nothing due!

+ <% if @upcoming_count > 0 %> +

Next review: <%= @upcoming.first.due_label %> — <%= @upcoming_count %> upcoming.

+
+ <% @upcoming.each do |r| %> +
+ <%= r.word.text %> + <%= r.due_label %> +
+ <% end %> +
+ <% else %> +

No words in the learning queue yet. <%= link_to "Filter some words first", filter_path %>.

+ <% end %> + <%= link_to "Back to words", words_path, class: "btn btn-outline" %> +
+ <% else %> +
+ + <%= @due_count %> due  ·  <%= @upcoming_count %> upcoming + + <%= link_to "word list", words_path, class: "link-muted" %> +
+ +
+
+
+
<%= @review.word.text %>
+
interval: <%= @review.interval %>d  ·  rep #<%= @review.repetitions %>
+
Space / tap to flip
+
+
+
<%= @review.word.text %>
+ <% if @review.word.definition.present? %> +
<%= @review.word.definition %>
+ <% end %> + <% if @review.word.example.present? %> +
<%= @review.word.example %>
+ <% end %> +
+
+ +
+ <%= form_with url: rate_review_path(@review), method: :post, id: "form-left", class: "inline-form", + data: { turbo_frame: "_top" } do |f| %> + <%= f.hidden_field :rating, value: "forgot" %> + <%= f.submit "Forgot 1", class: "btn btn-learning" %> + <% end %> + + <%= form_with url: rate_review_path(@review), method: :post, id: "form-q3", class: "inline-form", + data: { turbo_frame: "_top" } do |f| %> + <%= f.hidden_field :rating, value: "hard" %> + <%= f.submit "Hard 2", class: "btn btn-skip" %> + <% end %> + + <%= form_with url: rate_review_path(@review), method: :post, id: "form-right", class: "inline-form", + data: { turbo_frame: "_top" } do |f| %> + <%= f.hidden_field :rating, value: "easy" %> + <%= f.submit "Easy 3", class: "btn btn-known" %> + <% end %> +
+ +
+ 1 / Forgot    + 2 Hard    + 3 / Easy +
+
+ <% end %> +
diff --git a/app/views/words/index.html.erb b/app/views/words/index.html.erb new file mode 100644 index 0000000..0e3d5ea --- /dev/null +++ b/app/views/words/index.html.erb @@ -0,0 +1,85 @@ +<% content_for :title, "Lexivo" %> + +
+ <% if notice.present? %> +
<%= notice %>
+ <% end %> + <% if alert.present? %> +
<%= alert %>
+ <% end %> + +
+
+
<%= @stats[:total] %>
+
Total
+
+
+
<%= @stats[:unseen] %>
+
Unseen
+
+
+
<%= @stats[:learning] %>
+
Learning
+
+
+
<%= @stats[:known] %>
+
Known
+
+
+
<%= @stats[:due] %>
+
Due Now
+
+
+ +
+ <% if @stats[:unseen] > 0 %> + <%= link_to "Filter words (#{@stats[:unseen]} unseen)", filter_path, class: "btn btn-primary" %> + <% end %> + <% if @stats[:due] > 0 %> + <%= link_to "Review (#{@stats[:due]} due)", review_path, class: "btn btn-success" %> + <% end %> + <% if @stats[:learning] > 0 && @stats[:due] == 0 %> + <%= link_to "Review queue (#{@stats[:learning]} upcoming)", review_path, class: "btn btn-outline" %> + <% end %> +
+ +
+

Import words

+

+ One entry per line. Formats: word - definition  |  + word: definition  |  word | definition  |  + just word +

+ <%= form_with url: words_path, method: :post, local: false, data: { turbo: false } do |f| %> + <%= f.text_area :word_list, placeholder: "ephemeral - lasting for a very short time\npersistent\ntenacious: holding firmly to a purpose", rows: 8, class: "import-textarea" %> + <%= f.submit "Import", class: "btn btn-primary" %> + <% end %> +
+ + <% if @words.any? %> +
+

All words (<%= @words.count %>)

+ + + <% @words.each do |word| %> + "> + + + + + + <% end %> + +
<%= word.text %><%= word.definition.presence || "-" %> + + <%= word.review&.status || "new" %> + + <% if word.review&.status == "learning" && word.review.due_at %> + <%= word.review.due_label %> + <% end %> + + <%= button_to "x", word_path(word), method: :delete, class: "btn-del", data: { turbo_confirm: "Remove #{word.text}?" } %> +
+
+ <% end %> +
diff --git a/config/routes.rb b/config/routes.rb index d011670..07f1e44 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,17 +1,19 @@ # frozen_string_literal: true Rails.application.routes.draw do - get "up" => "rails/health#show", as: :rails_health_check + get "up" => "rails/health#show", as: :rails_health_check + get "manifest" => "rails/pwa#manifest", as: :pwa_manifest + get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker - # Render dynamic PWA files from app/views/pwa/* - get "manifest" => "rails/pwa#manifest", as: :pwa_manifest - get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker + resources :words, only: [ :index, :create, :destroy ] - resources :todos, only: [:index, :create, :edit, :update, :destroy] do - member do - patch :toggle - end - end + get "/filter", to: "filter#show", as: :filter + post "/filter/:id/known", to: "filter#known", as: :filter_known + post "/filter/:id/learning", to: "filter#learning", as: :filter_learning + post "/filter/:id/skip", to: "filter#skip", as: :filter_skip - root "todos#index" + get "/review", to: "reviews#show", as: :review + post "/review/:id/rate", to: "reviews#rate", as: :rate_review + + root "words#index" end diff --git a/db/migrate/20260422000001_create_words.rb b/db/migrate/20260422000001_create_words.rb new file mode 100644 index 0000000..463c468 --- /dev/null +++ b/db/migrate/20260422000001_create_words.rb @@ -0,0 +1,11 @@ +class CreateWords < ActiveRecord::Migration[8.1] + def change + create_table :words do |t| + t.string :text, null: false + t.text :definition + t.text :example + t.timestamps + end + add_index :words, :text, unique: true + end +end diff --git a/db/migrate/20260422000002_create_reviews.rb b/db/migrate/20260422000002_create_reviews.rb new file mode 100644 index 0000000..d0cb4cf --- /dev/null +++ b/db/migrate/20260422000002_create_reviews.rb @@ -0,0 +1,16 @@ +class CreateReviews < ActiveRecord::Migration[8.1] + def change + create_table :reviews do |t| + t.references :word, null: false, foreign_key: true + t.string :status, null: false, default: "new" + t.integer :interval, null: false, default: 0 + t.integer :repetitions, null: false, default: 0 + t.float :ease_factor, null: false, default: 2.5 + t.datetime :due_at + t.datetime :last_reviewed_at + t.timestamps + end + add_index :reviews, :status + add_index :reviews, [ :status, :due_at ] + end +end