aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-31 14:19:22 +0900
committernsfisis <nsfisis@gmail.com>2025-12-31 14:19:22 +0900
commitb074a4901c630ee5c5f7dcff79fa6ff911a14ded (patch)
treea52b738974393e31678c85e37756004d1b547823
parent29caaa7aaf14a41dad3d345cd29b319fff6e1305 (diff)
downloadkioku-b074a4901c630ee5c5f7dcff79fa6ff911a14ded.tar.gz
kioku-b074a4901c630ee5c5f7dcff79fa6ff911a14ded.tar.zst
kioku-b074a4901c630ee5c5f7dcff79fa6ff911a14ded.zip
feat(schema): make note_id and is_reversed NOT NULL
All cards now require note association - legacy card support removed. This aligns with the note-based card architecture introduced in Phase 8. - Add database migration for NOT NULL constraints - Update client Dexie schema to version 3 - Remove LegacyCardItem component and legacy card handling - Update sync schemas and type definitions - Update all tests to use note-based cards 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
-rw-r--r--drizzle/0003_brave_blacklash.sql2
-rw-r--r--drizzle/meta/0003_snapshot.json837
-rw-r--r--drizzle/meta/_journal.json7
-rw-r--r--src/client/components/DeleteNoteModal.test.tsx24
-rw-r--r--src/client/db/index.test.ts4
-rw-r--r--src/client/db/index.ts16
-rw-r--r--src/client/db/repositories.test.ts26
-rw-r--r--src/client/db/repositories.ts7
-rw-r--r--src/client/pages/DeckDetailPage.test.tsx73
-rw-r--r--src/client/pages/DeckDetailPage.tsx156
-rw-r--r--src/client/pages/StudyPage.tsx8
-rw-r--r--src/client/sync/conflict.test.ts12
-rw-r--r--src/client/sync/conflict.ts4
-rw-r--r--src/client/sync/pull.test.ts16
-rw-r--r--src/client/sync/pull.ts8
-rw-r--r--src/client/sync/push.test.ts18
-rw-r--r--src/client/sync/push.ts4
-rw-r--r--src/client/sync/queue.test.ts18
-rw-r--r--src/server/db/schema.ts6
-rw-r--r--src/server/repositories/card.test.ts69
-rw-r--r--src/server/repositories/card.ts56
-rw-r--r--src/server/repositories/note.test.ts4
-rw-r--r--src/server/repositories/sync.ts4
-rw-r--r--src/server/repositories/types.ts14
-rw-r--r--src/server/routes/cards.test.ts7
-rw-r--r--src/server/routes/study.test.ts15
-rw-r--r--src/server/routes/sync.test.ts16
-rw-r--r--src/server/routes/sync.ts4
28 files changed, 1082 insertions, 353 deletions
diff --git a/drizzle/0003_brave_blacklash.sql b/drizzle/0003_brave_blacklash.sql
new file mode 100644
index 0000000..d129f36
--- /dev/null
+++ b/drizzle/0003_brave_blacklash.sql
@@ -0,0 +1,2 @@
+ALTER TABLE "cards" ALTER COLUMN "note_id" SET NOT NULL;--> statement-breakpoint
+ALTER TABLE "cards" ALTER COLUMN "is_reversed" SET NOT NULL; \ No newline at end of file
diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json
new file mode 100644
index 0000000..1a46c49
--- /dev/null
+++ b/drizzle/meta/0003_snapshot.json
@@ -0,0 +1,837 @@
+{
+ "id": "64c4ab9c-4036-44de-99cb-c4b14f494a4d",
+ "prevId": "4b30f43f-1827-40c6-9814-d749334da729",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.cards": {
+ "name": "cards",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "deck_id": {
+ "name": "deck_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "note_id": {
+ "name": "note_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_reversed": {
+ "name": "is_reversed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "front": {
+ "name": "front",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "back": {
+ "name": "back",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "state": {
+ "name": "state",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "due": {
+ "name": "due",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "stability": {
+ "name": "stability",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "difficulty": {
+ "name": "difficulty",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "elapsed_days": {
+ "name": "elapsed_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "scheduled_days": {
+ "name": "scheduled_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "reps": {
+ "name": "reps",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "lapses": {
+ "name": "lapses",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "last_review": {
+ "name": "last_review",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sync_version": {
+ "name": "sync_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "cards_deck_id_decks_id_fk": {
+ "name": "cards_deck_id_decks_id_fk",
+ "tableFrom": "cards",
+ "tableTo": "decks",
+ "columnsFrom": [
+ "deck_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "cards_note_id_notes_id_fk": {
+ "name": "cards_note_id_notes_id_fk",
+ "tableFrom": "cards",
+ "tableTo": "notes",
+ "columnsFrom": [
+ "note_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.decks": {
+ "name": "decks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "new_cards_per_day": {
+ "name": "new_cards_per_day",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 20
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sync_version": {
+ "name": "sync_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "decks_user_id_users_id_fk": {
+ "name": "decks_user_id_users_id_fk",
+ "tableFrom": "decks",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.note_field_types": {
+ "name": "note_field_types",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "note_type_id": {
+ "name": "note_type_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "order": {
+ "name": "order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "field_type": {
+ "name": "field_type",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'text'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sync_version": {
+ "name": "sync_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "note_field_types_note_type_id_note_types_id_fk": {
+ "name": "note_field_types_note_type_id_note_types_id_fk",
+ "tableFrom": "note_field_types",
+ "tableTo": "note_types",
+ "columnsFrom": [
+ "note_type_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.note_field_values": {
+ "name": "note_field_values",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "note_id": {
+ "name": "note_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "note_field_type_id": {
+ "name": "note_field_type_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "sync_version": {
+ "name": "sync_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "note_field_values_note_id_notes_id_fk": {
+ "name": "note_field_values_note_id_notes_id_fk",
+ "tableFrom": "note_field_values",
+ "tableTo": "notes",
+ "columnsFrom": [
+ "note_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "note_field_values_note_field_type_id_note_field_types_id_fk": {
+ "name": "note_field_values_note_field_type_id_note_field_types_id_fk",
+ "tableFrom": "note_field_values",
+ "tableTo": "note_field_types",
+ "columnsFrom": [
+ "note_field_type_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.note_types": {
+ "name": "note_types",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "front_template": {
+ "name": "front_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "back_template": {
+ "name": "back_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_reversible": {
+ "name": "is_reversible",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sync_version": {
+ "name": "sync_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "note_types_user_id_users_id_fk": {
+ "name": "note_types_user_id_users_id_fk",
+ "tableFrom": "note_types",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.notes": {
+ "name": "notes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "deck_id": {
+ "name": "deck_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "note_type_id": {
+ "name": "note_type_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sync_version": {
+ "name": "sync_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "notes_deck_id_decks_id_fk": {
+ "name": "notes_deck_id_decks_id_fk",
+ "tableFrom": "notes",
+ "tableTo": "decks",
+ "columnsFrom": [
+ "deck_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "notes_note_type_id_note_types_id_fk": {
+ "name": "notes_note_type_id_note_types_id_fk",
+ "tableFrom": "notes",
+ "tableTo": "note_types",
+ "columnsFrom": [
+ "note_type_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.refresh_tokens": {
+ "name": "refresh_tokens",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "refresh_tokens_user_id_users_id_fk": {
+ "name": "refresh_tokens_user_id_users_id_fk",
+ "tableFrom": "refresh_tokens",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.review_logs": {
+ "name": "review_logs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "card_id": {
+ "name": "card_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rating": {
+ "name": "rating",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "state": {
+ "name": "state",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scheduled_days": {
+ "name": "scheduled_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "elapsed_days": {
+ "name": "elapsed_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reviewed_at": {
+ "name": "reviewed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sync_version": {
+ "name": "sync_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "review_logs_card_id_cards_id_fk": {
+ "name": "review_logs_card_id_cards_id_fk",
+ "tableFrom": "review_logs",
+ "tableTo": "cards",
+ "columnsFrom": [
+ "card_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "review_logs_user_id_users_id_fk": {
+ "name": "review_logs_user_id_users_id_fk",
+ "tableFrom": "review_logs",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "username": {
+ "name": "username",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_username_unique": {
+ "name": "users_username_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "username"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+} \ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 5da2908..cdfca60 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -22,6 +22,13 @@
"when": 1767108756805,
"tag": "0002_married_moondragon",
"breakpoints": true
+ },
+ {
+ "idx": 3,
+ "version": "7",
+ "when": 1767156818092,
+ "tag": "0003_brave_blacklash",
+ "breakpoints": true
}
]
} \ No newline at end of file
diff --git a/src/client/components/DeleteNoteModal.test.tsx b/src/client/components/DeleteNoteModal.test.tsx
index 85aaa14..a17323a 100644
--- a/src/client/components/DeleteNoteModal.test.tsx
+++ b/src/client/components/DeleteNoteModal.test.tsx
@@ -175,7 +175,7 @@ describe("DeleteNoteModal", () => {
const user = userEvent.setup();
// Create a promise that we can control
- let resolveDelete: (value: unknown) => void;
+ let resolveDelete: ((value: unknown) => void) | undefined;
const deletePromise = new Promise((resolve) => {
resolveDelete = resolve;
});
@@ -190,19 +190,17 @@ describe("DeleteNoteModal", () => {
expect(screen.getByText("Deleting...")).toBeDefined();
// Resolve the delete request to cleanup
- if (resolveDelete) {
- resolveDelete({
- ok: true,
- json: async () => ({ success: true }),
- });
- }
+ resolveDelete?.({
+ ok: true,
+ json: async () => ({ success: true }),
+ });
});
it("disables buttons while deleting", async () => {
const user = userEvent.setup();
// Create a promise that we can control
- let resolveDelete: (value: unknown) => void;
+ let resolveDelete: ((value: unknown) => void) | undefined;
const deletePromise = new Promise((resolve) => {
resolveDelete = resolve;
});
@@ -224,11 +222,9 @@ describe("DeleteNoteModal", () => {
);
// Resolve the delete request to cleanup
- if (resolveDelete) {
- resolveDelete({
- ok: true,
- json: async () => ({ success: true }),
- });
- }
+ resolveDelete?.({
+ ok: true,
+ json: async () => ({ success: true }),
+ });
});
});
diff --git a/src/client/db/index.test.ts b/src/client/db/index.test.ts
index a30f94b..0a4882d 100644
--- a/src/client/db/index.test.ts
+++ b/src/client/db/index.test.ts
@@ -132,8 +132,8 @@ describe("KiokuDatabase", () => {
const testCard: LocalCard = {
id: "card-1",
deckId: "deck-1",
- noteId: null,
- isReversed: null,
+ noteId: "test-note-id",
+ isReversed: false,
front: "Question",
back: "Answer",
state: CardState.New,
diff --git a/src/client/db/index.ts b/src/client/db/index.ts
index 5318b17..53df476 100644
--- a/src/client/db/index.ts
+++ b/src/client/db/index.ts
@@ -122,8 +122,8 @@ export interface LocalNoteFieldValue {
export interface LocalCard {
id: string;
deckId: string;
- noteId: string | null;
- isReversed: boolean | null;
+ noteId: string;
+ isReversed: boolean;
front: string;
back: string;
@@ -241,6 +241,18 @@ export class KiokuDatabase extends Dexie {
}
});
});
+
+ // Version 3: noteId and isReversed are now required (NOT NULL)
+ // No migration needed as production has no legacy data without notes
+ this.version(3).stores({
+ decks: "id, userId, updatedAt",
+ cards: "id, deckId, noteId, updatedAt, due, state",
+ reviewLogs: "id, cardId, userId, reviewedAt",
+ noteTypes: "id, userId, updatedAt",
+ noteFieldTypes: "id, noteTypeId, updatedAt",
+ notes: "id, deckId, noteTypeId, updatedAt",
+ noteFieldValues: "id, noteId, noteFieldTypeId, updatedAt",
+ });
}
}
diff --git a/src/client/db/repositories.test.ts b/src/client/db/repositories.test.ts
index 2fca210..da0f0d3 100644
--- a/src/client/db/repositories.test.ts
+++ b/src/client/db/repositories.test.ts
@@ -240,6 +240,8 @@ describe("localCardRepository", () => {
it("should create a card with FSRS defaults", async () => {
const card = await localCardRepository.create({
deckId,
+ noteId: "test-note-id",
+ isReversed: false,
front: "Question",
back: "Answer",
});
@@ -260,8 +262,8 @@ describe("localCardRepository", () => {
describe("findByDeckId", () => {
it("should return all cards for a deck", async () => {
- await localCardRepository.create({ deckId, front: "Q1", back: "A1" });
- await localCardRepository.create({ deckId, front: "Q2", back: "A2" });
+ await localCardRepository.create({ deckId, noteId: "test-note-id", isReversed: false, front: "Q1", back: "A1" });
+ await localCardRepository.create({ deckId, noteId: "test-note-id-2", isReversed: false, front: "Q2", back: "A2" });
const cards = await localCardRepository.findByDeckId(deckId);
expect(cards).toHaveLength(2);
@@ -270,6 +272,8 @@ describe("localCardRepository", () => {
it("should exclude soft-deleted cards", async () => {
const card = await localCardRepository.create({
deckId,
+ noteId: "test-note-id",
+ isReversed: false,
front: "Q",
back: "A",
});
@@ -287,6 +291,8 @@ describe("localCardRepository", () => {
const card1 = await localCardRepository.create({
deckId,
+ noteId: "test-note-id",
+ isReversed: false,
front: "Due",
back: "A",
});
@@ -294,6 +300,8 @@ describe("localCardRepository", () => {
const card2 = await localCardRepository.create({
deckId,
+ noteId: "test-note-id-2",
+ isReversed: false,
front: "Not Due",
back: "B",
});
@@ -308,6 +316,8 @@ describe("localCardRepository", () => {
for (let i = 0; i < 5; i++) {
await localCardRepository.create({
deckId,
+ noteId: `test-note-id-${i}`,
+ isReversed: false,
front: `Q${i}`,
back: `A${i}`,
});
@@ -322,12 +332,16 @@ describe("localCardRepository", () => {
it("should return only new cards", async () => {
await localCardRepository.create({
deckId,
+ noteId: "test-note-id",
+ isReversed: false,
front: "New",
back: "A",
});
const reviewedCard = await localCardRepository.create({
deckId,
+ noteId: "test-note-id-2",
+ isReversed: false,
front: "Reviewed",
back: "B",
});
@@ -343,6 +357,8 @@ describe("localCardRepository", () => {
it("should update card content", async () => {
const card = await localCardRepository.create({
deckId,
+ noteId: "test-note-id",
+ isReversed: false,
front: "Original",
back: "Original",
});
@@ -362,6 +378,8 @@ describe("localCardRepository", () => {
it("should update FSRS scheduling data", async () => {
const card = await localCardRepository.create({
deckId,
+ noteId: "test-note-id",
+ isReversed: false,
front: "Q",
back: "A",
});
@@ -391,6 +409,8 @@ describe("localCardRepository", () => {
it("should soft delete a card", async () => {
const card = await localCardRepository.create({
deckId,
+ noteId: "test-note-id",
+ isReversed: false,
front: "Q",
back: "A",
});
@@ -423,6 +443,8 @@ describe("localReviewLogRepository", () => {
const card = await localCardRepository.create({
deckId,
+ noteId: "test-note-id",
+ isReversed: false,
front: "Q",
back: "A",
});
diff --git a/src/client/db/repositories.ts b/src/client/db/repositories.ts
index 104f026..e01254e 100644
--- a/src/client/db/repositories.ts
+++ b/src/client/db/repositories.ts
@@ -176,8 +176,6 @@ export const localCardRepository = {
data: Omit<
LocalCard,
| "id"
- | "noteId"
- | "isReversed"
| "state"
| "due"
| "stability"
@@ -192,14 +190,11 @@ export const localCardRepository = {
| "deletedAt"
| "syncVersion"
| "_synced"
- > &
- Partial<Pick<LocalCard, "noteId" | "isReversed">>,
+ >,
): Promise<LocalCard> {
const now = new Date();
const card: LocalCard = {
id: uuidv4(),
- noteId: null,
- isReversed: null,
...data,
state: CardState.New,
due: now,
diff --git a/src/client/pages/DeckDetailPage.test.tsx b/src/client/pages/DeckDetailPage.test.tsx
index 35303d9..e02302f 100644
--- a/src/client/pages/DeckDetailPage.test.tsx
+++ b/src/client/pages/DeckDetailPage.test.tsx
@@ -51,13 +51,13 @@ const mockDeck = {
updatedAt: "2024-01-01T00:00:00Z",
};
-// Legacy cards (no noteId) for backward compatibility testing
-const mockLegacyCards = [
+// Basic note-based cards (each with its own note)
+const mockBasicCards = [
{
id: "card-1",
deckId: "deck-1",
- noteId: null,
- isReversed: null,
+ noteId: "note-1",
+ isReversed: false,
front: "Hello",
back: "こんにちは",
state: 0,
@@ -77,8 +77,8 @@ const mockLegacyCards = [
{
id: "card-2",
deckId: "deck-1",
- noteId: null,
- isReversed: null,
+ noteId: "note-2",
+ isReversed: false,
front: "Goodbye",
back: "さようなら",
state: 2,
@@ -143,11 +143,8 @@ const mockNoteBasedCards = [
},
];
-// Mixed cards (both legacy and note-based)
-const mockMixedCards = [...mockLegacyCards, ...mockNoteBasedCards];
-
-// Alias for backward compatibility in existing tests
-const mockCards = mockLegacyCards;
+// Alias for existing tests
+const mockCards = mockBasicCards;
function renderWithProviders(path = "/decks/deck-1") {
const { hook } = memoryLocation({ path, static: true });
@@ -430,8 +427,8 @@ describe("DeckDetailPage", () => {
expect(screen.queryByText("Common Japanese words")).toBeNull();
});
- describe("Delete Card", () => {
- it("shows Delete button for each card", async () => {
+ describe("Delete Note", () => {
+ it("shows Delete button for each note", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
@@ -449,7 +446,7 @@ describe("DeckDetailPage", () => {
});
const deleteButtons = screen.getAllByRole("button", {
- name: "Delete card",
+ name: "Delete note",
});
expect(deleteButtons.length).toBe(2);
});
@@ -474,7 +471,7 @@ describe("DeckDetailPage", () => {
});
const deleteButtons = screen.getAllByRole("button", {
- name: "Delete card",
+ name: "Delete note",
});
const firstDeleteButton = deleteButtons[0];
if (firstDeleteButton) {
@@ -483,7 +480,7 @@ describe("DeckDetailPage", () => {
expect(screen.getByRole("dialog")).toBeDefined();
expect(
- screen.getByRole("heading", { name: "Delete Card" }),
+ screen.getByRole("heading", { name: "Delete Note" }),
).toBeDefined();
});
@@ -507,7 +504,7 @@ describe("DeckDetailPage", () => {
});
const deleteButtons = screen.getAllByRole("button", {
- name: "Delete card",
+ name: "Delete note",
});
const firstDeleteButton = deleteButtons[0];
if (firstDeleteButton) {
@@ -521,7 +518,7 @@ describe("DeckDetailPage", () => {
expect(screen.queryByRole("dialog")).toBeNull();
});
- it("deletes card and refreshes list on confirmation", async () => {
+ it("deletes note and refreshes list on confirmation", async () => {
const user = userEvent.setup();
mockFetch
@@ -537,7 +534,7 @@ describe("DeckDetailPage", () => {
// Delete request
.mockResolvedValueOnce({
ok: true,
- json: async () => ({}),
+ json: async () => ({ success: true }),
})
// Refresh cards after deletion
.mockResolvedValueOnce({
@@ -552,7 +549,7 @@ describe("DeckDetailPage", () => {
});
const deleteButtons = screen.getAllByRole("button", {
- name: "Delete card",
+ name: "Delete note",
});
const firstDeleteButton = deleteButtons[0];
if (firstDeleteButton) {
@@ -575,8 +572,8 @@ describe("DeckDetailPage", () => {
expect(screen.queryByRole("dialog")).toBeNull();
});
- // Verify DELETE request was made
- expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1/cards/card-1", {
+ // Verify DELETE request was made to notes endpoint
+ expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1/notes/note-1", {
method: "DELETE",
headers: { Authorization: "Bearer access-token" },
});
@@ -604,7 +601,7 @@ describe("DeckDetailPage", () => {
.mockResolvedValueOnce({
ok: false,
status: 500,
- json: async () => ({ error: "Failed to delete card" }),
+ json: async () => ({ error: "Failed to delete note" }),
});
renderWithProviders();
@@ -614,7 +611,7 @@ describe("DeckDetailPage", () => {
});
const deleteButtons = screen.getAllByRole("button", {
- name: "Delete card",
+ name: "Delete note",
});
const firstDeleteButton = deleteButtons[0];
if (firstDeleteButton) {
@@ -635,7 +632,7 @@ describe("DeckDetailPage", () => {
// Error should be displayed in the modal
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain(
- "Failed to delete card",
+ "Failed to delete note",
);
});
});
@@ -665,32 +662,6 @@ describe("DeckDetailPage", () => {
expect(noteCards.length).toBe(2);
});
- it("displays legacy cards separately from note groups", async () => {
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ deck: mockDeck }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ cards: mockMixedCards }),
- });
-
- renderWithProviders();
-
- await waitFor(() => {
- // Should show both note groups and legacy cards
- expect(screen.getByTestId("note-group")).toBeDefined();
- });
-
- const legacyCards = screen.getAllByTestId("legacy-card");
- expect(legacyCards.length).toBe(2); // 2 legacy cards
-
- // Should show "Legacy" badge for legacy cards
- const legacyBadges = screen.getAllByText("Legacy");
- expect(legacyBadges.length).toBe(2);
- });
-
it("shows Normal and Reversed badges for note-based cards", async () => {
mockFetch
.mockResolvedValueOnce({
diff --git a/src/client/pages/DeckDetailPage.tsx b/src/client/pages/DeckDetailPage.tsx
index 87f9dc3..d018d1f 100644
--- a/src/client/pages/DeckDetailPage.tsx
+++ b/src/client/pages/DeckDetailPage.tsx
@@ -21,8 +21,8 @@ import { EditNoteModal } from "../components/EditNoteModal";
interface Card {
id: string;
deckId: string;
- noteId: string | null;
- isReversed: boolean | null;
+ noteId: string;
+ isReversed: boolean;
front: string;
back: string;
state: number;
@@ -33,10 +33,8 @@ interface Card {
updatedAt: string;
}
-/** Combined type for display: either a note group or a legacy card */
-type CardDisplayItem =
- | { type: "note"; noteId: string; cards: Card[] }
- | { type: "legacy"; card: Card };
+/** Combined type for display: note group */
+type CardDisplayItem = { type: "note"; noteId: string; cards: Card[] };
interface Deck {
id: string;
@@ -178,95 +176,6 @@ function NoteGroupCard({
);
}
-/** Component for displaying a legacy card (without note association) */
-function LegacyCardItem({
- card,
- index,
- onEdit,
- onDelete,
-}: {
- card: Card;
- index: number;
- onEdit: () => void;
- onDelete: () => void;
-}) {
- return (
- <div
- data-testid="legacy-card"
- className="bg-white rounded-xl border border-border/50 p-5 shadow-card hover:shadow-md transition-all duration-200"
- style={{ animationDelay: `${index * 30}ms` }}
- >
- <div className="flex items-start justify-between gap-4">
- <div className="flex-1 min-w-0">
- {/* Front/Back Preview */}
- <div className="grid grid-cols-2 gap-4 mb-3">
- <div>
- <span className="text-xs font-medium text-muted uppercase tracking-wide">
- Front
- </span>
- <p className="mt-1 text-slate text-sm line-clamp-2 whitespace-pre-wrap break-words">
- {card.front}
- </p>
- </div>
- <div>
- <span className="text-xs font-medium text-muted uppercase tracking-wide">
- Back
- </span>
- <p className="mt-1 text-slate text-sm line-clamp-2 whitespace-pre-wrap break-words">
- {card.back}
- </p>
- </div>
- </div>
-
- {/* Card Stats */}
- <div className="flex items-center gap-3 text-xs">
- <span
- className={`px-2 py-0.5 rounded-full font-medium ${CardStateColors[card.state] || "bg-muted/10 text-muted"}`}
- >
- {CardStateLabels[card.state] || "Unknown"}
- </span>
- <span className="px-2 py-0.5 rounded-full font-medium bg-amber-100 text-amber-700">
- Legacy
- </span>
- <span className="text-muted">{card.reps} reviews</span>
- {card.lapses > 0 && (
- <span className="text-muted">{card.lapses} lapses</span>
- )}
- </div>
- </div>
-
- {/* Actions */}
- <div className="flex items-center gap-1 shrink-0">
- <button
- type="button"
- onClick={onEdit}
- className="p-2 text-muted hover:text-slate hover:bg-ivory rounded-lg transition-colors"
- title="Edit card"
- >
- <FontAwesomeIcon
- icon={faPen}
- className="w-4 h-4"
- aria-hidden="true"
- />
- </button>
- <button
- type="button"
- onClick={onDelete}
- className="p-2 text-muted hover:text-error hover:bg-error/5 rounded-lg transition-colors"
- title="Delete card"
- >
- <FontAwesomeIcon
- icon={faTrash}
- className="w-4 h-4"
- aria-hidden="true"
- />
- </button>
- </div>
- </div>
- </div>
- );
-}
-
export function DeckDetailPage() {
const { deckId } = useParams<{ deckId: string }>();
const [deck, setDeck] = useState<Deck | null>(null);
@@ -282,24 +191,17 @@ export function DeckDetailPage() {
// Group cards by note for display
const displayItems = useMemo((): CardDisplayItem[] => {
const noteGroups = new Map<string, Card[]>();
- const legacyCards: Card[] = [];
for (const card of cards) {
- if (card.noteId) {
- const existing = noteGroups.get(card.noteId);
- if (existing) {
- existing.push(card);
- } else {
- noteGroups.set(card.noteId, [card]);
- }
+ const existing = noteGroups.get(card.noteId);
+ if (existing) {
+ existing.push(card);
} else {
- legacyCards.push(card);
+ noteGroups.set(card.noteId, [card]);
}
}
- const items: CardDisplayItem[] = [];
-
- // Add note groups first, sorted by earliest card creation
+ // Sort note groups by earliest card creation (newest first)
const sortedNoteGroups = Array.from(noteGroups.entries()).sort(
([, cardsA], [, cardsB]) => {
const minA = Math.min(
@@ -312,6 +214,7 @@ export function DeckDetailPage() {
},
);
+ const items: CardDisplayItem[] = [];
for (const [noteId, noteCards] of sortedNoteGroups) {
// Sort cards within group: normal first, then reversed
noteCards.sort((a, b) => {
@@ -321,15 +224,6 @@ export function DeckDetailPage() {
items.push({ type: "note", noteId, cards: noteCards });
}
- // Add legacy cards, newest first
- legacyCards.sort(
- (a, b) =>
- new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
- );
- for (const card of legacyCards) {
- items.push({ type: "legacy", card });
- }
-
return items;
}, [cards]);
@@ -551,26 +445,16 @@ export function DeckDetailPage() {
{/* Card List - Grouped by Note */}
{cards.length > 0 && (
<div className="space-y-4">
- {displayItems.map((item, index) =>
- item.type === "note" ? (
- <NoteGroupCard
- key={item.noteId}
- noteId={item.noteId}
- cards={item.cards}
- index={index}
- onEditNote={() => setEditingNoteId(item.noteId)}
- onDeleteNote={() => setDeletingNoteId(item.noteId)}
- />
- ) : (
- <LegacyCardItem
- key={item.card.id}
- card={item.card}
- index={index}
- onEdit={() => setEditingCard(item.card)}
- onDelete={() => setDeletingCard(item.card)}
- />
- ),
- )}
+ {displayItems.map((item, index) => (
+ <NoteGroupCard
+ key={item.noteId}
+ noteId={item.noteId}
+ cards={item.cards}
+ index={index}
+ onEditNote={() => setEditingNoteId(item.noteId)}
+ onDeleteNote={() => setDeletingNoteId(item.noteId)}
+ />
+ ))}
</div>
)}
</div>
diff --git a/src/client/pages/StudyPage.tsx b/src/client/pages/StudyPage.tsx
index bdaf7e3..0eb5118 100644
--- a/src/client/pages/StudyPage.tsx
+++ b/src/client/pages/StudyPage.tsx
@@ -13,8 +13,8 @@ import { renderCard } from "../utils/templateRenderer";
interface Card {
id: string;
deckId: string;
- noteId: string | null;
- isReversed: boolean | null;
+ noteId: string;
+ isReversed: boolean;
front: string;
back: string;
state: number;
@@ -23,11 +23,11 @@ interface Card {
difficulty: number;
reps: number;
lapses: number;
- /** Note type templates for rendering (null for legacy cards) */
+ /** Note type templates for rendering */
noteType: {
frontTemplate: string;
backTemplate: string;
- } | null;
+ };
/** Field values as a name-value map for template rendering */
fieldValuesMap: Record<string, string>;
}
diff --git a/src/client/sync/conflict.test.ts b/src/client/sync/conflict.test.ts
index 6a10c4f..52362ff 100644
--- a/src/client/sync/conflict.test.ts
+++ b/src/client/sync/conflict.test.ts
@@ -271,6 +271,8 @@ describe("ConflictResolver", () => {
const localCard = await localCardRepository.create({
deckId: deck.id,
+ noteId: "test-note-id",
+ isReversed: false,
front: "Local Question",
back: "Local Answer",
});
@@ -278,6 +280,8 @@ describe("ConflictResolver", () => {
const serverCard = {
id: localCard.id,
deckId: deck.id,
+ noteId: "test-note-id",
+ isReversed: false,
front: "Server Question",
back: "Server Answer",
state: CardState.Review,
@@ -316,6 +320,8 @@ describe("ConflictResolver", () => {
const localCard = await localCardRepository.create({
deckId: deck.id,
+ noteId: "test-note-id",
+ isReversed: false,
front: "Local Question",
back: "Local Answer",
});
@@ -323,6 +329,8 @@ describe("ConflictResolver", () => {
const serverCard = {
id: localCard.id,
deckId: deck.id,
+ noteId: "test-note-id",
+ isReversed: false,
front: "Server Question",
back: "Server Answer",
state: CardState.New,
@@ -435,6 +443,8 @@ describe("ConflictResolver", () => {
const card = await localCardRepository.create({
deckId: deck.id,
+ noteId: "test-note-id",
+ isReversed: false,
front: "Local Question",
back: "Local Answer",
});
@@ -456,6 +466,8 @@ describe("ConflictResolver", () => {
{
id: card.id,
deckId: deck.id,
+ noteId: "test-note-id",
+ isReversed: false,
front: "Server Question",
back: "Server Answer",
state: CardState.New,
diff --git a/src/client/sync/conflict.ts b/src/client/sync/conflict.ts
index d9c3f55..2451920 100644
--- a/src/client/sync/conflict.ts
+++ b/src/client/sync/conflict.ts
@@ -91,8 +91,8 @@ function serverCardToLocal(card: ServerCard): LocalCard {
return {
id: card.id,
deckId: card.deckId,
- noteId: card.noteId ?? null,
- isReversed: card.isReversed ?? null,
+ noteId: card.noteId,
+ isReversed: card.isReversed,
front: card.front,
back: card.back,
state: card.state as LocalCard["state"],
diff --git a/src/client/sync/pull.test.ts b/src/client/sync/pull.test.ts
index 9ba678e..dd562a0 100644
--- a/src/client/sync/pull.test.ts
+++ b/src/client/sync/pull.test.ts
@@ -101,6 +101,8 @@ describe("pullResultToLocalData", () => {
{
id: "card-1",
deckId: "deck-1",
+ noteId: "test-note-id",
+ isReversed: false,
front: "Question",
back: "Answer",
state: CardState.Review,
@@ -130,8 +132,8 @@ describe("pullResultToLocalData", () => {
expect(result.cards[0]).toEqual({
id: "card-1",
deckId: "deck-1",
- noteId: null,
- isReversed: null,
+ noteId: "test-note-id",
+ isReversed: false,
front: "Question",
back: "Answer",
state: CardState.Review,
@@ -156,6 +158,8 @@ describe("pullResultToLocalData", () => {
{
id: "card-1",
deckId: "deck-1",
+ noteId: "test-note-id",
+ isReversed: false,
front: "New Card",
back: "Answer",
state: CardState.New,
@@ -568,8 +572,8 @@ describe("PullService", () => {
{
id: "server-card-1",
deckId: deck.id,
- noteId: null,
- isReversed: null,
+ noteId: "test-note-id",
+ isReversed: false,
front: "Server Question",
back: "Server Answer",
state: CardState.New,
@@ -715,8 +719,8 @@ describe("PullService", () => {
{
id: "card-1",
deckId: "deck-1",
- noteId: null,
- isReversed: null,
+ noteId: "test-note-id",
+ isReversed: false,
front: "Q",
back: "A",
state: CardState.New,
diff --git a/src/client/sync/pull.ts b/src/client/sync/pull.ts
index 55c859c..8b55a9b 100644
--- a/src/client/sync/pull.ts
+++ b/src/client/sync/pull.ts
@@ -33,8 +33,8 @@ export interface ServerDeck {
export interface ServerCard {
id: string;
deckId: string;
- noteId?: string | null;
- isReversed?: boolean | null;
+ noteId: string;
+ isReversed: boolean;
front: string;
back: string;
state: number;
@@ -172,8 +172,8 @@ function serverCardToLocal(card: ServerCard): LocalCard {
return {
id: card.id,
deckId: card.deckId,
- noteId: card.noteId ?? null,
- isReversed: card.isReversed ?? null,
+ noteId: card.noteId,
+ isReversed: card.isReversed,
front: card.front,
back: card.back,
state: card.state as CardStateType,
diff --git a/src/client/sync/push.test.ts b/src/client/sync/push.test.ts
index 9a42eff..16198c1 100644
--- a/src/client/sync/push.test.ts
+++ b/src/client/sync/push.test.ts
@@ -131,8 +131,8 @@ describe("pendingChangesToPushData", () => {
{
id: "card-1",
deckId: "deck-1",
- noteId: null,
- isReversed: null,
+ noteId: "test-note-id",
+ isReversed: false,
front: "Question",
back: "Answer",
state: CardState.Review,
@@ -163,8 +163,8 @@ describe("pendingChangesToPushData", () => {
expect(result.cards[0]).toEqual({
id: "card-1",
deckId: "deck-1",
- noteId: null,
- isReversed: null,
+ noteId: "test-note-id",
+ isReversed: false,
front: "Question",
back: "Answer",
state: CardState.Review,
@@ -187,8 +187,8 @@ describe("pendingChangesToPushData", () => {
{
id: "card-1",
deckId: "deck-1",
- noteId: null,
- isReversed: null,
+ noteId: "test-note-id",
+ isReversed: false,
front: "New Card",
back: "Answer",
state: CardState.New,
@@ -566,6 +566,8 @@ describe("PushService", () => {
const card = await localCardRepository.create({
deckId: deck.id,
+ noteId: "test-note-id",
+ isReversed: false,
front: "Question",
back: "Answer",
});
@@ -611,6 +613,8 @@ describe("PushService", () => {
const card = await localCardRepository.create({
deckId: deck.id,
+ noteId: "test-note-id",
+ isReversed: false,
front: "Q",
back: "A",
});
@@ -740,6 +744,8 @@ describe("PushService", () => {
const card = await localCardRepository.create({
deckId: deck.id,
+ noteId: "test-note-id",
+ isReversed: false,
front: "Q",
back: "A",
});
diff --git a/src/client/sync/push.ts b/src/client/sync/push.ts
index f5c9275..b83136e 100644
--- a/src/client/sync/push.ts
+++ b/src/client/sync/push.ts
@@ -35,8 +35,8 @@ export interface SyncDeckData {
export interface SyncCardData {
id: string;
deckId: string;
- noteId: string | null;
- isReversed: boolean | null;
+ noteId: string;
+ isReversed: boolean;
front: string;
back: string;
state: number;
diff --git a/src/client/sync/queue.test.ts b/src/client/sync/queue.test.ts
index 2038e0d..e815282 100644
--- a/src/client/sync/queue.test.ts
+++ b/src/client/sync/queue.test.ts
@@ -85,6 +85,8 @@ describe("SyncQueue", () => {
});
await localCardRepository.create({
deckId: deck.id,
+ noteId: "test-note-id",
+ isReversed: false,
front: "Question",
back: "Answer",
});
@@ -103,6 +105,8 @@ describe("SyncQueue", () => {
});
const card = await localCardRepository.create({
deckId: deck.id,
+ noteId: "test-note-id",
+ isReversed: false,
front: "Question",
back: "Answer",
});
@@ -145,11 +149,15 @@ describe("SyncQueue", () => {
});
await localCardRepository.create({
deckId: deck.id,
+ noteId: "test-note-id-1",
+ isReversed: false,
front: "Q1",
back: "A1",
});
await localCardRepository.create({
deckId: deck.id,
+ noteId: "test-note-id-2",
+ isReversed: false,
front: "Q2",
back: "A2",
});
@@ -323,6 +331,8 @@ describe("SyncQueue", () => {
});
const card = await localCardRepository.create({
deckId: deck.id,
+ noteId: "test-note-id",
+ isReversed: false,
front: "Q",
back: "A",
});
@@ -351,6 +361,8 @@ describe("SyncQueue", () => {
});
const card = await localCardRepository.create({
deckId: deck.id,
+ noteId: "test-note-id",
+ isReversed: false,
front: "Q",
back: "A",
});
@@ -440,8 +452,8 @@ describe("SyncQueue", () => {
const serverCard = {
id: "server-card-1",
deckId: deck.id,
- noteId: null,
- isReversed: null,
+ noteId: "test-note-id",
+ isReversed: false,
front: "Server Question",
back: "Server Answer",
state: CardState.New,
@@ -484,6 +496,8 @@ describe("SyncQueue", () => {
});
const card = await localCardRepository.create({
deckId: deck.id,
+ noteId: "test-note-id",
+ isReversed: false,
front: "Q",
back: "A",
});
diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts
index bd3d396..0471a92 100644
--- a/src/server/db/schema.ts
+++ b/src/server/db/schema.ts
@@ -153,8 +153,10 @@ export const cards = pgTable("cards", {
deckId: uuid("deck_id")
.notNull()
.references(() => decks.id),
- noteId: uuid("note_id").references(() => notes.id),
- isReversed: boolean("is_reversed"),
+ noteId: uuid("note_id")
+ .notNull()
+ .references(() => notes.id),
+ isReversed: boolean("is_reversed").notNull(),
front: text("front").notNull(),
back: text("back").notNull(),
diff --git a/src/server/repositories/card.test.ts b/src/server/repositories/card.test.ts
index 9d7ffa6..98913e9 100644
--- a/src/server/repositories/card.test.ts
+++ b/src/server/repositories/card.test.ts
@@ -12,8 +12,8 @@ function createMockCard(overrides: Partial<Card> = {}): Card {
return {
id: "card-uuid-123",
deckId: "deck-uuid-123",
- noteId: null,
- isReversed: null,
+ noteId: "note-uuid-123",
+ isReversed: false,
front: "Front text",
back: "Back text",
state: 0,
@@ -89,15 +89,14 @@ function createMockCardWithNoteData(
function createMockCardForStudy(
overrides: Partial<CardForStudy> = {},
): CardForStudy {
- const card = createMockCard({
- noteId: overrides.noteType ? "note-uuid-123" : null,
- isReversed: overrides.noteType ? false : null,
- ...overrides,
- });
+ const card = createMockCard(overrides);
return {
...card,
- noteType: overrides.noteType ?? null,
- fieldValuesMap: overrides.fieldValuesMap ?? {},
+ noteType: overrides.noteType ?? {
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ },
+ fieldValuesMap: overrides.fieldValuesMap ?? { Front: "Q", Back: "A" },
};
}
@@ -125,8 +124,8 @@ describe("CardRepository mock factory", () => {
expect(card.id).toBe("card-uuid-123");
expect(card.deckId).toBe("deck-uuid-123");
- expect(card.noteId).toBeNull();
- expect(card.isReversed).toBeNull();
+ expect(card.noteId).toBe("note-uuid-123");
+ expect(card.isReversed).toBe(false);
expect(card.front).toBe("Front text");
expect(card.back).toBe("Back text");
expect(card.state).toBe(0);
@@ -205,17 +204,13 @@ describe("CardRepository mock factory", () => {
expect(cardWithNote.fieldValues[0]?.value).toBe("日本語");
});
- it("can represent legacy card with null note", () => {
- // For legacy cards without notes, we explicitly construct the type
- const legacyCard: CardWithNoteData = {
- ...createMockCard({ noteId: null, isReversed: null }),
- note: null,
- fieldValues: [],
- };
-
- expect(legacyCard.noteId).toBeNull();
- expect(legacyCard.note).toBeNull();
- expect(legacyCard.fieldValues).toHaveLength(0);
+ it("card always has note association", () => {
+ // All cards now require note association
+ const cardWithNote = createMockCardWithNoteData();
+
+ expect(cardWithNote.noteId).toBe("note-uuid-123");
+ expect(cardWithNote.note).not.toBeNull();
+ expect(cardWithNote.fieldValues).toHaveLength(2);
});
});
@@ -392,41 +387,39 @@ describe("Card interface contracts", () => {
expect(cardForStudy.fieldValuesMap.Front).toBe("Question");
});
- it("CardForStudy can represent legacy card with null noteType", () => {
- const legacyCard = createMockCardForStudy({
- front: "Legacy Question",
- back: "Legacy Answer",
+ it("CardForStudy has required note data", () => {
+ const cardForStudy = createMockCardForStudy({
+ front: "Question",
+ back: "Answer",
});
- expect(legacyCard.noteId).toBeNull();
- expect(legacyCard.noteType).toBeNull();
- expect(legacyCard.fieldValuesMap).toEqual({});
- expect(legacyCard.front).toBe("Legacy Question");
- expect(legacyCard.back).toBe("Legacy Answer");
+ expect(cardForStudy.noteId).toBe("note-uuid-123");
+ expect(cardForStudy.noteType).not.toBeNull();
+ expect(cardForStudy.noteType.frontTemplate).toBe("{{Front}}");
+ expect(cardForStudy.fieldValuesMap).toEqual({ Front: "Q", Back: "A" });
});
});
describe("Card and Note relationship", () => {
- it("legacy card has null noteId and isReversed", () => {
+ it("card has required noteId and isReversed", () => {
const card = createMockCard();
- expect(card.noteId).toBeNull();
- expect(card.isReversed).toBeNull();
+ expect(card.noteId).toBe("note-uuid-123");
+ expect(card.isReversed).toBe(false);
});
- it("note-based card has noteId and isReversed set", () => {
+ it("card with explicit note data", () => {
const card = createMockCard({
- noteId: "note-uuid-123",
+ noteId: "different-note-id",
isReversed: false,
});
- expect(card.noteId).toBe("note-uuid-123");
+ expect(card.noteId).toBe("different-note-id");
expect(card.isReversed).toBe(false);
});
it("reversed card has isReversed true", () => {
const card = createMockCard({
- noteId: "note-uuid-123",
isReversed: true,
});
diff --git a/src/server/repositories/card.ts b/src/server/repositories/card.ts
index 7116642..4c4fc81 100644
--- a/src/server/repositories/card.ts
+++ b/src/server/repositories/card.ts
@@ -47,20 +47,15 @@ export const cardRepository: CardRepository = {
return undefined;
}
- if (!card.noteId) {
- return {
- ...card,
- note: null,
- fieldValues: [],
- };
- }
-
const noteResult = await db
.select()
.from(notes)
.where(and(eq(notes.id, card.noteId), isNull(notes.deletedAt)));
- const note = noteResult[0] ?? null;
+ const note = noteResult[0];
+ if (!note) {
+ return undefined;
+ }
const fieldValuesResult = await db
.select()
@@ -85,6 +80,8 @@ export const cardRepository: CardRepository = {
async create(
deckId: string,
data: {
+ noteId: string;
+ isReversed: boolean;
front: string;
back: string;
},
@@ -93,6 +90,8 @@ export const cardRepository: CardRepository = {
.insert(cards)
.values({
deckId,
+ noteId: data.noteId,
+ isReversed: data.isReversed,
front: data.front,
back: data.back,
state: CardState.New,
@@ -200,21 +199,16 @@ export const cardRepository: CardRepository = {
const cardsWithNoteData: CardWithNoteData[] = [];
for (const card of dueCards) {
- if (!card.noteId) {
- cardsWithNoteData.push({
- ...card,
- note: null,
- fieldValues: [],
- });
- continue;
- }
-
const noteResult = await db
.select()
.from(notes)
.where(and(eq(notes.id, card.noteId), isNull(notes.deletedAt)));
- const note = noteResult[0] ?? null;
+ const note = noteResult[0];
+ if (!note) {
+ // Note was deleted, skip this card
+ continue;
+ }
const fieldValuesResult = await db
.select()
@@ -241,16 +235,6 @@ export const cardRepository: CardRepository = {
const cardsForStudy: CardForStudy[] = [];
for (const card of dueCards) {
- // Legacy card (no note association)
- if (!card.noteId) {
- cardsForStudy.push({
- ...card,
- noteType: null,
- fieldValuesMap: {},
- });
- continue;
- }
-
// Fetch note to get noteTypeId
const noteResult = await db
.select()
@@ -259,12 +243,7 @@ export const cardRepository: CardRepository = {
const note = noteResult[0];
if (!note) {
- // Note was deleted, treat as legacy card
- cardsForStudy.push({
- ...card,
- noteType: null,
- fieldValuesMap: {},
- });
+ // Note was deleted, skip this card
continue;
}
@@ -281,12 +260,7 @@ export const cardRepository: CardRepository = {
const noteType = noteTypeResult[0];
if (!noteType) {
- // Note type was deleted, treat as legacy card
- cardsForStudy.push({
- ...card,
- noteType: null,
- fieldValuesMap: {},
- });
+ // Note type was deleted, skip this card
continue;
}
diff --git a/src/server/repositories/note.test.ts b/src/server/repositories/note.test.ts
index 70dcb4a..cc1a9ae 100644
--- a/src/server/repositories/note.test.ts
+++ b/src/server/repositories/note.test.ts
@@ -40,8 +40,8 @@ function createMockCard(overrides: Partial<Card> = {}): Card {
return {
id: "card-uuid-123",
deckId: "deck-uuid-123",
- noteId: null,
- isReversed: null,
+ noteId: "note-uuid-123",
+ isReversed: false,
front: "Front text",
back: "Back text",
state: 0,
diff --git a/src/server/repositories/sync.ts b/src/server/repositories/sync.ts
index 8c4fd25..59a195a 100644
--- a/src/server/repositories/sync.ts
+++ b/src/server/repositories/sync.ts
@@ -45,8 +45,8 @@ export interface SyncDeckData {
export interface SyncCardData {
id: string;
deckId: string;
- noteId: string | null;
- isReversed: boolean | null;
+ noteId: string;
+ isReversed: boolean;
front: string;
back: string;
state: number;
diff --git a/src/server/repositories/types.ts b/src/server/repositories/types.ts
index c864be0..a986cad 100644
--- a/src/server/repositories/types.ts
+++ b/src/server/repositories/types.ts
@@ -81,8 +81,8 @@ export interface DeckRepository {
export interface Card {
id: string;
deckId: string;
- noteId: string | null;
- isReversed: boolean | null;
+ noteId: string;
+ isReversed: boolean;
front: string;
back: string;
@@ -104,22 +104,20 @@ export interface Card {
}
export interface CardWithNoteData extends Card {
- note: Note | null;
+ note: Note;
fieldValues: NoteFieldValue[];
}
/**
* Card data prepared for study, including all necessary template rendering info.
- * For note-based cards, includes templates and field values as a name-value map.
- * For legacy cards, note and templates are null.
*/
export interface CardForStudy extends Card {
- /** Note type templates for rendering (null for legacy cards) */
+ /** Note type templates for rendering */
noteType: {
frontTemplate: string;
backTemplate: string;
- } | null;
- /** Field values as a name-value map for template rendering (empty for legacy cards) */
+ };
+ /** Field values as a name-value map for template rendering */
fieldValuesMap: Record<string, string>;
}
diff --git a/src/server/routes/cards.test.ts b/src/server/routes/cards.test.ts
index 780ea44..66ba601 100644
--- a/src/server/routes/cards.test.ts
+++ b/src/server/routes/cards.test.ts
@@ -74,8 +74,8 @@ function createMockCard(overrides: Partial<Card> = {}): Card {
return {
id: "card-uuid-123",
deckId: "deck-uuid-123",
- noteId: null,
- isReversed: null,
+ noteId: "note-uuid-123",
+ isReversed: false,
front: "Question",
back: "Answer",
state: CardState.New,
@@ -110,7 +110,7 @@ function createMockCardWithNoteData(
): CardWithNoteData {
return {
...createMockCard(overrides),
- note: overrides.note ?? null,
+ note: overrides.note ?? createMockNote(),
fieldValues: overrides.fieldValues ?? [],
};
}
@@ -408,7 +408,6 @@ describe("GET /api/decks/:deckId/cards/:cardId", () => {
const mockCardWithNote = createMockCardWithNoteData({
id: CARD_ID,
deckId: DECK_ID,
- note: null,
fieldValues: [],
});
vi.mocked(mockDeckRepo.findById).mockResolvedValue(
diff --git a/src/server/routes/study.test.ts b/src/server/routes/study.test.ts
index 41abecd..e2fb457 100644
--- a/src/server/routes/study.test.ts
+++ b/src/server/routes/study.test.ts
@@ -80,8 +80,8 @@ function createMockCard(overrides: Partial<Card> = {}): Card {
return {
id: "card-uuid-123",
deckId: "deck-uuid-123",
- noteId: null,
- isReversed: null,
+ noteId: "note-uuid-123",
+ isReversed: false,
front: "Question",
back: "Answer",
state: CardState.New,
@@ -122,7 +122,10 @@ function createMockCardForStudy(
): CardForStudy {
return {
...createMockCard(overrides),
- noteType: overrides.noteType ?? null,
+ noteType: overrides.noteType ?? {
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ },
fieldValuesMap: overrides.fieldValuesMap ?? {},
};
}
@@ -187,20 +190,18 @@ describe("GET /api/decks/:deckId/study", () => {
);
});
- it("returns due cards (legacy cards without note)", async () => {
+ it("returns due cards", async () => {
const mockCards = [
createMockCardForStudy({
id: "card-1",
front: "Q1",
back: "A1",
- noteType: null,
fieldValuesMap: {},
}),
createMockCardForStudy({
id: "card-2",
front: "Q2",
back: "A2",
- noteType: null,
fieldValuesMap: {},
}),
];
@@ -217,7 +218,7 @@ describe("GET /api/decks/:deckId/study", () => {
expect(res.status).toBe(200);
const body = (await res.json()) as StudyResponse;
expect(body.cards).toHaveLength(2);
- expect(body.cards?.[0]?.noteType).toBeNull();
+ expect(body.cards?.[0]?.noteType).toBeDefined();
});
it("returns due cards with note type and field values when available", async () => {
diff --git a/src/server/routes/sync.test.ts b/src/server/routes/sync.test.ts
index 1107acd..f340af7 100644
--- a/src/server/routes/sync.test.ts
+++ b/src/server/routes/sync.test.ts
@@ -186,8 +186,8 @@ describe("POST /api/sync/push", () => {
const cardData = {
id: "550e8400-e29b-41d4-a716-446655440001",
deckId: "550e8400-e29b-41d4-a716-446655440000",
- noteId: null,
- isReversed: null,
+ noteId: "550e8400-e29b-41d4-a716-446655440020",
+ isReversed: false,
front: "Question",
back: "Answer",
state: 0,
@@ -435,8 +435,8 @@ describe("POST /api/sync/push", () => {
const cardData = {
id: "550e8400-e29b-41d4-a716-446655440005",
deckId: "550e8400-e29b-41d4-a716-446655440004",
- noteId: null,
- isReversed: null,
+ noteId: "550e8400-e29b-41d4-a716-446655440020",
+ isReversed: false,
front: "Q",
back: "A",
state: 0,
@@ -823,8 +823,8 @@ describe("GET /api/sync/pull", () => {
const mockCard: Card = {
id: "550e8400-e29b-41d4-a716-446655440001",
deckId: "550e8400-e29b-41d4-a716-446655440000",
- noteId: null,
- isReversed: null,
+ noteId: "550e8400-e29b-41d4-a716-446655440020",
+ isReversed: false,
front: "Question",
back: "Answer",
state: 2,
@@ -928,8 +928,8 @@ describe("GET /api/sync/pull", () => {
const mockCard: Card = {
id: "550e8400-e29b-41d4-a716-446655440001",
deckId: "550e8400-e29b-41d4-a716-446655440000",
- noteId: null,
- isReversed: null,
+ noteId: "550e8400-e29b-41d4-a716-446655440020",
+ isReversed: false,
front: "Q",
back: "A",
state: 0,
diff --git a/src/server/routes/sync.ts b/src/server/routes/sync.ts
index f05a7ba..fca099b 100644
--- a/src/server/routes/sync.ts
+++ b/src/server/routes/sync.ts
@@ -26,8 +26,8 @@ const syncDeckSchema = z.object({
const syncCardSchema = z.object({
id: z.uuid(),
deckId: z.uuid(),
- noteId: z.uuid().nullable(),
- isReversed: z.boolean().nullable(),
+ noteId: z.uuid(),
+ isReversed: z.boolean(),
front: z.string().min(1),
back: z.string().min(1),
state: z.number().int().min(0).max(3),