diff --git a/functions/lib/handle-error/handle-error.ts b/functions/lib/handle-error/handle-error.ts index 9cdbf317..af05b684 100644 --- a/functions/lib/handle-error/handle-error.ts +++ b/functions/lib/handle-error/handle-error.ts @@ -17,10 +17,11 @@ const reportErrorToTelegram = (error: unknown, env: unknown) => { message: (error as Error)?.message, name: (error as Error)?.name, stack: (error as Error)?.stack, - databaseException: error && error instanceof DatabaseException ? error.error : null, + databaseException: + error && error instanceof DatabaseException ? error.error : null, zodError: error && error instanceof ZodError ? error.issues : null, - error: JSON.stringify(error, null, 2) - } + error: JSON.stringify(error, null, 2), + }; const bot = new Bot(envSafe.data.BOT_ERROR_REPORTING_TOKEN); return bot.api diff --git a/functions/review-cards.ts b/functions/review-cards.ts index e54e9c72..5327258e 100644 --- a/functions/review-cards.ts +++ b/functions/review-cards.ts @@ -18,6 +18,7 @@ const requestSchema = z.object({ outcome: z.enum(["correct", "wrong"]), }), ), + isInterrupted: z.boolean().optional(), }); export type ReviewCardsRequest = z.infer; @@ -57,11 +58,18 @@ export const onRequestPost = handleError(async ({ env, request }) => { (review) => review.card_id === card.id, )?.interval; + const reviewResult = reviewCard( + now, + previousInterval, + card.outcome, + input.data.isInterrupted, + ); + return { user_id: user.id, card_id: card.id, last_review_date: now.toJSDate(), - interval: reviewCard(now, previousInterval, card.outcome).interval, + interval: reviewResult.interval, }; }), ) diff --git a/functions/services/review-card.test.ts b/functions/services/review-card.test.ts index 3663ac8c..44d67052 100644 --- a/functions/services/review-card.test.ts +++ b/functions/services/review-card.test.ts @@ -38,16 +38,35 @@ test("hit yes all the time", () => { ]); }); -test("forgetting resets interval", () => { +test("forgetting resets interval - non interrupted", () => { const date = DateTime.fromSQL("2021-05-20 10:00:00"); const { interval: newInterval1 } = reviewCard(date, undefined, "correct"); expect(newInterval1).toBe(1); const { interval: newInterval2 } = reviewCard(date, 1, "wrong"); - expect(newInterval2).toBe(0); + expect(newInterval2).toBe(0.4); const { interval: newInterval3 } = reviewCard(date, 0, "wrong"); + expect(newInterval3).toBe(0.4); + + const { interval: newInterval4 } = reviewCard(date, 0, "correct"); + expect(newInterval4).toBe(0.4); + + const { interval: newInterval5 } = reviewCard(date, 0.4, "correct"); + expect(newInterval5).toBe(1); +}); + +test("forgetting resets interval - interrupted", () => { + const date = DateTime.fromSQL("2021-05-20 10:00:00"); + + const { interval: newInterval1 } = reviewCard(date, undefined, "correct"); + expect(newInterval1).toBe(1); + + const { interval: newInterval2 } = reviewCard(date, 1, "wrong", true); + expect(newInterval2).toBe(0); + + const { interval: newInterval3 } = reviewCard(date, 0, "wrong", true); expect(newInterval3).toBe(0); const { interval: newInterval4 } = reviewCard(date, 0, "correct"); diff --git a/functions/services/review-card.ts b/functions/services/review-card.ts index eecae4d7..7d9ae6fc 100644 --- a/functions/services/review-card.ts +++ b/functions/services/review-card.ts @@ -16,6 +16,7 @@ export const reviewCard = ( now: DateTime, interval: number | undefined, reviewOutcome: ReviewOutcome, + isInterrupted = false, ): Result => { let calculatedInterval = interval === undefined ? startInterval : interval; @@ -26,7 +27,7 @@ export const reviewCard = ( calculatedInterval *= easeFactor; } } else if (reviewOutcome === "wrong") { - calculatedInterval = 0; + calculatedInterval = isInterrupted ? 0 : startInterval; } const nextReviewDate = now.plus({ day: calculatedInterval }); diff --git a/package-lock.json b/package-lock.json index 33835a1f..dde29811 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", "@vitejs/plugin-react": "^4.0.3", + "@vitest/coverage-v8": "^0.34.6", "eslint": "^8.45.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.3", @@ -420,6 +421,12 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true + }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.2.0.tgz", @@ -1143,6 +1150,15 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -1427,6 +1443,12 @@ "@types/chai": "*" } }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true + }, "node_modules/@types/json-schema": { "version": "7.0.13", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.13.tgz", @@ -1707,6 +1729,43 @@ "vite": "^4.2.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-0.34.6.tgz", + "integrity": "sha512-fivy/OK2d/EsJFoEoxHFEnNGTg+MmdZBAVK9Ka4qhXR2K3J0DS08vcGVwzDtXSuUMabLv4KtPcpSKkcMXFDViw==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.1", + "@bcoe/v8-coverage": "^0.2.3", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^4.0.1", + "istanbul-reports": "^3.1.5", + "magic-string": "^0.30.1", + "picocolors": "^1.0.0", + "std-env": "^3.3.3", + "test-exclude": "^6.0.0", + "v8-to-istanbul": "^9.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": ">=0.32.0 <1" + } + }, + "node_modules/@vitest/coverage-v8/node_modules/magic-string": { + "version": "0.30.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", + "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@vitest/expect": { "version": "0.34.6", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.34.6.tgz", @@ -3047,6 +3106,12 @@ "node": ">=4" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, "node_modules/ignore": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", @@ -3174,6 +3239,77 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz", + "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3355,6 +3491,21 @@ "sourcemap-codec": "^1.4.8" } }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4317,6 +4468,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -4525,6 +4690,20 @@ "node": ">=6.14.2" } }, + "node_modules/v8-to-istanbul": { + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.3.tgz", + "integrity": "sha512-9lDD+EVI2fjFsMWXc6dy5JJzBsVTcQ2fVkfBvncZ6xJWG9wtBhOldG+mHkSL0+V1K/xgZz0JDO5UT5hFwHUghg==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/vite": { "version": "4.4.9", "resolved": "https://registry.npmjs.org/vite/-/vite-4.4.9.tgz", diff --git a/package.json b/package.json index 710d5393..39ca30f9 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "dev:api:start": "npx wrangler pages dev /functions --compatibility-date=2023-09-22", "dev:tunnel": "../ngrok http --domain=causal-magpie-closing.ngrok-free.app 5173", "test:api": "npx vitest --dir functions/", + "test:api:coverage:": "npx vitest run --dir functions/ --coverage", "test:frontend": "npx vitest --dir src/", + "test:frontend:coverage": "npx vitest run --dir src/ --coverage", "prod:api:logs": "npx wrangler pages deployment tail", "prod:deploy": "npx wrangler pages publish functions --project-name=memo-card" }, @@ -45,6 +47,7 @@ "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", "@vitejs/plugin-react": "^4.0.3", + "@vitest/coverage-v8": "^0.34.6", "eslint": "^8.45.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.3", diff --git a/src/store/deck-form-store.ts b/src/store/deck-form-store.ts index b410eac5..9d90bb01 100644 --- a/src/store/deck-form-store.ts +++ b/src/store/deck-form-store.ts @@ -81,7 +81,7 @@ export class DeckFormStore { } openNewCardForm() { - assert(this.form, 'openNewCardForm: form is empty'); + assert(this.form, "openNewCardForm: form is empty"); this.cardFormIndex = this.form.cards.length; this.form.cards.push({ front: createCardSideField(""), @@ -101,7 +101,7 @@ export class DeckFormStore { } async onCardBack() { - assert(this.cardForm, 'onCardBack: cardForm is empty'); + assert(this.cardForm, "onCardBack: cardForm is empty"); if (isFormEmpty(this.cardForm)) { this.quitCardForm(); return; @@ -114,7 +114,7 @@ export class DeckFormStore { } async onDeckBack() { - assert(this.form, 'onDeckBack: form is empty'); + assert(this.form, "onDeckBack: form is empty"); if (isFormEmpty(this.form) || !isFormTouched(this.form)) { screenStore.navigateToMain(); return; @@ -127,7 +127,7 @@ export class DeckFormStore { } onDeckSave() { - assert(this.form, 'onDeckSave: form is empty'); + assert(this.form, "onDeckSave: form is empty"); if (this.form.cards.length === 0) { showAlert("Please add at least 1 card to create a deck"); @@ -161,8 +161,11 @@ export class DeckFormStore { } quitCardForm() { - assert(this.cardFormIndex !== undefined, 'quitCardForm: cardFormIndex is empty'); - assert(this.form, 'quitCardForm: form is empty'); + assert( + this.cardFormIndex !== undefined, + "quitCardForm: cardFormIndex is empty", + ); + assert(this.form, "quitCardForm: form is empty"); this.form.cards.splice(this.cardFormIndex, 1); this.cardFormIndex = undefined; } diff --git a/src/store/quick-add-card-form-store.ts b/src/store/quick-add-card-form-store.ts index f7e4336b..057fed83 100644 --- a/src/store/quick-add-card-form-store.ts +++ b/src/store/quick-add-card-form-store.ts @@ -27,7 +27,10 @@ export class QuickAddCardFormStore { return; } - assert(screenStore.cardQuickAddDeckId, "cardQuickAddDeckId should not be empty"); + assert( + screenStore.cardQuickAddDeckId, + "cardQuickAddDeckId should not be empty", + ); this.isSending = true; diff --git a/src/store/review-store.ts b/src/store/review-store.ts index 990f6cdc..77d57a6e 100644 --- a/src/store/review-store.ts +++ b/src/store/review-store.ts @@ -5,6 +5,7 @@ import { assert } from "../lib/typescript/assert.ts"; import { reviewCardsRequest } from "../api/api.ts"; import { ReviewOutcome } from "../../functions/services/review-card.ts"; import { screenStore } from "./screen-store.ts"; +import { deckListStore } from "./deck-list-store.ts"; type ReviewResult = { forgotIds: number[]; @@ -70,7 +71,10 @@ export class ReviewStore { changeState(cardState: CardState) { const currentCard = this.currentCard; - assert(currentCard, "currentCard should not be null while changing state in review"); + assert( + currentCard, + "currentCard should not be null while changing state in review", + ); currentCard.changeState(cardState); const currentCardIdx = this.cardsToReview.findIndex( @@ -116,7 +120,7 @@ export class ReviewStore { return; } - return reviewCardsRequest({ cards: this.cardsToSend }); + return reviewCardsRequest({ cards: this.cardsToSend, isInterrupted: true }); } get cardsToSend(): Array<{ id: number; outcome: ReviewOutcome }> { @@ -142,6 +146,7 @@ export class ReviewStore { return reviewCardsRequest({ cards: this.cardsToSend }).finally( action(() => { + deckListStore.load(); this.isReviewSending = false; }), ); diff --git a/vite.config.ts b/vite.config.ts index 19f0713b..eaafd340 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,5 +12,11 @@ export default defineConfig({ rewrite: (path) => path.replace(/^\/api/, ''), }, } - } + }, + // @ts-expect-error + test: { + coverage: { + reporter: ['text'], + }, + }, })