review-state.ts 2.8 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
  1. import type { ReviewLesson } from './lesson-types';
  2. export type ReviewRating = 'revisit' | 'ready';
  3. export type ReviewState = {
  4. answers: Record<string, ReviewRating>;
  5. notes: Record<string, string>;
  6. evidence: Record<string, boolean>;
  7. contentChanged: boolean;
  8. };
  9. export function emptyReviewState(): ReviewState {
  10. return { answers: {}, notes: {}, evidence: {}, contentChanged: false };
  11. }
  12. // Invalidate old self-ratings whenever their questions or mastery criteria change.
  13. export function reviewSignature(lesson: ReviewLesson) {
  14. const assessable = (item: ReviewLesson['questions'][number]) => ({
  15. id: item.id,
  16. prompt: item.prompt,
  17. checkpoints: item.checkpoints,
  18. misconception: item.misconception,
  19. });
  20. const text = JSON.stringify({
  21. sourceModule: lesson.sourceModule,
  22. questions: lesson.questions.map(assessable),
  23. challenges: lesson.challenges.map(assessable),
  24. mastery: lesson.mastery,
  25. });
  26. let hash = 2166136261;
  27. for (let i = 0; i < text.length; i++) {
  28. hash ^= text.charCodeAt(i);
  29. hash = Math.imul(hash, 16777619);
  30. }
  31. return (hash >>> 0).toString(16);
  32. }
  33. export function restoreReviewState(
  34. raw: string | null,
  35. lesson: ReviewLesson,
  36. ): ReviewState {
  37. const state = emptyReviewState();
  38. if (!raw) return state;
  39. try {
  40. const saved = JSON.parse(raw);
  41. if (saved?.sourceModule !== lesson.sourceModule || !saved.state)
  42. return state;
  43. const sameContent = saved.signature === reviewSignature(lesson);
  44. state.contentChanged = !sameContent || saved.state.contentChanged === true;
  45. for (const item of [...lesson.questions, ...lesson.challenges]) {
  46. const value = saved.state?.answers?.[item.id];
  47. if (sameContent && (value === 'revisit' || value === 'ready'))
  48. state.answers[item.id] = value;
  49. const note = saved.state?.notes?.[item.id];
  50. if (typeof note === 'string') state.notes[item.id] = note.slice(0, 4000);
  51. }
  52. for (const item of lesson.mastery) {
  53. if (sameContent && saved.state?.evidence?.[item.id] === true)
  54. state.evidence[item.id] = true;
  55. }
  56. } catch {
  57. // Corrupt or unavailable browser storage must not prevent studying.
  58. }
  59. return state;
  60. }
  61. export function reviewProgress(lesson: ReviewLesson, state: ReviewState) {
  62. const items = [...lesson.questions, ...lesson.challenges];
  63. const ready = items.filter(
  64. (item) => state.answers[item.id] === 'ready',
  65. ).length;
  66. const evidence = lesson.mastery.filter(
  67. (item) => state.evidence[item.id] === true,
  68. ).length;
  69. return {
  70. ready,
  71. total: items.length,
  72. evidence,
  73. evidenceTotal: lesson.mastery.length,
  74. mastered:
  75. items.length > 0 &&
  76. lesson.mastery.length > 0 &&
  77. ready === items.length &&
  78. evidence === lesson.mastery.length,
  79. revisit: items.filter((item) => state.answers[item.id] !== 'ready'),
  80. };
  81. }