@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
@@ -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>
@@ -0,0 +1,253 @@
1
+ import { calculateReadingPlan, getDefaultInputs, type BookReadingInputs, type ReadingMode } from './logic';
2
+ import { createMilestoneIcs } from './calendar';
3
+ import { renderReadingPlan } from './dom-views';
4
+ import { loadBookReadingState, saveBookReadingState } from './storage';
5
+ import { calculateWordsPerMinute, countReadingWords } from './speed-test';
6
+ import type { BookReadingUI } from './ui';
7
+
8
+ interface ReadingState {
9
+ inputs: BookReadingInputs;
10
+ }
11
+
12
+ export function initBookReadingPlanner(root: HTMLElement, ui: BookReadingUI): void {
13
+ const defaultInputs = getDefaultInputs();
14
+ const stored = loadBookReadingState({ inputs: defaultInputs });
15
+ const state: ReadingState = { inputs: { ...defaultInputs, ...stored.inputs } };
16
+ bindModeSelect(root, state, ui);
17
+ bindInputs(root, state, ui);
18
+ bindPresets(root, state, ui);
19
+ bindSpeedTest(root, state, ui);
20
+ bindCalendarExport(root, state, ui);
21
+ updateView(root, state, ui);
22
+ }
23
+
24
+ function bindInputs(root: HTMLElement, state: ReadingState, ui: BookReadingUI): void {
25
+ root.querySelectorAll<HTMLInputElement | HTMLSelectElement>('[data-field]').forEach((input) => {
26
+ const eventName = input instanceof HTMLSelectElement ? 'change' : 'input';
27
+ input.addEventListener(eventName, () => {
28
+ const field = input.dataset.field as keyof BookReadingInputs;
29
+ const isNumeric = input instanceof HTMLSelectElement || input.type === 'number';
30
+ state.inputs[field] = (isNumeric ? Number(input.value) : input.value) as never;
31
+ updateView(root, state, ui);
32
+ });
33
+ });
34
+ }
35
+
36
+ function bindModeSelect(root: HTMLElement, state: ReadingState, ui: BookReadingUI): void {
37
+ const select = root.querySelector<HTMLElement>('[data-select="mode"]');
38
+ const trigger = select?.querySelector<HTMLButtonElement>('[data-select-trigger]');
39
+ const menu = select?.querySelector<HTMLElement>('[data-select-menu]');
40
+ if (!select || !trigger || !menu) return;
41
+ trigger.addEventListener('click', () => {
42
+ const isOpen = select.dataset.open === 'true';
43
+ closeModeSelect(select, menu);
44
+ if (!isOpen) {
45
+ select.dataset.open = 'true';
46
+ menu.hidden = false;
47
+ }
48
+ });
49
+ select.querySelectorAll<HTMLButtonElement>('[data-select-option]').forEach((option) => {
50
+ option.addEventListener('click', () => {
51
+ const nextMode = option.dataset.value as ReadingMode;
52
+ adaptModeDefaults(state.inputs, nextMode);
53
+ closeModeSelect(select, menu);
54
+ updateView(root, state, ui);
55
+ });
56
+ });
57
+ document.addEventListener('click', (event) => {
58
+ if (!(event.target instanceof Node) || !root.contains(event.target)) closeModeSelect(select, menu);
59
+ });
60
+ }
61
+
62
+ function adaptModeDefaults(inputs: BookReadingInputs, nextMode: ReadingMode): void {
63
+ if (inputs.mode === nextMode) return;
64
+ const previousDefaults = inputs.mode === 'pages' ? { amount: 320, speed: 35 } : { amount: 60000, speed: 250 };
65
+ const nextDefaults = nextMode === 'pages' ? { amount: 320, speed: 35 } : { amount: 60000, speed: 250 };
66
+ if (inputs.amount === previousDefaults.amount) inputs.amount = nextDefaults.amount;
67
+ if (inputs.speed === previousDefaults.speed) inputs.speed = nextDefaults.speed;
68
+ inputs.mode = nextMode;
69
+ }
70
+
71
+ function bindPresets(root: HTMLElement, state: ReadingState, ui: BookReadingUI): void {
72
+ const presets: Record<string, { inputs: Partial<BookReadingInputs> }> = {
73
+ steady: { inputs: { sessionMinutes: 30, sessionsPerDay: 1, daysPerWeek: 5 } },
74
+ weekend: { inputs: { sessionMinutes: 45, sessionsPerDay: 2, daysPerWeek: 2 } },
75
+ academic: { inputs: { mode: 'words', amount: 60000, speed: 250, sessionMinutes: 45, sessionsPerDay: 2, daysPerWeek: 6 } },
76
+ };
77
+ root.querySelectorAll<HTMLButtonElement>('[data-preset]').forEach((button) => {
78
+ button.addEventListener('click', () => {
79
+ const preset = presets[button.dataset.preset || ''];
80
+ if (!preset) return;
81
+ state.inputs = { ...state.inputs, ...preset.inputs };
82
+ updateView(root, state, ui);
83
+ });
84
+ });
85
+ }
86
+
87
+ function bindSpeedTest(root: HTMLElement, state: ReadingState, ui: BookReadingUI): void {
88
+ const elements = getSpeedTestElements(root);
89
+ if (!elements) return;
90
+ const speedState: SpeedTestState = { startedAt: 0, timer: undefined, measuredRate: 0 };
91
+ elements.action.addEventListener('click', () => {
92
+ if (speedState.startedAt) finishSpeedTest(elements, speedState, ui);
93
+ else startSpeedTest(elements, speedState, ui);
94
+ });
95
+ elements.useResult.addEventListener('click', () => applySpeedTest({ elements, speedState, state, root, ui }));
96
+ }
97
+
98
+ function bindCalendarExport(root: HTMLElement, state: ReadingState, ui: BookReadingUI): void {
99
+ const button = root.querySelector<HTMLButtonElement>('[data-export-ics]');
100
+ if (!button) return;
101
+ button.addEventListener('click', () => downloadMilestones(state, ui));
102
+ }
103
+
104
+ function downloadMilestones(state: ReadingState, ui: BookReadingUI): void {
105
+ clampNumericInputs(state.inputs);
106
+ const calculation = calculateReadingPlan(state.inputs);
107
+ const ics = createMilestoneIcs(calculation, ui.exportCalendarTitle, {
108
+ start: ui.milestoneStart,
109
+ quarter: ui.milestoneQuarter,
110
+ half: ui.milestoneHalf,
111
+ threeQuarter: ui.milestoneThreeQuarter,
112
+ finish: ui.milestoneFinish,
113
+ });
114
+ const link = document.createElement('a');
115
+ const url = URL.createObjectURL(new Blob([ics], { type: 'text/calendar;charset=utf-8' }));
116
+ link.href = url;
117
+ link.download = `reading-milestones-${calculation.startDate}.ics`;
118
+ document.body.append(link);
119
+ link.click();
120
+ link.remove();
121
+ window.setTimeout(() => URL.revokeObjectURL(url), 0);
122
+ }
123
+
124
+ interface SpeedTestElements {
125
+ text: HTMLTextAreaElement;
126
+ action: HTMLButtonElement;
127
+ useResult: HTMLButtonElement;
128
+ status: HTMLElement;
129
+ elapsed: HTMLElement;
130
+ rate: HTMLElement;
131
+ }
132
+
133
+ interface SpeedTestState {
134
+ startedAt: number;
135
+ timer: ReturnType<typeof setInterval> | undefined;
136
+ measuredRate: number;
137
+ }
138
+
139
+ function getSpeedTestElements(root: HTMLElement): SpeedTestElements | null {
140
+ const elements = {
141
+ text: root.querySelector<HTMLTextAreaElement>('[data-speed-test-text]'),
142
+ action: root.querySelector<HTMLButtonElement>('[data-speed-test-action]'),
143
+ useResult: root.querySelector<HTMLButtonElement>('[data-speed-test-use]'),
144
+ status: root.querySelector<HTMLElement>('[data-speed-test-status]'),
145
+ elapsed: root.querySelector<HTMLElement>('[data-speed-test-elapsed]'),
146
+ rate: root.querySelector<HTMLElement>('[data-speed-test-rate]'),
147
+ };
148
+ if (Object.values(elements).some((element) => !element)) return null;
149
+ return elements as SpeedTestElements;
150
+ }
151
+
152
+ function startSpeedTest(elements: SpeedTestElements, speedState: SpeedTestState, ui: BookReadingUI): void {
153
+ if (!countReadingWords(elements.text.value)) {
154
+ elements.status.textContent = ui.speedTestNeedsText;
155
+ elements.text.focus();
156
+ return;
157
+ }
158
+ speedState.startedAt = Date.now();
159
+ speedState.measuredRate = 0;
160
+ elements.elapsed.textContent = formatSeconds(0, ui);
161
+ elements.rate.textContent = '-';
162
+ elements.useResult.hidden = true;
163
+ elements.action.textContent = ui.speedTestFinishLabel;
164
+ elements.action.classList.add('is-running');
165
+ elements.status.textContent = ui.speedTestRunning;
166
+ speedState.timer = setInterval(() => updateSpeedTestElapsed(elements, speedState, ui), 250);
167
+ }
168
+
169
+ function updateSpeedTestElapsed(elements: SpeedTestElements, speedState: SpeedTestState, ui: BookReadingUI): void {
170
+ if (speedState.startedAt) {
171
+ elements.elapsed.textContent = formatSeconds((Date.now() - speedState.startedAt) / 1000, ui);
172
+ }
173
+ }
174
+
175
+ function finishSpeedTest(elements: SpeedTestElements, speedState: SpeedTestState, ui: BookReadingUI): void {
176
+ if (!speedState.startedAt) return;
177
+ const seconds = Math.max(0.1, (Date.now() - speedState.startedAt) / 1000);
178
+ speedState.measuredRate = calculateWordsPerMinute(countReadingWords(elements.text.value), seconds);
179
+ elements.elapsed.textContent = formatSeconds(seconds, ui);
180
+ elements.rate.textContent = speedState.measuredRate ? `${speedState.measuredRate} ${ui.speedTestWpmLabel}` : ui.speedTestNoWords;
181
+ elements.status.textContent = ui.speedTestFinished;
182
+ elements.useResult.hidden = !speedState.measuredRate;
183
+ elements.action.textContent = ui.speedTestStartLabel;
184
+ elements.action.classList.remove('is-running');
185
+ clearInterval(speedState.timer);
186
+ speedState.timer = undefined;
187
+ speedState.startedAt = 0;
188
+ }
189
+
190
+ interface SpeedTestContext {
191
+ elements: SpeedTestElements;
192
+ speedState: SpeedTestState;
193
+ state: ReadingState;
194
+ root: HTMLElement;
195
+ ui: BookReadingUI;
196
+ }
197
+
198
+ function applySpeedTest(context: SpeedTestContext): void {
199
+ context.state.inputs.mode = 'words';
200
+ context.state.inputs.speed = context.speedState.measuredRate;
201
+ updateView(context.root, context.state, context.ui);
202
+ context.elements.status.textContent = context.ui.speedTestFinished;
203
+ }
204
+
205
+ function updateView(root: HTMLElement, state: ReadingState, ui: BookReadingUI): void {
206
+ clampNumericInputs(state.inputs);
207
+ const calculation = calculateReadingPlan(state.inputs);
208
+ updateInputs(root, state.inputs);
209
+ updateMode(root, state.inputs.mode, ui);
210
+ renderReadingPlan(root, calculation, ui);
211
+ saveBookReadingState({ inputs: state.inputs });
212
+ }
213
+
214
+ function clampNumericInputs(inputs: BookReadingInputs): void {
215
+ inputs.amount = Math.min(10000000, Math.max(1, Number.isFinite(inputs.amount) ? inputs.amount : 1));
216
+ const defaultSpeed = inputs.mode === 'words' ? 250 : 35;
217
+ inputs.speed = Math.min(5000, Math.max(1, Number.isFinite(inputs.speed) ? inputs.speed : defaultSpeed));
218
+ inputs.sessionMinutes = Math.min(240, Math.max(5, Number.isFinite(inputs.sessionMinutes) ? inputs.sessionMinutes : 30));
219
+ inputs.sessionsPerDay = Math.min(8, Math.max(1, Math.round(Number.isFinite(inputs.sessionsPerDay) ? inputs.sessionsPerDay : 1)));
220
+ inputs.daysPerWeek = Math.min(7, Math.max(1, Math.round(Number.isFinite(inputs.daysPerWeek) ? inputs.daysPerWeek : 5)));
221
+ }
222
+
223
+ function updateInputs(root: HTMLElement, inputs: BookReadingInputs): void {
224
+ root.querySelectorAll<HTMLInputElement | HTMLSelectElement>('[data-field]').forEach((input) => {
225
+ const field = input.dataset.field as keyof BookReadingInputs;
226
+ input.value = String(inputs[field]);
227
+ });
228
+ }
229
+
230
+ function updateMode(root: HTMLElement, mode: ReadingMode, ui: BookReadingUI): void {
231
+ const labels: Record<ReadingMode, string> = { pages: ui.pagesModeLabel, words: ui.wordsModeLabel };
232
+ const select = root.querySelector<HTMLElement>('[data-select="mode"]');
233
+ const trigger = select?.querySelector('[data-select-trigger]');
234
+ if (trigger) trigger.textContent = labels[mode];
235
+ root.querySelectorAll<HTMLElement>('[data-speed-label]').forEach((label) => {
236
+ label.textContent = mode === 'pages' ? ui.pagesPerHourLabel : ui.wordsPerMinuteLabel;
237
+ });
238
+ root.querySelectorAll<HTMLElement>('[data-amount-label]').forEach((label) => {
239
+ label.textContent = mode === 'pages' ? ui.pagesModeLabel : ui.wordsModeLabel;
240
+ });
241
+ root.querySelectorAll<HTMLElement>('[data-pages-only], [data-pages-output]').forEach((element) => {
242
+ element.hidden = mode !== 'pages';
243
+ });
244
+ }
245
+
246
+ function closeModeSelect(select: HTMLElement, menu: HTMLElement): void {
247
+ select.dataset.open = 'false';
248
+ menu.hidden = true;
249
+ }
250
+
251
+ function formatSeconds(seconds: number, ui: BookReadingUI): string {
252
+ return `${seconds.toFixed(1)} ${ui.speedTestSecondsLabel}`;
253
+ }
@@ -0,0 +1,60 @@
1
+ import type { BookReadingCalculation } from './logic';
2
+ import type { BookReadingUI } from './ui';
3
+ import { evaluateReadingPlan } from './evaluator';
4
+
5
+ export function createTimelineMarkup(calculation: BookReadingCalculation, ui: BookReadingUI): string {
6
+ const evaluation = evaluateReadingPlan(calculation);
7
+ const labels = [ui.milestoneStart, ui.milestoneQuarter, ui.milestoneHalf, ui.milestoneThreeQuarter, ui.milestoneFinish];
8
+ const points = calculation.milestoneDates.map((date, index) => {
9
+ const x = 22 + index * 69;
10
+ return `<g class="n-milestone"><circle cx="${x}" cy="72" r="7" /><text x="${x}" y="52" text-anchor="middle">${labels[index]}</text><text x="${x}" y="101" text-anchor="middle">${formatDate(date)}</text></g>`;
11
+ }).join('');
12
+ return `<svg class="n-timeline-svg" viewBox="0 0 320 118" role="img" aria-label="${ui.timelineLabel}"><path class="n-timeline-track" d="M22 72 H298" /><path class="n-timeline-fill n-timeline-fill-${evaluation.status}" d="M22 72 H${calculation.status === 'late' ? 160 : 298}" />${points}</svg>`;
13
+ }
14
+
15
+ export function renderReadingPlan(root: HTMLElement, calculation: BookReadingCalculation, ui: BookReadingUI): void {
16
+ const evaluation = evaluateReadingPlan(calculation);
17
+ root.dataset.status = evaluation.status;
18
+ const status = root.querySelector<HTMLElement>('.n-status');
19
+ if (status) status.dataset.status = evaluation.status;
20
+ const values: Record<string, string> = {
21
+ time: formatDuration(calculation.minutesNeeded, ui),
22
+ days: String(calculation.readingDaysNeeded),
23
+ capacity: String(calculation.availableReadingDays),
24
+ pace: `${formatNumber(calculation.dailyPace)} ${getModeLabel(calculation.mode, ui)}`,
25
+ estimatedWords: calculation.estimatedWords === null ? '-' : `${formatNumber(calculation.estimatedWords)} ${ui.wordsShortLabel}`,
26
+ session: `${formatNumber(calculation.sessionTarget)} ${getModeLabel(calculation.mode, ui)}`,
27
+ catchUp: calculation.sessionsShort > 0 ? `${calculation.sessionsShort} ${ui.bufferShortLabel}` : `${calculation.catchUpSessions} ${ui.bufferExtraLabel}`,
28
+ finish: formatDate(calculation.finishDate),
29
+ };
30
+ Object.entries(values).forEach(([key, value]) => {
31
+ const target = root.querySelector(`[data-output="${key}"]`);
32
+ if (target) target.textContent = value;
33
+ });
34
+ const statusTitle = root.querySelector('[data-status-title]');
35
+ const statusDetail = root.querySelector('[data-status-detail]');
36
+ if (statusTitle) statusTitle.textContent = ui[evaluation.messageKey];
37
+ if (statusDetail) statusDetail.textContent = ui[evaluation.detailKey];
38
+ const timeline = root.querySelector('[data-timeline]');
39
+ if (timeline) timeline.innerHTML = createTimelineMarkup(calculation, ui);
40
+ }
41
+
42
+ function formatNumber(value: number): string {
43
+ return new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value);
44
+ }
45
+
46
+ function formatDuration(minutes: number, ui: BookReadingUI): string {
47
+ const hours = Math.floor(minutes / 60);
48
+ const remaining = Math.round(minutes % 60);
49
+ if (hours === 0) return `${remaining} ${ui.minuteShortLabel}`;
50
+ if (remaining === 0) return `${hours} ${ui.hourShortLabel}`;
51
+ return `${hours} ${ui.hourShortLabel} ${remaining} ${ui.minuteShortLabel}`;
52
+ }
53
+
54
+ function getModeLabel(mode: BookReadingCalculation['mode'], ui: BookReadingUI): string {
55
+ return mode === 'pages' ? ui.pagesModeLabel : ui.wordsModeLabel;
56
+ }
57
+
58
+ function formatDate(value: string): string {
59
+ return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }).format(new Date(`${value}T00:00:00Z`));
60
+ }
@@ -0,0 +1,27 @@
1
+ import type { BooksToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { BookReadingUI } from './ui';
3
+
4
+ export type { BookReadingUI } from './ui';
5
+ export type BookReadingLocaleContent = ToolLocaleContent<BookReadingUI>;
6
+
7
+ export const bookReadingTimeDeadlinePlanner: BooksToolEntry<BookReadingUI> = {
8
+ id: 'book-reading-time-deadline-planner',
9
+ icons: { bg: 'mdi:book-clock-outline', fg: 'mdi:calendar-check-outline' },
10
+ i18n: {
11
+ de: () => import('./i18n/de').then((module) => module.content),
12
+ en: () => import('./i18n/en').then((module) => module.content),
13
+ es: () => import('./i18n/es').then((module) => module.content),
14
+ fr: () => import('./i18n/fr').then((module) => module.content),
15
+ id: () => import('./i18n/id').then((module) => module.content),
16
+ it: () => import('./i18n/it').then((module) => module.content),
17
+ ja: () => import('./i18n/ja').then((module) => module.content),
18
+ ko: () => import('./i18n/ko').then((module) => module.content),
19
+ nl: () => import('./i18n/nl').then((module) => module.content),
20
+ pl: () => import('./i18n/pl').then((module) => module.content),
21
+ pt: () => import('./i18n/pt').then((module) => module.content),
22
+ ru: () => import('./i18n/ru').then((module) => module.content),
23
+ sv: () => import('./i18n/sv').then((module) => module.content),
24
+ tr: () => import('./i18n/tr').then((module) => module.content),
25
+ zh: () => import('./i18n/zh').then((module) => module.content),
26
+ },
27
+ };
@@ -0,0 +1,17 @@
1
+ import type { BookReadingCalculation, ReadingStatus } from './logic';
2
+
3
+ export interface ReadingEvaluation {
4
+ status: ReadingStatus;
5
+ messageKey: 'statusOnTrack' | 'statusTight' | 'statusLate' | 'statusInvalid';
6
+ detailKey: 'statusOnTrackDetail' | 'statusTightDetail' | 'statusLateDetail' | 'statusInvalidDetail';
7
+ }
8
+
9
+ export function evaluateReadingPlan(calculation: BookReadingCalculation): ReadingEvaluation {
10
+ const keys: Record<ReadingStatus, { messageKey: ReadingEvaluation['messageKey']; detailKey: ReadingEvaluation['detailKey'] }> = {
11
+ 'on-track': { messageKey: 'statusOnTrack', detailKey: 'statusOnTrackDetail' },
12
+ tight: { messageKey: 'statusTight', detailKey: 'statusTightDetail' },
13
+ late: { messageKey: 'statusLate', detailKey: 'statusLateDetail' },
14
+ invalid: { messageKey: 'statusInvalid', detailKey: 'statusInvalidDetail' },
15
+ };
16
+ return { status: calculation.status, ...keys[calculation.status] };
17
+ }
@@ -0,0 +1,45 @@
1
+ import type { ToolLocaleContent } from '../../../types';
2
+ import type { BookReadingUI } from '../ui';
3
+ import { bibliography } from '../bibliography';
4
+ import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
5
+
6
+ const ui: BookReadingUI = {
7
+ setupLabel: 'Leseplan erstellen', modeLabel: 'Zaehlen nach', pagesModeLabel: 'Seiten', wordsModeLabel: 'Woerter', amountLabel: 'Gesamtumfang', speedLabel: 'Deine Lesegeschwindigkeit', difficultyLabel: 'Textschwierigkeit', difficultyLightLabel: 'Leicht · 1.2x', difficultyStandardLabel: 'Standard · 1.0x', difficultyDenseLabel: 'Dicht · 0.8x', wordsPerPageLabel: 'Geschaetzte Woerter pro Seite', wordsPerPageHint: 'Typischer Bereich: 100 bis 500', pagesPerHourLabel: 'Seiten pro Stunde', wordsPerMinuteLabel: 'Woerter pro Minute', startDateLabel: 'Startdatum', deadlineLabel: 'Deadline', daysPerWeekLabel: 'Lesetage pro Woche', sessionsPerDayLabel: 'Sitzungen pro Lesetag', sessionMinutesLabel: 'Minuten pro Sitzung', minutesRangeLabel: '5 bis 240 Min.', hourShortLabel: 'Std.', minuteShortLabel: 'Min.', bufferShortLabel: 'zu wenig', bufferExtraLabel: 'zusaetzlich', speedTestLabel: 'Lesegeschwindigkeit messen', speedTestHint: 'Verwende einen kurzen, passenden Text', speedTestPromptLabel: 'Zu lesender Text', speedTestPlaceholder: 'Fuege hier einen Satz oder kurzen Text ein ...', speedTestStartLabel: 'Timer starten', speedTestFinishLabel: 'Fertig gelesen', speedTestElapsedLabel: 'Vergangen', speedTestRateLabel: 'Geschaetztes Tempo', speedTestSecondsLabel: 'Sek.', speedTestWpmLabel: 'WPM', speedTestUseLabel: 'Diese WPM im Woertermodus verwenden', speedTestIdle: 'Gib einen Text ein und starte den Timer, wenn du bereit bist.', speedTestRunning: 'Timer laeuft. Lies in deinem natuerlichen Tempo.', speedTestFinished: 'Test beendet. Dies ist eine erste Schaetzung, kein Richtwert.', speedTestNeedsText: 'Fuege vor dem Start einen Text ein.', speedTestNoWords: 'Keine Woerter gemessen', presetLabel: 'Schneller Rhythmus', presetSteadyLabel: 'Gleichmaessige Woche', presetWeekendLabel: 'Wochenendfokus', presetAcademicLabel: 'Lernintensiv', planLabel: 'Deine Lesekarte', timeNeededLabel: 'Benoetigte Zeit', readingDaysLabel: 'Benoetigte Lesetage', capacityLabel: 'Verfuegbare Lesetage', dailyPaceLabel: 'Tempo bis zur Deadline', estimatedWordsLabel: 'Geschaetzte Woerter', wordsShortLabel: 'Woerter', sessionTargetLabel: 'Ziel pro Sitzung', catchUpLabel: 'Aufholpuffer', finishDateLabel: 'Voraussichtliches Ende', milestoneLabel: 'Meilensteine', timelineLabel: 'Zeitplan der Lesemeilensteine', calendarHint: 'Meilensteine sind Planungsmarken. Verschiebe die Deadline oder passe deinen Rhythmus an, wenn das echte Leben dazwischenkommt.', exportCalendarLabel: 'Meilensteine herunterladen (.ics)', exportCalendarTitle: 'Lesemeilensteine', milestoneStart: 'Start', milestoneQuarter: '25 %', milestoneHalf: 'Haelfte', milestoneThreeQuarter: '75 %', milestoneFinish: 'Ende', statusOnTrack: 'Luft im Plan', statusTight: 'Knapp passend', statusLate: 'Nach der Deadline', statusInvalid: 'Daten pruefen', statusOnTrackDetail: 'Deine verfuegbaren Sitzungen lassen Puffer fuer ausgefallene Tage oder ein langsameres Kapitel.', statusTightDetail: 'Der Plan passt, hat aber wenig Reserve. Halte eine Sitzung flexibel zum Aufholen.', statusLateDetail: 'Der aktuelle Rhythmus reicht nicht bis zur Deadline. Fuege Sitzungen hinzu, verlaengere den Zeitraum oder reduziere das Ziel.', statusInvalidDetail: 'Waehle ein gueltiges Startdatum und eine Deadline am oder nach dem Startdatum.', sourceLabel: 'Dies ist eine Schaetzung des Tempos, keine Zusage fuer Verstaendnis und keine feste Lesegeschwindigkeit. Miss dein eigenes Tempo an einem passenden Text.',
8
+ };
9
+
10
+ const faq = [
11
+ { question: 'Wie waehle ich meine Lesegeschwindigkeit?', answer: 'Stoppe die Zeit fuer einen passenden Text, zaehle Seiten oder Woerter und verwende das beobachtete Tempo. Dichte, technische, unbekannte oder stark kommentierte Buecher brauchen meist mehr Zeit.' },
12
+ { question: 'Garantiert der Plan, dass ich das Buch verstehe?', answer: 'Nein. Er macht aus deinem Tempo und deinem Zeitplan eine Kalender-Schaetzung. Lesefluss und Verstaendnis sind nicht dasselbe, also lies bei anspruchsvollen Stellen langsamer.' },
13
+ { question: 'Was bedeutet der Aufholpuffer?', answer: 'Er zeigt die Sitzungen, die nach dem Einplanen der geschaetzten Lesezeit bis zur Deadline uebrig bleiben. Ein negativer Wert bedeutet, dass die Kapazitaet des Rhythmus nicht ausreicht.' },
14
+ { question: 'Warum kann ich nach Seiten oder Woertern planen?', answer: 'Seiten sind praktisch fuer gedruckte Buecher, Woerter besser vergleichbar bei digitalen Texten oder Aufgaben. Waehle die Einheit, die du konsequent verfolgen kannst.' },
15
+ ];
16
+
17
+ const howTo = [
18
+ { name: 'Einheit waehlen', text: 'Zaehle das Buch nach Seiten oder Woertern und trage den Gesamtumfang ein.' },
19
+ { name: 'Eigenes Tempo messen', text: 'Verwende ein persoenlich gemessenes Tempo statt eines allgemeinen Versprechens zum Schnelllesen.' },
20
+ { name: 'Wochenrhythmus festlegen', text: 'Setze Minuten pro Sitzung, Sitzungen pro Lesetag und Lesetage pro Woche.' },
21
+ { name: 'Deadline pruefen', text: 'Sieh dir Ende, Tagestempo und Meilensteine an und reserviere den Puffer fuer echte Unterbrechungen.' },
22
+ ];
23
+
24
+ const seo: ToolLocaleContent<BookReadingUI>['seo'] = [
25
+ { type: 'title', text: 'Lesetempo und Buchdeadline planen', level: 2 },
26
+ { type: 'paragraph', html: 'Verwandle ein Seiten- oder Woerterziel in einen realistischen Leseplan. Trage Umfang, gemessenes Tempo, Sitzungsdauer, Haeufigkeit und dein Wunschdatum ein.' },
27
+ { type: 'title', text: 'Mit dem eigenen Lesetempo planen', level: 2 },
28
+ { type: 'paragraph', html: 'Eine persoenliche Zeitprobe ist nuetzlicher als ein allgemeines Versprechen zum Schnelllesen. Miss einen passenden Abschnitt und senke das Tempo bei dichtem, unbekanntem oder gruendlich zu bearbeitendem Text.' },
29
+ { type: 'list', items: ['Waehle Seiten fuer ein gedrucktes Buch oder Woerter fuer digitale Texte.', 'Miss einen passenden Abschnitt und verwende ein Tempo, das du mit ausreichendem Verstaendnis halten kannst.', 'Lege Sitzungsdauer, Sitzungen pro Lesetag und Lesetage pro Woche fest.', 'Nutze den Aufholpuffer fuer ausgefallene Tage statt jede Sitzung als Pflicht zu behandeln.', 'Vergleiche den Plan nach den ersten Sitzungen mit deinem tatsaechlichen Fortschritt.'] },
30
+ { type: 'title', text: 'Die Deadline-Karte lesen', level: 2 },
31
+ { type: 'paragraph', html: 'Die benoetigte Zeit ist der Aufwand bei deinem gewaehlten Tempo. Benoetigte Lesetage uebersetzen ihn in Sitzungen. Verfuegbare Lesetage schaetzen die Kapazitaet zwischen Start und Deadline anhand deiner Lesetage pro Woche.' },
32
+ { type: 'table', headers: ['Ausgabe', 'Bedeutung'], rows: [['Tempo bis zur Deadline', 'Umfang fuer jeden verfuegbaren Lesetag'], ['Ziel pro Sitzung', 'Umfang fuer eine geplante Sitzung'], ['Aufholpuffer', 'Uebrige Sitzungen nach dem Einpassen des Aufwands'], ['Voraussichtliches Ende', 'Enddatum bei konstantem Wochenrhythmus']] },
33
+ { type: 'tip', title: 'Ein Kalender ist kein Verstaendnis', html: 'Der Plan misst weder Verstaendnis noch Schwierigkeit, Ermuedung, Wiederholungen oder Unterbrechungen. Nutze ihn als erste Zusage und aktualisiere ihn mit deinem echten Tempo.' },
34
+ { type: 'title', text: 'Wenn der Plan knapp oder zu spaet ist', level: 2 },
35
+ { type: 'paragraph', html: 'Luft im Plan bedeutet Reserve. Ein knapp passender Plan braucht mindestens eine flexible Aufholsitzung. Liegt das Ende nach der Deadline, verlaengere den Zeitraum, fuege Sitzungen hinzu, erhoehe die Lesetage oder reduziere den Umfang.' },
36
+ { type: 'tip', title: 'Vor dem Optimieren messen', html: 'Vergleiche nach zwei oder drei Sitzungen dein geplantes und dein echtes Ergebnis. Wenn der Unterschied bleibt, aendere das Tempo statt den Kalender gegen ein zu optimistisches Ziel zu zwingen.' },
37
+ ];
38
+
39
+ const schemas: [WithContext<SoftwareApplication>, WithContext<FAQPage>, WithContext<HowTo>] = [
40
+ { '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: 'Lesetempo und Buchdeadline planen', applicationCategory: 'EducationalApplication', operatingSystem: 'Any', offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' } },
41
+ { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) },
42
+ { '@context': 'https://schema.org', '@type': 'HowTo', name: 'Eine Lesefrist planen', step: howTo.map((item) => ({ '@type': 'HowToStep', name: item.name, text: item.text })) },
43
+ ];
44
+
45
+ export const content: ToolLocaleContent<BookReadingUI> = { slug: 'buch-lesezeit-und-frist-planer', title: 'Lesetempo und Buchdeadline planen', description: 'Plane Lesetempo, Sitzungsrhythmus und Enddatum für dein Seiten- oder Wörterziel. Berücksichtige die Schwierigkeit, damit dein Kalender zu deinem echten Lesealltag passt.', ui, seo, faq, bibliography, howTo, schemas: schemas as unknown as Record<string, unknown>[] };
@@ -0,0 +1,51 @@
1
+ import type { ToolLocaleContent } from '../../../types';
2
+ import type { BookReadingUI } from '../ui';
3
+ import { bibliography } from '../bibliography';
4
+ import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
5
+
6
+ const ui: BookReadingUI = {
7
+ setupLabel: 'Build your reading plan', modeLabel: 'Count by', pagesModeLabel: 'Pages', wordsModeLabel: 'Words', amountLabel: 'Total to read', speedLabel: 'Your reading speed', difficultyLabel: 'Text difficulty', difficultyLightLabel: 'Light · 1.2×', difficultyStandardLabel: 'Standard · 1.0×', difficultyDenseLabel: 'Dense · 0.8×', wordsPerPageLabel: 'Estimated words per page', wordsPerPageHint: 'Typical range: 100 to 500', pagesPerHourLabel: 'pages per hour', wordsPerMinuteLabel: 'words per minute', startDateLabel: 'Start date', deadlineLabel: 'Deadline', daysPerWeekLabel: 'Reading days per week', sessionsPerDayLabel: 'Sessions per reading day', sessionMinutesLabel: 'Minutes per session', minutesRangeLabel: '5 to 240 min', hourShortLabel: 'h', minuteShortLabel: 'min', bufferShortLabel: 'short', bufferExtraLabel: 'extra', speedTestLabel: 'Measure your reading speed', speedTestHint: 'Use a short representative passage', speedTestPromptLabel: 'Passage to read', speedTestPlaceholder: 'Paste or type a sentence or short passage here...', speedTestStartLabel: 'Start timer', speedTestFinishLabel: 'I finished', speedTestElapsedLabel: 'Elapsed', speedTestRateLabel: 'Estimated pace', speedTestSecondsLabel: 'sec', speedTestWpmLabel: 'WPM', speedTestUseLabel: 'Use this WPM in Words mode', speedTestIdle: 'Enter a passage, then start when you are ready.', speedTestRunning: 'Timer running. Read at your natural pace.', speedTestFinished: 'Test complete. Treat this as a first estimate, not a benchmark.', speedTestNeedsText: 'Add a passage before starting the timer.', speedTestNoWords: 'No words measured', presetLabel: 'Quick rhythm', presetSteadyLabel: 'Steady week', presetWeekendLabel: 'Weekend focus', presetAcademicLabel: 'Academic sprint', planLabel: 'Your reading map', timeNeededLabel: 'Time needed', readingDaysLabel: 'Reading days needed', capacityLabel: 'Reading days available', dailyPaceLabel: 'Pace by deadline', estimatedWordsLabel: 'Estimated words', wordsShortLabel: 'words', sessionTargetLabel: 'Target per session', catchUpLabel: 'Catch-up buffer', finishDateLabel: 'Projected finish', milestoneLabel: 'Milestones', timelineLabel: 'Reading milestone timeline', calendarHint: 'Milestones are planning markers. Move the deadline or adjust your rhythm when real life interrupts.', exportCalendarLabel: 'Download milestones (.ics)', exportCalendarTitle: 'Reading milestones', milestoneStart: 'Start', milestoneQuarter: '25%', milestoneHalf: 'Half', milestoneThreeQuarter: '75%', milestoneFinish: 'Finish', statusOnTrack: 'Room to breathe', statusTight: 'A close fit', statusLate: 'Past the deadline', statusInvalid: 'Check the dates', statusOnTrackDetail: 'Your available sessions leave a useful buffer for missed days or a slower chapter.', statusTightDetail: 'The plan fits, but there is little spare capacity. Keep one session flexible for catch-up.', statusLateDetail: 'The current rhythm cannot fit before the deadline. Add sessions, extend the date, or reduce the target.', statusInvalidDetail: 'Choose a valid start date and a deadline on or after it.', sourceLabel: 'This is a pacing estimate, not a promise of comprehension or a fixed reading speed. Measure your own rate on a representative passage.',
8
+ };
9
+
10
+ const faq = [
11
+ { question: 'How should I choose my reading speed?', answer: 'Time yourself on a representative passage, count its pages or words, and use that observed rate. Dense, technical, unfamiliar or heavily annotated books usually take longer.' },
12
+ { question: 'Does the planner guarantee that I will understand the book?', answer: 'No. It turns your own pace and schedule into a calendar estimate. Reading fluency and comprehension are related but not interchangeable, so slow down when the text requires close study.' },
13
+ { question: 'What does the catch-up buffer mean?', answer: 'It is the number of spare sessions left after the estimated reading time is placed inside your deadline. A negative value means the current rhythm is short of the required capacity.' },
14
+ { question: 'Why can I plan by pages or words?', answer: 'Pages are convenient for a printed book, while words can be more comparable across digital layouts or assigned passages. Choose the measure you can track consistently.' },
15
+ ];
16
+
17
+ const howTo = [
18
+ { name: 'Choose the reading measure', text: 'Count the book by pages or words, then enter the total amount.' },
19
+ { name: 'Enter your observed speed', text: 'Use a measured personal pace rather than a generic speed-reading claim.' },
20
+ { name: 'Shape the weekly rhythm', text: 'Set minutes per session, sessions per reading day and reading days per week.' },
21
+ { name: 'Set the deadline and inspect the map', text: 'Review the projected finish, pace by deadline and milestone dates, then reserve the buffer for real interruptions.' },
22
+ ];
23
+
24
+ const seo: ToolLocaleContent<BookReadingUI>['seo'] = [
25
+ { type: 'title', text: 'Plan Your Reading Pace and Book Deadline', level: 2 },
26
+ { type: 'paragraph', html: 'Turn a page or word target into a realistic reading calendar. Enter the amount you want to finish, your measured reading speed, the length and frequency of sessions, and the date you want to be done.' },
27
+ { type: 'title', text: 'Use Your Own Reading Rate', level: 2 },
28
+ { type: 'paragraph', html: 'A personal timed sample is more useful than a universal reading-speed promise. Test a representative passage, keep the unit consistent, and lower the rate when the book is dense, unfamiliar or meant for close study.' },
29
+ { type: 'list', items: ['Choose Pages for a printed book or Words for a digital or assigned passage.', 'Time a representative sample and enter the rate you can sustain with the understanding you need.', 'Set your real session length, sessions per reading day and reading days per week.', 'Use the catch-up buffer for missed days instead of treating every planned session as mandatory.', 'Recheck the plan after the first few sessions and replace the estimate with your observed pace.'] },
30
+ { type: 'title', text: 'Read the Deadline Map', level: 2 },
31
+ { type: 'paragraph', html: 'Time needed is the estimated effort at your chosen rate. Reading days needed turns that effort into sessions at your session length. Available reading days estimates the capacity between your start and deadline from the number of reading days per week, without pretending to know which weekdays you will actually use.' },
32
+ { type: 'table', headers: ['Output', 'What it tells you'], rows: [['Pace by deadline', 'The amount to cover on each available reading day'], ['Target per session', 'The amount to aim for in one planned session'], ['Catch-up buffer', 'Spare sessions after the estimated work fits'], ['Projected finish', 'The finish date if your weekly rhythm stays constant']] },
33
+ { type: 'tip', title: 'A calendar is not comprehension', html: 'The planner does not measure understanding, difficulty, fatigue, rereading or interruptions. Use it to make a first commitment, then update it with your real pace and leave room for notes, review and difficult chapters.' },
34
+ { type: 'title', text: 'When the Plan Is Tight or Late', level: 2 },
35
+ { type: 'paragraph', html: 'Room to breathe means the estimated sessions fit with spare capacity. A close fit has a small buffer and deserves one flexible catch-up slot. Past the deadline means the configured schedule cannot hold the target, so change one of the levers: extend the deadline, add sessions, add reading days or reduce the amount.' },
36
+ { type: 'tip', title: 'Measure before you optimise', html: 'After two or three sessions, compare the planned pace with what you actually finished. If the difference persists, edit the speed rather than forcing the schedule to match an optimistic estimate.' },
37
+ ];
38
+
39
+ const schemas: [WithContext<SoftwareApplication>, WithContext<FAQPage>, WithContext<HowTo>] = [
40
+ { '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: 'Book Reading Time Deadline Planner', applicationCategory: 'EducationalApplication', operatingSystem: 'Any', offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' } },
41
+ { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) },
42
+ { '@context': 'https://schema.org', '@type': 'HowTo', name: 'Plan a reading deadline', step: howTo.map((item) => ({ '@type': 'HowToStep', name: item.name, text: item.text })) },
43
+ ];
44
+
45
+ export const content: ToolLocaleContent<BookReadingUI> = {
46
+ slug: 'book-reading-time-deadline-planner',
47
+ title: 'Book Reading Time Deadline Planner',
48
+ description: 'Plan a realistic reading pace, session rhythm and finish date from your page or word target.',
49
+ ui, seo, faq, bibliography, howTo,
50
+ schemas: schemas as unknown as Record<string, unknown>[],
51
+ };