├── .eslintignore ├── .flowconfig ├── .babelrc ├── .gitignore ├── .eslintrc ├── lib ├── dateDiffInDays.js ├── addReview.js ├── computeCardsSchedule.js ├── types.js ├── index.js └── applyReview.js ├── dist ├── dateDiffInDays.js.flow ├── addReview.js.flow ├── computeCardsSchedule.js.flow ├── types.js.flow ├── index.js.flow ├── applyReview.js.flow ├── bundle.mjs ├── bundle.js ├── bundle.mjs.map └── bundle.js.map ├── test ├── dates.js ├── types.test.js ├── addReview.test.js ├── index.test.js ├── applyReview.test.js └── computeCardsSchedule.test.js ├── rollup.config.js ├── LICENCE ├── package.json ├── README.md └── flow-typed └── npm └── jest_v19.x.x.js /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/* 2 | node_modules/* 3 | coverage/* 4 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [include] 2 | ./lib 3 | 4 | [ignore] 5 | ./node_modules/.* 6 | 7 | [libs] 8 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "env" 4 | ], 5 | "plugins": [ 6 | "transform-flow-strip-types", 7 | "transform-object-rest-spread" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # testing 7 | coverage 8 | 9 | # production 10 | build 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | npm-debug.log 16 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "airbnb", 4 | "plugin:flowtype/recommended" 5 | ], 6 | "rules": { 7 | "no-nested-ternary": 0, 8 | "no-underscore-dangle": 0 9 | }, 10 | "parser": "babel-eslint", 11 | "plugins": [ 12 | "flowtype", 13 | "jest" 14 | ], 15 | "env": { 16 | "node": true, 17 | "browser": true, 18 | "jest/globals": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /lib/dateDiffInDays.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export default function dateDiffInDays(a: Date, b: Date): number { 4 | // adapted from http://stackoverflow.com/a/15289883/251162 5 | const MS_PER_DAY = 1000 * 60 * 60 * 24; 6 | 7 | // Disstate the time and time-zone information. 8 | const utc1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate()); 9 | const utc2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate()); 10 | 11 | return (utc2 - utc1) / MS_PER_DAY; 12 | } 13 | -------------------------------------------------------------------------------- /dist/dateDiffInDays.js.flow: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export default function dateDiffInDays(a: Date, b: Date): number { 4 | // adapted from http://stackoverflow.com/a/15289883/251162 5 | const MS_PER_DAY = 1000 * 60 * 60 * 24; 6 | 7 | // Disstate the time and time-zone information. 8 | const utc1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate()); 9 | const utc2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate()); 10 | 11 | return (utc2 - utc1) / MS_PER_DAY; 12 | } 13 | -------------------------------------------------------------------------------- /lib/addReview.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import type { Review } from './types'; 3 | 4 | // This function only works if reviews is always sorted by timestamp 5 | export default function addReview(reviews: Review[], review: Review): Review[] { 6 | if (!reviews.length) { 7 | return [review]; 8 | } 9 | 10 | let i = reviews.length - 1; 11 | for (; i >= 0; i -= 1) { 12 | if (reviews[i].ts <= review.ts) { 13 | break; 14 | } 15 | } 16 | 17 | const newReviews = reviews.slice(0); 18 | newReviews.splice(i + 1, 0, review); 19 | 20 | return newReviews; 21 | } 22 | -------------------------------------------------------------------------------- /dist/addReview.js.flow: -------------------------------------------------------------------------------- 1 | // @flow 2 | import type { Review } from './types'; 3 | 4 | // This function only works if reviews is always sorted by timestamp 5 | export default function addReview(reviews: Review[], review: Review): Review[] { 6 | if (!reviews.length) { 7 | return [review]; 8 | } 9 | 10 | let i = reviews.length - 1; 11 | for (; i >= 0; i -= 1) { 12 | if (reviews[i].ts <= review.ts) { 13 | break; 14 | } 15 | } 16 | 17 | const newReviews = reviews.slice(0); 18 | newReviews.splice(i + 1, 0, review); 19 | 20 | return newReviews; 21 | } 22 | -------------------------------------------------------------------------------- /test/dates.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export function addToDate(date: Date, days: number): Date { 4 | const result = new Date(date); 5 | result.setDate(result.getDate() + Math.ceil(days)); 6 | return result; 7 | } 8 | 9 | export const today = new Date(1970, 1, 1); 10 | 11 | export const todayAt3AM = new Date(1970, 1, 1); 12 | todayAt3AM.setHours(3, 0, 0); 13 | 14 | export const laterToday = new Date(1970, 1, 1); 15 | laterToday.setHours(10); 16 | 17 | export const laterTmrw = addToDate(laterToday, 1); 18 | 19 | export const laterInTwoDays = addToDate(laterToday, 2); 20 | 21 | export const laterInFourDays = addToDate(laterToday, 4); 22 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import cjs from 'rollup-plugin-commonjs'; 2 | import resolve from 'rollup-plugin-node-resolve'; 3 | import babel from 'rollup-plugin-babel'; 4 | 5 | const pkg = require('./package.json'); 6 | 7 | const external = Object.keys(pkg.dependencies || {}); 8 | 9 | export default { 10 | entry: 'lib/index.js', 11 | plugins: [ 12 | babel({ 13 | exclude: 'node_modules/**', // only transpile our source code 14 | babelrc: false, 15 | presets: [ 16 | ['env', { 17 | modules: false, 18 | }], 19 | ], 20 | plugins: ['transform-flow-strip-types', 'transform-object-rest-spread', 'external-helpers'], 21 | }), 22 | cjs(), 23 | resolve(), 24 | ], 25 | external, 26 | globals: { 27 | uuid: 'uuid', 28 | }, 29 | 30 | targets: [ 31 | { 32 | dest: pkg.main, 33 | format: 'umd', 34 | moduleName: pkg.name, 35 | sourceMap: true, 36 | }, 37 | { 38 | dest: pkg.module, 39 | format: 'es', 40 | sourceMap: true, 41 | }, 42 | ], 43 | }; 44 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Aaron Yodaiken. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /test/types.test.js: -------------------------------------------------------------------------------- 1 | import { generateId, getCardId, cmpSchedule } from '../lib/types'; 2 | 3 | describe('generateId()', () => { 4 | it('should return a valid UUID', () => { 5 | expect(generateId()).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); 6 | }); 7 | it('should return different UUIDs when called twice', () => { 8 | expect(generateId()).not.toEqual(generateId()); 9 | }); 10 | }); 11 | 12 | describe('getCardId()', () => { 13 | it('should return a valid "card ID" string', () => { 14 | const id = generateId(); 15 | const combination = { front: [0, 1, 2], back: [3] }; 16 | expect(getCardId({ master: id, combination })).toEqual(`${id}#0,1,2@3`); 17 | }); 18 | }); 19 | 20 | describe('cmpSchedule()', () => { 21 | it("cmpSchedule('later', 'later') should return 0", () => { 22 | expect(cmpSchedule('later', 'later')).toEqual(0); 23 | }); 24 | it("cmpSchedule('later', 'due') should return 1", () => { 25 | expect(cmpSchedule('later', 'due')).toEqual(1); 26 | }); 27 | it("cmpSchedule('later', 'overdue') should return 1", () => { 28 | expect(cmpSchedule('later', 'overdue')).toEqual(1); 29 | }); 30 | it("cmpSchedule('learning', 'later') should return -1", () => { 31 | expect(cmpSchedule('learning', 'later')).toEqual(-1); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dolphinsr", 3 | "version": "0.0.1", 4 | "description": "Spaced repetition API for JavaScript", 5 | "main": "dist/bundle.js", 6 | "module": "dist/bundle.mjs", 7 | "jsnext:main": "dist/rollup-starter-project.mjs", 8 | "repository": "https://github.com/yodaiken/dolphinsr", 9 | "author": "Aaron Yodaiken", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "babel-eslint": "^7.2.1", 13 | "babel-jest": "^19.0.0", 14 | "babel-plugin-external-helpers": "^6.22.0", 15 | "babel-plugin-transform-flow-strip-types": "^6.22.0", 16 | "babel-plugin-transform-object-rest-spread": "^6.23.0", 17 | "babel-preset-env": "^1.3.3", 18 | "babel-preset-es2015": "^6.24.1", 19 | "eslint": "^3.19.0", 20 | "eslint-config-airbnb": "^14.1.0", 21 | "eslint-plugin-flowtype": "^2.30.4", 22 | "eslint-plugin-import": "^2.2.0", 23 | "eslint-plugin-jest": "^19.0.1", 24 | "eslint-plugin-jsx-a11y": "^4.0.0", 25 | "eslint-plugin-react": "^6.10.3", 26 | "eslint-watch": "^3.0.1", 27 | "flow-bin": "^0.43.1", 28 | "flow-copy-source": "^1.1.0", 29 | "jest": "^19.0.2", 30 | "rollup": "^0.41.6", 31 | "rollup-plugin-babel": "^2.7.1", 32 | "rollup-plugin-commonjs": "^8.0.2", 33 | "rollup-plugin-node-resolve": "^3.0.0", 34 | "rollup-watch": "^3.2.2" 35 | }, 36 | "scripts": { 37 | "lint": "eslint lib test --color", 38 | "flow": "flow check", 39 | "prebuild": "npm run lint", 40 | "build": "rollup -c && flow-copy-source lib dist", 41 | "postbuild": "npm test", 42 | "test:dev": "jest", 43 | "test:watch": "jest --watch", 44 | "test": "TEST_DIST=1 jest", 45 | "lint:watch": "esw --color --watch --fix", 46 | "prepublish": "npm test" 47 | }, 48 | "dependencies": { 49 | "debug": "^2.6.3", 50 | "uuid": "^3.0.1" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /test/addReview.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import addReview from '../lib/addReview'; 4 | import * as dates from './dates'; 5 | import type { Review } from '../lib/types'; 6 | import { generateId } from '../lib/types'; 7 | 8 | 9 | const master = generateId(); 10 | function makeReview(ts: Date): Review { 11 | return { 12 | master, 13 | ts, 14 | combination: { front: [0], back: [1] }, 15 | rating: 'easy', 16 | }; 17 | } 18 | 19 | const reviews = [ 20 | dates.today, 21 | dates.todayAt3AM, 22 | dates.laterToday, 23 | dates.laterTmrw, 24 | dates.laterInTwoDays, 25 | dates.laterInFourDays, 26 | ].map(makeReview); 27 | 28 | describe('addReview()', () => { 29 | it('should add a review to an empty list', () => { 30 | expect(addReview([], reviews[0])) 31 | .toEqual([reviews[0]]); 32 | }); 33 | it('should add a later review after a earlier review', () => { 34 | expect(addReview([reviews[0]], reviews[1])) 35 | .toEqual([reviews[0], reviews[1]]); 36 | }); 37 | it('should add an earlier review before a later review', () => { 38 | expect(addReview([reviews[1]], reviews[0])) 39 | .toEqual([reviews[0], reviews[1]]); 40 | }); 41 | it('should add an earlier review before a couple later reviews', () => { 42 | expect(addReview(reviews.slice(1), reviews[0])) 43 | .toEqual(reviews); 44 | }); 45 | it('should add a review in between reviews', () => { 46 | expect(addReview([reviews[0], reviews[1], reviews[2], reviews[4], reviews[5]], reviews[3])) 47 | .toEqual(reviews); 48 | }); 49 | it('should add an unidentical review with a same timestamp after', () => { 50 | const r: Review = makeReview(dates.today); 51 | const s: Review = makeReview(dates.today); 52 | s.rating = 'again'; 53 | 54 | expect(addReview([r], s)) 55 | .toEqual([r, s]); 56 | expect(addReview([s], r)) 57 | .toEqual([s, r]); 58 | 59 | expect(addReview([r, ...reviews], s)) 60 | .toEqual([r, reviews[0], s, ...reviews.slice(1)]); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /lib/computeCardsSchedule.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import type { CardState, ReviewingCardState, Schedule, CardsSchedule, State, CardId } from './types'; 4 | import { getCardId } from './types'; 5 | import dateDiffInDays from './dateDiffInDays'; 6 | 7 | // assumes that the day starts at 3:00am in the local timezone 8 | export function calculateDueDate(state: ReviewingCardState): Date { 9 | const result = new Date(state.lastReviewed); 10 | result.setHours(3, 0, 0); 11 | result.setDate(result.getDate() + Math.ceil(state.interval)); 12 | return result; 13 | } 14 | 15 | export function computeScheduleFromCardState(state: CardState, now: Date): Schedule { 16 | if (state.mode === 'lapsed' || state.mode === 'learning') { 17 | return 'learning'; 18 | } else if (state.mode === 'reviewing') { 19 | const diff = dateDiffInDays(calculateDueDate(state), now); 20 | if (diff < 0) { 21 | return 'later'; 22 | } else if (diff >= 0 && diff < 1) { 23 | return 'due'; 24 | } else if (diff >= 1) { 25 | return 'overdue'; 26 | } 27 | } 28 | throw new Error('unreachable'); 29 | } 30 | 31 | // Breaks ties first by last review (earlier beats later), 32 | // then by an alphabetical comparison of the cardId (just so it stays 100% deterministic) 33 | // 34 | // Returns null if no cards are due. 35 | export function pickMostDue(s: CardsSchedule, state: State): ?CardId { 36 | const prec: Schedule[] = ['learning', 'overdue', 'due']; 37 | for (let i = 0; i < prec.length; i += 1) { 38 | const sched = prec[i]; 39 | if (s[sched].length) { 40 | return s[sched].slice(0).sort((a, b) => { 41 | const cardA = state.cardStates[a]; 42 | const cardB = state.cardStates[b]; 43 | if (cardA == null) { 44 | throw new Error(`id not found in state: ${a}`); 45 | } 46 | if (cardB == null) { 47 | throw new Error(`id not found in state: ${b}`); 48 | } 49 | 50 | const reviewDiff = ( 51 | (cardA.lastReviewed == null && cardB.lastReviewed != null) ? 1 : 52 | (cardB.lastReviewed == null && cardA.lastReviewed != null) ? -1 : 53 | (cardA.lastReviewed == null && cardB.lastReviewed == null) ? 0 : 54 | (cardB.lastReviewed: any) - (cardA.lastReviewed: any) 55 | ); 56 | if (reviewDiff !== 0) { 57 | return -reviewDiff; 58 | } 59 | 60 | if (a === b) { 61 | throw new Error(`comparing duplicate id: ${a}`); 62 | } 63 | return b > a ? 1 : -1; 64 | })[0]; 65 | } 66 | } 67 | return null; 68 | } 69 | 70 | export default function computeCardsSchedule(state: State, now: Date): CardsSchedule { 71 | const s: CardsSchedule = { 72 | learning: [], 73 | later: [], 74 | due: [], 75 | overdue: [], 76 | }; 77 | Object.keys(state.cardStates).forEach((cardId) => { 78 | const cardState = state.cardStates[cardId]; 79 | s[computeScheduleFromCardState(cardState, now)].push(getCardId(cardState)); 80 | }); 81 | return s; 82 | } 83 | -------------------------------------------------------------------------------- /dist/computeCardsSchedule.js.flow: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import type { CardState, ReviewingCardState, Schedule, CardsSchedule, State, CardId } from './types'; 4 | import { getCardId } from './types'; 5 | import dateDiffInDays from './dateDiffInDays'; 6 | 7 | // assumes that the day starts at 3:00am in the local timezone 8 | export function calculateDueDate(state: ReviewingCardState): Date { 9 | const result = new Date(state.lastReviewed); 10 | result.setHours(3, 0, 0); 11 | result.setDate(result.getDate() + Math.ceil(state.interval)); 12 | return result; 13 | } 14 | 15 | export function computeScheduleFromCardState(state: CardState, now: Date): Schedule { 16 | if (state.mode === 'lapsed' || state.mode === 'learning') { 17 | return 'learning'; 18 | } else if (state.mode === 'reviewing') { 19 | const diff = dateDiffInDays(calculateDueDate(state), now); 20 | if (diff < 0) { 21 | return 'later'; 22 | } else if (diff >= 0 && diff < 1) { 23 | return 'due'; 24 | } else if (diff >= 1) { 25 | return 'overdue'; 26 | } 27 | } 28 | throw new Error('unreachable'); 29 | } 30 | 31 | // Breaks ties first by last review (earlier beats later), 32 | // then by an alphabetical comparison of the cardId (just so it stays 100% deterministic) 33 | // 34 | // Returns null if no cards are due. 35 | export function pickMostDue(s: CardsSchedule, state: State): ?CardId { 36 | const prec: Schedule[] = ['learning', 'overdue', 'due']; 37 | for (let i = 0; i < prec.length; i += 1) { 38 | const sched = prec[i]; 39 | if (s[sched].length) { 40 | return s[sched].slice(0).sort((a, b) => { 41 | const cardA = state.cardStates[a]; 42 | const cardB = state.cardStates[b]; 43 | if (cardA == null) { 44 | throw new Error(`id not found in state: ${a}`); 45 | } 46 | if (cardB == null) { 47 | throw new Error(`id not found in state: ${b}`); 48 | } 49 | 50 | const reviewDiff = ( 51 | (cardA.lastReviewed == null && cardB.lastReviewed != null) ? 1 : 52 | (cardB.lastReviewed == null && cardA.lastReviewed != null) ? -1 : 53 | (cardA.lastReviewed == null && cardB.lastReviewed == null) ? 0 : 54 | (cardB.lastReviewed: any) - (cardA.lastReviewed: any) 55 | ); 56 | if (reviewDiff !== 0) { 57 | return -reviewDiff; 58 | } 59 | 60 | if (a === b) { 61 | throw new Error(`comparing duplicate id: ${a}`); 62 | } 63 | return b > a ? 1 : -1; 64 | })[0]; 65 | } 66 | } 67 | return null; 68 | } 69 | 70 | export default function computeCardsSchedule(state: State, now: Date): CardsSchedule { 71 | const s: CardsSchedule = { 72 | learning: [], 73 | later: [], 74 | due: [], 75 | overdue: [], 76 | }; 77 | Object.keys(state.cardStates).forEach((cardId) => { 78 | const cardState = state.cardStates[cardId]; 79 | s[computeScheduleFromCardState(cardState, now)].push(getCardId(cardState)); 80 | }); 81 | return s; 82 | } 83 | -------------------------------------------------------------------------------- /lib/types.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import uuid from 'uuid'; 4 | 5 | // Generally all types should be considered opaque in application code. 6 | 7 | // -- Data types 8 | 9 | export type Id = string; 10 | export function generateId(): Id { 11 | return uuid.v4(); 12 | } 13 | 14 | export type Field = string; 15 | 16 | // numbers are indexes on master.fields 17 | export type Combination = {front: number[], back: number[], }; 18 | 19 | export type CardId = string; 20 | export function getCardId(o: {master: Id, combination: Combination}): CardId { 21 | return `${o.master}#${o.combination.front.join(',')}@${o.combination.back.join(',')}`; 22 | } 23 | 24 | 25 | export type Master = { 26 | id: Id, 27 | fields: Array, 28 | combinations: Array, 29 | } 30 | 31 | export type Rating = 'easy' | 'good' | 'hard' | 'again'; 32 | 33 | export type Review = { 34 | master: Id, 35 | combination: Combination, 36 | ts: Date, 37 | rating: Rating, 38 | } 39 | 40 | // -- Computed data types 41 | 42 | export type Card = { 43 | master: Id, 44 | combination: Combination, 45 | front: Field[], 46 | back: Field[] 47 | }; 48 | 49 | export type LearningCardState = { 50 | master: Id, 51 | combination: Combination, 52 | 53 | mode: 'learning', 54 | consecutiveCorrect: number, // 0 <= consecutiveCorrect < 2, int 55 | lastReviewed: ?Date 56 | }; 57 | export type ReviewingCardState = { 58 | master: Id, 59 | combination: Combination, 60 | 61 | mode: 'reviewing', 62 | factor: number, // float 63 | lapses: number, // int 64 | interval: number, // days since lastReviewed 65 | lastReviewed: Date 66 | }; 67 | export type LapsedCardState = { 68 | master: Id, 69 | combination: Combination, 70 | 71 | mode: 'lapsed', 72 | consecutiveCorrect: number, 73 | factor: number, 74 | lapses: number, 75 | interval: number, 76 | lastReviewed: Date, 77 | }; 78 | export type CardState = LearningCardState | ReviewingCardState | LapsedCardState; 79 | export function makeInitialCardState(master: Id, combination: Combination): LearningCardState { 80 | return { 81 | master, 82 | combination, 83 | 84 | mode: 'learning', 85 | consecutiveCorrect: 0, 86 | lastReviewed: null, 87 | }; 88 | } 89 | 90 | export type State = { 91 | cardStates: {[CardId]: CardState}, 92 | }; 93 | export function makeEmptyState(): State { 94 | return { 95 | cardStates: {}, 96 | }; 97 | } 98 | 99 | export type Schedule = 'later' | 'due' | 'overdue' | 'learning'; 100 | export function cmpSchedule(a: Schedule, b: Schedule) { 101 | const scheduleVals = { 102 | later: 0, 103 | due: 1, 104 | overdue: 2, 105 | learning: 3, 106 | }; 107 | const diff = scheduleVals[b] - scheduleVals[a]; 108 | if (diff < 0) { 109 | return -1; 110 | } else if (diff > 0) { 111 | return 1; 112 | } 113 | return 0; 114 | } 115 | 116 | export type CardsSchedule = { 117 | 'later': Array, 118 | 'due': Array, 119 | 'overdue': Array, 120 | 'learning': Array 121 | }; 122 | 123 | export type SummaryStatistics = { 124 | 'later': number, 125 | 'due': number, 126 | 'overdue': number, 127 | 'learning': number 128 | }; 129 | -------------------------------------------------------------------------------- /dist/types.js.flow: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import uuid from 'uuid'; 4 | 5 | // Generally all types should be considered opaque in application code. 6 | 7 | // -- Data types 8 | 9 | export type Id = string; 10 | export function generateId(): Id { 11 | return uuid.v4(); 12 | } 13 | 14 | export type Field = string; 15 | 16 | // numbers are indexes on master.fields 17 | export type Combination = {front: number[], back: number[], }; 18 | 19 | export type CardId = string; 20 | export function getCardId(o: {master: Id, combination: Combination}): CardId { 21 | return `${o.master}#${o.combination.front.join(',')}@${o.combination.back.join(',')}`; 22 | } 23 | 24 | 25 | export type Master = { 26 | id: Id, 27 | fields: Array, 28 | combinations: Array, 29 | } 30 | 31 | export type Rating = 'easy' | 'good' | 'hard' | 'again'; 32 | 33 | export type Review = { 34 | master: Id, 35 | combination: Combination, 36 | ts: Date, 37 | rating: Rating, 38 | } 39 | 40 | // -- Computed data types 41 | 42 | export type Card = { 43 | master: Id, 44 | combination: Combination, 45 | front: Field[], 46 | back: Field[] 47 | }; 48 | 49 | export type LearningCardState = { 50 | master: Id, 51 | combination: Combination, 52 | 53 | mode: 'learning', 54 | consecutiveCorrect: number, // 0 <= consecutiveCorrect < 2, int 55 | lastReviewed: ?Date 56 | }; 57 | export type ReviewingCardState = { 58 | master: Id, 59 | combination: Combination, 60 | 61 | mode: 'reviewing', 62 | factor: number, // float 63 | lapses: number, // int 64 | interval: number, // days since lastReviewed 65 | lastReviewed: Date 66 | }; 67 | export type LapsedCardState = { 68 | master: Id, 69 | combination: Combination, 70 | 71 | mode: 'lapsed', 72 | consecutiveCorrect: number, 73 | factor: number, 74 | lapses: number, 75 | interval: number, 76 | lastReviewed: Date, 77 | }; 78 | export type CardState = LearningCardState | ReviewingCardState | LapsedCardState; 79 | export function makeInitialCardState(master: Id, combination: Combination): LearningCardState { 80 | return { 81 | master, 82 | combination, 83 | 84 | mode: 'learning', 85 | consecutiveCorrect: 0, 86 | lastReviewed: null, 87 | }; 88 | } 89 | 90 | export type State = { 91 | cardStates: {[CardId]: CardState}, 92 | }; 93 | export function makeEmptyState(): State { 94 | return { 95 | cardStates: {}, 96 | }; 97 | } 98 | 99 | export type Schedule = 'later' | 'due' | 'overdue' | 'learning'; 100 | export function cmpSchedule(a: Schedule, b: Schedule) { 101 | const scheduleVals = { 102 | later: 0, 103 | due: 1, 104 | overdue: 2, 105 | learning: 3, 106 | }; 107 | const diff = scheduleVals[b] - scheduleVals[a]; 108 | if (diff < 0) { 109 | return -1; 110 | } else if (diff > 0) { 111 | return 1; 112 | } 113 | return 0; 114 | } 115 | 116 | export type CardsSchedule = { 117 | 'later': Array, 118 | 'due': Array, 119 | 'overdue': Array, 120 | 'learning': Array 121 | }; 122 | 123 | export type SummaryStatistics = { 124 | 'later': number, 125 | 'due': number, 126 | 'overdue': number, 127 | 'learning': number 128 | }; 129 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | 4 | import type { Master } from '../lib'; 5 | import * as dates from './dates'; 6 | 7 | const { DolphinSR, generateId } = ( 8 | process.env.TEST_DIST ? require('../dist/bundle') : require('../lib') 9 | ); 10 | 11 | describe('Dolphin instance', () => { 12 | it('should start out empty', () => { 13 | const d = new DolphinSR(); 14 | expect(d.nextCard()).toBeNull(); 15 | expect(d.summary()).toEqual({ due: 0, later: 0, learning: 0, overdue: 0 }); 16 | }); 17 | it('should add a new masters to the "learning"', () => { 18 | const d = new DolphinSR(); 19 | const master: Master = { 20 | id: generateId(), 21 | combinations: [{ front: [0], back: [1, 0] }], 22 | fields: ['hello', 'world'], 23 | }; 24 | d.addMasters(master); 25 | expect(d.nextCard()).toEqual({ 26 | master: master.id, 27 | combination: master.combinations[0], 28 | front: ['hello'], 29 | back: ['world', 'hello'], 30 | }); 31 | expect(d.summary()).toEqual({ due: 0, later: 0, learning: 1, overdue: 0 }); 32 | }); 33 | it('should add multiple new masters to the "learning" category', () => { 34 | const d = new DolphinSR(); 35 | d.addMasters({ 36 | id: generateId(), 37 | combinations: [{ front: [0], back: [1, 0] }], 38 | fields: ['hello', 'world'], 39 | }, { 40 | id: generateId(), 41 | combinations: [{ front: [0], back: [1, 0] }], 42 | fields: ['hello', 'world'], 43 | }); 44 | expect(d.summary()).toEqual({ due: 0, later: 0, learning: 2, overdue: 0 }); 45 | d.addMasters({ 46 | id: generateId(), 47 | combinations: [{ front: [0], back: [1, 0] }], 48 | fields: ['hello', 'world'], 49 | }); 50 | expect(d.summary()).toEqual({ due: 0, later: 0, learning: 3, overdue: 0 }); 51 | }); 52 | it('should add reviews', () => { 53 | const d = new DolphinSR(() => dates.today); 54 | const master: Master = { 55 | id: generateId(), 56 | combinations: [{ front: [0], back: [1, 0] }], 57 | fields: ['hello', 'world'], 58 | }; 59 | d.addMasters(master); 60 | expect(d.nextCard()).toEqual({ 61 | master: master.id, 62 | combination: master.combinations[0], 63 | front: ['hello'], 64 | back: ['world', 'hello'], 65 | }); 66 | expect(d.summary()).toEqual({ due: 0, later: 0, learning: 1, overdue: 0 }); 67 | 68 | expect(d.addReviews({ 69 | master: master.id, 70 | combination: master.combinations[0], 71 | ts: dates.today, 72 | rating: 'easy', 73 | })).toEqual(false); 74 | expect(d.summary()).toEqual({ due: 0, later: 1, learning: 0, overdue: 0 }); 75 | expect(d.nextCard()).toBeNull(); 76 | 77 | const secondMaster: Master = { 78 | id: generateId(), 79 | combinations: [{ front: [0], back: [1, 0] }], 80 | fields: ['hello', 'world'], 81 | }; 82 | d.addMasters(secondMaster); 83 | expect(d.summary()).toEqual({ due: 0, later: 1, learning: 1, overdue: 0 }); 84 | expect(d.addReviews({ 85 | master: secondMaster.id, 86 | combination: master.combinations[0], 87 | ts: dates.today, 88 | rating: 'easy', 89 | })).toEqual(false); 90 | expect(d.summary()).toEqual({ due: 0, later: 2, learning: 0, overdue: 0 }); 91 | expect(d.nextCard()).toBeNull(); 92 | }); 93 | 94 | it('should rebuild the cache when reviews are added out of order', () => { 95 | const d = new DolphinSR(() => dates.today); 96 | const master: Master = { 97 | id: generateId(), 98 | combinations: [{ front: [0], back: [1, 0] }], 99 | fields: ['hello', 'world'], 100 | }; 101 | d.addMasters(master); 102 | expect(d.nextCard()).toEqual({ 103 | master: master.id, 104 | combination: master.combinations[0], 105 | front: ['hello'], 106 | back: ['world', 'hello'], 107 | }); 108 | expect(d.summary()).toEqual({ due: 0, later: 0, learning: 1, overdue: 0 }); 109 | 110 | expect(d.addReviews({ 111 | master: master.id, 112 | combination: master.combinations[0], 113 | ts: dates.laterTmrw, 114 | rating: 'easy', 115 | })).toEqual(false); 116 | d._currentDateGetter = () => dates.laterTmrw; 117 | 118 | expect(d.addReviews({ 119 | master: master.id, 120 | combination: master.combinations[0], 121 | ts: dates.today, 122 | rating: 'easy', 123 | })).toEqual(true); 124 | 125 | expect(d.summary()).toEqual({ due: 0, later: 1, learning: 0, overdue: 0 }); 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import type { 4 | State, Master, Review, Id, CardId, CardsSchedule, Card, SummaryStatistics, 5 | } from './types'; 6 | import { makeEmptyState, getCardId, makeInitialCardState, generateId } from './types'; 7 | import addReview from './addReview'; 8 | import applyReview from './applyReview'; 9 | import computeCardsSchedule, { pickMostDue } from './computeCardsSchedule'; 10 | 11 | export type { Master, Review, Id, Card, SummaryStatistics }; 12 | export { generateId }; 13 | 14 | const debug = require('debug')('dolphin'); 15 | 16 | export class DolphinSR { 17 | 18 | _state: State; 19 | _masters: {[Id]: Master}; 20 | _reviews: Array; 21 | 22 | // TODO(April 3, 2017) 23 | // Currently the cachedCardsSchedule is not invalidated when the time changes (only when a review 24 | // or master is added), so there is a possibility for cards not switching from due to overdue 25 | // properly. In practice, this has not been a significant issue -- easy fix for later. 26 | _cachedCardsSchedule: ?CardsSchedule; 27 | 28 | // For testing, you can swap this out with a different function to change when 'now' is. 29 | _currentDateGetter: () => Date; 30 | 31 | 32 | constructor(currentDateGetter: () => Date = () => new Date()) { 33 | this._state = makeEmptyState(); 34 | this._masters = {}; 35 | this._reviews = []; 36 | this._currentDateGetter = currentDateGetter; 37 | } 38 | 39 | // gotcha: does not invalidate cache, that happens in addMasters() 40 | _addMaster(master: Master) { 41 | if (this._masters[master.id]) { 42 | throw new Error(`master already added: ${master.id}`); 43 | } 44 | master.combinations.forEach((combination) => { 45 | const id = getCardId({ master: master.id, combination }); 46 | this._state.cardStates[id] = makeInitialCardState(master.id, combination); 47 | }); 48 | this._masters[master.id] = master; 49 | } 50 | 51 | addMasters(...masters: Array) { 52 | masters.forEach(master => this._addMaster(master)); 53 | this._cachedCardsSchedule = null; 54 | } 55 | 56 | // gotcha: does not apply the reviews to state or invalidate cache, that happens in addReviews() 57 | _addReviewToReviews(review: Review): boolean { 58 | this._reviews = addReview(this._reviews, review); 59 | const lastReview = this._reviews[this._reviews.length - 1]; 60 | 61 | return ( 62 | `${getCardId(lastReview)}#${lastReview.ts.toISOString()}` !== 63 | `${getCardId(review)}#${review.ts.toISOString()}` 64 | ); 65 | } 66 | 67 | // Returns true if the entire state was rebuilt (inefficient, minimize) 68 | addReviews(...reviews: Array): boolean { 69 | const needsRebuild = reviews.reduce((v, review) => { 70 | if (this._addReviewToReviews(review)) { 71 | return true; 72 | } 73 | return v; 74 | }, false); 75 | 76 | if (needsRebuild) { 77 | this._rebuild(); 78 | } else { 79 | reviews.forEach((review) => { 80 | this._state = applyReview(this._state, review); 81 | }); 82 | } 83 | 84 | this._cachedCardsSchedule = null; 85 | 86 | return needsRebuild; 87 | } 88 | 89 | _rebuild() { 90 | debug('rebuilding state'); 91 | const masters = this._masters; 92 | const reviews = this._reviews; 93 | this._masters = {}; 94 | this._reviews = []; 95 | 96 | this.addMasters(...Object.keys(masters).map(k => masters[k])); 97 | this.addReviews(...reviews); 98 | } 99 | 100 | _getCardsSchedule(): CardsSchedule { 101 | if (this._cachedCardsSchedule != null) { 102 | return this._cachedCardsSchedule; 103 | } 104 | this._cachedCardsSchedule = computeCardsSchedule(this._state, this._currentDateGetter()); 105 | return this._cachedCardsSchedule; 106 | } 107 | 108 | _nextCardId(): ?CardId { 109 | const s = this._getCardsSchedule(); 110 | return pickMostDue(s, this._state); 111 | } 112 | 113 | _getCard(id: CardId): Card { 114 | const [masterId, combo] = id.split('#'); 115 | const [front, back] = combo.split('@').map(part => part.split(',').map(x => parseInt(x, 10))); 116 | const master = this._masters[masterId]; 117 | if (master == null) { 118 | throw new Error(`cannot getCard: no such master: ${masterId}`); 119 | } 120 | const combination = { front, back }; 121 | 122 | const frontFields = front.map(i => master.fields[i]); 123 | const backFields = back.map(i => master.fields[i]); 124 | 125 | return { 126 | master: masterId, 127 | combination, 128 | 129 | front: frontFields, 130 | back: backFields, 131 | }; 132 | } 133 | 134 | nextCard(): ?Card { 135 | const cardId = this._nextCardId(); 136 | if (cardId == null) { 137 | return null; 138 | } 139 | return this._getCard(cardId); 140 | } 141 | 142 | summary(): SummaryStatistics { 143 | const s = this._getCardsSchedule(); 144 | return { 145 | due: s.due.length, 146 | later: s.later.length, 147 | learning: s.learning.length, 148 | overdue: s.overdue.length, 149 | }; 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /dist/index.js.flow: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import type { 4 | State, Master, Review, Id, CardId, CardsSchedule, Card, SummaryStatistics, 5 | } from './types'; 6 | import { makeEmptyState, getCardId, makeInitialCardState, generateId } from './types'; 7 | import addReview from './addReview'; 8 | import applyReview from './applyReview'; 9 | import computeCardsSchedule, { pickMostDue } from './computeCardsSchedule'; 10 | 11 | export type { Master, Review, Id, Card, SummaryStatistics }; 12 | export { generateId }; 13 | 14 | const debug = require('debug')('dolphin'); 15 | 16 | export class DolphinSR { 17 | 18 | _state: State; 19 | _masters: {[Id]: Master}; 20 | _reviews: Array; 21 | 22 | // TODO(April 3, 2017) 23 | // Currently the cachedCardsSchedule is not invalidated when the time changes (only when a review 24 | // or master is added), so there is a possibility for cards not switching from due to overdue 25 | // properly. In practice, this has not been a significant issue -- easy fix for later. 26 | _cachedCardsSchedule: ?CardsSchedule; 27 | 28 | // For testing, you can swap this out with a different function to change when 'now' is. 29 | _currentDateGetter: () => Date; 30 | 31 | 32 | constructor(currentDateGetter: () => Date = () => new Date()) { 33 | this._state = makeEmptyState(); 34 | this._masters = {}; 35 | this._reviews = []; 36 | this._currentDateGetter = currentDateGetter; 37 | } 38 | 39 | // gotcha: does not invalidate cache, that happens in addMasters() 40 | _addMaster(master: Master) { 41 | if (this._masters[master.id]) { 42 | throw new Error(`master already added: ${master.id}`); 43 | } 44 | master.combinations.forEach((combination) => { 45 | const id = getCardId({ master: master.id, combination }); 46 | this._state.cardStates[id] = makeInitialCardState(master.id, combination); 47 | }); 48 | this._masters[master.id] = master; 49 | } 50 | 51 | addMasters(...masters: Array) { 52 | masters.forEach(master => this._addMaster(master)); 53 | this._cachedCardsSchedule = null; 54 | } 55 | 56 | // gotcha: does not apply the reviews to state or invalidate cache, that happens in addReviews() 57 | _addReviewToReviews(review: Review): boolean { 58 | this._reviews = addReview(this._reviews, review); 59 | const lastReview = this._reviews[this._reviews.length - 1]; 60 | 61 | return ( 62 | `${getCardId(lastReview)}#${lastReview.ts.toISOString()}` !== 63 | `${getCardId(review)}#${review.ts.toISOString()}` 64 | ); 65 | } 66 | 67 | // Returns true if the entire state was rebuilt (inefficient, minimize) 68 | addReviews(...reviews: Array): boolean { 69 | const needsRebuild = reviews.reduce((v, review) => { 70 | if (this._addReviewToReviews(review)) { 71 | return true; 72 | } 73 | return v; 74 | }, false); 75 | 76 | if (needsRebuild) { 77 | this._rebuild(); 78 | } else { 79 | reviews.forEach((review) => { 80 | this._state = applyReview(this._state, review); 81 | }); 82 | } 83 | 84 | this._cachedCardsSchedule = null; 85 | 86 | return needsRebuild; 87 | } 88 | 89 | _rebuild() { 90 | debug('rebuilding state'); 91 | const masters = this._masters; 92 | const reviews = this._reviews; 93 | this._masters = {}; 94 | this._reviews = []; 95 | 96 | this.addMasters(...Object.keys(masters).map(k => masters[k])); 97 | this.addReviews(...reviews); 98 | } 99 | 100 | _getCardsSchedule(): CardsSchedule { 101 | if (this._cachedCardsSchedule != null) { 102 | return this._cachedCardsSchedule; 103 | } 104 | this._cachedCardsSchedule = computeCardsSchedule(this._state, this._currentDateGetter()); 105 | return this._cachedCardsSchedule; 106 | } 107 | 108 | _nextCardId(): ?CardId { 109 | const s = this._getCardsSchedule(); 110 | return pickMostDue(s, this._state); 111 | } 112 | 113 | _getCard(id: CardId): Card { 114 | const [masterId, combo] = id.split('#'); 115 | const [front, back] = combo.split('@').map(part => part.split(',').map(x => parseInt(x, 10))); 116 | const master = this._masters[masterId]; 117 | if (master == null) { 118 | throw new Error(`cannot getCard: no such master: ${masterId}`); 119 | } 120 | const combination = { front, back }; 121 | 122 | const frontFields = front.map(i => master.fields[i]); 123 | const backFields = back.map(i => master.fields[i]); 124 | 125 | return { 126 | master: masterId, 127 | combination, 128 | 129 | front: frontFields, 130 | back: backFields, 131 | }; 132 | } 133 | 134 | nextCard(): ?Card { 135 | const cardId = this._nextCardId(); 136 | if (cardId == null) { 137 | return null; 138 | } 139 | return this._getCard(cardId); 140 | } 141 | 142 | summary(): SummaryStatistics { 143 | const s = this._getCardsSchedule(); 144 | return { 145 | due: s.due.length, 146 | later: s.later.length, 147 | learning: s.learning.length, 148 | overdue: s.overdue.length, 149 | }; 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /lib/applyReview.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import type { State, CardState, LearningCardState, ReviewingCardState, LapsedCardState, Review, Rating } from './types'; 4 | import { getCardId } from './types'; 5 | import dateDiffInDays from './dateDiffInDays'; 6 | import { calculateDueDate } from './computeCardsSchedule'; 7 | 8 | const debug = require('debug')('dolphin'); 9 | 10 | // -- applyToLearningCardState(...) 11 | 12 | // constants from Anki defaults 13 | // TODO(April 1, 2017) investigate rationales, consider changing them 14 | const INITIAL_FACTOR = 2500; 15 | const INITIAL_DAYS_WITHOUT_JUMP = 4; 16 | const INITIAL_DAYS_WITH_JUMP = 1; 17 | function applyToLearningCardState(prev: LearningCardState, ts: Date, rating: Rating): CardState { 18 | if (rating === 'easy' || (rating.match(/^easy|good$/) && prev.consecutiveCorrect > 0)) { 19 | return { 20 | master: prev.master, 21 | combination: prev.combination, 22 | 23 | mode: 'reviewing', 24 | factor: INITIAL_FACTOR, 25 | lapses: 0, 26 | interval: prev.consecutiveCorrect > 0 ? INITIAL_DAYS_WITHOUT_JUMP : INITIAL_DAYS_WITH_JUMP, 27 | lastReviewed: ts, 28 | }; 29 | } else if (rating === 'again') { 30 | return { 31 | master: prev.master, 32 | combination: prev.combination, 33 | 34 | mode: 'learning', 35 | consecutiveCorrect: 0, 36 | lastReviewed: ts, 37 | }; 38 | } else if (rating.match(/^good|hard$/) && prev.consecutiveCorrect < 1) { 39 | return { 40 | master: prev.master, 41 | combination: prev.combination, 42 | 43 | mode: 'learning', 44 | consecutiveCorrect: prev.consecutiveCorrect + 1, 45 | lastReviewed: ts, 46 | }; 47 | } 48 | throw new Error('logic error'); 49 | } 50 | 51 | // -- applyToReviewingCardState(...) 52 | 53 | const EASY_BONUS = 2; 54 | const MAX_INTERVAL = 365; 55 | const MIN_FACTOR = 0; // TODO 56 | const MAX_FACTOR = Number.MAX_VALUE; 57 | function constrainWithin(min, max, n) { 58 | if (min > max) { 59 | throw new Error(`min > max: ${min}=min, ${max}=max`); 60 | } 61 | return Math.max(Math.min(n, max), min); 62 | } 63 | 64 | function calculateDaysLate(state: ReviewingCardState, actual: Date): number { 65 | const expected = calculateDueDate(state); 66 | 67 | const daysLate = dateDiffInDays(actual, expected); 68 | 69 | if (daysLate < 0) { 70 | debug('last review occured earlier than expected', { 71 | daysLate, 72 | actual, 73 | expected, 74 | }); 75 | return 0; 76 | } 77 | 78 | return daysLate; 79 | } 80 | function applyToReviewingCardState(prev: ReviewingCardState, ts: Date, rating: Rating): CardState { 81 | if (rating === 'again') { 82 | return { 83 | master: prev.master, 84 | combination: prev.combination, 85 | 86 | mode: 'lapsed', 87 | consecutiveCorrect: 0, 88 | factor: constrainWithin(MIN_FACTOR, MAX_FACTOR, prev.factor - 200), 89 | lapses: prev.lapses + 1, 90 | interval: prev.interval, 91 | lastReviewed: ts, 92 | }; 93 | } 94 | const factorAdj = ( 95 | rating === 'hard' ? -150 : 96 | rating === 'good' ? 0 : 97 | rating === 'easy' ? 150 : 98 | NaN 99 | ); 100 | const daysLate = calculateDaysLate(prev, ts); 101 | 102 | const ival = constrainWithin(prev.interval + 1, MAX_INTERVAL, 103 | rating === 'hard' ? (prev.interval + (daysLate / 4)) * 1.2 : 104 | rating === 'good' ? ((prev.interval + (daysLate / 2)) * prev.factor) / 1000 : 105 | rating === 'easy' ? (((prev.interval + daysLate) * prev.factor) / 1000) * EASY_BONUS : 106 | NaN, 107 | ); 108 | 109 | if (isNaN(factorAdj) || isNaN(ival)) { 110 | throw new Error(`invalid rating: ${rating}`); 111 | } 112 | 113 | return { 114 | master: prev.master, 115 | combination: prev.combination, 116 | 117 | mode: 'reviewing', 118 | factor: constrainWithin(MIN_FACTOR, MAX_FACTOR, prev.factor + factorAdj), 119 | lapses: prev.lapses, 120 | interval: ival, 121 | lastReviewed: ts, 122 | }; 123 | } 124 | 125 | // -- applyToLapsedCardState(...) 126 | 127 | function applyToLapsedCardState(prev: LapsedCardState, ts: Date, rating: Rating): CardState { 128 | if (rating === 'easy' || (rating.match(/^easy|good$/) && prev.consecutiveCorrect > 0)) { 129 | return { 130 | master: prev.master, 131 | combination: prev.combination, 132 | 133 | mode: 'reviewing', 134 | factor: prev.factor, 135 | lapses: prev.lapses, 136 | interval: prev.consecutiveCorrect > 0 ? INITIAL_DAYS_WITHOUT_JUMP : INITIAL_DAYS_WITH_JUMP, 137 | lastReviewed: ts, 138 | }; 139 | } 140 | return { 141 | master: prev.master, 142 | combination: prev.combination, 143 | 144 | mode: 'lapsed', 145 | factor: prev.factor, 146 | lapses: prev.lapses, 147 | interval: prev.interval, 148 | lastReviewed: ts, 149 | consecutiveCorrect: rating === 'again' ? 0 : prev.consecutiveCorrect + 1, 150 | }; 151 | } 152 | 153 | // -- applyReview(...) 154 | 155 | 156 | export function applyToCardState(prev: CardState, ts: Date, rating: Rating): CardState { 157 | if (prev.lastReviewed != null && prev.lastReviewed > ts) { 158 | const p = prev.lastReviewed.toISOString(); 159 | const t = ts.toISOString(); 160 | throw new Error(`cannot apply review before current lastReviewed: ${p} > ${t}`); 161 | } 162 | 163 | if (prev.mode === 'learning') { 164 | return applyToLearningCardState((prev: any), ts, rating); 165 | } else if (prev.mode === 'reviewing') { 166 | return applyToReviewingCardState((prev: any), ts, rating); 167 | } else if (prev.mode === 'lapsed') { 168 | return applyToLapsedCardState((prev: any), ts, rating); 169 | } 170 | throw new Error(`invalid mode: ${prev.mode}`); 171 | } 172 | 173 | export default function applyReview(prev: State, review: Review): State { 174 | const cardId = getCardId(review); 175 | 176 | const cardState = prev.cardStates[cardId]; 177 | if (cardState == null) { 178 | throw new Error(`applying review to missing card: ${JSON.stringify(review)}`); 179 | } 180 | 181 | const state = { 182 | cardStates: { ...prev.cardStates }, 183 | }; 184 | state.cardStates[cardId] = applyToCardState(cardState, review.ts, review.rating); 185 | 186 | return state; 187 | } 188 | -------------------------------------------------------------------------------- /dist/applyReview.js.flow: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import type { State, CardState, LearningCardState, ReviewingCardState, LapsedCardState, Review, Rating } from './types'; 4 | import { getCardId } from './types'; 5 | import dateDiffInDays from './dateDiffInDays'; 6 | import { calculateDueDate } from './computeCardsSchedule'; 7 | 8 | const debug = require('debug')('dolphin'); 9 | 10 | // -- applyToLearningCardState(...) 11 | 12 | // constants from Anki defaults 13 | // TODO(April 1, 2017) investigate rationales, consider changing them 14 | const INITIAL_FACTOR = 2500; 15 | const INITIAL_DAYS_WITHOUT_JUMP = 4; 16 | const INITIAL_DAYS_WITH_JUMP = 1; 17 | function applyToLearningCardState(prev: LearningCardState, ts: Date, rating: Rating): CardState { 18 | if (rating === 'easy' || (rating.match(/^easy|good$/) && prev.consecutiveCorrect > 0)) { 19 | return { 20 | master: prev.master, 21 | combination: prev.combination, 22 | 23 | mode: 'reviewing', 24 | factor: INITIAL_FACTOR, 25 | lapses: 0, 26 | interval: prev.consecutiveCorrect > 0 ? INITIAL_DAYS_WITHOUT_JUMP : INITIAL_DAYS_WITH_JUMP, 27 | lastReviewed: ts, 28 | }; 29 | } else if (rating === 'again') { 30 | return { 31 | master: prev.master, 32 | combination: prev.combination, 33 | 34 | mode: 'learning', 35 | consecutiveCorrect: 0, 36 | lastReviewed: ts, 37 | }; 38 | } else if (rating.match(/^good|hard$/) && prev.consecutiveCorrect < 1) { 39 | return { 40 | master: prev.master, 41 | combination: prev.combination, 42 | 43 | mode: 'learning', 44 | consecutiveCorrect: prev.consecutiveCorrect + 1, 45 | lastReviewed: ts, 46 | }; 47 | } 48 | throw new Error('logic error'); 49 | } 50 | 51 | // -- applyToReviewingCardState(...) 52 | 53 | const EASY_BONUS = 2; 54 | const MAX_INTERVAL = 365; 55 | const MIN_FACTOR = 0; // TODO 56 | const MAX_FACTOR = Number.MAX_VALUE; 57 | function constrainWithin(min, max, n) { 58 | if (min > max) { 59 | throw new Error(`min > max: ${min}=min, ${max}=max`); 60 | } 61 | return Math.max(Math.min(n, max), min); 62 | } 63 | 64 | function calculateDaysLate(state: ReviewingCardState, actual: Date): number { 65 | const expected = calculateDueDate(state); 66 | 67 | const daysLate = dateDiffInDays(actual, expected); 68 | 69 | if (daysLate < 0) { 70 | debug('last review occured earlier than expected', { 71 | daysLate, 72 | actual, 73 | expected, 74 | }); 75 | return 0; 76 | } 77 | 78 | return daysLate; 79 | } 80 | function applyToReviewingCardState(prev: ReviewingCardState, ts: Date, rating: Rating): CardState { 81 | if (rating === 'again') { 82 | return { 83 | master: prev.master, 84 | combination: prev.combination, 85 | 86 | mode: 'lapsed', 87 | consecutiveCorrect: 0, 88 | factor: constrainWithin(MIN_FACTOR, MAX_FACTOR, prev.factor - 200), 89 | lapses: prev.lapses + 1, 90 | interval: prev.interval, 91 | lastReviewed: ts, 92 | }; 93 | } 94 | const factorAdj = ( 95 | rating === 'hard' ? -150 : 96 | rating === 'good' ? 0 : 97 | rating === 'easy' ? 150 : 98 | NaN 99 | ); 100 | const daysLate = calculateDaysLate(prev, ts); 101 | 102 | const ival = constrainWithin(prev.interval + 1, MAX_INTERVAL, 103 | rating === 'hard' ? (prev.interval + (daysLate / 4)) * 1.2 : 104 | rating === 'good' ? ((prev.interval + (daysLate / 2)) * prev.factor) / 1000 : 105 | rating === 'easy' ? (((prev.interval + daysLate) * prev.factor) / 1000) * EASY_BONUS : 106 | NaN, 107 | ); 108 | 109 | if (isNaN(factorAdj) || isNaN(ival)) { 110 | throw new Error(`invalid rating: ${rating}`); 111 | } 112 | 113 | return { 114 | master: prev.master, 115 | combination: prev.combination, 116 | 117 | mode: 'reviewing', 118 | factor: constrainWithin(MIN_FACTOR, MAX_FACTOR, prev.factor + factorAdj), 119 | lapses: prev.lapses, 120 | interval: ival, 121 | lastReviewed: ts, 122 | }; 123 | } 124 | 125 | // -- applyToLapsedCardState(...) 126 | 127 | function applyToLapsedCardState(prev: LapsedCardState, ts: Date, rating: Rating): CardState { 128 | if (rating === 'easy' || (rating.match(/^easy|good$/) && prev.consecutiveCorrect > 0)) { 129 | return { 130 | master: prev.master, 131 | combination: prev.combination, 132 | 133 | mode: 'reviewing', 134 | factor: prev.factor, 135 | lapses: prev.lapses, 136 | interval: prev.consecutiveCorrect > 0 ? INITIAL_DAYS_WITHOUT_JUMP : INITIAL_DAYS_WITH_JUMP, 137 | lastReviewed: ts, 138 | }; 139 | } 140 | return { 141 | master: prev.master, 142 | combination: prev.combination, 143 | 144 | mode: 'lapsed', 145 | factor: prev.factor, 146 | lapses: prev.lapses, 147 | interval: prev.interval, 148 | lastReviewed: ts, 149 | consecutiveCorrect: rating === 'again' ? 0 : prev.consecutiveCorrect + 1, 150 | }; 151 | } 152 | 153 | // -- applyReview(...) 154 | 155 | 156 | export function applyToCardState(prev: CardState, ts: Date, rating: Rating): CardState { 157 | if (prev.lastReviewed != null && prev.lastReviewed > ts) { 158 | const p = prev.lastReviewed.toISOString(); 159 | const t = ts.toISOString(); 160 | throw new Error(`cannot apply review before current lastReviewed: ${p} > ${t}`); 161 | } 162 | 163 | if (prev.mode === 'learning') { 164 | return applyToLearningCardState((prev: any), ts, rating); 165 | } else if (prev.mode === 'reviewing') { 166 | return applyToReviewingCardState((prev: any), ts, rating); 167 | } else if (prev.mode === 'lapsed') { 168 | return applyToLapsedCardState((prev: any), ts, rating); 169 | } 170 | throw new Error(`invalid mode: ${prev.mode}`); 171 | } 172 | 173 | export default function applyReview(prev: State, review: Review): State { 174 | const cardId = getCardId(review); 175 | 176 | const cardState = prev.cardStates[cardId]; 177 | if (cardState == null) { 178 | throw new Error(`applying review to missing card: ${JSON.stringify(review)}`); 179 | } 180 | 181 | const state = { 182 | cardStates: { ...prev.cardStates }, 183 | }; 184 | state.cardStates[cardId] = applyToCardState(cardState, review.ts, review.rating); 185 | 186 | return state; 187 | } 188 | -------------------------------------------------------------------------------- /test/applyReview.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import applyReview from '../lib/applyReview'; 4 | import { generateId, makeEmptyState, makeInitialCardState, getCardId } from '../lib/types'; 5 | import { calculateDueDate } from '../lib/computeCardsSchedule'; 6 | import type { Master } from '../lib/types'; 7 | 8 | import * as dates from './dates'; 9 | 10 | it('should throw an error if adding a review to an empty state', () => { 11 | const state = makeEmptyState(); 12 | const id = generateId(); 13 | expect(() => { 14 | applyReview(state, { 15 | master: id, combination: { front: [], back: [] }, rating: 'easy', ts: dates.today, 16 | }); 17 | }).toThrowError(); 18 | }); 19 | 20 | it('should error if adding a review to a state with a lastReviewed later than the review', () => { 21 | const state = makeEmptyState(); 22 | const id = generateId(); 23 | const master: Master = { 24 | id, 25 | combinations: [ 26 | { front: [0], back: [1] }, 27 | ], 28 | fields: ['field 1', 'field 2'], 29 | }; 30 | 31 | const combination = master.combinations[0]; 32 | const cardId = getCardId({ master: id, combination }); 33 | state.cardStates[cardId] = makeInitialCardState(id, combination); 34 | const newState = applyReview(state, { 35 | master: id, combination, rating: 'easy', ts: dates.laterToday, 36 | }); 37 | 38 | // should be OK 39 | applyReview(state, { 40 | master: id, combination, rating: 'easy', ts: dates.today, 41 | }); 42 | 43 | expect(() => { 44 | applyReview(newState, { 45 | master: id, combination, rating: 'easy', ts: dates.today, 46 | }); 47 | }).toThrowError(); 48 | }); 49 | 50 | it('should not error when adding a review to a state with the given master and combination', () => { 51 | const state = makeEmptyState(); 52 | 53 | const id = generateId(); 54 | const master: Master = { 55 | id, 56 | combinations: [ 57 | { front: [0], back: [1] }, 58 | ], 59 | fields: ['field 1', 'field 2'], 60 | }; 61 | 62 | const combination = master.combinations[0]; 63 | 64 | state.cardStates[getCardId({ master: id, combination })] = makeInitialCardState(id, combination); 65 | 66 | applyReview(state, { 67 | master: id, combination, rating: 'easy', ts: dates.today, 68 | }); 69 | }); 70 | it('should not mutate the state when adding a review to a state with the given master and combination', () => { 71 | const state = makeEmptyState(); 72 | 73 | const id = generateId(); 74 | const master: Master = { 75 | id, 76 | combinations: [ 77 | { front: [0], back: [1] }, 78 | ], 79 | fields: ['field 1', 'field 2'], 80 | }; 81 | 82 | const combination = master.combinations[0]; 83 | 84 | state.cardStates[getCardId({ master: id, combination })] = makeInitialCardState(id, combination); 85 | const stateCopy = { ...state }; 86 | 87 | applyReview(state, { 88 | master: id, combination, rating: 'easy', ts: dates.today, 89 | }); 90 | 91 | expect(state).toEqual(stateCopy); 92 | }); 93 | it('should return a new state reflecting the rating when adding a review to a state with the given master and combination', () => { 94 | const state = makeEmptyState(); 95 | 96 | const id = generateId(); 97 | const master: Master = { 98 | id, 99 | combinations: [ 100 | { front: [0], back: [1] }, 101 | ], 102 | fields: ['field 1', 'field 2'], 103 | }; 104 | 105 | const combination = master.combinations[0]; 106 | 107 | state.cardStates[getCardId({ master: id, combination })] = makeInitialCardState(id, combination); 108 | 109 | const newState = applyReview(state, { 110 | master: id, combination, rating: 'good', ts: dates.today, 111 | }); 112 | 113 | expect(newState.cardStates[getCardId({ master: id, combination })]).toEqual({ 114 | master: id, 115 | combination, 116 | 117 | consecutiveCorrect: 1, 118 | lastReviewed: dates.today, 119 | mode: 'learning', 120 | }); 121 | }); 122 | it('should accurately navigate through learning, reviewing, and lapsed modes', () => { 123 | const state = makeEmptyState(); 124 | 125 | const id = generateId(); 126 | const master: Master = { 127 | id, 128 | combinations: [ 129 | { front: [0], back: [1] }, 130 | ], 131 | fields: ['field 1', 'field 2'], 132 | }; 133 | 134 | const combination = master.combinations[0]; 135 | const cardId = getCardId({ master: id, combination }); 136 | 137 | state.cardStates[cardId] = makeInitialCardState(id, combination); 138 | 139 | const stateB = applyReview(state, { 140 | master: id, combination, rating: 'good', ts: dates.today, 141 | }); 142 | 143 | expect(stateB.cardStates[cardId]).toEqual({ 144 | master: id, 145 | combination, 146 | 147 | consecutiveCorrect: 1, 148 | lastReviewed: dates.today, 149 | mode: 'learning', 150 | }); 151 | 152 | const stateC = applyReview(stateB, { 153 | master: id, combination, rating: 'easy', ts: dates.laterToday, 154 | }); 155 | 156 | expect(stateC.cardStates[cardId]).toEqual({ 157 | master: id, 158 | combination, 159 | 160 | lastReviewed: dates.laterToday, 161 | mode: 'reviewing', 162 | factor: 2500, 163 | interval: 4, 164 | lapses: 0, 165 | }); 166 | 167 | const stateCDue = calculateDueDate((stateC.cardStates[cardId]: any)); 168 | expect(stateCDue).toEqual(dates.addToDate(dates.todayAt3AM, 4)); 169 | 170 | const stateD = applyReview(stateC, { 171 | master: id, combination, rating: 'easy', ts: stateCDue, 172 | }); 173 | expect(stateD.cardStates[cardId]).toEqual({ 174 | master: id, 175 | combination, 176 | 177 | lastReviewed: stateCDue, 178 | mode: 'reviewing', 179 | factor: 2650, 180 | interval: 20, 181 | lapses: 0, 182 | }); 183 | 184 | const stateDDue = calculateDueDate((stateD.cardStates[cardId]: any)); 185 | 186 | const stateE = applyReview(stateD, { 187 | master: id, combination, rating: 'again', ts: stateDDue, 188 | }); 189 | expect(stateE.cardStates[cardId]).toEqual({ 190 | master: id, 191 | combination, 192 | 193 | consecutiveCorrect: 0, 194 | factor: 2450, 195 | lapses: 1, 196 | interval: 20, 197 | lastReviewed: stateDDue, 198 | mode: 'lapsed', 199 | }); 200 | 201 | const eReviewDate = dates.addToDate(stateDDue, 1); 202 | 203 | const stateF = applyReview(stateE, { 204 | master: id, combination, rating: 'again', ts: eReviewDate, 205 | }); 206 | expect(stateF.cardStates[cardId]).toEqual({ 207 | master: id, 208 | combination, 209 | 210 | consecutiveCorrect: 0, 211 | factor: 2450, 212 | lapses: 1, 213 | interval: 20, 214 | lastReviewed: eReviewDate, 215 | mode: 'lapsed', 216 | }); 217 | 218 | const gReviewDate = dates.addToDate(stateDDue, 1); 219 | const stateG = applyReview(stateF, { 220 | master: id, combination, rating: 'easy', ts: gReviewDate, 221 | }); 222 | expect(stateG.cardStates[cardId]).toEqual({ 223 | master: id, 224 | combination, 225 | 226 | lastReviewed: gReviewDate, 227 | mode: 'reviewing', 228 | factor: 2450, 229 | interval: 1, 230 | lapses: 1, 231 | }); 232 | }); 233 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DolphinSR: Spaced Repetition in JavaScript 2 | 3 | DolphinSR implements [spaced repetition](https://en.wikipedia.org/wiki/Spaced_repetition) in 4 | JavaScript. Specifically, it uses [Anki's modifications](https://apps.ankiweb.net/docs/manual.html#what-algorithm) 5 | to the SM2 algorithm including: 6 | 7 | - an initial mode for learning new cards 8 | - a mode for re-learning cards after forgetting them 9 | - reducing the number of self-assigned ratings from 6 to 4 10 | - factoring lateness into card scheduling 11 | - Anki's default configuration options 12 | 13 | While DolphinSR is intentionally very similar to Anki's algorithm, it does deviate in a few ways: 14 | 15 | - improved support for adding reviews out of order (for example, due to network latency) 16 | - very different internal data structures (DolphinSR is largely written in a functional style to 17 | make testing and debugging easier, and does not rely on storing computed data or any SQL database) 18 | - only one kind of card 19 | 20 | ## Installation 21 | 22 | DolphinSR is an `npm` package. Install it with either `yarn add dolphinsr` or 23 | `npm install --save dolphinsr`. 24 | 25 | **It's strongly recommended that you use Flow to statically check your code when using DolphinSR.** 26 | We rely on it exclusively for type-checking, and don't do any runtime validation of type arguments. 27 | For more information, [visit the Flow webpage](https://flow.org); 28 | 29 | ## Quick Start 30 | 31 | ```{js} 32 | 33 | import { DolphinSR, generateId } from 'dolphinsr'; 34 | import type { Master, Review } from 'dolphinsr'; 35 | 36 | // Specify the combinations DolphinSR should make out of your master cards. 37 | // Numbers refer to indexes on the card. (Don't worry and keep reading if you don't understand) 38 | const chineseCombinations = [ 39 | {front: [0], back: [1, 2]}, 40 | {front: [1], back: [0, 2]}, 41 | {front: [2], back: [0, 3]} 42 | ]; 43 | const frenchCombinations = [ 44 | {front: [0], back: [1]}, 45 | {front: [1], back: [0]} 46 | ]; 47 | 48 | // Create the master cards that DolphinSR will use spaced repetition to teach. 49 | // Note: in a real program, you'd want to persist these somewhere (a database, localStorage, etc) 50 | const vocab: Array = [ 51 | { 52 | id: generateId(), 53 | combinations: chineseCombinations, 54 | fields: ['你好', 'nǐ hǎo', 'hello'] 55 | }, 56 | { 57 | id: generateId(), 58 | combinations: chineseCombinations, 59 | fields: ['世界', 'shìjiè', 'world'] 60 | }, 61 | { 62 | id: generateId(), 63 | combinations: frenchCombinations, 64 | fields: ['le monde', 'the world'] 65 | }, 66 | { 67 | id: generateId(), 68 | combinations: frenchCombinations, 69 | fields: ['bonjour', 'hello (good day)'] 70 | } 71 | ]; 72 | 73 | // Create the datastore used to house reviews. 74 | // Again, in a real app you'd want to persist this somewhere. 75 | const reviews: Array = []; 76 | 77 | // Create a new DolphinSR instance 78 | const d = new DolphinSR(); 79 | 80 | // Add all of your vocab to the DolphinSR instance 81 | d.addMasters(...vocab); 82 | 83 | // Add any existing reviews to the DolphinSR instance 84 | // (In this example, this doesn't do anything since reviews is empty.) 85 | d.addReviews(...reviews); 86 | 87 | // Now, DolphinSR can tell us what card to review next. 88 | // Since generateId() generates a random ID, it could be any of the cards we added. 89 | // For example, it could be: 90 | // { 91 | // master: , 92 | // combination: {front: [0], back: [1, 2]}, 93 | // front: ['你好'], 94 | // back: ['nǐ hǎo', 'hello'] 95 | // } 96 | const card = d.nextCard(); 97 | 98 | // It will also give us statistics on the cards we have: 99 | // Since we added 2 masters with 3 combinations (the Chinese vocab) and 2 masters with 2 100 | // combinations (the French vocab), we will have 10 cards. Since we haven't reviewed any of them 101 | // yet, they will all be in a "learning" state. 102 | const stats = d.summary(); // => { due: 0, later: 0, learning: 10, overdue: 0 } 103 | 104 | // Now, we can review the current card (probably triggered by a real app's UI) 105 | // If we already knew the answer, we would create a review saying that it was "easy" to recall: 106 | const review: Review = { 107 | // identify which card we're reviewing 108 | master: d.nextCard().master, 109 | combination: d.nextCard().combination, 110 | 111 | // store when we reviewed it 112 | ts: new Date(), 113 | 114 | // store how easy it was to remember 115 | rating: 'easy' 116 | }; 117 | reviews.push(review); // in a real app, we'd store this persistently 118 | d.addReviews(review); 119 | 120 | // Since we reviewed the current card, and marked it easy to remember, DolphinSR will move it into 121 | // 'review' mode, which resembles classic SM2 spaced repetition. So everything else will still be in 122 | // 'learn' mode, and it will be scheduled to be reviewed later. 123 | d.summary(); // => { due: 0, later: 1, learning: 9, overdue: 0 } 124 | 125 | // This will show the next card to review. 126 | d.nextCard(); 127 | ``` 128 | 129 | ## API 130 | 131 | ### generateId(): Id 132 | This generates a new ID for a master card. It uses the `uuid` package under the hood. Always use 133 | `generateId()` to generate IDs for your masters. 134 | 135 | ### new DolphinSR() 136 | Create a new DolphinSR instance, `d`. 137 | 138 | ### (new DolphinSR()).addMasters(...masters: Master[]): void 139 | Add masters to the DolphinSR instance. Masters with duplicate IDs will cause a runtime exception. 140 | 141 | ### (new DolphinSR()).addReviews(...reviews: Review[]): boolean 142 | Add reviews to the DolphinSR instance. 143 | 144 | `addReviews()` is significantly more efficient if `reviews` are sorted in ascending order by `ts`, 145 | and all chronologically come after the previous latest review for any card. If this condition is 146 | met, `addReviews()` will return `false`. Otherwise, it returns `true`. 147 | 148 | ### (new DolphinSR()).summary(): { due: number, later: number, learning: number, overdue: number } 149 | Returns summary statistics for cards. Each category (due, later, learning, overdue) is a count. 150 | 151 | - Due cards are cards that the algorithm has determined should be reviewed on the day of the call. 152 | (That is, the current date as reflected by `new Date()`.) 153 | - Overdue cards are cards that the algorithm has determined should be reviewed earlier than the 154 | day of the call. 155 | - Learning cards are cards that are either new and don't have any reviews, or cards that were 156 | forgotten (reviewed with an `again` rating) and not yet re-learned. 157 | - Later cards are cards that will be due in the future. 158 | 159 | ### (new DolphinSR()).nextCard(): ?Card 160 | Returns the next card to be reviewed, or `null` if there are no cards that need to be reviewed. Any 161 | card classified as due, overdue or learning by `.summary()` could appear in `.nextCard()`. 162 | 163 | *Note:* `.nextCard()` is deterministic--there is a defined order for prioritizing cards that are in 164 | different classifications and within classifications. That means that for any given set of masters 165 | and reviews added to a DolphinSR instance, `nextCard()` will return the same result. 166 | 167 | 168 | ## Types 169 | 170 | ### Master 171 | 172 | A `Master` is an object conforming to the following Flow signature: 173 | 174 | ```{js} 175 | { 176 | id: Id, // from generateId() 177 | fields: Array, // see Field 178 | combinations: Array, // see Combination 179 | } 180 | ``` 181 | 182 | ### Field 183 | 184 | A `Field` is a unit of data in a master. It is type alias for `string`. 185 | 186 | ### Combination 187 | 188 | A `Combination` is an object representing how `Fields` in a master should be combined to fit on a 189 | card with a front and a back. It looks like this: 190 | 191 | ```{js} 192 | {front: number[], back: number[]} 193 | ``` 194 | 195 | ### Rating 196 | 197 | A `Rating` is an enum describing how well a user knows a specific combination of a master card. It 198 | can be (in descending order of ease): 199 | 200 | - `'easy'` 201 | - `'good'` 202 | - `'hard'` 203 | - `'again'` 204 | 205 | ### Review 206 | 207 | A `Review` is an object describing a review of a card by a user. It should look like this: 208 | 209 | ```{js} 210 | { 211 | master: Id, 212 | combination: Combination, 213 | ts: Date, 214 | rating: Rating, 215 | } 216 | ``` 217 | 218 | ### Card 219 | 220 | A `Card` is an object returned by `.nextCard()` which describes a part of a master suitable for 221 | displaying to a user. It should look like this: 222 | 223 | ```{js} 224 | { 225 | master: Id, 226 | combination: Combination, 227 | front: Array, 228 | back: Array 229 | } 230 | ``` 231 | -------------------------------------------------------------------------------- /test/computeCardsSchedule.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import computeCardsSchedule, { 4 | calculateDueDate, computeScheduleFromCardState, pickMostDue, 5 | } from '../lib/computeCardsSchedule'; 6 | import { generateId, makeEmptyState, getCardId } from '../lib/types'; 7 | import type { LearningCardState, LapsedCardState, ReviewingCardState } from '../lib/types'; 8 | import * as dates from './dates'; 9 | 10 | describe('calculateDueDate()', () => { 11 | it('should add a rounded interval to the lastReviewed, set at 3am', () => { 12 | const state: ReviewingCardState = { 13 | master: generateId(), 14 | combination: { front: [0], back: [1] }, 15 | 16 | mode: 'reviewing', 17 | lastReviewed: dates.today, 18 | factor: 1000, 19 | interval: 13.3, 20 | lapses: 0, 21 | }; 22 | 23 | const due = calculateDueDate(state); 24 | expect(due).toEqual(dates.addToDate(dates.todayAt3AM, 14)); 25 | }); 26 | }); 27 | 28 | describe('computeScheduleFromCardState', () => { 29 | it('should return learning for lapsed and learning cards'); 30 | it('should return later for cards that are reviewing and not yet due', () => { 31 | const state: ReviewingCardState = { 32 | master: generateId(), 33 | combination: { front: [0], back: [1] }, 34 | 35 | mode: 'reviewing', 36 | lastReviewed: dates.today, 37 | factor: 1000, 38 | interval: 13.3, 39 | lapses: 0, 40 | }; 41 | // due in 14 days 42 | expect(computeScheduleFromCardState(state, dates.laterTmrw)).toEqual('later'); 43 | }); 44 | it('should return due for cards that are reviewing and due within the day', () => { 45 | const state: ReviewingCardState = { 46 | master: generateId(), 47 | combination: { front: [0], back: [1] }, 48 | 49 | mode: 'reviewing', 50 | lastReviewed: dates.today, 51 | factor: 1000, 52 | interval: 13.3, 53 | lapses: 0, 54 | }; 55 | // due in 14 days 56 | expect(computeScheduleFromCardState(state, dates.addToDate(dates.todayAt3AM, 14))).toEqual('due'); 57 | expect(computeScheduleFromCardState(state, dates.addToDate(dates.laterToday, 14))).toEqual('due'); 58 | }); 59 | it('should return overdue for cards that reviewing and due before the day', () => { 60 | const state: ReviewingCardState = { 61 | master: generateId(), 62 | combination: { front: [0], back: [1] }, 63 | 64 | mode: 'reviewing', 65 | lastReviewed: dates.today, 66 | factor: 1000, 67 | interval: 13.3, 68 | lapses: 0, 69 | }; 70 | // due in 14 days 71 | expect(computeScheduleFromCardState(state, dates.addToDate(dates.todayAt3AM, 15))).toEqual('overdue'); 72 | expect(computeScheduleFromCardState(state, dates.addToDate(dates.laterToday, 15))).toEqual('overdue'); 73 | }); 74 | }); 75 | 76 | describe('computeCardsSchedule()', () => { 77 | it('should return an empty schedule when passed an empty state', () => { 78 | expect(computeCardsSchedule(makeEmptyState(), dates.today)).toEqual({ 79 | learning: [], 80 | later: [], 81 | due: [], 82 | overdue: [], 83 | }); 84 | }); 85 | it('should a sorted list of cards when passed cards in multiple states', () => { 86 | const state = makeEmptyState(); 87 | const master = generateId(); 88 | const dueLater: ReviewingCardState = { 89 | master, 90 | combination: { front: [0], back: [1] }, 91 | 92 | mode: 'reviewing', 93 | lastReviewed: dates.laterTmrw, 94 | factor: 1000, 95 | interval: 13.3, 96 | lapses: 0, 97 | }; 98 | const dueNow: ReviewingCardState = { 99 | master, 100 | combination: { front: [0, 1], back: [1] }, 101 | 102 | mode: 'reviewing', 103 | lastReviewed: dates.laterTmrw, 104 | factor: 1000, 105 | interval: 0, 106 | lapses: 0, 107 | }; 108 | const overDue: ReviewingCardState = { 109 | master, 110 | combination: { front: [1], back: [0] }, 111 | 112 | mode: 'reviewing', 113 | lastReviewed: dates.today, 114 | factor: 1000, 115 | interval: 0, 116 | lapses: 0, 117 | }; 118 | const learning: LearningCardState = { 119 | master, 120 | combination: { front: [1, 0], back: [0] }, 121 | mode: 'learning', 122 | consecutiveCorrect: 0, 123 | lastReviewed: dates.today, 124 | }; 125 | const lapsed: LapsedCardState = { 126 | master, 127 | combination: { front: [1, 0], back: [0, 1] }, 128 | mode: 'lapsed', 129 | consecutiveCorrect: 0, 130 | lastReviewed: dates.today, 131 | factor: 1000, 132 | interval: 0, 133 | lapses: 1, 134 | }; 135 | 136 | [dueLater, dueNow, overDue, learning, lapsed].forEach((cardState) => { 137 | state.cardStates[getCardId(cardState)] = cardState; 138 | }); 139 | 140 | const s = computeCardsSchedule(state, dates.laterTmrw); 141 | expect(s.learning.length).toEqual(2); 142 | expect(s.learning).toContainEqual(getCardId(lapsed)); 143 | expect(s.learning).toContain(getCardId(learning)); 144 | expect(s.later).toEqual([getCardId(dueLater)]); 145 | expect(s.due).toEqual([getCardId(dueNow)]); 146 | expect(s.overdue).toEqual([getCardId(overDue)]); 147 | }); 148 | }); 149 | 150 | describe('pickMostDue()', () => { 151 | it('should return null when passed an empty schedule and state', () => { 152 | const state = makeEmptyState(); 153 | const sched = computeCardsSchedule(state, dates.today); 154 | expect(pickMostDue(sched, state)).toBeNull(); 155 | }); 156 | it('should return the learning card reviewed most recently if two learning cards are in the deck', () => { 157 | const state = makeEmptyState(); 158 | const master = generateId(); 159 | const dueLater: ReviewingCardState = { 160 | master, 161 | combination: { front: [0], back: [1] }, 162 | 163 | mode: 'reviewing', 164 | lastReviewed: dates.laterTmrw, 165 | factor: 1000, 166 | interval: 13.3, 167 | lapses: 0, 168 | }; 169 | const dueNow: ReviewingCardState = { 170 | master, 171 | combination: { front: [0, 1], back: [1] }, 172 | 173 | mode: 'reviewing', 174 | lastReviewed: dates.laterTmrw, 175 | factor: 1000, 176 | interval: 0, 177 | lapses: 0, 178 | }; 179 | const overDue: ReviewingCardState = { 180 | master, 181 | combination: { front: [1], back: [0] }, 182 | 183 | mode: 'reviewing', 184 | lastReviewed: dates.today, 185 | factor: 1000, 186 | interval: 0, 187 | lapses: 0, 188 | }; 189 | const learning: LearningCardState = { 190 | master, 191 | combination: { front: [1, 0], back: [0] }, 192 | mode: 'learning', 193 | consecutiveCorrect: 0, 194 | lastReviewed: dates.today, 195 | }; 196 | const lapsed: LapsedCardState = { 197 | master, 198 | combination: { front: [1, 0], back: [0, 1] }, 199 | mode: 'lapsed', 200 | consecutiveCorrect: 0, 201 | lastReviewed: dates.laterTmrw, 202 | factor: 1000, 203 | interval: 0, 204 | lapses: 1, 205 | }; 206 | 207 | [dueLater, dueNow, overDue, learning, lapsed].forEach((cardState) => { 208 | state.cardStates[getCardId(cardState)] = cardState; 209 | }); 210 | 211 | const s = computeCardsSchedule(state, dates.laterTmrw); 212 | 213 | expect(pickMostDue(s, state)).toEqual(getCardId(learning)); 214 | }); 215 | it('should return the learning card with the "greater" string ID on second tie break', () => { 216 | const state = makeEmptyState(); 217 | const master = generateId(); 218 | const dueLater: ReviewingCardState = { 219 | master, 220 | combination: { front: [0], back: [1] }, 221 | 222 | mode: 'reviewing', 223 | lastReviewed: dates.laterTmrw, 224 | factor: 1000, 225 | interval: 13.3, 226 | lapses: 0, 227 | }; 228 | const dueNow: ReviewingCardState = { 229 | master, 230 | combination: { front: [0, 1], back: [1] }, 231 | 232 | mode: 'reviewing', 233 | lastReviewed: dates.laterTmrw, 234 | factor: 1000, 235 | interval: 0, 236 | lapses: 0, 237 | }; 238 | const overDue: ReviewingCardState = { 239 | master, 240 | combination: { front: [1], back: [0] }, 241 | 242 | mode: 'reviewing', 243 | lastReviewed: dates.today, 244 | factor: 1000, 245 | interval: 0, 246 | lapses: 0, 247 | }; 248 | const learning: LearningCardState = { 249 | master, 250 | combination: { front: [1, 0], back: [0] }, 251 | mode: 'learning', 252 | consecutiveCorrect: 0, 253 | lastReviewed: dates.today, 254 | }; 255 | const lapsed: LapsedCardState = { 256 | master, 257 | combination: { front: [1, 0], back: [0, 1] }, 258 | mode: 'lapsed', 259 | consecutiveCorrect: 0, 260 | lastReviewed: dates.today, 261 | factor: 1000, 262 | interval: 0, 263 | lapses: 1, 264 | }; 265 | 266 | [dueLater, dueNow, overDue, learning, lapsed].forEach((cardState) => { 267 | state.cardStates[getCardId(cardState)] = cardState; 268 | }); 269 | 270 | const s = computeCardsSchedule(state, dates.laterTmrw); 271 | 272 | 273 | expect(pickMostDue(s, state)).toEqual( 274 | getCardId(lapsed) > getCardId(learning) ? getCardId(lapsed) : getCardId(learning), 275 | ); 276 | }); 277 | }); 278 | -------------------------------------------------------------------------------- /flow-typed/npm/jest_v19.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: b3ed97c44539e6cdbaf9032b315a2b31 2 | // flow-typed version: ea7ac31527/jest_v19.x.x/flow_>=v0.33.x 3 | 4 | type JestMockFn = { 5 | (...args: Array): any, 6 | /** 7 | * An object for introspecting mock calls 8 | */ 9 | mock: { 10 | /** 11 | * An array that represents all calls that have been made into this mock 12 | * function. Each call is represented by an array of arguments that were 13 | * passed during the call. 14 | */ 15 | calls: Array>, 16 | /** 17 | * An array that contains all the object instances that have been 18 | * instantiated from this mock function. 19 | */ 20 | instances: mixed, 21 | }, 22 | /** 23 | * Resets all information stored in the mockFn.mock.calls and 24 | * mockFn.mock.instances arrays. Often this is useful when you want to clean 25 | * up a mock's usage data between two assertions. 26 | */ 27 | mockClear(): Function, 28 | /** 29 | * Resets all information stored in the mock. This is useful when you want to 30 | * completely restore a mock back to its initial state. 31 | */ 32 | mockReset(): Function, 33 | /** 34 | * Accepts a function that should be used as the implementation of the mock. 35 | * The mock itself will still record all calls that go into and instances 36 | * that come from itself -- the only difference is that the implementation 37 | * will also be executed when the mock is called. 38 | */ 39 | mockImplementation(fn: Function): JestMockFn, 40 | /** 41 | * Accepts a function that will be used as an implementation of the mock for 42 | * one call to the mocked function. Can be chained so that multiple function 43 | * calls produce different results. 44 | */ 45 | mockImplementationOnce(fn: Function): JestMockFn, 46 | /** 47 | * Just a simple sugar function for returning `this` 48 | */ 49 | mockReturnThis(): void, 50 | /** 51 | * Deprecated: use jest.fn(() => value) instead 52 | */ 53 | mockReturnValue(value: any): JestMockFn, 54 | /** 55 | * Sugar for only returning a value once inside your mock 56 | */ 57 | mockReturnValueOnce(value: any): JestMockFn, 58 | } 59 | 60 | type JestAsymmetricEqualityType = { 61 | /** 62 | * A custom Jasmine equality tester 63 | */ 64 | asymmetricMatch(value: mixed): boolean, 65 | } 66 | 67 | type JestCallsType = { 68 | allArgs(): mixed, 69 | all(): mixed, 70 | any(): boolean, 71 | count(): number, 72 | first(): mixed, 73 | mostRecent(): mixed, 74 | reset(): void, 75 | } 76 | 77 | type JestClockType = { 78 | install(): void, 79 | mockDate(date: Date): void, 80 | tick(): void, 81 | uninstall(): void, 82 | } 83 | 84 | type JestMatcherResult = { 85 | message?: string | ()=>string, 86 | pass: boolean, 87 | } 88 | 89 | type JestMatcher = (actual: any, expected: any) => JestMatcherResult; 90 | 91 | type JestExpectType = { 92 | not: JestExpectType, 93 | /** 94 | * If you have a mock function, you can use .lastCalledWith to test what 95 | * arguments it was last called with. 96 | */ 97 | lastCalledWith(...args: Array): void, 98 | /** 99 | * toBe just checks that a value is what you expect. It uses === to check 100 | * strict equality. 101 | */ 102 | toBe(value: any): void, 103 | /** 104 | * Use .toHaveBeenCalled to ensure that a mock function got called. 105 | */ 106 | toBeCalled(): void, 107 | /** 108 | * Use .toBeCalledWith to ensure that a mock function was called with 109 | * specific arguments. 110 | */ 111 | toBeCalledWith(...args: Array): void, 112 | /** 113 | * Using exact equality with floating point numbers is a bad idea. Rounding 114 | * means that intuitive things fail. 115 | */ 116 | toBeCloseTo(num: number, delta: any): void, 117 | /** 118 | * Use .toBeDefined to check that a variable is not undefined. 119 | */ 120 | toBeDefined(): void, 121 | /** 122 | * Use .toBeFalsy when you don't care what a value is, you just want to 123 | * ensure a value is false in a boolean context. 124 | */ 125 | toBeFalsy(): void, 126 | /** 127 | * To compare floating point numbers, you can use toBeGreaterThan. 128 | */ 129 | toBeGreaterThan(number: number): void, 130 | /** 131 | * To compare floating point numbers, you can use toBeGreaterThanOrEqual. 132 | */ 133 | toBeGreaterThanOrEqual(number: number): void, 134 | /** 135 | * To compare floating point numbers, you can use toBeLessThan. 136 | */ 137 | toBeLessThan(number: number): void, 138 | /** 139 | * To compare floating point numbers, you can use toBeLessThanOrEqual. 140 | */ 141 | toBeLessThanOrEqual(number: number): void, 142 | /** 143 | * Use .toBeInstanceOf(Class) to check that an object is an instance of a 144 | * class. 145 | */ 146 | toBeInstanceOf(cls: Class<*>): void, 147 | /** 148 | * .toBeNull() is the same as .toBe(null) but the error messages are a bit 149 | * nicer. 150 | */ 151 | toBeNull(): void, 152 | /** 153 | * Use .toBeTruthy when you don't care what a value is, you just want to 154 | * ensure a value is true in a boolean context. 155 | */ 156 | toBeTruthy(): void, 157 | /** 158 | * Use .toBeUndefined to check that a variable is undefined. 159 | */ 160 | toBeUndefined(): void, 161 | /** 162 | * Use .toContain when you want to check that an item is in a list. For 163 | * testing the items in the list, this uses ===, a strict equality check. 164 | */ 165 | toContain(item: any): void, 166 | /** 167 | * Use .toContainEqual when you want to check that an item is in a list. For 168 | * testing the items in the list, this matcher recursively checks the 169 | * equality of all fields, rather than checking for object identity. 170 | */ 171 | toContainEqual(item: any): void, 172 | /** 173 | * Use .toEqual when you want to check that two objects have the same value. 174 | * This matcher recursively checks the equality of all fields, rather than 175 | * checking for object identity. 176 | */ 177 | toEqual(value: any): void, 178 | /** 179 | * Use .toHaveBeenCalled to ensure that a mock function got called. 180 | */ 181 | toHaveBeenCalled(): void, 182 | /** 183 | * Use .toHaveBeenCalledTimes to ensure that a mock function got called exact 184 | * number of times. 185 | */ 186 | toHaveBeenCalledTimes(number: number): void, 187 | /** 188 | * Use .toHaveBeenCalledWith to ensure that a mock function was called with 189 | * specific arguments. 190 | */ 191 | toHaveBeenCalledWith(...args: Array): void, 192 | /** 193 | * Check that an object has a .length property and it is set to a certain 194 | * numeric value. 195 | */ 196 | toHaveLength(number: number): void, 197 | /** 198 | * 199 | */ 200 | toHaveProperty(propPath: string, value?: any): void, 201 | /** 202 | * Use .toMatch to check that a string matches a regular expression. 203 | */ 204 | toMatch(regexp: RegExp): void, 205 | /** 206 | * Use .toMatchObject to check that a javascript object matches a subset of the properties of 207 | * an object. 208 | */ 209 | toMatchObject(object: Object): void, 210 | /** 211 | * This ensures that a React component matches the most recent snapshot. 212 | */ 213 | toMatchSnapshot(name?: string): void, 214 | /** 215 | * Use .toThrow to test that a function throws when it is called. 216 | */ 217 | toThrow(message?: string | Error): void, 218 | /** 219 | * Use .toThrowError to test that a function throws a specific error when it 220 | * is called. The argument can be a string for the error message, a class for 221 | * the error, or a regex that should match the error. 222 | */ 223 | toThrowError(message?: string | Error | RegExp): void, 224 | /** 225 | * Use .toThrowErrorMatchingSnapshot to test that a function throws a error 226 | * matching the most recent snapshot when it is called. 227 | */ 228 | toThrowErrorMatchingSnapshot(): void, 229 | } 230 | 231 | type JestObjectType = { 232 | /** 233 | * Disables automatic mocking in the module loader. 234 | * 235 | * After this method is called, all `require()`s will return the real 236 | * versions of each module (rather than a mocked version). 237 | */ 238 | disableAutomock(): JestObjectType, 239 | /** 240 | * An un-hoisted version of disableAutomock 241 | */ 242 | autoMockOff(): JestObjectType, 243 | /** 244 | * Enables automatic mocking in the module loader. 245 | */ 246 | enableAutomock(): JestObjectType, 247 | /** 248 | * An un-hoisted version of enableAutomock 249 | */ 250 | autoMockOn(): JestObjectType, 251 | /** 252 | * Clears the mock.calls and mock.instances properties of all mocks. 253 | * Equivalent to calling .mockClear() on every mocked function. 254 | */ 255 | clearAllMocks(): JestObjectType, 256 | /** 257 | * Resets the state of all mocks. Equivalent to calling .mockReset() on every 258 | * mocked function. 259 | */ 260 | resetAllMocks(): JestObjectType, 261 | /** 262 | * Removes any pending timers from the timer system. 263 | */ 264 | clearAllTimers(): void, 265 | /** 266 | * The same as `mock` but not moved to the top of the expectation by 267 | * babel-jest. 268 | */ 269 | doMock(moduleName: string, moduleFactory?: any): JestObjectType, 270 | /** 271 | * The same as `unmock` but not moved to the top of the expectation by 272 | * babel-jest. 273 | */ 274 | dontMock(moduleName: string): JestObjectType, 275 | /** 276 | * Returns a new, unused mock function. Optionally takes a mock 277 | * implementation. 278 | */ 279 | fn(implementation?: Function): JestMockFn, 280 | /** 281 | * Determines if the given function is a mocked function. 282 | */ 283 | isMockFunction(fn: Function): boolean, 284 | /** 285 | * Given the name of a module, use the automatic mocking system to generate a 286 | * mocked version of the module for you. 287 | */ 288 | genMockFromModule(moduleName: string): any, 289 | /** 290 | * Mocks a module with an auto-mocked version when it is being required. 291 | * 292 | * The second argument can be used to specify an explicit module factory that 293 | * is being run instead of using Jest's automocking feature. 294 | * 295 | * The third argument can be used to create virtual mocks -- mocks of modules 296 | * that don't exist anywhere in the system. 297 | */ 298 | mock(moduleName: string, moduleFactory?: any): JestObjectType, 299 | /** 300 | * Resets the module registry - the cache of all required modules. This is 301 | * useful to isolate modules where local state might conflict between tests. 302 | */ 303 | resetModules(): JestObjectType, 304 | /** 305 | * Exhausts the micro-task queue (usually interfaced in node via 306 | * process.nextTick). 307 | */ 308 | runAllTicks(): void, 309 | /** 310 | * Exhausts the macro-task queue (i.e., all tasks queued by setTimeout(), 311 | * setInterval(), and setImmediate()). 312 | */ 313 | runAllTimers(): void, 314 | /** 315 | * Exhausts all tasks queued by setImmediate(). 316 | */ 317 | runAllImmediates(): void, 318 | /** 319 | * Executes only the macro task queue (i.e. all tasks queued by setTimeout() 320 | * or setInterval() and setImmediate()). 321 | */ 322 | runTimersToTime(msToRun: number): void, 323 | /** 324 | * Executes only the macro-tasks that are currently pending (i.e., only the 325 | * tasks that have been queued by setTimeout() or setInterval() up to this 326 | * point) 327 | */ 328 | runOnlyPendingTimers(): void, 329 | /** 330 | * Explicitly supplies the mock object that the module system should return 331 | * for the specified module. Note: It is recommended to use jest.mock() 332 | * instead. 333 | */ 334 | setMock(moduleName: string, moduleExports: any): JestObjectType, 335 | /** 336 | * Indicates that the module system should never return a mocked version of 337 | * the specified module from require() (e.g. that it should always return the 338 | * real module). 339 | */ 340 | unmock(moduleName: string): JestObjectType, 341 | /** 342 | * Instructs Jest to use fake versions of the standard timer functions 343 | * (setTimeout, setInterval, clearTimeout, clearInterval, nextTick, 344 | * setImmediate and clearImmediate). 345 | */ 346 | useFakeTimers(): JestObjectType, 347 | /** 348 | * Instructs Jest to use the real versions of the standard timer functions. 349 | */ 350 | useRealTimers(): JestObjectType, 351 | /** 352 | * Creates a mock function similar to jest.fn but also tracks calls to 353 | * object[methodName]. 354 | */ 355 | spyOn(object: Object, methodName: string): JestMockFn, 356 | } 357 | 358 | type JestSpyType = { 359 | calls: JestCallsType, 360 | } 361 | 362 | /** Runs this function after every test inside this context */ 363 | declare function afterEach(fn: Function): void; 364 | /** Runs this function before every test inside this context */ 365 | declare function beforeEach(fn: Function): void; 366 | /** Runs this function after all tests have finished inside this context */ 367 | declare function afterAll(fn: Function): void; 368 | /** Runs this function before any tests have started inside this context */ 369 | declare function beforeAll(fn: Function): void; 370 | /** A context for grouping tests together */ 371 | declare function describe(name: string, fn: Function): void; 372 | 373 | /** An individual test unit */ 374 | declare var it: { 375 | /** 376 | * An individual test unit 377 | * 378 | * @param {string} Name of Test 379 | * @param {Function} Test 380 | */ 381 | (name: string, fn?: Function): ?Promise, 382 | /** 383 | * Only run this test 384 | * 385 | * @param {string} Name of Test 386 | * @param {Function} Test 387 | */ 388 | only(name: string, fn?: Function): ?Promise, 389 | /** 390 | * Skip running this test 391 | * 392 | * @param {string} Name of Test 393 | * @param {Function} Test 394 | */ 395 | skip(name: string, fn?: Function): ?Promise, 396 | /** 397 | * Run the test concurrently 398 | * 399 | * @param {string} Name of Test 400 | * @param {Function} Test 401 | */ 402 | concurrent(name: string, fn?: Function): ?Promise, 403 | }; 404 | declare function fit(name: string, fn: Function): ?Promise; 405 | /** An individual test unit */ 406 | declare var test: typeof it; 407 | /** A disabled group of tests */ 408 | declare var xdescribe: typeof describe; 409 | /** A focused group of tests */ 410 | declare var fdescribe: typeof describe; 411 | /** A disabled individual test */ 412 | declare var xit: typeof it; 413 | /** A disabled individual test */ 414 | declare var xtest: typeof it; 415 | 416 | /** The expect function is used every time you want to test a value */ 417 | declare var expect: { 418 | /** The object that you want to make assertions against */ 419 | (value: any): JestExpectType, 420 | /** Add additional Jasmine matchers to Jest's roster */ 421 | extend(matchers: {[name: string]: JestMatcher}): void, 422 | /** Add a module that formats application-specific data structures. */ 423 | addSnapshotSerializer(serializer: (input: Object) => string): void, 424 | assertions(expectedAssertions: number): void, 425 | any(value: mixed): JestAsymmetricEqualityType, 426 | anything(): void, 427 | arrayContaining(value: Array): void, 428 | objectContaining(value: Object): void, 429 | /** Matches any received string that contains the exact expected string. */ 430 | stringContaining(value: string): void, 431 | stringMatching(value: string | RegExp): void, 432 | }; 433 | 434 | // TODO handle return type 435 | // http://jasmine.github.io/2.4/introduction.html#section-Spies 436 | declare function spyOn(value: mixed, method: string): Object; 437 | 438 | /** Holds all functions related to manipulating test runner */ 439 | declare var jest: JestObjectType 440 | 441 | /** 442 | * The global Jamine object, this is generally not exposed as the public API, 443 | * using features inside here could break in later versions of Jest. 444 | */ 445 | declare var jasmine: { 446 | DEFAULT_TIMEOUT_INTERVAL: number, 447 | any(value: mixed): JestAsymmetricEqualityType, 448 | anything(): void, 449 | arrayContaining(value: Array): void, 450 | clock(): JestClockType, 451 | createSpy(name: string): JestSpyType, 452 | createSpyObj(baseName: string, methodNames: Array): {[methodName: string]: JestSpyType}, 453 | objectContaining(value: Object): void, 454 | stringMatching(value: string): void, 455 | } 456 | -------------------------------------------------------------------------------- /dist/bundle.mjs: -------------------------------------------------------------------------------- 1 | import uuid from 'uuid'; 2 | 3 | // Generally all types should be considered opaque in application code. 4 | 5 | // -- Data types 6 | 7 | function generateId() { 8 | return uuid.v4(); 9 | } 10 | 11 | // numbers are indexes on master.fields 12 | 13 | function getCardId(o) { 14 | return o.master + '#' + o.combination.front.join(',') + '@' + o.combination.back.join(','); 15 | } 16 | 17 | // -- Computed data types 18 | 19 | function makeInitialCardState(master, combination) { 20 | return { 21 | master: master, 22 | combination: combination, 23 | 24 | mode: 'learning', 25 | consecutiveCorrect: 0, 26 | lastReviewed: null 27 | }; 28 | } 29 | 30 | function makeEmptyState() { 31 | return { 32 | cardStates: {} 33 | }; 34 | } 35 | 36 | // This function only works if reviews is always sorted by timestamp 37 | function addReview(reviews, review) { 38 | if (!reviews.length) { 39 | return [review]; 40 | } 41 | 42 | var i = reviews.length - 1; 43 | for (; i >= 0; i -= 1) { 44 | if (reviews[i].ts <= review.ts) { 45 | break; 46 | } 47 | } 48 | 49 | var newReviews = reviews.slice(0); 50 | newReviews.splice(i + 1, 0, review); 51 | 52 | return newReviews; 53 | } 54 | 55 | function dateDiffInDays(a, b) { 56 | // adapted from http://stackoverflow.com/a/15289883/251162 57 | var MS_PER_DAY = 1000 * 60 * 60 * 24; 58 | 59 | // Disstate the time and time-zone information. 60 | var utc1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate()); 61 | var utc2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate()); 62 | 63 | return (utc2 - utc1) / MS_PER_DAY; 64 | } 65 | 66 | // assumes that the day starts at 3:00am in the local timezone 67 | function calculateDueDate(state) { 68 | var result = new Date(state.lastReviewed); 69 | result.setHours(3, 0, 0); 70 | result.setDate(result.getDate() + Math.ceil(state.interval)); 71 | return result; 72 | } 73 | 74 | function computeScheduleFromCardState(state, now) { 75 | if (state.mode === 'lapsed' || state.mode === 'learning') { 76 | return 'learning'; 77 | } else if (state.mode === 'reviewing') { 78 | var diff = dateDiffInDays(calculateDueDate(state), now); 79 | if (diff < 0) { 80 | return 'later'; 81 | } else if (diff >= 0 && diff < 1) { 82 | return 'due'; 83 | } else if (diff >= 1) { 84 | return 'overdue'; 85 | } 86 | } 87 | throw new Error('unreachable'); 88 | } 89 | 90 | // Breaks ties first by last review (earlier beats later), 91 | // then by an alphabetical comparison of the cardId (just so it stays 100% deterministic) 92 | // 93 | // Returns null if no cards are due. 94 | function pickMostDue(s, state) { 95 | var prec = ['learning', 'overdue', 'due']; 96 | for (var i = 0; i < prec.length; i += 1) { 97 | var sched = prec[i]; 98 | if (s[sched].length) { 99 | return s[sched].slice(0).sort(function (a, b) { 100 | var cardA = state.cardStates[a]; 101 | var cardB = state.cardStates[b]; 102 | if (cardA == null) { 103 | throw new Error('id not found in state: ' + a); 104 | } 105 | if (cardB == null) { 106 | throw new Error('id not found in state: ' + b); 107 | } 108 | 109 | var reviewDiff = cardA.lastReviewed == null && cardB.lastReviewed != null ? 1 : cardB.lastReviewed == null && cardA.lastReviewed != null ? -1 : cardA.lastReviewed == null && cardB.lastReviewed == null ? 0 : cardB.lastReviewed - cardA.lastReviewed; 110 | if (reviewDiff !== 0) { 111 | return -reviewDiff; 112 | } 113 | 114 | if (a === b) { 115 | throw new Error('comparing duplicate id: ' + a); 116 | } 117 | return b > a ? 1 : -1; 118 | })[0]; 119 | } 120 | } 121 | return null; 122 | } 123 | 124 | function computeCardsSchedule(state, now) { 125 | var s = { 126 | learning: [], 127 | later: [], 128 | due: [], 129 | overdue: [] 130 | }; 131 | Object.keys(state.cardStates).forEach(function (cardId) { 132 | var cardState = state.cardStates[cardId]; 133 | s[computeScheduleFromCardState(cardState, now)].push(getCardId(cardState)); 134 | }); 135 | return s; 136 | } 137 | 138 | var classCallCheck = function (instance, Constructor) { 139 | if (!(instance instanceof Constructor)) { 140 | throw new TypeError("Cannot call a class as a function"); 141 | } 142 | }; 143 | 144 | var createClass = function () { 145 | function defineProperties(target, props) { 146 | for (var i = 0; i < props.length; i++) { 147 | var descriptor = props[i]; 148 | descriptor.enumerable = descriptor.enumerable || false; 149 | descriptor.configurable = true; 150 | if ("value" in descriptor) descriptor.writable = true; 151 | Object.defineProperty(target, descriptor.key, descriptor); 152 | } 153 | } 154 | 155 | return function (Constructor, protoProps, staticProps) { 156 | if (protoProps) defineProperties(Constructor.prototype, protoProps); 157 | if (staticProps) defineProperties(Constructor, staticProps); 158 | return Constructor; 159 | }; 160 | }(); 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | var _extends = Object.assign || function (target) { 169 | for (var i = 1; i < arguments.length; i++) { 170 | var source = arguments[i]; 171 | 172 | for (var key in source) { 173 | if (Object.prototype.hasOwnProperty.call(source, key)) { 174 | target[key] = source[key]; 175 | } 176 | } 177 | } 178 | 179 | return target; 180 | }; 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | var slicedToArray = function () { 203 | function sliceIterator(arr, i) { 204 | var _arr = []; 205 | var _n = true; 206 | var _d = false; 207 | var _e = undefined; 208 | 209 | try { 210 | for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { 211 | _arr.push(_s.value); 212 | 213 | if (i && _arr.length === i) break; 214 | } 215 | } catch (err) { 216 | _d = true; 217 | _e = err; 218 | } finally { 219 | try { 220 | if (!_n && _i["return"]) _i["return"](); 221 | } finally { 222 | if (_d) throw _e; 223 | } 224 | } 225 | 226 | return _arr; 227 | } 228 | 229 | return function (arr, i) { 230 | if (Array.isArray(arr)) { 231 | return arr; 232 | } else if (Symbol.iterator in Object(arr)) { 233 | return sliceIterator(arr, i); 234 | } else { 235 | throw new TypeError("Invalid attempt to destructure non-iterable instance"); 236 | } 237 | }; 238 | }(); 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | var toConsumableArray = function (arr) { 253 | if (Array.isArray(arr)) { 254 | for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) arr2[i] = arr[i]; 255 | 256 | return arr2; 257 | } else { 258 | return Array.from(arr); 259 | } 260 | }; 261 | 262 | var debug$1 = require('debug')('dolphin'); 263 | 264 | // -- applyToLearningCardState(...) 265 | 266 | // constants from Anki defaults 267 | // TODO(April 1, 2017) investigate rationales, consider changing them 268 | var INITIAL_FACTOR = 2500; 269 | var INITIAL_DAYS_WITHOUT_JUMP = 4; 270 | var INITIAL_DAYS_WITH_JUMP = 1; 271 | function applyToLearningCardState(prev, ts, rating) { 272 | if (rating === 'easy' || rating.match(/^easy|good$/) && prev.consecutiveCorrect > 0) { 273 | return { 274 | master: prev.master, 275 | combination: prev.combination, 276 | 277 | mode: 'reviewing', 278 | factor: INITIAL_FACTOR, 279 | lapses: 0, 280 | interval: prev.consecutiveCorrect > 0 ? INITIAL_DAYS_WITHOUT_JUMP : INITIAL_DAYS_WITH_JUMP, 281 | lastReviewed: ts 282 | }; 283 | } else if (rating === 'again') { 284 | return { 285 | master: prev.master, 286 | combination: prev.combination, 287 | 288 | mode: 'learning', 289 | consecutiveCorrect: 0, 290 | lastReviewed: ts 291 | }; 292 | } else if (rating.match(/^good|hard$/) && prev.consecutiveCorrect < 1) { 293 | return { 294 | master: prev.master, 295 | combination: prev.combination, 296 | 297 | mode: 'learning', 298 | consecutiveCorrect: prev.consecutiveCorrect + 1, 299 | lastReviewed: ts 300 | }; 301 | } 302 | throw new Error('logic error'); 303 | } 304 | 305 | // -- applyToReviewingCardState(...) 306 | 307 | var EASY_BONUS = 2; 308 | var MAX_INTERVAL = 365; 309 | var MIN_FACTOR = 0; // TODO 310 | var MAX_FACTOR = Number.MAX_VALUE; 311 | function constrainWithin(min, max, n) { 312 | if (min > max) { 313 | throw new Error('min > max: ' + min + '=min, ' + max + '=max'); 314 | } 315 | return Math.max(Math.min(n, max), min); 316 | } 317 | 318 | function calculateDaysLate(state, actual) { 319 | var expected = calculateDueDate(state); 320 | 321 | var daysLate = dateDiffInDays(actual, expected); 322 | 323 | if (daysLate < 0) { 324 | debug$1('last review occured earlier than expected', { 325 | daysLate: daysLate, 326 | actual: actual, 327 | expected: expected 328 | }); 329 | return 0; 330 | } 331 | 332 | return daysLate; 333 | } 334 | function applyToReviewingCardState(prev, ts, rating) { 335 | if (rating === 'again') { 336 | return { 337 | master: prev.master, 338 | combination: prev.combination, 339 | 340 | mode: 'lapsed', 341 | consecutiveCorrect: 0, 342 | factor: constrainWithin(MIN_FACTOR, MAX_FACTOR, prev.factor - 200), 343 | lapses: prev.lapses + 1, 344 | interval: prev.interval, 345 | lastReviewed: ts 346 | }; 347 | } 348 | var factorAdj = rating === 'hard' ? -150 : rating === 'good' ? 0 : rating === 'easy' ? 150 : NaN; 349 | var daysLate = calculateDaysLate(prev, ts); 350 | 351 | var ival = constrainWithin(prev.interval + 1, MAX_INTERVAL, rating === 'hard' ? (prev.interval + daysLate / 4) * 1.2 : rating === 'good' ? (prev.interval + daysLate / 2) * prev.factor / 1000 : rating === 'easy' ? (prev.interval + daysLate) * prev.factor / 1000 * EASY_BONUS : NaN); 352 | 353 | if (isNaN(factorAdj) || isNaN(ival)) { 354 | throw new Error('invalid rating: ' + rating); 355 | } 356 | 357 | return { 358 | master: prev.master, 359 | combination: prev.combination, 360 | 361 | mode: 'reviewing', 362 | factor: constrainWithin(MIN_FACTOR, MAX_FACTOR, prev.factor + factorAdj), 363 | lapses: prev.lapses, 364 | interval: ival, 365 | lastReviewed: ts 366 | }; 367 | } 368 | 369 | // -- applyToLapsedCardState(...) 370 | 371 | function applyToLapsedCardState(prev, ts, rating) { 372 | if (rating === 'easy' || rating.match(/^easy|good$/) && prev.consecutiveCorrect > 0) { 373 | return { 374 | master: prev.master, 375 | combination: prev.combination, 376 | 377 | mode: 'reviewing', 378 | factor: prev.factor, 379 | lapses: prev.lapses, 380 | interval: prev.consecutiveCorrect > 0 ? INITIAL_DAYS_WITHOUT_JUMP : INITIAL_DAYS_WITH_JUMP, 381 | lastReviewed: ts 382 | }; 383 | } 384 | return { 385 | master: prev.master, 386 | combination: prev.combination, 387 | 388 | mode: 'lapsed', 389 | factor: prev.factor, 390 | lapses: prev.lapses, 391 | interval: prev.interval, 392 | lastReviewed: ts, 393 | consecutiveCorrect: rating === 'again' ? 0 : prev.consecutiveCorrect + 1 394 | }; 395 | } 396 | 397 | // -- applyReview(...) 398 | 399 | 400 | function applyToCardState(prev, ts, rating) { 401 | if (prev.lastReviewed != null && prev.lastReviewed > ts) { 402 | var p = prev.lastReviewed.toISOString(); 403 | var t = ts.toISOString(); 404 | throw new Error('cannot apply review before current lastReviewed: ' + p + ' > ' + t); 405 | } 406 | 407 | if (prev.mode === 'learning') { 408 | return applyToLearningCardState(prev, ts, rating); 409 | } else if (prev.mode === 'reviewing') { 410 | return applyToReviewingCardState(prev, ts, rating); 411 | } else if (prev.mode === 'lapsed') { 412 | return applyToLapsedCardState(prev, ts, rating); 413 | } 414 | throw new Error('invalid mode: ' + prev.mode); 415 | } 416 | 417 | function applyReview(prev, review) { 418 | var cardId = getCardId(review); 419 | 420 | var cardState = prev.cardStates[cardId]; 421 | if (cardState == null) { 422 | throw new Error('applying review to missing card: ' + JSON.stringify(review)); 423 | } 424 | 425 | var state = { 426 | cardStates: _extends({}, prev.cardStates) 427 | }; 428 | state.cardStates[cardId] = applyToCardState(cardState, review.ts, review.rating); 429 | 430 | return state; 431 | } 432 | 433 | var debug = require('debug')('dolphin'); 434 | 435 | var DolphinSR = function () { 436 | 437 | // TODO(April 3, 2017) 438 | // Currently the cachedCardsSchedule is not invalidated when the time changes (only when a review 439 | // or master is added), so there is a possibility for cards not switching from due to overdue 440 | // properly. In practice, this has not been a significant issue -- easy fix for later. 441 | function DolphinSR() { 442 | var currentDateGetter = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : function () { 443 | return new Date(); 444 | }; 445 | classCallCheck(this, DolphinSR); 446 | 447 | this._state = makeEmptyState(); 448 | this._masters = {}; 449 | this._reviews = []; 450 | this._currentDateGetter = currentDateGetter; 451 | } 452 | 453 | // gotcha: does not invalidate cache, that happens in addMasters() 454 | 455 | 456 | // For testing, you can swap this out with a different function to change when 'now' is. 457 | 458 | 459 | createClass(DolphinSR, [{ 460 | key: '_addMaster', 461 | value: function _addMaster(master) { 462 | var _this = this; 463 | 464 | if (this._masters[master.id]) { 465 | throw new Error('master already added: ' + master.id); 466 | } 467 | master.combinations.forEach(function (combination) { 468 | var id = getCardId({ master: master.id, combination: combination }); 469 | _this._state.cardStates[id] = makeInitialCardState(master.id, combination); 470 | }); 471 | this._masters[master.id] = master; 472 | } 473 | }, { 474 | key: 'addMasters', 475 | value: function addMasters() { 476 | var _this2 = this; 477 | 478 | for (var _len = arguments.length, masters = Array(_len), _key = 0; _key < _len; _key++) { 479 | masters[_key] = arguments[_key]; 480 | } 481 | 482 | masters.forEach(function (master) { 483 | return _this2._addMaster(master); 484 | }); 485 | this._cachedCardsSchedule = null; 486 | } 487 | 488 | // gotcha: does not apply the reviews to state or invalidate cache, that happens in addReviews() 489 | 490 | }, { 491 | key: '_addReviewToReviews', 492 | value: function _addReviewToReviews(review) { 493 | this._reviews = addReview(this._reviews, review); 494 | var lastReview = this._reviews[this._reviews.length - 1]; 495 | 496 | return getCardId(lastReview) + '#' + lastReview.ts.toISOString() !== getCardId(review) + '#' + review.ts.toISOString(); 497 | } 498 | 499 | // Returns true if the entire state was rebuilt (inefficient, minimize) 500 | 501 | }, { 502 | key: 'addReviews', 503 | value: function addReviews() { 504 | var _this3 = this; 505 | 506 | for (var _len2 = arguments.length, reviews = Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { 507 | reviews[_key2] = arguments[_key2]; 508 | } 509 | 510 | var needsRebuild = reviews.reduce(function (v, review) { 511 | if (_this3._addReviewToReviews(review)) { 512 | return true; 513 | } 514 | return v; 515 | }, false); 516 | 517 | if (needsRebuild) { 518 | this._rebuild(); 519 | } else { 520 | reviews.forEach(function (review) { 521 | _this3._state = applyReview(_this3._state, review); 522 | }); 523 | } 524 | 525 | this._cachedCardsSchedule = null; 526 | 527 | return needsRebuild; 528 | } 529 | }, { 530 | key: '_rebuild', 531 | value: function _rebuild() { 532 | debug('rebuilding state'); 533 | var masters = this._masters; 534 | var reviews = this._reviews; 535 | this._masters = {}; 536 | this._reviews = []; 537 | 538 | this.addMasters.apply(this, toConsumableArray(Object.keys(masters).map(function (k) { 539 | return masters[k]; 540 | }))); 541 | this.addReviews.apply(this, toConsumableArray(reviews)); 542 | } 543 | }, { 544 | key: '_getCardsSchedule', 545 | value: function _getCardsSchedule() { 546 | if (this._cachedCardsSchedule != null) { 547 | return this._cachedCardsSchedule; 548 | } 549 | this._cachedCardsSchedule = computeCardsSchedule(this._state, this._currentDateGetter()); 550 | return this._cachedCardsSchedule; 551 | } 552 | }, { 553 | key: '_nextCardId', 554 | value: function _nextCardId() { 555 | var s = this._getCardsSchedule(); 556 | return pickMostDue(s, this._state); 557 | } 558 | }, { 559 | key: '_getCard', 560 | value: function _getCard(id) { 561 | var _id$split = id.split('#'), 562 | _id$split2 = slicedToArray(_id$split, 2), 563 | masterId = _id$split2[0], 564 | combo = _id$split2[1]; 565 | 566 | var _combo$split$map = combo.split('@').map(function (part) { 567 | return part.split(',').map(function (x) { 568 | return parseInt(x, 10); 569 | }); 570 | }), 571 | _combo$split$map2 = slicedToArray(_combo$split$map, 2), 572 | front = _combo$split$map2[0], 573 | back = _combo$split$map2[1]; 574 | 575 | var master = this._masters[masterId]; 576 | if (master == null) { 577 | throw new Error('cannot getCard: no such master: ' + masterId); 578 | } 579 | var combination = { front: front, back: back }; 580 | 581 | var frontFields = front.map(function (i) { 582 | return master.fields[i]; 583 | }); 584 | var backFields = back.map(function (i) { 585 | return master.fields[i]; 586 | }); 587 | 588 | return { 589 | master: masterId, 590 | combination: combination, 591 | 592 | front: frontFields, 593 | back: backFields 594 | }; 595 | } 596 | }, { 597 | key: 'nextCard', 598 | value: function nextCard() { 599 | var cardId = this._nextCardId(); 600 | if (cardId == null) { 601 | return null; 602 | } 603 | return this._getCard(cardId); 604 | } 605 | }, { 606 | key: 'summary', 607 | value: function summary() { 608 | var s = this._getCardsSchedule(); 609 | return { 610 | due: s.due.length, 611 | later: s.later.length, 612 | learning: s.learning.length, 613 | overdue: s.overdue.length 614 | }; 615 | } 616 | }]); 617 | return DolphinSR; 618 | }(); 619 | 620 | export { generateId, DolphinSR }; 621 | //# sourceMappingURL=bundle.mjs.map 622 | -------------------------------------------------------------------------------- /dist/bundle.js: -------------------------------------------------------------------------------- 1 | (function (global, factory) { 2 | typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('uuid')) : 3 | typeof define === 'function' && define.amd ? define(['exports', 'uuid'], factory) : 4 | (factory((global.dolphinsr = global.dolphinsr || {}),global.uuid)); 5 | }(this, (function (exports,uuid) { 'use strict'; 6 | 7 | uuid = 'default' in uuid ? uuid['default'] : uuid; 8 | 9 | // Generally all types should be considered opaque in application code. 10 | 11 | // -- Data types 12 | 13 | function generateId() { 14 | return uuid.v4(); 15 | } 16 | 17 | // numbers are indexes on master.fields 18 | 19 | function getCardId(o) { 20 | return o.master + '#' + o.combination.front.join(',') + '@' + o.combination.back.join(','); 21 | } 22 | 23 | // -- Computed data types 24 | 25 | function makeInitialCardState(master, combination) { 26 | return { 27 | master: master, 28 | combination: combination, 29 | 30 | mode: 'learning', 31 | consecutiveCorrect: 0, 32 | lastReviewed: null 33 | }; 34 | } 35 | 36 | function makeEmptyState() { 37 | return { 38 | cardStates: {} 39 | }; 40 | } 41 | 42 | // This function only works if reviews is always sorted by timestamp 43 | function addReview(reviews, review) { 44 | if (!reviews.length) { 45 | return [review]; 46 | } 47 | 48 | var i = reviews.length - 1; 49 | for (; i >= 0; i -= 1) { 50 | if (reviews[i].ts <= review.ts) { 51 | break; 52 | } 53 | } 54 | 55 | var newReviews = reviews.slice(0); 56 | newReviews.splice(i + 1, 0, review); 57 | 58 | return newReviews; 59 | } 60 | 61 | function dateDiffInDays(a, b) { 62 | // adapted from http://stackoverflow.com/a/15289883/251162 63 | var MS_PER_DAY = 1000 * 60 * 60 * 24; 64 | 65 | // Disstate the time and time-zone information. 66 | var utc1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate()); 67 | var utc2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate()); 68 | 69 | return (utc2 - utc1) / MS_PER_DAY; 70 | } 71 | 72 | // assumes that the day starts at 3:00am in the local timezone 73 | function calculateDueDate(state) { 74 | var result = new Date(state.lastReviewed); 75 | result.setHours(3, 0, 0); 76 | result.setDate(result.getDate() + Math.ceil(state.interval)); 77 | return result; 78 | } 79 | 80 | function computeScheduleFromCardState(state, now) { 81 | if (state.mode === 'lapsed' || state.mode === 'learning') { 82 | return 'learning'; 83 | } else if (state.mode === 'reviewing') { 84 | var diff = dateDiffInDays(calculateDueDate(state), now); 85 | if (diff < 0) { 86 | return 'later'; 87 | } else if (diff >= 0 && diff < 1) { 88 | return 'due'; 89 | } else if (diff >= 1) { 90 | return 'overdue'; 91 | } 92 | } 93 | throw new Error('unreachable'); 94 | } 95 | 96 | // Breaks ties first by last review (earlier beats later), 97 | // then by an alphabetical comparison of the cardId (just so it stays 100% deterministic) 98 | // 99 | // Returns null if no cards are due. 100 | function pickMostDue(s, state) { 101 | var prec = ['learning', 'overdue', 'due']; 102 | for (var i = 0; i < prec.length; i += 1) { 103 | var sched = prec[i]; 104 | if (s[sched].length) { 105 | return s[sched].slice(0).sort(function (a, b) { 106 | var cardA = state.cardStates[a]; 107 | var cardB = state.cardStates[b]; 108 | if (cardA == null) { 109 | throw new Error('id not found in state: ' + a); 110 | } 111 | if (cardB == null) { 112 | throw new Error('id not found in state: ' + b); 113 | } 114 | 115 | var reviewDiff = cardA.lastReviewed == null && cardB.lastReviewed != null ? 1 : cardB.lastReviewed == null && cardA.lastReviewed != null ? -1 : cardA.lastReviewed == null && cardB.lastReviewed == null ? 0 : cardB.lastReviewed - cardA.lastReviewed; 116 | if (reviewDiff !== 0) { 117 | return -reviewDiff; 118 | } 119 | 120 | if (a === b) { 121 | throw new Error('comparing duplicate id: ' + a); 122 | } 123 | return b > a ? 1 : -1; 124 | })[0]; 125 | } 126 | } 127 | return null; 128 | } 129 | 130 | function computeCardsSchedule(state, now) { 131 | var s = { 132 | learning: [], 133 | later: [], 134 | due: [], 135 | overdue: [] 136 | }; 137 | Object.keys(state.cardStates).forEach(function (cardId) { 138 | var cardState = state.cardStates[cardId]; 139 | s[computeScheduleFromCardState(cardState, now)].push(getCardId(cardState)); 140 | }); 141 | return s; 142 | } 143 | 144 | var classCallCheck = function (instance, Constructor) { 145 | if (!(instance instanceof Constructor)) { 146 | throw new TypeError("Cannot call a class as a function"); 147 | } 148 | }; 149 | 150 | var createClass = function () { 151 | function defineProperties(target, props) { 152 | for (var i = 0; i < props.length; i++) { 153 | var descriptor = props[i]; 154 | descriptor.enumerable = descriptor.enumerable || false; 155 | descriptor.configurable = true; 156 | if ("value" in descriptor) descriptor.writable = true; 157 | Object.defineProperty(target, descriptor.key, descriptor); 158 | } 159 | } 160 | 161 | return function (Constructor, protoProps, staticProps) { 162 | if (protoProps) defineProperties(Constructor.prototype, protoProps); 163 | if (staticProps) defineProperties(Constructor, staticProps); 164 | return Constructor; 165 | }; 166 | }(); 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | var _extends = Object.assign || function (target) { 175 | for (var i = 1; i < arguments.length; i++) { 176 | var source = arguments[i]; 177 | 178 | for (var key in source) { 179 | if (Object.prototype.hasOwnProperty.call(source, key)) { 180 | target[key] = source[key]; 181 | } 182 | } 183 | } 184 | 185 | return target; 186 | }; 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | var slicedToArray = function () { 209 | function sliceIterator(arr, i) { 210 | var _arr = []; 211 | var _n = true; 212 | var _d = false; 213 | var _e = undefined; 214 | 215 | try { 216 | for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { 217 | _arr.push(_s.value); 218 | 219 | if (i && _arr.length === i) break; 220 | } 221 | } catch (err) { 222 | _d = true; 223 | _e = err; 224 | } finally { 225 | try { 226 | if (!_n && _i["return"]) _i["return"](); 227 | } finally { 228 | if (_d) throw _e; 229 | } 230 | } 231 | 232 | return _arr; 233 | } 234 | 235 | return function (arr, i) { 236 | if (Array.isArray(arr)) { 237 | return arr; 238 | } else if (Symbol.iterator in Object(arr)) { 239 | return sliceIterator(arr, i); 240 | } else { 241 | throw new TypeError("Invalid attempt to destructure non-iterable instance"); 242 | } 243 | }; 244 | }(); 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | var toConsumableArray = function (arr) { 259 | if (Array.isArray(arr)) { 260 | for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) arr2[i] = arr[i]; 261 | 262 | return arr2; 263 | } else { 264 | return Array.from(arr); 265 | } 266 | }; 267 | 268 | var debug$1 = require('debug')('dolphin'); 269 | 270 | // -- applyToLearningCardState(...) 271 | 272 | // constants from Anki defaults 273 | // TODO(April 1, 2017) investigate rationales, consider changing them 274 | var INITIAL_FACTOR = 2500; 275 | var INITIAL_DAYS_WITHOUT_JUMP = 4; 276 | var INITIAL_DAYS_WITH_JUMP = 1; 277 | function applyToLearningCardState(prev, ts, rating) { 278 | if (rating === 'easy' || rating.match(/^easy|good$/) && prev.consecutiveCorrect > 0) { 279 | return { 280 | master: prev.master, 281 | combination: prev.combination, 282 | 283 | mode: 'reviewing', 284 | factor: INITIAL_FACTOR, 285 | lapses: 0, 286 | interval: prev.consecutiveCorrect > 0 ? INITIAL_DAYS_WITHOUT_JUMP : INITIAL_DAYS_WITH_JUMP, 287 | lastReviewed: ts 288 | }; 289 | } else if (rating === 'again') { 290 | return { 291 | master: prev.master, 292 | combination: prev.combination, 293 | 294 | mode: 'learning', 295 | consecutiveCorrect: 0, 296 | lastReviewed: ts 297 | }; 298 | } else if (rating.match(/^good|hard$/) && prev.consecutiveCorrect < 1) { 299 | return { 300 | master: prev.master, 301 | combination: prev.combination, 302 | 303 | mode: 'learning', 304 | consecutiveCorrect: prev.consecutiveCorrect + 1, 305 | lastReviewed: ts 306 | }; 307 | } 308 | throw new Error('logic error'); 309 | } 310 | 311 | // -- applyToReviewingCardState(...) 312 | 313 | var EASY_BONUS = 2; 314 | var MAX_INTERVAL = 365; 315 | var MIN_FACTOR = 0; // TODO 316 | var MAX_FACTOR = Number.MAX_VALUE; 317 | function constrainWithin(min, max, n) { 318 | if (min > max) { 319 | throw new Error('min > max: ' + min + '=min, ' + max + '=max'); 320 | } 321 | return Math.max(Math.min(n, max), min); 322 | } 323 | 324 | function calculateDaysLate(state, actual) { 325 | var expected = calculateDueDate(state); 326 | 327 | var daysLate = dateDiffInDays(actual, expected); 328 | 329 | if (daysLate < 0) { 330 | debug$1('last review occured earlier than expected', { 331 | daysLate: daysLate, 332 | actual: actual, 333 | expected: expected 334 | }); 335 | return 0; 336 | } 337 | 338 | return daysLate; 339 | } 340 | function applyToReviewingCardState(prev, ts, rating) { 341 | if (rating === 'again') { 342 | return { 343 | master: prev.master, 344 | combination: prev.combination, 345 | 346 | mode: 'lapsed', 347 | consecutiveCorrect: 0, 348 | factor: constrainWithin(MIN_FACTOR, MAX_FACTOR, prev.factor - 200), 349 | lapses: prev.lapses + 1, 350 | interval: prev.interval, 351 | lastReviewed: ts 352 | }; 353 | } 354 | var factorAdj = rating === 'hard' ? -150 : rating === 'good' ? 0 : rating === 'easy' ? 150 : NaN; 355 | var daysLate = calculateDaysLate(prev, ts); 356 | 357 | var ival = constrainWithin(prev.interval + 1, MAX_INTERVAL, rating === 'hard' ? (prev.interval + daysLate / 4) * 1.2 : rating === 'good' ? (prev.interval + daysLate / 2) * prev.factor / 1000 : rating === 'easy' ? (prev.interval + daysLate) * prev.factor / 1000 * EASY_BONUS : NaN); 358 | 359 | if (isNaN(factorAdj) || isNaN(ival)) { 360 | throw new Error('invalid rating: ' + rating); 361 | } 362 | 363 | return { 364 | master: prev.master, 365 | combination: prev.combination, 366 | 367 | mode: 'reviewing', 368 | factor: constrainWithin(MIN_FACTOR, MAX_FACTOR, prev.factor + factorAdj), 369 | lapses: prev.lapses, 370 | interval: ival, 371 | lastReviewed: ts 372 | }; 373 | } 374 | 375 | // -- applyToLapsedCardState(...) 376 | 377 | function applyToLapsedCardState(prev, ts, rating) { 378 | if (rating === 'easy' || rating.match(/^easy|good$/) && prev.consecutiveCorrect > 0) { 379 | return { 380 | master: prev.master, 381 | combination: prev.combination, 382 | 383 | mode: 'reviewing', 384 | factor: prev.factor, 385 | lapses: prev.lapses, 386 | interval: prev.consecutiveCorrect > 0 ? INITIAL_DAYS_WITHOUT_JUMP : INITIAL_DAYS_WITH_JUMP, 387 | lastReviewed: ts 388 | }; 389 | } 390 | return { 391 | master: prev.master, 392 | combination: prev.combination, 393 | 394 | mode: 'lapsed', 395 | factor: prev.factor, 396 | lapses: prev.lapses, 397 | interval: prev.interval, 398 | lastReviewed: ts, 399 | consecutiveCorrect: rating === 'again' ? 0 : prev.consecutiveCorrect + 1 400 | }; 401 | } 402 | 403 | // -- applyReview(...) 404 | 405 | 406 | function applyToCardState(prev, ts, rating) { 407 | if (prev.lastReviewed != null && prev.lastReviewed > ts) { 408 | var p = prev.lastReviewed.toISOString(); 409 | var t = ts.toISOString(); 410 | throw new Error('cannot apply review before current lastReviewed: ' + p + ' > ' + t); 411 | } 412 | 413 | if (prev.mode === 'learning') { 414 | return applyToLearningCardState(prev, ts, rating); 415 | } else if (prev.mode === 'reviewing') { 416 | return applyToReviewingCardState(prev, ts, rating); 417 | } else if (prev.mode === 'lapsed') { 418 | return applyToLapsedCardState(prev, ts, rating); 419 | } 420 | throw new Error('invalid mode: ' + prev.mode); 421 | } 422 | 423 | function applyReview(prev, review) { 424 | var cardId = getCardId(review); 425 | 426 | var cardState = prev.cardStates[cardId]; 427 | if (cardState == null) { 428 | throw new Error('applying review to missing card: ' + JSON.stringify(review)); 429 | } 430 | 431 | var state = { 432 | cardStates: _extends({}, prev.cardStates) 433 | }; 434 | state.cardStates[cardId] = applyToCardState(cardState, review.ts, review.rating); 435 | 436 | return state; 437 | } 438 | 439 | var debug = require('debug')('dolphin'); 440 | 441 | var DolphinSR = function () { 442 | 443 | // TODO(April 3, 2017) 444 | // Currently the cachedCardsSchedule is not invalidated when the time changes (only when a review 445 | // or master is added), so there is a possibility for cards not switching from due to overdue 446 | // properly. In practice, this has not been a significant issue -- easy fix for later. 447 | function DolphinSR() { 448 | var currentDateGetter = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : function () { 449 | return new Date(); 450 | }; 451 | classCallCheck(this, DolphinSR); 452 | 453 | this._state = makeEmptyState(); 454 | this._masters = {}; 455 | this._reviews = []; 456 | this._currentDateGetter = currentDateGetter; 457 | } 458 | 459 | // gotcha: does not invalidate cache, that happens in addMasters() 460 | 461 | 462 | // For testing, you can swap this out with a different function to change when 'now' is. 463 | 464 | 465 | createClass(DolphinSR, [{ 466 | key: '_addMaster', 467 | value: function _addMaster(master) { 468 | var _this = this; 469 | 470 | if (this._masters[master.id]) { 471 | throw new Error('master already added: ' + master.id); 472 | } 473 | master.combinations.forEach(function (combination) { 474 | var id = getCardId({ master: master.id, combination: combination }); 475 | _this._state.cardStates[id] = makeInitialCardState(master.id, combination); 476 | }); 477 | this._masters[master.id] = master; 478 | } 479 | }, { 480 | key: 'addMasters', 481 | value: function addMasters() { 482 | var _this2 = this; 483 | 484 | for (var _len = arguments.length, masters = Array(_len), _key = 0; _key < _len; _key++) { 485 | masters[_key] = arguments[_key]; 486 | } 487 | 488 | masters.forEach(function (master) { 489 | return _this2._addMaster(master); 490 | }); 491 | this._cachedCardsSchedule = null; 492 | } 493 | 494 | // gotcha: does not apply the reviews to state or invalidate cache, that happens in addReviews() 495 | 496 | }, { 497 | key: '_addReviewToReviews', 498 | value: function _addReviewToReviews(review) { 499 | this._reviews = addReview(this._reviews, review); 500 | var lastReview = this._reviews[this._reviews.length - 1]; 501 | 502 | return getCardId(lastReview) + '#' + lastReview.ts.toISOString() !== getCardId(review) + '#' + review.ts.toISOString(); 503 | } 504 | 505 | // Returns true if the entire state was rebuilt (inefficient, minimize) 506 | 507 | }, { 508 | key: 'addReviews', 509 | value: function addReviews() { 510 | var _this3 = this; 511 | 512 | for (var _len2 = arguments.length, reviews = Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { 513 | reviews[_key2] = arguments[_key2]; 514 | } 515 | 516 | var needsRebuild = reviews.reduce(function (v, review) { 517 | if (_this3._addReviewToReviews(review)) { 518 | return true; 519 | } 520 | return v; 521 | }, false); 522 | 523 | if (needsRebuild) { 524 | this._rebuild(); 525 | } else { 526 | reviews.forEach(function (review) { 527 | _this3._state = applyReview(_this3._state, review); 528 | }); 529 | } 530 | 531 | this._cachedCardsSchedule = null; 532 | 533 | return needsRebuild; 534 | } 535 | }, { 536 | key: '_rebuild', 537 | value: function _rebuild() { 538 | debug('rebuilding state'); 539 | var masters = this._masters; 540 | var reviews = this._reviews; 541 | this._masters = {}; 542 | this._reviews = []; 543 | 544 | this.addMasters.apply(this, toConsumableArray(Object.keys(masters).map(function (k) { 545 | return masters[k]; 546 | }))); 547 | this.addReviews.apply(this, toConsumableArray(reviews)); 548 | } 549 | }, { 550 | key: '_getCardsSchedule', 551 | value: function _getCardsSchedule() { 552 | if (this._cachedCardsSchedule != null) { 553 | return this._cachedCardsSchedule; 554 | } 555 | this._cachedCardsSchedule = computeCardsSchedule(this._state, this._currentDateGetter()); 556 | return this._cachedCardsSchedule; 557 | } 558 | }, { 559 | key: '_nextCardId', 560 | value: function _nextCardId() { 561 | var s = this._getCardsSchedule(); 562 | return pickMostDue(s, this._state); 563 | } 564 | }, { 565 | key: '_getCard', 566 | value: function _getCard(id) { 567 | var _id$split = id.split('#'), 568 | _id$split2 = slicedToArray(_id$split, 2), 569 | masterId = _id$split2[0], 570 | combo = _id$split2[1]; 571 | 572 | var _combo$split$map = combo.split('@').map(function (part) { 573 | return part.split(',').map(function (x) { 574 | return parseInt(x, 10); 575 | }); 576 | }), 577 | _combo$split$map2 = slicedToArray(_combo$split$map, 2), 578 | front = _combo$split$map2[0], 579 | back = _combo$split$map2[1]; 580 | 581 | var master = this._masters[masterId]; 582 | if (master == null) { 583 | throw new Error('cannot getCard: no such master: ' + masterId); 584 | } 585 | var combination = { front: front, back: back }; 586 | 587 | var frontFields = front.map(function (i) { 588 | return master.fields[i]; 589 | }); 590 | var backFields = back.map(function (i) { 591 | return master.fields[i]; 592 | }); 593 | 594 | return { 595 | master: masterId, 596 | combination: combination, 597 | 598 | front: frontFields, 599 | back: backFields 600 | }; 601 | } 602 | }, { 603 | key: 'nextCard', 604 | value: function nextCard() { 605 | var cardId = this._nextCardId(); 606 | if (cardId == null) { 607 | return null; 608 | } 609 | return this._getCard(cardId); 610 | } 611 | }, { 612 | key: 'summary', 613 | value: function summary() { 614 | var s = this._getCardsSchedule(); 615 | return { 616 | due: s.due.length, 617 | later: s.later.length, 618 | learning: s.learning.length, 619 | overdue: s.overdue.length 620 | }; 621 | } 622 | }]); 623 | return DolphinSR; 624 | }(); 625 | 626 | exports.generateId = generateId; 627 | exports.DolphinSR = DolphinSR; 628 | 629 | Object.defineProperty(exports, '__esModule', { value: true }); 630 | 631 | }))); 632 | //# sourceMappingURL=bundle.js.map 633 | -------------------------------------------------------------------------------- /dist/bundle.mjs.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"bundle.mjs","sources":["../lib/types.js","../lib/addReview.js","../lib/dateDiffInDays.js","../lib/computeCardsSchedule.js","../lib/applyReview.js","../lib/index.js"],"sourcesContent":["// @flow\n\nimport uuid from 'uuid';\n\n// Generally all types should be considered opaque in application code.\n\n// -- Data types\n\nexport type Id = string;\nexport function generateId(): Id {\n return uuid.v4();\n}\n\nexport type Field = string;\n\n// numbers are indexes on master.fields\nexport type Combination = {front: number[], back: number[], };\n\nexport type CardId = string;\nexport function getCardId(o: {master: Id, combination: Combination}): CardId {\n return `${o.master}#${o.combination.front.join(',')}@${o.combination.back.join(',')}`;\n}\n\n\nexport type Master = {\n id: Id,\n fields: Array,\n combinations: Array,\n}\n\nexport type Rating = 'easy' | 'good' | 'hard' | 'again';\n\nexport type Review = {\n master: Id,\n combination: Combination,\n ts: Date,\n rating: Rating,\n}\n\n// -- Computed data types\n\nexport type Card = {\n master: Id,\n combination: Combination,\n front: Field[],\n back: Field[]\n};\n\nexport type LearningCardState = {\n master: Id,\n combination: Combination,\n\n mode: 'learning',\n consecutiveCorrect: number, // 0 <= consecutiveCorrect < 2, int\n lastReviewed: ?Date\n};\nexport type ReviewingCardState = {\n master: Id,\n combination: Combination,\n\n mode: 'reviewing',\n factor: number, // float\n lapses: number, // int\n interval: number, // days since lastReviewed\n lastReviewed: Date\n};\nexport type LapsedCardState = {\n master: Id,\n combination: Combination,\n\n mode: 'lapsed',\n consecutiveCorrect: number,\n factor: number,\n lapses: number,\n interval: number,\n lastReviewed: Date,\n};\nexport type CardState = LearningCardState | ReviewingCardState | LapsedCardState;\nexport function makeInitialCardState(master: Id, combination: Combination): LearningCardState {\n return {\n master,\n combination,\n\n mode: 'learning',\n consecutiveCorrect: 0,\n lastReviewed: null,\n };\n}\n\nexport type State = {\n cardStates: {[CardId]: CardState},\n};\nexport function makeEmptyState(): State {\n return {\n cardStates: {},\n };\n}\n\nexport type Schedule = 'later' | 'due' | 'overdue' | 'learning';\nexport function cmpSchedule(a: Schedule, b: Schedule) {\n const scheduleVals = {\n later: 0,\n due: 1,\n overdue: 2,\n learning: 3,\n };\n const diff = scheduleVals[b] - scheduleVals[a];\n if (diff < 0) {\n return -1;\n } else if (diff > 0) {\n return 1;\n }\n return 0;\n}\n\nexport type CardsSchedule = {\n 'later': Array,\n 'due': Array,\n 'overdue': Array,\n 'learning': Array\n};\n\nexport type SummaryStatistics = {\n 'later': number,\n 'due': number,\n 'overdue': number,\n 'learning': number\n};\n","// @flow\nimport type { Review } from './types';\n\n// This function only works if reviews is always sorted by timestamp\nexport default function addReview(reviews: Review[], review: Review): Review[] {\n if (!reviews.length) {\n return [review];\n }\n\n let i = reviews.length - 1;\n for (; i >= 0; i -= 1) {\n if (reviews[i].ts <= review.ts) {\n break;\n }\n }\n\n const newReviews = reviews.slice(0);\n newReviews.splice(i + 1, 0, review);\n\n return newReviews;\n}\n","// @flow\n\nexport default function dateDiffInDays(a: Date, b: Date): number {\n // adapted from http://stackoverflow.com/a/15289883/251162\n const MS_PER_DAY = 1000 * 60 * 60 * 24;\n\n // Disstate the time and time-zone information.\n const utc1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());\n const utc2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());\n\n return (utc2 - utc1) / MS_PER_DAY;\n}\n","// @flow\n\nimport type { CardState, ReviewingCardState, Schedule, CardsSchedule, State, CardId } from './types';\nimport { getCardId } from './types';\nimport dateDiffInDays from './dateDiffInDays';\n\n// assumes that the day starts at 3:00am in the local timezone\nexport function calculateDueDate(state: ReviewingCardState): Date {\n const result = new Date(state.lastReviewed);\n result.setHours(3, 0, 0);\n result.setDate(result.getDate() + Math.ceil(state.interval));\n return result;\n}\n\nexport function computeScheduleFromCardState(state: CardState, now: Date): Schedule {\n if (state.mode === 'lapsed' || state.mode === 'learning') {\n return 'learning';\n } else if (state.mode === 'reviewing') {\n const diff = dateDiffInDays(calculateDueDate(state), now);\n if (diff < 0) {\n return 'later';\n } else if (diff >= 0 && diff < 1) {\n return 'due';\n } else if (diff >= 1) {\n return 'overdue';\n }\n }\n throw new Error('unreachable');\n}\n\n// Breaks ties first by last review (earlier beats later),\n// then by an alphabetical comparison of the cardId (just so it stays 100% deterministic)\n//\n// Returns null if no cards are due.\nexport function pickMostDue(s: CardsSchedule, state: State): ?CardId {\n const prec: Schedule[] = ['learning', 'overdue', 'due'];\n for (let i = 0; i < prec.length; i += 1) {\n const sched = prec[i];\n if (s[sched].length) {\n return s[sched].slice(0).sort((a, b) => {\n const cardA = state.cardStates[a];\n const cardB = state.cardStates[b];\n if (cardA == null) {\n throw new Error(`id not found in state: ${a}`);\n }\n if (cardB == null) {\n throw new Error(`id not found in state: ${b}`);\n }\n\n const reviewDiff = (\n (cardA.lastReviewed == null && cardB.lastReviewed != null) ? 1 :\n (cardB.lastReviewed == null && cardA.lastReviewed != null) ? -1 :\n (cardA.lastReviewed == null && cardB.lastReviewed == null) ? 0 :\n (cardB.lastReviewed: any) - (cardA.lastReviewed: any)\n );\n if (reviewDiff !== 0) {\n return -reviewDiff;\n }\n\n if (a === b) {\n throw new Error(`comparing duplicate id: ${a}`);\n }\n return b > a ? 1 : -1;\n })[0];\n }\n }\n return null;\n}\n\nexport default function computeCardsSchedule(state: State, now: Date): CardsSchedule {\n const s: CardsSchedule = {\n learning: [],\n later: [],\n due: [],\n overdue: [],\n };\n Object.keys(state.cardStates).forEach((cardId) => {\n const cardState = state.cardStates[cardId];\n s[computeScheduleFromCardState(cardState, now)].push(getCardId(cardState));\n });\n return s;\n}\n","// @flow\n\nimport type { State, CardState, LearningCardState, ReviewingCardState, LapsedCardState, Review, Rating } from './types';\nimport { getCardId } from './types';\nimport dateDiffInDays from './dateDiffInDays';\nimport { calculateDueDate } from './computeCardsSchedule';\n\nconst debug = require('debug')('dolphin');\n\n// -- applyToLearningCardState(...)\n\n// constants from Anki defaults\n// TODO(April 1, 2017) investigate rationales, consider changing them\nconst INITIAL_FACTOR = 2500;\nconst INITIAL_DAYS_WITHOUT_JUMP = 4;\nconst INITIAL_DAYS_WITH_JUMP = 1;\nfunction applyToLearningCardState(prev: LearningCardState, ts: Date, rating: Rating): CardState {\n if (rating === 'easy' || (rating.match(/^easy|good$/) && prev.consecutiveCorrect > 0)) {\n return {\n master: prev.master,\n combination: prev.combination,\n\n mode: 'reviewing',\n factor: INITIAL_FACTOR,\n lapses: 0,\n interval: prev.consecutiveCorrect > 0 ? INITIAL_DAYS_WITHOUT_JUMP : INITIAL_DAYS_WITH_JUMP,\n lastReviewed: ts,\n };\n } else if (rating === 'again') {\n return {\n master: prev.master,\n combination: prev.combination,\n\n mode: 'learning',\n consecutiveCorrect: 0,\n lastReviewed: ts,\n };\n } else if (rating.match(/^good|hard$/) && prev.consecutiveCorrect < 1) {\n return {\n master: prev.master,\n combination: prev.combination,\n\n mode: 'learning',\n consecutiveCorrect: prev.consecutiveCorrect + 1,\n lastReviewed: ts,\n };\n }\n throw new Error('logic error');\n}\n\n// -- applyToReviewingCardState(...)\n\nconst EASY_BONUS = 2;\nconst MAX_INTERVAL = 365;\nconst MIN_FACTOR = 0; // TODO\nconst MAX_FACTOR = Number.MAX_VALUE;\nfunction constrainWithin(min, max, n) {\n if (min > max) {\n throw new Error(`min > max: ${min}=min, ${max}=max`);\n }\n return Math.max(Math.min(n, max), min);\n}\n\nfunction calculateDaysLate(state: ReviewingCardState, actual: Date): number {\n const expected = calculateDueDate(state);\n\n const daysLate = dateDiffInDays(actual, expected);\n\n if (daysLate < 0) {\n debug('last review occured earlier than expected', {\n daysLate,\n actual,\n expected,\n });\n return 0;\n }\n\n return daysLate;\n}\nfunction applyToReviewingCardState(prev: ReviewingCardState, ts: Date, rating: Rating): CardState {\n if (rating === 'again') {\n return {\n master: prev.master,\n combination: prev.combination,\n\n mode: 'lapsed',\n consecutiveCorrect: 0,\n factor: constrainWithin(MIN_FACTOR, MAX_FACTOR, prev.factor - 200),\n lapses: prev.lapses + 1,\n interval: prev.interval,\n lastReviewed: ts,\n };\n }\n const factorAdj = (\n rating === 'hard' ? -150 :\n rating === 'good' ? 0 :\n rating === 'easy' ? 150 :\n NaN\n );\n const daysLate = calculateDaysLate(prev, ts);\n\n const ival = constrainWithin(prev.interval + 1, MAX_INTERVAL,\n rating === 'hard' ? (prev.interval + (daysLate / 4)) * 1.2 :\n rating === 'good' ? ((prev.interval + (daysLate / 2)) * prev.factor) / 1000 :\n rating === 'easy' ? (((prev.interval + daysLate) * prev.factor) / 1000) * EASY_BONUS :\n NaN,\n );\n\n if (isNaN(factorAdj) || isNaN(ival)) {\n throw new Error(`invalid rating: ${rating}`);\n }\n\n return {\n master: prev.master,\n combination: prev.combination,\n\n mode: 'reviewing',\n factor: constrainWithin(MIN_FACTOR, MAX_FACTOR, prev.factor + factorAdj),\n lapses: prev.lapses,\n interval: ival,\n lastReviewed: ts,\n };\n}\n\n// -- applyToLapsedCardState(...)\n\nfunction applyToLapsedCardState(prev: LapsedCardState, ts: Date, rating: Rating): CardState {\n if (rating === 'easy' || (rating.match(/^easy|good$/) && prev.consecutiveCorrect > 0)) {\n return {\n master: prev.master,\n combination: prev.combination,\n\n mode: 'reviewing',\n factor: prev.factor,\n lapses: prev.lapses,\n interval: prev.consecutiveCorrect > 0 ? INITIAL_DAYS_WITHOUT_JUMP : INITIAL_DAYS_WITH_JUMP,\n lastReviewed: ts,\n };\n }\n return {\n master: prev.master,\n combination: prev.combination,\n\n mode: 'lapsed',\n factor: prev.factor,\n lapses: prev.lapses,\n interval: prev.interval,\n lastReviewed: ts,\n consecutiveCorrect: rating === 'again' ? 0 : prev.consecutiveCorrect + 1,\n };\n}\n\n// -- applyReview(...)\n\n\nexport function applyToCardState(prev: CardState, ts: Date, rating: Rating): CardState {\n if (prev.lastReviewed != null && prev.lastReviewed > ts) {\n const p = prev.lastReviewed.toISOString();\n const t = ts.toISOString();\n throw new Error(`cannot apply review before current lastReviewed: ${p} > ${t}`);\n }\n\n if (prev.mode === 'learning') {\n return applyToLearningCardState((prev: any), ts, rating);\n } else if (prev.mode === 'reviewing') {\n return applyToReviewingCardState((prev: any), ts, rating);\n } else if (prev.mode === 'lapsed') {\n return applyToLapsedCardState((prev: any), ts, rating);\n }\n throw new Error(`invalid mode: ${prev.mode}`);\n}\n\nexport default function applyReview(prev: State, review: Review): State {\n const cardId = getCardId(review);\n\n const cardState = prev.cardStates[cardId];\n if (cardState == null) {\n throw new Error(`applying review to missing card: ${JSON.stringify(review)}`);\n }\n\n const state = {\n cardStates: { ...prev.cardStates },\n };\n state.cardStates[cardId] = applyToCardState(cardState, review.ts, review.rating);\n\n return state;\n}\n","// @flow\n\nimport type {\n State, Master, Review, Id, CardId, CardsSchedule, Card, SummaryStatistics,\n} from './types';\nimport { makeEmptyState, getCardId, makeInitialCardState, generateId } from './types';\nimport addReview from './addReview';\nimport applyReview from './applyReview';\nimport computeCardsSchedule, { pickMostDue } from './computeCardsSchedule';\n\nexport type { Master, Review, Id, Card, SummaryStatistics };\nexport { generateId };\n\nconst debug = require('debug')('dolphin');\n\nexport class DolphinSR {\n\n _state: State;\n _masters: {[Id]: Master};\n _reviews: Array;\n\n // TODO(April 3, 2017)\n // Currently the cachedCardsSchedule is not invalidated when the time changes (only when a review\n // or master is added), so there is a possibility for cards not switching from due to overdue\n // properly. In practice, this has not been a significant issue -- easy fix for later.\n _cachedCardsSchedule: ?CardsSchedule;\n\n // For testing, you can swap this out with a different function to change when 'now' is.\n _currentDateGetter: () => Date;\n\n\n constructor(currentDateGetter: () => Date = () => new Date()) {\n this._state = makeEmptyState();\n this._masters = {};\n this._reviews = [];\n this._currentDateGetter = currentDateGetter;\n }\n\n // gotcha: does not invalidate cache, that happens in addMasters()\n _addMaster(master: Master) {\n if (this._masters[master.id]) {\n throw new Error(`master already added: ${master.id}`);\n }\n master.combinations.forEach((combination) => {\n const id = getCardId({ master: master.id, combination });\n this._state.cardStates[id] = makeInitialCardState(master.id, combination);\n });\n this._masters[master.id] = master;\n }\n\n addMasters(...masters: Array) {\n masters.forEach(master => this._addMaster(master));\n this._cachedCardsSchedule = null;\n }\n\n // gotcha: does not apply the reviews to state or invalidate cache, that happens in addReviews()\n _addReviewToReviews(review: Review): boolean {\n this._reviews = addReview(this._reviews, review);\n const lastReview = this._reviews[this._reviews.length - 1];\n\n return (\n `${getCardId(lastReview)}#${lastReview.ts.toISOString()}` !==\n `${getCardId(review)}#${review.ts.toISOString()}`\n );\n }\n\n // Returns true if the entire state was rebuilt (inefficient, minimize)\n addReviews(...reviews: Array): boolean {\n const needsRebuild = reviews.reduce((v, review) => {\n if (this._addReviewToReviews(review)) {\n return true;\n }\n return v;\n }, false);\n\n if (needsRebuild) {\n this._rebuild();\n } else {\n reviews.forEach((review) => {\n this._state = applyReview(this._state, review);\n });\n }\n\n this._cachedCardsSchedule = null;\n\n return needsRebuild;\n }\n\n _rebuild() {\n debug('rebuilding state');\n const masters = this._masters;\n const reviews = this._reviews;\n this._masters = {};\n this._reviews = [];\n\n this.addMasters(...Object.keys(masters).map(k => masters[k]));\n this.addReviews(...reviews);\n }\n\n _getCardsSchedule(): CardsSchedule {\n if (this._cachedCardsSchedule != null) {\n return this._cachedCardsSchedule;\n }\n this._cachedCardsSchedule = computeCardsSchedule(this._state, this._currentDateGetter());\n return this._cachedCardsSchedule;\n }\n\n _nextCardId(): ?CardId {\n const s = this._getCardsSchedule();\n return pickMostDue(s, this._state);\n }\n\n _getCard(id: CardId): Card {\n const [masterId, combo] = id.split('#');\n const [front, back] = combo.split('@').map(part => part.split(',').map(x => parseInt(x, 10)));\n const master = this._masters[masterId];\n if (master == null) {\n throw new Error(`cannot getCard: no such master: ${masterId}`);\n }\n const combination = { front, back };\n\n const frontFields = front.map(i => master.fields[i]);\n const backFields = back.map(i => master.fields[i]);\n\n return {\n master: masterId,\n combination,\n\n front: frontFields,\n back: backFields,\n };\n }\n\n nextCard(): ?Card {\n const cardId = this._nextCardId();\n if (cardId == null) {\n return null;\n }\n return this._getCard(cardId);\n }\n\n summary(): SummaryStatistics {\n const s = this._getCardsSchedule();\n return {\n due: s.due.length,\n later: s.later.length,\n learning: s.learning.length,\n overdue: s.overdue.length,\n };\n }\n}\n"],"names":["generateId","uuid","v4","getCardId","o","master","combination","front","join","back","makeInitialCardState","makeEmptyState","addReview","reviews","review","length","i","ts","newReviews","slice","splice","dateDiffInDays","a","b","MS_PER_DAY","utc1","Date","UTC","getFullYear","getMonth","getDate","utc2","calculateDueDate","state","result","lastReviewed","setHours","setDate","Math","ceil","interval","computeScheduleFromCardState","now","mode","diff","Error","pickMostDue","s","prec","sched","sort","cardA","cardStates","cardB","reviewDiff","computeCardsSchedule","keys","forEach","cardId","cardState","push","debug","require","INITIAL_FACTOR","INITIAL_DAYS_WITHOUT_JUMP","INITIAL_DAYS_WITH_JUMP","applyToLearningCardState","prev","rating","match","consecutiveCorrect","EASY_BONUS","MAX_INTERVAL","MIN_FACTOR","MAX_FACTOR","Number","MAX_VALUE","constrainWithin","min","max","n","calculateDaysLate","actual","expected","daysLate","applyToReviewingCardState","factor","lapses","factorAdj","NaN","ival","isNaN","applyToLapsedCardState","applyToCardState","p","toISOString","t","applyReview","JSON","stringify","DolphinSR","currentDateGetter","_state","_masters","_reviews","_currentDateGetter","id","combinations","masters","_addMaster","_cachedCardsSchedule","lastReview","needsRebuild","reduce","v","_addReviewToReviews","_rebuild","addMasters","Object","map","k","addReviews","_getCardsSchedule","split","masterId","combo","part","parseInt","x","frontFields","fields","backFields","_nextCardId","_getCard","due","later","learning","overdue"],"mappings":"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"} -------------------------------------------------------------------------------- /dist/bundle.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"bundle.js","sources":["../lib/types.js","../lib/addReview.js","../lib/dateDiffInDays.js","../lib/computeCardsSchedule.js","../lib/applyReview.js","../lib/index.js"],"sourcesContent":["// @flow\n\nimport uuid from 'uuid';\n\n// Generally all types should be considered opaque in application code.\n\n// -- Data types\n\nexport type Id = string;\nexport function generateId(): Id {\n return uuid.v4();\n}\n\nexport type Field = string;\n\n// numbers are indexes on master.fields\nexport type Combination = {front: number[], back: number[], };\n\nexport type CardId = string;\nexport function getCardId(o: {master: Id, combination: Combination}): CardId {\n return `${o.master}#${o.combination.front.join(',')}@${o.combination.back.join(',')}`;\n}\n\n\nexport type Master = {\n id: Id,\n fields: Array,\n combinations: Array,\n}\n\nexport type Rating = 'easy' | 'good' | 'hard' | 'again';\n\nexport type Review = {\n master: Id,\n combination: Combination,\n ts: Date,\n rating: Rating,\n}\n\n// -- Computed data types\n\nexport type Card = {\n master: Id,\n combination: Combination,\n front: Field[],\n back: Field[]\n};\n\nexport type LearningCardState = {\n master: Id,\n combination: Combination,\n\n mode: 'learning',\n consecutiveCorrect: number, // 0 <= consecutiveCorrect < 2, int\n lastReviewed: ?Date\n};\nexport type ReviewingCardState = {\n master: Id,\n combination: Combination,\n\n mode: 'reviewing',\n factor: number, // float\n lapses: number, // int\n interval: number, // days since lastReviewed\n lastReviewed: Date\n};\nexport type LapsedCardState = {\n master: Id,\n combination: Combination,\n\n mode: 'lapsed',\n consecutiveCorrect: number,\n factor: number,\n lapses: number,\n interval: number,\n lastReviewed: Date,\n};\nexport type CardState = LearningCardState | ReviewingCardState | LapsedCardState;\nexport function makeInitialCardState(master: Id, combination: Combination): LearningCardState {\n return {\n master,\n combination,\n\n mode: 'learning',\n consecutiveCorrect: 0,\n lastReviewed: null,\n };\n}\n\nexport type State = {\n cardStates: {[CardId]: CardState},\n};\nexport function makeEmptyState(): State {\n return {\n cardStates: {},\n };\n}\n\nexport type Schedule = 'later' | 'due' | 'overdue' | 'learning';\nexport function cmpSchedule(a: Schedule, b: Schedule) {\n const scheduleVals = {\n later: 0,\n due: 1,\n overdue: 2,\n learning: 3,\n };\n const diff = scheduleVals[b] - scheduleVals[a];\n if (diff < 0) {\n return -1;\n } else if (diff > 0) {\n return 1;\n }\n return 0;\n}\n\nexport type CardsSchedule = {\n 'later': Array,\n 'due': Array,\n 'overdue': Array,\n 'learning': Array\n};\n\nexport type SummaryStatistics = {\n 'later': number,\n 'due': number,\n 'overdue': number,\n 'learning': number\n};\n","// @flow\nimport type { Review } from './types';\n\n// This function only works if reviews is always sorted by timestamp\nexport default function addReview(reviews: Review[], review: Review): Review[] {\n if (!reviews.length) {\n return [review];\n }\n\n let i = reviews.length - 1;\n for (; i >= 0; i -= 1) {\n if (reviews[i].ts <= review.ts) {\n break;\n }\n }\n\n const newReviews = reviews.slice(0);\n newReviews.splice(i + 1, 0, review);\n\n return newReviews;\n}\n","// @flow\n\nexport default function dateDiffInDays(a: Date, b: Date): number {\n // adapted from http://stackoverflow.com/a/15289883/251162\n const MS_PER_DAY = 1000 * 60 * 60 * 24;\n\n // Disstate the time and time-zone information.\n const utc1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());\n const utc2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());\n\n return (utc2 - utc1) / MS_PER_DAY;\n}\n","// @flow\n\nimport type { CardState, ReviewingCardState, Schedule, CardsSchedule, State, CardId } from './types';\nimport { getCardId } from './types';\nimport dateDiffInDays from './dateDiffInDays';\n\n// assumes that the day starts at 3:00am in the local timezone\nexport function calculateDueDate(state: ReviewingCardState): Date {\n const result = new Date(state.lastReviewed);\n result.setHours(3, 0, 0);\n result.setDate(result.getDate() + Math.ceil(state.interval));\n return result;\n}\n\nexport function computeScheduleFromCardState(state: CardState, now: Date): Schedule {\n if (state.mode === 'lapsed' || state.mode === 'learning') {\n return 'learning';\n } else if (state.mode === 'reviewing') {\n const diff = dateDiffInDays(calculateDueDate(state), now);\n if (diff < 0) {\n return 'later';\n } else if (diff >= 0 && diff < 1) {\n return 'due';\n } else if (diff >= 1) {\n return 'overdue';\n }\n }\n throw new Error('unreachable');\n}\n\n// Breaks ties first by last review (earlier beats later),\n// then by an alphabetical comparison of the cardId (just so it stays 100% deterministic)\n//\n// Returns null if no cards are due.\nexport function pickMostDue(s: CardsSchedule, state: State): ?CardId {\n const prec: Schedule[] = ['learning', 'overdue', 'due'];\n for (let i = 0; i < prec.length; i += 1) {\n const sched = prec[i];\n if (s[sched].length) {\n return s[sched].slice(0).sort((a, b) => {\n const cardA = state.cardStates[a];\n const cardB = state.cardStates[b];\n if (cardA == null) {\n throw new Error(`id not found in state: ${a}`);\n }\n if (cardB == null) {\n throw new Error(`id not found in state: ${b}`);\n }\n\n const reviewDiff = (\n (cardA.lastReviewed == null && cardB.lastReviewed != null) ? 1 :\n (cardB.lastReviewed == null && cardA.lastReviewed != null) ? -1 :\n (cardA.lastReviewed == null && cardB.lastReviewed == null) ? 0 :\n (cardB.lastReviewed: any) - (cardA.lastReviewed: any)\n );\n if (reviewDiff !== 0) {\n return -reviewDiff;\n }\n\n if (a === b) {\n throw new Error(`comparing duplicate id: ${a}`);\n }\n return b > a ? 1 : -1;\n })[0];\n }\n }\n return null;\n}\n\nexport default function computeCardsSchedule(state: State, now: Date): CardsSchedule {\n const s: CardsSchedule = {\n learning: [],\n later: [],\n due: [],\n overdue: [],\n };\n Object.keys(state.cardStates).forEach((cardId) => {\n const cardState = state.cardStates[cardId];\n s[computeScheduleFromCardState(cardState, now)].push(getCardId(cardState));\n });\n return s;\n}\n","// @flow\n\nimport type { State, CardState, LearningCardState, ReviewingCardState, LapsedCardState, Review, Rating } from './types';\nimport { getCardId } from './types';\nimport dateDiffInDays from './dateDiffInDays';\nimport { calculateDueDate } from './computeCardsSchedule';\n\nconst debug = require('debug')('dolphin');\n\n// -- applyToLearningCardState(...)\n\n// constants from Anki defaults\n// TODO(April 1, 2017) investigate rationales, consider changing them\nconst INITIAL_FACTOR = 2500;\nconst INITIAL_DAYS_WITHOUT_JUMP = 4;\nconst INITIAL_DAYS_WITH_JUMP = 1;\nfunction applyToLearningCardState(prev: LearningCardState, ts: Date, rating: Rating): CardState {\n if (rating === 'easy' || (rating.match(/^easy|good$/) && prev.consecutiveCorrect > 0)) {\n return {\n master: prev.master,\n combination: prev.combination,\n\n mode: 'reviewing',\n factor: INITIAL_FACTOR,\n lapses: 0,\n interval: prev.consecutiveCorrect > 0 ? INITIAL_DAYS_WITHOUT_JUMP : INITIAL_DAYS_WITH_JUMP,\n lastReviewed: ts,\n };\n } else if (rating === 'again') {\n return {\n master: prev.master,\n combination: prev.combination,\n\n mode: 'learning',\n consecutiveCorrect: 0,\n lastReviewed: ts,\n };\n } else if (rating.match(/^good|hard$/) && prev.consecutiveCorrect < 1) {\n return {\n master: prev.master,\n combination: prev.combination,\n\n mode: 'learning',\n consecutiveCorrect: prev.consecutiveCorrect + 1,\n lastReviewed: ts,\n };\n }\n throw new Error('logic error');\n}\n\n// -- applyToReviewingCardState(...)\n\nconst EASY_BONUS = 2;\nconst MAX_INTERVAL = 365;\nconst MIN_FACTOR = 0; // TODO\nconst MAX_FACTOR = Number.MAX_VALUE;\nfunction constrainWithin(min, max, n) {\n if (min > max) {\n throw new Error(`min > max: ${min}=min, ${max}=max`);\n }\n return Math.max(Math.min(n, max), min);\n}\n\nfunction calculateDaysLate(state: ReviewingCardState, actual: Date): number {\n const expected = calculateDueDate(state);\n\n const daysLate = dateDiffInDays(actual, expected);\n\n if (daysLate < 0) {\n debug('last review occured earlier than expected', {\n daysLate,\n actual,\n expected,\n });\n return 0;\n }\n\n return daysLate;\n}\nfunction applyToReviewingCardState(prev: ReviewingCardState, ts: Date, rating: Rating): CardState {\n if (rating === 'again') {\n return {\n master: prev.master,\n combination: prev.combination,\n\n mode: 'lapsed',\n consecutiveCorrect: 0,\n factor: constrainWithin(MIN_FACTOR, MAX_FACTOR, prev.factor - 200),\n lapses: prev.lapses + 1,\n interval: prev.interval,\n lastReviewed: ts,\n };\n }\n const factorAdj = (\n rating === 'hard' ? -150 :\n rating === 'good' ? 0 :\n rating === 'easy' ? 150 :\n NaN\n );\n const daysLate = calculateDaysLate(prev, ts);\n\n const ival = constrainWithin(prev.interval + 1, MAX_INTERVAL,\n rating === 'hard' ? (prev.interval + (daysLate / 4)) * 1.2 :\n rating === 'good' ? ((prev.interval + (daysLate / 2)) * prev.factor) / 1000 :\n rating === 'easy' ? (((prev.interval + daysLate) * prev.factor) / 1000) * EASY_BONUS :\n NaN,\n );\n\n if (isNaN(factorAdj) || isNaN(ival)) {\n throw new Error(`invalid rating: ${rating}`);\n }\n\n return {\n master: prev.master,\n combination: prev.combination,\n\n mode: 'reviewing',\n factor: constrainWithin(MIN_FACTOR, MAX_FACTOR, prev.factor + factorAdj),\n lapses: prev.lapses,\n interval: ival,\n lastReviewed: ts,\n };\n}\n\n// -- applyToLapsedCardState(...)\n\nfunction applyToLapsedCardState(prev: LapsedCardState, ts: Date, rating: Rating): CardState {\n if (rating === 'easy' || (rating.match(/^easy|good$/) && prev.consecutiveCorrect > 0)) {\n return {\n master: prev.master,\n combination: prev.combination,\n\n mode: 'reviewing',\n factor: prev.factor,\n lapses: prev.lapses,\n interval: prev.consecutiveCorrect > 0 ? INITIAL_DAYS_WITHOUT_JUMP : INITIAL_DAYS_WITH_JUMP,\n lastReviewed: ts,\n };\n }\n return {\n master: prev.master,\n combination: prev.combination,\n\n mode: 'lapsed',\n factor: prev.factor,\n lapses: prev.lapses,\n interval: prev.interval,\n lastReviewed: ts,\n consecutiveCorrect: rating === 'again' ? 0 : prev.consecutiveCorrect + 1,\n };\n}\n\n// -- applyReview(...)\n\n\nexport function applyToCardState(prev: CardState, ts: Date, rating: Rating): CardState {\n if (prev.lastReviewed != null && prev.lastReviewed > ts) {\n const p = prev.lastReviewed.toISOString();\n const t = ts.toISOString();\n throw new Error(`cannot apply review before current lastReviewed: ${p} > ${t}`);\n }\n\n if (prev.mode === 'learning') {\n return applyToLearningCardState((prev: any), ts, rating);\n } else if (prev.mode === 'reviewing') {\n return applyToReviewingCardState((prev: any), ts, rating);\n } else if (prev.mode === 'lapsed') {\n return applyToLapsedCardState((prev: any), ts, rating);\n }\n throw new Error(`invalid mode: ${prev.mode}`);\n}\n\nexport default function applyReview(prev: State, review: Review): State {\n const cardId = getCardId(review);\n\n const cardState = prev.cardStates[cardId];\n if (cardState == null) {\n throw new Error(`applying review to missing card: ${JSON.stringify(review)}`);\n }\n\n const state = {\n cardStates: { ...prev.cardStates },\n };\n state.cardStates[cardId] = applyToCardState(cardState, review.ts, review.rating);\n\n return state;\n}\n","// @flow\n\nimport type {\n State, Master, Review, Id, CardId, CardsSchedule, Card, SummaryStatistics,\n} from './types';\nimport { makeEmptyState, getCardId, makeInitialCardState, generateId } from './types';\nimport addReview from './addReview';\nimport applyReview from './applyReview';\nimport computeCardsSchedule, { pickMostDue } from './computeCardsSchedule';\n\nexport type { Master, Review, Id, Card, SummaryStatistics };\nexport { generateId };\n\nconst debug = require('debug')('dolphin');\n\nexport class DolphinSR {\n\n _state: State;\n _masters: {[Id]: Master};\n _reviews: Array;\n\n // TODO(April 3, 2017)\n // Currently the cachedCardsSchedule is not invalidated when the time changes (only when a review\n // or master is added), so there is a possibility for cards not switching from due to overdue\n // properly. In practice, this has not been a significant issue -- easy fix for later.\n _cachedCardsSchedule: ?CardsSchedule;\n\n // For testing, you can swap this out with a different function to change when 'now' is.\n _currentDateGetter: () => Date;\n\n\n constructor(currentDateGetter: () => Date = () => new Date()) {\n this._state = makeEmptyState();\n this._masters = {};\n this._reviews = [];\n this._currentDateGetter = currentDateGetter;\n }\n\n // gotcha: does not invalidate cache, that happens in addMasters()\n _addMaster(master: Master) {\n if (this._masters[master.id]) {\n throw new Error(`master already added: ${master.id}`);\n }\n master.combinations.forEach((combination) => {\n const id = getCardId({ master: master.id, combination });\n this._state.cardStates[id] = makeInitialCardState(master.id, combination);\n });\n this._masters[master.id] = master;\n }\n\n addMasters(...masters: Array) {\n masters.forEach(master => this._addMaster(master));\n this._cachedCardsSchedule = null;\n }\n\n // gotcha: does not apply the reviews to state or invalidate cache, that happens in addReviews()\n _addReviewToReviews(review: Review): boolean {\n this._reviews = addReview(this._reviews, review);\n const lastReview = this._reviews[this._reviews.length - 1];\n\n return (\n `${getCardId(lastReview)}#${lastReview.ts.toISOString()}` !==\n `${getCardId(review)}#${review.ts.toISOString()}`\n );\n }\n\n // Returns true if the entire state was rebuilt (inefficient, minimize)\n addReviews(...reviews: Array): boolean {\n const needsRebuild = reviews.reduce((v, review) => {\n if (this._addReviewToReviews(review)) {\n return true;\n }\n return v;\n }, false);\n\n if (needsRebuild) {\n this._rebuild();\n } else {\n reviews.forEach((review) => {\n this._state = applyReview(this._state, review);\n });\n }\n\n this._cachedCardsSchedule = null;\n\n return needsRebuild;\n }\n\n _rebuild() {\n debug('rebuilding state');\n const masters = this._masters;\n const reviews = this._reviews;\n this._masters = {};\n this._reviews = [];\n\n this.addMasters(...Object.keys(masters).map(k => masters[k]));\n this.addReviews(...reviews);\n }\n\n _getCardsSchedule(): CardsSchedule {\n if (this._cachedCardsSchedule != null) {\n return this._cachedCardsSchedule;\n }\n this._cachedCardsSchedule = computeCardsSchedule(this._state, this._currentDateGetter());\n return this._cachedCardsSchedule;\n }\n\n _nextCardId(): ?CardId {\n const s = this._getCardsSchedule();\n return pickMostDue(s, this._state);\n }\n\n _getCard(id: CardId): Card {\n const [masterId, combo] = id.split('#');\n const [front, back] = combo.split('@').map(part => part.split(',').map(x => parseInt(x, 10)));\n const master = this._masters[masterId];\n if (master == null) {\n throw new Error(`cannot getCard: no such master: ${masterId}`);\n }\n const combination = { front, back };\n\n const frontFields = front.map(i => master.fields[i]);\n const backFields = back.map(i => master.fields[i]);\n\n return {\n master: masterId,\n combination,\n\n front: frontFields,\n back: backFields,\n };\n }\n\n nextCard(): ?Card {\n const cardId = this._nextCardId();\n if (cardId == null) {\n return null;\n }\n return this._getCard(cardId);\n }\n\n summary(): SummaryStatistics {\n const s = this._getCardsSchedule();\n return {\n due: s.due.length,\n later: s.later.length,\n learning: s.learning.length,\n overdue: s.overdue.length,\n };\n }\n}\n"],"names":["generateId","uuid","v4","getCardId","o","master","combination","front","join","back","makeInitialCardState","makeEmptyState","addReview","reviews","review","length","i","ts","newReviews","slice","splice","dateDiffInDays","a","b","MS_PER_DAY","utc1","Date","UTC","getFullYear","getMonth","getDate","utc2","calculateDueDate","state","result","lastReviewed","setHours","setDate","Math","ceil","interval","computeScheduleFromCardState","now","mode","diff","Error","pickMostDue","s","prec","sched","sort","cardA","cardStates","cardB","reviewDiff","computeCardsSchedule","keys","forEach","cardId","cardState","push","debug","require","INITIAL_FACTOR","INITIAL_DAYS_WITHOUT_JUMP","INITIAL_DAYS_WITH_JUMP","applyToLearningCardState","prev","rating","match","consecutiveCorrect","EASY_BONUS","MAX_INTERVAL","MIN_FACTOR","MAX_FACTOR","Number","MAX_VALUE","constrainWithin","min","max","n","calculateDaysLate","actual","expected","daysLate","applyToReviewingCardState","factor","lapses","factorAdj","NaN","ival","isNaN","applyToLapsedCardState","applyToCardState","p","toISOString","t","applyReview","JSON","stringify","DolphinSR","currentDateGetter","_state","_masters","_reviews","_currentDateGetter","id","combinations","masters","_addMaster","_cachedCardsSchedule","lastReview","needsRebuild","reduce","v","_addReviewToReviews","_rebuild","addMasters","Object","map","k","addReviews","_getCardsSchedule","split","masterId","combo","part","parseInt","x","frontFields","fields","backFields","_nextCardId","_getCard","due","later","learning","overdue"],"mappings":"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"} --------------------------------------------------------------------------------