@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.
- package/package.json +1 -1
- package/src/entries.ts +4 -1
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/tool_validation.test.ts +1 -1
- package/src/tool/book-reading-time-deadline-planner/bibliography.astro +16 -0
- package/src/tool/book-reading-time-deadline-planner/bibliography.ts +31 -0
- package/src/tool/book-reading-time-deadline-planner/book-reading-time-deadline-planner.css +449 -0
- package/src/tool/book-reading-time-deadline-planner/calendar.test.ts +18 -0
- package/src/tool/book-reading-time-deadline-planner/calendar.ts +56 -0
- package/src/tool/book-reading-time-deadline-planner/component.astro +91 -0
- package/src/tool/book-reading-time-deadline-planner/controller.ts +253 -0
- package/src/tool/book-reading-time-deadline-planner/dom-views.ts +60 -0
- package/src/tool/book-reading-time-deadline-planner/entry.ts +27 -0
- package/src/tool/book-reading-time-deadline-planner/evaluator.ts +17 -0
- package/src/tool/book-reading-time-deadline-planner/i18n/de.ts +45 -0
- package/src/tool/book-reading-time-deadline-planner/i18n/en.ts +51 -0
- package/src/tool/book-reading-time-deadline-planner/i18n/es.ts +45 -0
- package/src/tool/book-reading-time-deadline-planner/i18n/fr.ts +45 -0
- package/src/tool/book-reading-time-deadline-planner/i18n/id.ts +45 -0
- package/src/tool/book-reading-time-deadline-planner/i18n/it.ts +45 -0
- package/src/tool/book-reading-time-deadline-planner/i18n/ja.ts +45 -0
- package/src/tool/book-reading-time-deadline-planner/i18n/ko.ts +45 -0
- package/src/tool/book-reading-time-deadline-planner/i18n/nl.ts +45 -0
- package/src/tool/book-reading-time-deadline-planner/i18n/pl.ts +45 -0
- package/src/tool/book-reading-time-deadline-planner/i18n/pt.ts +45 -0
- package/src/tool/book-reading-time-deadline-planner/i18n/ru.ts +45 -0
- package/src/tool/book-reading-time-deadline-planner/i18n/sv.ts +45 -0
- package/src/tool/book-reading-time-deadline-planner/i18n/tr.ts +45 -0
- package/src/tool/book-reading-time-deadline-planner/i18n/zh.ts +45 -0
- package/src/tool/book-reading-time-deadline-planner/index.ts +11 -0
- package/src/tool/book-reading-time-deadline-planner/logic.test.ts +61 -0
- package/src/tool/book-reading-time-deadline-planner/logic.ts +129 -0
- package/src/tool/book-reading-time-deadline-planner/seo.astro +15 -0
- package/src/tool/book-reading-time-deadline-planner/speed-test.test.ts +15 -0
- package/src/tool/book-reading-time-deadline-planner/speed-test.ts +9 -0
- package/src/tool/book-reading-time-deadline-planner/storage.ts +24 -0
- package/src/tool/book-reading-time-deadline-planner/ui.ts +76 -0
- package/src/tool/book-reading-time-deadline-planner/validation.ts +19 -0
- package/src/tools.ts +2 -1
|
@@ -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: 'Составьте план чтения', modeLabel: 'Считать по', pagesModeLabel: 'Страницам', wordsModeLabel: 'Словам', amountLabel: 'Всего прочитать', speedLabel: 'Ваша скорость чтения', difficultyLabel: 'Сложность текста', difficultyLightLabel: 'Легкий · 1.2x', difficultyStandardLabel: 'Стандартный · 1.0x', difficultyDenseLabel: 'Плотный · 0.8x', wordsPerPageLabel: 'Примерное число слов на странице', wordsPerPageHint: 'Обычно: от 100 до 500', pagesPerHourLabel: 'страниц в час', wordsPerMinuteLabel: 'слов в минуту', startDateLabel: 'Дата начала', deadlineLabel: 'Срок', daysPerWeekLabel: 'Дней чтения в неделю', sessionsPerDayLabel: 'Сеансов в день чтения', sessionMinutesLabel: 'Минут на сеанс', minutesRangeLabel: 'От 5 до 240 минут', hourShortLabel: 'ч', minuteShortLabel: 'мин', bufferShortLabel: 'не хватает', bufferExtraLabel: 'запас', speedTestLabel: 'Измерьте скорость чтения', speedTestHint: 'Возьмите короткий показательный фрагмент', speedTestPromptLabel: 'Текст для чтения', speedTestPlaceholder: 'Вставьте или введите предложение или короткий фрагмент...', speedTestStartLabel: 'Запустить таймер', speedTestFinishLabel: 'Я закончил', speedTestElapsedLabel: 'Прошло', speedTestRateLabel: 'Расчетный темп', speedTestSecondsLabel: 'с', speedTestWpmLabel: 'СЛМ', speedTestUseLabel: 'Использовать этот темп в режиме Слова', speedTestIdle: 'Введите фрагмент и запустите таймер, когда будете готовы.', speedTestRunning: 'Таймер запущен. Читайте в естественном темпе.', speedTestFinished: 'Тест завершен. Это первая оценка, а не постоянная норма.', speedTestNeedsText: 'Добавьте текст перед запуском таймера.', speedTestNoWords: 'Слова не измерены', presetLabel: 'Быстрый ритм', presetSteadyLabel: 'Ровная неделя', presetWeekendLabel: 'Чтение на выходных', presetAcademicLabel: 'Учебный ритм', planLabel: 'Карта чтения', timeNeededLabel: 'Нужно времени', readingDaysLabel: 'Нужно дней чтения', capacityLabel: 'Доступно дней чтения', dailyPaceLabel: 'Темп до срока', estimatedWordsLabel: 'Примерное число слов', wordsShortLabel: 'слов', sessionTargetLabel: 'Цель на сеанс', catchUpLabel: 'Запас для наверстывания', finishDateLabel: 'Ожидаемая дата окончания', milestoneLabel: 'Этапы', timelineLabel: 'Шкала этапов чтения', calendarHint: 'Этапы служат ориентирами для плана. Перенесите срок или измените ритм, если реальные дела прервут чтение.', exportCalendarLabel: 'Скачать этапы (.ics)', exportCalendarTitle: 'Этапы чтения', milestoneStart: 'Начало', milestoneQuarter: '25%', milestoneHalf: 'Половина', milestoneThreeQuarter: '75%', milestoneFinish: 'Конец', statusOnTrack: 'Есть запас', statusTight: 'Впритык', statusLate: 'После срока', statusInvalid: 'Проверьте даты', statusOnTrackDetail: 'Доступные сеансы оставляют запас на пропущенные дни или медленную главу.', statusTightDetail: 'План помещается, но запас небольшой. Оставьте один гибкий сеанс для наверстывания.', statusLateDetail: 'При текущем ритме цель не помещается до срока. Добавьте сеансы, продлите период или уменьшите цель.', statusInvalidDetail: 'Выберите правильную дату начала и срок не раньше нее.', sourceLabel: 'Это оценка темпа, а не гарантия понимания и не фиксированная скорость чтения. Измерьте свой темп на показательном фрагменте.',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const faq = [
|
|
11
|
+
{ question: 'Как выбрать скорость чтения?', answer: 'Засеките время на показательный фрагмент, посчитайте страницы или слова и используйте полученный темп. Плотные, технические, незнакомые или хорошо аннотированные книги обычно требуют больше времени.' },
|
|
12
|
+
{ question: 'Гарантирует ли план понимание книги?', answer: 'Нет. Он превращает ваш темп и расписание в оценку по календарю. Беглость чтения и понимание не одно и то же, поэтому замедляйтесь там, где нужен подробный разбор.' },
|
|
13
|
+
{ question: 'Что означает запас для наверстывания?', answer: 'Это число сеансов, оставшихся после размещения расчетного времени до срока. Отрицательное значение означает, что текущего ритма недостаточно.' },
|
|
14
|
+
{ question: 'Зачем планировать по страницам или словам?', answer: 'Страницы удобны для печатной книги, а слова проще сравнивать в цифровом тексте или задании. Выберите единицу, которую сможете регулярно отслеживать.' },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const howTo = [
|
|
18
|
+
{ name: 'Выберите единицу чтения', text: 'Посчитайте книгу в страницах или словах и введите общий объем.' },
|
|
19
|
+
{ name: 'Измерьте свой темп', text: 'Используйте измеренную личную скорость, а не общее обещание быстрого чтения.' },
|
|
20
|
+
{ name: 'Настройте недельный ритм', text: 'Задайте минуты на сеанс, число сеансов за день чтения и дни чтения в неделю.' },
|
|
21
|
+
{ name: 'Проверьте срок', text: 'Посмотрите дату окончания, дневной темп и этапы, оставив запас на реальные перерывы.' },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const seo: ToolLocaleContent<BookReadingUI>['seo'] = [
|
|
25
|
+
{ type: 'title', text: 'Спланируйте темп чтения и срок окончания книги', level: 2 },
|
|
26
|
+
{ type: 'paragraph', html: 'Превратите цель в страницах или словах в реалистичный календарь чтения. Укажите объем, измеренную скорость, длительность и частоту сеансов и желаемую дату окончания.' },
|
|
27
|
+
{ type: 'title', text: 'Используйте собственный темп чтения', level: 2 },
|
|
28
|
+
{ type: 'paragraph', html: 'Личная проверка по времени полезнее универсального обещания быстрого чтения. Измерьте показательный фрагмент и снизьте темп, если книга плотная, незнакомая или требует внимательного изучения.' },
|
|
29
|
+
{ type: 'list', items: ['Выберите Страницы для печатной книги или Слова для цифрового текста и задания.', 'Измерьте показательный фрагмент и используйте темп, который сохраняет нужное понимание.', 'Задайте длительность сеансов, число сеансов в день и дни чтения в неделю.', 'Оставьте запас на пропущенные дни, а не превращайте каждый сеанс в обязательство.', 'После первых сеансов сравните план с реальным прогрессом.'] },
|
|
30
|
+
{ type: 'title', text: 'Как читать карту срока', level: 2 },
|
|
31
|
+
{ type: 'paragraph', html: 'Нужное время показывает расчетные усилия при выбранном темпе. Нужные дни чтения переводят их в сеансы. Доступные дни чтения оценивают вместимость между началом и сроком по числу дней чтения в неделю.' },
|
|
32
|
+
{ type: 'table', headers: ['Результат', 'Что он означает'], rows: [['Темп до срока', 'Объем для каждого доступного дня чтения'], ['Цель на сеанс', 'Объем для одного запланированного сеанса'], ['Запас для наверстывания', 'Оставшиеся сеансы после распределения усилий'], ['Ожидаемая дата окончания', 'Дата конца при сохранении недельного ритма']] },
|
|
33
|
+
{ type: 'tip', title: 'Календарь не измеряет понимание', html: 'План не измеряет понимание, сложность, усталость, перечитывание или перерывы. Используйте его как первое обязательство и обновляйте по реальному темпу.' },
|
|
34
|
+
{ type: 'title', text: 'Если план слишком плотный или опаздывает', level: 2 },
|
|
35
|
+
{ type: 'paragraph', html: 'Запас есть, когда расчетные сеансы помещаются с резервом. Для плотного плана оставьте один гибкий сеанс. Если окончание выходит за срок, продлите период, добавьте сеансы или уменьшите цель.' },
|
|
36
|
+
{ type: 'tip', title: 'Сначала измерьте, потом улучшайте', html: 'Через два или три сеанса сравните ожидаемый и реальный прогресс. Если разница сохраняется, измените скорость, а не пытайтесь удержать слишком оптимистичный календарь.' },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const schemas: [WithContext<SoftwareApplication>, WithContext<FAQPage>, WithContext<HowTo>] = [
|
|
40
|
+
{ '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: 'Спланируйте темп чтения и срок окончания книги', applicationCategory: 'EducationalApplication', operatingSystem: 'Any', offers: { '@type': 'Offer', price: '0', priceCurrency: 'RUB' } },
|
|
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: 'Спланировать срок чтения', step: howTo.map((item) => ({ '@type': 'HowToStep', name: item.name, text: item.text })) },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export const content: ToolLocaleContent<BookReadingUI> = { slug: 'planirovshchik-vremeni-chteniya-i-sroka-knigi', title: 'Спланируйте темп чтения и срок окончания книги', description: 'Рассчитайте реалистичный темп чтения, ритм сеансов и дату окончания по количеству страниц или слов.', ui, seo, faq, bibliography, howTo, schemas: schemas as unknown as Record<string, unknown>[] };
|
|
@@ -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: 'Skapa din läsplan', modeLabel: 'Räkna i', pagesModeLabel: 'Sidor', wordsModeLabel: 'Ord', amountLabel: 'Totalt att läsa', speedLabel: 'Din läshastighet', difficultyLabel: 'Textens svårighetsgrad', difficultyLightLabel: 'Lätt · 1.2x', difficultyStandardLabel: 'Standard · 1.0x', difficultyDenseLabel: 'Tät · 0.8x', wordsPerPageLabel: 'Uppskattade ord per sida', wordsPerPageHint: 'Vanligt intervall: 100 till 500', pagesPerHourLabel: 'sidor per timme', wordsPerMinuteLabel: 'ord per minut', startDateLabel: 'Startdatum', deadlineLabel: 'Deadline', daysPerWeekLabel: 'Läsdagar per vecka', sessionsPerDayLabel: 'Sessioner per läsdag', sessionMinutesLabel: 'Minuter per session', minutesRangeLabel: '5 till 240 min', hourShortLabel: 'tim', minuteShortLabel: 'min', bufferShortLabel: 'korta', bufferExtraLabel: 'extra', speedTestLabel: 'Mät din läshastighet', speedTestHint: 'Använd ett kort och representativt stycke', speedTestPromptLabel: 'Text att läsa', speedTestPlaceholder: 'Klistra in eller skriv en mening eller ett kort stycke här...', speedTestStartLabel: 'Starta timer', speedTestFinishLabel: 'Jag är klar', speedTestElapsedLabel: 'Förfluten tid', speedTestRateLabel: 'Uppskattat tempo', speedTestSecondsLabel: 'sek', speedTestWpmLabel: 'OPM', speedTestUseLabel: 'Använd detta OPM i läget Ord', speedTestIdle: 'Skriv in ett stycke och starta när du är redo.', speedTestRunning: 'Timern körs. Läs i ditt naturliga tempo.', speedTestFinished: 'Testet är klart. Detta är en första uppskattning, inte en fast norm.', speedTestNeedsText: 'Lägg till text innan du startar timern.', speedTestNoWords: 'Inga ord mättes', presetLabel: 'Snabb rytm', presetSteadyLabel: 'Jämn vecka', presetWeekendLabel: 'Helgfokus', presetAcademicLabel: 'Studietempo', planLabel: 'Din läskarta', timeNeededLabel: 'Tid som behövs', readingDaysLabel: 'Nödvändiga läsdagar', capacityLabel: 'Tillgängliga läsdagar', dailyPaceLabel: 'Tempo till deadline', estimatedWordsLabel: 'Uppskattade ord', wordsShortLabel: 'ord', sessionTargetLabel: 'Mål per session', catchUpLabel: 'Utrymme för att komma ikapp', finishDateLabel: 'Beräknat slut', milestoneLabel: 'Milstolpar', timelineLabel: 'Tidslinje för läsmilstolpar', calendarHint: 'Milstolparna är planeringspunkter. Flytta deadlinen eller ändra rytmen när verkligheten avbryter planen.', exportCalendarLabel: 'Ladda ner milstolpar (.ics)', exportCalendarTitle: 'Läsmilstolpar', milestoneStart: 'Start', milestoneQuarter: '25%', milestoneHalf: 'Halvvägs', milestoneThreeQuarter: '75%', milestoneFinish: 'Slut', statusOnTrack: 'Det finns marginal', statusTight: 'Precis lagom', statusLate: 'Efter deadline', statusInvalid: 'Kontrollera datumen', statusOnTrackDetail: 'Dina tillgängliga sessioner lämnar marginal för missade dagar eller ett långsammare kapitel.', statusTightDetail: 'Planen går ihop, men marginalen är liten. Spara en flexibel session för att komma ikapp.', statusLateDetail: 'Den nuvarande rytmen räcker inte före deadlinen. Lägg till sessioner, förläng perioden eller minska målet.', statusInvalidDetail: 'Välj ett giltigt startdatum och en deadline på eller efter startdatumet.', sourceLabel: 'Detta är en uppskattning av tempo, inte ett löfte om förståelse eller en fast läshastighet. Mät ditt eget tempo med ett representativt stycke.',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const faq = [
|
|
11
|
+
{ question: 'Hur väljer jag min läshastighet?', answer: 'Ta tiden på ett representativt stycke, räkna sidor eller ord och använd det uppmätta tempot. Täta, tekniska, obekanta eller hårt kommenterade böcker kräver ofta mer tid.' },
|
|
12
|
+
{ question: 'Garanterar planen att jag förstår boken?', answer: 'Nej. Den omvandlar ditt tempo och ditt schema till en kalenderuppskattning. Läsflyt och förståelse är inte samma sak, så sänk tempot när texten kräver noggrann läsning.' },
|
|
13
|
+
{ question: 'Vad betyder utrymme för att komma ikapp?', answer: 'Det är antalet sessioner som återstår när den uppskattade lästiden har placerats före deadlinen. Ett negativt värde betyder att den nuvarande rytmen inte räcker.' },
|
|
14
|
+
{ question: 'Varför kan jag planera i sidor eller ord?', answer: 'Sidor är praktiskt för tryckta böcker, medan ord är lättare att jämföra i digital text eller uppgifter. Välj den enhet du kan följa konsekvent.' },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const howTo = [
|
|
18
|
+
{ name: 'Välj läsenhet', text: 'Räkna boken i sidor eller ord och skriv in totalen.' },
|
|
19
|
+
{ name: 'Mät ditt tempo', text: 'Använd ett personligt uppmätt tempo i stället för ett generellt löfte om snabbläsning.' },
|
|
20
|
+
{ name: 'Ställ in veckorytmen', text: 'Ange minuter per session, sessioner per läsdag och läsdagar per vecka.' },
|
|
21
|
+
{ name: 'Kontrollera deadlinen', text: 'Se det beräknade slutet, dagstempot och milstolparna och spara marginal för verkliga avbrott.' },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const seo: ToolLocaleContent<BookReadingUI>['seo'] = [
|
|
25
|
+
{ type: 'title', text: 'Planera lästempo och deadline för en bok', level: 2 },
|
|
26
|
+
{ type: 'paragraph', html: 'Förvandla ett mål i sidor eller ord till en realistisk läskalender. Ange mängden, din uppmätta hastighet, sessionernas längd och frekvens samt datumet då du vill vara klar.' },
|
|
27
|
+
{ type: 'title', text: 'Använd din egen läshastighet', level: 2 },
|
|
28
|
+
{ type: 'paragraph', html: 'Ett personligt tidstest är mer användbart än ett allmänt löfte om snabbläsning. Testa ett representativt stycke och sänk tempot när boken är tät, obekant eller kräver noggrann läsning.' },
|
|
29
|
+
{ type: 'list', items: ['Välj Sidor för en tryckt bok eller Ord för digital text och uppgifter.', 'Ta tiden på ett representativt stycke och använd ett tempo som du kan hålla med önskad förståelse.', 'Ställ in sessionernas längd, sessioner per dag och läsdagar per vecka.', 'Använd marginalen för missade dagar i stället för att göra varje session obligatorisk.', 'Jämför planen med dina faktiska framsteg efter de första sessionerna.'] },
|
|
30
|
+
{ type: 'title', text: 'Läs deadlinekartan', level: 2 },
|
|
31
|
+
{ type: 'paragraph', html: 'Tid som behövs är den uppskattade arbetsmängden i ditt valda tempo. Nödvändiga läsdagar omvandlar den till sessioner. Tillgängliga läsdagar uppskattar kapaciteten mellan start och deadline utifrån läsdagar per vecka.' },
|
|
32
|
+
{ type: 'table', headers: ['Resultat', 'Vad det visar'], rows: [['Tempo till deadline', 'Mängd för varje tillgänglig läsdag'], ['Mål per session', 'Mängd att sikta på i en planerad session'], ['Utrymme för att komma ikapp', 'Sessioner som återstår efter planeringen'], ['Beräknat slut', 'Slutdatum om veckorytmen förblir konstant']] },
|
|
33
|
+
{ type: 'tip', title: 'En kalender mäter inte förståelse', html: 'Planen mäter inte förståelse, svårighet, trötthet, omläsning eller avbrott. Använd den som ett första åtagande och uppdatera den med ditt verkliga tempo.' },
|
|
34
|
+
{ type: 'title', text: 'När planen är knapp eller för sen', level: 2 },
|
|
35
|
+
{ type: 'paragraph', html: 'Det finns marginal när de uppskattade sessionerna ryms med reserv. En knapp plan behöver en flexibel ikappsession. Om slutet hamnar efter deadlinen kan du förlänga perioden, lägga till sessioner eller minska målet.' },
|
|
36
|
+
{ type: 'tip', title: 'Mät innan du optimerar', html: 'Efter två eller tre sessioner jämför du planerade och faktiska framsteg. Om skillnaden består, ändra hastigheten i stället för att tvinga fram en alltför optimistisk kalender.' },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const schemas: [WithContext<SoftwareApplication>, WithContext<FAQPage>, WithContext<HowTo>] = [
|
|
40
|
+
{ '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: 'Planera lästempo och deadline för en bok', applicationCategory: 'EducationalApplication', operatingSystem: 'Any', offers: { '@type': 'Offer', price: '0', priceCurrency: 'SEK' } },
|
|
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: 'Planera en läsdeadline', step: howTo.map((item) => ({ '@type': 'HowToStep', name: item.name, text: item.text })) },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export const content: ToolLocaleContent<BookReadingUI> = { slug: 'planerare-for-lasningstid-och-bokdeadline', title: 'Planera lästempo och deadline för en bok', description: 'Planera ett realistiskt lästempo, ett sessionschema och ett slutdatum utifrån ditt mål i sidor eller ord.', ui, seo, faq, bibliography, howTo, schemas: schemas as unknown as Record<string, unknown>[] };
|
|
@@ -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: 'Okuma planını oluştur', modeLabel: 'Sayım türü', pagesModeLabel: 'Sayfa', wordsModeLabel: 'Kelime', amountLabel: 'Okunacak toplam', speedLabel: 'Okuma hızınız', difficultyLabel: 'Metin zorluğu', difficultyLightLabel: 'Kolay · 1.2x', difficultyStandardLabel: 'Standart · 1.0x', difficultyDenseLabel: 'Yoğun · 0.8x', wordsPerPageLabel: 'Sayfa başına tahmini kelime', wordsPerPageHint: 'Yaygın aralık: 100 ile 500', pagesPerHourLabel: 'sayfa/saat', wordsPerMinuteLabel: 'kelime/dakika', startDateLabel: 'Başlangıç tarihi', deadlineLabel: 'Son tarih', daysPerWeekLabel: 'Haftalık okuma günü', sessionsPerDayLabel: 'Okuma günü başına oturum', sessionMinutesLabel: 'Oturum süresi', minutesRangeLabel: '5 ile 240 dakika', hourShortLabel: 'sa', minuteShortLabel: 'dk', bufferShortLabel: 'eksik', bufferExtraLabel: 'ekstra', speedTestLabel: 'Okuma hızınızı ölçün', speedTestHint: 'Kısa ve temsilî bir metin kullanın', speedTestPromptLabel: 'Okunacak metin', speedTestPlaceholder: 'Buraya bir cümle veya kısa bir metin yapıştırın ya da yazın...', speedTestStartLabel: 'Sayacı başlat', speedTestFinishLabel: 'Bitirdim', speedTestElapsedLabel: 'Geçen süre', speedTestRateLabel: 'Tahmini tempo', speedTestSecondsLabel: 'sn', speedTestWpmLabel: 'KDM', speedTestUseLabel: 'Bu KDM değerini Kelime modunda kullan', speedTestIdle: 'Bir metin girin ve hazır olduğunuzda başlayın.', speedTestRunning: 'Sayaç çalışıyor. Doğal temponuzda okuyun.', speedTestFinished: 'Test tamamlandı. Bu ilk tahmindir, sabit bir ölçüt değildir.', speedTestNeedsText: 'Sayacı başlatmadan önce bir metin ekleyin.', speedTestNoWords: 'Kelime ölçülmedi', presetLabel: 'Hızlı ritim', presetSteadyLabel: 'Düzenli hafta', presetWeekendLabel: 'Hafta sonu odağı', presetAcademicLabel: 'Çalışma temposu', planLabel: 'Okuma haritanız', timeNeededLabel: 'Gereken süre', readingDaysLabel: 'Gereken okuma günü', capacityLabel: 'Kullanılabilir okuma günü', dailyPaceLabel: 'Son tarihe kadar tempo', estimatedWordsLabel: 'Tahmini kelime', wordsShortLabel: 'kelime', sessionTargetLabel: 'Oturum hedefi', catchUpLabel: 'Telafi payı', finishDateLabel: 'Tahmini bitiş', milestoneLabel: 'Kilometre taşları', timelineLabel: 'Okuma kilometre taşları zaman çizelgesi', calendarHint: 'Kilometre taşları planlama işaretleridir. Gerçek hayat planı böldüğünde son tarihi değiştirin veya temponuzu ayarlayın.', exportCalendarLabel: 'Kilometre taşlarını indir (.ics)', exportCalendarTitle: 'Okuma kilometre taşları', milestoneStart: 'Başlangıç', milestoneQuarter: '25%', milestoneHalf: 'Yarı', milestoneThreeQuarter: '75%', milestoneFinish: 'Bitiş', statusOnTrack: 'Pay var', statusTight: 'Sınırda', statusLate: 'Son tarihten sonra', statusInvalid: 'Tarihleri kontrol edin', statusOnTrackDetail: 'Kullanılabilir oturumlarınız kaçırılan günler veya daha yavaş bir bölüm için pay bırakıyor.', statusTightDetail: 'Plan sığıyor ancak yedek pay az. Telafi için bir oturumu esnek bırakın.', statusLateDetail: 'Mevcut tempo son tarihe kadar yetmiyor. Oturum ekleyin, süreyi uzatın veya hedefi azaltın.', statusInvalidDetail: 'Geçerli bir başlangıç tarihi ve başlangıçtan sonraki bir son tarih seçin.', sourceLabel: 'Bu bir tempo tahminidir; anlama garantisi veya sabit bir okuma hızı değildir. Kendi temponuzu temsilî bir metinle ölçün.',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const faq = [
|
|
11
|
+
{ question: 'Okuma hızımı nasıl seçmeliyim?', answer: 'Temsilî bir metni okuma sürenizi ölçün, sayfa veya kelime sayısını hesaplayın ve gözlenen tempoyu kullanın. Yoğun, teknik, bilinmeyen veya çok açıklamalı kitaplar genellikle daha fazla zaman ister.' },
|
|
12
|
+
{ question: 'Plan kitabı anlayacağımı garanti eder mi?', answer: 'Hayır. Kendi temponuzu ve takviminizi bir tarih tahminine dönüştürür. Akıcı okumak anlamakla aynı değildir; ayrıntılı çalışma gereken yerlerde yavaşlayın.' },
|
|
13
|
+
{ question: 'Telafi payı ne anlama gelir?', answer: 'Tahmini okuma süresini son tarihe yerleştirdikten sonra kalan oturum sayısıdır. Negatif değer, mevcut ritmin yeterli kapasite sunmadığını gösterir.' },
|
|
14
|
+
{ question: 'Neden sayfa veya kelimeyle plan yapabilirim?', answer: 'Basılı kitaplarda sayfalar pratiktir; dijital metinlerde veya ödevlerde kelime sayısı daha kolay karşılaştırılır. Düzenli takip edebileceğiniz birimi seçin.' },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const howTo = [
|
|
18
|
+
{ name: 'Okuma birimini seçin', text: 'Kitabı sayfa veya kelime olarak sayıp toplamı girin.' },
|
|
19
|
+
{ name: 'Kendi temponuzu ölçün', text: 'Genel bir hızlı okuma iddiası yerine ölçtüğünüz kişisel hızı kullanın.' },
|
|
20
|
+
{ name: 'Haftalık ritmi belirleyin', text: 'Oturum süresini, okuma günü başına oturum sayısını ve haftalık okuma günlerini ayarlayın.' },
|
|
21
|
+
{ name: 'Son tarihi inceleyin', text: 'Tahmini bitişi, günlük tempoyu ve kilometre taşlarını kontrol edip gerçek kesintiler için pay bırakın.' },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const seo: ToolLocaleContent<BookReadingUI>['seo'] = [
|
|
25
|
+
{ type: 'title', text: 'Okuma temponuzu ve kitap son tarihinizi planlayın', level: 2 },
|
|
26
|
+
{ type: 'paragraph', html: 'Sayfa veya kelime hedefinizi gerçekçi bir okuma takvimine dönüştürün. Miktarı, ölçtüğünüz okuma hızını, oturumların süresini ve sıklığını ve bitirmek istediğiniz tarihi girin.' },
|
|
27
|
+
{ type: 'title', text: 'Kendi okuma hızınızı kullanın', level: 2 },
|
|
28
|
+
{ type: 'paragraph', html: 'Kişisel bir süre ölçümü, genel hızlı okuma iddialarından daha kullanışlıdır. Temsilî bir metni test edin; kitap yoğunsa, bilinmiyorsa veya dikkatli okuma gerektiriyorsa temponuzu düşürün.' },
|
|
29
|
+
{ type: 'list', items: ['Basılı kitap için Sayfa, dijital metin veya ödev için Kelime seçin.', 'Temsilî bir bölümü ölçün ve gereken anlama düzeyini koruyabildiğiniz tempoyu kullanın.', 'Oturum süresini, günlük oturum sayısını ve haftalık okuma günlerini ayarlayın.', 'Her oturumu zorunlu saymak yerine kaçırılan günler için telafi payını kullanın.', 'İlk birkaç oturumdan sonra planı gerçek ilerlemenizle karşılaştırın.'] },
|
|
30
|
+
{ type: 'title', text: 'Son tarih haritasını okuyun', level: 2 },
|
|
31
|
+
{ type: 'paragraph', html: 'Gereken süre, seçtiğiniz tempodaki tahmini çalışmadır. Gereken günler bunu oturumlara çevirir. Kullanılabilir günler, haftalık okuma günü sayısına göre başlangıç ve son tarih arasındaki kapasiteyi tahmin eder.' },
|
|
32
|
+
{ type: 'table', headers: ['Sonuç', 'Ne anlatır'], rows: [['Son tarihe kadar tempo', 'Her kullanılabilir okuma gününde tamamlanacak miktar'], ['Oturum hedefi', 'Tek bir planlı oturumda ulaşılacak miktar'], ['Telafi payı', 'Tahmini çalışma yerleştirildikten sonra kalan oturumlar'], ['Tahmini bitiş', 'Haftalık ritim korunursa bitiş tarihi']] },
|
|
33
|
+
{ type: 'tip', title: 'Takvim anlama düzeyini ölçmez', html: 'Plan anlama, zorluk, yorgunluk, yeniden okuma veya kesintileri ölçmez. İlk taahhüt olarak kullanın ve gerçek temponuzla güncelleyin.' },
|
|
34
|
+
{ type: 'title', text: 'Plan sıkışık veya gecikmişse', level: 2 },
|
|
35
|
+
{ type: 'paragraph', html: 'Tahmini oturumlar yedek kapasiteyle sığıyorsa pay vardır. Sınırdaki plan için esnek bir telafi oturumu bırakın. Bitiş son tarihten sonraysa süreyi uzatın, oturum ekleyin veya hedefi azaltın.' },
|
|
36
|
+
{ type: 'tip', title: 'İyileştirmeden önce ölçün', html: 'İki veya üç oturumdan sonra planlanan ilerlemeyi gerçek ilerlemeyle karşılaştırın. Fark sürerse aşırı iyimser bir takvimi zorlamak yerine hızı değiştirin.' },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const schemas: [WithContext<SoftwareApplication>, WithContext<FAQPage>, WithContext<HowTo>] = [
|
|
40
|
+
{ '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: 'Okuma temponuzu ve kitap son tarihinizi planlayın', applicationCategory: 'EducationalApplication', operatingSystem: 'Any', offers: { '@type': 'Offer', price: '0', priceCurrency: 'TRY' } },
|
|
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: 'Okuma son tarihi planlamak', step: howTo.map((item) => ({ '@type': 'HowToStep', name: item.name, text: item.text })) },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export const content: ToolLocaleContent<BookReadingUI> = { slug: 'kitap-okuma-suresi-ve-son-tarih-planlayici', title: 'Okuma temponuzu ve kitap son tarihinizi planlayın', description: 'Sayfa veya kelime hedefinize göre gerçekçi bir okuma temposu, oturum ritmi ve bitiş tarihi planlayın.', ui, seo, faq, bibliography, howTo, schemas: schemas as unknown as Record<string, unknown>[] };
|
|
@@ -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: '制定阅读计划', modeLabel: '计数方式', pagesModeLabel: '页数', wordsModeLabel: '字数', amountLabel: '阅读总量', speedLabel: '你的阅读速度', difficultyLabel: '文本难度', difficultyLightLabel: '轻松 · 1.2x', difficultyStandardLabel: '标准 · 1.0x', difficultyDenseLabel: '密集 · 0.8x', wordsPerPageLabel: '每页估计字数', wordsPerPageHint: '常见范围: 100 到 500', pagesPerHourLabel: '页/小时', wordsPerMinuteLabel: '字/分钟', startDateLabel: '开始日期', deadlineLabel: '截止日期', daysPerWeekLabel: '每周阅读天数', sessionsPerDayLabel: '每天阅读场次', sessionMinutesLabel: '每场分钟数', minutesRangeLabel: '5 到 240 分钟', hourShortLabel: '小时', minuteShortLabel: '分钟', bufferShortLabel: '不足', bufferExtraLabel: '余量', speedTestLabel: '测量你的阅读速度', speedTestHint: '使用一段简短且有代表性的文字', speedTestPromptLabel: '要阅读的文字', speedTestPlaceholder: '在这里粘贴或输入一个句子或短段落...', speedTestStartLabel: '开始计时', speedTestFinishLabel: '我读完了', speedTestElapsedLabel: '已用时间', speedTestRateLabel: '估计速度', speedTestSecondsLabel: '秒', speedTestWpmLabel: '字/分钟', speedTestUseLabel: '在字数模式中使用这个速度', speedTestIdle: '输入一段文字,准备好后开始计时。', speedTestRunning: '计时进行中。请按自然速度阅读。', speedTestFinished: '测试完成。这是第一次估计,不是固定标准。', speedTestNeedsText: '开始计时前请先添加文字。', speedTestNoWords: '没有测量到字数', presetLabel: '快速节奏', presetSteadyLabel: '稳定一周', presetWeekendLabel: '周末集中', presetAcademicLabel: '学习节奏', planLabel: '你的阅读地图', timeNeededLabel: '所需时间', readingDaysLabel: '所需阅读天数', capacityLabel: '可用阅读天数', dailyPaceLabel: '截止日前的进度', estimatedWordsLabel: '估计字数', wordsShortLabel: '字', sessionTargetLabel: '每场目标', catchUpLabel: '补进度余量', finishDateLabel: '预计完成日期', milestoneLabel: '里程碑', timelineLabel: '阅读里程碑时间线', calendarHint: '里程碑是计划参考点。如果现实安排打断了计划,可以调整截止日期或阅读节奏。', exportCalendarLabel: '下载里程碑 (.ics)', exportCalendarTitle: '阅读里程碑', milestoneStart: '开始', milestoneQuarter: '25%', milestoneHalf: '一半', milestoneThreeQuarter: '75%', milestoneFinish: '完成', statusOnTrack: '有余量', statusTight: '刚好够用', statusLate: '超过截止日期', statusInvalid: '检查日期', statusOnTrackDetail: '可用场次为漏读的日期或较慢的章节留下了余量。', statusTightDetail: '计划可以完成,但余量很少。留出一场用于补进度。', statusLateDetail: '按当前节奏无法在截止日期前完成。请增加场次、延长期限或减少目标。', statusInvalidDetail: '请选择有效的开始日期,以及不早于开始日期的截止日期。', sourceLabel: '这是阅读节奏的估计,不保证理解程度,也不是固定阅读速度。请用有代表性的文字测量自己的速度。',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const faq = [
|
|
11
|
+
{ question: '如何选择阅读速度?', answer: '先测量阅读一段有代表性文字所需的时间,再数出页数或字数,使用实际测得的速度。密集、技术性强、不熟悉或注释很多的书通常需要更多时间。' },
|
|
12
|
+
{ question: '这个计划能保证我理解书的内容吗?', answer: '不能。它只是把你的阅读速度和时间安排转换成日历估计。阅读流畅度不等于理解程度,遇到需要精读的内容时应当放慢速度。' },
|
|
13
|
+
{ question: '补进度余量是什么意思?', answer: '这是把估计阅读时间安排在截止日期前之后剩余的场次数。如果数值为负,说明当前节奏的容量不足。' },
|
|
14
|
+
{ question: '为什么可以按页数或字数计划?', answer: '纸质书按页数更方便,数字文本或阅读作业按字数更容易比较。请选择你能持续记录的单位。' },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const howTo = [
|
|
18
|
+
{ name: '选择阅读单位', text: '按页数或字数计算书籍,并输入总量。' },
|
|
19
|
+
{ name: '测量自己的速度', text: '使用实际测得的个人速度,而不是笼统的快速阅读承诺。' },
|
|
20
|
+
{ name: '设置每周节奏', text: '设置每场时长、每天场次和每周阅读天数。' },
|
|
21
|
+
{ name: '检查截止日期', text: '查看预计完成日期、每日进度和里程碑,并为现实中的中断留出余量。' },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const seo: ToolLocaleContent<BookReadingUI>['seo'] = [
|
|
25
|
+
{ type: 'title', text: '规划阅读速度和书籍截止日期', level: 2 },
|
|
26
|
+
{ type: 'paragraph', html: '把页数或字数目标转换成现实的阅读日程。输入阅读总量、测得的速度、场次时长和频率,以及希望完成阅读的日期。' },
|
|
27
|
+
{ type: 'title', text: '使用自己的阅读速度', level: 2 },
|
|
28
|
+
{ type: 'paragraph', html: '个人计时结果比笼统的快速阅读说法更有参考价值。请用有代表性的文字测试;如果书籍密集、不熟悉或需要精读,就应当降低速度。' },
|
|
29
|
+
{ type: 'list', items: ['纸质书选择页数,数字文本或作业选择字数。', '测量有代表性的文字,并使用能保持所需理解程度的速度。', '设置场次时长、每天场次和每周阅读天数。', '使用余量应对漏读日期,不要把每场都当成必须完成的任务。', '完成几场阅读后,把计划与实际进度进行比较。'] },
|
|
30
|
+
{ type: 'title', text: '如何阅读截止日期地图', level: 2 },
|
|
31
|
+
{ type: 'paragraph', html: '所需时间是按所选速度估计的阅读工作量。所需阅读天数会把它转换成场次。可用阅读天数则根据每周阅读天数,估算开始日期到截止日期之间的容量。' },
|
|
32
|
+
{ type: 'table', headers: ['结果', '含义'], rows: [['截止日前的进度', '每个可用阅读日需要完成的量'], ['每场目标', '一场计划阅读需要完成的量'], ['补进度余量', '安排估计工作量后剩余的场次'], ['预计完成日期', '保持每周节奏时的完成日期']] },
|
|
33
|
+
{ type: 'tip', title: '日历不能衡量理解程度', html: '计划不会测量理解、难度、疲劳、重读或中断。请把它当作第一次承诺,再根据实际速度更新。' },
|
|
34
|
+
{ type: 'title', text: '计划太紧或会延迟时', level: 2 },
|
|
35
|
+
{ type: 'paragraph', html: '如果估计场次安排后仍有备用容量,就说明有余量。刚好够用的计划最好保留一场用于补进度。如果完成日期超过截止日期,可以延长期限、增加场次、增加阅读天数或减少目标。' },
|
|
36
|
+
{ type: 'tip', title: '先测量,再优化', html: '完成两到三场后,比较计划进度和实际进度。如果差距持续存在,请调整速度,而不是强行遵守过于乐观的日历。' },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const schemas: [WithContext<SoftwareApplication>, WithContext<FAQPage>, WithContext<HowTo>] = [
|
|
40
|
+
{ '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: '规划阅读速度和书籍截止日期', applicationCategory: 'EducationalApplication', operatingSystem: 'Any', offers: { '@type': 'Offer', price: '0', priceCurrency: 'CNY' } },
|
|
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: '规划阅读截止日期', step: howTo.map((item) => ({ '@type': 'HowToStep', name: item.name, text: item.text })) },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export const content: ToolLocaleContent<BookReadingUI> = { slug: 'book-reading-time-deadline-planner', title: '规划阅读速度和书籍截止日期', description: '根据页数或字数目标,规划现实的阅读速度、阅读场次和完成日期。', ui, seo, faq, bibliography, howTo, schemas: schemas as unknown as Record<string, unknown>[] };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ToolDefinition } from '../../types';
|
|
2
|
+
import { bookReadingTimeDeadlinePlanner } from './entry';
|
|
3
|
+
|
|
4
|
+
export * from './entry';
|
|
5
|
+
|
|
6
|
+
export const BOOK_READING_TIME_DEADLINE_PLANNER_TOOL: ToolDefinition = {
|
|
7
|
+
entry: bookReadingTimeDeadlinePlanner,
|
|
8
|
+
Component: () => import('./component.astro'),
|
|
9
|
+
SEOComponent: () => import('./seo.astro'),
|
|
10
|
+
BibliographyComponent: () => import('./bibliography.astro'),
|
|
11
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { calculateReadingPlan, DEFAULT_INPUTS, normalizeInputs } from './logic';
|
|
3
|
+
|
|
4
|
+
describe('book reading time deadline planner', () => {
|
|
5
|
+
it('calculates a page plan from reading speed and sessions', () => {
|
|
6
|
+
const result = calculateReadingPlan(DEFAULT_INPUTS);
|
|
7
|
+
expect(result.minutesNeeded).toBeCloseTo(548.57, 1);
|
|
8
|
+
expect(result.sessionsNeeded).toBe(19);
|
|
9
|
+
expect(result.readingDaysNeeded).toBe(19);
|
|
10
|
+
expect(result.finishDate).toBe('2026-01-27');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('calculates words per minute plans without treating words as pages', () => {
|
|
14
|
+
const result = calculateReadingPlan({ ...DEFAULT_INPUTS, mode: 'words', amount: 30000, speed: 250, daysPerWeek: 7 });
|
|
15
|
+
expect(result.minutesNeeded).toBe(120);
|
|
16
|
+
expect(result.sessionsNeeded).toBe(4);
|
|
17
|
+
expect(result.dailyPace).toBe(968);
|
|
18
|
+
expect(result.estimatedWords).toBeNull();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('applies the difficulty factor to the observed speed', () => {
|
|
22
|
+
const standard = calculateReadingPlan(DEFAULT_INPUTS);
|
|
23
|
+
const dense = calculateReadingPlan({ ...DEFAULT_INPUTS, difficultyFactor: 0.8 });
|
|
24
|
+
expect(dense.minutesNeeded).toBeCloseTo(standard.minutesNeeded / 0.8, 1);
|
|
25
|
+
expect(dense.readingDaysNeeded).toBeGreaterThan(standard.readingDaysNeeded);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('estimates words from pages using the configured words-per-page value', () => {
|
|
29
|
+
const result = calculateReadingPlan({ ...DEFAULT_INPUTS, amount: 320, wordsPerPage: 300 });
|
|
30
|
+
expect(result.estimatedWords).toBe(96000);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('shows the catch-up buffer when capacity exceeds the plan', () => {
|
|
34
|
+
const result = calculateReadingPlan({ ...DEFAULT_INPUTS, amount: 100, sessionMinutes: 60, daysPerWeek: 7, deadline: '2026-01-10' });
|
|
35
|
+
expect(result.catchUpSessions).toBeGreaterThan(0);
|
|
36
|
+
expect(result.sessionsShort).toBe(0);
|
|
37
|
+
expect(result.status).toBe('on-track');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('marks a plan late when the deadline cannot hold the required sessions', () => {
|
|
41
|
+
const result = calculateReadingPlan({ ...DEFAULT_INPUTS, amount: 1000, deadline: '2026-01-03' });
|
|
42
|
+
expect(result.sessionsShort).toBeGreaterThan(0);
|
|
43
|
+
expect(result.status).toBe('late');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('handles invalid dates without producing a false on-track result', () => {
|
|
47
|
+
const result = calculateReadingPlan({ ...DEFAULT_INPUTS, startDate: '2026-02-01', deadline: '2026-01-01' });
|
|
48
|
+
expect(result.invalidInput).toBe(true);
|
|
49
|
+
expect(result.status).toBe('invalid');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('clamps numeric input to safe planning ranges', () => {
|
|
53
|
+
const result = normalizeInputs({ ...DEFAULT_INPUTS, amount: 0, speed: Number.NaN, difficultyFactor: 2, wordsPerPage: 99, sessionsPerDay: 99, daysPerWeek: 0 });
|
|
54
|
+
expect(result.amount).toBe(1);
|
|
55
|
+
expect(result.speed).toBe(35);
|
|
56
|
+
expect(result.sessionsPerDay).toBe(8);
|
|
57
|
+
expect(result.daysPerWeek).toBe(1);
|
|
58
|
+
expect(result.difficultyFactor).toBe(1.2);
|
|
59
|
+
expect(result.wordsPerPage).toBe(100);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
export type ReadingMode = 'pages' | 'words';
|
|
2
|
+
|
|
3
|
+
export interface BookReadingInputs {
|
|
4
|
+
mode: ReadingMode;
|
|
5
|
+
amount: number;
|
|
6
|
+
speed: number;
|
|
7
|
+
difficultyFactor: number;
|
|
8
|
+
wordsPerPage: number;
|
|
9
|
+
sessionMinutes: number;
|
|
10
|
+
sessionsPerDay: number;
|
|
11
|
+
daysPerWeek: number;
|
|
12
|
+
startDate: string;
|
|
13
|
+
deadline: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type ReadingStatus = 'on-track' | 'tight' | 'late' | 'invalid';
|
|
17
|
+
|
|
18
|
+
export interface BookReadingCalculation {
|
|
19
|
+
mode: ReadingMode;
|
|
20
|
+
amount: number;
|
|
21
|
+
speed: number;
|
|
22
|
+
difficultyFactor: number;
|
|
23
|
+
estimatedWords: number | null;
|
|
24
|
+
minutesNeeded: number;
|
|
25
|
+
sessionsNeeded: number;
|
|
26
|
+
readingDaysNeeded: number;
|
|
27
|
+
availableReadingDays: number;
|
|
28
|
+
capacitySessions: number;
|
|
29
|
+
dailyPace: number;
|
|
30
|
+
sessionTarget: number;
|
|
31
|
+
catchUpSessions: number;
|
|
32
|
+
sessionsShort: number;
|
|
33
|
+
finishDate: string;
|
|
34
|
+
milestoneDates: string[];
|
|
35
|
+
startDate: string;
|
|
36
|
+
deadline: string;
|
|
37
|
+
calendarDays: number;
|
|
38
|
+
status: ReadingStatus;
|
|
39
|
+
invalidInput: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const DEFAULT_INPUTS: BookReadingInputs = getDefaultInputs(new Date('2026-01-01T00:00:00Z'));
|
|
43
|
+
|
|
44
|
+
export function getDefaultInputs(referenceDate = new Date()): BookReadingInputs {
|
|
45
|
+
const startDate = toIsoDate(referenceDate);
|
|
46
|
+
return {
|
|
47
|
+
mode: 'pages', amount: 320, speed: 35, difficultyFactor: 1, wordsPerPage: 275, sessionMinutes: 30, sessionsPerDay: 1, daysPerWeek: 5,
|
|
48
|
+
startDate, deadline: addDays(startDate, 30),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function normalizeInputs(inputs: BookReadingInputs): BookReadingInputs {
|
|
53
|
+
return {
|
|
54
|
+
mode: inputs.mode === 'words' ? 'words' : 'pages',
|
|
55
|
+
amount: clampNumber(inputs.amount, 1, 10000000, 320),
|
|
56
|
+
speed: clampNumber(inputs.speed, 1, 5000, inputs.mode === 'words' ? 250 : 35),
|
|
57
|
+
difficultyFactor: clampNumber(inputs.difficultyFactor, 0.8, 1.2, 1),
|
|
58
|
+
wordsPerPage: clampInteger(inputs.wordsPerPage, 100, 500, 275),
|
|
59
|
+
sessionMinutes: clampNumber(inputs.sessionMinutes, 5, 240, 30),
|
|
60
|
+
sessionsPerDay: clampInteger(inputs.sessionsPerDay, 1, 8, 1),
|
|
61
|
+
daysPerWeek: clampInteger(inputs.daysPerWeek, 1, 7, 5),
|
|
62
|
+
startDate: isIsoDate(inputs.startDate) ? inputs.startDate : DEFAULT_INPUTS.startDate,
|
|
63
|
+
deadline: isIsoDate(inputs.deadline) ? inputs.deadline : DEFAULT_INPUTS.deadline,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function calculateReadingPlan(inputs: BookReadingInputs): BookReadingCalculation {
|
|
68
|
+
const invalidInput = !isIsoDate(inputs.startDate) || !isIsoDate(inputs.deadline) || inputs.deadline < inputs.startDate;
|
|
69
|
+
const normalized = normalizeInputs(inputs);
|
|
70
|
+
const effectiveSpeed = normalized.speed * normalized.difficultyFactor;
|
|
71
|
+
const minutesNeeded = normalized.mode === 'pages' ? (normalized.amount / effectiveSpeed) * 60 : normalized.amount / effectiveSpeed;
|
|
72
|
+
const sessionsNeeded = Math.ceil(minutesNeeded / normalized.sessionMinutes);
|
|
73
|
+
const readingDaysNeeded = Math.ceil(sessionsNeeded / normalized.sessionsPerDay);
|
|
74
|
+
const calendarDays = invalidInput ? 0 : daysBetweenInclusive(normalized.startDate, normalized.deadline);
|
|
75
|
+
const availableReadingDays = invalidInput ? 0 : Math.max(1, Math.ceil((calendarDays * normalized.daysPerWeek) / 7));
|
|
76
|
+
const capacitySessions = availableReadingDays * normalized.sessionsPerDay;
|
|
77
|
+
const dailyPace = Math.ceil(normalized.amount / Math.max(1, availableReadingDays));
|
|
78
|
+
const sessionTarget = Math.ceil(normalized.amount / Math.max(1, capacitySessions));
|
|
79
|
+
const catchUpSessions = Math.max(0, capacitySessions - sessionsNeeded);
|
|
80
|
+
const sessionsShort = Math.max(0, sessionsNeeded - capacitySessions);
|
|
81
|
+
const finishDate = addDays(normalized.startDate, Math.max(0, Math.ceil((readingDaysNeeded * 7) / normalized.daysPerWeek) - 1));
|
|
82
|
+
const milestoneDates = [0, 0.25, 0.5, 0.75, 1].map((fraction) => milestoneDate(normalized, sessionsNeeded, fraction));
|
|
83
|
+
const status = getStatus({ invalidInput, sessionsShort, catchUpSessions, capacitySessions });
|
|
84
|
+
const estimatedWords = normalized.mode === 'pages' ? normalized.amount * normalized.wordsPerPage : null;
|
|
85
|
+
return { mode: normalized.mode, amount: normalized.amount, speed: normalized.speed, difficultyFactor: normalized.difficultyFactor, estimatedWords, minutesNeeded, sessionsNeeded, readingDaysNeeded, availableReadingDays, capacitySessions, dailyPace, sessionTarget, catchUpSessions, sessionsShort, finishDate, milestoneDates, startDate: normalized.startDate, deadline: normalized.deadline, calendarDays, status, invalidInput };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getStatus(values: { invalidInput: boolean; sessionsShort: number; catchUpSessions: number; capacitySessions: number }): ReadingStatus {
|
|
89
|
+
if (values.invalidInput || values.capacitySessions === 0) return 'invalid';
|
|
90
|
+
if (values.sessionsShort > 0) return 'late';
|
|
91
|
+
if (values.catchUpSessions <= Math.max(1, Math.ceil(values.capacitySessions * 0.15))) return 'tight';
|
|
92
|
+
return 'on-track';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function milestoneDate(inputs: BookReadingInputs, sessionsNeeded: number, fraction: number): string {
|
|
96
|
+
if (fraction === 0) return inputs.startDate;
|
|
97
|
+
const sessionIndex = Math.max(1, Math.ceil(sessionsNeeded * fraction));
|
|
98
|
+
const readingDay = Math.max(1, Math.ceil(sessionIndex / inputs.sessionsPerDay));
|
|
99
|
+
return addDays(inputs.startDate, Math.max(0, Math.ceil((readingDay * 7) / inputs.daysPerWeek) - 1));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function clampNumber(value: number, min: number, max: number, fallback: number): number {
|
|
103
|
+
const safeValue = Number.isFinite(value) ? value : fallback;
|
|
104
|
+
return Math.min(max, Math.max(min, safeValue));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function clampInteger(value: number, min: number, max: number, fallback: number): number {
|
|
108
|
+
return Math.round(clampNumber(value, min, max, fallback));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isIsoDate(value: string): boolean {
|
|
112
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(new Date(`${value}T00:00:00Z`).getTime());
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function toIsoDate(date: Date): string {
|
|
116
|
+
return date.toISOString().slice(0, 10);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function addDays(date: string, amount: number): string {
|
|
120
|
+
const result = new Date(`${date}T00:00:00Z`);
|
|
121
|
+
result.setUTCDate(result.getUTCDate() + amount);
|
|
122
|
+
return toIsoDate(result);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function daysBetweenInclusive(startDate: string, endDate: string): number {
|
|
126
|
+
const start = new Date(`${startDate}T00:00:00Z`).getTime();
|
|
127
|
+
const end = new Date(`${endDate}T00:00:00Z`).getTime();
|
|
128
|
+
return Math.floor((end - start) / 86400000) + 1;
|
|
129
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { bookReadingTimeDeadlinePlanner } from './index';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
locale?: KnownLocale;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { locale = 'en' } = Astro.props as Props;
|
|
11
|
+
const loader = bookReadingTimeDeadlinePlanner.i18n[locale] || bookReadingTimeDeadlinePlanner.i18n.en;
|
|
12
|
+
const content = await loader?.();
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
{content ? <SEORenderer content={{ locale, sections: content.seo }} /> : null}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { calculateWordsPerMinute, countReadingWords } from './speed-test';
|
|
3
|
+
|
|
4
|
+
describe('reading speed test', () => {
|
|
5
|
+
it('counts words across repeated whitespace', () => {
|
|
6
|
+
expect(countReadingWords('Read a short passage.')).toBe(4);
|
|
7
|
+
expect(countReadingWords('')).toBe(0);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('converts elapsed reading time into rounded words per minute', () => {
|
|
11
|
+
expect(calculateWordsPerMinute(200, 60)).toBe(200);
|
|
12
|
+
expect(calculateWordsPerMinute(0, 60)).toBe(0);
|
|
13
|
+
expect(calculateWordsPerMinute(200, 0)).toBe(0);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function countReadingWords(text: string): number {
|
|
2
|
+
const trimmed = text.trim();
|
|
3
|
+
return trimmed ? trimmed.split(/\s+/).length : 0;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function calculateWordsPerMinute(wordCount: number, elapsedSeconds: number): number {
|
|
7
|
+
if (!Number.isFinite(wordCount) || !Number.isFinite(elapsedSeconds) || wordCount <= 0 || elapsedSeconds <= 0) return 0;
|
|
8
|
+
return Math.round((wordCount * 60) / elapsedSeconds);
|
|
9
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { BookReadingInputs } from './logic';
|
|
2
|
+
|
|
3
|
+
const STORAGE_KEY = 'book-reading-time-deadline-planner-state';
|
|
4
|
+
|
|
5
|
+
export interface StoredBookReadingState {
|
|
6
|
+
inputs: BookReadingInputs;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function loadBookReadingState(fallback: StoredBookReadingState): StoredBookReadingState {
|
|
10
|
+
try {
|
|
11
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
12
|
+
if (!raw) return fallback;
|
|
13
|
+
const stored = JSON.parse(raw) as Partial<StoredBookReadingState>;
|
|
14
|
+
return { ...fallback, ...stored, inputs: { ...fallback.inputs, ...stored.inputs } };
|
|
15
|
+
} catch {
|
|
16
|
+
return fallback;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function saveBookReadingState(state: StoredBookReadingState): void {
|
|
21
|
+
try {
|
|
22
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
|
23
|
+
} catch {}
|
|
24
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export interface BookReadingUI {
|
|
2
|
+
[key: string]: string;
|
|
3
|
+
setupLabel: string;
|
|
4
|
+
modeLabel: string;
|
|
5
|
+
pagesModeLabel: string;
|
|
6
|
+
wordsModeLabel: string;
|
|
7
|
+
amountLabel: string;
|
|
8
|
+
speedLabel: string;
|
|
9
|
+
difficultyLabel: string;
|
|
10
|
+
difficultyLightLabel: string;
|
|
11
|
+
difficultyStandardLabel: string;
|
|
12
|
+
difficultyDenseLabel: string;
|
|
13
|
+
wordsPerPageLabel: string;
|
|
14
|
+
wordsPerPageHint: string;
|
|
15
|
+
pagesPerHourLabel: string;
|
|
16
|
+
wordsPerMinuteLabel: string;
|
|
17
|
+
startDateLabel: string;
|
|
18
|
+
deadlineLabel: string;
|
|
19
|
+
daysPerWeekLabel: string;
|
|
20
|
+
sessionsPerDayLabel: string;
|
|
21
|
+
sessionMinutesLabel: string;
|
|
22
|
+
minutesRangeLabel: string;
|
|
23
|
+
hourShortLabel: string;
|
|
24
|
+
minuteShortLabel: string;
|
|
25
|
+
bufferShortLabel: string;
|
|
26
|
+
bufferExtraLabel: string;
|
|
27
|
+
speedTestLabel: string;
|
|
28
|
+
speedTestHint: string;
|
|
29
|
+
speedTestPromptLabel: string;
|
|
30
|
+
speedTestPlaceholder: string;
|
|
31
|
+
speedTestStartLabel: string;
|
|
32
|
+
speedTestFinishLabel: string;
|
|
33
|
+
speedTestElapsedLabel: string;
|
|
34
|
+
speedTestRateLabel: string;
|
|
35
|
+
speedTestSecondsLabel: string;
|
|
36
|
+
speedTestWpmLabel: string;
|
|
37
|
+
speedTestUseLabel: string;
|
|
38
|
+
speedTestIdle: string;
|
|
39
|
+
speedTestRunning: string;
|
|
40
|
+
speedTestFinished: string;
|
|
41
|
+
speedTestNeedsText: string;
|
|
42
|
+
speedTestNoWords: string;
|
|
43
|
+
presetLabel: string;
|
|
44
|
+
presetSteadyLabel: string;
|
|
45
|
+
presetWeekendLabel: string;
|
|
46
|
+
presetAcademicLabel: string;
|
|
47
|
+
planLabel: string;
|
|
48
|
+
timeNeededLabel: string;
|
|
49
|
+
readingDaysLabel: string;
|
|
50
|
+
capacityLabel: string;
|
|
51
|
+
dailyPaceLabel: string;
|
|
52
|
+
sessionTargetLabel: string;
|
|
53
|
+
catchUpLabel: string;
|
|
54
|
+
finishDateLabel: string;
|
|
55
|
+
milestoneLabel: string;
|
|
56
|
+
timelineLabel: string;
|
|
57
|
+
calendarHint: string;
|
|
58
|
+
milestoneStart: string;
|
|
59
|
+
milestoneQuarter: string;
|
|
60
|
+
milestoneHalf: string;
|
|
61
|
+
milestoneThreeQuarter: string;
|
|
62
|
+
milestoneFinish: string;
|
|
63
|
+
estimatedWordsLabel: string;
|
|
64
|
+
wordsShortLabel: string;
|
|
65
|
+
exportCalendarLabel: string;
|
|
66
|
+
exportCalendarTitle: string;
|
|
67
|
+
statusOnTrack: string;
|
|
68
|
+
statusTight: string;
|
|
69
|
+
statusLate: string;
|
|
70
|
+
statusInvalid: string;
|
|
71
|
+
statusOnTrackDetail: string;
|
|
72
|
+
statusTightDetail: string;
|
|
73
|
+
statusLateDetail: string;
|
|
74
|
+
statusInvalidDetail: string;
|
|
75
|
+
sourceLabel: string;
|
|
76
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const validationEvidence = {
|
|
2
|
+
reviewedAt: '2026-08-31',
|
|
3
|
+
reviewer: { name: 'jjlmoya-utils-books team', expertise: 'Reading pace planning and unit conversion' },
|
|
4
|
+
jurisdiction: ['INTL'],
|
|
5
|
+
methodology: 'The planner converts a word or page target into estimated reading time, adjusts observed speed with a difficulty factor, estimates page words from the configured value, and exports all-day milestone markers.',
|
|
6
|
+
version: '1.0',
|
|
7
|
+
effectiveFrom: '2026-08-31',
|
|
8
|
+
sources: [
|
|
9
|
+
{ title: 'Allegheny College. College Level Reading', url: 'https://sites.allegheny.edu/academic-success/academic-resources/reading/', accessedAt: '2026-08-31', primary: true },
|
|
10
|
+
{ title: 'Ministerio de Educación y Formación Profesional. Marco teórico de lectura PISA 2018', url: 'https://www.educacionfpydeportes.gob.es/dam/jcr%3A2f1081a1-c1e4-4799-8a49-9bc589724ca4/marco%20teorico%20lectura%202018_esp_ESP.pdf', accessedAt: '2026-08-31', primary: true },
|
|
11
|
+
{ title: 'Rayner, Schotter, Masson, Potter and Treiman. So much to read, so little time', url: 'https://profiles.wustl.edu/en/publications/so-much-to-read-so-little-time-how-do-we-read-and-can-speed-readi/', accessedAt: '2026-08-31', primary: true },
|
|
12
|
+
],
|
|
13
|
+
referenceCases: [
|
|
14
|
+
{ name: 'Page estimate', input: { pages: 320, wordsPerPage: 275 }, expected: { words: 88000 }, tolerance: 0, sourceUrl: 'https://sites.allegheny.edu/academic-success/academic-resources/reading/' },
|
|
15
|
+
{ name: 'Word timing', input: { words: 30000, wordsPerMinute: 250 }, expected: { minutes: 120 }, tolerance: 0, sourceUrl: 'https://profiles.wustl.edu/en/publications/so-much-to-read-so-little-time-how-do-we-read-and-can-speed-readi/' },
|
|
16
|
+
{ name: 'Difficulty factor', input: { minutesAtStandard: 120, difficultyFactor: 0.8 }, expected: { minutes: 150 }, tolerance: 0, sourceUrl: 'https://www.educacionfpydeportes.gob.es/dam/jcr%3A2f1081a1-c1e4-4799-8a49-9bc589724ca4/marco%20teorico%20lectura%202018_esp_ESP.pdf' },
|
|
17
|
+
],
|
|
18
|
+
limitations: ['Words per page is an editable estimate, not a measurement of the specific edition.', 'Difficulty factor is a planning adjustment, not a reading-comprehension score.', 'The calendar does not measure understanding, fatigue or interruptions.'],
|
|
19
|
+
} as const;
|
package/src/tools.ts
CHANGED
|
@@ -2,5 +2,6 @@ export { ALL_ENTRIES } from './entries';
|
|
|
2
2
|
import type { ToolDefinition } from './types';
|
|
3
3
|
import { BOOK_PAGINATION_AND_SPINE_CALCULATOR_TOOL } from './tool/book-pagination-and-spine-calculator';
|
|
4
4
|
import { BOOK_INTERIOR_MARGIN_AND_GUTTER_PLANNER_TOOL } from './tool/book-interior-margin-and-gutter-planner';
|
|
5
|
+
import { BOOK_READING_TIME_DEADLINE_PLANNER_TOOL } from './tool/book-reading-time-deadline-planner';
|
|
5
6
|
|
|
6
|
-
export const ALL_TOOLS: ToolDefinition[] = [BOOK_PAGINATION_AND_SPINE_CALCULATOR_TOOL, BOOK_INTERIOR_MARGIN_AND_GUTTER_PLANNER_TOOL];
|
|
7
|
+
export const ALL_TOOLS: ToolDefinition[] = [BOOK_PAGINATION_AND_SPINE_CALCULATOR_TOOL, BOOK_INTERIOR_MARGIN_AND_GUTTER_PLANNER_TOOL, BOOK_READING_TIME_DEADLINE_PLANNER_TOOL];
|