유대선
프로젝트로
·업데이트·2

Decks (Anki-style clip grouping) + Review next-due toast

Two user-perception fixes shipped together. (1) Anki-style decks: a new `decks` table, nullable `deck_id` on `clips` (`ON DELETE SET NULL` so clips fall back to Inbox), library sidebar, per-card move dropdown, deck filter on review. (2) After Easy/Good/Hard/Again, a toast surfaces the actual next-due date so the clip doesn't feel like it 'vanished' from the queue.

After using my own tool for a few days, a real perception problem surfaced. I'd clip ten phrases, review them, and the review queue would go empty. Instinct: "wait, where did my clips go?"

DB check: 40 clips, 40 reviews, all alive. SM-2 had just bumped the due date out 5 days — exactly like Anki. The data was right; the UX lied.

Two changes, shipped in the same migration window.

Decks

A new bounded context. One clip belongs to at most one deck (Anki's model). The schema:

-- V13__create_decks.sql
CREATE TABLE decks (
    id          UUID PRIMARY KEY,
    user_id     UUID         NOT NULL REFERENCES users (id) ON DELETE CASCADE,
    name        VARCHAR(120) NOT NULL,
    description TEXT,
    created_at  TIMESTAMP    NOT NULL,
    updated_at  TIMESTAMP
);
CREATE UNIQUE INDEX uk_decks_user_name ON decks (user_id, name);
 
ALTER TABLE clips
    ADD COLUMN deck_id UUID REFERENCES decks (id) ON DELETE SET NULL;

ON DELETE SET NULL is the important bit: deleting a deck drops its clips back to "Inbox" (deck_id NULL). Clips never vanish because of deck management.

Frontend: DeckSidebar on the library, a <select> per clip card to move it, a filter <select> on the review page. The review backend grew an optional deckId query parameter (handling "INBOX" and UUIDs explicitly).

Next-due toast

After every respond, the backend already returned the updated ReviewItem with the new dueDate. The frontend was throwing it away. Five lines later it surfaces a toast:

toast.success(t(quality === REVIEW_QUALITY.AGAIN ? "respondedAgain"
              : quality === REVIEW_QUALITY.HARD  ? "respondedHard"
              : quality === REVIEW_QUALITY.GOOD  ? "respondedGood"
              :                                    "respondedEasy",
              { date: updated.dueDate }));

So instead of clips silently leaving the queue, the learner sees:

"Solid — next due 2026-06-02"

Both changes are zero-data-loss. Same SM-2 algorithm, same persistence. The fix was naming what was already true.

Pattern: when users tell you data is missing, check the DB before the code. The bug is usually that the UI doesn't surface the state — not that the state is wrong.

Commit: ba90e00[feat] i18n + 직독직해 + Output quizzes + Decks + Playlist + project log