@jjlmoya/utils-books 1.7.0 → 1.9.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 (70) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +7 -1
  4. package/src/index.ts +1 -0
  5. package/src/tests/locale_completeness.test.ts +1 -1
  6. package/src/tests/tool_validation.test.ts +1 -1
  7. package/src/tool/book-index-page-budget-calculator/bibliography.astro +6 -0
  8. package/src/tool/book-index-page-budget-calculator/bibliography.ts +17 -0
  9. package/src/tool/book-index-page-budget-calculator/book-index-page-budget-calculator.css +478 -0
  10. package/src/tool/book-index-page-budget-calculator/component.astro +134 -0
  11. package/src/tool/book-index-page-budget-calculator/controller.ts +114 -0
  12. package/src/tool/book-index-page-budget-calculator/dom-views.ts +89 -0
  13. package/src/tool/book-index-page-budget-calculator/entry.ts +39 -0
  14. package/src/tool/book-index-page-budget-calculator/evaluator.ts +26 -0
  15. package/src/tool/book-index-page-budget-calculator/i18n/de.ts +35 -0
  16. package/src/tool/book-index-page-budget-calculator/i18n/en.ts +113 -0
  17. package/src/tool/book-index-page-budget-calculator/i18n/es.ts +35 -0
  18. package/src/tool/book-index-page-budget-calculator/i18n/fr.ts +8 -0
  19. package/src/tool/book-index-page-budget-calculator/i18n/id.ts +35 -0
  20. package/src/tool/book-index-page-budget-calculator/i18n/it.ts +8 -0
  21. package/src/tool/book-index-page-budget-calculator/i18n/ja.ts +8 -0
  22. package/src/tool/book-index-page-budget-calculator/i18n/ko.ts +8 -0
  23. package/src/tool/book-index-page-budget-calculator/i18n/nl.ts +8 -0
  24. package/src/tool/book-index-page-budget-calculator/i18n/pl.ts +8 -0
  25. package/src/tool/book-index-page-budget-calculator/i18n/pt.ts +8 -0
  26. package/src/tool/book-index-page-budget-calculator/i18n/ru.ts +8 -0
  27. package/src/tool/book-index-page-budget-calculator/i18n/sv.ts +8 -0
  28. package/src/tool/book-index-page-budget-calculator/i18n/tr.ts +8 -0
  29. package/src/tool/book-index-page-budget-calculator/i18n/zh.ts +8 -0
  30. package/src/tool/book-index-page-budget-calculator/index.ts +11 -0
  31. package/src/tool/book-index-page-budget-calculator/logic.test.ts +52 -0
  32. package/src/tool/book-index-page-budget-calculator/logic.ts +86 -0
  33. package/src/tool/book-index-page-budget-calculator/seo.astro +15 -0
  34. package/src/tool/book-index-page-budget-calculator/storage.ts +17 -0
  35. package/src/tool/book-index-page-budget-calculator/ui.ts +41 -0
  36. package/src/tool/book-reading-time-deadline-planner/bibliography.astro +16 -0
  37. package/src/tool/book-reading-time-deadline-planner/bibliography.ts +31 -0
  38. package/src/tool/book-reading-time-deadline-planner/book-reading-time-deadline-planner.css +449 -0
  39. package/src/tool/book-reading-time-deadline-planner/calendar.test.ts +18 -0
  40. package/src/tool/book-reading-time-deadline-planner/calendar.ts +56 -0
  41. package/src/tool/book-reading-time-deadline-planner/component.astro +91 -0
  42. package/src/tool/book-reading-time-deadline-planner/controller.ts +253 -0
  43. package/src/tool/book-reading-time-deadline-planner/dom-views.ts +60 -0
  44. package/src/tool/book-reading-time-deadline-planner/entry.ts +27 -0
  45. package/src/tool/book-reading-time-deadline-planner/evaluator.ts +17 -0
  46. package/src/tool/book-reading-time-deadline-planner/i18n/de.ts +45 -0
  47. package/src/tool/book-reading-time-deadline-planner/i18n/en.ts +51 -0
  48. package/src/tool/book-reading-time-deadline-planner/i18n/es.ts +45 -0
  49. package/src/tool/book-reading-time-deadline-planner/i18n/fr.ts +45 -0
  50. package/src/tool/book-reading-time-deadline-planner/i18n/id.ts +45 -0
  51. package/src/tool/book-reading-time-deadline-planner/i18n/it.ts +45 -0
  52. package/src/tool/book-reading-time-deadline-planner/i18n/ja.ts +45 -0
  53. package/src/tool/book-reading-time-deadline-planner/i18n/ko.ts +45 -0
  54. package/src/tool/book-reading-time-deadline-planner/i18n/nl.ts +45 -0
  55. package/src/tool/book-reading-time-deadline-planner/i18n/pl.ts +45 -0
  56. package/src/tool/book-reading-time-deadline-planner/i18n/pt.ts +45 -0
  57. package/src/tool/book-reading-time-deadline-planner/i18n/ru.ts +45 -0
  58. package/src/tool/book-reading-time-deadline-planner/i18n/sv.ts +45 -0
  59. package/src/tool/book-reading-time-deadline-planner/i18n/tr.ts +45 -0
  60. package/src/tool/book-reading-time-deadline-planner/i18n/zh.ts +45 -0
  61. package/src/tool/book-reading-time-deadline-planner/index.ts +11 -0
  62. package/src/tool/book-reading-time-deadline-planner/logic.test.ts +61 -0
  63. package/src/tool/book-reading-time-deadline-planner/logic.ts +129 -0
  64. package/src/tool/book-reading-time-deadline-planner/seo.astro +15 -0
  65. package/src/tool/book-reading-time-deadline-planner/speed-test.test.ts +15 -0
  66. package/src/tool/book-reading-time-deadline-planner/speed-test.ts +9 -0
  67. package/src/tool/book-reading-time-deadline-planner/storage.ts +24 -0
  68. package/src/tool/book-reading-time-deadline-planner/ui.ts +76 -0
  69. package/src/tool/book-reading-time-deadline-planner/validation.ts +19 -0
  70. package/src/tools.ts +3 -1
@@ -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
+ }
@@ -0,0 +1,91 @@
1
+ ---
2
+ import { calculateReadingPlan, getDefaultInputs } from './logic';
3
+ import { createTimelineMarkup } from './dom-views';
4
+ import type { BookReadingUI } from './ui';
5
+
6
+ interface Props {
7
+ ui: BookReadingUI;
8
+ }
9
+
10
+ const { ui } = Astro.props as Props;
11
+ const initialInputs = getDefaultInputs();
12
+ const initialCalculation = calculateReadingPlan(initialInputs);
13
+
14
+ function formatInitialDuration(minutes: number): string {
15
+ const hours = Math.floor(minutes / 60);
16
+ const remaining = Math.round(minutes % 60);
17
+ if (hours === 0) return `${remaining} ${ui.minuteShortLabel}`;
18
+ if (remaining === 0) return `${hours} ${ui.hourShortLabel}`;
19
+ return `${hours} ${ui.hourShortLabel} ${remaining} ${ui.minuteShortLabel}`;
20
+ }
21
+
22
+ function formatInitialDate(value: string): string {
23
+ return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }).format(new Date(`${value}T00:00:00Z`));
24
+ }
25
+
26
+ const initialPace = `${initialCalculation.dailyPace} ${ui.pagesModeLabel}`;
27
+ const initialSession = `${initialCalculation.sessionTarget} ${ui.pagesModeLabel}`;
28
+ const initialBuffer = `${initialCalculation.catchUpSessions} ${ui.bufferExtraLabel}`;
29
+ const initialEstimatedWords = `${initialCalculation.estimatedWords?.toLocaleString('en-US') || 0} ${ui.wordsShortLabel}`;
30
+ ---
31
+
32
+ <div class="n-tool" data-tool="book-reading-time-deadline-planner">
33
+ <script is:inline type="application/json" data-ui set:html={JSON.stringify(ui)}></script>
34
+ <div class="n-setup">
35
+ <div class="n-setup-head"><span class="n-eyebrow">{ui.setupLabel}</span><span class="n-bookmark" aria-hidden="true">+</span></div>
36
+ <div class="n-field-grid">
37
+ <div class="n-field n-select n-field-wide" data-select="mode" data-open="false">
38
+ <span>{ui.modeLabel}</span>
39
+ <button class="n-select-trigger" type="button" data-select-trigger aria-haspopup="listbox">{ui.pagesModeLabel}</button>
40
+ <div class="n-select-menu" data-select-menu role="listbox" hidden>
41
+ <button type="button" data-select-option data-value="pages" role="option">{ui.pagesModeLabel}</button>
42
+ <button type="button" data-select-option data-value="words" role="option">{ui.wordsModeLabel}</button>
43
+ </div>
44
+ </div>
45
+ <label class="n-field n-field-wide"><span>{ui.amountLabel} <small data-amount-label>{ui.pagesModeLabel}</small></span><input data-field="amount" type="number" inputmode="numeric" min="1" max="10000000" step="1" value={initialInputs.amount} /></label>
46
+ <label class="n-field"><span>{ui.speedLabel}</span><input data-field="speed" type="number" inputmode="decimal" min="1" max="5000" step="1" value={initialInputs.speed} /><small data-speed-label>{ui.pagesPerHourLabel}</small></label>
47
+ <label class="n-field"><span>{ui.difficultyLabel}</span><select data-field="difficultyFactor"><option value="1.2">{ui.difficultyLightLabel}</option><option value="1" selected>{ui.difficultyStandardLabel}</option><option value="0.8">{ui.difficultyDenseLabel}</option></select></label>
48
+ <label class="n-field" data-pages-only><span>{ui.wordsPerPageLabel}</span><input data-field="wordsPerPage" type="number" inputmode="numeric" min="100" max="500" step="25" value={initialInputs.wordsPerPage} /><small>{ui.wordsPerPageHint}</small></label>
49
+ <label class="n-field"><span>{ui.sessionMinutesLabel}</span><input data-field="sessionMinutes" type="number" inputmode="numeric" min="5" max="240" step="5" value={initialInputs.sessionMinutes} /><small>{ui.minutesRangeLabel}</small></label>
50
+ <label class="n-field"><span>{ui.sessionsPerDayLabel}</span><input data-field="sessionsPerDay" type="number" inputmode="numeric" min="1" max="8" step="1" value={initialInputs.sessionsPerDay} /></label>
51
+ <label class="n-field"><span>{ui.daysPerWeekLabel}</span><input data-field="daysPerWeek" type="number" inputmode="numeric" min="1" max="7" step="1" value={initialInputs.daysPerWeek} /></label>
52
+ <label class="n-field"><span>{ui.startDateLabel}</span><input data-field="startDate" type="date" value={initialInputs.startDate} /></label>
53
+ <label class="n-field"><span>{ui.deadlineLabel}</span><input data-field="deadline" type="date" value={initialInputs.deadline} /></label>
54
+ </div>
55
+ <div class="n-speed-test">
56
+ <div class="n-panel-label"><span>{ui.speedTestLabel}</span><span>{ui.speedTestHint}</span></div>
57
+ <label class="n-field"><span>{ui.speedTestPromptLabel}</span><textarea data-speed-test-text rows="3" placeholder={ui.speedTestPlaceholder}></textarea></label>
58
+ <div class="n-speed-test-actions"><button type="button" data-speed-test-action>{ui.speedTestStartLabel}</button></div>
59
+ <p class="n-speed-test-status" data-speed-test-status aria-live="polite">{ui.speedTestIdle}</p>
60
+ <div class="n-speed-test-result"><span>{ui.speedTestElapsedLabel}</span><strong data-speed-test-elapsed>0.0 {ui.speedTestSecondsLabel}</strong><span>{ui.speedTestRateLabel}</span><strong data-speed-test-rate>-</strong><button type="button" data-speed-test-use hidden>{ui.speedTestUseLabel}</button></div>
61
+ </div>
62
+ <div class="n-presets"><span>{ui.presetLabel}</span><div><button type="button" data-preset="steady">{ui.presetSteadyLabel}</button><button type="button" data-preset="weekend">{ui.presetWeekendLabel}</button><button type="button" data-preset="academic">{ui.presetAcademicLabel}</button></div></div>
63
+ </div>
64
+ <div class="n-plan" aria-label={ui.planLabel} aria-live="polite">
65
+ <div class="n-status" data-status="tight"><span class="n-status-dot" aria-hidden="true"></span><div><strong data-status-title>{ui.statusTight}</strong><p data-status-detail>{ui.statusTightDetail}</p></div></div>
66
+ <div class="n-output-grid">
67
+ <div class="n-output-card"><span>{ui.timeNeededLabel}</span><strong data-output="time">{formatInitialDuration(initialCalculation.minutesNeeded)}</strong></div>
68
+ <div class="n-output-card"><span>{ui.readingDaysLabel}</span><strong data-output="days">{initialCalculation.readingDaysNeeded}</strong></div>
69
+ <div class="n-output-card"><span>{ui.capacityLabel}</span><strong data-output="capacity">{initialCalculation.availableReadingDays}</strong></div>
70
+ <div class="n-output-card"><span>{ui.dailyPaceLabel}</span><strong data-output="pace">{initialPace}</strong></div>
71
+ <div class="n-output-card" data-pages-output><span>{ui.estimatedWordsLabel}</span><strong data-output="estimatedWords">{initialEstimatedWords}</strong></div>
72
+ <div class="n-output-card"><span>{ui.sessionTargetLabel}</span><strong data-output="session">{initialSession}</strong></div>
73
+ <div class="n-output-card"><span>{ui.catchUpLabel}</span><strong data-output="catchUp">{initialBuffer}</strong></div>
74
+ <div class="n-output-card n-output-wide"><span>{ui.finishDateLabel}</span><strong data-output="finish">{formatInitialDate(initialCalculation.finishDate)}</strong></div>
75
+ </div>
76
+ <div class="n-timeline-panel"><div class="n-panel-label"><span>{ui.milestoneLabel}</span><span>{ui.timelineLabel}</span></div><div data-timeline set:html={createTimelineMarkup(initialCalculation, ui)}></div><p>{ui.calendarHint}</p><button class="n-export-calendar" type="button" data-export-ics>{ui.exportCalendarLabel}</button></div>
77
+ <p class="n-source">{ui.sourceLabel}</p>
78
+ </div>
79
+ </div>
80
+
81
+ <script>
82
+ import { initBookReadingPlanner } from './controller';
83
+
84
+ const root = document.querySelector<HTMLElement>('[data-tool="book-reading-time-deadline-planner"]');
85
+ const data = root?.querySelector<HTMLScriptElement>('[data-ui]');
86
+ if (root && data) initBookReadingPlanner(root, JSON.parse(data.textContent || '{}'));
87
+ </script>
88
+
89
+ <style>
90
+ @import url('./book-reading-time-deadline-planner.css');
91
+ </style>