@jjlmoya/utils-books 1.7.0 → 1.8.0

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 (39) hide show
  1. package/package.json +1 -1
  2. package/src/entries.ts +4 -1
  3. package/src/tests/locale_completeness.test.ts +1 -1
  4. package/src/tests/tool_validation.test.ts +1 -1
  5. package/src/tool/book-reading-time-deadline-planner/bibliography.astro +16 -0
  6. package/src/tool/book-reading-time-deadline-planner/bibliography.ts +31 -0
  7. package/src/tool/book-reading-time-deadline-planner/book-reading-time-deadline-planner.css +449 -0
  8. package/src/tool/book-reading-time-deadline-planner/calendar.test.ts +18 -0
  9. package/src/tool/book-reading-time-deadline-planner/calendar.ts +56 -0
  10. package/src/tool/book-reading-time-deadline-planner/component.astro +91 -0
  11. package/src/tool/book-reading-time-deadline-planner/controller.ts +253 -0
  12. package/src/tool/book-reading-time-deadline-planner/dom-views.ts +60 -0
  13. package/src/tool/book-reading-time-deadline-planner/entry.ts +27 -0
  14. package/src/tool/book-reading-time-deadline-planner/evaluator.ts +17 -0
  15. package/src/tool/book-reading-time-deadline-planner/i18n/de.ts +45 -0
  16. package/src/tool/book-reading-time-deadline-planner/i18n/en.ts +51 -0
  17. package/src/tool/book-reading-time-deadline-planner/i18n/es.ts +45 -0
  18. package/src/tool/book-reading-time-deadline-planner/i18n/fr.ts +45 -0
  19. package/src/tool/book-reading-time-deadline-planner/i18n/id.ts +45 -0
  20. package/src/tool/book-reading-time-deadline-planner/i18n/it.ts +45 -0
  21. package/src/tool/book-reading-time-deadline-planner/i18n/ja.ts +45 -0
  22. package/src/tool/book-reading-time-deadline-planner/i18n/ko.ts +45 -0
  23. package/src/tool/book-reading-time-deadline-planner/i18n/nl.ts +45 -0
  24. package/src/tool/book-reading-time-deadline-planner/i18n/pl.ts +45 -0
  25. package/src/tool/book-reading-time-deadline-planner/i18n/pt.ts +45 -0
  26. package/src/tool/book-reading-time-deadline-planner/i18n/ru.ts +45 -0
  27. package/src/tool/book-reading-time-deadline-planner/i18n/sv.ts +45 -0
  28. package/src/tool/book-reading-time-deadline-planner/i18n/tr.ts +45 -0
  29. package/src/tool/book-reading-time-deadline-planner/i18n/zh.ts +45 -0
  30. package/src/tool/book-reading-time-deadline-planner/index.ts +11 -0
  31. package/src/tool/book-reading-time-deadline-planner/logic.test.ts +61 -0
  32. package/src/tool/book-reading-time-deadline-planner/logic.ts +129 -0
  33. package/src/tool/book-reading-time-deadline-planner/seo.astro +15 -0
  34. package/src/tool/book-reading-time-deadline-planner/speed-test.test.ts +15 -0
  35. package/src/tool/book-reading-time-deadline-planner/speed-test.ts +9 -0
  36. package/src/tool/book-reading-time-deadline-planner/storage.ts +24 -0
  37. package/src/tool/book-reading-time-deadline-planner/ui.ts +76 -0
  38. package/src/tool/book-reading-time-deadline-planner/validation.ts +19 -0
  39. package/src/tools.ts +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-books",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
package/src/entries.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  export { bookPaginationAndSpineCalculator } from './tool/book-pagination-and-spine-calculator/entry';
2
2
  export type { BookPaginationUI, BookPaginationLocaleContent } from './tool/book-pagination-and-spine-calculator/entry';
3
+ export { bookReadingTimeDeadlinePlanner } from './tool/book-reading-time-deadline-planner/entry';
4
+ export type { BookReadingUI, BookReadingLocaleContent } from './tool/book-reading-time-deadline-planner/entry';
3
5
 
4
6
  import { bookPaginationAndSpineCalculator } from './tool/book-pagination-and-spine-calculator/entry';
5
7
  import { bookInteriorMarginAndGutterPlanner } from './tool/book-interior-margin-and-gutter-planner/entry';
8
+ import { bookReadingTimeDeadlinePlanner } from './tool/book-reading-time-deadline-planner/entry';
6
9
 
7
- export const ALL_ENTRIES = [bookPaginationAndSpineCalculator, bookInteriorMarginAndGutterPlanner];
10
+ export const ALL_ENTRIES = [bookPaginationAndSpineCalculator, bookInteriorMarginAndGutterPlanner, bookReadingTimeDeadlinePlanner];
@@ -18,6 +18,6 @@ describe('Locale Completeness Validation', () => {
18
18
  });
19
19
 
20
20
  it('all tools registered', () => {
21
- expect(ALL_TOOLS.length).toBe(2);
21
+ expect(ALL_TOOLS.length).toBe(3);
22
22
  });
23
23
  });
@@ -5,7 +5,7 @@ import { booksCategory } from '../data';
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
7
  it('should have tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(2);
8
+ expect(ALL_TOOLS.length).toBe(3);
9
9
  });
10
10
 
11
11
  it('booksCategory should be defined', () => {
@@ -0,0 +1,16 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { bookReadingTimeDeadlinePlanner } from './index';
4
+ import { bibliography } from './bibliography';
5
+ import type { KnownLocale } from '../../types';
6
+
7
+ interface Props {
8
+ locale?: KnownLocale;
9
+ }
10
+
11
+ const { locale = 'en' } = Astro.props as Props;
12
+ const loader = bookReadingTimeDeadlinePlanner.i18n[locale] || bookReadingTimeDeadlinePlanner.i18n.en;
13
+ const content = await loader?.();
14
+ ---
15
+
16
+ <SharedBibliography links={content?.bibliography || bibliography} />
@@ -0,0 +1,31 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ interface TracedBibliographyEntry extends BibliographyEntry {
4
+ region: string;
5
+ originalLanguage: string;
6
+ supports: string;
7
+ }
8
+
9
+ export const bibliography: TracedBibliographyEntry[] = [
10
+ {
11
+ name: 'Allegheny College. College Level Reading',
12
+ url: 'https://sites.allegheny.edu/academic-success/academic-resources/reading/',
13
+ region: 'United States',
14
+ originalLanguage: 'English',
15
+ supports: 'Measure an individual reading rate in words per minute and pair pacing with comprehension.',
16
+ },
17
+ {
18
+ name: 'Ministerio de Educación y Formación Profesional. Marco teórico de lectura PISA 2018',
19
+ url: 'https://www.educacionfpydeportes.gob.es/dam/jcr%3A2f1081a1-c1e4-4799-8a49-9bc589724ca4/marco%20teorico%20lectura%202018_esp_ESP.pdf',
20
+ region: 'Spain and OECD',
21
+ originalLanguage: 'Spanish',
22
+ supports: 'Reading fluency combines accuracy, automaticity and meaning, so a speed target is not a comprehension guarantee.',
23
+ },
24
+ {
25
+ name: 'Rayner, Schotter, Masson, Potter and Treiman. So much to read, so little time',
26
+ url: 'https://profiles.wustl.edu/en/publications/so-much-to-read-so-little-time-how-do-we-read-and-can-speed-readi/',
27
+ region: 'International research',
28
+ originalLanguage: 'English',
29
+ supports: 'Reading speed and comprehension can trade off, so the planner treats speed as a personal estimate rather than a promise.',
30
+ },
31
+ ];
@@ -0,0 +1,449 @@
1
+ .n-tool {
2
+ --n-ink: #28231e;
3
+ --n-muted: #766d63;
4
+ --n-paper: #fffdf8;
5
+ --n-paper-alt: #f4ede1;
6
+ --n-paper-deep: #e7dac7;
7
+ --n-line: #d7c7b4;
8
+ --n-accent: #9c4d35;
9
+ --n-accent-soft: #f0d8cb;
10
+ --n-ok: #427257;
11
+ --n-warn: #a6652f;
12
+ --n-bad: #a34038;
13
+ --n-shadow: rgba(57, 39, 25, 0.13);
14
+
15
+ display: grid;
16
+ grid-template-columns: minmax(290px, 0.83fr) minmax(420px, 1.17fr);
17
+ overflow: visible;
18
+ color: var(--n-ink);
19
+ background: var(--n-paper);
20
+ border: 1px solid var(--n-line);
21
+ box-shadow: 0 18px 40px var(--n-shadow);
22
+ }
23
+
24
+ .n-setup,
25
+ .n-plan {
26
+ padding: 30px;
27
+ }
28
+ .n-setup {
29
+ display: flex;
30
+ flex-direction: column;
31
+ gap: 24px;
32
+ background: var(--n-paper-alt);
33
+ border-right: 1px solid var(--n-line);
34
+ }
35
+ .n-setup-head,
36
+ .n-panel-label,
37
+ .n-presets,
38
+ .n-presets > div {
39
+ display: flex;
40
+ align-items: center;
41
+ justify-content: space-between;
42
+ gap: 12px;
43
+ }
44
+ .n-eyebrow,
45
+ .n-panel-label,
46
+ .n-presets > span {
47
+ color: var(--n-muted);
48
+ font-size: 0.7rem;
49
+ font-weight: 700;
50
+ letter-spacing: 0.12em;
51
+ text-transform: uppercase;
52
+ }
53
+ .n-bookmark {
54
+ color: var(--n-accent);
55
+ font-size: 1.35rem;
56
+ }
57
+ .n-field-grid {
58
+ display: grid;
59
+ grid-template-columns: repeat(2, minmax(0, 1fr));
60
+ gap: 16px;
61
+ }
62
+ .n-field-wide {
63
+ grid-column: 1 / -1;
64
+ }
65
+ .n-field {
66
+ display: flex;
67
+ min-width: 0;
68
+ flex-direction: column;
69
+ gap: 8px;
70
+ color: var(--n-muted);
71
+ font-size: 0.76rem;
72
+ font-weight: 700;
73
+ }
74
+ .n-field input,
75
+ .n-field select,
76
+ .n-select-trigger {
77
+ width: 100%;
78
+ min-height: 45px;
79
+ padding: 9px 12px;
80
+ border: 1px solid var(--n-line);
81
+ border-radius: 2px;
82
+ background: var(--n-paper);
83
+ color: var(--n-ink);
84
+ font: inherit;
85
+ font-size: 0.95rem;
86
+ }
87
+ .n-field input:focus-visible,
88
+ .n-select-trigger:focus-visible,
89
+ .n-select-menu button:focus-visible,
90
+ .n-presets button:focus-visible {
91
+ outline: 3px solid var(--n-accent-soft);
92
+ outline-offset: 2px;
93
+ }
94
+ .n-field small {
95
+ color: var(--n-accent);
96
+ font-size: 0.68rem;
97
+ }
98
+ .n-select {
99
+ position: relative;
100
+ z-index: 3;
101
+ }
102
+ .n-select-trigger {
103
+ position: relative;
104
+ padding-right: 32px;
105
+ cursor: pointer;
106
+ text-align: left;
107
+ }
108
+ .n-select-trigger::after {
109
+ position: absolute;
110
+ top: 50%;
111
+ right: 12px;
112
+ width: 7px;
113
+ height: 7px;
114
+ border-right: 1.5px solid currentcolor;
115
+ border-bottom: 1.5px solid currentcolor;
116
+ content: "";
117
+ transform: translateY(-70%) rotate(45deg);
118
+ }
119
+ .n-select[data-open="true"] .n-select-trigger::after {
120
+ transform: translateY(-20%) rotate(225deg);
121
+ }
122
+ .n-select-menu {
123
+ position: absolute;
124
+ z-index: 5;
125
+ top: calc(100% + 6px);
126
+ right: 0;
127
+ left: 0;
128
+ padding: 5px;
129
+ border: 1px solid var(--n-line);
130
+ background: var(--n-paper);
131
+ box-shadow: 0 12px 25px var(--n-shadow);
132
+ }
133
+ .n-select-menu[hidden] {
134
+ display: none;
135
+ }
136
+ .n-select-menu button {
137
+ display: block;
138
+ width: 100%;
139
+ padding: 10px;
140
+ border: 0;
141
+ background: transparent;
142
+ color: var(--n-ink);
143
+ cursor: pointer;
144
+ font: inherit;
145
+ text-align: left;
146
+ }
147
+ .n-select-menu button:hover {
148
+ background: var(--n-accent-soft);
149
+ }
150
+ .n-presets {
151
+ align-items: flex-start;
152
+ flex-direction: column;
153
+ margin-top: auto;
154
+ padding-top: 18px;
155
+ border-top: 1px solid var(--n-line);
156
+ }
157
+ .n-presets > div {
158
+ flex-wrap: wrap;
159
+ justify-content: flex-start;
160
+ }
161
+ .n-presets button {
162
+ padding: 8px 10px;
163
+ border: 1px solid var(--n-line);
164
+ border-radius: 999px;
165
+ background: var(--n-paper);
166
+ color: var(--n-ink);
167
+ cursor: pointer;
168
+ font: inherit;
169
+ font-size: 0.72rem;
170
+ font-weight: 700;
171
+ }
172
+ .n-presets button:hover {
173
+ border-color: var(--n-accent);
174
+ color: var(--n-accent);
175
+ }
176
+ .n-speed-test {
177
+ display: grid;
178
+ gap: 12px;
179
+ padding: 16px;
180
+ border: 1px solid var(--n-line);
181
+ background: var(--n-paper);
182
+ }
183
+ .n-speed-test .n-panel-label {
184
+ align-items: flex-start;
185
+ flex-direction: column;
186
+ }
187
+ .n-speed-test textarea {
188
+ width: 100%;
189
+ min-height: 76px;
190
+ padding: 10px 12px;
191
+ border: 1px solid var(--n-line);
192
+ border-radius: 2px;
193
+ background: var(--n-paper-alt);
194
+ color: var(--n-ink);
195
+ font: inherit;
196
+ line-height: 1.45;
197
+ resize: vertical;
198
+ }
199
+ .n-speed-test textarea:focus-visible,
200
+ .n-speed-test-actions button:focus-visible,
201
+ .n-speed-test-result button:focus-visible {
202
+ outline: 3px solid var(--n-accent-soft);
203
+ outline-offset: 2px;
204
+ }
205
+ .n-speed-test-actions {
206
+ display: flex;
207
+ flex-wrap: wrap;
208
+ gap: 8px;
209
+ }
210
+ .n-speed-test-actions button,
211
+ .n-speed-test-result button {
212
+ padding: 8px 10px;
213
+ border: 1px solid var(--n-line);
214
+ border-radius: 999px;
215
+ background: var(--n-paper-alt);
216
+ color: var(--n-ink);
217
+ cursor: pointer;
218
+ font: inherit;
219
+ font-size: 0.72rem;
220
+ font-weight: 700;
221
+ }
222
+ .n-speed-test-actions button[data-speed-test-action],
223
+ .n-speed-test-result button {
224
+ border-color: var(--n-ink);
225
+ background: var(--n-ink);
226
+ color: var(--n-paper);
227
+ }
228
+ .n-speed-test-actions button[data-speed-test-action].is-running {
229
+ border-color: var(--n-accent);
230
+ background: var(--n-paper-alt);
231
+ color: var(--n-accent);
232
+ }
233
+ .n-speed-test-status {
234
+ margin: 0;
235
+ color: var(--n-muted);
236
+ font-size: 0.72rem;
237
+ line-height: 1.4;
238
+ }
239
+ .n-speed-test-result {
240
+ display: grid;
241
+ grid-template-columns: auto 1fr auto 1fr;
242
+ align-items: center;
243
+ gap: 7px 10px;
244
+ color: var(--n-muted);
245
+ font-size: 0.68rem;
246
+ text-transform: uppercase;
247
+ }
248
+ .n-speed-test-result strong {
249
+ color: var(--n-accent);
250
+ font-size: 0.85rem;
251
+ text-transform: none;
252
+ }
253
+ .n-speed-test-result button {
254
+ grid-column: 1 / -1;
255
+ justify-self: start;
256
+ }
257
+ .n-export-calendar {
258
+ justify-self: start;
259
+ padding: 8px 11px;
260
+ border: 1px solid var(--n-ink);
261
+ border-radius: 999px;
262
+ background: var(--n-ink);
263
+ color: var(--n-paper);
264
+ cursor: pointer;
265
+ font: inherit;
266
+ font-size: 0.72rem;
267
+ font-weight: 700;
268
+ }
269
+ .n-export-calendar:focus-visible {
270
+ outline: 3px solid var(--n-accent-soft);
271
+ outline-offset: 2px;
272
+ }
273
+ .n-plan {
274
+ display: grid;
275
+ grid-template-rows: auto auto 1fr auto;
276
+ gap: 22px;
277
+ }
278
+ .n-status {
279
+ display: flex;
280
+ align-items: flex-start;
281
+ gap: 11px;
282
+ min-height: 64px;
283
+ }
284
+ .n-status-dot {
285
+ flex: 0 0 auto;
286
+ width: 12px;
287
+ height: 12px;
288
+ margin-top: 5px;
289
+ border-radius: 50%;
290
+ background: var(--n-ok);
291
+ box-shadow: 0 0 0 5px rgba(66, 114, 87, 0.12);
292
+ }
293
+ .n-status[data-status="tight"] .n-status-dot {
294
+ background: var(--n-warn);
295
+ box-shadow: 0 0 0 5px rgba(166, 101, 47, 0.12);
296
+ }
297
+ .n-status[data-status="late"] .n-status-dot,
298
+ .n-status[data-status="invalid"] .n-status-dot {
299
+ background: var(--n-bad);
300
+ box-shadow: 0 0 0 5px rgba(163, 64, 56, 0.12);
301
+ }
302
+ .n-status strong {
303
+ display: block;
304
+ font-size: 1.08rem;
305
+ }
306
+ .n-status p {
307
+ max-width: 390px;
308
+ margin: 5px 0 0;
309
+ color: var(--n-muted);
310
+ font-size: 0.79rem;
311
+ line-height: 1.45;
312
+ }
313
+ .n-output-grid {
314
+ display: grid;
315
+ grid-template-columns: repeat(3, minmax(0, 1fr));
316
+ gap: 10px;
317
+ }
318
+ .n-output-card {
319
+ display: flex;
320
+ min-height: 74px;
321
+ flex-direction: column;
322
+ justify-content: space-between;
323
+ padding: 13px;
324
+ border: 1px solid var(--n-line);
325
+ background: var(--n-paper-alt);
326
+ }
327
+ .n-output-card span {
328
+ color: var(--n-muted);
329
+ font-size: 0.67rem;
330
+ font-weight: 700;
331
+ text-transform: uppercase;
332
+ }
333
+ .n-output-card strong {
334
+ color: var(--n-accent);
335
+ font-size: 1.08rem;
336
+ }
337
+ .n-output-wide {
338
+ grid-column: 1 / -1;
339
+ min-height: 58px;
340
+ flex-direction: row;
341
+ align-items: center;
342
+ }
343
+ .n-output-wide strong {
344
+ font-size: 1.18rem;
345
+ }
346
+ .n-timeline-panel {
347
+ display: grid;
348
+ align-content: center;
349
+ padding: 16px;
350
+ border: 1px solid var(--n-line);
351
+ background: var(--n-paper-alt);
352
+ }
353
+ .n-panel-label {
354
+ font-size: 0.65rem;
355
+ }
356
+ .n-panel-label span:last-child {
357
+ color: var(--n-accent);
358
+ }
359
+ .n-timeline-svg {
360
+ display: block;
361
+ width: 100%;
362
+ margin-top: 12px;
363
+ overflow: visible;
364
+ }
365
+ .n-timeline-track {
366
+ fill: none;
367
+ stroke: var(--n-paper-deep);
368
+ stroke-width: 6;
369
+ }
370
+ .n-timeline-fill {
371
+ fill: none;
372
+ stroke: var(--n-accent);
373
+ stroke-width: 6;
374
+ stroke-linecap: round;
375
+ }
376
+ .n-timeline-fill-late {
377
+ stroke: var(--n-bad);
378
+ }
379
+ .n-milestone circle {
380
+ fill: var(--n-paper);
381
+ stroke: var(--n-accent);
382
+ stroke-width: 3;
383
+ }
384
+ .n-milestone:first-of-type circle {
385
+ fill: var(--n-accent);
386
+ }
387
+ .n-milestone text {
388
+ fill: var(--n-muted);
389
+ font-size: 8px;
390
+ font-weight: 700;
391
+ }
392
+ .n-milestone text:first-of-type {
393
+ fill: var(--n-ink);
394
+ }
395
+ .n-timeline-panel p,
396
+ .n-source {
397
+ margin: 9px 0 0;
398
+ color: var(--n-muted);
399
+ font-size: 0.72rem;
400
+ line-height: 1.45;
401
+ }
402
+ .n-source {
403
+ margin: 0;
404
+ padding-top: 10px;
405
+ border-top: 1px solid var(--n-line);
406
+ }
407
+ html.theme-dark .n-tool {
408
+ --n-ink: #f6efe5;
409
+ --n-muted: #b9afa2;
410
+ --n-paper: #25231f;
411
+ --n-paper-alt: #171613;
412
+ --n-paper-deep: #51483e;
413
+ --n-line: #51483e;
414
+ --n-accent: #e49a72;
415
+ --n-accent-soft: #493129;
416
+ --n-ok: #82ba93;
417
+ --n-warn: #e0a36f;
418
+ --n-bad: #ee8b7f;
419
+ --n-shadow: rgba(0, 0, 0, 0.28);
420
+ }
421
+
422
+ @media (max-width: 780px) {
423
+ .n-tool {
424
+ grid-template-columns: 1fr;
425
+ max-width: calc(100vw - 40px);
426
+ }
427
+ .n-setup {
428
+ border-right: 0;
429
+ border-bottom: 1px solid var(--n-line);
430
+ }
431
+ }
432
+
433
+ @media (max-width: 480px) {
434
+ .n-setup,
435
+ .n-plan {
436
+ padding: 20px;
437
+ }
438
+ .n-field-grid,
439
+ .n-output-grid {
440
+ grid-template-columns: 1fr;
441
+ }
442
+ .n-field-wide,
443
+ .n-output-wide {
444
+ grid-column: auto;
445
+ }
446
+ .n-speed-test-result {
447
+ grid-template-columns: auto 1fr;
448
+ }
449
+ }
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createMilestoneIcs } from './calendar';
3
+ import { DEFAULT_INPUTS, calculateReadingPlan } from './logic';
4
+
5
+ describe('reading milestone calendar export', () => {
6
+ it('creates five all-day milestone events with exclusive end dates', () => {
7
+ const calculation = calculateReadingPlan(DEFAULT_INPUTS);
8
+ const ics = createMilestoneIcs(calculation, 'Reading plan', {
9
+ start: 'Start', quarter: '25%', half: 'Half', threeQuarter: '75%', finish: 'Finish',
10
+ });
11
+
12
+ expect(ics).toContain('BEGIN:VCALENDAR');
13
+ expect(ics.match(/BEGIN:VEVENT/g)).toHaveLength(5);
14
+ expect(ics).toContain('DTSTART;VALUE=DATE:20260101');
15
+ expect(ics).toContain('DTEND;VALUE=DATE:20260102');
16
+ expect(ics).toContain('SUMMARY:Reading plan - Start');
17
+ });
18
+ });
@@ -0,0 +1,56 @@
1
+ import type { BookReadingCalculation } from './logic';
2
+
3
+ export interface MilestoneCalendarLabels {
4
+ start: string;
5
+ quarter: string;
6
+ half: string;
7
+ threeQuarter: string;
8
+ finish: string;
9
+ }
10
+
11
+ export function createMilestoneIcs(calculation: BookReadingCalculation, title: string, labels: MilestoneCalendarLabels): string {
12
+ const milestoneLabels = [labels.start, labels.quarter, labels.half, labels.threeQuarter, labels.finish];
13
+ const events = calculation.milestoneDates.map((date, index) => createEvent({ calculation, date, label: milestoneLabels[index] || labels.start, index, title }));
14
+ return ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//jjlmoya-utils//Book Reading Planner//EN', 'CALSCALE:GREGORIAN', ...events, 'END:VCALENDAR', ''].join('\r\n');
15
+ }
16
+
17
+ interface CalendarEventContext {
18
+ calculation: BookReadingCalculation;
19
+ date: string;
20
+ label: string;
21
+ index: number;
22
+ title: string;
23
+ }
24
+
25
+ function createEvent(context: CalendarEventContext): string {
26
+ const nextDate = addOneDay(context.date);
27
+ const summary = escapeCalendarText(`${context.title} - ${context.label}`);
28
+ return [
29
+ 'BEGIN:VEVENT',
30
+ `UID:reading-milestone-${context.calculation.startDate}-${context.index}@jjlmoya-utils`,
31
+ `DTSTAMP:${toCalendarDate(new Date())}T000000Z`,
32
+ `DTSTART;VALUE=DATE:${toCalendarDateString(context.date)}`,
33
+ `DTEND;VALUE=DATE:${toCalendarDateString(nextDate)}`,
34
+ `SUMMARY:${summary}`,
35
+ `DESCRIPTION:${escapeCalendarText(`Reading milestone: ${context.label}`)}`,
36
+ 'END:VEVENT',
37
+ ].join('\r\n');
38
+ }
39
+
40
+ function toCalendarDate(date: Date): string {
41
+ return date.toISOString().slice(0, 10).replaceAll('-', '');
42
+ }
43
+
44
+ function toCalendarDateString(date: string): string {
45
+ return date.replaceAll('-', '');
46
+ }
47
+
48
+ function addOneDay(date: string): string {
49
+ const next = new Date(`${date}T00:00:00Z`);
50
+ next.setUTCDate(next.getUTCDate() + 1);
51
+ return next.toISOString().slice(0, 10);
52
+ }
53
+
54
+ function escapeCalendarText(value: string): string {
55
+ return value.replaceAll('\\', '\\\\').replaceAll(';', '\\;').replaceAll(',', '\\,').replaceAll('\r\n', '\\n').replaceAll('\n', '\\n');
56
+ }