@jjlmoya/utils-nature 1.26.0 → 1.28.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 (38) hide show
  1. package/package.json +1 -1
  2. package/src/entries.ts +4 -1
  3. package/src/index.ts +2 -0
  4. package/src/tests/tool_validation.test.ts +2 -2
  5. package/src/tool/seedStratificationCalendar/bibliography.astro +9 -0
  6. package/src/tool/seedStratificationCalendar/bibliography.ts +16 -0
  7. package/src/tool/seedStratificationCalendar/calendar-actions.ts +66 -0
  8. package/src/tool/seedStratificationCalendar/component.astro +62 -0
  9. package/src/tool/seedStratificationCalendar/controller.ts +227 -0
  10. package/src/tool/seedStratificationCalendar/dom-views.ts +130 -0
  11. package/src/tool/seedStratificationCalendar/entry.ts +29 -0
  12. package/src/tool/seedStratificationCalendar/evaluator.ts +32 -0
  13. package/src/tool/seedStratificationCalendar/i18n/de.ts +35 -0
  14. package/src/tool/seedStratificationCalendar/i18n/en.ts +149 -0
  15. package/src/tool/seedStratificationCalendar/i18n/es.ts +35 -0
  16. package/src/tool/seedStratificationCalendar/i18n/fr.ts +35 -0
  17. package/src/tool/seedStratificationCalendar/i18n/id.ts +35 -0
  18. package/src/tool/seedStratificationCalendar/i18n/it.ts +35 -0
  19. package/src/tool/seedStratificationCalendar/i18n/ja.ts +35 -0
  20. package/src/tool/seedStratificationCalendar/i18n/ko.ts +35 -0
  21. package/src/tool/seedStratificationCalendar/i18n/nl.ts +35 -0
  22. package/src/tool/seedStratificationCalendar/i18n/pl.ts +35 -0
  23. package/src/tool/seedStratificationCalendar/i18n/pt.ts +35 -0
  24. package/src/tool/seedStratificationCalendar/i18n/ru.ts +35 -0
  25. package/src/tool/seedStratificationCalendar/i18n/sv.ts +35 -0
  26. package/src/tool/seedStratificationCalendar/i18n/tr.ts +35 -0
  27. package/src/tool/seedStratificationCalendar/i18n/zh.ts +35 -0
  28. package/src/tool/seedStratificationCalendar/index.ts +11 -0
  29. package/src/tool/seedStratificationCalendar/localized.ts +70 -0
  30. package/src/tool/seedStratificationCalendar/logic.test.ts +53 -0
  31. package/src/tool/seedStratificationCalendar/logic.ts +198 -0
  32. package/src/tool/seedStratificationCalendar/profile-labels.ts +18 -0
  33. package/src/tool/seedStratificationCalendar/profile-select.ts +43 -0
  34. package/src/tool/seedStratificationCalendar/seed-stratification-calendar.css +558 -0
  35. package/src/tool/seedStratificationCalendar/seo.astro +10 -0
  36. package/src/tool/seedStratificationCalendar/storage.ts +27 -0
  37. package/src/tool/seedStratificationCalendar/ui.ts +66 -0
  38. package/src/tools.ts +3 -1
@@ -0,0 +1,35 @@
1
+ import { createSeedCalendarContent } from '../localized';
2
+
3
+ export const content = createSeedCalendarContent({
4
+ locale: 'pt',
5
+ title: 'Calendário de estratificação de sementes e datas de sementeira',
6
+ description: 'Crie um plano de preparação com datas, contando para trás a partir da sementeira ou para a frente a partir do dia em que as sementes estão prontas.',
7
+ faq: [
8
+ { question: 'O que calcula este calendário?', answer: 'Coloca a demolha, as fases húmidas quente e fria e a sementeira em dias concretos, contando para trás da data alvo ou para a frente do início.' },
9
+ { question: 'Porque existem dois modos de data?', answer: 'Use o modo de sementeira quando o dia está definido, ou o modo sementes prontas quando a preparação começa hoje e quer saber o futuro dia exato da sementeira.' },
10
+ { question: 'Posso alterar a duração e a temperatura?', answer: 'Sim. Escolha um perfil inicial e ajuste as fases à embalagem, a uma fonte especializada e às condições locais.' },
11
+ { question: 'O plano garante a germinação?', answer: 'Não. Organiza datas, mas idade, viabilidade, humidade, dormência, bolor e condições de sementeira também afetam o resultado.' },
12
+ ],
13
+ howTo: [
14
+ { name: 'Escolher um perfil', text: 'Selecione a espécie mais próxima para carregar fases iniciais adequadas.' },
15
+ { name: 'Escolher o modo de data', text: 'Conte para trás do dia de sementeira ou para a frente do dia em que as sementes estão prontas.' },
16
+ { name: 'Verificar as fases', text: 'Ajuste os dias de demolha, calor e frio e o intervalo de temperatura fria.' },
17
+ { name: 'Seguir a sequência', text: 'Comece na data inicial, avance por todas as fases e semeie no dia indicado.' },
18
+ ],
19
+ seoTitle: 'Planear a janela de preparação',
20
+ seoIntro: 'A dormência das sementes também é um problema de calendário. Se uma espécie precisa de um período frio e húmido, começar tarde pode fazer perder a janela de sementeira. Um tratamento demasiado longo consome tempo e pode favorecer bolor ou germinação precoce. Este calendário transforma a data alvo numa sequência de tarefas datadas.',
21
+ seoModes: 'Escolha uma data alvo para contar para trás, ou use o modo sementes prontas para contar para a frente desde o início da preparação. Cada fase tem as suas datas: a demolha liga ao tratamento seguinte e o último tratamento termina na véspera da sementeira. O resultado serve para etiquetar um recipiente ou criar lembretes.',
22
+ seoPhaseTitle: 'Como ler cada fase',
23
+ seoPhaseItems: ['<strong>Demolha:</strong> hidrate as sementes durante os dias indicados e não prolongue o tratamento sem uma razão específica.', '<strong>Quente:</strong> mantenha a semente húmida no intervalo quente quando o perfil o pedir.', '<strong>Frio:</strong> mantenha-a húmida e refrigerada, verificando regularmente bolor ou raízes precoces.', '<strong>Sementeira:</strong> passe a semente preparada para um substrato adequado na data alvo.'],
24
+ seoEvidenceTitle: 'Usar os perfis como ponto de partida',
25
+ seoEvidence: 'Os perfis não são receitas universais. A variedade, a origem, a idade da semente e as instruções da embalagem podem alterar a dormência. Se um fornecedor fiável recomendar outra duração, edite o perfil e registe a fonte usada em vez de fazer uma média entre recomendações contraditórias.',
26
+ seoTipTitle: 'Um calendário não é um teste de germinação',
27
+ seoTip: 'Registe o lote, o meio húmido, a temperatura e as datas. Se surgirem raízes durante o frio, semeie com cuidado. Se as sementes continuarem dormentes, não prolongue o tratamento automaticamente: confirme primeiro a orientação da espécie.',
28
+ seoChecksTitle: 'Verificações antes de começar',
29
+ seoChecks: ['Confirme que a data de sementeira combina com luz, geadas e local.', 'Etiquete o recipiente com espécie, início, fim e intervalo térmico.', 'Use um meio húmido mas arejado e verifique-o regularmente.', 'Guarde o plano junto da embalagem para poder rastrear o tratamento.'],
30
+ seoLimitsTitle: 'O que o resultado não pode dizer',
31
+ seoLimits: 'A ferramenta não identifica a dormência por fotografia, não testa a viabilidade e não substitui as instruções específicas de uma espécie. Também não prevê o clima local nem garante a germinação. Considere o resultado um cálculo organizado de datas e confirme a biologia numa fonte adequada.',
32
+ ui: {
33
+ labelProfile: 'Perfil da semente', labelMode: 'Modo do plano', labelModeSowing: 'Quero semear em', labelModeReady: 'As sementes estão prontas hoje', labelSowingDate: 'Data alvo da sementeira', labelReadyDate: 'Data de preparação', labelSoakDays: 'Dias de demolha', labelWarmDays: 'Dias quentes', labelColdDays: 'Dias frios', labelColdRange: 'Intervalo de frio', labelUnits: 'Unidades de temperatura', labelMetric: 'Métrico °C', labelImperial: 'Imperial °F', labelFrom: 'Temperatura mínima', labelTo: 'Temperatura máxima', labelGenerate: 'Atualizar calendário', labelReset: 'Repor perfil', labelAddCalendar: 'Adicionar ao meu calendário', labelShare: 'Copiar ligação', labelCopied: 'Ligação copiada', labelPrint: 'Imprimir etiqueta compacta', labelTechnique: 'Técnica', labelTodayStatus: 'Estado de hoje', labelDayOf: 'dia de', labelGantt: 'Linha de preparação', labelTimeline: 'Linha de preparação', labelNextAction: 'Fase atual', labelCalendarStarts: 'início do calendário', labelSowingDay: 'dia da sementeira', labelDuration: 'Preparação', labelDays: 'dias', labelTemperature: 'Temperatura', labelReady: 'Semear hoje', labelUpcoming: 'A seguir', labelActive: 'Em curso', labelComplete: 'Plano concluído', labelEditHint: 'Ajuste as sugestões com a embalagem ou uma fonte especializada. A última configuração fica guardada neste dispositivo.', labelWarm: 'Estratificação quente', labelCold: 'Estratificação fria', labelSoak: 'Demolhar sementes', labelSow: 'Semear sementes', noteApple: 'Período frio húmido depois de uma demolha curta', noteLavender: 'Período frio curto antes da sementeira primaveril', noteMilkweed: 'Fase quente seguida de fase fria', noteRose: 'Período frio longo depois da demolha', profileApple: 'Maçã · Malus domestica', profileLavender: 'Lavanda · Lavandula angustifolia', profileMilkweed: 'Serralha · Asclepias syriaca', profileRose: 'Rosa · Rosa spp.', profileStrawberry: 'Morangueiro · Fragaria × ananassa', profilePeach: 'Pessegueiro · Prunus persica', profileCherry: 'Cerejeira doce · Prunus avium', profilePear: 'Pereira · Pyrus communis', profilePoppy: 'Papoila · Papaver spp.', profileSunflower: 'Girassol · Helianthus annuus', profileTomato: 'Tomate · Solanum lycopersicum', profileBasil: 'Manjericão · Ocimum basilicum', instructionSoak: 'Mantenha as sementes hidratadas antes da fase seguinte.', instructionWarm: 'Conserve as sementes húmidas num local quente.', instructionCold: 'Mantenha as sementes húmidas e frias e verifique-as regularmente.', instructionSow: 'Semeie no substrato preparado.', emptyState: 'Defina as datas para criar uma linha de preparação.', warningDate: 'Escolha uma data de sementeira válida.', warningTemperature: 'Verifique duração e temperatura antes de atualizar o calendário.',
34
+ },
35
+ });
@@ -0,0 +1,35 @@
1
+ import { createSeedCalendarContent } from '../localized';
2
+
3
+ export const content = createSeedCalendarContent({
4
+ locale: 'ru',
5
+ title: 'Ка�"ендарь стратификации семян и сроков посева',
6
+ description: 'Создайте п�"ан подготовки с датами, считая назад от посева и�"и вперёд от дня, когда семена готовы.',
7
+ faq: [
8
+ { question: 'Что рассчитывает этот ка�"ендарь?', answer: 'Он размещает замачивание, тёп�"ую и хо�"одную в�"ажные фазы и посев по точным датам, считая от це�"евой даты и�"и от нача�"а подготовки.' },
9
+ { question: 'Зачем нужны два режима дат?', answer: 'Выберите режим даты посева, ес�"и день уже опреде�"ён, и�"и режим готовых семян, ес�"и подготовка начинается сегодня и нужна будущая дата посева.' },
10
+ { question: 'Можно �"и изменить д�"ите�"ьность и температуру?', answer: 'Да. Выберите профи�"ь как отправную точку и настройте этапы по упаковке семян, специа�"ьному источнику и местным ус�"овиям.' },
11
+ { question: 'Гарантирует �"и п�"ан всхожесть?', answer: 'Нет. Он организует даты, но возраст, жизнеспособность, в�"ажность, покой, п�"есень и ус�"овия посева также в�"ияют на резу�"ьтат.' },
12
+ ],
13
+ howTo: [
14
+ { name: 'Выберите профи�"ь семян', text: 'Выберите наибо�"ее подходящий вид, чтобы загрузить разумные нача�"ьные этапы.' },
15
+ { name: 'Выберите режим даты', text: 'Считайте назад от посева и�"и вперёд от дня, когда семена готовы.' },
16
+ { name: 'Проверьте этапы', text: 'Настройте дни замачивания, теп�"а и хо�"ода, а также хо�"одный диапазон температуры.' },
17
+ { name: 'С�"едуйте пос�"едовате�"ьности', text: 'Начните в нача�"ьную дату, пройдите все этапы и посейте в указанный день.' },
18
+ ],
19
+ seoTitle: 'П�"анируйте период подготовки',
20
+ seoIntro: 'Покой семян - это также вопрос сроков. Виды, которым нужен в�"ажный хо�"одный период, могут пропустить подходящее окно посева, ес�"и начать подготовку поздно. С�"ишком д�"ите�"ьная обработка тратит время и может уси�"ить риск п�"есени и�"и раннего прорастания. Ка�"ендарь превращает це�"евую дату в понятную пос�"едовате�"ьность действий.',
21
+ seoModes: 'Выберите це�"евую дату посева, чтобы считать назад, и�"и испо�"ьзуйте режим готовых семян, чтобы считать вперёд от нача�"а подготовки. У каждого этапа свои даты: замачивание переходит в с�"едующую обработку, а пос�"едняя обработка заканчивается за день до посева. Резу�"ьтат можно испо�"ьзовать д�"я этикетки и�"и напоминания.',
22
+ seoPhaseTitle: 'Как читать этапы',
23
+ seoPhaseItems: ['<strong>Замачивание:</strong> ув�"ажняйте семена указанное чис�"о дней и не прод�"евайте обработку без причины.', '<strong>Теп�"о:</strong> держите в�"ажные семена в тёп�"ом диапазоне, ес�"и это предусмотрено профи�"ем.', '<strong>Хо�"од:</strong> храните их в�"ажными и прох�"адными, регу�"ярно проверяя п�"есень и�"и ранние корни.', '<strong>Посев:</strong> перенесите подготов�"енные семена в подходящий субстрат в це�"евую дату.'],
24
+ seoEvidenceTitle: 'Испо�"ьзуйте профи�"и как отправную точку',
25
+ seoEvidence: 'Профи�"и не яв�"яются универса�"ьными рецептами. Сорт, происхождение, возраст семян и указания на упаковке могут изменить покой. Ес�"и надёжный поставщик рекомендует другой срок, измените профи�"ь и запишите источник, а не усредняйте противоречивые рекомендации.',
26
+ seoTipTitle: 'Ка�"ендарь не яв�"яется тестом на всхожесть',
27
+ seoTip: 'Запишите партию семян, в�"ажный материа�", температуру и даты. Ес�"и во время хо�"одного этапа появи�"ись корни, осторожно посейте семена. Ес�"и они остаются в покое, не прод�"евайте обработку автоматически - снача�"а проверьте рекомендации д�"я вида.',
28
+ seoChecksTitle: 'Проверьте перед нача�"ом',
29
+ seoChecks: ['Убедитесь, что дата посева подходит д�"я света, заморозков и места.', 'Подпишите контейнер: вид, нача�"о, конец и диапазон температуры.', 'Испо�"ьзуйте в�"ажный, но аэрируемый материа�" и регу�"ярно его проверяйте.', 'Храните п�"ан вместе с упаковкой, чтобы можно бы�"о восстановить ход обработки.'],
30
+ seoLimitsTitle: 'Чего резу�"ьтат не сообщает',
31
+ seoLimits: 'Инструмент не опреде�"яет покой семян по фотографии, не проверяет жизнеспособность и не заменяет инструкции д�"я конкретного вида. Он также не предсказывает местную погоду и не гарантирует всхожесть. Воспринимайте резу�"ьтат как организованный расчёт дат и проверяйте био�"огию по подходящему источнику.',
32
+ ui: {
33
+ labelProfile: 'Профи�"ь семян', labelMode: 'Режим п�"ана', labelModeSowing: 'Я хочу посеять', labelModeReady: 'Семена готовы сегодня', labelSowingDate: 'Це�"евая дата посева', labelReadyDate: 'Дата подготовки', labelSoakDays: 'Дни замачивания', labelWarmDays: 'Тёп�"ые дни', labelColdDays: 'Хо�"одные дни', labelColdRange: 'Диапазон хо�"одной температуры', labelUnits: 'Единицы температуры', labelMetric: 'Метрические °C', labelImperial: 'Имперские °F', labelFrom: 'Минима�"ьная температура', labelTo: 'Максима�"ьная температура', labelGenerate: 'Обновить ка�"ендарь', labelReset: 'Сбросить профи�"ь', labelAddCalendar: 'Добавить в ка�"ендарь', labelShare: 'Копировать ссы�"ку', labelCopied: 'Ссы�"ка скопирована', labelPrint: 'Печать компактной этикетки', labelTechnique: 'Метод', labelTodayStatus: 'Состояние сегодня', labelDayOf: 'день из', labelGantt: 'Шка�"а подготовки', labelTimeline: 'Шка�"а подготовки', labelNextAction: 'Текущий этап', labelCalendarStarts: 'нача�"о ка�"ендаря', labelSowingDay: 'день посева', labelDuration: 'Подготовка', labelDays: 'дней', labelTemperature: 'Температура', labelReady: 'Посеять сегодня', labelUpcoming: 'Предстоящий', labelActive: 'В процессе', labelComplete: 'П�"ан завершён', labelEditHint: 'Уточняйте пред�"ожения по упаковке и�"и специа�"изированному источнику. Пос�"едняя настройка сохраняется на этом устройстве.', labelWarm: 'Тёп�"ая стратификация', labelCold: 'Хо�"одная стратификация', labelSoak: 'Замочить семена', labelSow: 'Посеять семена', noteApple: 'В�"ажный хо�"од пос�"е короткого замачивания', noteLavender: 'Короткий хо�"од перед весенним посевом', noteMilkweed: 'Тёп�"ый этап, за которым с�"едует хо�"одный', noteRose: 'До�"гий хо�"од пос�"е замачивания', profileApple: 'Яб�"оня · Malus domestica', profileLavender: 'Лаванда · Lavandula angustifolia', profileMilkweed: 'Ваточник · Asclepias syriaca', profileRose: 'Роза · Rosa spp.', profileStrawberry: 'Зем�"яника · Fragaria × ananassa', profilePeach: 'Персик · Prunus persica', profileCherry: 'Черешня · Prunus avium', profilePear: 'Груша · Pyrus communis', profilePoppy: 'Мак · Papaver spp.', profileSunflower: 'Подсо�"нечник · Helianthus annuus', profileTomato: 'Томат · Solanum lycopersicum', profileBasil: 'Бази�"ик · Ocimum basilicum', instructionSoak: 'Сохраняйте семена ув�"ажнёнными до с�"едующего этапа.', instructionWarm: 'Держите в�"ажные семена в тёп�"ом месте.', instructionCold: 'Храните семена в�"ажными и хо�"одными, регу�"ярно проверяя их.', instructionSow: 'Посейте в подготов�"енный субстрат.', emptyState: 'Укажите даты, чтобы создать шка�"у подготовки.', warningDate: 'Выберите действите�"ьную дату посева.', warningTemperature: 'Проверьте д�"ите�"ьность и температуру перед обнов�"ением ка�"ендаря.',
34
+ },
35
+ });
@@ -0,0 +1,35 @@
1
+ import { createSeedCalendarContent } from '../localized';
2
+
3
+ export const content = createSeedCalendarContent({
4
+ locale: 'sv',
5
+ title: 'Kalender för fröstratifiering och sådatum',
6
+ description: 'Skapa en daterad förberedelseplan genom att räkna bakåt från sådagen eller framåt från dagen då fröna är klara.',
7
+ faq: [
8
+ { question: 'Vad beräknar kalendern?', answer: 'Den placerar blötläggning, varm och kall fuktbehandling samt sådd på exakta dagar, räknat från måldatumet eller startdagen.' },
9
+ { question: 'Varför finns två datumlägen?', answer: 'Använd sådatumsläget när planteringsdagen är bestämd, eller läget frön klara när förberedelsen börjar i dag och du vill veta det framtida sådatumet.' },
10
+ { question: 'Kan jag ändra tid och temperatur?', answer: 'Ja. Välj en profil som utgångspunkt och anpassa faserna efter fröpåsen, en specialiserad källa och lokala förhållanden.' },
11
+ { question: 'Garanterar planen groning?', answer: 'Nej. Planen ordnar datumen, men ålder, grobarhet, fukt, frövila, mögel och såförhållanden påverkar också resultatet.' },
12
+ ],
13
+ howTo: [
14
+ { name: 'Välj en fröprofil', text: 'Välj den närmaste arten så att rimliga startfaser laddas.' },
15
+ { name: 'Välj datumläge', text: 'Räkna bakåt från sådagen eller framåt från dagen då fröna är klara.' },
16
+ { name: 'Kontrollera faserna', text: 'Justera dagar för blötläggning, värme och kyla samt det kalla temperaturområdet.' },
17
+ { name: 'Följ datumföljden', text: 'Börja på startdatumet, gå igenom alla faser och så på den angivna dagen.' },
18
+ ],
19
+ seoTitle: 'Planera förberedelseperioden',
20
+ seoIntro: 'Frövila är också ett planeringsproblem. En art som behöver en fuktig kylperiod kan missa rätt såfönster om förberedelsen börjar för sent. En onödigt lång behandling tar tid och kan gynna mögel eller för tidig groning. Kalendern gör om ett måldatum till en tydlig följd av genomförbara steg.',
21
+ seoModes: 'Välj ett måldatum för att räkna bakåt, eller använd läget frön klara för att räkna framåt från förberedelsens början. Varje fas har egna datum: blötläggningen går över i nästa behandling och den sista behandlingen slutar dagen före sådd. Resultatet kan användas på en etikett eller som påminnelse.',
22
+ seoPhaseTitle: 'Så läser du faserna',
23
+ seoPhaseItems: ['<strong>Blötläggning:</strong> återfukta fröna under angivet antal dagar och förläng inte utan ett tydligt skäl.', '<strong>Värme:</strong> håll fröna fuktiga inom det varma området när profilen kräver det.', '<strong>Kyla:</strong> förvara dem fuktiga och svalt och kontrollera regelbundet mögel eller tidiga rötter.', '<strong>Sådd:</strong> flytta de förberedda fröna till ett lämpligt substrat på måldatumet.'],
24
+ seoEvidenceTitle: 'Använd profiler som startpunkt',
25
+ seoEvidence: 'Profilerna är inte universella föreskrifter. Sort, ursprung, fröets ålder och instruktioner på påsen kan ändra frövila. Om en pålitlig leverantör anger en annan tid ska du redigera profilen och skriva upp källan i stället för att blanda motstridiga råd.',
26
+ seoTipTitle: 'En kalender är inget groningstest',
27
+ seoTip: 'Anteckna fröparti, fuktigt medium, temperatur och datum. Om rötter syns under kylfasen ska du så försiktigt. Om fröna förblir vilande ska behandlingen inte förlängas automatiskt; kontrollera först artens råd.',
28
+ seoChecksTitle: 'Kontrollera före start',
29
+ seoChecks: ['Kontrollera att sådatumet passar ljus, frost och plats.', 'Märk behållaren med art, start, slut och temperaturområde.', 'Använd ett fuktigt men luftigt medium och kontrollera det regelbundet.', 'Förvara planen med fröpåsen så att behandlingen går att följa upp.'],
30
+ seoLimitsTitle: 'Vad resultatet inte kan säga',
31
+ seoLimits: 'Verktyget avgör inte frövila från ett foto, testar inte grobarhet och ersätter inte artspecifika instruktioner. Det förutsäger inte lokalt väder och garanterar ingen groning. Se resultatet som en organiserad datumberäkning och kontrollera biologin mot en lämplig källa.',
32
+ ui: {
33
+ labelProfile: 'Fröprofil', labelMode: 'Planläge', labelModeSowing: 'Jag vill så den', labelModeReady: 'Fröna är klara i dag', labelSowingDate: 'Måldatum för sådd', labelReadyDate: 'Förberedelsedatum', labelSoakDays: 'Dagar blötläggning', labelWarmDays: 'Varma dagar', labelColdDays: 'Kalla dagar', labelColdRange: 'Kallt temperaturområde', labelUnits: 'Temperaturenheter', labelMetric: 'Metriskt °C', labelImperial: 'Imperialt °F', labelFrom: 'Lägsta temperatur', labelTo: 'Högsta temperatur', labelGenerate: 'Uppdatera kalender', labelReset: 'Återställ profil', labelAddCalendar: 'Lägg till i min kalender', labelShare: 'Kopiera länk', labelCopied: 'Länk kopierad', labelPrint: 'Skriv ut kompakt etikett', labelTechnique: 'Metod', labelTodayStatus: 'Dagens status', labelDayOf: 'dag av', labelGantt: 'Förberedelsetidslinje', labelTimeline: 'Förberedelsetidslinje', labelNextAction: 'Aktuell fas', labelCalendarStarts: 'kalendern börjar', labelSowingDay: 'sådag', labelDuration: 'Förberedelse', labelDays: 'dagar', labelTemperature: 'Temperatur', labelReady: 'Så i dag', labelUpcoming: 'Kommande', labelActive: 'Pågår', labelComplete: 'Planen är klar', labelEditHint: 'Justera förslagen efter fröpåsen eller en specialiserad källa. Den senaste inställningen sparas på den här enheten.', labelWarm: 'Varm stratifiering', labelCold: 'Kall stratifiering', labelSoak: 'Blötlägg frön', labelSow: 'Så frön', noteApple: 'Fuktig kylperiod efter kort blötläggning', noteLavender: 'Kort kylperiod före vårsådd', noteMilkweed: 'Varm fas följd av en kall fas', noteRose: 'Lång kylperiod efter blötläggning', profileApple: 'Äpple · Malus domestica', profileLavender: 'Lavendel · Lavandula angustifolia', profileMilkweed: 'Asclepias · Asclepias syriaca', profileRose: 'Ros · Rosa spp.', profileStrawberry: 'Jordgubbe · Fragaria × ananassa', profilePeach: 'Persika · Prunus persica', profileCherry: 'Sötkörsbär · Prunus avium', profilePear: 'Päron · Pyrus communis', profilePoppy: 'Vallmo · Papaver spp.', profileSunflower: 'Solros · Helianthus annuus', profileTomato: 'Tomat · Solanum lycopersicum', profileBasil: 'Basilika · Ocimum basilicum', instructionSoak: 'Håll fröna återfuktade före nästa fas.', instructionWarm: 'Förvara de fuktiga fröna varmt.', instructionCold: 'Håll fröna fuktiga och kalla och kontrollera dem regelbundet.', instructionSow: 'Så i det förberedda substratet.', emptyState: 'Ange datum för att skapa en förberedelsetidslinje.', warningDate: 'Välj ett giltigt sådatum.', warningTemperature: 'Kontrollera tid och temperatur innan kalendern uppdateras.',
34
+ },
35
+ });
@@ -0,0 +1,35 @@
1
+ import { createSeedCalendarContent } from '../localized';
2
+
3
+ export const content = createSeedCalendarContent({
4
+ locale: 'tr',
5
+ title: 'Tohum katlama ve ekim tarihleri takvimi',
6
+ description: 'Ekim gününden geriye ya da tohumların hazır olduğu günden ileriye doğru hesaplayarak tarihli bir hazırlık planı oluşturun.',
7
+ faq: [
8
+ { question: 'Bu takvim neyi hesaplar?', answer: 'Islatmayı, ılık ve soğuk nemli işlemleri ve ekimi, hedef tarihten geriye ya da başlangıç gününden ileriye doğru kesin günlere yerleştirir.' },
9
+ { question: 'Neden iki tarih modu var?', answer: 'Ekim günü sabitse ekim tarihi modunu, hazırlık bugün başlıyorsa ve ilerideki ekim gününü arıyorsanız tohumlar hazır modunu kullanın.' },
10
+ { question: 'Süreyi ve sıcaklığı değiştirebilir miyim?', answer: 'Evet. Bir başlangıç profili seçin ve aşamaları tohum paketi, uzman bir kaynak ve yerel koşullara göre düzenleyin.' },
11
+ { question: 'Plan çimlenmeyi garanti eder mi?', answer: 'Hayır. Plan tarihleri düzenler; yaş, canlılık, nem, dormansi, küf ve ekim koşulları da sonucu etkiler.' },
12
+ ],
13
+ howTo: [
14
+ { name: 'Tohum profili seçin', text: 'Mümkün olan en yakın türü seçerek uygun başlangıç aşamalarını yükleyin.' },
15
+ { name: 'Tarih modunu seçin', text: 'Ekim gününden geriye veya tohumların hazır olduğu günden ileriye hesaplayın.' },
16
+ { name: 'Aşamaları kontrol edin', text: 'Islatma, ılık ve soğuk günleri ve soğuk sıcaklık aralığını düzenleyin.' },
17
+ { name: 'Tarih sırasını uygulayın', text: 'Başlangıç gününde başlayın, her aşamayı izleyin ve belirtilen günde ekin.' },
18
+ ],
19
+ seoTitle: 'Hazırlık dönemini planlayın',
20
+ seoIntro: 'Tohum dormansisi aynı zamanda bir zamanlama sorunudur. Nemli bir soğuk dönem isteyen türlerde hazırlık geç başlarsa uygun ekim aralığı kaçırılabilir. Gereksiz uzun bir işlem zaman kaybettirir ve küf ya da erken çimlenme riskini artırabilir. Bu takvim hedef tarihi uygulanabilir adımlara dönüştürür.',
21
+ seoModes: 'Geriye hesaplamak için hedef ekim tarihini seçin veya hazırlığın başladığı günden ileriye hesaplamak için tohumlar hazır modunu kullanın. Her aşamanın kendi tarihleri vardır; ıslatma sonraki işleme bağlanır ve son işlem ekimden bir gün önce biter. Sonucu kap etiketi ya da hatırlatıcı olarak kullanabilirsiniz.',
22
+ seoPhaseTitle: 'Aşamaları nasıl okuyabilirsiniz?',
23
+ seoPhaseItems: ['<strong>Islatma:</strong> tohumları belirtilen gün kadar nemlendirin ve özel bir neden olmadan süreyi uzatmayın.', '<strong>Ilık:</strong> profil gerektiriyorsa nemli tohumları ılık aralıkta tutun.', '<strong>Soğuk:</strong> tohumları nemli ve soğuk saklayın; küf veya erken kökleri düzenli kontrol edin.', '<strong>Ekim:</strong> hazırlanmış tohumları hedef tarihte uygun bir yetiştirme ortamına aktarın.'],
24
+ seoEvidenceTitle: 'Profilleri başlangıç noktası olarak kullanın',
25
+ seoEvidence: 'Profiller evrensel reçeteler değildir. Çeşit, köken, tohum yaşı ve paketteki bilgiler dormansiyi değiştirebilir. Güvenilir bir üretici farklı bir süre veriyorsa profili düzenleyin ve kullandığınız kaynağı not edin; çelişkili önerileri rastgele ortalamayın.',
26
+ seoTipTitle: 'Takvim çimlenme testi değildir',
27
+ seoTip: 'Tohum partisinin, nemli ortamın, sıcaklığın ve tarihlerin kaydını tutun. Soğuk aşamada kök çıkarsa dikkatle ekin. Tohumlar dorman kalırsa işlemi otomatik olarak uzatmayın; önce türün önerisini kontrol edin.',
28
+ seoChecksTitle: 'Başlamadan önce kontrol edin',
29
+ seoChecks: ['Ekim tarihinin ışık, don ve konumla uyumlu olduğundan emin olun.', 'Kabı tür, başlangıç, bitiş ve sıcaklık aralığıyla etiketleyin.', 'Nemli fakat hava alan bir ortam kullanın ve düzenli kontrol edin.', 'İşlemi takip edebilmek için planı tohum paketiyle saklayın.'],
30
+ seoLimitsTitle: 'Sonucun söyleyemeyeceği şeyler',
31
+ seoLimits: 'Araç fotoğraftan dormansi belirlemez, canlılığı test etmez ve türe özel talimatların yerini tutmaz. Yerel hava durumunu öngörmez ve çimlenme garantisi vermez. Sonucu düzenlenmiş bir tarih hesabı olarak görün ve biyolojik bilgiyi uygun bir kaynakla doğrulayın.',
32
+ ui: {
33
+ labelProfile: 'Tohum profili', labelMode: 'Plan modu', labelModeSowing: 'Şu tarihte ekmek istiyorum', labelModeReady: 'Tohumlar bugün hazır', labelSowingDate: 'Hedef ekim tarihi', labelReadyDate: 'Hazırlık tarihi', labelSoakDays: 'Islatma günleri', labelWarmDays: 'Ilık günler', labelColdDays: 'Soğuk günler', labelColdRange: 'Soğuk sıcaklık aralığı', labelUnits: 'Sıcaklık birimleri', labelMetric: 'Metrik °C', labelImperial: 'İmperyal °F', labelFrom: 'Minimum sıcaklık', labelTo: 'Maksimum sıcaklık', labelGenerate: 'Takvimi güncelle', labelReset: 'Profili sıfırla', labelAddCalendar: 'Takvimime ekle', labelShare: 'Bağlantıyı kopyala', labelCopied: 'Bağlantı kopyalandı', labelPrint: 'Kompakt etiketi yazdır', labelTechnique: 'Teknik', labelTodayStatus: 'Bugünün durumu', labelDayOf: 'gün /', labelGantt: 'Hazırlık zaman çizelgesi', labelTimeline: 'Hazırlık zaman çizelgesi', labelNextAction: 'Geçerli aşama', labelCalendarStarts: 'takvim başlangıcı', labelSowingDay: 'ekim günü', labelDuration: 'Hazırlık', labelDays: 'gün', labelTemperature: 'Sıcaklık', labelReady: 'Bugün ek', labelUpcoming: 'Yaklaşan', labelActive: 'Devam ediyor', labelComplete: 'Plan tamamlandı', labelEditHint: 'Önerileri paket veya uzman kaynak bilgisine göre düzenleyin. Son ayar bu cihazda saklanır.', labelWarm: 'Ilık katlama', labelCold: 'Soğuk katlama', labelSoak: 'Tohumları ıslat', labelSow: 'Tohumları ek', noteApple: 'Kısa ıslatmadan sonra nemli soğuk dönem', noteLavender: 'İlkbahar ekiminden önce kısa soğuk dönem', noteMilkweed: 'Soğuk aşamanın izlediği ılık aşama', noteRose: 'Islatmadan sonra uzun soğuk dönem', profileApple: 'Elma · Malus domestica', profileLavender: 'Lavanta · Lavandula angustifolia', profileMilkweed: 'Süt otu · Asclepias syriaca', profileRose: 'Gül · Rosa spp.', profileStrawberry: 'Çilek · Fragaria × ananassa', profilePeach: 'Şeftali · Prunus persica', profileCherry: 'Tatlı kiraz · Prunus avium', profilePear: 'Armut · Pyrus communis', profilePoppy: 'Haşhaş · Papaver spp.', profileSunflower: 'Ayçiçeği · Helianthus annuus', profileTomato: 'Domates · Solanum lycopersicum', profileBasil: 'Fesleğen · Ocimum basilicum', instructionSoak: 'Bir sonraki aşamadan önce tohumları nemli tutun.', instructionWarm: 'Nemli tohumları sıcak bir yerde saklayın.', instructionCold: 'Tohumları nemli ve soğuk tutun, düzenli kontrol edin.', instructionSow: 'Hazırlanmış yetiştirme ortamına ekin.', emptyState: 'Hazırlık zaman çizelgesi oluşturmak için tarihleri ayarlayın.', warningDate: 'Geçerli bir ekim tarihi seçin.', warningTemperature: 'Takvimi güncellemeden önce süre ve sıcaklık değerlerini kontrol edin.',
34
+ },
35
+ });
@@ -0,0 +1,35 @@
1
+ import { createSeedCalendarContent } from '../localized';
2
+
3
+ export const content = createSeedCalendarContent({
4
+ locale: 'zh',
5
+ title: '种子层积处理与播种日期日历',
6
+ description: '可以从播种日向前倒推,也可以从种子准备好的日期向后推算,建立带日期的准备计划。',
7
+ faq: [
8
+ { question: '这个日历计算什么?', answer: '它会把浸种、温暖湿润处理、低温湿润处理和播种安排到具体日期,可从目标日期倒推,也可从准备开始日顺推。' },
9
+ { question: '为什么有两种日期模式?', answer: '播种日已经确定时使用播种日期模式;如果今天开始准备种子并想知道未来的播种日,则使用种子已准备模式。' },
10
+ { question: '可以修改天数和温度吗?', answer: '可以。先选择一个品种档案,再根据种子包装、专业资料和当地条件调整各个阶段。' },
11
+ { question: '按计划处理就一定会发芽吗?', answer: '不会。计划只能整理日期,种子年龄、活力、水分、休眠、霉菌和播种条件也会影响结果。' },
12
+ ],
13
+ howTo: [
14
+ { name: '选择种子档案', text: '选择最接近的种类,以载入合理的初始阶段。' },
15
+ { name: '选择日期模式', text: '从播种日倒推,或从种子准备好的日期顺推。' },
16
+ { name: '检查处理阶段', text: '调整浸种、温暖、低温天数和低温范围。' },
17
+ { name: '按日期顺序执行', text: '从开始日期着手,完成每个阶段,并在显示的日期播种。' },
18
+ ],
19
+ seoTitle: '规划准备周期',
20
+ seoIntro: '种子休眠也是一个时间规划问题。需要湿冷期的种类如果准备太晚,可能会错过合适的播种窗口。没有必要地延长处理会浪费时间,也可能增加发霉或提前发芽的风险。这个日历把目标日期转换成清晰、可执行的步骤。',
21
+ seoModes: '选择目标播种日即可倒推;选择种子已准备模式则从准备开始日顺推。每个阶段都有自己的日期,浸种会衔接到下一项处理,最后一项处理在播种前一天结束。结果可以用于容器标签或日历提醒。',
22
+ seoPhaseTitle: '如何阅读各个阶段',
23
+ seoPhaseItems: ['<strong>浸种:</strong>按照指定天数使种子吸水,没有明确理由不要延长。', '<strong>温暖:</strong>档案要求时,将湿润种子保持在温暖范围内。', '<strong>低温:</strong>保持湿润和低温,并定期检查霉菌或早生根。', '<strong>播种:</strong>在目标日期把处理好的种子移入合适的培养基。'],
24
+ seoEvidenceTitle: '把档案当作起点',
25
+ seoEvidence: '这些档案不是适用于所有情况的配方。品种、来源、种子年龄和包装说明都可能改变休眠要求。如果可靠供应商给出不同的处理时长,请编辑档案并记录所使用的资料,不要简单平均互相矛盾的建议。',
26
+ seoTipTitle: '日历不是发芽测试',
27
+ seoTip: '记录种子批次、湿润材料、温度和日期。如果低温处理时出现根,请小心播种。如果种子仍然休眠,也不要自动延长处理,先查看该种类的建议。',
28
+ seoChecksTitle: '开始前检查',
29
+ seoChecks: ['确认播种日期适合光照、霜冻和地点条件。', '在容器上写明种类、开始、结束和温度范围。', '使用湿润但通气的材料并定期检查。', '把计划和种子包装放在一起,以便追踪处理过程。'],
30
+ seoLimitsTitle: '结果无法说明什么',
31
+ seoLimits: '工具不会根据照片判断休眠,不会检测活力,也不能替代具体种类的说明。它不会预测当地天气,也不保证发芽。请把结果看作日期计算,并用合适的资料核实生物学条件。',
32
+ ui: {
33
+ labelProfile: '种子档案', labelMode: '计划模式', labelModeSowing: '我想在这天播种', labelModeReady: '种子今天准备好了', labelSowingDate: '目标播种日期', labelReadyDate: '准备开始日期', labelSoakDays: '浸种天数', labelWarmDays: '温暖天数', labelColdDays: '低温天数', labelColdRange: '低温范围', labelUnits: '温度单位', labelMetric: '公制 °C', labelImperial: '英制 °F', labelFrom: '最低温度', labelTo: '最高温度', labelGenerate: '更新日历', labelReset: '重置档案', labelAddCalendar: '添加到我的日历', labelShare: '复制链接', labelCopied: '链接已复制', labelPrint: '打印紧凑标签', labelTechnique: '方法', labelTodayStatus: '今日状态', labelDayOf: '第 / 天', labelGantt: '准备时间线', labelTimeline: '准备时间线', labelNextAction: '当前阶段', labelCalendarStarts: '日历开始', labelSowingDay: '播种日', labelDuration: '准备', labelDays: '天', labelTemperature: '温度', labelReady: '今天播种', labelUpcoming: '即将开始', labelActive: '进行中', labelComplete: '计划完成', labelEditHint: '请根据种子包装或专业资料调整建议。最后的设置会保存在此设备上。', labelWarm: '温暖层积处理', labelCold: '低温层积处理', labelSoak: '浸种', labelSow: '播种', noteApple: '短暂浸种后的湿冷期', noteLavender: '春季播种前的短暂低温期', noteMilkweed: '温暖阶段之后进行低温阶段', noteRose: '浸种后的长低温期', profileApple: '苹果 · Malus domestica', profileLavender: '薰衣草 · Lavandula angustifolia', profileMilkweed: '马利筋 · Asclepias syriaca', profileRose: '玫瑰 · Rosa spp.', profileStrawberry: '草莓 · Fragaria × ananassa', profilePeach: '桃 · Prunus persica', profileCherry: '甜樱桃 · Prunus avium', profilePear: '梨 · Pyrus communis', profilePoppy: '罂粟 · Papaver spp.', profileSunflower: '向日葵 · Helianthus annuus', profileTomato: '番茄 · Solanum lycopersicum', profileBasil: '罗勒 · Ocimum basilicum', instructionSoak: '在下一阶段前保持种子水分充足。', instructionWarm: '把湿润种子放在温暖处。', instructionCold: '保持种子湿润和低温,并定期检查。', instructionSow: '播种到准备好的培养基中。', emptyState: '设置日期以创建准备时间线。', warningDate: '请选择有效的播种日期。', warningTemperature: '更新日历前请检查天数和温度。',
34
+ },
35
+ });
@@ -0,0 +1,11 @@
1
+ import type { ToolDefinition } from '../../types';
2
+ import { seedStratificationCalendar } from './entry';
3
+
4
+ export * from './entry';
5
+
6
+ export const SEED_STRATIFICATION_CALENDAR_TOOL: ToolDefinition = {
7
+ entry: seedStratificationCalendar,
8
+ Component: () => import('./component.astro'),
9
+ SEOComponent: () => import('./seo.astro'),
10
+ BibliographyComponent: () => import('./bibliography.astro'),
11
+ };
@@ -0,0 +1,70 @@
1
+ import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
2
+ import type { ToolLocaleContent } from '../../types';
3
+ import { bibliography } from './bibliography';
4
+ import type { SeedStratificationCalendarUI } from './ui';
5
+
6
+ export interface SeedCalendarTranslation {
7
+ locale: string;
8
+ title: string;
9
+ description: string;
10
+ faq: { question: string; answer: string }[];
11
+ howTo: { name: string; text: string }[];
12
+ seoTitle: string;
13
+ seoIntro: string;
14
+ seoModes: string;
15
+ seoPhaseTitle: string;
16
+ seoPhaseItems: string[];
17
+ seoEvidenceTitle: string;
18
+ seoEvidence: string;
19
+ seoTipTitle: string;
20
+ seoTip: string;
21
+ seoChecksTitle: string;
22
+ seoChecks: string[];
23
+ seoLimitsTitle: string;
24
+ seoLimits: string;
25
+ ui: SeedStratificationCalendarUI;
26
+ }
27
+
28
+ const localizedSlugs: Record<string, string> = {
29
+ de: 'saatgut-stratifizierungskalender',
30
+ es: 'calendario-estratificacion-semillas',
31
+ fr: 'calendrier-stratification-graines',
32
+ id: 'kalender-stratifikasi-benih',
33
+ it: 'calendario-stratificazione-semi',
34
+ nl: 'kalender-zaadstratificatie',
35
+ pl: 'kalendar-stratyfikacji-nasion',
36
+ pt: 'calendario-estratificacao-sementes',
37
+ ru: 'kalendar-stratifikacii-semyan',
38
+ sv: 'kalender-frostratifiering',
39
+ tr: 'tohum-tabakalandirma-takvimi',
40
+ };
41
+
42
+ export function createSeedCalendarContent(copy: SeedCalendarTranslation): ToolLocaleContent<SeedStratificationCalendarUI> {
43
+ const faqSchema: WithContext<FAQPage> = { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: copy.faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) };
44
+ const howToSchema: WithContext<HowTo> = { '@context': 'https://schema.org', '@type': 'HowTo', name: copy.title, description: copy.description, step: copy.howTo.map((step, index) => ({ '@type': 'HowToStep', position: index + 1, name: step.name, text: step.text })) };
45
+ const appSchema: WithContext<SoftwareApplication> = { '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: copy.title, description: copy.description, applicationCategory: 'UtilityApplication', operatingSystem: 'All', offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' }, inLanguage: copy.locale };
46
+ return {
47
+ slug: localizedSlugs[copy.locale] ?? 'seed-stratification-calendar',
48
+ title: copy.title,
49
+ description: copy.description,
50
+ faq: copy.faq,
51
+ bibliography,
52
+ howTo: copy.howTo,
53
+ schemas: [faqSchema, howToSchema, appSchema],
54
+ seo: [
55
+ { type: 'title', text: copy.seoTitle, level: 2 },
56
+ { type: 'paragraph', html: copy.seoIntro },
57
+ { type: 'paragraph', html: copy.seoModes },
58
+ { type: 'title', text: copy.seoPhaseTitle, level: 2 },
59
+ { type: 'list', items: copy.seoPhaseItems },
60
+ { type: 'title', text: copy.seoEvidenceTitle, level: 2 },
61
+ { type: 'paragraph', html: copy.seoEvidence },
62
+ { type: 'tip', title: copy.seoTipTitle, html: `<p>${copy.seoTip}</p>` },
63
+ { type: 'title', text: copy.seoChecksTitle, level: 2 },
64
+ { type: 'list', items: copy.seoChecks },
65
+ { type: 'title', text: copy.seoLimitsTitle, level: 2 },
66
+ { type: 'paragraph', html: copy.seoLimits },
67
+ ],
68
+ ui: copy.ui,
69
+ };
70
+ }
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { applyProfile, celsiusToFahrenheit, createCalendarPlan, fahrenheitToCelsius } from './logic';
3
+
4
+ describe('seed stratification calendar logic', () => {
5
+ it('works backwards from sowing day without overlapping phases', () => {
6
+ const plan = createCalendarPlan({
7
+ profileId: 'apple',
8
+ mode: 'sowing-date',
9
+ anchorDate: '2027-04-15',
10
+ soakDays: 1,
11
+ warmDays: 0,
12
+ coldDays: 90,
13
+ coldMinC: 1,
14
+ coldMaxC: 5,
15
+ referenceDate: '2027-01-15',
16
+ });
17
+ expect(plan.startDate).toBe('2027-01-14');
18
+ expect(plan.phases[0]).toMatchObject({ kind: 'soak', startDate: '2027-01-14', endDate: '2027-01-14', durationDays: 1 });
19
+ expect(plan.phases[1]).toMatchObject({ kind: 'cold', startDate: '2027-01-15', endDate: '2027-04-14', durationDays: 90, minTempC: 1, maxTempC: 5 });
20
+ expect(plan.phases[2]).toMatchObject({ kind: 'sow', startDate: '2027-04-15', endDate: '2027-04-15', durationDays: 1 });
21
+ expect(plan.activePhaseIndex).toBe(1);
22
+ });
23
+
24
+ it('keeps warm and cold phases in sequence', () => {
25
+ const plan = createCalendarPlan({ ...applyProfile('milkweed'), anchorDate: '2027-05-31', referenceDate: '2027-04-20' });
26
+ expect(plan.phases.map((phase) => phase.kind)).toEqual(['warm', 'cold', 'sow']);
27
+ expect(plan.phases[0]).toMatchObject({ startDate: '2027-04-01', endDate: '2027-04-30' });
28
+ expect(plan.phases[1]).toMatchObject({ startDate: '2027-05-01', endDate: '2027-05-30' });
29
+ expect(plan.activePhaseIndex).toBe(0);
30
+ });
31
+
32
+ it('rejects inverted temperatures and negative durations', () => {
33
+ expect(() => createCalendarPlan({ ...applyProfile('apple'), coldMinC: 6, coldMaxC: 2 })).toThrow('Temperature range is inverted');
34
+ expect(() => createCalendarPlan({ ...applyProfile('apple'), coldDays: -1 })).toThrow('Durations cannot be negative');
35
+ });
36
+
37
+ it('uses sowing day as the active phase when no reference date is supplied', () => {
38
+ const plan = createCalendarPlan(applyProfile('lavender'));
39
+ expect(plan.activePhaseIndex).toBe(1);
40
+ });
41
+
42
+ it('converts displayed temperature without changing the physical value', () => {
43
+ expect(celsiusToFahrenheit(5)).toBe(41);
44
+ expect(fahrenheitToCelsius(41)).toBe(5);
45
+ });
46
+
47
+ it('calculates a future sowing date from a ready today date', () => {
48
+ const plan = createCalendarPlan({ ...applyProfile('apple', '2027-01-14'), mode: 'ready-date', anchorDate: '2027-01-14' });
49
+ expect(plan.sowingDate).toBe('2027-04-15');
50
+ expect(plan.startDate).toBe('2027-01-14');
51
+ expect(plan.mode).toBe('ready-date');
52
+ });
53
+ });
@@ -0,0 +1,198 @@
1
+ export type SeedPhaseKind = 'soak' | 'warm' | 'cold' | 'sow';
2
+ export type TemperatureUnit = 'metric' | 'imperial';
3
+ export type CalendarMode = 'sowing-date' | 'ready-date';
4
+
5
+ export interface SeedProfile {
6
+ id: string;
7
+ scientificName: string;
8
+ soakDays: number;
9
+ warmDays: number;
10
+ coldDays: number;
11
+ warmMinC: number;
12
+ warmMaxC: number;
13
+ coldMinC: number;
14
+ coldMaxC: number;
15
+ }
16
+
17
+ export interface CalendarInput {
18
+ mode: CalendarMode;
19
+ anchorDate: string;
20
+ profileId: string;
21
+ soakDays: number;
22
+ warmDays: number;
23
+ coldDays: number;
24
+ coldMinC: number;
25
+ coldMaxC: number;
26
+ referenceDate?: string;
27
+ }
28
+
29
+ export interface CalendarPhase {
30
+ kind: SeedPhaseKind;
31
+ startDate: string;
32
+ endDate: string;
33
+ durationDays: number;
34
+ minTempC?: number;
35
+ maxTempC?: number;
36
+ technique: string;
37
+ }
38
+
39
+ export interface CalendarPlan {
40
+ profileId: string;
41
+ sowingDate: string;
42
+ startDate: string;
43
+ totalPreparationDays: number;
44
+ phases: CalendarPhase[];
45
+ activePhaseIndex: number;
46
+ mode: CalendarMode;
47
+ }
48
+
49
+ export const SEED_PROFILES: readonly SeedProfile[] = [
50
+ { id: 'apple', scientificName: 'Malus domestica', soakDays: 1, warmDays: 0, coldDays: 90, warmMinC: 18, warmMaxC: 24, coldMinC: 1, coldMaxC: 5 },
51
+ { id: 'lavender', scientificName: 'Lavandula angustifolia', soakDays: 0, warmDays: 0, coldDays: 30, warmMinC: 18, warmMaxC: 24, coldMinC: 1, coldMaxC: 5 },
52
+ { id: 'milkweed', scientificName: 'Asclepias syriaca', soakDays: 0, warmDays: 30, coldDays: 30, warmMinC: 18, warmMaxC: 24, coldMinC: 1, coldMaxC: 5 },
53
+ { id: 'rose', scientificName: 'Rosa spp.', soakDays: 1, warmDays: 0, coldDays: 60, warmMinC: 18, warmMaxC: 24, coldMinC: 1, coldMaxC: 5 },
54
+ { id: 'strawberry', scientificName: 'Fragaria × ananassa', soakDays: 0, warmDays: 0, coldDays: 30, warmMinC: 18, warmMaxC: 24, coldMinC: 1, coldMaxC: 5 },
55
+ { id: 'peach', scientificName: 'Prunus persica', soakDays: 1, warmDays: 0, coldDays: 90, warmMinC: 18, warmMaxC: 24, coldMinC: 1, coldMaxC: 5 },
56
+ { id: 'cherry', scientificName: 'Prunus avium', soakDays: 1, warmDays: 0, coldDays: 90, warmMinC: 18, warmMaxC: 24, coldMinC: 1, coldMaxC: 5 },
57
+ { id: 'pear', scientificName: 'Pyrus communis', soakDays: 1, warmDays: 0, coldDays: 60, warmMinC: 18, warmMaxC: 24, coldMinC: 1, coldMaxC: 5 },
58
+ { id: 'poppy', scientificName: 'Papaver spp.', soakDays: 0, warmDays: 0, coldDays: 0, warmMinC: 18, warmMaxC: 24, coldMinC: 1, coldMaxC: 5 },
59
+ { id: 'sunflower', scientificName: 'Helianthus annuus', soakDays: 0, warmDays: 0, coldDays: 0, warmMinC: 18, warmMaxC: 24, coldMinC: 1, coldMaxC: 5 },
60
+ { id: 'tomato', scientificName: 'Solanum lycopersicum', soakDays: 0, warmDays: 0, coldDays: 0, warmMinC: 18, warmMaxC: 24, coldMinC: 1, coldMaxC: 5 },
61
+ { id: 'basil', scientificName: 'Ocimum basilicum', soakDays: 0, warmDays: 0, coldDays: 0, warmMinC: 18, warmMaxC: 24, coldMinC: 1, coldMaxC: 5 },
62
+ ] as const;
63
+
64
+ export const DEFAULT_INPUT: CalendarInput = {
65
+ mode: 'sowing-date',
66
+ anchorDate: '2027-04-15',
67
+ profileId: 'apple',
68
+ soakDays: 1,
69
+ warmDays: 0,
70
+ coldDays: 90,
71
+ coldMinC: 1,
72
+ coldMaxC: 5,
73
+ };
74
+
75
+ const MS_PER_DAY = 86_400_000;
76
+
77
+ export function celsiusToFahrenheit(value: number): number {
78
+ return (value * 9) / 5 + 32;
79
+ }
80
+
81
+ export function fahrenheitToCelsius(value: number): number {
82
+ return ((value - 32) * 5) / 9;
83
+ }
84
+
85
+ function parseDate(value: string): Date {
86
+ const date = new Date(`${value}T00:00:00Z`);
87
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(date.getTime())) {
88
+ throw new Error('Invalid date');
89
+ }
90
+ return date;
91
+ }
92
+
93
+ function formatDate(date: Date): string {
94
+ return date.toISOString().slice(0, 10);
95
+ }
96
+
97
+ function shiftDate(date: Date, days: number): Date {
98
+ return new Date(date.getTime() + days * MS_PER_DAY);
99
+ }
100
+
101
+ interface PhaseOptions {
102
+ kind: SeedPhaseKind;
103
+ endDate: Date;
104
+ durationDays: number;
105
+ minTempC?: number;
106
+ maxTempC?: number;
107
+ }
108
+
109
+ function makePhase(options: PhaseOptions, technique: string): CalendarPhase {
110
+ const { kind, endDate, durationDays, minTempC, maxTempC } = options;
111
+ const startDate = shiftDate(endDate, -(durationDays - 1));
112
+ const phase: CalendarPhase = { kind, startDate: formatDate(startDate), endDate: formatDate(endDate), durationDays, technique };
113
+ if (minTempC !== undefined) phase.minTempC = minTempC;
114
+ if (maxTempC !== undefined) phase.maxTempC = maxTempC;
115
+ return phase;
116
+ }
117
+
118
+ function validateInput(input: CalendarInput): void {
119
+ parseDate(input.anchorDate);
120
+ if (input.soakDays < 0 || input.warmDays < 0 || input.coldDays < 0) throw new Error('Durations cannot be negative');
121
+ if (input.coldMaxC < input.coldMinC) throw new Error('Temperature range is inverted');
122
+ }
123
+
124
+ function phaseForDate(phases: CalendarPhase[], referenceDate: string): number {
125
+ const date = parseDate(referenceDate);
126
+ const index = phases.findIndex((phase) => phase.startDate <= referenceDate && referenceDate <= phase.endDate);
127
+ if (index >= 0) return index;
128
+ const firstPhase = phases[0];
129
+ if (!firstPhase) throw new Error('Calendar has no phases');
130
+ return date < parseDate(firstPhase.startDate) ? 0 : phases.length - 1;
131
+ }
132
+
133
+ function addPreparationPhase(phases: CalendarPhase[], options: PhaseOptions, technique: string): Date {
134
+ const phase = makePhase(options, technique);
135
+ phases.unshift(phase);
136
+ return shiftDate(parseDate(phase.startDate), -1);
137
+ }
138
+
139
+ export function createCalendarPlan(input: CalendarInput): CalendarPlan {
140
+ validateInput(input);
141
+ const anchorDate = parseDate(input.anchorDate);
142
+ const totalPreparationDays = input.soakDays + input.warmDays + input.coldDays;
143
+ const sowingDate = input.mode === 'ready-date' ? shiftDate(anchorDate, totalPreparationDays) : anchorDate;
144
+ const phases: CalendarPhase[] = [];
145
+ let phaseEnd = shiftDate(sowingDate, -1);
146
+ if (input.coldDays > 0) phaseEnd = addPreparationPhase(phases, { kind: 'cold', endDate: phaseEnd, durationDays: input.coldDays, minTempC: input.coldMinC, maxTempC: input.coldMaxC }, coldTechnique(input.profileId));
147
+ if (input.warmDays > 0) phaseEnd = addPreparationPhase(phases, { kind: 'warm', endDate: phaseEnd, durationDays: input.warmDays, minTempC: 18, maxTempC: 24 }, warmTechnique(input.profileId));
148
+ if (input.soakDays > 0) addPreparationPhase(phases, { kind: 'soak', endDate: phaseEnd, durationDays: input.soakDays, minTempC: 18, maxTempC: 24 }, soakTechnique(input.profileId));
149
+ phases.push(makePhase({ kind: 'sow', endDate: sowingDate, durationDays: 1 }, sowTechnique(input.profileId)));
150
+ const referenceDate = input.referenceDate ?? input.anchorDate;
151
+ const firstPhase = phases[0];
152
+ if (!firstPhase) throw new Error('Calendar has no phases');
153
+ return {
154
+ profileId: input.profileId,
155
+ sowingDate: formatDate(sowingDate),
156
+ startDate: firstPhase.startDate,
157
+ totalPreparationDays,
158
+ phases,
159
+ activePhaseIndex: phaseForDate(phases, referenceDate),
160
+ mode: input.mode,
161
+ };
162
+ }
163
+
164
+ export function getProfile(profileId: string): SeedProfile {
165
+ const fallback = SEED_PROFILES[0];
166
+ if (!fallback) throw new Error('No seed profiles available');
167
+ return SEED_PROFILES.find((profile) => profile.id === profileId) ?? fallback;
168
+ }
169
+
170
+ function soakTechnique(profileId: string): string {
171
+ return profileId === 'milkweed' ? 'Room temperature water, short rinse only' : 'Warm water, 24 hours maximum';
172
+ }
173
+
174
+ function warmTechnique(profileId: string): string {
175
+ return profileId === 'milkweed' ? 'Moist paper towel at 18 to 24 °C (64 to 75 °F)' : 'Moist medium in a warm propagator';
176
+ }
177
+
178
+ function coldTechnique(profileId: string): string {
179
+ return profileId === 'milkweed' ? 'Damp sand or vermiculite' : 'Zip bag with damp paper towel or sand/perlite';
180
+ }
181
+
182
+ function sowTechnique(profileId: string): string {
183
+ return profileId === 'lavender' ? 'Fine, free draining seed mix' : 'Sterile, free draining seed mix';
184
+ }
185
+
186
+ export function applyProfile(profileId: string, anchorDate = DEFAULT_INPUT.anchorDate): CalendarInput {
187
+ const profile = getProfile(profileId);
188
+ return {
189
+ mode: 'sowing-date',
190
+ anchorDate,
191
+ profileId: profile.id,
192
+ soakDays: profile.soakDays,
193
+ warmDays: profile.warmDays,
194
+ coldDays: profile.coldDays,
195
+ coldMinC: profile.coldMinC,
196
+ coldMaxC: profile.coldMaxC,
197
+ };
198
+ }
@@ -0,0 +1,18 @@
1
+ import type { SeedStratificationCalendarUI } from './ui';
2
+
3
+ export function profileLabels(ui: SeedStratificationCalendarUI): Record<string, string> {
4
+ return {
5
+ apple: ui.profileApple,
6
+ lavender: ui.profileLavender,
7
+ milkweed: ui.profileMilkweed,
8
+ rose: ui.profileRose,
9
+ strawberry: ui.profileStrawberry,
10
+ peach: ui.profilePeach,
11
+ cherry: ui.profileCherry,
12
+ pear: ui.profilePear,
13
+ poppy: ui.profilePoppy,
14
+ sunflower: ui.profileSunflower,
15
+ tomato: ui.profileTomato,
16
+ basil: ui.profileBasil,
17
+ };
18
+ }