@lernapps/app-templates 0.0.1 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (232) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +195 -2
  3. package/dist/appname-Bh23cUuz.mjs +12 -0
  4. package/dist/assessment-Dy359O1O.mjs +1696 -0
  5. package/dist/block-BK_ohsH1.mjs +82 -0
  6. package/dist/blocks-CxtCPAa5.mjs +149 -0
  7. package/dist/blocks.mjs +4 -0
  8. package/dist/check-CteIFN96.mjs +254 -0
  9. package/dist/content-DO6oEJxj.mjs +140 -0
  10. package/dist/contract-PONpxJtd.mjs +52 -0
  11. package/dist/e2e-CU0n_unV.mjs +89 -0
  12. package/dist/engine-CArxqizW.mjs +136 -0
  13. package/dist/explainer-e2e.mjs +1311 -0
  14. package/dist/explainer-islands.mjs +197 -0
  15. package/dist/explainer-plugin.mjs +2132 -0
  16. package/dist/explainer.mjs +1246 -0
  17. package/dist/html-mffkHGHh.mjs +89 -0
  18. package/dist/image-CspJVfyQ.mjs +21 -0
  19. package/dist/interactive-e2e.mjs +140 -0
  20. package/dist/interactive-plugin.mjs +242 -0
  21. package/dist/interactive.mjs +75 -0
  22. package/dist/load-RosvaPZf.mjs +16976 -0
  23. package/dist/needs-BzEahJ6X.mjs +97 -0
  24. package/dist/pages-9RaaomoO.mjs +81 -0
  25. package/dist/questions-e2e.mjs +1 -0
  26. package/dist/questions-engine.mjs +3 -0
  27. package/dist/questions.mjs +6 -0
  28. package/dist/quiz-DnW-xtCh.mjs +70 -0
  29. package/dist/quiz-e2e.mjs +174 -0
  30. package/dist/quiz-plugin.mjs +266 -0
  31. package/dist/quiz.mjs +246 -0
  32. package/dist/render-D5kwkNt1.mjs +507 -0
  33. package/dist/render-DqHOJyHK.mjs +146 -0
  34. package/dist/render-GS1Htlu8.mjs +545 -0
  35. package/dist/result-D_mmCVwN.mjs +180 -0
  36. package/dist/tasks-DADUYy52.mjs +74 -0
  37. package/dist/validate-BDwL7Su3.mjs +1076 -0
  38. package/dist/validate-Bxy9NfB8.mjs +146 -0
  39. package/dist/vue-DO1m6q1R.mjs +40 -0
  40. package/dist/vue-code-BkJjW_I_.mjs +320 -0
  41. package/docs/arc42/01-introduction-and-goals.arc42.md +82 -0
  42. package/docs/arc42/02-constraints.arc42.md +228 -0
  43. package/docs/arc42/03-system-context.arc42.md +118 -0
  44. package/docs/arc42/04-solution-strategy.arc42.md +47 -0
  45. package/docs/arc42/05-building-blocks.arc42.md +307 -0
  46. package/docs/arc42/06-runtime-view.arc42.md +98 -0
  47. package/docs/arc42/07-deployment-view.arc42.md +160 -0
  48. package/docs/arc42/08-concepts.arc42.md +123 -0
  49. package/docs/arc42/09-decisions.arc42.md +246 -0
  50. package/docs/arc42/10-quality-requirements.arc42.md +155 -0
  51. package/docs/arc42/11-risks.arc42.md +113 -0
  52. package/docs/arc42/12-glossary.arc42.md +162 -0
  53. package/docs/arc42/architecture-evidence.md +395 -0
  54. package/explainer/.arc42ignore +3 -0
  55. package/explainer/README.md +54 -0
  56. package/explainer/_gitignore +5 -0
  57. package/explainer/docs/arc42/03-explainer-system-context.arc42.md +35 -0
  58. package/explainer/docs/arc42/05-explainer-building-blocks.arc42.md +659 -0
  59. package/explainer/docs/arc42/06-explainer-runtime-view.arc42.md +105 -0
  60. package/explainer/docs/arc42/07-explainer-deployment-view.arc42.md +34 -0
  61. package/explainer/docs/arc42/08-explainer-concepts.arc42.md +17 -0
  62. package/explainer/docs/arc42/09-explainer-decisions.arc42.md +443 -0
  63. package/explainer/docs/arc42/10-explainer-quality-requirements.arc42.md +54 -0
  64. package/explainer/docs/arc42/11-explainer-risks.arc42.md +17 -0
  65. package/explainer/docs/arc42/12-explainer-glossary.arc42.md +185 -0
  66. package/explainer/e2e/explainer.e2e.ts +3 -0
  67. package/explainer/index.html +11 -0
  68. package/explainer/package.json +18 -0
  69. package/explainer/playwright.config.ts +1 -0
  70. package/explainer/src/app.yaml +20 -0
  71. package/explainer/src/components/ProzentFeld.test.ts +30 -0
  72. package/explainer/src/components/ProzentFeld.ts +121 -0
  73. package/explainer/src/components/ProzentFeld.vue +84 -0
  74. package/explainer/src/images/hunderterfeld-klammern.svg +105 -0
  75. package/explainer/src/main.ts +4 -0
  76. package/explainer/src/prozent/chapter.yaml +18 -0
  77. package/explainer/src/prozent/grundwert.yaml +115 -0
  78. package/explainer/src/prozent/prozentsatz.yaml +99 -0
  79. package/explainer/src/prozent/prozentwert.yaml +119 -0
  80. package/explainer/tsconfig.json +1 -0
  81. package/explainer/vite.config.ts +4 -0
  82. package/interactive/.arc42ignore +5 -0
  83. package/interactive/README.md +28 -0
  84. package/interactive/_gitignore +4 -0
  85. package/interactive/docs/arc42/05-interactive-building-blocks.arc42.md +518 -0
  86. package/interactive/docs/arc42/06-interactive-runtime-view.arc42.md +58 -0
  87. package/interactive/docs/arc42/07-interactive-deployment-view.arc42.md +35 -0
  88. package/interactive/docs/arc42/08-interactive-concepts.arc42.md +52 -0
  89. package/interactive/docs/arc42/09-interactive-decisions.arc42.md +130 -0
  90. package/interactive/docs/arc42/10-interactive-quality-requirements.arc42.md +45 -0
  91. package/interactive/docs/arc42/11-interactive-risks.arc42.md +20 -0
  92. package/interactive/docs/arc42/12-interactive-glossary.arc42.md +80 -0
  93. package/interactive/e2e/interactive.e2e.ts +3 -0
  94. package/interactive/index.html +11 -0
  95. package/interactive/package.json +18 -0
  96. package/interactive/playwright.config.ts +1 -0
  97. package/interactive/src/app.yaml +28 -0
  98. package/interactive/src/core/index.ts +101 -0
  99. package/interactive/src/data/lessons.json +4 -0
  100. package/interactive/src/env.d.ts +6 -0
  101. package/interactive/src/main.ts +6 -0
  102. package/interactive/src/messages/de.json +12 -0
  103. package/interactive/src/playthroughs/grundreihe.yaml +33 -0
  104. package/interactive/src/playthroughs/obere-reihe.yaml +14 -0
  105. package/interactive/src/ui/App.vue +85 -0
  106. package/interactive/tsconfig.json +1 -0
  107. package/interactive/vite.config.ts +4 -0
  108. package/package.json +143 -4
  109. package/quiz/README.md +31 -0
  110. package/quiz/_gitignore +5 -0
  111. package/quiz/docs/arc42/05-quiz-building-blocks.arc42.md +283 -0
  112. package/quiz/docs/arc42/06-quiz-runtime-view.arc42.md +21 -0
  113. package/quiz/docs/arc42/07-quiz-deployment-view.arc42.md +33 -0
  114. package/quiz/docs/arc42/09-quiz-decisions.arc42.md +36 -0
  115. package/quiz/docs/arc42/10-quiz-quality-requirements.arc42.md +53 -0
  116. package/quiz/docs/arc42/11-quiz-risks.arc42.md +19 -0
  117. package/quiz/docs/arc42/12-quiz-glossary.arc42.md +27 -0
  118. package/quiz/e2e/quiz.e2e.ts +3 -0
  119. package/quiz/index.html +11 -0
  120. package/quiz/package.json +18 -0
  121. package/quiz/playwright.config.ts +1 -0
  122. package/quiz/src/images/berge-ordnen.jpg +0 -0
  123. package/quiz/src/images/everest-hoehe.jpg +0 -0
  124. package/quiz/src/images/galapagos.jpg +0 -0
  125. package/quiz/src/images/grand-canyon.jpg +0 -0
  126. package/quiz/src/images/totes-meer.jpg +0 -0
  127. package/quiz/src/images/uluru.jpg +0 -0
  128. package/quiz/src/images/wasserfaelle-laender.jpg +0 -0
  129. package/quiz/src/main.ts +4 -0
  130. package/quiz/src/quiz.json +312 -0
  131. package/quiz/tsconfig.json +1 -0
  132. package/quiz/vite.config.ts +4 -0
  133. package/runtime/explainer/assessment-client.ts +478 -0
  134. package/runtime/explainer/assessment-page.ts +448 -0
  135. package/runtime/explainer/assessment.ts +393 -0
  136. package/runtime/explainer/check-page.ts +520 -0
  137. package/runtime/explainer/components/exercise.ts +409 -0
  138. package/runtime/explainer/components/props.ts +230 -0
  139. package/runtime/explainer/components/read.ts +428 -0
  140. package/runtime/explainer/content.ts +277 -0
  141. package/runtime/explainer/e2e.ts +1488 -0
  142. package/runtime/explainer/exercises.ts +326 -0
  143. package/runtime/explainer/explainer.css +1283 -0
  144. package/runtime/explainer/explainer.ts +838 -0
  145. package/runtime/explainer/explainer.v1.schema.json +1165 -0
  146. package/runtime/explainer/expression.ts +407 -0
  147. package/runtime/explainer/fraction.ts +107 -0
  148. package/runtime/explainer/generated/comparator.ts +31 -0
  149. package/runtime/explainer/generated/question.ts +165 -0
  150. package/runtime/explainer/generated/soundness.ts +280 -0
  151. package/runtime/explainer/generated/texts.ts +257 -0
  152. package/runtime/explainer/generated/types.ts +103 -0
  153. package/runtime/explainer/generated/values.ts +136 -0
  154. package/runtime/explainer/islands.ts +281 -0
  155. package/runtime/explainer/load.ts +457 -0
  156. package/runtime/explainer/messages/de.json +200 -0
  157. package/runtime/explainer/messages/en.json +200 -0
  158. package/runtime/explainer/messages.ts +13 -0
  159. package/runtime/explainer/needs.ts +122 -0
  160. package/runtime/explainer/open-task.ts +235 -0
  161. package/runtime/explainer/outline.ts +306 -0
  162. package/runtime/explainer/plugin.ts +488 -0
  163. package/runtime/explainer/position.ts +62 -0
  164. package/runtime/explainer/prompt.ts +75 -0
  165. package/runtime/explainer/render.ts +686 -0
  166. package/runtime/explainer/result.ts +238 -0
  167. package/runtime/explainer/routes.ts +119 -0
  168. package/runtime/explainer/schema.ts +140 -0
  169. package/runtime/explainer/scope.ts +87 -0
  170. package/runtime/explainer/stuck.ts +57 -0
  171. package/runtime/explainer/tasks.ts +109 -0
  172. package/runtime/explainer/tutor.ts +469 -0
  173. package/runtime/explainer/tutorpage.ts +669 -0
  174. package/runtime/explainer/video.ts +123 -0
  175. package/runtime/interactive/contract.ts +121 -0
  176. package/runtime/interactive/e2e.ts +190 -0
  177. package/runtime/interactive/interactive.css +82 -0
  178. package/runtime/interactive/interactive.ts +115 -0
  179. package/runtime/interactive/interactive.v1.schema.json +146 -0
  180. package/runtime/interactive/load.ts +188 -0
  181. package/runtime/interactive/messages/de.json +14 -0
  182. package/runtime/interactive/messages/en.json +14 -0
  183. package/runtime/interactive/messages.ts +11 -0
  184. package/runtime/interactive/playthrough.v1.schema.json +143 -0
  185. package/runtime/interactive/plugin.ts +266 -0
  186. package/runtime/interactive/render.ts +126 -0
  187. package/runtime/quiz/e2e.ts +233 -0
  188. package/runtime/quiz/engine.ts +242 -0
  189. package/runtime/quiz/messages/de.json +23 -0
  190. package/runtime/quiz/messages/en.json +23 -0
  191. package/runtime/quiz/plugin.ts +150 -0
  192. package/runtime/quiz/quiz.css +154 -0
  193. package/runtime/quiz/quiz.ts +56 -0
  194. package/runtime/quiz/quiz.v1.schema.json +72 -0
  195. package/runtime/quiz/render.ts +173 -0
  196. package/runtime/quiz/validate.ts +108 -0
  197. package/runtime/shared/appname.ts +16 -0
  198. package/runtime/shared/blocks/block.ts +162 -0
  199. package/runtime/shared/blocks/blocks.css +190 -0
  200. package/runtime/shared/blocks/blocks.v1.schema.json +432 -0
  201. package/runtime/shared/blocks/code.ts +26 -0
  202. package/runtime/shared/blocks/figures.ts +49 -0
  203. package/runtime/shared/blocks/image.ts +25 -0
  204. package/runtime/shared/blocks/index.ts +13 -0
  205. package/runtime/shared/blocks/messages/de.json +4 -0
  206. package/runtime/shared/blocks/messages/en.json +4 -0
  207. package/runtime/shared/blocks/render.ts +134 -0
  208. package/runtime/shared/blocks/validate.ts +165 -0
  209. package/runtime/shared/content.ts +197 -0
  210. package/runtime/shared/examples.ts +20 -0
  211. package/runtime/shared/html.ts +10 -0
  212. package/runtime/shared/languages.ts +94 -0
  213. package/runtime/shared/page.css +101 -0
  214. package/runtime/shared/pages.ts +114 -0
  215. package/runtime/shared/questions/e2e.ts +115 -0
  216. package/runtime/shared/questions/engine.ts +209 -0
  217. package/runtime/shared/questions/index.ts +39 -0
  218. package/runtime/shared/questions/messages/de.json +26 -0
  219. package/runtime/shared/questions/messages/en.json +26 -0
  220. package/runtime/shared/questions/question.ts +433 -0
  221. package/runtime/shared/questions/question.v1.schema.json +346 -0
  222. package/runtime/shared/questions/questions.css +192 -0
  223. package/runtime/shared/questions/render.ts +255 -0
  224. package/runtime/shared/questions/validate.ts +518 -0
  225. package/runtime/shared/schema.ts +44 -0
  226. package/runtime/shared/text/check.ts +68 -0
  227. package/runtime/shared/text/formula.ts +69 -0
  228. package/runtime/shared/text/markdown.ts +185 -0
  229. package/runtime/shared/text/speech.ts +39 -0
  230. package/runtime/shared/text/text.v1.schema.json +34 -0
  231. package/runtime/shared/vue-code.ts +395 -0
  232. package/runtime/shared/vue.ts +38 -0
@@ -0,0 +1,393 @@
1
+ // The self-assessment of an explainer (selfAssessment in a chapter.yaml): before learning, the learner may answer
2
+ // exercises of the content pages, a few on each of its own pages, without the page around them and without a check of
3
+ // any single one. A chapter declares its own; the app's is every chapter's in the order of the chapters, offered on the
4
+ // start page, and a chapter's is offered on its page (in an app with one chapter, the app's is the chapter's). Only at the end are they checked, all together, and the result says for each content page
5
+ // in its scope (the whole app, or one chapter) whether the learner probably knows it already, should look at it again
6
+ // or should read it; a page with no exercise in the self-assessment is not asked. It runs with JavaScript only
7
+ // (assessment-client.ts); without it the offer stays hidden. The last result stays on the device, one per
8
+ // self-assessment (@lernapps/tooling/storage), and a new start replaces it. The app sends nothing: the learner may take
9
+ // the result to a KI tutor with a click, as the address of the self-assessment's tutor page (assessment-page.ts) with
10
+ // the result in its query, ?a1=17-falsch-F1&a2=richtig, and the lines of the prompt. Pure and without Node: the client
11
+ // module calls it too.
12
+ import {
13
+ allBlocks,
14
+ inLanguage,
15
+ isExercise,
16
+ type BlockOf,
17
+ type Chapter,
18
+ type Exercise,
19
+ type Page,
20
+ type Site,
21
+ } from "./content.ts";
22
+ import { message } from "./messages.ts";
23
+
24
+ /** The folder of a self-assessment: einschaetzung/ for the app, <chapter>/einschaetzung/ for a chapter. */
25
+ export const ASSESSMENT = "einschaetzung";
26
+
27
+ /** The address of a self-assessment within its language tree: of the app, or of the chapter `chapter`. */
28
+ export const assessmentPathOf = (chapter?: string): string =>
29
+ chapter === undefined ? `${ASSESSMENT}/` : `${chapter}/${ASSESSMENT}/`;
30
+
31
+ /** The key its last result is kept under on the device. */
32
+ export const assessmentKeyOf = (chapter?: string): string =>
33
+ chapter === undefined ? "self-assessment" : `self-assessment:${chapter}`;
34
+
35
+ /** An exercise of a self-assessment, where it comes from and on which of its pages it is. */
36
+ export interface AssessmentItem {
37
+ /** Its name in the result and on the tutor page: a1, a2, … in order. */
38
+ key: string;
39
+ /** /<chapter>/<page>/#<exercise>, as the content names it. */
40
+ ref: string;
41
+ page: Page;
42
+ /** Its id on the page. */
43
+ exercise: string;
44
+ block: AssessmentBlock;
45
+ /** The index of its page in the self-assessment, from 0. */
46
+ step: number;
47
+ }
48
+
49
+ /** A self-assessment of the app or of one chapter, with its exercises resolved. */
50
+ export interface Assessment {
51
+ /** The chapter it belongs to; none for the app's. */
52
+ chapter?: Chapter;
53
+ /** Its address within the language tree. */
54
+ local: string;
55
+ /** The key its last result is kept under on the device. */
56
+ key: string;
57
+ /** The content pages it says something about, in order: the app's, or the chapter's. */
58
+ pages: readonly Page[];
59
+ items: readonly AssessmentItem[];
60
+ /** How many pages it has. */
61
+ steps: number;
62
+ }
63
+
64
+ /** The parts of an exercise's reference /<chapter>/<page>/#<exercise>, if it has that shape. */
65
+ export function parseRef(ref: string): { chapter: string; page: string; exercise: string } | undefined {
66
+ const match = /^\/([a-z0-9-]+)\/([a-z0-9-]+)\/#([a-z0-9-]+)$/.exec(ref);
67
+ return match === null ? undefined : { chapter: match[1] ?? "", page: match[2] ?? "", exercise: match[3] ?? "" };
68
+ }
69
+
70
+ /** An exercise a self-assessment can ask: a question of the question types, generated, or an exercise component. */
71
+ export type AssessmentBlock = Exercise | BlockOf<"vue-component">;
72
+
73
+ /**
74
+ * The exercise of `page` with the id `id`: a question of the question types, generated, or an exercise component
75
+ * (of the component uses `components`, by its anchor); none for anything else.
76
+ */
77
+ export function exerciseOf(components: Site["components"], page: Page, id: string): AssessmentBlock | undefined {
78
+ const blocks = [...allBlocks(page.blocks)];
79
+ for (const block of blocks) {
80
+ if (isExercise(block) && block.id === id) return block;
81
+ }
82
+ const use = components.find(
83
+ (candidate) =>
84
+ candidate.component.exercise && candidate.id === id && blocks.some((block) => block === candidate.block),
85
+ );
86
+ return use?.block;
87
+ }
88
+
89
+ /** Whether each listing of `block` draws a task of its own: a generated exercise, or an exercise component. */
90
+ export const drawsTasks = (block: AssessmentBlock): boolean =>
91
+ block.type === "generated" || block.type === "vue-component";
92
+
93
+ /** The self-assessments of a valid site, the app's first, then each chapter's in order. */
94
+ export function assessments(site: Site): Assessment[] {
95
+ const of = (
96
+ source: { pages: readonly { exercises: readonly string[] }[] },
97
+ pages: readonly Page[],
98
+ chapter?: Chapter,
99
+ ): Assessment => {
100
+ const items: AssessmentItem[] = [];
101
+ source.pages.forEach((step, index) => {
102
+ for (const ref of step.exercises) {
103
+ const parts = parseRef(ref);
104
+ const page = pages.find((candidate) => candidate.chapter === parts?.chapter && candidate.id === parts.page);
105
+ const block =
106
+ page === undefined || parts === undefined ? undefined : exerciseOf(site.components, page, parts.exercise);
107
+ // the build checked every reference
108
+ if (page === undefined || parts === undefined || block === undefined) continue;
109
+ items.push({ key: `a${items.length + 1}`, ref, page, exercise: parts.exercise, block, step: index });
110
+ }
111
+ });
112
+ return {
113
+ ...(chapter === undefined ? {} : { chapter }),
114
+ local: assessmentPathOf(chapter?.id),
115
+ key: assessmentKeyOf(chapter?.id),
116
+ pages,
117
+ items,
118
+ steps: source.pages.length,
119
+ };
120
+ };
121
+ // the app's: the pages of every chapter's, in the order of the chapters
122
+ const steps = site.chapters.flatMap((chapter) => chapter.selfAssessment?.pages ?? []);
123
+ if (steps.length === 0) return [];
124
+ const all = site.chapters.flatMap((chapter) => chapter.pages);
125
+ // a chapter's of its own only where the app shows its chapters: with one chapter, the app's is the chapter's
126
+ const own = site.chapters.length === 1 ? [] : site.chapters;
127
+ return [
128
+ of({ pages: steps }, all),
129
+ ...own.flatMap((chapter) =>
130
+ chapter.selfAssessment === undefined ? [] : [of(chapter.selfAssessment, chapter.pages, chapter)],
131
+ ),
132
+ ];
133
+ }
134
+
135
+ /** The self-assessment of the app (no chapter) or of the chapter `chapter`, if it has one. */
136
+ export const assessmentOf = (site: Site, chapter?: string): Assessment | undefined =>
137
+ assessments(site).find((assessment) => assessment.chapter?.id === chapter);
138
+
139
+ /** The fewest and the most exercises a page of a self-assessment should show. */
140
+ export const STEP_SIZE = { min: 2, max: 5 } as const;
141
+
142
+ /**
143
+ * The note of the build on the pages of the chapters' self-assessments with fewer or more exercises than STEP_SIZE,
144
+ * each by its file and place, or none: a hint, no problem.
145
+ */
146
+ export function stepHint(site: Site): string | undefined {
147
+ const off = site.chapters.flatMap((chapter) =>
148
+ (chapter.selfAssessment?.pages ?? []).flatMap((step, index) => {
149
+ const count = step.exercises.length;
150
+ return count < STEP_SIZE.min || count > STEP_SIZE.max
151
+ ? [`src/${chapter.id}/chapter.yaml: selfAssessment.pages[${index}] (${count})`]
152
+ : [];
153
+ }),
154
+ );
155
+ if (off.length === 0) return undefined;
156
+ return `lernapps explainer: ${off.length} page${off.length === 1 ? "" : "s"} of a self-assessment show${off.length === 1 ? "s" : ""} fewer than ${STEP_SIZE.min} or more than ${STEP_SIZE.max} exercises: ${off.join(", ")}`;
157
+ }
158
+
159
+ // What the browser keeps and shows of one run.
160
+
161
+ /**
162
+ * What became of one exercise of a run: its task number, right, partly right, wrong or open (not answered), the input,
163
+ * the mistake and the points it missed.
164
+ */
165
+ export interface ItemResult {
166
+ key: string;
167
+ /** The exercise it was, /<chapter>/<page>/#<exercise>: a kept result whose exercise moved does not count. */
168
+ ref: string;
169
+ /** The task number of a generated exercise. */
170
+ number?: number;
171
+ state: "richtig" | "teilweise" | "falsch" | "offen";
172
+ /** The input as text, for a wrong or partly right answer. */
173
+ input?: string;
174
+ /** The typical mistake it matches, from 1 (F1), for a wrong answer of a generated exercise. */
175
+ mistake?: number;
176
+ /** The points of a good answer it missed, each from 1 (P1), for a partly right or wrong answer. */
177
+ missed?: number[];
178
+ }
179
+
180
+ /** The last run of a self-assessment, as it stays on the device. */
181
+ export interface StoredResult {
182
+ version: 1;
183
+ items: ItemResult[];
184
+ }
185
+
186
+ const STATES: readonly string[] = ["richtig", "teilweise", "falsch", "offen"];
187
+
188
+ /** Whether `value` is a result as the device keeps it. */
189
+ export function isStoredResult(value: unknown): value is StoredResult {
190
+ if (typeof value !== "object" || value === null) return false;
191
+ const { version, items } = value as Partial<StoredResult>;
192
+ return (
193
+ version === 1 &&
194
+ Array.isArray(items) &&
195
+ items.every(
196
+ (item: Partial<ItemResult>) =>
197
+ typeof item === "object" &&
198
+ typeof item.key === "string" &&
199
+ typeof item.ref === "string" &&
200
+ STATES.includes(String(item.state)) &&
201
+ (item.number === undefined || Number.isInteger(item.number)) &&
202
+ (item.input === undefined || typeof item.input === "string") &&
203
+ (item.mistake === undefined || Number.isInteger(item.mistake)) &&
204
+ (item.missed === undefined ||
205
+ (Array.isArray(item.missed) && item.missed.every((point) => Number.isInteger(point)))),
206
+ )
207
+ );
208
+ }
209
+
210
+ /** How a content page did: known, partly, to read; not asked when no exercise of it was in the self-assessment. */
211
+ export type Band = "known" | "partly" | "read" | "unasked";
212
+
213
+ /** A content page in the result: the exercises of it that were asked, and how many of them were right or partly right. */
214
+ export interface PageResult {
215
+ /** <chapter>/<page>/, within the language tree. */
216
+ page: string;
217
+ right: number;
218
+ partly: number;
219
+ total: number;
220
+ band: Band;
221
+ }
222
+
223
+ /** The words of each band, as the result shows them. */
224
+ export const BAND_MESSAGES = {
225
+ known: "assessmentKnown",
226
+ partly: "assessmentPartly",
227
+ read: "assessmentRead",
228
+ unasked: "assessmentUnasked",
229
+ } as const;
230
+
231
+ /**
232
+ * The band of `right` and `partly` of `total`: all right of two or more, at least half, less; not asked for none. A
233
+ * partly right task counts half. One task alone is too little to say a page is known: right, it is "partly".
234
+ */
235
+ export function bandOf(right: number, total: number, partly = 0): Band {
236
+ if (total === 0) return "unasked";
237
+ if (right === total && total >= 2) return "known";
238
+ return right * 2 + partly >= total ? "partly" : "read";
239
+ }
240
+
241
+ /**
242
+ * The result of each content page in `pages` (each <chapter>/<page>/, in order), from the results of a run and the page
243
+ * each exercise is from (`pageOf`, by key); an exercise the content no longer has does not count.
244
+ */
245
+ export function pageResults(
246
+ pages: readonly string[],
247
+ pageOf: Readonly<Record<string, string>>,
248
+ results: readonly ItemResult[],
249
+ ): PageResult[] {
250
+ return pages.map((page) => {
251
+ const asked = results.filter((result) => pageOf[result.key] === page);
252
+ const right = asked.filter((result) => result.state === "richtig").length;
253
+ const partly = asked.filter((result) => result.state === "teilweise").length;
254
+ return { page, right, partly, total: asked.length, band: bandOf(right, asked.length, partly) };
255
+ });
256
+ }
257
+
258
+ /** The value of one exercise in the query of the tutor page: 17-falsch-F1, richtig, 3-offen, teilweise-P2P3. */
259
+ export function resultValue(result: ItemResult): string {
260
+ return [
261
+ ...(result.number === undefined ? [] : [String(result.number)]),
262
+ result.state,
263
+ ...(result.state === "falsch" && result.mistake !== undefined ? [`F${result.mistake}`] : []),
264
+ ...(result.missed !== undefined && result.missed.length > 0
265
+ ? [result.missed.map((point) => `P${point}`).join("")]
266
+ : []),
267
+ ].join("-");
268
+ }
269
+
270
+ /** The query of the tutor page with a run's results: ?a1=17-falsch-F1&a2=richtig. */
271
+ export function resultQuery(results: readonly ItemResult[]): string {
272
+ const query = new URLSearchParams();
273
+ for (const result of results) query.set(result.key, resultValue(result));
274
+ const text = query.toString();
275
+ return text === "" ? "" : `?${text}`;
276
+ }
277
+
278
+ /** What the prompt of a run says about one exercise: its key, its page's title, its id and its task number. */
279
+ export interface PromptItem {
280
+ key: string;
281
+ page: string;
282
+ exercise: string;
283
+ }
284
+
285
+ const oneLine = (text: string) => text.replace(/\s+/g, " ").trim();
286
+
287
+ /** The words of each state in a line of the prompt. */
288
+ const STATE_MESSAGES = {
289
+ richtig: "assessmentRight",
290
+ teilweise: "assessmentPartlyRight",
291
+ falsch: "assessmentWrong",
292
+ offen: "assessmentOpen",
293
+ } as const;
294
+
295
+ /**
296
+ * The prompt that takes a run to a tutor, in `lang`, in the learner's voice: the tutor page with the results in its
297
+ * address first, then the result of each content page as the learner saw it (its title in `pages`), so that the tutor
298
+ * works nothing out, then one line per exercise (its page, the exercise, its task number, how it went, the input of a
299
+ * wrong one). The solution is never in it. `back`: for a chat that runs already (via=tutor), the lines with the tutor
300
+ * page last, which "Zurück zum Tutor" copies.
301
+ */
302
+ export function assessmentPrompt(
303
+ tutor: string,
304
+ items: readonly PromptItem[],
305
+ results: readonly ItemResult[],
306
+ lang: string,
307
+ pages: readonly (PageResult & { title: string })[] = [],
308
+ opening: "ask" | "back" = "ask",
309
+ links: readonly string[] = [],
310
+ ): string {
311
+ const bands = pages.map((page) =>
312
+ page.band === "unasked"
313
+ ? `- ${message(lang, "assessmentPageUnasked", { page: oneLine(page.title) })}`
314
+ : `- ${message(lang, page.partly > 0 ? "assessmentPageLinePartly" : "assessmentPageLine", {
315
+ page: oneLine(page.title),
316
+ band: message(lang, BAND_MESSAGES[page.band]),
317
+ right: String(page.right),
318
+ partly: String(page.partly),
319
+ total: String(page.total),
320
+ })}`,
321
+ );
322
+ const lines = results.map((result) => {
323
+ const item = items.find((candidate) => candidate.key === result.key);
324
+ const state = message(lang, STATE_MESSAGES[result.state]);
325
+ const missed =
326
+ result.missed !== undefined && result.missed.length > 0
327
+ ? ` ${message(lang, "assessmentMissed", { points: result.missed.map((point) => `P${point}`).join(", ") })}`
328
+ : "";
329
+ const input =
330
+ (result.state === "falsch" || result.state === "teilweise") && result.input !== undefined
331
+ ? ` ${message(lang, "assessmentInput", { input: oneLine(result.input) })}`
332
+ : "";
333
+ return `- ${message(lang, "assessmentLine", {
334
+ key: result.key.toUpperCase(),
335
+ page: oneLine(item?.page ?? ""),
336
+ exercise: item?.exercise ?? "",
337
+ number: result.number === undefined ? "" : message(lang, "resultNumber", { n: String(result.number) }),
338
+ state,
339
+ })}${missed}${input}`;
340
+ });
341
+ // back to a running chat: the result first, the tutor page last, as the result line of a page has it
342
+ if (opening === "back") {
343
+ return [
344
+ message(lang, "assessmentBackLine"),
345
+ ...bands,
346
+ message(lang, "assessmentTasks"),
347
+ ...lines,
348
+ ...links,
349
+ `${message(lang, "resultTutor")} ${tutor}`,
350
+ ].join("\n");
351
+ }
352
+ return [
353
+ message(lang, "assessmentAsk", { url: tutor }),
354
+ ...bands,
355
+ ...(bands.length === 0 ? [] : [message(lang, "assessmentTasks")]),
356
+ ...lines,
357
+ ...links,
358
+ ].join("\n");
359
+ }
360
+
361
+ /** A chapter for the links a tutor gets: its title, its self-assessment if any, its pages, each within the language tree. */
362
+ export interface LinkChapter {
363
+ title: string;
364
+ assessment?: string;
365
+ pages: { title: string; local: string }[];
366
+ }
367
+
368
+ /**
369
+ * The full address of every page and every self-assessment of the app, chapter by chapter, each with via=tutor, from
370
+ * `root`, the address of the language tree as the browser has it: a tutor takes these and builds no address itself.
371
+ */
372
+ export function linkLines(root: string, chapters: readonly LinkChapter[], whole: string, lang: string): string[] {
373
+ const at = (local: string) => {
374
+ const url = new URL(local, root);
375
+ url.searchParams.set("via", "tutor");
376
+ return url.href;
377
+ };
378
+ return [
379
+ message(lang, "assessmentLinks"),
380
+ ...chapters.flatMap((chapter) => [
381
+ chapter.assessment === undefined
382
+ ? message(lang, "assessmentLinkChapterNone", { chapter: chapter.title })
383
+ : message(lang, "assessmentLinkChapter", { chapter: chapter.title, url: at(chapter.assessment) }),
384
+ ...chapter.pages.map(
385
+ (page) => `- ${message(lang, "assessmentLinkPage", { page: page.title, url: at(page.local) })}`,
386
+ ),
387
+ ]),
388
+ ...(chapters.length > 1 ? [message(lang, "assessmentLinkWhole", { url: at(whole) })] : []),
389
+ ];
390
+ }
391
+
392
+ /** The title of a content page in `lang`, on one line. */
393
+ export const titleOf = (page: Page, lang: string): string => oneLine(inLanguage(page.title, lang));