├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── deploy.yml │ └── publish.yml ├── .gitignore ├── .idx └── dev.nix ├── .npmignore ├── .nvmrc ├── .prettierrc.json ├── .vscode ├── launch.json └── setting.json ├── LICENSE ├── README.md ├── README_CN.md ├── README_JA.md ├── __tests__ ├── FSRS-5.test.ts ├── FSRS-6.test.ts ├── alea.test.ts ├── algorithm.test.ts ├── constant.test.ts ├── default.test.ts ├── elapsed_days.test.ts ├── fixed │ ├── calc-elapsed-days.test.ts │ └── same-seed.test.ts ├── forget.test.ts ├── handler.test.ts ├── help.test.ts ├── impl │ ├── abstract_scheduler.test.ts │ ├── basic_scheduler.test.ts │ └── long-term_scheduler.test.ts ├── models.test.ts ├── reschedule.test.ts ├── rollback.test.ts ├── show_diff_message.test.ts ├── strategies │ ├── learning-steps.test.ts │ └── seed.test.ts └── version.test.ts ├── debug ├── index.ts ├── long-term.ts └── short-term.ts ├── eslint.config.mjs ├── example ├── example.html ├── example.jsx └── exampleComponent.jsx ├── jest.config.js ├── jsr.json ├── package.json ├── pnpm-lock.yaml ├── rollup.config.ts ├── src └── fsrs │ ├── abstract_scheduler.ts │ ├── alea.ts │ ├── algorithm.ts │ ├── constant.ts │ ├── convert.ts │ ├── default.ts │ ├── fsrs.ts │ ├── help.ts │ ├── impl │ ├── basic_scheduler.ts │ └── long_term_scheduler.ts │ ├── index.ts │ ├── models.ts │ ├── reschedule.ts │ ├── strategies │ ├── index.ts │ ├── learning_steps.ts │ ├── seed.ts │ └── types.ts │ └── types.ts ├── ts-fsrs-workflow.drawio ├── tsconfig.json └── typedoc.ts /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: ishiko732 # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: weekly 12 | groups: 13 | dependencies: 14 | dependency-type: production 15 | devDependencies: 16 | dependency-type: development 17 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | concurrency: 4 | group: ci-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | 9 | pull_request: 10 | branches: 11 | - main 12 | - master 13 | - dev 14 | paths-ignore: 15 | - ".devcontainer/**" 16 | # - '.github/**' 17 | - ".vscode/**" 18 | - ".gitignore" 19 | - ".npmignore" 20 | - "LICENSE" 21 | - "README.md" 22 | push: 23 | paths-ignore: 24 | - ".devcontainer/**" 25 | # - '.github/**' 26 | - ".vscode/**" 27 | - ".gitignore" 28 | - ".npmignore" 29 | - "LICENSE" 30 | - "README.md" 31 | branches: 32 | - main 33 | - master 34 | - dev 35 | 36 | permissions: 37 | contents: read # to fetch code (actions/checkout) 38 | 39 | jobs: 40 | build: 41 | strategy: 42 | matrix: 43 | node: [18] 44 | platform: [ubuntu-latest, macos-latest, windows-latest] 45 | name: "${{matrix.platform}} / Node.js ${{ matrix.node }}" 46 | runs-on: ${{matrix.platform}} 47 | 48 | steps: 49 | - name: Checkout 50 | uses: actions/checkout@v4 51 | 52 | - name: Install pnpm 53 | uses: pnpm/action-setup@v4 54 | with: 55 | version: 9.12.3 56 | run_install: false 57 | 58 | - name: Install Node.js 59 | uses: actions/setup-node@v4 60 | with: 61 | node-version: ${{ matrix.node }} 62 | cache: 'pnpm' 63 | 64 | - name: Get pnpm store directory 65 | shell: bash 66 | run: | 67 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 68 | 69 | - uses: actions/cache@v4 70 | name: Setup pnpm cache 71 | with: 72 | path: ${{ env.STORE_PATH }} 73 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 74 | restore-keys: | 75 | ${{ runner.os }}-pnpm-store- 76 | 77 | - name: Install dependencies 78 | run: pnpm install --no-frozen-lockfile 79 | 80 | - name: Lint 81 | run: pnpm run lint 82 | 83 | - name: Format 84 | run: pnpm prettier -c src/ --end-of-line auto 85 | 86 | - name: Run tests and collect coverage 87 | run: pnpm run test::coverage 88 | 89 | - name: Upload coverage reports to Codecov 90 | uses: codecov/codecov-action@v4 91 | with: 92 | token: ${{ secrets.CODECOV_TOKEN }} 93 | fail_ci_if_error: true 94 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | pages: write 11 | id-token: write 12 | 13 | jobs: 14 | build-and-deploy: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | 21 | - uses: pnpm/action-setup@v4 22 | name: Install pnpm 23 | with: 24 | version: 9.12.3 25 | run_install: false 26 | 27 | - name: Install Node.js 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: 18 31 | cache: 'pnpm' 32 | 33 | - name: Install dependencies 34 | run: pnpm install 35 | 36 | - name: Build docs 37 | run: pnpm run docs 38 | - name: Copy example folder 39 | run: cp -R example/* docs/ 40 | 41 | - name: Deploy to GitHub Pages 42 | uses: JamesIves/github-pages-deploy-action@4.1.1 43 | with: 44 | branch: gh-pages 45 | folder: docs -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages 3 | 4 | name: Publish Package 5 | 6 | on: 7 | push: 8 | tags: 9 | - 'v*.*.*' 10 | 11 | jobs: 12 | publish-npm: 13 | runs-on: ubuntu-latest 14 | environment: production 15 | permissions: 16 | contents: write 17 | packages: write # allow GITHUB_TOKEN to publish packages 18 | id-token: write 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | 23 | - name: Install pnpm 24 | uses: pnpm/action-setup@v4 25 | with: 26 | version: 9.5.0 27 | run_install: false 28 | 29 | - name: Install Node.js 30 | uses: actions/setup-node@v4 31 | with: 32 | node-version: 18 33 | cache: 'pnpm' 34 | 35 | - name: Get pnpm store directory 36 | shell: bash 37 | run: | 38 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 39 | 40 | - uses: actions/cache@v4 41 | name: Setup pnpm cache 42 | with: 43 | path: ${{ env.STORE_PATH }} 44 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 45 | restore-keys: | 46 | ${{ runner.os }}-pnpm-store- 47 | 48 | - name: Install dependencies 49 | run: pnpm install --no-frozen-lockfile 50 | 51 | - name: Lint 52 | run: pnpm run lint 53 | 54 | - name: Run tests and collect coverage 55 | run: pnpm run test::coverage 56 | 57 | - name: Upload coverage reports to Codecov 58 | uses: codecov/codecov-action@v4 59 | with: 60 | token: ${{ secrets.CODECOV_TOKEN }} 61 | fail_ci_if_error: true 62 | 63 | - name: Process tag 64 | id: tag 65 | # ref: https://github.com/Leaflet/Leaflet/blob/c4a1e362bfd7f7709efdaff94f154e100a706129/.github/workflows/main.yml#L192-L214 66 | run: | 67 | TAG=$(echo $GITHUB_REF_NAME | grep -oP '^v\d+\.\d+\.\d+-?\K(\w+)?') 68 | TAG="${TAG:-latest}" # Default to 'latest' if TAG is empty 69 | echo $TAG 70 | echo "TAG=$TAG" >> $GITHUB_OUTPUT 71 | 72 | - name: Publish Package to NPM 73 | uses: JS-DevTools/npm-publish@v3 74 | with: 75 | token: ${{secrets.npm_token}} 76 | provenance: true 77 | tag: ${{ steps.tag.outputs.TAG }} 78 | 79 | publish-jsr: 80 | runs-on: ubuntu-latest 81 | environment: production 82 | permissions: 83 | contents: read 84 | id-token: write 85 | 86 | steps: 87 | - uses: actions/checkout@v4 88 | 89 | - name: Publish package 90 | run: npx jsr publish -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /lib/ 3 | .idea 4 | example/.env.local 5 | 6 | /coverage/ 7 | /docs/ 8 | 9 | /dist -------------------------------------------------------------------------------- /.idx/dev.nix: -------------------------------------------------------------------------------- 1 | # To learn more about how to use Nix to configure your environment 2 | # see: https://developers.google.com/idx/guides/customize-idx-env 3 | { pkgs, ... }: { 4 | # Which nixpkgs channel to use. 5 | channel = "stable-24.11"; # or "unstable" 6 | 7 | # Use https://search.nixos.org/packages to find packages 8 | packages = [ 9 | # pkgs.go 10 | # pkgs.python311 11 | # pkgs.python311Packages.pip 12 | # pkgs.nodejs_20 13 | # pkgs.nodePackages.nodemon 14 | pkgs.pnpm 15 | pkgs.nodejs_18 16 | pkgs.fish 17 | ]; 18 | 19 | # Sets environment variables in the workspace 20 | env = {}; 21 | idx = { 22 | # Search for the extensions you want on https://open-vsx.org/ and use "publisher.id" 23 | extensions = [ 24 | # "vscodevim.vim" 25 | "dbaeumer.vscode-eslint" 26 | "esbenp.prettier-vscode" 27 | "yoavbls.pretty-ts-errors" 28 | ]; 29 | 30 | # Enable previews 31 | previews = { 32 | enable = true; 33 | previews = { 34 | # web = { 35 | # # Example: run "npm run dev" with PORT set to IDX's defined port for previews, 36 | # # and show it in IDX's web preview panel 37 | # command = ["npm" "run" "dev"]; 38 | # manager = "web"; 39 | # env = { 40 | # # Environment variables to set for your server 41 | # PORT = "$PORT"; 42 | # }; 43 | # }; 44 | }; 45 | }; 46 | 47 | # Workspace lifecycle hooks 48 | workspace = { 49 | # Runs when a workspace is first created 50 | onCreate = { 51 | # Example: install JS dependencies from NPM 52 | # npm-install = "npm install"; 53 | "pnpm-install" = "pnpm i"; 54 | }; 55 | # Runs when the workspace is (re)started 56 | onStart = { 57 | # Example: start a background task to watch and re-build backend code 58 | # watch-backend = "npm run watch-backend"; 59 | }; 60 | }; 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | .gitgnore 3 | test 4 | .eslintrc.json 5 | jest.config.js 6 | .prettierrc.json -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.20.3 -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true 6 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "ts-fsrs debug", 8 | "skipFiles": [ 9 | "/**", 10 | "${workspaceFolder}/node_modules/**" 11 | ], 12 | "env": { 13 | // "DEBUG": "*", 14 | }, 15 | "outputCapture": "std", 16 | "runtimeExecutable": "tsx", 17 | "restart": true, 18 | "console": "integratedTerminal", 19 | "cwd": "${workspaceFolder}", 20 | "args": ["watch","${workspaceFolder}/debug/index.ts"] 21 | }, 22 | { 23 | "type": "node", 24 | "request": "attach", 25 | "name": "Node: Nodemon", 26 | "processId": "${command:PickProcess}", 27 | "restart": true, 28 | "port": 9229, 29 | "skipFiles": [ 30 | // Node.js internal core modules 31 | "/**", 32 | 33 | // Ignore all dependencies (optional) 34 | "${workspaceFolder}/node_modules/**" 35 | ] 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /.vscode/setting.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.preferences.importModuleSpecifier": "non-relative", 3 | "typescript.preferences.quoteStyle": "single", 4 | "typescript.preferGoToSourceDefinition": true, 5 | "typescript.tsdk": "./node_modules/typescript/lib" 6 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Open Spaced Repetition 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [Introduction](./README.md) | [简体中文](./README_CN.md) |[はじめに](./README_JA.md) 2 | 3 | --- 4 | 5 | # About 6 | [![fsrs version](https://img.shields.io/badge/FSRS-v6-blue?style=flat-square)](https://github.com/open-spaced-repetition/fsrs4anki/wiki/The-Algorithm#fsrs-6) 7 | [![ts-fsrs npm version](https://img.shields.io/npm/v/ts-fsrs.svg?style=flat-square&logo=npm)](https://www.npmjs.com/package/ts-fsrs) 8 | [![Downloads](https://img.shields.io/npm/dm/ts-fsrs?style=flat-square)](https://www.npmjs.com/package/ts-fsrs) 9 | [![codecov](https://img.shields.io/codecov/c/github/open-spaced-repetition/ts-fsrs?token=E3KLLDL8QH&style=flat-square&logo=codecov 10 | )](https://codecov.io/gh/open-spaced-repetition/ts-fsrs) 11 | [![Publish](https://img.shields.io/github/actions/workflow/status/open-spaced-repetition/ts-fsrs/publish.yml?style=flat-square&logo=githubactions&label=Publish 12 | )](https://github.com/open-spaced-repetition/ts-fsrs/actions/workflows/publish.yml) 13 | [![Deploy](https://img.shields.io/github/actions/workflow/status/open-spaced-repetition/ts-fsrs/deploy.yml?style=flat-square&logo=githubpages&label=Pages 14 | )](https://github.com/open-spaced-repetition/ts-fsrs/actions/workflows/deploy.yml) 15 | 16 | ts-fsrs is a versatile package written in TypeScript that supports [ES modules](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c), [CommonJS](https://en.wikipedia.org/wiki/CommonJS), and UMD. It implements the [Free Spaced Repetition Scheduler (FSRS) algorithm](https://github.com/open-spaced-repetition/free-spaced-repetition-scheduler), enabling developers to integrate FSRS into their flashcard applications to enhance the user learning experience. 17 | 18 | You can find the state transition diagram for cards here: 19 | > - google drive: [ts-fsrs-workflow.drawio](https://drive.google.com/file/d/1FLKjpt4T3Iis02vjoA10q7vxKCWwClfR/view?usp=sharing) (You're free to leave comments) 20 | > - github: [ts-fsrs-workflow.drawio](./ts-fsrs-workflow.drawio) 21 | 22 | 23 | # Usage 24 | `ts-fsrs@3.x` requires Node.js version `16.0.0` or higher. Starting with `ts-fsrs@4.x`, the minimum required Node.js version is `18.0.0`. 25 | From version `3.5.6` onwards, ts-fsrs supports CommonJS, ESM, and UMD module systems. 26 | 27 | ``` 28 | npm install ts-fsrs # npm install github:open-spaced-repetition/ts-fsrs 29 | yarn add ts-fsrs 30 | pnpm install ts-fsrs # pnpm install github:open-spaced-repetition/ts-fsrs 31 | bun add ts-fsrs 32 | ``` 33 | 34 | # Example 35 | 36 | ```typescript 37 | import {createEmptyCard, formatDate, fsrs, generatorParameters, Rating, Grades} from 'ts-fsrs'; 38 | 39 | const params = generatorParameters({ enable_fuzz: true, enable_short_term: false }); 40 | const f = fsrs(params); 41 | const card = createEmptyCard(new Date('2022-2-1 10:00:00'));// createEmptyCard(); 42 | const now = new Date('2022-2-2 10:00:00');// new Date(); 43 | const scheduling_cards = f.repeat(card, now); 44 | 45 | for (const item of scheduling_cards) { 46 | // grades = [Rating.Again, Rating.Hard, Rating.Good, Rating.Easy] 47 | const grade = item.log.rating 48 | const { log, card } = item; 49 | console.group(`${Rating[grade]}`); 50 | console.table({ 51 | [`card_${Rating[grade]}`]: { 52 | ...card, 53 | due: formatDate(card.due), 54 | last_review: formatDate(card.last_review as Date), 55 | }, 56 | }); 57 | console.table({ 58 | [`log_${Rating[grade]}`]: { 59 | ...log, 60 | review: formatDate(log.review), 61 | }, 62 | }); 63 | console.groupEnd(); 64 | console.log('----------------------------------------------------------------'); 65 | } 66 | ``` 67 | 68 | More resources: 69 | - [Docs - Github Pages](https://open-spaced-repetition.github.io/ts-fsrs/) 70 | - [Example.html - Github Pages](https://open-spaced-repetition.github.io/ts-fsrs/example) 71 | - [Browser](https://github.com/open-spaced-repetition/ts-fsrs/blob/main/example/example.html) (ts-fsrs package using CDN) 72 | - [ts-fsrs-demo - Next.js+Hono.js+kysely](https://github.com/ishiko732/ts-fsrs-demo) 73 | - [spaced - Next.js+Drizzle+tRPC](https://github.com/zsh-eng/spaced) 74 | 75 | # Basic Use 76 | 77 | ## 1. **Initialization**: 78 | To begin, create an empty card instance and set the current date (default: current time from the system): 79 | 80 | ```typescript 81 | import { Card, createEmptyCard } from "ts-fsrs"; 82 | let card: Card = createEmptyCard(); 83 | // createEmptyCard(new Date('2022-2-1 10:00:00')); 84 | // createEmptyCard(new Date(Date.UTC(2023, 9, 18, 14, 32, 3, 370))); 85 | // createEmptyCard(new Date('2023-09-18T14:32:03.370Z')); 86 | ``` 87 | 88 | ## 2. **Parameter Configuration**: 89 | The library has multiple modifiable "SRS parameters" (settings, besides the weight/parameter values). Use `generatorParameters` to set these parameters for the SRS algorithm. Here's an example for setting a maximum interval: 90 | 91 | ```typescript 92 | import { Card, createEmptyCard, generatorParameters, FSRSParameters } from "ts-fsrs"; 93 | let card: Card = createEmptyCard(); 94 | const params: FSRSParameters = generatorParameters({ maximum_interval: 1000 }); 95 | ``` 96 | 97 | ## 3. **Scheduling with FSRS**: 98 | The core functionality lies in the `repeat` function of the `fsrs` class. When invoked, it returns a set of cards scheduled based on different potential user ratings: 99 | 100 | ```typescript 101 | import { 102 | Card, 103 | createEmptyCard, 104 | generatorParameters, 105 | FSRSParameters, 106 | FSRS, 107 | RecordLog, 108 | } from "ts-fsrs"; 109 | 110 | let card: Card = createEmptyCard(); 111 | const f: FSRS = new FSRS(); // or const f: FSRS = fsrs(params); 112 | let scheduling_cards: RecordLog = f.repeat(card, new Date()); 113 | // if you want to specify the grade, you can use the following code: (ts-fsrs >=4.0.0) 114 | // let scheduling_card: RecordLog = f.next(card, new Date(), Rating.Good); 115 | ``` 116 | 117 | ## 4. **Retrieving Scheduled Cards**: 118 | Once you have the `scheduling_cards` object, you can retrieve cards based on user ratings. For instance, to access the card scheduled for a 'Good' rating: 119 | 120 | ```typescript 121 | const good: RecordLogItem = scheduling_cards[Rating.Good]; 122 | const newCard: Card = good.card; 123 | ``` 124 | 125 | Get the new state of card for each rating: 126 | ```typescript 127 | scheduling_cards[Rating.Again].card 128 | scheduling_cards[Rating.Again].log 129 | 130 | scheduling_cards[Rating.Hard].card 131 | scheduling_cards[Rating.Hard].log 132 | 133 | scheduling_cards[Rating.Good].card 134 | scheduling_cards[Rating.Good].log 135 | 136 | scheduling_cards[Rating.Easy].card 137 | scheduling_cards[Rating.Easy].log 138 | ``` 139 | 140 | ## 5. **Understanding Card Attributes**: 141 | Each `Card` object consists of various attributes that determine its status, scheduling, and other metrics: 142 | 143 | ```typescript 144 | type Card = { 145 | due: Date; // Date when the card is next due for review 146 | stability: number; // A measure of how well the information is retained 147 | difficulty: number; // Reflects the inherent difficulty of the card content 148 | elapsed_days: number; // Days since the card was last reviewed 149 | scheduled_days: number;// The interval of time in days between this review and the next one 150 | learning_steps: number;// Keeps track of the current step during the (re)learning stages 151 | reps: number; // Total number of times the card has been reviewed 152 | lapses: number; // Times the card was forgotten or remembered incorrectly 153 | state: State; // The current state of the card (New, Learning, Review, Relearning) 154 | last_review?: Date; // The most recent review date, if applicable 155 | }; 156 | ``` 157 | 158 | ## 6. **Understanding Log Attributes**: 159 | Each `ReviewLog` object contains various attributes that represent a review that was done on a card. Used for analysis, undoing the review, and [optimization (WIP)](https://github.com/open-spaced-repetition/fsrs-rs-nodejs). 160 | 161 | ```typescript 162 | type ReviewLog = { 163 | rating: Rating; // Rating of the review (Again, Hard, Good, Easy) 164 | state: State; // State of the review (New, Learning, Review, Relearning) 165 | due: Date; // Date of the last scheduling 166 | stability: number; // Stability of the card before the review 167 | difficulty: number; // Difficulty of the card before the review 168 | elapsed_days: number; // Number of days elapsed since the last review 169 | last_elapsed_days: number; // Number of days between the last two reviews 170 | scheduled_days: number; // Number of days until the next review 171 | learning_steps: number; // Keeps track of the current step during the (re)learning stages 172 | review: Date; // Date of the review 173 | } 174 | ``` 175 | -------------------------------------------------------------------------------- /README_CN.md: -------------------------------------------------------------------------------- 1 | [Introduction](./README.md) | [简体中文](./README_CN.md) |[はじめに](./README_JA.md) 2 | 3 | --- 4 | 5 | # 关于 6 | 7 | [![fsrs version](https://img.shields.io/badge/FSRS-v6-blue?style=flat-square)](https://github.com/open-spaced-repetition/fsrs4anki/wiki/The-Algorithm#fsrs-6) 8 | [![ts-fsrs npm version](https://img.shields.io/npm/v/ts-fsrs.svg?style=flat-square&logo=npm)](https://www.npmjs.com/package/ts-fsrs) 9 | [![Downloads](https://img.shields.io/npm/dm/ts-fsrs?style=flat-square)](https://www.npmjs.com/package/ts-fsrs) 10 | [![codecov](https://img.shields.io/codecov/c/github/open-spaced-repetition/ts-fsrs?token=E3KLLDL8QH&style=flat-square&logo=codecov 11 | )](https://codecov.io/gh/open-spaced-repetition/ts-fsrs) 12 | [![Publish](https://img.shields.io/github/actions/workflow/status/open-spaced-repetition/ts-fsrs/publish.yml?style=flat-square&logo=githubactions&label=Publish 13 | )](https://github.com/open-spaced-repetition/ts-fsrs/actions/workflows/publish.yml) 14 | [![Deploy](https://img.shields.io/github/actions/workflow/status/open-spaced-repetition/ts-fsrs/deploy.yml?style=flat-square&logo=githubpages&label=Pages 15 | )](https://github.com/open-spaced-repetition/ts-fsrs/actions/workflows/deploy.yml) 16 | 17 | ts-fsrs是一个基于TypeScript的多功能包,支持[ES模块](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c)、[CommonJS](https://en.wikipedia.org/wiki/CommonJS)和UMD。它实现了[自由间隔重复调度器(FSRS)算法](https://github.com/open-spaced-repetition/free-spaced-repetition-scheduler/blob/main/README_CN.md),使开发人员能够将FSRS集成到他们的闪卡应用程序中,从而增强用户的学习体验。 18 | 19 | > 你可以通过[ts-fsrs-workflow.drawio](./ts-fsrs-workflow.drawio)来获取ts-fsrs的工作流信息。 20 | 21 | # 使用ts-fsrs 22 | 23 | `ts-fsrs@3.x`需要运行在 Node.js (>=16.0.0)上,`ts-fsrs@4.x`需要运行在 Node.js (>=18.0.0)上。 24 | 从`ts-fsrs@3.5.6`开始,ts-fsrs支持CommonJS、ESM和UMD模块系统 25 | 26 | ``` 27 | npm install ts-fsrs # npm install github:open-spaced-repetition/ts-fsrs 28 | yarn add ts-fsrs 29 | pnpm install ts-fsrs # pnpm install github:open-spaced-repetition/ts-fsrs 30 | bun add ts-fsrs 31 | ``` 32 | 33 | # 例子 34 | 35 | ```typescript 36 | import {createEmptyCard, formatDate, fsrs, generatorParameters, Rating, Grades} from 'ts-fsrs'; 37 | 38 | const params = generatorParameters({ enable_fuzz: true, enable_short_term: false }); 39 | const f = fsrs(params); 40 | const card = createEmptyCard(new Date('2022-2-1 10:00:00'));// createEmptyCard(); 41 | const now = new Date('2022-2-2 10:00:00');// new Date(); 42 | const scheduling_cards = f.repeat(card, now); 43 | 44 | // console.log(scheduling_cards); 45 | for (const item of scheduling_cards) { 46 | // grades = [Rating.Again, Rating.Hard, Rating.Good, Rating.Easy] 47 | const grade = item.log.rating 48 | const { log, card } = item; 49 | console.group(`${Rating[grade]}`); 50 | console.table({ 51 | [`card_${Rating[grade]}`]: { 52 | ...card, 53 | due: formatDate(card.due), 54 | last_review: formatDate(card.last_review as Date), 55 | }, 56 | }); 57 | console.table({ 58 | [`log_${Rating[grade]}`]: { 59 | ...log, 60 | review: formatDate(log.review), 61 | }, 62 | }); 63 | console.groupEnd(); 64 | console.log('----------------------------------------------------------------'); 65 | } 66 | ``` 67 | 68 | 更多的参考: 69 | 70 | - [参考文档- Github Pages](https://open-spaced-repetition.github.io/ts-fsrs/) 71 | - [参考调度 - Github Pages](https://open-spaced-repetition.github.io/ts-fsrs/example) 72 | - [浏览器使用](https://github.com/open-spaced-repetition/ts-fsrs/blob/main/example/example.html) (使用CDN来访问ts-fsrs ESM包) 73 | - [案例应用 - 基于Next.js+Hono.js+kysely](https://github.com/ishiko732/ts-fsrs-demo) 74 | - [现代化抽成卡 - Next.js+Drizzle+tRPC](https://github.com/zsh-eng/spaced) 75 | 76 | # 基本使用方法 77 | 78 | ## 1. **初始化**: 79 | 80 | 首先,创建一个空的卡片实例并设置当前日期(默认为当前系统时间): 81 | 82 | ```typescript 83 | import {Card, createEmptyCard} from "ts-fsrs"; 84 | 85 | let card: Card = createEmptyCard(); 86 | // createEmptyCard(new Date('2022-2-1 10:00:00')); 87 | // createEmptyCard(new Date(Date.UTC(2023, 9, 18, 14, 32, 3, 370))); 88 | // createEmptyCard(new Date('2023-09-18T14:32:03.370Z')); 89 | ``` 90 | 91 | ## 2. **FSRS参数配置**: 92 | 93 | 该ts-fsrs库允许自定义SRS参数。使用`generatorParameters`来生成SRS算法的最终参数集。以下是设置最大间隔的示例: 94 | 95 | ```typescript 96 | import {Card, createEmptyCard, generatorParameters, FSRSParameters} from "ts-fsrs"; 97 | 98 | let card: Card = createEmptyCard(); 99 | const params: FSRSParameters = generatorParameters({maximum_interval: 1000}); 100 | ``` 101 | 102 | ## 3. **使用FSRS进行调度**: 103 | 104 | 核心功能位于`fsrs`函数中。当调用`repeat`该函数时,它会根据不同的用户评级返回一个卡片集合的调度结果: 105 | 106 | ```typescript 107 | import { 108 | Card, 109 | createEmptyCard, 110 | generatorParameters, 111 | FSRSParameters, 112 | FSRS, 113 | RecordLog, 114 | } from "ts-fsrs"; 115 | 116 | let card: Card = createEmptyCard(); 117 | const f: FSRS = new FSRS(); // or const f: FSRS = fsrs(params); 118 | let scheduling_cards: RecordLog = f.repeat(card, new Date()); 119 | // 如果你想要指定一个特定的评级,你可以这样做:(ts-fsrs版本必须 >= 4.0.0) 120 | // let scheduling_cards: RecordLog = f.next(card, new Date(), Rating.Good); 121 | ``` 122 | 123 | ## 4. **检查调度卡片信息**: 124 | 125 | 一旦你有了`scheduling_cards`对象,你可以根据用户评级来获取卡片。例如,要访问一个被安排在“`Good`”评级下的卡片: 126 | 127 | ```typescript 128 | const good: RecordLogItem = scheduling_cards[Rating.Good]; 129 | const newCard: Card = good.card; 130 | ``` 131 | 132 | 当然,你可以获取每个评级下卡片的新状态和对应的历史记录: 133 | 134 | ```typescript 135 | scheduling_cards[Rating.Again].card 136 | scheduling_cards[Rating.Again].log 137 | 138 | scheduling_cards[Rating.Hard].card 139 | scheduling_cards[Rating.Hard].log 140 | 141 | scheduling_cards[Rating.Good].card 142 | scheduling_cards[Rating.Good].log 143 | 144 | scheduling_cards[Rating.Easy].card 145 | scheduling_cards[Rating.Easy].log 146 | ``` 147 | 148 | ## 5. **理解卡片属性**: 149 | 150 | 每个`Card`对象都包含各种属性,这些属性决定了它的状态、调度和其他指标(DS): 151 | 152 | ```typescript 153 | type Card = { 154 | due: Date; // 卡片下次复习的日期 155 | stability: number; // 记忆稳定性 156 | difficulty: number; // 卡片难度 157 | elapsed_days: number; // 自上次复习以来的天数 158 | scheduled_days: number;// 下次复习的间隔天数 159 | learning_steps: number;// 当前的(重新)学习步骤 160 | reps: number; // 卡片被复习的总次数 161 | lapses: number; // 卡片被遗忘或错误记忆的次数 162 | state: State; // 卡片的当前状态(新卡片、学习中、复习中、重新学习中) 163 | last_review?: Date; // 最近的复习日期(如果适用) 164 | }; 165 | ``` 166 | 167 | ## 6. **理解复习记录属性**: 168 | 169 | 每个`ReviewLog` 170 | 对象都包含各种属性,这些属性决定了与之关联的卡片的复习记录信息,用于分析,回退本次复习,[优化(编写中)](https://github.com/open-spaced-repetition/fsrs-rs-nodejs): 171 | 172 | ```typescript 173 | type ReviewLog = { 174 | rating: Rating; // 复习的评级(手动变更,重来,困难,良好,容易) 175 | state: State; // 复习的状态(新卡片、学习中、复习中、重新学习中) 176 | due: Date; // 上次的调度日期 177 | stability: number; // 复习前的记忆稳定性 178 | difficulty: number; // 复习前的卡片难度 179 | elapsed_days: number; // 自上次复习以来的天数 180 | last_elapsed_days: number; // 上次复习的间隔天数 181 | scheduled_days: number; // 下次复习的间隔天数 182 | learning_steps: number;// 复习前的(重新)学习步骤 183 | review: Date; // 复习的日期 184 | } 185 | ``` -------------------------------------------------------------------------------- /README_JA.md: -------------------------------------------------------------------------------- 1 | [Introduction](./README.md) | [简体中文](./README_CN.md) |[はじめに](./README_JA.md) 2 | 3 | --- 4 | 5 | # について 6 | 7 | [![fsrs version](https://img.shields.io/badge/FSRS-v6-blue?style=flat-square)](https://github.com/open-spaced-repetition/fsrs4anki/wiki/The-Algorithm#fsrs-6) 8 | [![ts-fsrs npm version](https://img.shields.io/npm/v/ts-fsrs.svg?style=flat-square&logo=npm)](https://www.npmjs.com/package/ts-fsrs) 9 | [![Downloads](https://img.shields.io/npm/dm/ts-fsrs?style=flat-square)](https://www.npmjs.com/package/ts-fsrs) 10 | [![codecov](https://img.shields.io/codecov/c/github/open-spaced-repetition/ts-fsrs?token=E3KLLDL8QH&style=flat-square&logo=codecov 11 | )](https://codecov.io/gh/open-spaced-repetition/ts-fsrs) 12 | [![Publish](https://img.shields.io/github/actions/workflow/status/open-spaced-repetition/ts-fsrs/publish.yml?style=flat-square&logo=githubactions&label=Publish 13 | )](https://github.com/open-spaced-repetition/ts-fsrs/actions/workflows/publish.yml) 14 | [![Deploy](https://img.shields.io/github/actions/workflow/status/open-spaced-repetition/ts-fsrs/deploy.yml?style=flat-square&logo=githubpages&label=Pages 15 | )](https://github.com/open-spaced-repetition/ts-fsrs/actions/workflows/deploy.yml) 16 | 17 | ts-fsrsはTypeScriptに基づいた多機能なパッケージで、[ESモジュール]((https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c))、[CommonJS](https://en.wikipedia.org/wiki/CommonJS)、UMDに対応しています。[自由間隔重複スケジューラ(FSRS)アルゴリズム](https://github.com/open-spaced-repetition/free-spaced-repetition-scheduler) を実装しており、開発者がFSRSをフラッシュカードアプリケーションに統合することで、ユーザーの学習体験を向上させることができます。 18 | 19 | ts-fsrsのワークフローについては、以下のリソースを参照してください。 20 | > - google drive: [ts-fsrs-workflow.drawio](https://drive.google.com/file/d/1FLKjpt4T3Iis02vjoA10q7vxKCWwClfR/view?usp=sharing) (コメントを提供できます) 21 | > - github: [ts-fsrs-workflow.drawio](./ts-fsrs-workflow.drawio) 22 | 23 | 24 | # ts-fsrsの使用方法 25 | 26 | `ts-fsrs@3.x`はNode.js(>=16.0.0)で動作する必要があります。`ts-fsrs@4.x`からは、最小必要なNode.jsバージョンは18.0.0です。 27 | `ts-fsrs@3.5.6`以降、ts-fsrsはCommonJS、ESM、UMDモジュールシステムをサポートしています。 28 | 29 | ``` 30 | npm install ts-fsrs # npm install github:open-spaced-repetition/ts-fsrs 31 | yarn add ts-fsrs 32 | pnpm install ts-fsrs # pnpm install github:open-spaced-repetition/ts-fsrs 33 | bun add ts-fsrs 34 | ``` 35 | 36 | # 例 37 | 38 | ```typescript 39 | import {createEmptyCard, formatDate, fsrs, generatorParameters, Rating, Grades} from 'ts-fsrs'; 40 | 41 | const params = generatorParameters({ enable_fuzz: true, enable_short_term: false }); 42 | const f = fsrs(params); 43 | const card = createEmptyCard(new Date('2022-2-1 10:00:00'));// createEmptyCard(); 44 | const now = new Date('2022-2-2 10:00:00');// new Date(); 45 | const scheduling_cards = f.repeat(card, now); 46 | 47 | // console.log(scheduling_cards); 48 | for (const item of scheduling_cards) { 49 | // grades = [Rating.Again, Rating.Hard, Rating.Good, Rating.Easy] 50 | const grade = item.log.rating 51 | const { log, card } = item; 52 | console.group(`${Rating[grade]}`); 53 | console.table({ 54 | [`card_${Rating[grade]}`]: { 55 | ...card, 56 | due: formatDate(card.due), 57 | last_review: formatDate(card.last_review as Date), 58 | }, 59 | }); 60 | console.table({ 61 | [`log_${Rating[grade]}`]: { 62 | ...log, 63 | review: formatDate(log.review), 64 | }, 65 | }); 66 | console.groupEnd(); 67 | console.log('----------------------------------------------------------------'); 68 | } 69 | ``` 70 | 71 | もっと: 72 | 73 | - [参考資料- Github Pages](https://open-spaced-repetition.github.io/ts-fsrs/) 74 | - [参考スケジューラ - Github Pages](https://open-spaced-repetition.github.io/ts-fsrs/example) 75 | - [ブラウザで使い方](https://github.com/open-spaced-repetition/ts-fsrs/blob/main/example/example.html) (CDNを使用して ts-fsrs ESM 76 | パッケージにアクセスする) 77 | - [実際のケース - Next.jsやHono.js、kyselyを利用する](https://github.com/ishiko732/ts-fsrs-demo) 78 | - [モダンなフラッシュカード - Next.jsやtRPCなど技術を利用している](https://github.com/zsh-eng/spaced) 79 | 80 | # 基本的な使い方 81 | 82 | ## 1. **初期化**: 83 | 84 | まずは、空ぽっいカードインスタンスを作成して、現在の日付を設定します(デフォルトはシステムの現在時刻): 85 | 86 | ```typescript 87 | import {Card, createEmptyCard} from "ts-fsrs"; 88 | 89 | let card: Card = createEmptyCard(); 90 | // createEmptyCard(new Date('2022-2-1 10:00:00')); 91 | // createEmptyCard(new Date(Date.UTC(2023, 9, 18, 14, 32, 3, 370))); 92 | // createEmptyCard(new Date('2023-09-18T14:32:03.370Z')); 93 | ``` 94 | 95 | ## 2. **FSRSのパラメータ設定**: 96 | 97 | このts-fsrsライブラリは、カスタムSRSパラメータを許可します。`generatorParameters` 98 | を使用して、SRSアルゴリズムの最終パラメータセットを生成します。以下は、最大間隔を設定する例です: 99 | 100 | ```typescript 101 | import {Card, createEmptyCard, generatorParameters, FSRSParameters} from "ts-fsrs"; 102 | 103 | let card: Card = createEmptyCard(); 104 | const params: FSRSParameters = generatorParameters({maximum_interval: 1000}); 105 | ``` 106 | 107 | ## 3. **FSRSを使いしてスケジューリングする**: 108 | 109 | 核心機能は「`fsrs`」関数にあります。この`repeat`関数を呼び出すと、異なるユーザー評価に基づいて、カードセットのスケジュール結果が返されます。 110 | 111 | ```typescript 112 | import { 113 | Card, 114 | createEmptyCard, 115 | generatorParameters, 116 | FSRSParameters, 117 | FSRS, 118 | RecordLog, 119 | } from "ts-fsrs"; 120 | 121 | let card: Card = createEmptyCard(); 122 | const f: FSRS = new FSRS(); // or const f: FSRS = fsrs(params); 123 | let scheduling_cards: RecordLog = f.repeat(card, new Date()); 124 | // もしくは、開発者が評価を指定する場合:(TS-FSRSのバージョンは4.0.0以降である必要があります) 125 | // let scheduling_cards: RecordLog = f.repeat(card, new Date(), Rating.Good); 126 | ``` 127 | 128 | ## 4. **スケジュールされたカードの取得**: 129 | 130 | scheduling_cardsオブジェクトがあると、ユーザーの評価に基づいてカードを取得できます。例えば、`Good`評価でスケジュールされたカードにアクセスするには: 131 | 132 | ```typescript 133 | const good: RecordLogItem = scheduling_cards[Rating.Good]; 134 | const newCard: Card = good.card; 135 | ``` 136 | 137 | もちろん、各評価に対応するカードの新しい状態と履歴を取得できます: 138 | 139 | ```typescript 140 | scheduling_cards[Rating.Again].card 141 | scheduling_cards[Rating.Again].log 142 | 143 | scheduling_cards[Rating.Hard].card 144 | scheduling_cards[Rating.Hard].log 145 | 146 | scheduling_cards[Rating.Good].card 147 | scheduling_cards[Rating.Good].log 148 | 149 | scheduling_cards[Rating.Easy].card 150 | scheduling_cards[Rating.Easy].log 151 | ``` 152 | 153 | ## 5. **カード属性の理解**: 154 | 155 | それぞれの`Card`オブジェクトは、その状態、スケジュール、その他の指標を決定するさまざまな属性を含んでいます: 156 | 157 | ```typescript 158 | type Card = { 159 | due: Date; // カードの次のレビュー日 160 | stability: number; // 記憶の安定性 161 | difficulty: number; // カードの難易度 162 | elapsed_days: number; // 前回のレビューからの日数 163 | scheduled_days: number;// 次のレビューの間隔日数 164 | learning_steps: number;// 現在の(再)学習ステップ 165 | reps: number; // カードのレビュー回数 166 | lapses: number; // カードが忘れられたか、間違って覚えられた回数 167 | state: State; // カードの現在の状態(新しいカード、学習中、レビュー中、再学習中) 168 | last_review?: Date; // 最近のレビュー日(適用される場合) 169 | }; 170 | ``` 171 | 172 | ## 6. **レビュー履歴属性の理解**: 173 | 174 | それぞれの`ReviewLog` 175 | オブジェクトは、そのカードに関連するレビュー記録情報を決定するさまざまな属性を含んでいます。分析、今回のレビューをやり直す、[最適化(作成中)](https://github.com/open-spaced-repetition/fsrs-rs-nodejs): 176 | 177 | ```typescript 178 | type ReviewLog = { 179 | rating: Rating; // レビューの評価(手動変更、やり直し、難しい、良い、簡単) 180 | state: State; // レビューの状態(新しいカード、学習中、レビュー中、再学習中) 181 | due: Date; // レビューの次の日付 182 | stability: number; // レビュー前の記憶の安定性 183 | difficulty: number; // レビュー前のカードの難易度 184 | elapsed_days: number; // 前回のレビューからの日数 185 | last_elapsed_days: number; // 前回のレビューの間隔日数 186 | scheduled_days: number; // 次のレビューの間隔日数 187 | learning_steps: number;// 前回の(再)学習ステップ 188 | review: Date; // レビュー日 189 | } 190 | ``` -------------------------------------------------------------------------------- /__tests__/FSRS-5.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | fsrs, 3 | Rating, 4 | FSRS, 5 | createEmptyCard, 6 | State, 7 | Grade, 8 | Grades, 9 | } from '../src/fsrs' 10 | 11 | describe('FSRS-5', () => { 12 | const w = [ 13 | 0.40255, 1.18385, 3.173, 15.69105, 7.1949, 0.5345, 1.4604, 0.0046, 1.54575, 14 | 0.1192, 1.01925, 1.9395, 0.11, 0.29605, 2.2698, 0.2315, 2.9898, 0.51655, 15 | 0.6621, 16 | ] 17 | const f: FSRS = fsrs({ w }) 18 | it('ivl_history', () => { 19 | let card = createEmptyCard() 20 | let now = new Date(2022, 11, 29, 12, 30, 0, 0) 21 | let scheduling_cards = f.repeat(card, now) 22 | const ratings: Grade[] = [ 23 | Rating.Good, 24 | Rating.Good, 25 | Rating.Good, 26 | Rating.Good, 27 | Rating.Good, 28 | Rating.Good, 29 | Rating.Again, 30 | Rating.Again, 31 | Rating.Good, 32 | Rating.Good, 33 | Rating.Good, 34 | Rating.Good, 35 | Rating.Good, 36 | ] 37 | const ivl_history: number[] = [] 38 | for (const rating of ratings) { 39 | for (const check of Grades) { 40 | const rollbackCard = f.rollback( 41 | scheduling_cards[check].card, 42 | scheduling_cards[check].log 43 | ) 44 | expect(rollbackCard).toEqual(card) 45 | expect(scheduling_cards[check].log.elapsed_days).toEqual( 46 | card.last_review ? now.diff(card.last_review as Date, 'days') : 0 47 | ) 48 | const _f = fsrs({ w }) 49 | const next = _f.next(card, now, check) 50 | expect(scheduling_cards[check]).toEqual(next) 51 | } 52 | card = scheduling_cards[rating].card 53 | const ivl = card.scheduled_days 54 | ivl_history.push(ivl) 55 | now = card.due 56 | scheduling_cards = f.repeat(card, now) 57 | } 58 | expect(ivl_history).toEqual([ 59 | 0, 4, 14, 44, 125, 328, 0, 0, 7, 16, 34, 71, 142, 60 | ]) 61 | }) 62 | 63 | it('memory state', () => { 64 | let card = createEmptyCard() 65 | let now = new Date(2022, 11, 29, 12, 30, 0, 0) 66 | let scheduling_cards = f.repeat(card, now) 67 | const ratings: Grade[] = [ 68 | Rating.Again, 69 | Rating.Good, 70 | Rating.Good, 71 | Rating.Good, 72 | Rating.Good, 73 | Rating.Good, 74 | ] 75 | const intervals: number[] = [0, 0, 1, 3, 8, 21] 76 | for (const [index, rating] of ratings.entries()) { 77 | card = scheduling_cards[rating].card 78 | now = new Date(now.getTime() + intervals[index] * 24 * 60 * 60 * 1000) 79 | scheduling_cards = f.repeat(card, now) 80 | } 81 | 82 | const { stability, difficulty } = scheduling_cards[Rating.Good].card 83 | expect(stability).toBeCloseTo(48.4848, 4) 84 | expect(difficulty).toBeCloseTo(7.0866, 4) 85 | }) 86 | 87 | it('first repeat', () => { 88 | const card = createEmptyCard() 89 | const now = new Date(2022, 11, 29, 12, 30, 0, 0) 90 | const scheduling_cards = f.repeat(card, now) 91 | 92 | const stability: number[] = [] 93 | const difficulty: number[] = [] 94 | const elapsed_days: number[] = [] 95 | const scheduled_days: number[] = [] 96 | const reps: number[] = [] 97 | const lapses: number[] = [] 98 | const states: State[] = [] 99 | for (const item of scheduling_cards) { 100 | const first_card = item.card 101 | stability.push(first_card.stability) 102 | difficulty.push(first_card.difficulty) 103 | reps.push(first_card.reps) 104 | lapses.push(first_card.lapses) 105 | elapsed_days.push(first_card.elapsed_days) 106 | scheduled_days.push(first_card.scheduled_days) 107 | states.push(first_card.state) 108 | } 109 | expect(stability).toEqual([0.40255, 1.18385, 3.173, 15.69105]) 110 | expect(difficulty).toEqual([7.1949, 6.48830527, 5.28243442, 3.22450159]) 111 | expect(reps).toEqual([1, 1, 1, 1]) 112 | expect(lapses).toEqual([0, 0, 0, 0]) 113 | expect(elapsed_days).toEqual([0, 0, 0, 0]) 114 | expect(scheduled_days).toEqual([0, 0, 0, 16]) 115 | expect(states).toEqual([ 116 | State.Learning, 117 | State.Learning, 118 | State.Learning, 119 | State.Review, 120 | ]) 121 | }) 122 | }) 123 | 124 | describe('get retrievability', () => { 125 | const fsrs = new FSRS({}) 126 | test('return 0.00% for new cards', () => { 127 | const card = createEmptyCard() 128 | const now = new Date() 129 | const expected = '0.00%' 130 | expect(fsrs.get_retrievability(card, now)).toBe(expected) 131 | }) 132 | 133 | test('return retrievability percentage for review cards', () => { 134 | const card = createEmptyCard('2023-12-01 04:00:00') 135 | const sc = fsrs.repeat(card, '2023-12-01 04:05:00') 136 | const r = ['100.00%', '100.00%', '100.00%', '90.07%'] 137 | const r_number = [1, 1, 1, 0.90068938] 138 | Grades.forEach((grade, index) => { 139 | expect(fsrs.get_retrievability(sc[grade].card, sc[grade].card.due)).toBe( 140 | r[index] 141 | ) 142 | expect( 143 | fsrs.get_retrievability(sc[grade].card, sc[grade].card.due, false) 144 | ).toBe(r_number[index]) 145 | }) 146 | }) 147 | 148 | test('fake the current system time', () => { 149 | const card = createEmptyCard('2023-12-01 04:00:00') 150 | const sc = fsrs.repeat(card, '2023-12-01 04:05:00') 151 | const r = ['100.00%', '100.00%', '100.00%', '90.07%'] 152 | const r_number = [1, 1, 1, 0.90068938] 153 | jest.useFakeTimers() 154 | Grades.forEach((grade, index) => { 155 | jest.setSystemTime(sc[grade].card.due) 156 | expect(fsrs.get_retrievability(sc[grade].card)).toBe(r[index]) 157 | expect(fsrs.get_retrievability(sc[grade].card, undefined, false)).toBe( 158 | r_number[index] 159 | ) 160 | }) 161 | jest.useRealTimers() 162 | }) 163 | 164 | test('loop Again', () => { 165 | const fsrs = new FSRS({}) 166 | let card = createEmptyCard() 167 | let now = new Date() 168 | let i = 0 169 | while (i < 5) { 170 | card = fsrs.next(card, now, Rating.Again).card 171 | now = card.due 172 | i++ 173 | 174 | const r = fsrs.get_retrievability(card, now, false) 175 | console.debug(`Loop ${i}: s:${card.stability} r:${r} `) 176 | 177 | expect(r).not.toBeNaN() 178 | } 179 | }) 180 | }) 181 | 182 | describe('fsrs.next method', () => { 183 | const fsrs = new FSRS({}) 184 | test('invalid grade', () => { 185 | const card = createEmptyCard() 186 | const now = new Date() 187 | const g = Rating.Manual as unknown as Grade 188 | expect(() => fsrs.next(card, now, g)).toThrow( 189 | 'Cannot review a manual rating' 190 | ) 191 | }) 192 | }) 193 | -------------------------------------------------------------------------------- /__tests__/FSRS-6.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | fsrs, 3 | Rating, 4 | FSRS, 5 | createEmptyCard, 6 | State, 7 | Grade, 8 | Grades, 9 | FSRSState, 10 | } from '../src/fsrs' 11 | 12 | describe('FSRS-6 ', () => { 13 | const w = [ 14 | 0.2172, 1.1771, 3.2602, 16.1507, 7.0114, 0.57, 2.0966, 0.0069, 1.5261, 15 | 0.112, 1.0178, 1.849, 0.1133, 0.3127, 2.2934, 0.2191, 3.0004, 0.7536, 16 | 0.3332, 0.1437, 0.2, 17 | ] 18 | const f: FSRS = fsrs({ w }) 19 | it('ivl_history', () => { 20 | let card = createEmptyCard() 21 | let now = new Date(2022, 11, 29, 12, 30, 0, 0) 22 | let scheduling_cards = f.repeat(card, now) 23 | const ratings: Grade[] = [ 24 | Rating.Good, 25 | Rating.Good, 26 | Rating.Good, 27 | Rating.Good, 28 | Rating.Good, 29 | Rating.Good, 30 | Rating.Again, 31 | Rating.Again, 32 | Rating.Good, 33 | Rating.Good, 34 | Rating.Good, 35 | Rating.Good, 36 | Rating.Good, 37 | ] 38 | const ivl_history: number[] = [] 39 | for (const rating of ratings) { 40 | for (const check of Grades) { 41 | const rollbackCard = f.rollback( 42 | scheduling_cards[check].card, 43 | scheduling_cards[check].log 44 | ) 45 | expect(rollbackCard).toEqual(card) 46 | expect(scheduling_cards[check].log.elapsed_days).toEqual( 47 | card.last_review ? now.diff(card.last_review as Date, 'days') : 0 48 | ) 49 | const _f = fsrs({ w }) 50 | const next = _f.next(card, now, check) 51 | expect(scheduling_cards[check]).toEqual(next) 52 | } 53 | card = scheduling_cards[rating].card 54 | const ivl = card.scheduled_days 55 | ivl_history.push(ivl) 56 | now = card.due 57 | scheduling_cards = f.repeat(card, now) 58 | } 59 | expect(ivl_history).toEqual([ 60 | 0, 4, 14, 45, 135, 372, 0, 0, 2, 5, 10, 20, 40, 61 | ]) 62 | }) 63 | 64 | describe('memory state', () => { 65 | const ratings: Grade[] = [ 66 | Rating.Again, 67 | Rating.Good, 68 | Rating.Good, 69 | Rating.Good, 70 | Rating.Good, 71 | Rating.Good, 72 | ] 73 | const intervals: number[] = [0, 0, 1, 3, 8, 21] 74 | function assertMemoryState( 75 | f: FSRS, 76 | text: string, 77 | expect_stability: number, 78 | expect_difficulty: number 79 | ) { 80 | let card = createEmptyCard() 81 | let now = new Date(2022, 11, 29, 12, 30, 0, 0) 82 | 83 | for (const [index, rating] of ratings.entries()) { 84 | now = new Date(+now + intervals[index] * 24 * 60 * 60 * 1000) 85 | card = f.next(card, now, rating).card 86 | console.debug(text, index + 1, card.stability, card.difficulty) 87 | } 88 | 89 | const { stability, difficulty } = card 90 | expect(stability).toBeCloseTo(expect_stability, 4) 91 | expect(difficulty).toBeCloseTo(expect_difficulty, 4) 92 | } 93 | it('memory state[short-term]', () => { 94 | const f: FSRS = fsrs({ w, enable_short_term: true }) 95 | assertMemoryState(f, 'short-term', 49.4472, 6.8573) 96 | }) 97 | 98 | it('memory state[long-term]', () => { 99 | const f: FSRS = fsrs({ w, enable_short_term: false }) 100 | assertMemoryState(f, 'long-term', 48.6015, 6.8573) 101 | }) 102 | 103 | it('memory state using next_state[short-term]', () => { 104 | const f: FSRS = fsrs({ w, enable_short_term: true }) 105 | let state: FSRSState | null = null 106 | for (const [index, rating] of ratings.entries()) { 107 | state = f.next_state(state, intervals[index], rating) 108 | } 109 | 110 | const { stability, difficulty } = state! 111 | expect(stability).toBeCloseTo(49.4472, 4) 112 | expect(difficulty).toBeCloseTo(6.8573, 4) 113 | }) 114 | 115 | it('memory state using next_state[long-term]', () => { 116 | const f: FSRS = fsrs({ w, enable_short_term: false }) 117 | let state: FSRSState | null = null 118 | for (const [index, rating] of ratings.entries()) { 119 | state = f.next_state(state, intervals[index], rating) 120 | } 121 | 122 | const { stability, difficulty } = state! 123 | expect(stability).toBeCloseTo(48.6015, 4) 124 | expect(difficulty).toBeCloseTo(6.8573, 4) 125 | }) 126 | }) 127 | 128 | it('first repeat', () => { 129 | const card = createEmptyCard() 130 | const now = new Date(2022, 11, 29, 12, 30, 0, 0) 131 | const scheduling_cards = f.repeat(card, now) 132 | 133 | const stability: number[] = [] 134 | const difficulty: number[] = [] 135 | const elapsed_days: number[] = [] 136 | const scheduled_days: number[] = [] 137 | const reps: number[] = [] 138 | const lapses: number[] = [] 139 | const states: State[] = [] 140 | for (const item of scheduling_cards) { 141 | const first_card = item.card 142 | stability.push(first_card.stability) 143 | difficulty.push(first_card.difficulty) 144 | reps.push(first_card.reps) 145 | lapses.push(first_card.lapses) 146 | elapsed_days.push(first_card.elapsed_days) 147 | scheduled_days.push(first_card.scheduled_days) 148 | states.push(first_card.state) 149 | } 150 | expect(stability).toEqual([0.2172, 1.1771, 3.2602, 16.1507]) 151 | expect(difficulty).toEqual([7.0114, 6.24313295, 4.88463163, 2.48243852]) 152 | expect(reps).toEqual([1, 1, 1, 1]) 153 | expect(lapses).toEqual([0, 0, 0, 0]) 154 | expect(elapsed_days).toEqual([0, 0, 0, 0]) 155 | expect(scheduled_days).toEqual([0, 0, 0, 16]) 156 | expect(states).toEqual([ 157 | State.Learning, 158 | State.Learning, 159 | State.Learning, 160 | State.Review, 161 | ]) 162 | }) 163 | }) 164 | 165 | describe('get retrievability', () => { 166 | const fsrs = new FSRS({}) 167 | test('return 0.00% for new cards', () => { 168 | const card = createEmptyCard() 169 | const now = new Date() 170 | const expected = '0.00%' 171 | expect(fsrs.get_retrievability(card, now)).toBe(expected) 172 | }) 173 | 174 | test('return retrievability percentage for review cards', () => { 175 | const card = createEmptyCard('2023-12-01 04:00:00') 176 | const sc = fsrs.repeat(card, '2023-12-01 04:05:00') 177 | const r = ['100.00%', '100.00%', '100.00%', '90.07%'] 178 | const r_number = [1, 1, 1, 0.90068938] 179 | Grades.forEach((grade, index) => { 180 | expect(fsrs.get_retrievability(sc[grade].card, sc[grade].card.due)).toBe( 181 | r[index] 182 | ) 183 | expect( 184 | fsrs.get_retrievability(sc[grade].card, sc[grade].card.due, false) 185 | ).toBe(r_number[index]) 186 | }) 187 | }) 188 | 189 | test('fake the current system time', () => { 190 | const card = createEmptyCard('2023-12-01 04:00:00') 191 | const sc = fsrs.repeat(card, '2023-12-01 04:05:00') 192 | const r = ['100.00%', '100.00%', '100.00%', '90.07%'] 193 | const r_number = [1, 1, 1, 0.90068938] 194 | jest.useFakeTimers() 195 | Grades.forEach((grade, index) => { 196 | jest.setSystemTime(sc[grade].card.due) 197 | expect(fsrs.get_retrievability(sc[grade].card)).toBe(r[index]) 198 | expect(fsrs.get_retrievability(sc[grade].card, undefined, false)).toBe( 199 | r_number[index] 200 | ) 201 | }) 202 | jest.useRealTimers() 203 | }) 204 | 205 | test('loop Again', () => { 206 | const fsrs = new FSRS({}) 207 | let card = createEmptyCard() 208 | let now = new Date() 209 | let i = 0 210 | while (i < 5) { 211 | card = fsrs.next(card, now, Rating.Again).card 212 | now = card.due 213 | i++ 214 | 215 | const r = fsrs.get_retrievability(card, now, false) 216 | console.debug(`Loop ${i}: s:${card.stability} r:${r} `) 217 | 218 | expect(r).not.toBeNaN() 219 | } 220 | }) 221 | }) 222 | -------------------------------------------------------------------------------- /__tests__/alea.test.ts: -------------------------------------------------------------------------------- 1 | // Import the Alea generator and additional required elements 2 | import { alea } from '../src/fsrs/alea' // Adjust the import path according to your project structure 3 | 4 | describe('Alea PRNG Tests', () => { 5 | it('make sure two seeded values are the same', () => { 6 | const prng1 = alea(1) 7 | const prng2 = alea(3) 8 | const prng3 = alea(1) 9 | 10 | const a = prng1.state() 11 | const b = prng2.state() 12 | const c = prng3.state() 13 | 14 | expect(a).toEqual(c) 15 | expect(a).not.toEqual(b) 16 | }) 17 | 18 | it('Known values test', () => { 19 | const seed = 12345 20 | const generator = alea(seed) 21 | const results = Array.from({ length: 3 }, () => generator()) 22 | expect(results).toEqual([ 23 | 0.27138191112317145, 0.19615925149992108, 0.6810678059700876, 24 | ]) 25 | }) 26 | 27 | it('should generate an int32', () => { 28 | const generator = alea('int32test') 29 | const int32 = generator.int32() 30 | expect(int32).toBeLessThanOrEqual(0xffffffff) 31 | expect(int32).toBeGreaterThanOrEqual(0) 32 | }) 33 | 34 | it('Uint32 test', () => { 35 | const seed = 12345 36 | const generator = alea(seed) 37 | const results = Array.from({ length: 3 }, () => generator.int32()) 38 | expect(results).toEqual([1165576433, 842497570, -1369803343]) 39 | }) 40 | 41 | it('should generate a double', () => { 42 | const generator = alea('doubletest') 43 | const double = generator.double() 44 | expect(double).toBeGreaterThanOrEqual(0) 45 | expect(double).toBeLessThan(1) 46 | }) 47 | 48 | it('Fract53 test', () => { 49 | const seed = 12345 50 | const generator = alea(seed) 51 | const results = Array.from({ length: 3 }, () => generator.double()) 52 | expect(results).toEqual([ 53 | 0.27138191116884325, 0.6810678062004586, 0.3407802057882554, 54 | ]) 55 | }) 56 | 57 | it('Import with Alea.importState()', () => { 58 | const prng1 = alea(Math.random()) 59 | 60 | // generate a few numbers 61 | prng1() 62 | prng1() 63 | prng1() 64 | 65 | const e = prng1.state() 66 | 67 | const prng4 = alea().importState(e) 68 | expect(prng4.state()).toEqual(prng1.state()) 69 | for (let i = 0; i < 10000; i++) { 70 | const a = prng1() 71 | const b = prng4() 72 | expect(a).toEqual(b) 73 | expect(a).toBeGreaterThanOrEqual(0) 74 | expect(a).toBeLessThan(1) 75 | expect(b).toBeLessThan(1) 76 | } 77 | }) 78 | it('should have reproducible state', () => { 79 | const generator = alea('statetest') 80 | const state1 = generator.state() 81 | const next1 = generator() 82 | const state2 = generator.state() 83 | const next2 = generator() 84 | 85 | expect(state1.s0).not.toEqual(state2.s0) 86 | expect(state1.s1).not.toEqual(state2.s1) 87 | expect(state1.s2).not.toEqual(state2.s2) 88 | expect(next1).not.toEqual(next2) 89 | }) 90 | 91 | it('s2<0', () => { 92 | const seed = 12345 93 | const generator = alea(seed).importState({ 94 | c: 0, 95 | s0: 0, 96 | s1: 0, 97 | s2: -0.5, 98 | }) 99 | const results = generator() 100 | const state = generator.state() 101 | expect(results).toEqual(0) 102 | expect(state).toEqual({ 103 | c: 0, 104 | s0: 0, 105 | s1: -0.5, 106 | s2: 0, 107 | }) 108 | }) 109 | 110 | it('seed 1727015666066', () => { 111 | const seed = '1727015666066' // constructor s0 = -0.4111432870849967 +1 112 | const generator = alea(seed) 113 | const results = generator() 114 | const state = generator.state() 115 | expect(results).toEqual(0.6320083506871015) 116 | expect(state).toEqual({ 117 | c: 1828249, 118 | s0: 0.5888567129150033, 119 | s1: 0.5074866858776659, 120 | s2: 0.6320083506871015, 121 | }) 122 | }) 123 | 124 | it('seed Seedp5fxh9kf4r0', () => { 125 | const seed = 'Seedp5fxh9kf4r0' // constructor s1 = -0.3221628828905523 +1 126 | const generator = alea(seed) 127 | const results = generator() 128 | const state = generator.state() 129 | expect(results).toEqual(0.14867847645655274) 130 | expect(state).toEqual({ 131 | c: 1776946, 132 | s0: 0.6778371171094477, 133 | s1: 0.0770602801349014, 134 | s2: 0.14867847645655274, 135 | }) 136 | }) 137 | 138 | it('seed NegativeS2Seed', () => { 139 | const seed = 'NegativeS2Seed' // constructor s2 = -0.07867425470612943 +1 140 | const generator = alea(seed) 141 | const results = generator() 142 | const state = generator.state() 143 | expect(results).toEqual(0.830770346801728) 144 | expect(state).toEqual({ 145 | c: 952982, 146 | s0: 0.25224833423271775, 147 | s1: 0.9213257452938706, 148 | s2: 0.830770346801728, 149 | }) 150 | }) 151 | }) 152 | -------------------------------------------------------------------------------- /__tests__/constant.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CLAMP_PARAMETERS, 3 | default_enable_fuzz, 4 | default_maximum_interval, 5 | default_request_retention, 6 | default_w, 7 | FSRS6_DEFAULT_DECAY, 8 | generatorParameters, 9 | W17_W18_Ceiling, 10 | } from '../src/fsrs' 11 | 12 | describe('default params', () => { 13 | const expected_w = [ 14 | 0.2172, 1.1771, 3.2602, 16.1507, 7.0114, 0.57, 2.0966, 0.0069, 1.5261, 15 | 0.112, 1.0178, 1.849, 0.1133, 0.3127, 2.2934, 0.2191, 3.0004, 0.7536, 16 | 0.3332, 0.1437, 0.2, 17 | ] 18 | expect(default_request_retention).toEqual(0.9) 19 | expect(default_maximum_interval).toEqual(36500) 20 | expect(default_enable_fuzz).toEqual(false) 21 | expect(default_w.length).toBe(expected_w.length) 22 | expect(default_w).toEqual(expected_w) 23 | 24 | const params = generatorParameters() 25 | it('default_request_retention', () => { 26 | expect(params.request_retention).toEqual(default_request_retention) 27 | }) 28 | it('default_maximum_interval', () => { 29 | expect(params.maximum_interval).toEqual(default_maximum_interval) 30 | }) 31 | it('default_w ', () => { 32 | expect(params.w).toEqual(expected_w) 33 | }) 34 | it('default_enable_fuzz ', () => { 35 | expect(params.enable_fuzz).toEqual(default_enable_fuzz) 36 | }) 37 | 38 | it('clamp w to limit the minimum', () => { 39 | const w = Array.from({ length: 21 }, () => 0) 40 | const params = generatorParameters({ w }) 41 | const w_min = CLAMP_PARAMETERS(W17_W18_Ceiling).map((x) => x[0]) 42 | expect(params.w).toEqual(w_min) 43 | }) 44 | 45 | it('clamp w to limit the maximum', () => { 46 | const w = Array.from({ length: 21 }, () => Number.MAX_VALUE) 47 | const params = generatorParameters({ w }) 48 | const w_max = CLAMP_PARAMETERS(W17_W18_Ceiling).map((x) => x[1]) 49 | expect(params.w).toEqual(w_max) 50 | }) 51 | 52 | it('default w can not be overwritten', () => { 53 | expect(() => { 54 | // @ts-expect-error test modify 55 | default_w[4] = 0.5 56 | }).toThrow() 57 | }) 58 | 59 | it('CLAMP_PARAMETERS can not be overwritten', () => { 60 | const clamp_parameters1 = (CLAMP_PARAMETERS(FSRS6_DEFAULT_DECAY)[4] = [ 61 | 0.5, 0.5, 62 | ]) 63 | const clamp_parameters2 = CLAMP_PARAMETERS(FSRS6_DEFAULT_DECAY) 64 | expect(clamp_parameters1[4]).not.toEqual(clamp_parameters2) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /__tests__/default.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | checkParameters, 3 | CLAMP_PARAMETERS, 4 | clipParameters, 5 | createEmptyCard, 6 | default_w, 7 | fsrs, 8 | FSRS5_DEFAULT_DECAY, 9 | generatorParameters, 10 | W17_W18_Ceiling, 11 | } from '../src/fsrs' 12 | 13 | describe('default params', () => { 14 | it('convert FSRS-4.5 to FSRS-6', () => { 15 | const params = generatorParameters({ 16 | w: [ 17 | 0.4, 0.6, 2.4, 5.8, 4.93, 0.94, 0.86, 0.01, 1.49, 0.14, 0.94, 2.18, 18 | 0.05, 0.34, 1.26, 0.29, 2.61, 19 | ], 20 | }) 21 | expect(params.w).toEqual([ 22 | 0.4, 23 | 0.6, 24 | 2.4, 25 | 5.8, 26 | 6.81, 27 | 0.44675014, 28 | 1.36, 29 | 0.01, 30 | 1.49, 31 | 0.14, 32 | 0.94, 33 | 2.18, 34 | 0.05, 35 | 0.34, 36 | 1.26, 37 | 0.29, 38 | 2.61, 39 | 0.0, 40 | 0.0, 41 | 0.0, 42 | FSRS5_DEFAULT_DECAY, 43 | ]) 44 | }) 45 | 46 | it('convert FSRS-5 to FSRS-6', () => { 47 | const params = generatorParameters({ 48 | w: [ 49 | 0.40255, 1.18385, 3.173, 15.69105, 7.1949, 0.5345, 1.4604, 0.0046, 50 | 1.54575, 0.1192, 1.01925, 1.9395, 0.11, 0.29605, 2.2698, 0.2315, 2.9898, 51 | 0.51655, 0.6621, 52 | ], 53 | }) 54 | expect(params.w).toEqual([ 55 | 0.40255, 56 | 1.18385, 57 | 3.173, 58 | 15.69105, 59 | 7.1949, 60 | 0.5345, 61 | 1.4604, 62 | 0.0046, 63 | 1.54575, 64 | 0.1192, 65 | 1.01925, 66 | 1.9395, 67 | 0.11, 68 | 0.29605, 69 | 2.2698, 70 | 0.2315, 71 | 2.9898, 72 | 0.51655, 73 | 0.6621, 74 | 0.0, 75 | FSRS5_DEFAULT_DECAY, 76 | ]) 77 | }) 78 | 79 | it('revert to default params', () => { 80 | const params = generatorParameters({ 81 | w: [0.40255], 82 | }) 83 | expect(params.w).toEqual(default_w) 84 | 85 | const f = fsrs(params) 86 | f.parameters.w = [0] 87 | expect(f.parameters.w).toEqual(default_w) 88 | }) 89 | 90 | it('checkParameters', () => { 91 | const w = [...default_w] 92 | 93 | expect(checkParameters(w)).toBe(w) 94 | expect(checkParameters(w)).toMatchObject(default_w) 95 | expect(() => checkParameters(w.slice(0, 19))).not.toThrow() 96 | expect(() => checkParameters(w.slice(0, 17))).not.toThrow() 97 | expect(() => checkParameters([0.40255])).toThrow(/^Invalid parameter length/) 98 | expect(() => checkParameters(w.slice(0, 16))).toThrow(/^Invalid parameter length/) 99 | w[5] = Infinity 100 | expect(() => checkParameters(w)).toThrow(/^Non-finite/) 101 | 102 | }) 103 | 104 | it('if num relearning steps > 1', () => { 105 | const w = [...default_w] 106 | w[17] = Number.MAX_VALUE 107 | w[18] = Number.MAX_VALUE 108 | const params = clipParameters(w, 2) 109 | expect(params[17]).toEqual(0.05801436) 110 | expect(params[18]).toEqual(0.05801436) 111 | }) 112 | }) 113 | 114 | describe('default Card', () => { 115 | it('empty card', () => { 116 | const time = [new Date(), new Date('2023-10-3 00:00:00')] 117 | for (const now of time) { 118 | const card = createEmptyCard(now) 119 | expect(card.due).toEqual(now) 120 | expect(card.stability).toEqual(0) 121 | expect(card.difficulty).toEqual(0) 122 | expect(card.elapsed_days).toEqual(0) 123 | expect(card.scheduled_days).toEqual(0) 124 | expect(card.reps).toEqual(0) 125 | expect(card.lapses).toEqual(0) 126 | expect(card.state).toEqual(0) 127 | } 128 | }) 129 | }) 130 | -------------------------------------------------------------------------------- /__tests__/elapsed_days.test.ts: -------------------------------------------------------------------------------- 1 | // Ref:https://github.com/ishiko732/ts-fsrs/issues/44 2 | 3 | import { 4 | fsrs, 5 | FSRS, 6 | createEmptyCard, 7 | Rating, 8 | Grade, 9 | ReviewLog, 10 | } from '../src/fsrs' 11 | 12 | describe('elapsed_days', () => { 13 | const f: FSRS = fsrs() 14 | 15 | const createDue = new Date(Date.UTC(2023, 9, 18, 14, 32, 3, 370)) 16 | const grades: Grade[] = [Rating.Good, Rating.Again, Rating.Again, Rating.Good] 17 | let currentLog: ReviewLog | null = null 18 | let index = 0 19 | let card = createEmptyCard(createDue) 20 | test('first repeat[Rating.Good]', () => { 21 | const firstDue = new Date(Date.UTC(2023, 10, 5, 8, 27, 2, 605)) 22 | const sc = f.repeat(card, firstDue) 23 | currentLog = sc[grades[index]].log 24 | 25 | expect(currentLog.elapsed_days).toEqual(0) 26 | // console.log(sc[grades[index]].log) 27 | card = sc[grades[index]].card 28 | // console.log(card) 29 | index += 1 30 | }) 31 | 32 | test('second repeat[Rating.Again]', () => { 33 | // 2023-11-08 15:02:09.791,4.93,2023-11-05 08:27:02.605 34 | const secondDue = new Date(Date.UTC(2023, 10, 8, 15, 2, 9, 791)) 35 | expect(card).not.toBeNull() 36 | const sc = f.repeat(card, secondDue) 37 | 38 | currentLog = sc[grades[index]].log 39 | expect(currentLog.elapsed_days).toEqual( 40 | secondDue.diff(card.last_review as Date, 'days') 41 | ) // 3 42 | expect(currentLog.elapsed_days).toEqual(3) // 0 43 | card = sc[grades[index]].card 44 | // console.log(card) 45 | // console.log(currentLog) 46 | index += 1 47 | }) 48 | 49 | test('third repeat[Rating.Again]', () => { 50 | // 2023-11-08 15:02:30.799,4.93,2023-11-08 15:02:09.791 51 | const secondDue = new Date(Date.UTC(2023, 10, 8, 15, 2, 30, 799)) 52 | expect(card).not.toBeNull() 53 | const sc = f.repeat(card, secondDue) 54 | 55 | currentLog = sc[grades[index]].log 56 | expect(currentLog.elapsed_days).toEqual( 57 | secondDue.diff(card.last_review as Date, 'days') 58 | ) // 0 59 | expect(currentLog.elapsed_days).toEqual(0) // 0 60 | // console.log(currentLog); 61 | card = sc[grades[index]].card 62 | // console.log(card); 63 | index += 1 64 | }) 65 | 66 | test('fourth repeat[Rating.Good]', () => { 67 | // 2023-11-08 15:04:08.739,4.93,2023-11-08 15:02:30.799 68 | const secondDue = new Date(Date.UTC(2023, 10, 8, 15, 4, 8, 739)) 69 | expect(card).not.toBeNull() 70 | const sc = f.repeat(card, secondDue) 71 | 72 | currentLog = sc[grades[index]].log 73 | expect(currentLog.elapsed_days).toEqual( 74 | secondDue.diff(card.last_review as Date, 'days') 75 | ) // 0 76 | expect(currentLog.elapsed_days).toEqual(0) // 0 77 | // console.log(currentLog); 78 | card = sc[grades[index]].card 79 | // console.log(card); 80 | index += 1 81 | }) 82 | }) 83 | -------------------------------------------------------------------------------- /__tests__/fixed/calc-elapsed-days.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createEmptyCard, 3 | dateDiffInDays, 4 | fsrs, 5 | FSRSHistory, 6 | Grade, 7 | Rating, 8 | State, 9 | FSRSState, 10 | } from '../../src/fsrs' 11 | 12 | /** 13 | * @see https://forums.ankiweb.net/t/feature-request-estimated-total-knowledge-over-time/53036/58?u=l.m.sherlock 14 | * @see https://ankiweb.net/shared/info/1613056169 15 | */ 16 | test('TS-FSRS-Simulator', () => { 17 | const f = fsrs({ 18 | w: [ 19 | 1.1596, 1.7974, 13.1205, 49.3729, 7.2303, 0.5081, 1.5371, 0.001, 1.5052, 20 | 0.1261, 0.9735, 1.8924, 0.1486, 0.2407, 2.1937, 0.1518, 3.0699, 0.4636, 21 | 0.6048, 22 | ], 23 | }) 24 | const rids = [1704468957000, 1704469645000, 1704599572000, 1705509507000] 25 | 26 | const expected = [13.1205, 17.3668145, 21.28550751, 39.63452215] 27 | let card = createEmptyCard(new Date(rids[0])) 28 | const grades: Grade[] = [Rating.Good, Rating.Good, Rating.Good, Rating.Good] 29 | for (let i = 0; i < rids.length; i++) { 30 | const now = new Date(rids[i]) 31 | const log = f.next(card, now, grades[i]) 32 | card = log.card 33 | expect(card.stability).toBeCloseTo(expected[i], 4) 34 | } 35 | }) 36 | 37 | 38 | test('SSE use next_state', () => { 39 | const f = fsrs({ 40 | w: [ 41 | 0.4911, 4.5674, 24.8836, 77.045, 7.5474, 0.1873, 1.7732, 0.001, 1.1112, 42 | 0.152, 0.5728, 1.8747, 0.1733, 0.2449, 2.2905, 0.0, 2.9898, 0.0883, 43 | 0.9033, 44 | ], 45 | }) 46 | 47 | const rids = [ 48 | 1698678054940 /**2023-10-30T15:00:54.940Z */, 49 | 1698678126399 /**2023-10-30T15:02:06.399Z */, 50 | 1698688771401 /**2023-10-30T17:59:31.401Z */, 51 | 1698688837021 /**2023-10-30T18:00:37.021Z */, 52 | 1698688916440 /**2023-10-30T18:01:56.440Z */, 53 | 1698698192380 /**2023-10-30T20:36:32.380Z */, 54 | 1699260169343 /**2023-11-06T08:42:49.343Z */, 55 | 1702718934003 /**2023-12-16T09:28:54.003Z */, 56 | 1704910583686 /**2024-01-10T18:16:23.686Z */, 57 | 1713000017248 /**2024-04-13T09:20:17.248Z */, 58 | ] 59 | const ratings: Rating[] = [3, 3, 1, 3, 3, 3, 0, 3, 0, 3] 60 | // 0,0,0,0,0,0,47,119 61 | let last = new Date(rids[0]) 62 | let memoryState: FSRSState | null = null 63 | for (let i = 0; i < rids.length; i++) { 64 | const current = new Date(rids[i]) 65 | const rating = ratings[i] 66 | const delta_t = dateDiffInDays(last, current) 67 | const nextStates = f.next_state(memoryState, delta_t, rating) 68 | if (rating !== 0) { 69 | last = new Date(rids[i]) 70 | } 71 | 72 | console.debug( 73 | rids[i + 1], 74 | rids[i], 75 | delta_t, 76 | +nextStates.stability.toFixed(2), 77 | +nextStates.difficulty.toFixed(2) 78 | ) 79 | memoryState = nextStates 80 | } 81 | expect(memoryState?.stability).toBeCloseTo(71.77) 82 | }) 83 | 84 | test.skip('SSE 71.77', () => { 85 | const f = fsrs({ 86 | w: [ 87 | 0.4911, 4.5674, 24.8836, 77.045, 7.5474, 0.1873, 1.7732, 0.001, 1.1112, 88 | 0.152, 0.5728, 1.8747, 0.1733, 0.2449, 2.2905, 0.0, 2.9898, 0.0883, 89 | 0.9033, 90 | ], 91 | }) 92 | 93 | const rids = [ 94 | 1698678054940 /**2023-10-30T15:00:54.940Z */, 95 | 1698678126399 /**2023-10-30T15:02:06.399Z */, 96 | 1698688771401 /**2023-10-30T17:59:31.401Z */, 97 | 1698688837021 /**2023-10-30T18:00:37.021Z */, 98 | 1698688916440 /**2023-10-30T18:01:56.440Z */, 99 | 1698698192380 /**2023-10-30T20:36:32.380Z */, 100 | 1699260169343 /**2023-11-06T08:42:49.343Z */, 101 | 1702718934003 /**2023-12-16T09:28:54.003Z */, 102 | 1704910583686 /**2024-01-10T18:16:23.686Z */, 103 | 1713000017248 /**2024-04-13T09:20:17.248Z */, 104 | ] 105 | const ratings: Rating[] = [3, 3, 1, 3, 3, 3, 0, 3, 0, 3] 106 | 107 | const expected = [ 108 | { 109 | elapsed_days: 0, 110 | s: 24.88, 111 | d: 7.09, 112 | }, 113 | { 114 | elapsed_days: 0, 115 | s: 26.95, 116 | d: 7.09, 117 | }, 118 | { 119 | elapsed_days: 0, 120 | s: 24.46, 121 | d: 8.24, 122 | }, 123 | { 124 | elapsed_days: 0, 125 | s: 26.48, 126 | d: 8.24, 127 | }, 128 | { 129 | elapsed_days: 0, 130 | s: 28.69, 131 | d: 8.23, 132 | }, 133 | { 134 | elapsed_days: 0, 135 | s: 31.08, 136 | d: 8.23, 137 | }, 138 | { 139 | elapsed_days: 0, 140 | s: 47.44, 141 | d: 8.23, 142 | }, 143 | { 144 | elapsed_days: 119, 145 | s: 71.77, 146 | d: 8.23, 147 | }, 148 | ] 149 | 150 | let card = createEmptyCard(new Date(rids[0])) 151 | 152 | for (let i = 0; i < rids.length; i++) { 153 | const rating = ratings[i] 154 | if (rating == 0) { 155 | continue 156 | } 157 | 158 | const now = new Date(rids[i]) 159 | const log = f.next(card, now, rating) 160 | card = log.card 161 | console.debug(i + 1) 162 | expect(card.elapsed_days).toBe(expected[i].elapsed_days) 163 | expect(card.stability).toBeCloseTo(expected[i].s, 2) 164 | expect(card.difficulty).toBeCloseTo(expected[i].d, 2) 165 | } 166 | 167 | expect(card.stability).toBeCloseTo(71.77) 168 | }) 169 | -------------------------------------------------------------------------------- /__tests__/fixed/same-seed.test.ts: -------------------------------------------------------------------------------- 1 | import { createEmptyCard, fsrs, Rating } from '../../src/fsrs' 2 | 3 | describe('fuzz same seed', () => { 4 | const MOCK_NOW = new Date(2024, 7, 15) 5 | const size = 100 6 | 7 | // https://github.com/open-spaced-repetition/ts-fsrs/issues/113 8 | it('should be the same[short-term]', () => { 9 | const { card } = fsrs().next(createEmptyCard(), MOCK_NOW, Rating.Good) 10 | const scheduler = fsrs({ enable_fuzz: true }) 11 | const MOCK_TOMORROW = new Date(2024, 7, 16) 12 | 13 | const timestamp: number[] = [] 14 | for (let count = 0; count < size; count++) { 15 | setTimeout(() => { 16 | const _card = scheduler.next(card, MOCK_TOMORROW, Rating.Good).card 17 | timestamp.push(_card.due.getTime()) 18 | if (timestamp.length === size) { 19 | expect(timestamp.every((value) => value === timestamp[0])).toBe(true) 20 | } 21 | }, 50) 22 | } 23 | }) 24 | 25 | it('should be the same[long-term]', () => { 26 | const { card } = fsrs({ enable_short_term: false }).next( 27 | createEmptyCard(), 28 | MOCK_NOW, 29 | Rating.Good 30 | ) 31 | const scheduler = fsrs({ enable_fuzz: true, enable_short_term: false }) 32 | const MOCK_TOMORROW = new Date(2024, 7, 18) 33 | 34 | const timestamp: number[] = [] 35 | for (let count = 0; count < size; count++) { 36 | setTimeout(() => { 37 | const _card = scheduler.next(card, MOCK_TOMORROW, Rating.Good).card 38 | timestamp.push(_card.due.getTime()) 39 | if (timestamp.length === size) { 40 | expect(timestamp.every((value) => value === timestamp[0])).toBe(true) 41 | } 42 | }, 50) 43 | } 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /__tests__/forget.test.ts: -------------------------------------------------------------------------------- 1 | import { createEmptyCard, fsrs, FSRS, Rating } from '../src/fsrs' 2 | import { Grade } from '../src/fsrs' 3 | 4 | describe('FSRS forget', () => { 5 | const f: FSRS = fsrs({ 6 | w: [ 7 | 1.14, 1.01, 5.44, 14.67, 5.3024, 1.5662, 1.2503, 0.0028, 1.5489, 0.1763, 8 | 0.9953, 2.7473, 0.0179, 0.3105, 0.3976, 0.0, 2.0902, 9 | ], 10 | enable_fuzz: false, 11 | }) 12 | it('forget', () => { 13 | const card = createEmptyCard() 14 | const now = new Date(2022, 11, 29, 12, 30, 0, 0) 15 | const forget_now = new Date(2023, 11, 30, 12, 30, 0, 0) 16 | const scheduling_cards = f.repeat(card, now) 17 | const grades: Grade[] = [ 18 | Rating.Again, 19 | Rating.Hard, 20 | Rating.Good, 21 | Rating.Easy, 22 | ] 23 | for (const grade of grades) { 24 | const forgetCard = f.forget( 25 | scheduling_cards[grade].card, 26 | forget_now, 27 | true 28 | ) 29 | expect(forgetCard.card).toEqual({ 30 | ...card, 31 | due: forget_now, 32 | lapses: 0, 33 | reps: 0, 34 | last_review: scheduling_cards[grade].card.last_review, 35 | }) 36 | expect(forgetCard.log.rating).toEqual(Rating.Manual) 37 | expect(() => f.rollback(forgetCard.card, forgetCard.log)).toThrow( 38 | 'Cannot rollback a manual rating' 39 | ) 40 | } 41 | for (const grade of grades) { 42 | const forgetCard = f.forget(scheduling_cards[grade].card, forget_now) 43 | expect(forgetCard.card).toEqual({ 44 | ...card, 45 | due: forget_now, 46 | lapses: scheduling_cards[grade].card.lapses, 47 | reps: scheduling_cards[grade].card.reps, 48 | last_review: scheduling_cards[grade].card.last_review, 49 | }) 50 | expect(forgetCard.log.rating).toEqual(Rating.Manual) 51 | expect(() => f.rollback(forgetCard.card, forgetCard.log)).toThrow( 52 | 'Cannot rollback a manual rating' 53 | ) 54 | } 55 | }) 56 | 57 | it('new card forget[reset true]', () => { 58 | const card = createEmptyCard() 59 | const forget_now = new Date(2023, 11, 30, 12, 30, 0, 0) 60 | const forgetCard = f.forget(card, forget_now, true) 61 | expect(forgetCard.card).toEqual({ 62 | ...card, 63 | due: forget_now, 64 | lapses: 0, 65 | reps: 0, 66 | }) 67 | }) 68 | it('new card forget[reset true]', () => { 69 | const card = createEmptyCard() 70 | const forget_now = new Date(2023, 11, 30, 12, 30, 0, 0) 71 | const forgetCard = f.forget(card, forget_now) 72 | expect(forgetCard.card).toEqual({ 73 | ...card, 74 | due: forget_now, 75 | }) 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /__tests__/handler.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | createEmptyCard, 4 | date_scheduler, 5 | fixState, 6 | fsrs, 7 | FSRS, 8 | Grades, 9 | Rating, 10 | RatingType, 11 | RecordLog, 12 | RecordLogItem, 13 | ReviewLog, 14 | State, 15 | StateType, 16 | } from '../src/fsrs' 17 | 18 | interface CardPrismaUnChecked 19 | extends Omit { 20 | cid: string 21 | due: Date | number 22 | last_review: Date | null | number 23 | state: StateType 24 | } 25 | 26 | interface RevLogPrismaUnchecked 27 | extends Omit { 28 | cid: string 29 | due: Date | number 30 | state: StateType 31 | review: Date | number 32 | rating: RatingType 33 | } 34 | 35 | interface RepeatRecordLog { 36 | card: CardPrismaUnChecked 37 | log: RevLogPrismaUnchecked 38 | } 39 | 40 | describe('afterHandler', () => { 41 | const f: FSRS = fsrs() 42 | const now = new Date() 43 | 44 | function cardAfterHandler(card: Card) { 45 | return { 46 | ...card, 47 | cid: 'test001', 48 | state: State[card.state], 49 | last_review: card.last_review ?? null, 50 | } as CardPrismaUnChecked 51 | } 52 | 53 | function repeatAfterHandler(recordLog: RecordLog) { 54 | const record: RepeatRecordLog[] = [] 55 | for (const grade of Grades) { 56 | record.push({ 57 | card: { 58 | ...(recordLog[grade].card as Card & { cid: string }), 59 | due: recordLog[grade].card.due.getTime(), 60 | state: State[recordLog[grade].card.state] as StateType, 61 | last_review: recordLog[grade].card.last_review 62 | ? recordLog[grade].card.last_review!.getTime() 63 | : null, 64 | }, 65 | log: { 66 | ...recordLog[grade].log, 67 | cid: (recordLog[grade].card as Card & { cid: string }).cid, 68 | due: recordLog[grade].log.due.getTime(), 69 | review: recordLog[grade].log.review.getTime(), 70 | state: State[recordLog[grade].log.state] as StateType, 71 | rating: Rating[recordLog[grade].log.rating] as RatingType, 72 | }, 73 | }) 74 | } 75 | return record 76 | } 77 | 78 | // function repeatAfterHandler(recordLog: RecordLog) { 79 | // const record: { [key in Grade]: RepeatRecordLog } = {} as { 80 | // [key in Grade]: RepeatRecordLog; 81 | // }; 82 | // for (const grade of Grades) { 83 | // record[grade] = { 84 | // card: { 85 | // ...(recordLog[grade].card as Card & { cid: string }), 86 | // due: recordLog[grade].card.due.getTime(), 87 | // state: State[recordLog[grade].card.state] as StateType, 88 | // last_review: recordLog[grade].card.last_review 89 | // ? recordLog[grade].card.last_review!.getTime() 90 | // : null, 91 | // }, 92 | // log: { 93 | // ...recordLog[grade].log, 94 | // cid: (recordLog[grade].card as Card & { cid: string }).cid, 95 | // due: recordLog[grade].log.due.getTime(), 96 | // review: recordLog[grade].log.review.getTime(), 97 | // state: State[recordLog[grade].log.state] as StateType, 98 | // rating: Rating[recordLog[grade].log.rating] as RatingType, 99 | // }, 100 | // }; 101 | // } 102 | // return record; 103 | // } 104 | function nextAfterHandler(recordLogItem: RecordLogItem) { 105 | const recordItem = { 106 | card: { 107 | ...(recordLogItem.card as Card & { cid: string }), 108 | due: recordLogItem.card.due.getTime(), 109 | state: State[recordLogItem.card.state] as StateType, 110 | last_review: recordLogItem.card.last_review 111 | ? recordLogItem.card.last_review!.getTime() 112 | : null, 113 | }, 114 | log: { 115 | ...recordLogItem.log, 116 | cid: (recordLogItem.card as Card & { cid: string }).cid, 117 | due: recordLogItem.log.due.getTime(), 118 | review: recordLogItem.log.review.getTime(), 119 | state: State[recordLogItem.log.state] as StateType, 120 | rating: Rating[recordLogItem.log.rating] as RatingType, 121 | }, 122 | } 123 | return recordItem 124 | } 125 | 126 | function forgetAfterHandler(recordLogItem: RecordLogItem): RepeatRecordLog { 127 | return { 128 | card: { 129 | ...(recordLogItem.card as Card & { cid: string }), 130 | due: recordLogItem.card.due.getTime(), 131 | state: State[recordLogItem.card.state] as StateType, 132 | last_review: recordLogItem.card.last_review 133 | ? recordLogItem.card.last_review!.getTime() 134 | : null, 135 | }, 136 | log: { 137 | ...recordLogItem.log, 138 | cid: (recordLogItem.card as Card & { cid: string }).cid, 139 | due: recordLogItem.log.due.getTime(), 140 | review: recordLogItem.log.review.getTime(), 141 | state: State[recordLogItem.log.state] as StateType, 142 | rating: Rating[recordLogItem.log.rating] as RatingType, 143 | }, 144 | } 145 | } 146 | 147 | it('createEmptyCard[afterHandler]', () => { 148 | const emptyCardFormAfterHandler = createEmptyCard(now, cardAfterHandler) 149 | expect(emptyCardFormAfterHandler.state).toEqual('New') 150 | expect(fixState(emptyCardFormAfterHandler.state)).toEqual(State.New) 151 | expect(emptyCardFormAfterHandler.last_review).toEqual(null) 152 | expect(emptyCardFormAfterHandler.cid).toEqual('test001') 153 | 154 | const emptyCardFormAfterHandler2 = createEmptyCard( 155 | now, 156 | cardAfterHandler 157 | ) 158 | expect(emptyCardFormAfterHandler2.state).toEqual('New') 159 | expect(fixState(emptyCardFormAfterHandler2.state)).toEqual(State.New) 160 | expect(emptyCardFormAfterHandler2.last_review).toEqual(null) 161 | expect(emptyCardFormAfterHandler2.cid).toEqual('test001') 162 | }) 163 | 164 | it('repeat[afterHandler]', () => { 165 | const emptyCardFormAfterHandler = createEmptyCard(now, cardAfterHandler) 166 | const repeat = f.repeat(emptyCardFormAfterHandler, now, repeatAfterHandler) 167 | expect(Array.isArray(repeat)).toEqual(true) 168 | 169 | for (let i = 0; i < Grades.length; i++) { 170 | expect(Number.isSafeInteger(repeat[i].card.due)).toEqual(true) 171 | expect(typeof repeat[i].card.state === 'string').toEqual(true) 172 | expect(Number.isSafeInteger(repeat[i].card.last_review)).toEqual(true) 173 | 174 | expect(Number.isSafeInteger(repeat[i].log.due)).toEqual(true) 175 | expect(Number.isSafeInteger(repeat[i].log.review)).toEqual(true) 176 | expect(typeof repeat[i].log.state === 'string').toEqual(true) 177 | expect(typeof repeat[i].log.rating === 'string').toEqual(true) 178 | expect(repeat[i].card.cid).toEqual('test001') 179 | expect(repeat[i].log.cid).toEqual(repeat[i].card.cid) 180 | } 181 | }) 182 | 183 | it('next[afterHandler]', () => { 184 | const emptyCardFormAfterHandler = createEmptyCard(now, cardAfterHandler) 185 | for (const grade of Grades) { 186 | const next = f.next( 187 | emptyCardFormAfterHandler, 188 | now, 189 | grade, 190 | nextAfterHandler 191 | ) 192 | expect('card' in next).toEqual(true) 193 | expect('log' in next).toEqual(true) 194 | 195 | expect(Number.isSafeInteger(next.card.due)).toEqual(true) 196 | expect(typeof next.card.state === 'string').toEqual(true) 197 | expect(Number.isSafeInteger(next.card.last_review)).toEqual(true) 198 | 199 | expect(Number.isSafeInteger(next.log.due)).toEqual(true) 200 | expect(Number.isSafeInteger(next.log.review)).toEqual(true) 201 | expect(typeof next.log.state === 'string').toEqual(true) 202 | expect(typeof next.log.rating === 'string').toEqual(true) 203 | expect(next.card.cid).toEqual('test001') 204 | expect(next.log.cid).toEqual(next.card.cid) 205 | } 206 | }) 207 | 208 | it('rollback[afterHandler]', () => { 209 | const emptyCardFormAfterHandler = createEmptyCard(now, cardAfterHandler) 210 | const repeatFormAfterHandler = f.repeat( 211 | emptyCardFormAfterHandler, 212 | now, 213 | repeatAfterHandler 214 | ) 215 | const { card, log } = repeatFormAfterHandler[Rating.Hard] 216 | const rollbackFromAfterHandler = f.rollback(card, log, cardAfterHandler) 217 | expect(rollbackFromAfterHandler).toEqual(emptyCardFormAfterHandler) 218 | expect(rollbackFromAfterHandler.cid).toEqual('test001') 219 | }) 220 | 221 | it('forget[afterHandler]', () => { 222 | const emptyCardFormAfterHandler = createEmptyCard(now, cardAfterHandler) 223 | const repeatFormAfterHandler = f.repeat( 224 | emptyCardFormAfterHandler, 225 | now, 226 | repeatAfterHandler 227 | ) 228 | const { card } = repeatFormAfterHandler[Rating.Hard] 229 | const forgetFromAfterHandler = f.forget( 230 | card, 231 | date_scheduler(now, 1, true), 232 | false, 233 | forgetAfterHandler 234 | ) 235 | 236 | expect(Number.isSafeInteger(forgetFromAfterHandler.card.due)).toEqual(true) 237 | expect(typeof forgetFromAfterHandler.card.state === 'string').toEqual(true) 238 | expect( 239 | Number.isSafeInteger(forgetFromAfterHandler.card.last_review) 240 | ).toEqual(true) 241 | 242 | expect(Number.isSafeInteger(forgetFromAfterHandler.log.due)).toEqual(true) 243 | expect(Number.isSafeInteger(forgetFromAfterHandler.log.review)).toEqual( 244 | true 245 | ) 246 | expect(typeof forgetFromAfterHandler.log.state === 'string').toEqual(true) 247 | expect(typeof forgetFromAfterHandler.log.rating === 'string').toEqual(true) 248 | expect(forgetFromAfterHandler.card.cid).toEqual('test001') 249 | expect(forgetFromAfterHandler.log.cid).toEqual( 250 | forgetFromAfterHandler.card.cid 251 | ) 252 | }) 253 | }) 254 | -------------------------------------------------------------------------------- /__tests__/help.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | date_diff, 3 | date_scheduler, 4 | fixDate, 5 | fixRating, 6 | fixState, 7 | formatDate, 8 | Grades, 9 | Rating, 10 | State, 11 | } from '../src/fsrs' 12 | 13 | test('FSRS-Grades', () => { 14 | expect(Grades).toStrictEqual([ 15 | Rating.Again, 16 | Rating.Hard, 17 | Rating.Good, 18 | Rating.Easy, 19 | ]) 20 | }) 21 | 22 | test('Date.prototype.format', () => { 23 | const now = new Date(2022, 11, 30, 12, 30, 0, 0) 24 | const last_review = new Date(2022, 11, 29, 12, 30, 0, 0) 25 | expect(now.format()).toEqual('2022-12-30 12:30:00') 26 | expect(formatDate(now)).toEqual('2022-12-30 12:30:00') 27 | expect(formatDate(now.getTime())).toEqual('2022-12-30 12:30:00') 28 | expect(formatDate(now.toUTCString())).toEqual('2022-12-30 12:30:00') 29 | const TIMEUNITFORMAT_TEST = ['秒', '分', '时', '天', '月', '年'] 30 | expect(now.dueFormat(last_review)).toEqual('1') 31 | expect(now.dueFormat(last_review, true)).toEqual('1day') 32 | expect(now.dueFormat(last_review, true, TIMEUNITFORMAT_TEST)).toEqual('1天') 33 | }) 34 | 35 | describe('date_scheduler', () => { 36 | test('offset by minutes', () => { 37 | const now = new Date('2023-01-01T12:00:00Z') 38 | const t = 30 39 | const expected = new Date('2023-01-01T12:30:00Z') 40 | 41 | expect(date_scheduler(now, t)).toEqual(expected) 42 | }) 43 | 44 | test('offset by days', () => { 45 | const now = new Date('2023-01-01T12:00:00Z') 46 | const t = 3 47 | const expected = new Date('2023-01-04T12:00:00Z') 48 | 49 | expect(date_scheduler(now, t, true)).toEqual(expected) 50 | }) 51 | 52 | test('negative offset', () => { 53 | const now = new Date('2023-01-01T12:00:00Z') 54 | const t = -15 55 | const expected = new Date('2023-01-01T11:45:00Z') 56 | 57 | expect(date_scheduler(now, t)).toEqual(expected) 58 | }) 59 | 60 | test('offset with isDay parameter', () => { 61 | const now = new Date('2023-01-01T12:00:00Z') 62 | const t = 2 63 | const expected = new Date('2023-01-03T12:00:00Z') 64 | 65 | expect(date_scheduler(now, t, true)).toEqual(expected) 66 | }) 67 | 68 | test('Date data real type is string/number', () => { 69 | const now = '2023-01-01T12:00:00Z' 70 | const t = 2 71 | const expected = new Date('2023-01-03T12:00:00Z') 72 | 73 | expect(date_scheduler(now, t, true)).toEqual(expected) 74 | }) 75 | }) 76 | 77 | describe('date_diff', () => { 78 | test('wrong fix', () => { 79 | const now = new Date(2022, 11, 30, 12, 30, 0, 0) 80 | const last_review = new Date(2022, 11, 29, 12, 30, 0, 0) 81 | 82 | expect(() => date_diff(now, null as unknown as Date, 'days')).toThrowError( 83 | 'Invalid date' 84 | ) 85 | expect(() => 86 | date_diff(now, null as unknown as Date, 'minutes') 87 | ).toThrowError('Invalid date') 88 | expect(() => 89 | date_diff(null as unknown as Date, last_review, 'days') 90 | ).toThrowError('Invalid date') 91 | expect(() => 92 | date_diff(null as unknown as Date, last_review, 'minutes') 93 | ).toThrowError('Invalid date') 94 | }) 95 | 96 | test('calculate difference in minutes', () => { 97 | const now = new Date('2023-11-25T12:30:00Z') 98 | const pre = new Date('2023-11-25T12:00:00Z') 99 | const unit = 'minutes' 100 | const expected = 30 101 | expect(date_diff(now, pre, unit)).toBe(expected) 102 | }) 103 | 104 | test('calculate difference in minutes for negative time difference', () => { 105 | const now = new Date('2023-11-25T12:00:00Z') 106 | const pre = new Date('2023-11-25T12:30:00Z') 107 | const unit = 'minutes' 108 | const expected = -30 109 | expect(date_diff(now, pre, unit)).toBe(expected) 110 | }) 111 | 112 | test('Date data real type is string/number', () => { 113 | const now = '2023-11-25T12:30:00Z' 114 | const pre = new Date('2023-11-25T12:00:00Z').getTime() 115 | const unit = 'minutes' 116 | const expected = 30 117 | expect(date_diff(now, pre, unit)).toBe(expected) 118 | }) 119 | }) 120 | 121 | describe('fixDate', () => { 122 | test('throw error for invalid date value', () => { 123 | const input = 'invalid-date' 124 | expect(() => fixDate(input)).toThrowError('Invalid date:[invalid-date]') 125 | }) 126 | 127 | test('throw error for unsupported value type', () => { 128 | const input = true 129 | expect(() => fixDate(input)).toThrowError('Invalid date:[true]') 130 | }) 131 | 132 | test('throw error for undefined value', () => { 133 | const input = undefined 134 | expect(() => fixDate(input)).toThrowError('Invalid date:[undefined]') 135 | }) 136 | 137 | test('throw error for null value', () => { 138 | const input = null 139 | expect(() => fixDate(input)).toThrowError('Invalid date:[null]') 140 | }) 141 | }) 142 | 143 | describe('fixState', () => { 144 | test('fix state value', () => { 145 | const newState = 'New' 146 | expect(fixState('new')).toEqual(State.New) 147 | expect(fixState(newState)).toEqual(State.New) 148 | 149 | const learning = 'Learning' 150 | expect(fixState('learning')).toEqual(State.Learning) 151 | expect(fixState(learning)).toEqual(State.Learning) 152 | 153 | const relearning = 'Relearning' 154 | expect(fixState('relearning')).toEqual(State.Relearning) 155 | expect(fixState(relearning)).toEqual(State.Relearning) 156 | 157 | const review = 'Review' 158 | expect(fixState('review')).toEqual(State.Review) 159 | expect(fixState(review)).toEqual(State.Review) 160 | }) 161 | 162 | test('throw error for invalid state value', () => { 163 | const input = 'invalid-state' 164 | expect(() => fixState(input)).toThrowError('Invalid state:[invalid-state]') 165 | expect(() => fixState(null)).toThrowError('Invalid state:[null]') 166 | expect(() => fixState(undefined)).toThrowError('Invalid state:[undefined]') 167 | }) 168 | }) 169 | 170 | describe('fixRating', () => { 171 | test('fix Rating value', () => { 172 | const again = 'Again' 173 | expect(fixRating('again')).toEqual(Rating.Again) 174 | expect(fixRating(again)).toEqual(Rating.Again) 175 | 176 | const hard = 'Hard' 177 | expect(fixRating('hard')).toEqual(Rating.Hard) 178 | expect(fixRating(hard)).toEqual(Rating.Hard) 179 | 180 | const good = 'Good' 181 | expect(fixRating('good')).toEqual(Rating.Good) 182 | expect(fixRating(good)).toEqual(Rating.Good) 183 | 184 | const easy = 'Easy' 185 | expect(fixRating('easy')).toEqual(Rating.Easy) 186 | expect(fixRating(easy)).toEqual(Rating.Easy) 187 | }) 188 | 189 | test('throw error for invalid rating value', () => { 190 | const input = 'invalid-rating' 191 | expect(() => fixRating(input)).toThrowError( 192 | 'Invalid rating:[invalid-rating]' 193 | ) 194 | expect(() => fixRating(null)).toThrowError('Invalid rating:[null]') 195 | expect(() => fixRating(undefined)).toThrowError( 196 | 'Invalid rating:[undefined]' 197 | ) 198 | }) 199 | }) 200 | 201 | describe('default values can not be overwritten', () => { 202 | it('Grades can not be overwritten', () => { 203 | expect(() => { 204 | // @ts-expect-error test modify 205 | Grades[4] = Rating.Manual 206 | }).toThrow() 207 | expect(Grades.length).toEqual(4) 208 | }) 209 | }) 210 | -------------------------------------------------------------------------------- /__tests__/impl/abstract_scheduler.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createEmptyCard, 3 | fsrs, 4 | Grade, 5 | type IPreview, 6 | Rating, 7 | } from '../../src/fsrs' 8 | 9 | describe('basic scheduler', () => { 10 | const now = new Date() 11 | 12 | it('[Symbol.iterator]', () => { 13 | const card = createEmptyCard(now) 14 | const f = fsrs() 15 | const preview = f.repeat(card, now) 16 | const again = f.next(card, now, Rating.Again) 17 | const hard = f.next(card, now, Rating.Hard) 18 | const good = f.next(card, now, Rating.Good) 19 | const easy = f.next(card, now, Rating.Easy) 20 | 21 | const expect_preview = { 22 | [Rating.Again]: again, 23 | [Rating.Hard]: hard, 24 | [Rating.Good]: good, 25 | [Rating.Easy]: easy, 26 | [Symbol.iterator]: preview[Symbol.iterator], 27 | } satisfies IPreview 28 | expect(preview).toEqual(expect_preview) 29 | for (const item of preview) { 30 | expect(item).toEqual(expect_preview[item.log.rating]) 31 | } 32 | const iterator = preview[Symbol.iterator]() 33 | expect(iterator.next().value).toEqual(again) 34 | expect(iterator.next().value).toEqual(hard) 35 | expect(iterator.next().value).toEqual(good) 36 | expect(iterator.next().value).toEqual(easy) 37 | expect(iterator.next().done).toBeTruthy() 38 | }) 39 | 40 | }) 41 | -------------------------------------------------------------------------------- /__tests__/impl/basic_scheduler.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createEmptyCard, 3 | FSRSAlgorithm, 4 | generatorParameters, 5 | Grade, 6 | Rating, 7 | } from '../../src/fsrs' 8 | import BasicScheduler from '../../src/fsrs/impl/basic_scheduler' 9 | 10 | describe('basic scheduler', () => { 11 | const params = generatorParameters() 12 | const algorithm = new FSRSAlgorithm(params) 13 | const now = new Date() 14 | 15 | it('[State.New]exist', () => { 16 | const card = createEmptyCard(now) 17 | const basicScheduler = new BasicScheduler(card, now, algorithm) 18 | const preview = basicScheduler.preview() 19 | const again = basicScheduler.review(Rating.Again) 20 | const hard = basicScheduler.review(Rating.Hard) 21 | const good = basicScheduler.review(Rating.Good) 22 | const easy = basicScheduler.review(Rating.Easy) 23 | expect(preview).toEqual({ 24 | [Rating.Again]: again, 25 | [Rating.Hard]: hard, 26 | [Rating.Good]: good, 27 | [Rating.Easy]: easy, 28 | [Symbol.iterator]: basicScheduler[`previewIterator`].bind(basicScheduler), 29 | }) 30 | for (const item of preview) { 31 | expect(item).toEqual(basicScheduler.review(item.log.rating)) 32 | } 33 | const iterator = preview[Symbol.iterator]() 34 | expect(iterator.next().value).toEqual(again) 35 | expect(iterator.next().value).toEqual(hard) 36 | expect(iterator.next().value).toEqual(good) 37 | expect(iterator.next().value).toEqual(easy) 38 | expect(iterator.next().done).toBeTruthy() 39 | }) 40 | it('[State.New]invalid grade', () => { 41 | const card = createEmptyCard(now) 42 | const basicScheduler = new BasicScheduler(card, now, algorithm) 43 | expect(() => basicScheduler.review('invalid' as unknown as Grade)).toThrow( 44 | 'Invalid grade' 45 | ) 46 | }) 47 | 48 | it('[State.Learning]exist', () => { 49 | const cardByNew = createEmptyCard(now) 50 | const { card } = new BasicScheduler(cardByNew, now, algorithm).review( 51 | Rating.Again 52 | ) 53 | const basicScheduler = new BasicScheduler(card, now, algorithm) 54 | 55 | const preview = basicScheduler.preview() 56 | const again = basicScheduler.review(Rating.Again) 57 | const hard = basicScheduler.review(Rating.Hard) 58 | const good = basicScheduler.review(Rating.Good) 59 | const easy = basicScheduler.review(Rating.Easy) 60 | expect(preview).toEqual({ 61 | [Rating.Again]: again, 62 | [Rating.Hard]: hard, 63 | [Rating.Good]: good, 64 | [Rating.Easy]: easy, 65 | [Symbol.iterator]: basicScheduler[`previewIterator`].bind(basicScheduler), 66 | }) 67 | for (const item of preview) { 68 | expect(item).toEqual(basicScheduler.review(item.log.rating)) 69 | } 70 | }) 71 | it('[State.Learning]invalid grade', () => { 72 | const cardByNew = createEmptyCard(now) 73 | const { card } = new BasicScheduler(cardByNew, now, algorithm).review( 74 | Rating.Again 75 | ) 76 | const basicScheduler = new BasicScheduler(card, now, algorithm) 77 | expect(() => basicScheduler.review('invalid' as unknown as Grade)).toThrow( 78 | 'Invalid grade' 79 | ) 80 | }) 81 | 82 | it('[State.Review]exist', () => { 83 | const cardByNew = createEmptyCard(now) 84 | const { card } = new BasicScheduler(cardByNew, now, algorithm).review( 85 | Rating.Easy 86 | ) 87 | const basicScheduler = new BasicScheduler(card, now, algorithm) 88 | 89 | const preview = basicScheduler.preview() 90 | const again = basicScheduler.review(Rating.Again) 91 | const hard = basicScheduler.review(Rating.Hard) 92 | const good = basicScheduler.review(Rating.Good) 93 | const easy = basicScheduler.review(Rating.Easy) 94 | expect(preview).toEqual({ 95 | [Rating.Again]: again, 96 | [Rating.Hard]: hard, 97 | [Rating.Good]: good, 98 | [Rating.Easy]: easy, 99 | [Symbol.iterator]: basicScheduler[`previewIterator`].bind(basicScheduler), 100 | }) 101 | for (const item of preview) { 102 | expect(item).toEqual(basicScheduler.review(item.log.rating)) 103 | } 104 | }) 105 | it('[State.Review]invalid grade', () => { 106 | const cardByNew = createEmptyCard(now) 107 | const { card } = new BasicScheduler(cardByNew, now, algorithm).review( 108 | Rating.Easy 109 | ) 110 | const basicScheduler = new BasicScheduler(card, now, algorithm) 111 | expect(() => basicScheduler.review('invalid' as unknown as Grade)).toThrow( 112 | 'Invalid grade' 113 | ) 114 | }) 115 | }) 116 | -------------------------------------------------------------------------------- /__tests__/impl/long-term_scheduler.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CardInput, 3 | createEmptyCard, 4 | fsrs, 5 | generatorParameters, 6 | Grade, 7 | Rating, 8 | State, 9 | } from '../../src/fsrs' 10 | 11 | describe('Long-term scheduler', () => { 12 | const w = [ 13 | 0.4197, 1.1869, 3.0412, 15.2441, 7.1434, 0.6477, 1.0007, 0.0674, 1.6597, 14 | 0.1712, 1.1178, 2.0225, 0.0904, 0.3025, 2.1214, 0.2498, 2.9466, 0.4891, 15 | 0.6468, 16 | ] 17 | const params = generatorParameters({ w, enable_short_term: false }) 18 | const f = fsrs(params) 19 | // Grades => const grade: Grade[] = [Rating.Again, Rating.Hard, Rating.Good, Rating.Easy] 20 | 21 | test('test1', () => { 22 | let card = createEmptyCard() 23 | let now = new Date(2022, 11, 29, 12, 30, 0, 0) 24 | const ratings: Grade[] = [ 25 | Rating.Good, 26 | Rating.Good, 27 | Rating.Good, 28 | Rating.Good, 29 | Rating.Good, 30 | Rating.Good, 31 | Rating.Again, 32 | Rating.Again, 33 | Rating.Good, 34 | Rating.Good, 35 | Rating.Good, 36 | Rating.Good, 37 | Rating.Good, 38 | ] 39 | const ivl_history: number[] = [] 40 | const s_history: number[] = [] 41 | const d_history: number[] = [] 42 | for (const rating of ratings) { 43 | const record = f.repeat(card, now)[rating] 44 | // https://github.com/open-spaced-repetition/ts-fsrs/issues/105 45 | const next = fsrs(params).next(card, now, rating) 46 | expect(record).toEqual(next) 47 | 48 | card = record.card 49 | ivl_history.push(card.scheduled_days) 50 | s_history.push(card.stability) 51 | d_history.push(card.difficulty) 52 | now = card.due 53 | } 54 | expect(ivl_history).toEqual([ 55 | 3, 13, 48, 155, 445, 1158, 17, 3, 11, 37, 112, 307, 773, 56 | ]) 57 | expect(s_history).toEqual([ 58 | 3.0412, 13.09130698, 48.15848988, 154.93732625, 445.05562739, 59 | 1158.07779739, 16.63063166, 3.01732209, 11.42247264, 37.37521902, 60 | 111.8752758, 306.5974569, 772.94026648, 61 | ]) 62 | expect(d_history).toEqual([ 63 | 4.49094334, 4.26664289, 4.05746029, 3.86237659, 3.68044154, 3.51076891, 64 | 4.69833071, 5.55956298, 5.26323756, 4.98688448, 4.72915759, 4.4888015, 65 | 4.26464541, 66 | ]) 67 | }) 68 | 69 | test('test2', () => { 70 | let card = createEmptyCard() 71 | let now = new Date(2022, 11, 29, 12, 30, 0, 0) 72 | const ratings: Grade[] = [ 73 | Rating.Again, 74 | Rating.Hard, 75 | Rating.Good, 76 | Rating.Easy, 77 | Rating.Again, 78 | Rating.Hard, 79 | Rating.Good, 80 | Rating.Easy, 81 | ] 82 | const ivl_history: number[] = [] 83 | const s_history: number[] = [] 84 | const d_history: number[] = [] 85 | for (const rating of ratings) { 86 | const record = f.repeat(card, now)[rating] 87 | // https://github.com/open-spaced-repetition/ts-fsrs/issues/105 88 | const next = fsrs(params).next(card, now, rating) 89 | expect(record).toEqual(next) 90 | 91 | card = record.card 92 | ivl_history.push(card.scheduled_days) 93 | s_history.push(card.stability) 94 | d_history.push(card.difficulty) 95 | now = card.due 96 | } 97 | expect(ivl_history).toEqual([1, 2, 6, 41, 4, 7, 21, 133]) 98 | expect(s_history).toEqual([ 99 | 0.4197, 1.0344317, 5.5356759, 41.0033667, 4.46605519, 6.67743292, 100 | 20.88868155, 132.81849454, 101 | ]) 102 | expect(d_history).toEqual([ 103 | 7.1434, 7.03653841, 6.64066485, 5.92312772, 6.44779861, 6.45995078, 104 | 6.10293922, 5.36588547, 105 | ]) 106 | }) 107 | 108 | test('test3', () => { 109 | let card = createEmptyCard() 110 | let now = new Date(2022, 11, 29, 12, 30, 0, 0) 111 | const ratings: Grade[] = [ 112 | Rating.Hard, 113 | Rating.Good, 114 | Rating.Easy, 115 | Rating.Again, 116 | Rating.Hard, 117 | Rating.Good, 118 | Rating.Easy, 119 | Rating.Again, 120 | ] 121 | const ivl_history: number[] = [] 122 | const s_history: number[] = [] 123 | const d_history: number[] = [] 124 | for (const rating of ratings) { 125 | const record = f.repeat(card, now)[rating] 126 | // https://github.com/open-spaced-repetition/ts-fsrs/issues/105 127 | const next = fsrs(params).next(card, now, rating) 128 | expect(record).toEqual(next) 129 | 130 | card = record.card 131 | ivl_history.push(card.scheduled_days) 132 | s_history.push(card.stability) 133 | d_history.push(card.difficulty) 134 | now = card.due 135 | } 136 | expect(ivl_history).toEqual([2, 7, 54, 5, 8, 26, 171, 8]) 137 | 138 | expect(s_history).toEqual([ 139 | 1.1869, 6.59167572, 53.76078737, 5.0853693, 8.09786749, 25.52991279, 140 | 171.16195166, 8.11072373, 141 | ]) 142 | expect(d_history).toEqual([ 143 | 6.23225985, 5.89059466, 5.14583392, 5.884097, 5.99269555, 5.667177, 144 | 4.91430736, 5.71619151, 145 | ]) 146 | }) 147 | 148 | test('test4', () => { 149 | let card = createEmptyCard() 150 | let now = new Date(2022, 11, 29, 12, 30, 0, 0) 151 | const ratings: Grade[] = [ 152 | Rating.Good, 153 | Rating.Easy, 154 | Rating.Again, 155 | Rating.Hard, 156 | Rating.Good, 157 | Rating.Easy, 158 | Rating.Again, 159 | Rating.Hard, 160 | ] 161 | const ivl_history: number[] = [] 162 | const s_history: number[] = [] 163 | const d_history: number[] = [] 164 | for (const rating of ratings) { 165 | const record = f.repeat(card, now)[rating] 166 | // https://github.com/open-spaced-repetition/ts-fsrs/issues/105 167 | const next = fsrs(params).next(card, now, rating) 168 | expect(record).toEqual(next) 169 | 170 | card = record.card 171 | ivl_history.push(card.scheduled_days) 172 | s_history.push(card.stability) 173 | d_history.push(card.difficulty) 174 | now = card.due 175 | } 176 | expect(ivl_history).toEqual([3, 33, 4, 7, 26, 193, 9, 14]) 177 | 178 | expect(s_history).toEqual([ 179 | 3.0412, 32.65484522, 4.22256838, 7.2325009, 25.52681746, 193.36618775, 180 | 8.63899847, 14.31323867, 181 | ]) 182 | expect(d_history).toEqual([ 183 | 4.49094334, 3.69538259, 4.83221448, 5.12078462, 4.85403286, 4.07165035, 184 | 5.1050878, 5.34697075, 185 | ]) 186 | }) 187 | test('test5', () => { 188 | let card = createEmptyCard() 189 | let now = new Date(2022, 11, 29, 12, 30, 0, 0) 190 | const ratings: Grade[] = [ 191 | Rating.Easy, 192 | Rating.Again, 193 | Rating.Hard, 194 | Rating.Good, 195 | Rating.Easy, 196 | Rating.Again, 197 | Rating.Hard, 198 | Rating.Good, 199 | ] 200 | const ivl_history: number[] = [] 201 | const s_history: number[] = [] 202 | const d_history: number[] = [] 203 | for (const rating of ratings) { 204 | const record = f.repeat(card, now)[rating] 205 | // https://github.com/open-spaced-repetition/ts-fsrs/issues/105 206 | const next = fsrs(params).next(card, now, rating) 207 | expect(record).toEqual(next) 208 | 209 | card = record.card 210 | ivl_history.push(card.scheduled_days) 211 | s_history.push(card.stability) 212 | d_history.push(card.difficulty) 213 | now = card.due 214 | } 215 | expect(ivl_history).toEqual([15, 3, 6, 27, 240, 10, 17, 60]) 216 | 217 | expect(s_history).toEqual([ 218 | 15.2441, 3.25621013, 6.32684549, 26.56339029, 239.70462771, 9.75621519, 219 | 17.06035531, 59.59547542, 220 | ]) 221 | expect(d_history).toEqual([ 222 | 1.16304343, 2.99573557, 3.59851762, 3.43436666, 2.60045771, 4.03816348, 223 | 4.46259158, 4.24020203, 224 | ]) 225 | }) 226 | 227 | test('[State.(Re)Learning]switch long-term scheduler', () => { 228 | // Good(short)->Good(long)->Again(long)->Good(long)->Good(short)->Again(short) 229 | const ivl_history: number[] = [] 230 | const s_history: number[] = [] 231 | const d_history: number[] = [] 232 | const state_history: string[] = [] 233 | 234 | const grades: Grade[] = [ 235 | Rating.Good, 236 | Rating.Good, 237 | Rating.Again, 238 | Rating.Good, 239 | Rating.Good, 240 | Rating.Again, 241 | ] 242 | const short_term = [true, false, false, false, true, true] 243 | 244 | let now = new Date(2022, 11, 29, 12, 30, 0, 0) 245 | let card = createEmptyCard(now) 246 | const f = fsrs({ w }) 247 | for (let i = 0; i < grades.length; i++) { 248 | const grade = grades[i] 249 | const enable = short_term[i] 250 | f.parameters.enable_short_term = enable 251 | const record = f.repeat(card, now)[grade] 252 | // https://github.com/open-spaced-repetition/ts-fsrs/issues/105 253 | const next = fsrs({ ...params, enable_short_term: enable }).next( 254 | card, 255 | now, 256 | grade 257 | ) 258 | expect(record).toEqual(next) 259 | 260 | card = record.card 261 | now = card.due 262 | ivl_history.push(card.scheduled_days) 263 | s_history.push(card.stability) 264 | d_history.push(card.difficulty) 265 | state_history.push(State[card.state]) 266 | } 267 | 268 | expect(ivl_history).toEqual([0, 4, 1, 5, 19, 0]) 269 | expect(s_history).toEqual([ 270 | 3.0412, 3.0412, 1.21778427, 4.73753014, 19.02294877, 3.20676576, 271 | ]) 272 | expect(d_history).toEqual([ 273 | 4.49094334, 4.26664289, 5.24649844, 4.97127357, 4.71459886, 5.57136081, 274 | ]) 275 | expect(state_history).toEqual([ 276 | 'Learning', 277 | 'Review', 278 | 'Review', 279 | 'Review', 280 | 'Review', 281 | 'Relearning', 282 | ]) 283 | }) 284 | 285 | test('[Long-term]get_retrievability ', () => { 286 | const f = fsrs({ 287 | w: [ 288 | 0.4072, 1.1829, 3.1262, 15.4722, 7.2102, 0.5316, 1.0651, 0.0234, 1.616, 289 | 0.1544, 1.0824, 1.9813, 0.0953, 0.2975, 2.2042, 0.2407, 2.9466, 0.5034, 290 | 0.6567, 291 | ], 292 | enable_short_term: false, 293 | }) 294 | const now = '2024-08-03T18:15:34.500Z' 295 | const view_date = '2024-08-03T18:25:34.500Z' 296 | let card: CardInput = createEmptyCard(now) 297 | card = f.repeat(card, now)[Rating.Again].card 298 | let r = f.get_retrievability(card, view_date) 299 | expect(r).toEqual('100.00%') 300 | 301 | card = { 302 | cid: 81, 303 | due: '2024-08-04T18:15:34.500Z', 304 | stability: 0.4072, 305 | difficulty: 7.2102, 306 | elapsed_days: 0, 307 | scheduled_days: 1, 308 | reps: 1, 309 | lapses: 0, 310 | learning_steps: 0, 311 | state: 'Review', 312 | last_review: '2024-08-03T18:15:34.500Z', 313 | nid: 82, 314 | suspended: false, 315 | deleted: false, 316 | } as CardInput 317 | r = f.get_retrievability(card, view_date) 318 | expect(r).toEqual('100.00%') 319 | }) 320 | }) 321 | -------------------------------------------------------------------------------- /__tests__/models.test.ts: -------------------------------------------------------------------------------- 1 | import { Rating, RatingType, State, StateType } from '../src/fsrs' 2 | 3 | describe('State', () => { 4 | it('use State.New', () => { 5 | expect(State.New).toEqual(0) 6 | expect(0).toEqual(State.New) 7 | expect(State[State.New]).toEqual('New') 8 | expect((0 as State).valueOf()).toEqual(0) 9 | expect(State['New' as StateType]).toEqual(0) 10 | }) 11 | 12 | it('use State.Learning', () => { 13 | expect(State.Learning).toEqual(1) 14 | expect(1).toEqual(State.Learning) 15 | expect(State[State.Learning]).toEqual('Learning') 16 | expect((1 as State).valueOf()).toEqual(1) 17 | expect(State['Learning' as StateType]).toEqual(1) 18 | }) 19 | 20 | it('use State.Review', () => { 21 | expect(State.Review).toEqual(2) 22 | expect(2).toEqual(State.Review) 23 | expect(State[State.Review]).toEqual('Review') 24 | expect((2 as State).valueOf()).toEqual(2) 25 | expect(State['Review' as StateType]).toEqual(2) 26 | }) 27 | 28 | it('use State.Relearning', () => { 29 | expect(State.Relearning).toEqual(3) 30 | expect(3).toEqual(State.Relearning) 31 | expect(State[State.Relearning]).toEqual('Relearning') 32 | expect((3 as State).valueOf()).toEqual(3) 33 | expect(State['Relearning' as StateType]).toEqual(3) 34 | }) 35 | }) 36 | 37 | describe('Rating', () => { 38 | it('use Rating.Again', () => { 39 | expect(Rating.Again).toEqual(1) 40 | expect(1).toEqual(Rating.Again) 41 | expect(Rating[Rating.Again]).toEqual('Again') 42 | expect((1 as Rating).valueOf()).toEqual(1) 43 | expect(Rating['Again' as RatingType]).toEqual(1) 44 | }) 45 | 46 | it('use Rating.Hard', () => { 47 | expect(Rating.Hard).toEqual(2) 48 | expect(2).toEqual(Rating.Hard) 49 | expect(Rating[Rating.Hard]).toEqual('Hard') 50 | expect((2 as Rating).valueOf()).toEqual(2) 51 | expect(Rating['Hard' as RatingType]).toEqual(2) 52 | }) 53 | 54 | it('use Rating.Good', () => { 55 | expect(Rating.Good).toEqual(3) 56 | expect(3).toEqual(Rating.Good) 57 | expect(Rating[Rating.Good]).toEqual('Good') 58 | expect((3 as Rating).valueOf()).toEqual(3) 59 | expect(Rating['Good' as RatingType]).toEqual(3) 60 | }) 61 | 62 | it('use Rating.Easy', () => { 63 | expect(Rating.Easy).toEqual(4) 64 | expect(4).toEqual(Rating.Easy) 65 | expect(Rating[Rating.Easy]).toEqual('Easy') 66 | expect((4 as Rating).valueOf()).toEqual(4) 67 | expect(Rating['Easy' as RatingType]).toEqual(4) 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /__tests__/rollback.test.ts: -------------------------------------------------------------------------------- 1 | import { createEmptyCard, fsrs, FSRS, Grade, Rating } from '../src/fsrs' 2 | 3 | describe('FSRS rollback', () => { 4 | const f: FSRS = fsrs({ 5 | w: [ 6 | 1.14, 1.01, 5.44, 14.67, 5.3024, 1.5662, 1.2503, 0.0028, 1.5489, 0.1763, 7 | 0.9953, 2.7473, 0.0179, 0.3105, 0.3976, 0.0, 2.0902, 8 | ], 9 | enable_fuzz: false, 10 | }) 11 | it('first rollback', () => { 12 | const card = createEmptyCard() 13 | const now = new Date(2022, 11, 29, 12, 30, 0, 0) 14 | const scheduling_cards = f.repeat(card, now) 15 | const grade: Grade[] = [Rating.Again, Rating.Hard, Rating.Good, Rating.Easy] 16 | for (const rating of grade) { 17 | const rollbackCard = f.rollback( 18 | scheduling_cards[rating].card, 19 | scheduling_cards[rating].log 20 | ) 21 | expect(rollbackCard).toEqual(card) 22 | } 23 | }) 24 | 25 | it('rollback 2', () => { 26 | let card = createEmptyCard() 27 | let now = new Date(2022, 11, 29, 12, 30, 0, 0) 28 | let scheduling_cards = f.repeat(card, now) 29 | card = scheduling_cards['4'].card 30 | now = card.due 31 | scheduling_cards = f.repeat(card, now) 32 | const grade: Grade[] = [Rating.Again, Rating.Hard, Rating.Good, Rating.Easy] 33 | for (const rating of grade) { 34 | const rollbackCard = f.rollback( 35 | scheduling_cards[rating].card, 36 | scheduling_cards[rating].log 37 | ) 38 | expect(rollbackCard).toEqual(card) 39 | } 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /__tests__/show_diff_message.test.ts: -------------------------------------------------------------------------------- 1 | import { fixDate, show_diff_message } from '../src/fsrs' 2 | 3 | test('show_diff_message_bad_type', () => { 4 | const TIMEUNITFORMAT_TEST = ['秒', '分', '时', '天', '月', '年'] 5 | //https://github.com/ishiko732/ts-fsrs/issues/19 6 | const t1 = '1970-01-01T00:00:00.000Z' 7 | const t2 = '1970-01-02T00:00:00.000Z' 8 | const t3 = '1970-01-01 00:00:00' 9 | const t4 = '1970-01-02 00:00:00' 10 | 11 | const t5 = 0 12 | const t6 = 1000 * 60 * 60 * 24 13 | // @ts-ignore 14 | expect(show_diff_message(t2, t1)).toBe('1') 15 | // @ts-ignore 16 | expect(show_diff_message(t2, t1, true)).toEqual('1day') 17 | expect(fixDate(t2).dueFormat(fixDate(t1), true, TIMEUNITFORMAT_TEST)).toEqual( 18 | '1天' 19 | ) 20 | 21 | // @ts-ignore 22 | expect(show_diff_message(t4, t3)).toBe('1') 23 | // @ts-ignore 24 | expect(show_diff_message(t4, t3, true)).toEqual('1day') 25 | expect(fixDate(t4).dueFormat(fixDate(t3), true, TIMEUNITFORMAT_TEST)).toEqual( 26 | '1天' 27 | ) 28 | 29 | // @ts-ignore 30 | expect(show_diff_message(t6, t5)).toBe('1') 31 | // @ts-ignore 32 | expect(show_diff_message(t6, t5, true)).toEqual('1day') 33 | expect(fixDate(t6).dueFormat(fixDate(t5), true, TIMEUNITFORMAT_TEST)).toEqual( 34 | '1天' 35 | ) 36 | }) 37 | 38 | test('show_diff_message_min', () => { 39 | const TIMEUNITFORMAT_TEST = ['秒', '分', '时', '天', '月', '年'] 40 | //https://github.com/ishiko732/ts-fsrs/issues/19 41 | const t1 = new Date() 42 | const t2 = new Date(t1.getTime() + 1000 * 60) 43 | const t3 = new Date(t1.getTime() + 1000 * 60 * 59) 44 | expect(show_diff_message(t2, t1)).toBe('1') 45 | expect(show_diff_message(t2, t1, true)).toEqual('1min') 46 | expect(fixDate(t2).dueFormat(fixDate(t1), true, TIMEUNITFORMAT_TEST)).toEqual( 47 | '1分' 48 | ) 49 | 50 | expect(show_diff_message(t3, t1, true)).toEqual('59min') 51 | expect(fixDate(t3).dueFormat(fixDate(t1), true, TIMEUNITFORMAT_TEST)).toEqual( 52 | '59分' 53 | ) 54 | }) 55 | 56 | test('show_diff_message_hour', () => { 57 | const TIMEUNITFORMAT_TEST = ['秒', '分', '小时', '天', '月', '年'] 58 | //https://github.com/ishiko732/ts-fsrs/issues/19 59 | const t1 = new Date() 60 | const t2 = new Date(t1.getTime() + 1000 * 60 * 60) 61 | const t3 = new Date(t1.getTime() + 1000 * 60 * 60 * 59) 62 | expect(show_diff_message(t2, t1)).toBe('1') 63 | 64 | expect(show_diff_message(t2, t1, true)).toEqual('1hour') 65 | expect(fixDate(t2).dueFormat(fixDate(t1), true, TIMEUNITFORMAT_TEST)).toEqual( 66 | '1小时' 67 | ) 68 | 69 | expect(show_diff_message(t3, t1, true)).not.toBe('59hour') 70 | expect( 71 | fixDate(t3).dueFormat(fixDate(t1), true, TIMEUNITFORMAT_TEST) 72 | ).not.toEqual('59小时') 73 | 74 | expect(show_diff_message(t3, t1, true)).toBe('2day') 75 | expect(fixDate(t3).dueFormat(fixDate(t1), true, TIMEUNITFORMAT_TEST)).toEqual( 76 | '2天' 77 | ) 78 | }) 79 | 80 | test('show_diff_message_day', () => { 81 | const TIMEUNITFORMAT_TEST = ['秒', '分', '小时', '天', '个月', '年'] 82 | //https://github.com/ishiko732/ts-fsrs/issues/19 83 | const t1 = new Date() 84 | const t2 = new Date(t1.getTime() + 1000 * 60 * 60 * 24) 85 | const t3 = new Date(t1.getTime() + 1000 * 60 * 60 * 24 * 30) 86 | const t4 = new Date(t1.getTime() + 1000 * 60 * 60 * 24 * 31) 87 | expect(show_diff_message(t2, t1)).toBe('1') 88 | expect(show_diff_message(t2, t1, true)).toEqual('1day') 89 | expect(fixDate(t2).dueFormat(fixDate(t1), true, TIMEUNITFORMAT_TEST)).toEqual( 90 | '1天' 91 | ) 92 | 93 | expect(show_diff_message(t3, t1)).toBe('30') 94 | expect(show_diff_message(t3, t1, true)).toEqual('30day') 95 | expect(fixDate(t3).dueFormat(fixDate(t1), true, TIMEUNITFORMAT_TEST)).toEqual( 96 | '30天' 97 | ) 98 | 99 | expect(show_diff_message(t4, t1)).not.toBe('31') 100 | expect(show_diff_message(t4, t1, true)).toEqual('1month') 101 | expect(fixDate(t4).dueFormat(fixDate(t1), true, TIMEUNITFORMAT_TEST)).toEqual( 102 | '1个月' 103 | ) 104 | }) 105 | 106 | test('show_diff_message_month', () => { 107 | const TIMEUNITFORMAT_TEST = ['秒', '分', '小时', '天', '个月', '年'] 108 | //https://github.com/ishiko732/ts-fsrs/issues/19 109 | const t1 = new Date() 110 | const t2 = new Date(t1.getTime() + 1000 * 60 * 60 * 24 * 31) 111 | const t3 = new Date(t1.getTime() + 1000 * 60 * 60 * 24 * 31 * 12) 112 | const t4 = new Date(t1.getTime() + 1000 * 60 * 60 * 24 * 31 * 13) 113 | expect(show_diff_message(t2, t1)).toBe('1') 114 | expect(show_diff_message(t2, t1, true)).toEqual('1month') 115 | expect(fixDate(t2).dueFormat(fixDate(t1), true, TIMEUNITFORMAT_TEST)).toEqual( 116 | '1个月' 117 | ) 118 | 119 | expect(show_diff_message(t3, t1)).not.toBe('12') 120 | expect(show_diff_message(t3, t1, true)).not.toEqual('12month') 121 | expect( 122 | fixDate(t3).dueFormat(fixDate(t1), true, TIMEUNITFORMAT_TEST) 123 | ).not.toEqual('12个月') 124 | 125 | expect(show_diff_message(t4, t1)).not.toBe('13') 126 | expect(show_diff_message(t4, t1, true)).toEqual('1year') 127 | expect(fixDate(t4).dueFormat(fixDate(t1), true, TIMEUNITFORMAT_TEST)).toEqual( 128 | '1年' 129 | ) 130 | }) 131 | 132 | test('show_diff_message_year', () => { 133 | const TIMEUNITFORMAT_TEST = ['秒', '分', '小时', '天', '个月', '年'] 134 | //https://github.com/ishiko732/ts-fsrs/issues/19 135 | const t1 = new Date() 136 | const t2 = new Date(t1.getTime() + 1000 * 60 * 60 * 24 * 31 * 13) 137 | const t3 = new Date( 138 | t1.getTime() + 1000 * 60 * 60 * 24 * 31 * 13 + 1000 * 60 * 60 * 24 139 | ) 140 | const t4 = new Date( 141 | t1.getTime() + 1000 * 60 * 60 * 24 * 31 * 24 + 1000 * 60 * 60 * 24 142 | ) 143 | expect(show_diff_message(t2, t1)).toBe('1') 144 | expect(show_diff_message(t2, t1, true)).toEqual('1year') 145 | expect(fixDate(t2).dueFormat(fixDate(t1), true, TIMEUNITFORMAT_TEST)).toEqual( 146 | '1年' 147 | ) 148 | 149 | expect(show_diff_message(t3, t1)).toBe('1') 150 | expect(show_diff_message(t3, t1, true)).toEqual('1year') 151 | expect(fixDate(t3).dueFormat(fixDate(t1), true, TIMEUNITFORMAT_TEST)).toEqual( 152 | '1年' 153 | ) 154 | 155 | expect(show_diff_message(t4, t1)).toBe('2') 156 | expect(show_diff_message(t4, t1, true)).toEqual('2year') 157 | expect(fixDate(t4).dueFormat(fixDate(t1), true, TIMEUNITFORMAT_TEST)).toEqual( 158 | '2年' 159 | ) 160 | }) 161 | 162 | test('wrong timeUnit length', () => { 163 | const TIMEUNITFORMAT_TEST = ['年'] 164 | const t1 = new Date() 165 | const t2 = new Date(t1.getTime() + 1000 * 60 * 60 * 24 * 31 * 13) 166 | expect(show_diff_message(t2, t1)).toBe('1') 167 | expect(show_diff_message(t2, t1, true)).toEqual('1year') 168 | expect( 169 | fixDate(t2).dueFormat(fixDate(t1), true, TIMEUNITFORMAT_TEST) 170 | ).not.toEqual('1年') 171 | expect(fixDate(t2).dueFormat(fixDate(t1), true, TIMEUNITFORMAT_TEST)).toEqual( 172 | '1year' 173 | ) 174 | }) 175 | 176 | test('Date data real type is string/number', () => { 177 | const TIMEUNITFORMAT_TEST = ['年'] 178 | const t1 = new Date() 179 | const t2 = new Date( 180 | t1.getTime() + 1000 * 60 * 60 * 24 * 31 * 13 181 | ).toDateString() 182 | expect(show_diff_message(t2, t1.getTime())).toBe('1') 183 | expect(show_diff_message(t2, t1.toUTCString(), true)).toEqual('1year') 184 | expect( 185 | fixDate(t2).dueFormat(fixDate(t1), true, TIMEUNITFORMAT_TEST) 186 | ).not.toEqual('1年') 187 | expect(fixDate(t2).dueFormat(fixDate(t1), true, TIMEUNITFORMAT_TEST)).toEqual( 188 | '1year' 189 | ) 190 | }) 191 | -------------------------------------------------------------------------------- /__tests__/strategies/seed.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AbstractScheduler, 3 | Card, 4 | createEmptyCard, 5 | DefaultInitSeedStrategy, 6 | fsrs, 7 | GenSeedStrategyWithCardId, 8 | Rating, 9 | StrategyMode, 10 | } from '../../src/fsrs' 11 | 12 | interface ICard extends Card { 13 | card_id: number 14 | } 15 | 16 | describe('seed strategy', () => { 17 | it('default seed strategy', () => { 18 | const seedStrategy = DefaultInitSeedStrategy 19 | const f = fsrs().useStrategy(StrategyMode.SEED, seedStrategy) 20 | const now = Date.UTC(2022, 11, 29, 12, 30, 0, 0) 21 | 22 | const card = createEmptyCard(now, (card: Card) => { 23 | Object.assign(card, { card_id: 555 }) 24 | return card as ICard 25 | }) 26 | 27 | const record = f.repeat(card, now) 28 | const strategies = new Map([[StrategyMode.SEED, seedStrategy]]) 29 | const scheduler = new f['Scheduler']( 30 | card, 31 | now, 32 | f, 33 | strategies 34 | ) as AbstractScheduler 35 | 36 | const seed = seedStrategy.bind(scheduler)() 37 | console.debug('seed', seed) 38 | 39 | expect(f['_seed']).toBe(seed) 40 | }) 41 | }) 42 | 43 | describe('seed strategy with card ID', () => { 44 | it('use seedStrategy', () => { 45 | const seedStrategy = GenSeedStrategyWithCardId('card_id') 46 | const f = fsrs().useStrategy(StrategyMode.SEED, seedStrategy) 47 | 48 | expect(f['strategyHandler'].get(StrategyMode.SEED)).toBe(seedStrategy) 49 | 50 | f.clearStrategy() 51 | expect(f['strategyHandler'].get(StrategyMode.SEED)).toBeUndefined() 52 | }) 53 | it('clear seedStrategy', () => { 54 | const seedStrategy = GenSeedStrategyWithCardId('card_id') 55 | const f = fsrs().useStrategy(StrategyMode.SEED, seedStrategy) 56 | const now = Date.UTC(2022, 11, 29, 12, 30, 0, 0) 57 | 58 | const card = createEmptyCard(now, (card: Card) => { 59 | Object.assign(card, { card_id: 555 }) 60 | return card as ICard 61 | }) 62 | 63 | f.repeat(card, now) 64 | const strategies = new Map([[StrategyMode.SEED, seedStrategy]]) 65 | 66 | let scheduler = new f['Scheduler']( 67 | card, 68 | now, 69 | f, 70 | strategies 71 | ) as AbstractScheduler 72 | 73 | const seed_with_card_id = seedStrategy.bind(scheduler)() 74 | console.debug('seed with card_id=555', seed_with_card_id) 75 | 76 | f.clearStrategy(StrategyMode.SEED) 77 | 78 | f.repeat(card, now) 79 | 80 | scheduler = new f['Scheduler']( 81 | card, 82 | now, 83 | f, 84 | new Map([[StrategyMode.SEED, DefaultInitSeedStrategy]]) 85 | ) as AbstractScheduler 86 | const basic_seed = DefaultInitSeedStrategy.bind(scheduler)() 87 | console.debug('basic_seed with card_id=555', basic_seed) 88 | 89 | expect(f['_seed']).toBe(basic_seed) 90 | 91 | expect(seed_with_card_id).not.toBe(basic_seed) 92 | }) 93 | 94 | it('exist card_id', () => { 95 | const seedStrategy = GenSeedStrategyWithCardId('card_id') 96 | const f = fsrs().useStrategy(StrategyMode.SEED, seedStrategy) 97 | const now = Date.UTC(2022, 11, 29, 12, 30, 0, 0) 98 | 99 | const card = createEmptyCard(now, (card: Card) => { 100 | Object.assign(card, { card_id: 555 }) 101 | return card as ICard 102 | }) 103 | 104 | const record = f.repeat(card, now) 105 | const strategies = new Map([[StrategyMode.SEED, seedStrategy]]) 106 | const scheduler = new f['Scheduler']( 107 | card, 108 | now, 109 | f, 110 | strategies 111 | ) as AbstractScheduler 112 | 113 | const seed = seedStrategy.bind(scheduler)() 114 | console.debug('seed with card_id=555', seed) 115 | 116 | expect(f['_seed']).toBe(seed) 117 | }) 118 | 119 | it('not exist card_id', () => { 120 | const seedStrategy = GenSeedStrategyWithCardId('card_id') 121 | const f = fsrs().useStrategy(StrategyMode.SEED, seedStrategy) 122 | const now = Date.UTC(2022, 11, 29, 12, 30, 0, 0) 123 | 124 | const card = createEmptyCard(now) 125 | 126 | const record = f.repeat(card, now) 127 | const strategies = new Map([[StrategyMode.SEED, seedStrategy]]) 128 | const scheduler = new f['Scheduler']( 129 | card, 130 | now, 131 | f, 132 | strategies 133 | ) as AbstractScheduler 134 | 135 | const seed = seedStrategy.bind(scheduler)() 136 | console.debug('seed with card_id=undefined(default)', seed) 137 | 138 | expect(f['_seed']).toBe(seed) 139 | }) 140 | 141 | it('card_id = -1', () => { 142 | const seedStrategy = GenSeedStrategyWithCardId('card_id') 143 | const f = fsrs().useStrategy(StrategyMode.SEED, seedStrategy) 144 | const now = Date.UTC(2022, 11, 29, 12, 30, 0, 0) 145 | 146 | const card = createEmptyCard(now, (card: Card) => { 147 | Object.assign(card, { card_id: -1 }) 148 | return card as ICard 149 | }) 150 | 151 | const record = f.repeat(card, now) 152 | const strategies = new Map([[StrategyMode.SEED, seedStrategy]]) 153 | const scheduler = new f['Scheduler']( 154 | card, 155 | now, 156 | f, 157 | strategies 158 | ) as AbstractScheduler 159 | 160 | const seed = seedStrategy.bind(scheduler)() 161 | console.debug('with card_id=-1', seed) 162 | 163 | expect(f['_seed']).toBe(seed) 164 | expect(f['_seed']).toBe('0') 165 | }) 166 | 167 | it('card_id is undefined', () => { 168 | const seedStrategy = GenSeedStrategyWithCardId('card_id') 169 | const f = fsrs().useStrategy(StrategyMode.SEED, seedStrategy) 170 | const now = Date.UTC(2022, 11, 29, 12, 30, 0, 0) 171 | 172 | const card = createEmptyCard(now, (card: Card) => { 173 | Object.assign(card, { card_id: undefined }) 174 | return card as ICard 175 | }) 176 | 177 | const item = f.next(card, now, Rating.Good) 178 | const strategies = new Map([[StrategyMode.SEED, seedStrategy]]) 179 | const scheduler = new f['Scheduler']( 180 | card, 181 | now, 182 | f, 183 | strategies 184 | ) as AbstractScheduler 185 | 186 | const seed = seedStrategy.bind(scheduler)() 187 | console.debug('seed with card_id=undefined', seed) 188 | 189 | expect(f['_seed']).toBe(seed) 190 | expect(f['_seed']).toBe(`${item.card.reps}`) 191 | }) 192 | 193 | it('card_id is null', () => { 194 | const seedStrategy = GenSeedStrategyWithCardId('card_id') 195 | const f = fsrs().useStrategy(StrategyMode.SEED, seedStrategy) 196 | const now = Date.UTC(2022, 11, 29, 12, 30, 0, 0) 197 | 198 | const card = createEmptyCard(now, (card: Card) => { 199 | Object.assign(card, { card_id: null }) 200 | return card as ICard 201 | }) 202 | 203 | const item = f.next(card, now, Rating.Good) 204 | const strategies = new Map([[StrategyMode.SEED, seedStrategy]]) 205 | const scheduler = new f['Scheduler']( 206 | card, 207 | now, 208 | f, 209 | strategies 210 | ) as AbstractScheduler 211 | 212 | const seed = seedStrategy.bind(scheduler)() 213 | console.debug('seed with card_id=null', seed) 214 | 215 | expect(f['_seed']).toBe(seed) 216 | expect(f['_seed']).toBe(`${item.card.reps}`) 217 | }) 218 | }) 219 | -------------------------------------------------------------------------------- /__tests__/version.test.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 2 | // @ts-ignore 3 | import { version } from '../package.json' 4 | import { FSRSVersion } from '../src/fsrs' 5 | 6 | test('TS-FSRS-Version', () => { 7 | // v3.5.7 using FSRS V5.0 8 | // test 3.5.7 9 | expect(version).toBe(FSRSVersion.split(' ')[0].slice(1)) 10 | }) 11 | -------------------------------------------------------------------------------- /debug/index.ts: -------------------------------------------------------------------------------- 1 | import { FSRSVersion } from '../src/fsrs' 2 | import { runLongTerm } from './long-term' 3 | import { runShortTerm } from './short-term' 4 | 5 | console.log(FSRSVersion) 6 | 7 | 8 | runShortTerm() 9 | runLongTerm() -------------------------------------------------------------------------------- /debug/long-term.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import { createEmptyCard, fsrs, Grade, Rating } from '../src/fsrs' 3 | 4 | const f = fsrs({ enable_short_term: false }) 5 | 6 | function test1() { 7 | let card = createEmptyCard() 8 | let now = new Date(2022, 11, 29, 12, 30, 0, 0) 9 | const ratings: Grade[] = [ 10 | Rating.Good, 11 | Rating.Good, 12 | Rating.Good, 13 | Rating.Good, 14 | Rating.Good, 15 | Rating.Good, 16 | Rating.Again, 17 | Rating.Again, 18 | Rating.Good, 19 | Rating.Good, 20 | Rating.Good, 21 | Rating.Good, 22 | Rating.Good, 23 | ] 24 | const ivl_history: number[] = [] 25 | const s_history: number[] = [] 26 | const d_history: number[] = [] 27 | for (const rating of ratings) { 28 | const record = f.repeat(card, now)[rating] 29 | card = record.card 30 | ivl_history.push(card.scheduled_days) 31 | s_history.push(card.stability) 32 | d_history.push(card.difficulty) 33 | now = card.due 34 | } 35 | 36 | assert.deepStrictEqual( 37 | ivl_history, 38 | [ 39 | 3, 11, 35, 101, 269, 669, 40 | 12, 2, 5, 12, 26, 55, 41 | 112 42 | ] 43 | ) 44 | assert.deepStrictEqual( 45 | s_history, 46 | [ 47 | 3.173, 10.73892592, 48 | 34.57762416, 100.74831139, 49 | 269.283835, 669.30934162, 50 | 11.89873732, 2.23603312, 51 | 5.20013908, 11.89928679, 52 | 26.49170577, 55.49486382, 53 | 111.97264222 54 | ] 55 | ) 56 | assert.deepStrictEqual( 57 | d_history, 58 | [ 59 | 5.28243442, 5.27296793, 60 | 5.26354498, 5.25416538, 61 | 5.24482893, 5.23553542, 62 | 6.76539959, 7.79401833, 63 | 7.77299855, 7.75207546, 64 | 7.73124862, 7.71051758, 65 | 7.68988191 66 | ] 67 | ) 68 | } 69 | 70 | export function runLongTerm() { 71 | test1() 72 | } 73 | -------------------------------------------------------------------------------- /debug/short-term.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import { createEmptyCard, fsrs, Grade, Rating } from '../src/fsrs' 3 | 4 | const f = fsrs() 5 | 6 | function test1() { 7 | let card = createEmptyCard() 8 | let now = new Date(2022, 11, 29, 12, 30, 0, 0) 9 | const ratings: Grade[] = [ 10 | Rating.Good, 11 | Rating.Good, 12 | Rating.Good, 13 | Rating.Good, 14 | Rating.Good, 15 | Rating.Good, 16 | Rating.Again, 17 | Rating.Again, 18 | Rating.Good, 19 | Rating.Good, 20 | Rating.Good, 21 | Rating.Good, 22 | Rating.Good, 23 | ] 24 | const ivl_history: number[] = [] 25 | const s_history: number[] = [] 26 | const d_history: number[] = [] 27 | for (const rating of ratings) { 28 | const record = f.repeat(card, now)[rating] 29 | card = record.card 30 | ivl_history.push(card.scheduled_days) 31 | s_history.push(card.stability) 32 | d_history.push(card.difficulty) 33 | now = card.due 34 | } 35 | 36 | assert.deepStrictEqual( 37 | ivl_history, 38 | [ 39 | 0, 4, 14, 44, 125, 328, 40 | 0, 0, 7, 16, 34, 71, 41 | 142 42 | ] 43 | ) 44 | assert.deepStrictEqual( 45 | s_history, 46 | [ 47 | 3.173, 4.46685806, 48 | 14.21728391, 43.7250927, 49 | 124.79655286, 328.47343304, 50 | 9.25594883, 4.63749438, 51 | 6.5285311, 15.55546765, 52 | 34.36241506, 71.10191819, 53 | 141.83400645 54 | ] 55 | ) 56 | assert.deepStrictEqual( 57 | d_history, 58 | [ 59 | 5.28243442, 5.27296793, 60 | 5.26354498, 5.25416538, 61 | 5.24482893, 5.23553542, 62 | 6.76539959, 7.79401833, 63 | 7.77299855, 7.75207546, 64 | 7.73124862, 7.71051758, 65 | 7.68988191 66 | ] 67 | ) 68 | } 69 | 70 | export function runShortTerm() { 71 | test1() 72 | } 73 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import pluginJs from '@eslint/js' 2 | import eslintConfigPrettier from 'eslint-config-prettier' 3 | import tseslint from 'typescript-eslint' 4 | 5 | /** @type {import('eslint').Linter.Config[]} */ 6 | export default [ 7 | pluginJs.configs.recommended, 8 | ...tseslint.configs.recommended, 9 | { 10 | plugins: { 11 | '@typescript-eslint': tseslint.plugin, 12 | }, 13 | 14 | languageOptions: { 15 | parser: tseslint.parser, 16 | }, 17 | 18 | rules: { 19 | 'no-console': 'warn', 20 | 'require-await': 'error', 21 | 22 | '@typescript-eslint/no-unused-vars': 'warn', 23 | '@typescript-eslint/no-namespace': 'warn', 24 | '@typescript-eslint/no-empty-interface': 'error', 25 | }, 26 | }, 27 | eslintConfigPrettier, 28 | { 29 | ignores: ['dist/*'], 30 | }, 31 | ] 32 | -------------------------------------------------------------------------------- /example/example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 22 | TS-FSRS example 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /example/example.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | const { generatorParameters, fsrs, createEmptyCard, State, Rating } = tsfsrs 3 | 4 | const App = ({ cardRecord, logRecord }) => { 5 | const [cards, setCards] = React.useState(cardRecord || []) 6 | const [logs, setLogs] = React.useState(logRecord || []) 7 | const [f, setF] = React.useState(fsrs()) 8 | return 9 |
Current TS-FSRS Version:{tsfsrs.FSRSVersion}
10 |
Example
11 |
12 | 13 | 14 |
15 |
16 | 17 |
; 18 | }; 19 | const root = ReactDOM.createRoot(document.getElementById('root')); 20 | root.render(); -------------------------------------------------------------------------------- /example/exampleComponent.jsx: -------------------------------------------------------------------------------- 1 | const { State, Rating, createEmptyCard, generatorParameters, fsrs } = tsfsrs; 2 | 3 | const ExampleCard = ({ cardRecord, f, className }) => { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {cardRecord.map((record, index) => ( 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | ))} 37 | 38 | 39 |
indexduestatelast_reviewstabilitydifficultyRelapsed_daysscheduled_daysrepslapses
{index + 1}{record.due.toLocaleString()}{`${record.state}(${State[record.state]})`}{record.last_review.toLocaleString()}{record.stability.toFixed(2)}{record.difficulty.toFixed(2)}{f.get_retrievability(record, record.due) || "/"}{record.elapsed_days}{record.scheduled_days}{record.reps}{record.lapses}
40 | ); 41 | }; 42 | 43 | const ExampleLog = ({ logRecord, className }) => { 44 | return ( 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | {logRecord.map((record) => ( 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | ))} 69 | 70 | 71 |
#ratingstatedueelapsed_daysscheduled_daysreview
{"=>"}{`${record.rating}(${Rating[record.rating]})`}{`${record.state}(${State[record.state]})`}{record.due.toLocaleString()}{record.elapsed_days}{record.scheduled_days}{record.review.toLocaleString()}
72 | ); 73 | }; 74 | 75 | const ScheduledButton = ({ rating, children, handleClick, tip }) => { 76 | return ( 77 | 86 | ); 87 | }; 88 | 89 | const ExampleGenerator = ({ f, cards, setCards, setLogs, className }) => { 90 | const [review, setReview] = React.useState(new Date()); 91 | React.useEffect(() => { 92 | if (cards.length > 0) { 93 | setReview(cards[cards.length - 1].due); 94 | } 95 | }, [cards]); 96 | const handleClick = (e, rating) => { 97 | console.log(Rating[rating]); 98 | const preCard = 99 | cards.length > 0 ? cards[cards.length - 1] : createEmptyCard(new Date()); 100 | console.log(f.parameters); 101 | const scheduling_cards = f.repeat(preCard, preCard.due); 102 | console.log(scheduling_cards); 103 | setCards((pre) => [...pre, scheduling_cards[rating].card]); 104 | setLogs((pre) => [...pre, scheduling_cards[rating].log]); 105 | }; 106 | return ( 107 |
108 | 113 | Again 114 | 115 | 120 | Hard 121 | 122 | 127 | Good 128 | 129 | 134 | Easy 135 | 136 |
Next review:{review.toLocaleString()}
137 |
138 | ); 139 | }; 140 | 141 | const ParamsComponent = ({ f, setF }) => { 142 | const handleChange = (key, value) => { 143 | console.log(key, value); 144 | setF((pre) => { 145 | const newF = fsrs({ 146 | ...pre.parameters, 147 | [key]: value, 148 | }); 149 | return newF; 150 | }); 151 | }; 152 | 153 | return ( 154 |
155 |
Parameters:
156 | 159 | 169 | e.target.value > 0 && 170 | e.target.value < 1 && 171 | handleChange("request_retention", e.target.value) 172 | } 173 | /> 174 |
175 | Represents the probability of the target memory you want. Note that 176 | there is a trade-off between higher retention rates and higher 177 | repetition rates. It is recommended that you set this value between 0.8 178 | and 0.9. 179 |
180 | 181 | 184 | 194 | e.target.value > 0 && handleChange("maximum_interval", e.target.value) 195 | } 196 | /> 197 |
198 | The maximum number of days between reviews of a card. When the review 199 | interval of a card reaches this number of days, the{" "} 200 | {`'hard', 'good', and 'easy'`} intervals will be consistent. The shorter 201 | the interval, the more workload. 202 |
203 | 204 | 207 | { 214 | let value = e.target.value; 215 | if (value[0] !== "[") { 216 | value = `[${value}]`; 217 | } 218 | handleChange("w", JSON.parse(value)); 219 | }} 220 | /> 221 |
222 | Weights created by running the FSRS optimizer. By default, these are 223 | calculated from a sample dataset. 224 |
225 | 226 |
227 | 230 | handleChange("enable_fuzz", e.target.checked)} 237 | /> 238 |
239 |
240 | When enabled, this adds a small random delay to the new interval time to 241 | prevent cards from sticking together and always being reviewed on the 242 | same day. 243 |
244 | 245 |
246 | 249 | handleChange("enable_short_term", e.target.checked)} 256 | /> 257 |
258 |
259 | When disabled, this allow user to skip the short-term schedule. 260 |
261 |
262 |
263 |
current apply parameters:
264 |
{Object.keys(f.parameters).map(key=>

265 | {`${key} : ${JSON.stringify(f.parameters[key])}`} 266 |

)}
267 |
268 |
269 | ); 270 | }; 271 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | export default { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | testMatch: [ 6 | '**/__tests__/*.ts?(x)', 7 | '**/__tests__/**/*.ts?(x)', 8 | ], 9 | collectCoverage: true, 10 | coverageReporters: ['text', 'cobertura'], 11 | coverageThreshold: { 12 | global: { 13 | lines: 80, 14 | }, 15 | }, 16 | transformIgnorePatterns: ['/node_modules/(?!(module-to-transform)/)'], 17 | } 18 | -------------------------------------------------------------------------------- /jsr.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://jsr.io/schema/config-file.v1.json", 3 | "exports": { 4 | ".": "./src/fsrs/index.ts", 5 | "./algorithm": "./src/fsrs/algorithm.ts", 6 | "./constants": "./src/fsrs/constants.ts" 7 | }, 8 | "name": "@fsrs/ts-fsrs", 9 | "version": "5.0.1", 10 | "license": "MIT", 11 | "publish": { 12 | "include": [ 13 | "LICENSE", 14 | "README.md", 15 | "src/**/*.ts" 16 | ], 17 | "exclude": [ 18 | "__tests__" 19 | ] 20 | } 21 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ts-fsrs", 3 | "version": "5.0.1", 4 | "description": "ts-fsrs is a versatile package written in TypeScript that supports ES modules, CommonJS, and UMD. It implements the Free Spaced Repetition Scheduler (FSRS) algorithm, enabling developers to integrate FSRS into their flashcard applications to enhance the user learning experience.", 5 | "types": "dist/index.d.ts", 6 | "main": "dist/index.cjs", 7 | "module": "dist/index.mjs", 8 | "exports": { 9 | ".": { 10 | "require": "./dist/index.cjs", 11 | "import": "./dist/index.mjs", 12 | "umd": "./dist/index.umd.js", 13 | "default": "./dist/index.mjs", 14 | "types": "./dist/index.d.ts" 15 | } 16 | }, 17 | "type": "module", 18 | "scripts": { 19 | "lint": "eslint src/", 20 | "lint::fix": "eslint --fix src/ && prettier --write src/", 21 | "dev": "rollup -c rollup.config.ts --configPlugin esbuild -w", 22 | "test": "jest --config=jest.config.js --passWithNoTests", 23 | "test::coverage": "jest --config=jest.config.js --coverage", 24 | "test::publish": "yalc publish", 25 | "prebuild": "rimraf ./dist", 26 | "prepare": "rollup -c rollup.config.ts --configPlugin esbuild", 27 | "build:types": "tsc --project ./tsconfig.json --declaration true", 28 | "predocs": "rimraf ./docs", 29 | "docs": "tsx ./typedoc.ts" 30 | }, 31 | "devDependencies": { 32 | "@eslint/js": "^9.26.0", 33 | "@rollup/plugin-commonjs": "^28.0.3", 34 | "@rollup/plugin-json": "^6.1.0", 35 | "@rollup/plugin-node-resolve": "^16.0.1", 36 | "@types/jest": "^29.5.14", 37 | "@types/node": "^20.17.32", 38 | "decimal.js": "^10.5.0", 39 | "eslint": "^9.26.0", 40 | "eslint-config-prettier": "^10.1.2", 41 | "jest": "^29.7.0", 42 | "prettier": "^3.5.3", 43 | "rimraf": "^6.0.1", 44 | "rollup": "^4.40.1", 45 | "rollup-plugin-dts": "^6.2.1", 46 | "rollup-plugin-esbuild": "^6.2.1", 47 | "ts-jest": "^29.3.2", 48 | "tslib": "^2.8.1", 49 | "typedoc": "0.28.3", 50 | "typedoc-plugin-extras": "4.0.0", 51 | "tsx": "^4.19.4", 52 | "typescript": "^5.8.3", 53 | "typescript-eslint": "^8.31.1" 54 | }, 55 | "author": "ishiko", 56 | "license": "MIT", 57 | "keywords": [ 58 | "SuperMemo", 59 | "Anki", 60 | "FSRS" 61 | ], 62 | "files": [ 63 | "dist", 64 | "README.md", 65 | "LICENSE" 66 | ], 67 | "repository": { 68 | "type": "git", 69 | "url": "git+https://github.com/open-spaced-repetition/ts-fsrs.git" 70 | }, 71 | "bugs": { 72 | "url": "https://github.com/open-spaced-repetition/ts-fsrs/issues" 73 | }, 74 | "homepage": "https://github.com/open-spaced-repetition/ts-fsrs#readme", 75 | "engines": { 76 | "node": ">=18.0.0" 77 | }, 78 | "pnpm": { 79 | "overrides": { 80 | "is-core-module": "npm:@nolyfill/is-core-module@^1" 81 | } 82 | } 83 | } -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'rollup' 2 | import json from '@rollup/plugin-json' 3 | import resolve from '@rollup/plugin-node-resolve' 4 | import esbuild from 'rollup-plugin-esbuild' 5 | import commonjs from '@rollup/plugin-commonjs' 6 | import dts from 'rollup-plugin-dts' 7 | 8 | const isDev = process.env.NODE_ENV === 'development' 9 | const minify = isDev ? false : true 10 | 11 | export default defineConfig([ 12 | { 13 | input: { 14 | index: 'src/fsrs/index.ts', 15 | }, 16 | output: [ 17 | { 18 | dir: 'dist', 19 | entryFileNames: '[name].cjs', 20 | format: 'cjs', 21 | sourcemap: true, 22 | exports: 'named', 23 | footer: ({ exports }) => 24 | exports.length > 0 25 | ? 'module.exports = Object.assign(exports.default || {}, exports)' 26 | : '', 27 | }, 28 | { 29 | dir: 'dist', 30 | entryFileNames: '[name].mjs', 31 | format: 'esm', 32 | sourcemap: true, 33 | }, 34 | ], 35 | plugins: [ 36 | json({ 37 | preferConst: true, 38 | compact: true, 39 | }), 40 | resolve({ preferBuiltins: true }), 41 | esbuild({ 42 | target: 'node18.0', 43 | sourceMap: true, 44 | minify: minify, 45 | }), 46 | commonjs(), 47 | ], 48 | external: [], 49 | }, 50 | { 51 | input: 'src/fsrs/index.ts', 52 | output: { 53 | file: 'dist/index.umd.js', 54 | format: 'umd', 55 | name: 'FSRS', 56 | sourcemap: true, 57 | }, 58 | plugins: [ 59 | json({ 60 | preferConst: true, 61 | compact: true, 62 | }), 63 | resolve(), 64 | esbuild({ 65 | target: 'es2017', 66 | minify: minify, 67 | sourceMap: true, 68 | }), 69 | commonjs(), 70 | ], 71 | external: [], 72 | }, 73 | { 74 | input: 'src/fsrs/index.ts', 75 | output: { 76 | file: 'dist/index.d.ts', 77 | format: 'esm', 78 | }, 79 | plugins: [ 80 | dts({ 81 | // https://github.com/Swatinem/rollup-plugin-dts/issues/143 82 | compilerOptions: { preserveSymlinks: false }, 83 | respectExternal: true, 84 | }), 85 | ], 86 | external: [], 87 | }, 88 | ]) 89 | -------------------------------------------------------------------------------- /src/fsrs/abstract_scheduler.ts: -------------------------------------------------------------------------------- 1 | import { FSRSAlgorithm } from './algorithm' 2 | import { TypeConvert } from './convert' 3 | import { dateDiffInDays, Grades } from './help' 4 | import { 5 | type Card, 6 | type Grade, 7 | type RecordLogItem, 8 | State, 9 | Rating, 10 | type ReviewLog, 11 | type CardInput, 12 | type DateInput, 13 | } from './models' 14 | import { DefaultInitSeedStrategy } from './strategies' 15 | import { 16 | StrategyMode, 17 | TSeedStrategy, 18 | TStrategyHandler, 19 | } from './strategies/types' 20 | import type { IPreview, IScheduler } from './types' 21 | 22 | export abstract class AbstractScheduler implements IScheduler { 23 | protected last: Card 24 | protected current: Card 25 | protected review_time: Date 26 | protected next: Map = new Map() 27 | protected algorithm: FSRSAlgorithm 28 | protected strategies: Map | undefined 29 | 30 | constructor( 31 | card: CardInput | Card, 32 | now: DateInput, 33 | algorithm: FSRSAlgorithm, 34 | strategies?: Map 35 | ) { 36 | this.algorithm = algorithm 37 | this.last = TypeConvert.card(card) 38 | this.current = TypeConvert.card(card) 39 | this.review_time = TypeConvert.time(now) 40 | this.strategies = strategies 41 | this.init() 42 | } 43 | 44 | protected checkGrade(grade: Grade): void { 45 | if (!Number.isFinite(grade) || grade < 0 || grade > 4) { 46 | throw new Error(`Invalid grade "${grade}",expected 1-4`) 47 | } 48 | } 49 | 50 | private init() { 51 | const { state, last_review } = this.current 52 | let interval = 0 // card.state === State.New => 0 53 | if (state !== State.New && last_review) { 54 | interval = dateDiffInDays(last_review, this.review_time) 55 | } 56 | this.current.last_review = this.review_time 57 | this.current.elapsed_days = interval 58 | this.current.reps += 1 59 | 60 | // init seed strategy 61 | let seed_strategy = DefaultInitSeedStrategy 62 | if (this.strategies) { 63 | const custom_strategy = this.strategies.get(StrategyMode.SEED) 64 | if (custom_strategy) { 65 | seed_strategy = custom_strategy as TSeedStrategy 66 | } 67 | } 68 | this.algorithm.seed = (seed_strategy).call(this) 69 | } 70 | 71 | public preview(): IPreview { 72 | return { 73 | [Rating.Again]: this.review(Rating.Again), 74 | [Rating.Hard]: this.review(Rating.Hard), 75 | [Rating.Good]: this.review(Rating.Good), 76 | [Rating.Easy]: this.review(Rating.Easy), 77 | [Symbol.iterator]: this.previewIterator.bind(this), 78 | } satisfies IPreview 79 | } 80 | 81 | private *previewIterator(): IterableIterator { 82 | for (const grade of Grades) { 83 | yield this.review(grade) 84 | } 85 | } 86 | 87 | public review(grade: Grade): RecordLogItem { 88 | const { state } = this.last 89 | let item: RecordLogItem | undefined 90 | this.checkGrade(grade) 91 | switch (state) { 92 | case State.New: 93 | item = this.newState(grade) 94 | break 95 | case State.Learning: 96 | case State.Relearning: 97 | item = this.learningState(grade) 98 | break 99 | case State.Review: 100 | item = this.reviewState(grade) 101 | break 102 | } 103 | return item 104 | } 105 | 106 | protected abstract newState(grade: Grade): RecordLogItem 107 | 108 | protected abstract learningState(grade: Grade): RecordLogItem 109 | 110 | protected abstract reviewState(grade: Grade): RecordLogItem 111 | 112 | protected buildLog(rating: Grade): ReviewLog { 113 | const { last_review, due, elapsed_days } = this.last 114 | 115 | return { 116 | rating: rating, 117 | state: this.current.state, 118 | due: last_review || due, 119 | stability: this.current.stability, 120 | difficulty: this.current.difficulty, 121 | elapsed_days: this.current.elapsed_days, 122 | last_elapsed_days: elapsed_days, 123 | scheduled_days: this.current.scheduled_days, 124 | learning_steps: this.current.learning_steps, 125 | review: this.review_time, 126 | } satisfies ReviewLog 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/fsrs/alea.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/davidbau/seedrandom/blob/released/lib/alea.js 2 | // A port of an algorithm by Johannes Baagøe , 2010 3 | // http://baagoe.com/en/RandomMusings/javascript/ 4 | // https://github.com/nquinlan/better-random-numbers-for-javascript-mirror 5 | // Original work is under MIT license - 6 | 7 | // Copyright (C) 2010 by Johannes Baagøe 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in 17 | // all copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | // THE SOFTWARE. 26 | 27 | type State = { 28 | c: number 29 | s0: number 30 | s1: number 31 | s2: number 32 | } 33 | 34 | class Alea { 35 | private c: number 36 | private s0: number 37 | private s1: number 38 | private s2: number 39 | 40 | constructor(seed?: number | string) { 41 | const mash = Mash() 42 | this.c = 1 43 | this.s0 = mash(' ') 44 | this.s1 = mash(' ') 45 | this.s2 = mash(' ') 46 | if (seed == null) seed = +new Date() 47 | this.s0 -= mash(seed) 48 | if (this.s0 < 0) this.s0 += 1 49 | this.s1 -= mash(seed) 50 | if (this.s1 < 0) this.s1 += 1 51 | this.s2 -= mash(seed) 52 | if (this.s2 < 0) this.s2 += 1 53 | } 54 | 55 | next(): number { 56 | const t = 2091639 * this.s0 + this.c * 2.3283064365386963e-10 // 2^-32 57 | this.s0 = this.s1 58 | this.s1 = this.s2 59 | this.s2 = t - (this.c = t | 0) 60 | return this.s2 61 | } 62 | 63 | set state(state: State) { 64 | this.c = state.c 65 | this.s0 = state.s0 66 | this.s1 = state.s1 67 | this.s2 = state.s2 68 | } 69 | 70 | get state(): State { 71 | return { 72 | c: this.c, 73 | s0: this.s0, 74 | s1: this.s1, 75 | s2: this.s2, 76 | } 77 | } 78 | } 79 | 80 | function Mash() { 81 | let n = 0xefc8249d 82 | return function mash(data: string | number): number { 83 | data = String(data) 84 | for (let i = 0; i < data.length; i++) { 85 | n += data.charCodeAt(i) 86 | let h = 0.02519603282416938 * n 87 | n = h >>> 0 88 | h -= n 89 | h *= n 90 | n = h >>> 0 91 | h -= n 92 | n += h * 0x100000000 // 2^32 93 | } 94 | return (n >>> 0) * 2.3283064365386963e-10 // 2^-32 95 | } 96 | } 97 | 98 | function alea(seed?: number | string) { 99 | const xg = new Alea(seed) 100 | const prng = () => xg.next() 101 | 102 | prng.int32 = () => (xg.next() * 0x100000000) | 0 103 | prng.double = () => 104 | prng() + ((prng() * 0x200000) | 0) * 1.1102230246251565e-16 // 2^-53 105 | prng.state = () => xg.state 106 | prng.importState = (state: State) => { 107 | xg.state = state 108 | return prng 109 | } 110 | return prng 111 | } 112 | 113 | export { alea } 114 | -------------------------------------------------------------------------------- /src/fsrs/constant.ts: -------------------------------------------------------------------------------- 1 | import { version } from '../../package.json' 2 | import type { StepUnit } from './models' 3 | 4 | export const default_request_retention = 0.9 5 | export const default_maximum_interval = 36500 6 | export const default_enable_fuzz = false 7 | export const default_enable_short_term = true 8 | export const default_learning_steps: readonly StepUnit[] = Object.freeze([ 9 | '1m', 10 | '10m', 11 | ]) // New->Learning,Learning->Learning 12 | 13 | export const default_relearning_steps: readonly StepUnit[] = Object.freeze([ 14 | '10m', 15 | ]) // Relearning->Relearning 16 | 17 | export const FSRSVersion: string = `v${version} using FSRS-6.0` 18 | 19 | export const S_MIN = 0.001 20 | export const S_MAX = 36500.0 21 | export const INIT_S_MAX = 100.0 22 | export const FSRS5_DEFAULT_DECAY = 0.5 23 | export const FSRS6_DEFAULT_DECAY = 0.2 24 | export const default_w = Object.freeze([ 25 | 0.2172, 26 | 1.1771, 27 | 3.2602, 28 | 16.1507, 29 | 7.0114, 30 | 0.57, 31 | 2.0966, 32 | 0.0069, 33 | 1.5261, 34 | 0.112, 35 | 1.0178, 36 | 1.849, 37 | 0.1133, 38 | 0.3127, 39 | 2.2934, 40 | 0.2191, 41 | 3.0004, 42 | 0.7536, 43 | 0.3332, 44 | 0.1437, 45 | FSRS6_DEFAULT_DECAY, 46 | ]) satisfies readonly number[] 47 | 48 | export const W17_W18_Ceiling = 2.0 49 | export const CLAMP_PARAMETERS = (w17_w18_ceiling: number) => [ 50 | [S_MIN, INIT_S_MAX] /** initial stability (Again) */, 51 | [S_MIN, INIT_S_MAX] /** initial stability (Hard) */, 52 | [S_MIN, INIT_S_MAX] /** initial stability (Good) */, 53 | [S_MIN, INIT_S_MAX] /** initial stability (Easy) */, 54 | [1.0, 10.0] /** initial difficulty (Good) */, 55 | [0.001, 4.0] /** initial difficulty (multiplier) */, 56 | [0.001, 4.0] /** difficulty (multiplier) */, 57 | [0.001, 0.75] /** difficulty (multiplier) */, 58 | [0.0, 4.5] /** stability (exponent) */, 59 | [0.0, 0.8] /** stability (negative power) */, 60 | [0.001, 3.5] /** stability (exponent) */, 61 | [0.001, 5.0] /** fail stability (multiplier) */, 62 | [0.001, 0.25] /** fail stability (negative power) */, 63 | [0.001, 0.9] /** fail stability (power) */, 64 | [0.0, 4.0] /** fail stability (exponent) */, 65 | [0.0, 1.0] /** stability (multiplier for Hard) */, 66 | [1.0, 6.0] /** stability (multiplier for Easy) */, 67 | [0.0, w17_w18_ceiling] /** short-term stability (exponent) */, 68 | [0.0, w17_w18_ceiling] /** short-term stability (exponent) */, 69 | [0.0, 0.8] /** short-term last-stability (exponent) */, 70 | [0.1, 0.8] /** decay */, 71 | ] 72 | -------------------------------------------------------------------------------- /src/fsrs/convert.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | CardInput, 4 | Rating, 5 | ReviewLog, 6 | ReviewLogInput, 7 | State, 8 | } from './models' 9 | 10 | export class TypeConvert { 11 | static card(card: T): Card { 12 | return { 13 | ...card, 14 | state: TypeConvert.state(card.state), 15 | due: TypeConvert.time(card.due), 16 | last_review: card.last_review 17 | ? TypeConvert.time(card.last_review) 18 | : undefined, 19 | } as Card 20 | } 21 | static rating(value: unknown): Rating { 22 | if (typeof value === 'string') { 23 | const firstLetter = value.charAt(0).toUpperCase() 24 | const restOfString = value.slice(1).toLowerCase() 25 | const ret = Rating[`${firstLetter}${restOfString}` as keyof typeof Rating] 26 | if (ret === undefined) { 27 | throw new Error(`Invalid rating:[${value}]`) 28 | } 29 | return ret 30 | } else if (typeof value === 'number') { 31 | return value as Rating 32 | } 33 | throw new Error(`Invalid rating:[${value}]`) 34 | } 35 | static state(value: unknown): State { 36 | if (typeof value === 'string') { 37 | const firstLetter = value.charAt(0).toUpperCase() 38 | const restOfString = value.slice(1).toLowerCase() 39 | const ret = State[`${firstLetter}${restOfString}` as keyof typeof State] 40 | if (ret === undefined) { 41 | throw new Error(`Invalid state:[${value}]`) 42 | } 43 | return ret 44 | } else if (typeof value === 'number') { 45 | return value as State 46 | } 47 | throw new Error(`Invalid state:[${value}]`) 48 | } 49 | static time(value: unknown): Date { 50 | if (typeof value === 'object' && value instanceof Date) { 51 | return value 52 | } else if (typeof value === 'string') { 53 | const timestamp = Date.parse(value) 54 | if (!isNaN(timestamp)) { 55 | return new Date(timestamp) 56 | } else { 57 | throw new Error(`Invalid date:[${value}]`) 58 | } 59 | } else if (typeof value === 'number') { 60 | return new Date(value) 61 | } 62 | throw new Error(`Invalid date:[${value}]`) 63 | } 64 | static review_log(log: ReviewLogInput | ReviewLog): ReviewLog { 65 | return { 66 | ...log, 67 | due: TypeConvert.time(log.due), 68 | rating: TypeConvert.rating(log.rating), 69 | state: TypeConvert.state(log.state), 70 | review: TypeConvert.time(log.review), 71 | } satisfies ReviewLog 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/fsrs/default.ts: -------------------------------------------------------------------------------- 1 | import { Card, DateInput, FSRSParameters, State } from './models' 2 | import { TypeConvert } from './convert' 3 | import { clamp } from './help' 4 | import { 5 | CLAMP_PARAMETERS, 6 | default_enable_fuzz, 7 | default_enable_short_term, 8 | default_learning_steps, 9 | default_maximum_interval, 10 | default_relearning_steps, 11 | default_request_retention, 12 | default_w, 13 | FSRS5_DEFAULT_DECAY, 14 | W17_W18_Ceiling, 15 | } from './constant' 16 | 17 | export const clipParameters = ( 18 | parameters: number[], 19 | numRelearningSteps: number 20 | ) => { 21 | let w17_w18_ceiling = W17_W18_Ceiling 22 | if (Math.max(0, numRelearningSteps) > 1) { 23 | // PLS = w11 * D ^ -w12 * [(S + 1) ^ w13 - 1] * e ^ (w14 * (1 - R)) 24 | // PLS * e ^ (num_relearning_steps * w17 * w18) should be <= S 25 | // Given D = 1, R = 0.7, S = 1, PLS is equal to w11 * (2 ^ w13 - 1) * e ^ (w14 * 0.3) 26 | // So num_relearning_steps * w17 * w18 + ln(w11) + ln(2 ^ w13 - 1) + w14 * 0.3 should be <= ln(1) 27 | // => num_relearning_steps * w17 * w18 <= - ln(w11) - ln(2 ^ w13 - 1) - w14 * 0.3 28 | // => w17 * w18 <= -[ln(w11) + ln(2 ^ w13 - 1) + w14 * 0.3] / num_relearning_steps 29 | const value = 30 | -( 31 | Math.log(parameters[11]) + 32 | Math.log(Math.pow(2.0, parameters[13]) - 1.0) + 33 | parameters[14] * 0.3 34 | ) / numRelearningSteps 35 | 36 | w17_w18_ceiling = clamp(+value.toFixed(8), 0.01, 2.0) 37 | } 38 | const clip = CLAMP_PARAMETERS(w17_w18_ceiling) 39 | return clip.map(([min, max], index) => clamp(parameters[index], min, max)) 40 | } 41 | 42 | /** 43 | * @returns The input if the parameters are valid, throws if they are invalid 44 | * @example 45 | * try { 46 | * generatorParameters({ 47 | * w: checkParameters([0.40255]) 48 | * }); 49 | * } catch (e: any) { 50 | * alert(e); 51 | * } 52 | */ 53 | export const checkParameters = (parameters: number[] | readonly number[]) => { 54 | const invalid = parameters.find((param) => !isFinite(param) && !isNaN(param)) 55 | if (invalid !== undefined) { 56 | throw Error(`Non-finite or NaN value in parameters ${parameters}`) 57 | } else if (![17, 19, 21].includes(parameters.length)) { 58 | throw Error( 59 | `Invalid parameter length: ${parameters.length}. Must be 17, 19 or 21 for FSRSv4, 5 and 6 respectively.` 60 | ) 61 | } 62 | return parameters 63 | } 64 | 65 | export const migrateParameters = ( 66 | parameters?: number[] | readonly number[] 67 | ) => { 68 | if (parameters === undefined) { 69 | return [...default_w] 70 | } 71 | switch (parameters.length) { 72 | case 21: 73 | return [...parameters] 74 | case 19: 75 | console.debug('[FSRS-6]auto fill w from 19 to 21 length') 76 | return [...parameters, 0.0, FSRS5_DEFAULT_DECAY] 77 | case 17: { 78 | const w = [...parameters] 79 | w[4] = +(w[5] * 2.0 + w[4]).toFixed(8) 80 | w[5] = +(Math.log(w[5] * 3.0 + 1.0) / 3.0).toFixed(8) 81 | w[6] = +(w[6] + 0.5).toFixed(8) 82 | console.debug('[FSRS-6]auto fill w from 17 to 21 length') 83 | return w.concat([0.0, 0.0, 0.0, FSRS5_DEFAULT_DECAY]) 84 | } 85 | default: 86 | // To throw use "checkParameters" 87 | // ref: https://github.com/open-spaced-repetition/ts-fsrs/pull/174#discussion_r2070436201 88 | console.warn('[FSRS]Invalid parameters length, using default parameters') 89 | return [...default_w] 90 | } 91 | } 92 | 93 | export const generatorParameters = ( 94 | props?: Partial 95 | ): FSRSParameters => { 96 | const learning_steps = Array.isArray(props?.learning_steps) 97 | ? props!.learning_steps 98 | : default_learning_steps 99 | const relearning_steps = Array.isArray(props?.relearning_steps) 100 | ? props!.relearning_steps 101 | : default_relearning_steps 102 | const w = clipParameters(migrateParameters(props?.w), relearning_steps.length) 103 | return { 104 | request_retention: props?.request_retention || default_request_retention, 105 | maximum_interval: props?.maximum_interval || default_maximum_interval, 106 | w: w, 107 | enable_fuzz: props?.enable_fuzz ?? default_enable_fuzz, 108 | enable_short_term: props?.enable_short_term ?? default_enable_short_term, 109 | learning_steps: learning_steps, 110 | relearning_steps: relearning_steps, 111 | } satisfies FSRSParameters 112 | } 113 | 114 | /** 115 | * Create an empty card 116 | * @param now Current time 117 | * @param afterHandler Convert the result to another type. (Optional) 118 | * @example 119 | * ```typescript 120 | * const card: Card = createEmptyCard(new Date()); 121 | * ``` 122 | * @example 123 | * ```typescript 124 | * interface CardUnChecked 125 | * extends Omit { 126 | * cid: string; 127 | * due: Date | number; 128 | * last_review: Date | null | number; 129 | * state: StateType; 130 | * } 131 | * 132 | * function cardAfterHandler(card: Card) { 133 | * return { 134 | * ...card, 135 | * cid: "test001", 136 | * state: State[card.state], 137 | * last_review: card.last_review ?? null, 138 | * } as CardUnChecked; 139 | * } 140 | * 141 | * const card: CardUnChecked = createEmptyCard(new Date(), cardAfterHandler); 142 | * ``` 143 | */ 144 | export function createEmptyCard( 145 | now?: DateInput, 146 | afterHandler?: (card: Card) => R 147 | ): R { 148 | const emptyCard: Card = { 149 | due: now ? TypeConvert.time(now) : new Date(), 150 | stability: 0, 151 | difficulty: 0, 152 | elapsed_days: 0, 153 | scheduled_days: 0, 154 | reps: 0, 155 | lapses: 0, 156 | learning_steps: 0, 157 | state: State.New, 158 | last_review: undefined, 159 | } 160 | if (afterHandler && typeof afterHandler === 'function') { 161 | return afterHandler(emptyCard) 162 | } else { 163 | return emptyCard as R 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/fsrs/help.ts: -------------------------------------------------------------------------------- 1 | import type { int, unit } from './types' 2 | import type { DateInput, Grade } from './models' 3 | import { Rating, State } from './models' 4 | import { TypeConvert } from './convert' 5 | 6 | declare global { 7 | export interface Date { 8 | scheduler(t: int, isDay?: boolean): Date 9 | 10 | diff(pre: Date, unit: unit): int 11 | 12 | format(): string 13 | 14 | dueFormat(last_review: Date, unit?: boolean, timeUnit?: string[]): string 15 | } 16 | } 17 | 18 | Date.prototype.scheduler = function (t: int, isDay?: boolean): Date { 19 | return date_scheduler(this, t, isDay) 20 | } 21 | 22 | /** 23 | * 当前时间与之前的时间差值 24 | * @param pre 比当前时间还要之前 25 | * @param unit 单位: days | minutes 26 | */ 27 | Date.prototype.diff = function (pre: Date, unit: unit): int { 28 | return date_diff(this, pre, unit) as int 29 | } 30 | 31 | Date.prototype.format = function (): string { 32 | return formatDate(this) 33 | } 34 | 35 | Date.prototype.dueFormat = function ( 36 | last_review: Date, 37 | unit?: boolean, 38 | timeUnit?: string[] 39 | ) { 40 | return show_diff_message(this, last_review, unit, timeUnit) 41 | } 42 | 43 | /** 44 | * 计算日期和时间的偏移,并返回一个新的日期对象。 45 | * @param now 当前日期和时间 46 | * @param t 时间偏移量,当 isDay 为 true 时表示天数,为 false 时表示分钟 47 | * @param isDay (可选)是否按天数单位进行偏移,默认为 false,表示按分钟单位计算偏移 48 | * @returns 偏移后的日期和时间对象 49 | */ 50 | export function date_scheduler( 51 | now: DateInput, 52 | t: number, 53 | isDay?: boolean 54 | ): Date { 55 | return new Date( 56 | isDay 57 | ? TypeConvert.time(now).getTime() + t * 24 * 60 * 60 * 1000 58 | : TypeConvert.time(now).getTime() + t * 60 * 1000 59 | ) 60 | } 61 | 62 | export function date_diff(now: DateInput, pre: DateInput, unit: unit): number { 63 | if (!now || !pre) { 64 | throw new Error('Invalid date') 65 | } 66 | const diff = TypeConvert.time(now).getTime() - TypeConvert.time(pre).getTime() 67 | let r = 0 68 | switch (unit) { 69 | case 'days': 70 | r = Math.floor(diff / (24 * 60 * 60 * 1000)) 71 | break 72 | case 'minutes': 73 | r = Math.floor(diff / (60 * 1000)) 74 | break 75 | } 76 | return r 77 | } 78 | 79 | export function formatDate(dateInput: DateInput): string { 80 | const date = TypeConvert.time(dateInput) 81 | const year: number = date.getFullYear() 82 | const month: number = date.getMonth() + 1 83 | const day: number = date.getDate() 84 | const hours: number = date.getHours() 85 | const minutes: number = date.getMinutes() 86 | const seconds: number = date.getSeconds() 87 | 88 | return `${year}-${padZero(month)}-${padZero(day)} ${padZero(hours)}:${padZero( 89 | minutes 90 | )}:${padZero(seconds)}` 91 | } 92 | 93 | function padZero(num: number): string { 94 | return num < 10 ? `0${num}` : `${num}` 95 | } 96 | 97 | const TIMEUNIT = [60, 60, 24, 31, 12] 98 | const TIMEUNITFORMAT = ['second', 'min', 'hour', 'day', 'month', 'year'] 99 | 100 | export function show_diff_message( 101 | due: DateInput, 102 | last_review: DateInput, 103 | unit?: boolean, 104 | timeUnit: string[] = TIMEUNITFORMAT 105 | ): string { 106 | due = TypeConvert.time(due) 107 | last_review = TypeConvert.time(last_review) 108 | if (timeUnit.length !== TIMEUNITFORMAT.length) { 109 | timeUnit = TIMEUNITFORMAT 110 | } 111 | let diff = due.getTime() - last_review.getTime() 112 | let i 113 | diff /= 1000 114 | for (i = 0; i < TIMEUNIT.length; i++) { 115 | if (diff < TIMEUNIT[i]) { 116 | break 117 | } else { 118 | diff /= TIMEUNIT[i] 119 | } 120 | } 121 | return `${Math.floor(diff)}${unit ? timeUnit[i] : ''}` 122 | } 123 | 124 | /** 125 | * 126 | * @deprecated Use TypeConvert.time instead 127 | */ 128 | export function fixDate(value: unknown) { 129 | return TypeConvert.time(value) 130 | } 131 | 132 | /** 133 | * @deprecated Use TypeConvert.state instead 134 | */ 135 | export function fixState(value: unknown): State { 136 | return TypeConvert.state(value) 137 | } 138 | 139 | /** 140 | * @deprecated Use TypeConvert.rating instead 141 | */ 142 | export function fixRating(value: unknown): Rating { 143 | return TypeConvert.rating(value) 144 | } 145 | 146 | export const Grades: Readonly = Object.freeze([ 147 | Rating.Again, 148 | Rating.Hard, 149 | Rating.Good, 150 | Rating.Easy, 151 | ]) 152 | 153 | const FUZZ_RANGES = [ 154 | { 155 | start: 2.5, 156 | end: 7.0, 157 | factor: 0.15, 158 | }, 159 | { 160 | start: 7.0, 161 | end: 20.0, 162 | factor: 0.1, 163 | }, 164 | { 165 | start: 20.0, 166 | end: Infinity, 167 | factor: 0.05, 168 | }, 169 | ] as const 170 | 171 | export function get_fuzz_range( 172 | interval: number, 173 | elapsed_days: number, 174 | maximum_interval: number 175 | ) { 176 | let delta = 1.0 177 | for (const range of FUZZ_RANGES) { 178 | delta += 179 | range.factor * Math.max(Math.min(interval, range.end) - range.start, 0.0) 180 | } 181 | interval = Math.min(interval, maximum_interval) 182 | let min_ivl = Math.max(2, Math.round(interval - delta)) 183 | const max_ivl = Math.min(Math.round(interval + delta), maximum_interval) 184 | if (interval > elapsed_days) { 185 | min_ivl = Math.max(min_ivl, elapsed_days + 1) 186 | } 187 | min_ivl = Math.min(min_ivl, max_ivl) 188 | return { min_ivl, max_ivl } 189 | } 190 | 191 | export function clamp(value: number, min: number, max: number): number { 192 | return Math.min(Math.max(value, min), max) 193 | } 194 | 195 | export function dateDiffInDays(last: Date, cur: Date) { 196 | // Discard the time and time-zone information. 197 | const utc1 = Date.UTC( 198 | last.getUTCFullYear(), 199 | last.getUTCMonth(), 200 | last.getUTCDate() 201 | ) 202 | const utc2 = Date.UTC( 203 | cur.getUTCFullYear(), 204 | cur.getUTCMonth(), 205 | cur.getUTCDate() 206 | ) 207 | 208 | return Math.floor((utc2 - utc1) / 86400000 /** 1000 * 60 * 60 * 24*/) 209 | } 210 | -------------------------------------------------------------------------------- /src/fsrs/impl/basic_scheduler.ts: -------------------------------------------------------------------------------- 1 | import { AbstractScheduler } from '../abstract_scheduler' 2 | import { TypeConvert } from '../convert' 3 | import { S_MIN } from '../constant' 4 | import { clamp } from '../help' 5 | import { 6 | type Card, 7 | CardInput, 8 | DateInput, 9 | type Grade, 10 | Rating, 11 | type RecordLogItem, 12 | State, 13 | } from '../models' 14 | import type { int } from '../types' 15 | import { 16 | StrategyMode, 17 | TLearningStepsStrategy, 18 | TStrategyHandler, 19 | } from '../strategies' 20 | import { FSRSAlgorithm } from '../algorithm' 21 | import { BasicLearningStepsStrategy } from '../strategies/learning_steps' 22 | 23 | export default class BasicScheduler extends AbstractScheduler { 24 | private learningStepsStrategy: TLearningStepsStrategy 25 | 26 | constructor( 27 | card: CardInput | Card, 28 | now: DateInput, 29 | algorithm: FSRSAlgorithm, 30 | strategies?: Map 31 | ) { 32 | super(card, now, algorithm, strategies) 33 | 34 | // init learning steps strategy 35 | let learningStepStrategy = BasicLearningStepsStrategy 36 | if (this.strategies) { 37 | const custom_strategy = this.strategies.get(StrategyMode.LEARNING_STEPS) 38 | if (custom_strategy) { 39 | learningStepStrategy = custom_strategy as TLearningStepsStrategy 40 | } 41 | } 42 | this.learningStepsStrategy = learningStepStrategy 43 | } 44 | 45 | private getLearningInfo(card: Card, grade: Grade) { 46 | const parameters = this.algorithm.parameters 47 | card.learning_steps = card.learning_steps || 0 48 | const steps_strategy = this.learningStepsStrategy( 49 | parameters, 50 | card.state, 51 | // In the original learning steps setup (Again = 5m, Hard = 10m, Good = FSRS), 52 | // not adding 1 can cause slight variations in the memory state’s ds. 53 | this.current.state === State.Learning 54 | ? card.learning_steps + 1 55 | : card.learning_steps 56 | ) 57 | const scheduled_minutes = Math.max( 58 | 0, 59 | steps_strategy[grade]?.scheduled_minutes ?? 0 60 | ) 61 | const next_steps = Math.max(0, steps_strategy[grade]?.next_step ?? 0) 62 | return { 63 | scheduled_minutes, 64 | next_steps, 65 | } 66 | } 67 | /** 68 | * @description This function applies the learning steps based on the current card's state and grade. 69 | */ 70 | private applyLearningSteps( 71 | nextCard: Card, 72 | grade: Grade, 73 | /** 74 | * returns the next state for the card (if applicable) 75 | */ 76 | to_state: State 77 | ) { 78 | const { scheduled_minutes, next_steps } = this.getLearningInfo( 79 | this.current, 80 | grade 81 | ) 82 | if ( 83 | scheduled_minutes > 0 && 84 | scheduled_minutes < 1440 /** 1440 minutes = 1 day */ 85 | ) { 86 | nextCard.learning_steps = next_steps 87 | nextCard.scheduled_days = 0 88 | nextCard.state = to_state 89 | nextCard.due = this.review_time.scheduler( 90 | Math.round(scheduled_minutes) as int, 91 | false /** true:days false: minute */ 92 | ) 93 | } else { 94 | nextCard.state = State.Review 95 | if (scheduled_minutes >= 1440) { 96 | nextCard.learning_steps = next_steps 97 | nextCard.due = this.review_time.scheduler( 98 | Math.round(scheduled_minutes) as int, 99 | false /** true:days false: minute */ 100 | ) 101 | nextCard.scheduled_days = Math.floor(scheduled_minutes / 1440) 102 | } else { 103 | nextCard.learning_steps = 0 104 | const interval = this.algorithm.next_interval( 105 | nextCard.stability, 106 | this.current.elapsed_days 107 | ) 108 | nextCard.scheduled_days = interval 109 | nextCard.due = this.review_time.scheduler(interval as int, true) 110 | } 111 | } 112 | } 113 | 114 | protected override newState(grade: Grade): RecordLogItem { 115 | const exist = this.next.get(grade) 116 | if (exist) { 117 | return exist 118 | } 119 | const next = TypeConvert.card(this.current) 120 | next.difficulty = this.algorithm.init_difficulty(grade) 121 | next.stability = this.algorithm.init_stability(grade) 122 | 123 | this.applyLearningSteps(next, grade, State.Learning) 124 | const item = { 125 | card: next, 126 | log: this.buildLog(grade), 127 | } satisfies RecordLogItem 128 | this.next.set(grade, item) 129 | return item 130 | } 131 | 132 | protected override learningState(grade: Grade): RecordLogItem { 133 | const exist = this.next.get(grade) 134 | if (exist) { 135 | return exist 136 | } 137 | const { state, difficulty, stability } = this.last 138 | const next = TypeConvert.card(this.current) 139 | next.difficulty = this.algorithm.next_difficulty(difficulty, grade) 140 | next.stability = this.algorithm.next_short_term_stability(stability, grade) 141 | this.applyLearningSteps(next, grade, state /** Learning or Relearning */) 142 | const item = { 143 | card: next, 144 | log: this.buildLog(grade), 145 | } satisfies RecordLogItem 146 | this.next.set(grade, item) 147 | return item 148 | } 149 | 150 | protected override reviewState(grade: Grade): RecordLogItem { 151 | const exist = this.next.get(grade) 152 | if (exist) { 153 | return exist 154 | } 155 | const interval = this.current.elapsed_days 156 | const { difficulty, stability } = this.last 157 | const retrievability = this.algorithm.forgetting_curve(interval, stability) 158 | const next_again = TypeConvert.card(this.current) 159 | const next_hard = TypeConvert.card(this.current) 160 | const next_good = TypeConvert.card(this.current) 161 | const next_easy = TypeConvert.card(this.current) 162 | 163 | this.next_ds( 164 | next_again, 165 | next_hard, 166 | next_good, 167 | next_easy, 168 | difficulty, 169 | stability, 170 | retrievability 171 | ) 172 | 173 | this.next_interval(next_hard, next_good, next_easy, interval) 174 | this.next_state(next_hard, next_good, next_easy) 175 | this.applyLearningSteps(next_again, Rating.Again, State.Relearning) 176 | next_again.lapses += 1 177 | 178 | const item_again = { 179 | card: next_again, 180 | log: this.buildLog(Rating.Again), 181 | } satisfies RecordLogItem 182 | const item_hard = { 183 | card: next_hard, 184 | log: super.buildLog(Rating.Hard), 185 | } satisfies RecordLogItem 186 | const item_good = { 187 | card: next_good, 188 | log: super.buildLog(Rating.Good), 189 | } satisfies RecordLogItem 190 | const item_easy = { 191 | card: next_easy, 192 | log: super.buildLog(Rating.Easy), 193 | } satisfies RecordLogItem 194 | 195 | this.next.set(Rating.Again, item_again) 196 | this.next.set(Rating.Hard, item_hard) 197 | this.next.set(Rating.Good, item_good) 198 | this.next.set(Rating.Easy, item_easy) 199 | return this.next.get(grade)! 200 | } 201 | 202 | /** 203 | * Review next_ds 204 | */ 205 | private next_ds( 206 | next_again: Card, 207 | next_hard: Card, 208 | next_good: Card, 209 | next_easy: Card, 210 | difficulty: number, 211 | stability: number, 212 | retrievability: number 213 | ): void { 214 | next_again.difficulty = this.algorithm.next_difficulty( 215 | difficulty, 216 | Rating.Again 217 | ) 218 | const nextSMin = 219 | stability / 220 | Math.exp( 221 | this.algorithm.parameters.w[17] * this.algorithm.parameters.w[18] 222 | ) 223 | const s_after_fail = this.algorithm.next_forget_stability( 224 | difficulty, 225 | stability, 226 | retrievability 227 | ) 228 | next_again.stability = clamp(+nextSMin.toFixed(8), S_MIN, s_after_fail) 229 | 230 | next_hard.difficulty = this.algorithm.next_difficulty( 231 | difficulty, 232 | Rating.Hard 233 | ) 234 | next_hard.stability = this.algorithm.next_recall_stability( 235 | difficulty, 236 | stability, 237 | retrievability, 238 | Rating.Hard 239 | ) 240 | next_good.difficulty = this.algorithm.next_difficulty( 241 | difficulty, 242 | Rating.Good 243 | ) 244 | next_good.stability = this.algorithm.next_recall_stability( 245 | difficulty, 246 | stability, 247 | retrievability, 248 | Rating.Good 249 | ) 250 | next_easy.difficulty = this.algorithm.next_difficulty( 251 | difficulty, 252 | Rating.Easy 253 | ) 254 | next_easy.stability = this.algorithm.next_recall_stability( 255 | difficulty, 256 | stability, 257 | retrievability, 258 | Rating.Easy 259 | ) 260 | } 261 | 262 | /** 263 | * Review next_interval 264 | */ 265 | private next_interval( 266 | next_hard: Card, 267 | next_good: Card, 268 | next_easy: Card, 269 | interval: number 270 | ): void { 271 | let hard_interval: int, good_interval: int 272 | hard_interval = this.algorithm.next_interval(next_hard.stability, interval) 273 | good_interval = this.algorithm.next_interval(next_good.stability, interval) 274 | hard_interval = Math.min(hard_interval, good_interval) as int 275 | good_interval = Math.max(good_interval, hard_interval + 1) as int 276 | const easy_interval = Math.max( 277 | this.algorithm.next_interval(next_easy.stability, interval), 278 | good_interval + 1 279 | ) as int 280 | 281 | next_hard.scheduled_days = hard_interval 282 | next_hard.due = this.review_time.scheduler(hard_interval, true) 283 | next_good.scheduled_days = good_interval 284 | next_good.due = this.review_time.scheduler(good_interval, true) 285 | 286 | next_easy.scheduled_days = easy_interval 287 | next_easy.due = this.review_time.scheduler(easy_interval, true) 288 | } 289 | 290 | /** 291 | * Review next_state 292 | */ 293 | private next_state(next_hard: Card, next_good: Card, next_easy: Card) { 294 | next_hard.state = State.Review 295 | next_hard.learning_steps = 0 296 | 297 | next_good.state = State.Review 298 | next_good.learning_steps = 0 299 | 300 | next_easy.state = State.Review 301 | next_easy.learning_steps = 0 302 | } 303 | } 304 | -------------------------------------------------------------------------------- /src/fsrs/impl/long_term_scheduler.ts: -------------------------------------------------------------------------------- 1 | import { AbstractScheduler } from '../abstract_scheduler' 2 | import { TypeConvert } from '../convert' 3 | import { S_MIN } from '../constant' 4 | import { clamp } from '../help' 5 | import { 6 | type Card, 7 | type Grade, 8 | Rating, 9 | type RecordLogItem, 10 | State, 11 | } from '../models' 12 | import type { int } from '../types' 13 | 14 | export default class LongTermScheduler extends AbstractScheduler { 15 | protected override newState(grade: Grade): RecordLogItem { 16 | const exist = this.next.get(grade) 17 | if (exist) { 18 | return exist 19 | } 20 | 21 | this.current.scheduled_days = 0 22 | this.current.elapsed_days = 0 23 | 24 | const next_again = TypeConvert.card(this.current) 25 | const next_hard = TypeConvert.card(this.current) 26 | const next_good = TypeConvert.card(this.current) 27 | const next_easy = TypeConvert.card(this.current) 28 | 29 | this.init_ds(next_again, next_hard, next_good, next_easy) 30 | const first_interval = 0 31 | 32 | this.next_interval( 33 | next_again, 34 | next_hard, 35 | next_good, 36 | next_easy, 37 | first_interval 38 | ) 39 | 40 | this.next_state(next_again, next_hard, next_good, next_easy) 41 | this.update_next(next_again, next_hard, next_good, next_easy) 42 | return this.next.get(grade)! 43 | } 44 | 45 | private init_ds( 46 | next_again: Card, 47 | next_hard: Card, 48 | next_good: Card, 49 | next_easy: Card 50 | ): void { 51 | next_again.difficulty = this.algorithm.init_difficulty(Rating.Again) 52 | next_again.stability = this.algorithm.init_stability(Rating.Again) 53 | 54 | next_hard.difficulty = this.algorithm.init_difficulty(Rating.Hard) 55 | next_hard.stability = this.algorithm.init_stability(Rating.Hard) 56 | 57 | next_good.difficulty = this.algorithm.init_difficulty(Rating.Good) 58 | next_good.stability = this.algorithm.init_stability(Rating.Good) 59 | 60 | next_easy.difficulty = this.algorithm.init_difficulty(Rating.Easy) 61 | next_easy.stability = this.algorithm.init_stability(Rating.Easy) 62 | } 63 | 64 | /** 65 | * @see https://github.com/open-spaced-repetition/ts-fsrs/issues/98#issuecomment-2241923194 66 | */ 67 | protected override learningState(grade: Grade): RecordLogItem { 68 | return this.reviewState(grade) 69 | } 70 | protected override reviewState(grade: Grade): RecordLogItem { 71 | const exist = this.next.get(grade) 72 | if (exist) { 73 | return exist 74 | } 75 | const interval = this.current.elapsed_days 76 | const { difficulty, stability } = this.last 77 | const retrievability = this.algorithm.forgetting_curve(interval, stability) 78 | const next_again = TypeConvert.card(this.current) 79 | const next_hard = TypeConvert.card(this.current) 80 | const next_good = TypeConvert.card(this.current) 81 | const next_easy = TypeConvert.card(this.current) 82 | 83 | this.next_ds( 84 | next_again, 85 | next_hard, 86 | next_good, 87 | next_easy, 88 | difficulty, 89 | stability, 90 | retrievability 91 | ) 92 | 93 | this.next_interval(next_again, next_hard, next_good, next_easy, interval) 94 | this.next_state(next_again, next_hard, next_good, next_easy) 95 | next_again.lapses += 1 96 | 97 | this.update_next(next_again, next_hard, next_good, next_easy) 98 | return this.next.get(grade)! 99 | } 100 | 101 | /** 102 | * Review next_ds 103 | */ 104 | private next_ds( 105 | next_again: Card, 106 | next_hard: Card, 107 | next_good: Card, 108 | next_easy: Card, 109 | difficulty: number, 110 | stability: number, 111 | retrievability: number 112 | ): void { 113 | next_again.difficulty = this.algorithm.next_difficulty( 114 | difficulty, 115 | Rating.Again 116 | ) 117 | const s_after_fail = this.algorithm.next_forget_stability( 118 | difficulty, 119 | stability, 120 | retrievability 121 | ) 122 | next_again.stability = clamp(stability, S_MIN, s_after_fail) 123 | 124 | next_hard.difficulty = this.algorithm.next_difficulty( 125 | difficulty, 126 | Rating.Hard 127 | ) 128 | next_hard.stability = this.algorithm.next_recall_stability( 129 | difficulty, 130 | stability, 131 | retrievability, 132 | Rating.Hard 133 | ) 134 | next_good.difficulty = this.algorithm.next_difficulty( 135 | difficulty, 136 | Rating.Good 137 | ) 138 | next_good.stability = this.algorithm.next_recall_stability( 139 | difficulty, 140 | stability, 141 | retrievability, 142 | Rating.Good 143 | ) 144 | next_easy.difficulty = this.algorithm.next_difficulty( 145 | difficulty, 146 | Rating.Easy 147 | ) 148 | next_easy.stability = this.algorithm.next_recall_stability( 149 | difficulty, 150 | stability, 151 | retrievability, 152 | Rating.Easy 153 | ) 154 | } 155 | 156 | /** 157 | * Review/New next_interval 158 | */ 159 | private next_interval( 160 | next_again: Card, 161 | next_hard: Card, 162 | next_good: Card, 163 | next_easy: Card, 164 | interval: number 165 | ): void { 166 | let again_interval: int, 167 | hard_interval: int, 168 | good_interval: int, 169 | easy_interval: int 170 | again_interval = this.algorithm.next_interval( 171 | next_again.stability, 172 | interval 173 | ) 174 | hard_interval = this.algorithm.next_interval(next_hard.stability, interval) 175 | good_interval = this.algorithm.next_interval(next_good.stability, interval) 176 | easy_interval = this.algorithm.next_interval(next_easy.stability, interval) 177 | 178 | again_interval = Math.min(again_interval, hard_interval) as int 179 | hard_interval = Math.max(hard_interval, again_interval + 1) as int 180 | good_interval = Math.max(good_interval, hard_interval + 1) as int 181 | easy_interval = Math.max(easy_interval, good_interval + 1) as int 182 | 183 | next_again.scheduled_days = again_interval 184 | next_again.due = this.review_time.scheduler(again_interval, true) 185 | 186 | next_hard.scheduled_days = hard_interval 187 | next_hard.due = this.review_time.scheduler(hard_interval, true) 188 | 189 | next_good.scheduled_days = good_interval 190 | next_good.due = this.review_time.scheduler(good_interval, true) 191 | 192 | next_easy.scheduled_days = easy_interval 193 | next_easy.due = this.review_time.scheduler(easy_interval, true) 194 | } 195 | 196 | /** 197 | * Review/New next_state 198 | */ 199 | private next_state( 200 | next_again: Card, 201 | next_hard: Card, 202 | next_good: Card, 203 | next_easy: Card 204 | ) { 205 | next_again.state = State.Review 206 | // next_again.lapses += 1 207 | next_again.learning_steps = 0 208 | 209 | next_hard.state = State.Review 210 | next_hard.learning_steps = 0 211 | 212 | next_good.state = State.Review 213 | next_good.learning_steps = 0 214 | 215 | next_easy.state = State.Review 216 | next_easy.learning_steps = 0 217 | } 218 | 219 | private update_next( 220 | next_again: Card, 221 | next_hard: Card, 222 | next_good: Card, 223 | next_easy: Card 224 | ) { 225 | const item_again = { 226 | card: next_again, 227 | log: this.buildLog(Rating.Again), 228 | } satisfies RecordLogItem 229 | const item_hard = { 230 | card: next_hard, 231 | log: super.buildLog(Rating.Hard), 232 | } satisfies RecordLogItem 233 | const item_good = { 234 | card: next_good, 235 | log: super.buildLog(Rating.Good), 236 | } satisfies RecordLogItem 237 | const item_easy = { 238 | card: next_easy, 239 | log: super.buildLog(Rating.Easy), 240 | } satisfies RecordLogItem 241 | 242 | this.next.set(Rating.Again, item_again) 243 | this.next.set(Rating.Hard, item_hard) 244 | this.next.set(Rating.Good, item_good) 245 | this.next.set(Rating.Easy, item_easy) 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /src/fsrs/index.ts: -------------------------------------------------------------------------------- 1 | export * from './default' 2 | export * from './constant' 3 | export * from './help' 4 | export * from './algorithm' 5 | export * from './fsrs' 6 | 7 | export type * from './types' 8 | export type { 9 | FSRSParameters, 10 | Card, 11 | ReviewLog, 12 | RecordLog, 13 | RecordLogItem, 14 | StateType, 15 | RatingType, 16 | GradeType, 17 | Grade, 18 | CardInput, 19 | ReviewLogInput, 20 | DateInput, 21 | FSRSReview, 22 | FSRSHistory, 23 | FSRSState, 24 | TimeUnit, 25 | StepUnit, 26 | Steps, 27 | } from './models' 28 | export { State, Rating } from './models' 29 | 30 | export * from './convert' 31 | 32 | export * from './strategies' 33 | export * from './abstract_scheduler' 34 | export * from './impl/basic_scheduler' 35 | export * from './impl/long_term_scheduler' 36 | -------------------------------------------------------------------------------- /src/fsrs/models.ts: -------------------------------------------------------------------------------- 1 | export type StateType = 'New' | 'Learning' | 'Review' | 'Relearning' 2 | 3 | export enum State { 4 | New = 0, 5 | Learning = 1, 6 | Review = 2, 7 | Relearning = 3, 8 | } 9 | 10 | export type RatingType = 'Manual' | 'Again' | 'Hard' | 'Good' | 'Easy' 11 | 12 | export enum Rating { 13 | Manual = 0, 14 | Again = 1, 15 | Hard = 2, 16 | Good = 3, 17 | Easy = 4, 18 | } 19 | 20 | export type GradeType = Exclude 21 | export type Grade = Exclude 22 | 23 | export interface ReviewLog { 24 | rating: Rating // Rating of the review (Again, Hard, Good, Easy) 25 | state: State // State of the review (New, Learning, Review, Relearning) 26 | due: Date // Date of the last scheduling 27 | stability: number // Memory stability during the review 28 | difficulty: number // Difficulty of the card during the review 29 | elapsed_days: number // Number of days elapsed since the last review 30 | last_elapsed_days: number // Number of days between the last two reviews 31 | scheduled_days: number // Number of days until the next review 32 | learning_steps: number // Keeps track of the current step during the (re)learning stages 33 | review: Date // Date of the review 34 | } 35 | 36 | export type RecordLogItem = { 37 | card: Card 38 | log: ReviewLog 39 | } 40 | export type RecordLog = { 41 | [key in Grade]: RecordLogItem 42 | } 43 | 44 | export interface Card { 45 | due: Date // Due date 46 | stability: number // Stability 47 | difficulty: number // Difficulty level 48 | elapsed_days: number // Number of days elapsed 49 | scheduled_days: number // Number of days scheduled 50 | learning_steps: number // Keeps track of the current step during the (re)learning stages 51 | reps: number // Repetition count 52 | lapses: number // Number of lapses or mistakes 53 | state: State // Card's state (New, Learning, Review, Relearning) 54 | last_review?: Date // Date of the last review (optional) 55 | } 56 | 57 | export interface CardInput extends Omit { 58 | state: StateType | State // Card's state (New, Learning, Review, Relearning) 59 | due: DateInput // Due date 60 | last_review?: DateInput | null // Date of the last review (optional) 61 | } 62 | 63 | export type DateInput = Date | number | string 64 | export type TimeUnit = 'm' | 'h' | 'd' 65 | export type StepUnit = `${number}${TimeUnit}` 66 | /** 67 | * (re)Learning steps: 68 | * [1m, 10m] 69 | * step1:again=1m hard=6m good=10m 70 | * step2(good): again=1m hard=10m 71 | * 72 | * [5m] 73 | * step1:again=5m hard=8m 74 | * step2(good): again=5m 75 | * step2(hard): again=5m hard=7.5m 76 | * 77 | * [] 78 | * step: Managed by FSRS 79 | * 80 | */ 81 | export type Steps = StepUnit[] | readonly StepUnit[] 82 | 83 | export interface ReviewLogInput 84 | extends Omit { 85 | rating: RatingType | Rating // Rating of the review (Again, Hard, Good, Easy) 86 | state: StateType | State // Card's state (New, Learning, Review, Relearning) 87 | due: DateInput // Due date 88 | review: DateInput // Date of the last review 89 | } 90 | 91 | export interface FSRSParameters { 92 | request_retention: number 93 | maximum_interval: number 94 | w: number[] | readonly number[] 95 | enable_fuzz: boolean 96 | /** 97 | * When enable_short_term = false, the (re)learning steps are not applied. 98 | */ 99 | enable_short_term: boolean 100 | learning_steps: Steps 101 | relearning_steps: Steps 102 | } 103 | 104 | export interface FSRSReview { 105 | /** 106 | * 0-4: Manual, Again, Hard, Good, Easy 107 | * = revlog.rating 108 | */ 109 | rating: Rating 110 | /** 111 | * The number of days that passed 112 | * = revlog.elapsed_days 113 | * = round(revlog[-1].review - revlog[-2].review) 114 | */ 115 | delta_t: number 116 | } 117 | 118 | export type FSRSHistory = Partial< 119 | Omit 120 | > & 121 | ( 122 | | { 123 | rating: Grade 124 | review: DateInput | Date 125 | } 126 | | { 127 | rating: Rating.Manual 128 | due: DateInput | Date 129 | state: State 130 | review: DateInput | Date 131 | } 132 | ) 133 | 134 | export interface FSRSState { 135 | stability: number 136 | difficulty: number 137 | } 138 | -------------------------------------------------------------------------------- /src/fsrs/reschedule.ts: -------------------------------------------------------------------------------- 1 | import { TypeConvert } from './convert' 2 | import { createEmptyCard } from './default' 3 | import type { FSRS } from './fsrs' 4 | import { 5 | type Card, 6 | type CardInput, 7 | DateInput, 8 | type FSRSHistory, 9 | type Grade, 10 | Rating, 11 | type RecordLogItem, 12 | type ReviewLog, 13 | State, 14 | } from './models' 15 | 16 | /** 17 | * The `Reschedule` class provides methods to handle the rescheduling of cards based on their review history. 18 | * determine the next review dates and update the card's state accordingly. 19 | */ 20 | export class Reschedule { 21 | private fsrs: FSRS 22 | /** 23 | * Creates an instance of the `Reschedule` class. 24 | * @param fsrs - An instance of the FSRS class used for scheduling. 25 | */ 26 | constructor(fsrs: FSRS) { 27 | this.fsrs = fsrs 28 | } 29 | 30 | /** 31 | * Replays a review for a card and determines the next review date based on the given rating. 32 | * @param card - The card being reviewed. 33 | * @param reviewed - The date the card was reviewed. 34 | * @param rating - The grade given to the card during the review. 35 | * @returns A `RecordLogItem` containing the updated card and review log. 36 | */ 37 | replay(card: Card, reviewed: Date, rating: Grade): RecordLogItem { 38 | return this.fsrs.next(card, reviewed, rating) 39 | } 40 | 41 | /** 42 | * Processes a manual review for a card, allowing for custom state, stability, difficulty, and due date. 43 | * @param card - The card being reviewed. 44 | * @param state - The state of the card after the review. 45 | * @param reviewed - The date the card was reviewed. 46 | * @param elapsed_days - The number of days since the last review. 47 | * @param stability - (Optional) The stability of the card. 48 | * @param difficulty - (Optional) The difficulty of the card. 49 | * @param due - (Optional) The due date for the next review. 50 | * @returns A `RecordLogItem` containing the updated card and review log. 51 | * @throws Will throw an error if the state or due date is not provided when required. 52 | */ 53 | handleManualRating( 54 | card: Card, 55 | state: State, 56 | reviewed: Date, 57 | elapsed_days: number, 58 | stability?: number, 59 | difficulty?: number, 60 | due?: Date 61 | ): RecordLogItem { 62 | if (typeof state === 'undefined') { 63 | throw new Error('reschedule: state is required for manual rating') 64 | } 65 | let log: ReviewLog 66 | let next_card: Card 67 | if (state === State.New) { 68 | log = { 69 | rating: Rating.Manual, 70 | state: state, 71 | due: due ?? reviewed, 72 | stability: card.stability, 73 | difficulty: card.difficulty, 74 | elapsed_days: elapsed_days, 75 | last_elapsed_days: card.elapsed_days, 76 | scheduled_days: card.scheduled_days, 77 | learning_steps: card.learning_steps, 78 | review: reviewed, 79 | } satisfies ReviewLog 80 | next_card = createEmptyCard(reviewed) 81 | next_card.last_review = reviewed 82 | } else { 83 | if (typeof due === 'undefined') { 84 | throw new Error('reschedule: due is required for manual rating') 85 | } 86 | const scheduled_days = due.diff(reviewed as Date, 'days') 87 | log = { 88 | rating: Rating.Manual, 89 | state: card.state, 90 | due: card.last_review || card.due, 91 | stability: card.stability, 92 | difficulty: card.difficulty, 93 | elapsed_days: elapsed_days, 94 | last_elapsed_days: card.elapsed_days, 95 | scheduled_days: card.scheduled_days, 96 | learning_steps: card.learning_steps, 97 | review: reviewed, 98 | } satisfies ReviewLog 99 | next_card = { 100 | ...card, 101 | state: state, 102 | due: due, 103 | last_review: reviewed, 104 | stability: stability || card.stability, 105 | difficulty: difficulty || card.difficulty, 106 | elapsed_days: elapsed_days, 107 | scheduled_days: scheduled_days, 108 | reps: card.reps + 1, 109 | } satisfies Card 110 | } 111 | 112 | return { card: next_card, log } 113 | } 114 | 115 | /** 116 | * Reschedules a card based on its review history. 117 | * 118 | * @param current_card - The card to be rescheduled. 119 | * @param reviews - An array of review history objects. 120 | * @returns An array of record log items representing the rescheduling process. 121 | */ 122 | reschedule(current_card: CardInput, reviews: FSRSHistory[]) { 123 | const collections: RecordLogItem[] = [] 124 | let cur_card = createEmptyCard(current_card.due) 125 | for (const review of reviews) { 126 | let item: RecordLogItem 127 | review.review = TypeConvert.time(review.review) 128 | if (review.rating === Rating.Manual) { 129 | // ref: abstract_scheduler.ts#init 130 | let interval = 0 131 | if (cur_card.state !== State.New && cur_card.last_review) { 132 | interval = review.review.diff(cur_card.last_review as Date, 'days') 133 | } 134 | item = this.handleManualRating( 135 | cur_card, 136 | review.state, 137 | review.review, 138 | interval, 139 | review.stability, 140 | review.difficulty, 141 | review.due ? TypeConvert.time(review.due) : undefined 142 | ) 143 | } else { 144 | item = this.replay(cur_card, review.review, review.rating) 145 | } 146 | collections.push(item) 147 | cur_card = item.card 148 | } 149 | return collections 150 | } 151 | 152 | calculateManualRecord( 153 | current_card: CardInput, 154 | now: DateInput, 155 | record_log_item?: RecordLogItem, 156 | update_memory?: boolean 157 | ): RecordLogItem | null { 158 | if (!record_log_item) { 159 | return null 160 | } 161 | // if first_card === recordItem.card then return null 162 | const { card: reschedule_card, log } = record_log_item 163 | const cur_card = TypeConvert.card(current_card) // copy card 164 | if (cur_card.due.getTime() === reschedule_card.due.getTime()) { 165 | return null 166 | } 167 | cur_card.scheduled_days = reschedule_card.due.diff( 168 | cur_card.due as Date, 169 | 'days' 170 | ) 171 | return this.handleManualRating( 172 | cur_card, 173 | reschedule_card.state, 174 | TypeConvert.time(now), 175 | log.elapsed_days, 176 | update_memory ? reschedule_card.stability : undefined, 177 | update_memory ? reschedule_card.difficulty : undefined, 178 | reschedule_card.due 179 | ) 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/fsrs/strategies/index.ts: -------------------------------------------------------------------------------- 1 | export * from './seed' 2 | export * from './learning_steps' 3 | export * from './types' 4 | -------------------------------------------------------------------------------- /src/fsrs/strategies/learning_steps.ts: -------------------------------------------------------------------------------- 1 | import { FSRSParameters, Rating, State, StepUnit, TimeUnit } from '../models' 2 | import { TLearningStepsStrategy } from './types' 3 | 4 | export const ConvertStepUnitToMinutes = (step: StepUnit): number => { 5 | const unit = step.slice(-1) as TimeUnit 6 | const value = parseInt(step.slice(0, -1), 10) 7 | if (isNaN(value) || !Number.isFinite(value) || value < 0) { 8 | throw new Error(`Invalid step value: ${step}`) 9 | } 10 | switch (unit) { 11 | case 'm': 12 | return value 13 | case 'h': 14 | return value * 60 15 | case 'd': 16 | return value * 1440 17 | default: 18 | throw new Error(`Invalid step unit: ${step}, expected m/h/d`) 19 | } 20 | } 21 | 22 | export const BasicLearningStepsStrategy: TLearningStepsStrategy = ( 23 | params: FSRSParameters, 24 | state: State, 25 | cur_step: number 26 | ) => { 27 | const learning_steps = 28 | state === State.Relearning || state === State.Review 29 | ? params.relearning_steps 30 | : params.learning_steps 31 | const steps_length = learning_steps.length 32 | // steps_length === 0 ,return empty object 33 | if (steps_length === 0 || cur_step >= steps_length) return {} 34 | 35 | // steps_length > 0 36 | const firstStep = learning_steps[0] 37 | 38 | const toMinutes = ConvertStepUnitToMinutes 39 | 40 | const getAgainInterval = (): number => { 41 | return toMinutes(firstStep) 42 | } 43 | 44 | const getHardInterval = (): number => { 45 | // steps_length > 0,return firstStep*1.5 46 | if (steps_length === 1) return Math.round(toMinutes(firstStep) * 1.5) 47 | // steps_length > 1,return (firstStep+nextStep)/2 48 | const nextStep = learning_steps[1] 49 | return Math.round((toMinutes(firstStep) + toMinutes(nextStep)) / 2) 50 | } 51 | 52 | const getStepInfo = (index: number) => { 53 | if (index < 0 || index >= steps_length) { 54 | return null 55 | } else { 56 | return learning_steps[index] 57 | } 58 | } 59 | 60 | const getGoodMinutes = (step: StepUnit): number | null => { 61 | return toMinutes(step) 62 | } 63 | 64 | const result: ReturnType = {} 65 | const step_info = getStepInfo(Math.max(0, cur_step)) 66 | // review -> again 67 | // new, learning, relearning -> again,hard,good(if next step exists) 68 | if (state === State.Review) { 69 | // review 70 | result[Rating.Again] = { 71 | scheduled_minutes: toMinutes(step_info!), 72 | next_step: 0, 73 | } 74 | return result 75 | } else { 76 | // new,learning, relearning 77 | result[Rating.Again] = { 78 | scheduled_minutes: getAgainInterval(), 79 | next_step: 0, 80 | } 81 | 82 | result[Rating.Hard] = { 83 | scheduled_minutes: getHardInterval(), 84 | next_step: cur_step, 85 | } 86 | const next_info = getStepInfo(cur_step + 1) 87 | if (next_info) { 88 | const nextMin = getGoodMinutes(next_info) 89 | 90 | if (nextMin) { 91 | result[Rating.Good] = { 92 | scheduled_minutes: Math.round(nextMin), 93 | next_step: cur_step + 1, 94 | } 95 | } 96 | } 97 | } 98 | return result 99 | } 100 | -------------------------------------------------------------------------------- /src/fsrs/strategies/seed.ts: -------------------------------------------------------------------------------- 1 | import type { AbstractScheduler } from '../abstract_scheduler' 2 | import type { TSeedStrategy } from './types' 3 | 4 | export function DefaultInitSeedStrategy(this: AbstractScheduler): string { 5 | const time = this.review_time.getTime() 6 | const reps = this.current.reps 7 | const mul = this.current.difficulty * this.current.stability 8 | return `${time}_${reps}_${mul}` 9 | } 10 | 11 | /** 12 | * Generates a seed strategy function for card IDs. 13 | * 14 | * @param card_id_field - The field name of the card ID in the current object. 15 | * @returns A function that generates a seed based on the card ID and repetitions. 16 | * 17 | * @remarks 18 | * The returned function uses the `card_id_field` to retrieve the card ID from the current object. 19 | * It then adds the number of repetitions (`reps`) to the card ID to generate the seed. 20 | * 21 | * @example 22 | * ```typescript 23 | * const seedStrategy = GenCardIdSeedStrategy('card_id'); 24 | * const f = fsrs().useStrategy(StrategyMode.SEED, seedStrategy) 25 | * const card = createEmptyCard() 26 | * card.card_id = 555 27 | * const record = f.repeat(card, new Date()) 28 | * ``` 29 | */ 30 | export function GenSeedStrategyWithCardId( 31 | card_id_field: string | number 32 | ): TSeedStrategy { 33 | return function (this: AbstractScheduler): string { 34 | // https://github.com/open-spaced-repetition/ts-fsrs/issues/131#issuecomment-2408426225 35 | const card_id = Reflect.get(this.current, card_id_field) ?? 0 36 | const reps = this.current.reps 37 | // ex1 38 | // card_id:string + reps:number = 'e2ecb1f7-8d15-420b-bec4-c7212ad2e5dc' + 4 39 | // = 'e2ecb1f7-8d15-420b-bec4-c7212ad2e5dc4' 40 | 41 | // ex2 42 | // card_id:number + reps:number = 1732452519198 + 4 43 | // = '17324525191984' 44 | return String(card_id + reps || 0) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/fsrs/strategies/types.ts: -------------------------------------------------------------------------------- 1 | import type { AbstractScheduler } from '../abstract_scheduler' 2 | import type { FSRSAlgorithm } from '../algorithm' 3 | import type { 4 | Card, 5 | CardInput, 6 | DateInput, 7 | FSRSParameters, 8 | Grade, 9 | State, 10 | } from '../models' 11 | import type { IScheduler } from '../types' 12 | 13 | export enum StrategyMode { 14 | SCHEDULER = 'Scheduler', 15 | LEARNING_STEPS = 'LearningSteps', 16 | SEED = 'Seed', 17 | } 18 | 19 | export type TSeedStrategy = (this: AbstractScheduler) => string 20 | export type TSchedulerStrategy = 21 | new ( 22 | card: T, 23 | now: DateInput, 24 | algorithm: FSRSAlgorithm, 25 | strategies: Map 26 | ) => IScheduler 27 | 28 | /** 29 | * When enable_short_term = false, the learning steps strategy will not take effect. 30 | */ 31 | export type TLearningStepsStrategy = ( 32 | params: FSRSParameters, 33 | state: State, 34 | cur_step: number 35 | ) => { 36 | [K in Grade]?: { scheduled_minutes: number; next_step: number } 37 | } 38 | 39 | type StrategyMap = { 40 | [StrategyMode.SCHEDULER]: TSchedulerStrategy 41 | [StrategyMode.SEED]: TSeedStrategy 42 | [StrategyMode.LEARNING_STEPS]: TLearningStepsStrategy 43 | } 44 | 45 | export type TStrategyHandler = E extends StrategyMode 46 | ? StrategyMap[E] 47 | : never 48 | -------------------------------------------------------------------------------- /src/fsrs/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CardInput, 3 | DateInput, 4 | FSRSHistory, 5 | Grade, 6 | RecordLog, 7 | RecordLogItem, 8 | } from './models' 9 | 10 | export type unit = 'days' | 'minutes' 11 | export type int = number & { __int__: void } 12 | export type double = number & { __double__: void } 13 | 14 | export interface IPreview extends RecordLog { 15 | [Symbol.iterator](): IterableIterator 16 | } 17 | 18 | export interface IScheduler { 19 | preview(): IPreview 20 | review(state: Grade): RecordLogItem 21 | } 22 | 23 | /** 24 | * Options for rescheduling. 25 | * 26 | * @template T - The type of the result returned by the `recordLogHandler` function. 27 | */ 28 | export type RescheduleOptions = { 29 | /** 30 | * A function that handles recording the log. 31 | * 32 | * @param recordLog - The log to be recorded. 33 | * @returns The result of recording the log. 34 | */ 35 | recordLogHandler: (recordLog: RecordLogItem) => T 36 | 37 | /** 38 | * A function that defines the order of reviews. 39 | * 40 | * @param a - The first FSRSHistory object. 41 | * @param b - The second FSRSHistory object. 42 | * @returns A negative number if `a` should be ordered before `b`, a positive number if `a` should be ordered after `b`, or 0 if they have the same order. 43 | */ 44 | reviewsOrderBy: (a: FSRSHistory, b: FSRSHistory) => number 45 | 46 | /** 47 | * Indicating whether to skip manual steps. 48 | */ 49 | skipManual: boolean 50 | 51 | /** 52 | * Indicating whether to update the FSRS memory state. 53 | */ 54 | update_memory_state: boolean 55 | 56 | /** 57 | * The current date and time. 58 | */ 59 | now: DateInput 60 | 61 | /** 62 | * The input for the first card. 63 | */ 64 | first_card?: CardInput 65 | } 66 | 67 | export type IReschedule = { 68 | collections: T[] 69 | reschedule_item: T | null 70 | } 71 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 15 | "lib": [ 16 | "esnext" 17 | ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, 18 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 19 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 20 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 21 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 22 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 23 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 24 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 25 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 26 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 27 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 28 | 29 | /* Modules */ 30 | "module": "ESNext" /* Specify what module code is generated. */, 31 | // "rootDir": "./", /* Specify the root folder within your source files. */ 32 | "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, 33 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 34 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 35 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 36 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 37 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 38 | "allowUmdGlobalAccess": true /* Allow accessing UMD globals from modules. */, 39 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 40 | "resolveJsonModule": true, /* Enable importing .json files. */ 41 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 42 | 43 | /* JavaScript Support */ 44 | "allowJs": true /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */, 45 | "checkJs": true /* Enable error reporting in type-checked JavaScript files. */, 46 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 47 | 48 | /* Emit */ 49 | "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */, 50 | "declarationMap": true /* Create sourcemaps for d.ts files. */, 51 | "emitDeclarationOnly": true /* Only output d.ts files and not JavaScript files. */, 52 | "sourceMap": true /* Create source map files for emitted JavaScript files. */, 53 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 54 | "outDir": "./dist" /* Specify an output folder for all emitted files. */, 55 | // "removeComments": true, /* Disable emitting comments. */ 56 | // "noEmit": true, /* Disable emitting files from a compilation. */ 57 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 58 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 59 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 60 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 61 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 62 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 63 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 64 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 65 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 66 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 67 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 68 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 69 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 70 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 71 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 72 | 73 | /* Interop Constraints */ 74 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 75 | "allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */, 76 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 77 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 78 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 79 | 80 | /* Type Checking */ 81 | "strict": true /* Enable all strict type-checking options. */, 82 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 83 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 84 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 85 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 86 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 87 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 88 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 89 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 90 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 91 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 92 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 93 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 94 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 95 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 96 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 97 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 98 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 99 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 100 | 101 | /* Completeness */ 102 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 103 | "skipLibCheck": false /* Skip type checking all .d.ts files. */ 104 | }, 105 | "include": ["src"], 106 | "exclude": ["node_modules", "**/__tests__/*"], 107 | "detectCycles": true 108 | } 109 | -------------------------------------------------------------------------------- /typedoc.ts: -------------------------------------------------------------------------------- 1 | import { Application, JSX } from 'typedoc' 2 | const generator = async () => { 3 | // Application.bootstrap also exists, which will not load plugins 4 | // Also accepts an array of option readers if you want to disable 5 | // TypeDoc's tsconfig.json/package.json/typedoc.json option readers 6 | const app = await Application.bootstrapWithPlugins({ 7 | name: 'TS-FSRS', 8 | titleLink: 'https://open-spaced-repetition.github.io/ts-fsrs/', 9 | entryPoints: ['./src/fsrs'], 10 | plugin: ['typedoc-plugin-extras'], 11 | out: './docs', 12 | navigationLinks: { 13 | Docs: 'https://open-spaced-repetition.github.io/ts-fsrs/', 14 | GitHub: 'https://github.com/open-spaced-repetition/ts-fsrs', 15 | }, 16 | visibilityFilters: { 17 | protected: false, 18 | private: false, 19 | inherited: false, 20 | external: false, 21 | }, 22 | exclude: ['__tests__', 'dist'], 23 | }) 24 | const project = await app.convert() 25 | 26 | // https://github.com/TypeStrong/typedoc/issues/1799 27 | app.renderer.hooks.on('head.end', () => { 28 | return JSX.createElement( 29 | JSX.Fragment, 30 | null, 31 | JSX.createElement( 32 | 'link', 33 | { 34 | rel: 'stylesheet', 35 | href: 'https://cdn.jsdelivr.net/npm/katex@0.15.1/dist/katex.min.css', 36 | integrity: 37 | 'sha384-R4558gYOUz8mP9YWpZJjofhk+zx0AS11p36HnD2ZKj/6JR5z27gSSULCNHIRReVs', 38 | crossorigin: 'anonymous', 39 | }, 40 | JSX.createElement('script', { 41 | defer: true, 42 | src: 'https://cdn.jsdelivr.net/npm/katex@0.15.1/dist/katex.min.js', 43 | integrity: 44 | 'sha384-z1fJDqw8ZApjGO3/unPWUPsIymfsJmyrDVWC8Tv/a1HeOtGmkwNd/7xUS0Xcnvsx', 45 | crossorigin: 'anonymous', 46 | }), 47 | JSX.createElement('script', { 48 | defer: true, 49 | src: 'https://cdn.jsdelivr.net/npm/katex@0.15.1/dist/contrib/auto-render.min.js', 50 | integrity: 51 | 'sha384-+XBljXPPiv+OzfbB3cVmLHf4hdUFHlWNZN5spNQ7rmHTXpd7WvJum6fIACpNNfIR', 52 | crossorigin: 'anonymous', 53 | onload: 'renderMathInElement(document.body);', 54 | }) 55 | ) 56 | ) 57 | }) 58 | 59 | if (project) { 60 | const outputDir = 'docs' 61 | // Generate HTML rendered docs 62 | await app.generateDocs(project, outputDir) 63 | } 64 | process.exit(0) 65 | } 66 | 67 | generator() 68 | --------------------------------------------------------------------------------