@jjlmoya/utils-nature 1.26.0 → 1.27.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 +2 -0
  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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-nature",
3
- "version": "1.26.0",
3
+ "version": "1.27.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
package/src/entries.ts CHANGED
@@ -8,6 +8,8 @@ export { seedCalculator } from './tool/seedCalculator/entry';
8
8
  export type { SeedCalculatorLocaleContent } from './tool/seedCalculator/entry';
9
9
  export { urbanGardenPlanner } from './tool/urbanGardenPlanner/entry';
10
10
  export type { UrbanGardenPlannerLocaleContent } from './tool/urbanGardenPlanner/entry';
11
+ export { seedStratificationCalendar } from './tool/seedStratificationCalendar/entry';
12
+ export type { SeedStratificationCalendarLocaleContent } from './tool/seedStratificationCalendar/entry';
11
13
  export { natureCategory } from './category';
12
14
  import { cricketThermometer } from './tool/cricketThermometer/entry';
13
15
  import { digitalCarbon } from './tool/digitalCarbon/entry';
package/src/index.ts CHANGED
@@ -30,3 +30,5 @@ export { URBAN_GARDEN_PLANNER_TOOL, urbanGardenPlanner } from './tool/urbanGarde
30
30
  export { COMPOST_BIN_VOLUME_RATIO_CALCULATOR_TOOL, compostBinVolumeRatioCalculator } from './tool/compostBinVolumeRatioCalculator/index';
31
31
 
32
32
  export { WILDLIFE_CAMERA_TRAP_EFFORT_PLANNER_TOOL, wildlifeCameraTrapEffortPlanner } from './tool/wildlifeCameraTrapEffortPlanner/index';
33
+
34
+ export { SEED_STRATIFICATION_CALENDAR_TOOL, seedStratificationCalendar } from './tool/seedStratificationCalendar/index';
@@ -3,8 +3,8 @@ import { ALL_TOOLS, natureCategory } from '../index';
3
3
 
4
4
  describe('Tool Validation Suite', () => {
5
5
  describe('Library Registration', () => {
6
- it('should have 7 tools in ALL_TOOLS', () => {
7
- expect(ALL_TOOLS.length).toBe(7);
6
+ it('should have 8 tools in ALL_TOOLS', () => {
7
+ expect(ALL_TOOLS.length).toBe(8);
8
8
  });
9
9
 
10
10
  it('natureCategory should be defined', () => {
@@ -0,0 +1,9 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { seedStratificationCalendar } from './index';
4
+
5
+ const { locale = 'en' } = Astro.props;
6
+ const content = await seedStratificationCalendar.i18n[locale as keyof typeof seedStratificationCalendar.i18n]?.();
7
+ ---
8
+
9
+ {content && <SharedBibliography links={content.bibliography} />}
@@ -0,0 +1,16 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'Royal Horticultural Society, Growing Trees and Shrubs from Seed',
6
+ url: 'https://www.rhs.org.uk/plants/types/trees/trees-shrubs-from-seed',
7
+ },
8
+ {
9
+ name: 'Ministerio de Agricultura de España, Producción de plantas por medio de semillas',
10
+ url: 'https://www.mapa.gob.es/ministerio/pags/biblioteca/hojas/hd_1980_15.pdf',
11
+ },
12
+ {
13
+ name: 'Plantas y Flores, Calendario de Plantación 2025 | Guía Completa de Cultivo Mes a Mes',
14
+ url: 'https://plantasyflores.online/calendario/',
15
+ },
16
+ ];
@@ -0,0 +1,66 @@
1
+ import type { CalendarPlan } from './logic';
2
+ import type { SeedStratificationCalendarUI } from './ui';
3
+ import { profileLabels } from './profile-labels';
4
+
5
+ export function todayISO(): string {
6
+ const date = new Date();
7
+ const month = String(date.getMonth() + 1).padStart(2, '0');
8
+ const day = String(date.getDate()).padStart(2, '0');
9
+ return `${date.getFullYear()}-${month}-${day}`;
10
+ }
11
+
12
+ function formatICSDate(value: string): string {
13
+ return value.replaceAll('-', '');
14
+ }
15
+
16
+ function addDay(value: string): string {
17
+ const date = new Date(`${value}T00:00:00Z`);
18
+ date.setUTCDate(date.getUTCDate() + 1);
19
+ return date.toISOString().slice(0, 10);
20
+ }
21
+
22
+ function escapeICS(value: string): string {
23
+ return value.replaceAll('\\', '\\\\').replaceAll(';', '\\;').replaceAll(',', '\\,').replaceAll('\n', '\\n');
24
+ }
25
+
26
+ function buildICS(plan: CalendarPlan, ui: SeedStratificationCalendarUI): string {
27
+ const labels = { soak: ui.labelSoak, warm: ui.labelWarm, cold: ui.labelCold, sow: ui.labelSow };
28
+ const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//jjlmoya-utils-nature//Seed Calendar//EN', 'CALSCALE:GREGORIAN'];
29
+ plan.phases.forEach((phase) => {
30
+ const label = labels[phase.kind];
31
+ lines.push('BEGIN:VEVENT', `UID:${plan.profileId}-${phase.kind}-${phase.startDate}@jjlmoya-utils-nature`, `DTSTAMP:${formatICSDate(todayISO())}T000000Z`, `DTSTART;VALUE=DATE:${formatICSDate(phase.startDate)}`, `DTEND;VALUE=DATE:${formatICSDate(addDay(phase.endDate))}`, `SUMMARY:${escapeICS(label)}`, `DESCRIPTION:${escapeICS(phase.technique)}`, 'BEGIN:VALARM', 'TRIGGER:PT0S', 'ACTION:DISPLAY', `DESCRIPTION:${escapeICS(label)}`, 'END:VALARM', 'END:VEVENT');
32
+ });
33
+ lines.push('END:VCALENDAR');
34
+ return `${lines.join('\r\n')}\r\n`;
35
+ }
36
+
37
+ export function downloadCalendar(plan: CalendarPlan, ui: SeedStratificationCalendarUI): void {
38
+ const blob = new Blob([buildICS(plan, ui)], { type: 'text/calendar;charset=utf-8' });
39
+ const link = document.createElement('a');
40
+ link.href = URL.createObjectURL(blob);
41
+ link.download = `seed-calendar-${plan.profileId}.ics`;
42
+ link.click();
43
+ URL.revokeObjectURL(link.href);
44
+ }
45
+
46
+ export async function copyShareLink(root: HTMLElement, link: string, ui: SeedStratificationCalendarUI): Promise<void> {
47
+ try {
48
+ await navigator.clipboard.writeText(link);
49
+ root.querySelector<HTMLElement>('[data-role="share-status"]')!.textContent = ui.labelCopied;
50
+ } catch {
51
+ root.querySelector<HTMLElement>('[data-role="share-status"]')!.textContent = link;
52
+ }
53
+ }
54
+
55
+ function formatDateForPrint(value: string, locale: string): string {
56
+ return new Intl.DateTimeFormat(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }).format(new Date(`${value}T00:00:00Z`));
57
+ }
58
+
59
+ export function updatePrintLabel(root: HTMLElement, plan: CalendarPlan, ui: SeedStratificationCalendarUI, locale: string): void {
60
+ const phaseLabels = { soak: ui.labelSoak, warm: ui.labelWarm, cold: ui.labelCold, sow: ui.labelSow };
61
+ const profileLabelsMap = profileLabels(ui);
62
+ root.querySelector<HTMLElement>('[data-role="print-profile"]')!.textContent = profileLabelsMap[plan.profileId] ?? ui.profileApple;
63
+ root.querySelector<HTMLElement>('[data-role="print-start"]')!.textContent = `${ui.labelCalendarStarts}: ${formatDateForPrint(plan.startDate, locale)}`;
64
+ root.querySelector<HTMLElement>('[data-role="print-sowing"]')!.textContent = `${ui.labelSowingDay}: ${formatDateForPrint(plan.sowingDate, locale)}`;
65
+ root.querySelector<HTMLElement>('[data-role="print-phases"]')!.textContent = plan.phases.map((phase) => `${phaseLabels[phase.kind]} ${formatDateForPrint(phase.startDate, locale)}–${formatDateForPrint(phase.endDate, locale)}`).join(' · ');
66
+ }
@@ -0,0 +1,62 @@
1
+ ---
2
+ import { seedStratificationCalendar } from './index';
3
+ import { SEED_PROFILES as profileOptions } from './logic';
4
+ import { profileLabels } from './profile-labels';
5
+ import type { KnownLocale } from '../../types';
6
+ import type { SeedStratificationCalendarUI } from './ui';
7
+
8
+ const { locale = 'en', ui: providedUI } = Astro.props as { locale?: KnownLocale; ui?: SeedStratificationCalendarUI };
9
+ const fallbackContent = await seedStratificationCalendar.i18n[locale]?.();
10
+ const ui = providedUI ?? fallbackContent?.ui;
11
+ if (!ui) return null;
12
+ const labels = profileLabels(ui);
13
+ ---
14
+
15
+ <section class="n-seed-calendar" data-tool="seed-stratification-calendar" data-locale={locale}>
16
+ <div class="n-calendar-controls">
17
+ <div class="n-unit-switch" role="group" aria-label={ui.labelUnits}>
18
+ <span class="n-field-label">{ui.labelUnits}</span>
19
+ <div class="n-unit-options"><button class="n-unit-button is-active" type="button" data-unit-option="metric" aria-pressed="true">{ui.labelMetric}</button><button class="n-unit-button" type="button" data-unit-option="imperial" aria-pressed="false">{ui.labelImperial}</button></div>
20
+ </div>
21
+ <div class="n-mode-switch" role="group" aria-label={ui.labelMode}>
22
+ <span class="n-field-label">{ui.labelMode}</span>
23
+ <div class="n-mode-options"><button class="n-mode-button is-active" type="button" data-mode-option="sowing-date" aria-pressed="true">{ui.labelModeSowing}</button><button class="n-mode-button" type="button" data-mode-option="ready-date" aria-pressed="false">{ui.labelModeReady}</button></div>
24
+ </div>
25
+ <div class="n-control-block">
26
+ <label class="n-field-label" for="seed-profile-trigger">{ui.labelProfile}</label>
27
+ <div class="n-custom-select" data-role="profile-select" data-value="apple">
28
+ <button id="seed-profile-trigger" class="n-select-trigger" type="button" data-select-trigger aria-haspopup="listbox" aria-expanded="false">{ui.profileApple}<span class="n-chevron" aria-hidden="true"></span></button>
29
+ <div class="n-select-menu" data-select-menu role="listbox" hidden>
30
+ {profileOptions.map((profile) => <button class="n-select-option" type="button" data-option={profile.id} role="option" aria-selected={profile.id === 'apple'}>{labels[profile.id]}</button>)}
31
+ </div>
32
+ </div>
33
+ </div>
34
+ <div class="n-control-block">
35
+ <label class="n-field-label" data-role="date-label" for="seed-anchor-date">{ui.labelSowingDate}</label>
36
+ <input id="seed-anchor-date" class="n-field" data-role="anchor-date" type="date" value="2027-04-15" />
37
+ </div>
38
+ <div class="n-duration-grid">
39
+ <label class="n-control-block"><span class="n-field-label">{ui.labelSoakDays}</span><input class="n-field" data-role="soak-days" type="number" min="0" max="30" step="1" value="1" /></label>
40
+ <label class="n-control-block"><span class="n-field-label">{ui.labelWarmDays}</span><input class="n-field" data-role="warm-days" type="number" min="0" max="180" step="1" value="0" /></label>
41
+ <label class="n-control-block"><span class="n-field-label">{ui.labelColdDays}</span><input class="n-field" data-role="cold-days" type="number" min="0" max="365" step="1" value="90" /></label>
42
+ </div>
43
+ <div class="n-range-block">
44
+ <div class="n-range-heading"><span class="n-field-label">{ui.labelColdRange}</span><span class="n-range-values"><output data-role="cold-min-value">1 °C / 34 °F</output><span>to</span><output data-role="cold-max-value">5 °C / 41 °F</output></span></div>
45
+ <div class="n-range-pair"><label><span class="sr-only">{ui.labelFrom}</span><input class="n-range" data-role="cold-min" type="range" min="-5" max="15" value="1" /></label><label><span class="sr-only">{ui.labelTo}</span><input class="n-range" data-role="cold-max" type="range" min="-5" max="15" value="5" /></label></div>
46
+ </div>
47
+ <p class="n-edit-hint">{ui.labelEditHint}</p>
48
+ <div class="n-control-actions"><button class="n-primary-button" type="button" data-role="generate">{ui.labelGenerate}</button><button class="n-quiet-button" type="button" data-role="reset">{ui.labelReset}</button></div>
49
+ <p class="n-error" data-role="error" role="alert" hidden>{ui.warningTemperature}</p>
50
+ </div>
51
+ <div class="n-calendar-results" data-role="results" aria-live="polite"><p class="n-empty-state">{ui.emptyState}</p></div>
52
+ <div class="n-calendar-actions"><button class="n-secondary-button" type="button" data-role="calendar">{ui.labelAddCalendar}</button><button class="n-secondary-button" type="button" data-role="share">{ui.labelShare}</button><button class="n-secondary-button" type="button" data-role="print">{ui.labelPrint}</button><span class="n-share-status" data-role="share-status" role="status" aria-live="polite"></span></div>
53
+ <div class="n-print-label" data-role="print-label"><strong data-role="print-profile">{ui.profileApple}</strong><span data-role="print-start"></span><span data-role="print-sowing"></span><span data-role="print-phases"></span></div>
54
+ <script is:inline type="application/json" data-seed-calendar-ui set:html={JSON.stringify(ui)}></script>
55
+ </section>
56
+
57
+ <script>
58
+ import { initializeSeedStratificationCalendar } from './controller';
59
+ const root = document.querySelector<HTMLElement>('[data-tool="seed-stratification-calendar"]');
60
+ const uiNode = root?.querySelector('[data-seed-calendar-ui]');
61
+ if (root && uiNode) initializeSeedStratificationCalendar({ root, ui: JSON.parse(uiNode.textContent ?? '{}'), locale: root.dataset.locale ?? 'en' });
62
+ </script>
@@ -0,0 +1,227 @@
1
+ import { applyProfile, celsiusToFahrenheit, createCalendarPlan, DEFAULT_INPUT, fahrenheitToCelsius, SEED_PROFILES, type CalendarInput, type CalendarMode, type TemperatureUnit } from './logic';
2
+ import { renderTimeline } from './dom-views';
3
+ import { loadCalendarState, saveCalendarState } from './storage';
4
+ import { copyShareLink, downloadCalendar, todayISO, updatePrintLabel } from './calendar-actions';
5
+ import { setupSelect } from './profile-select';
6
+ import type { SeedStratificationCalendarUI } from './ui';
7
+ interface ControllerOptions {
8
+ root: HTMLElement;
9
+ ui: SeedStratificationCalendarUI;
10
+ locale: string;
11
+ }
12
+
13
+ function query<T extends Element>(root: HTMLElement, selector: string): T {
14
+ const element = root.querySelector<T>(selector);
15
+ if (!element) throw new Error(`Missing calendar element ${selector}`);
16
+ return element;
17
+ }
18
+
19
+ function readNumber(root: HTMLElement, role: string): number {
20
+ return Number(query<HTMLInputElement>(root, `[data-role="${role}"]`).value);
21
+ }
22
+
23
+ function getUnit(root: HTMLElement): TemperatureUnit {
24
+ return root.dataset.unit === 'imperial' ? 'imperial' : 'metric';
25
+ }
26
+
27
+ function getMode(root: HTMLElement): CalendarMode {
28
+ return root.dataset.mode === 'ready-date' ? 'ready-date' : 'sowing-date';
29
+ }
30
+
31
+ function readTemperature(root: HTMLElement, role: string): number {
32
+ const value = readNumber(root, role);
33
+ return getUnit(root) === 'metric' ? value : fahrenheitToCelsius(value);
34
+ }
35
+
36
+ function readInput(root: HTMLElement): CalendarInput {
37
+ return {
38
+ mode: getMode(root),
39
+ anchorDate: query<HTMLInputElement>(root, '[data-role="anchor-date"]').value,
40
+ profileId: query<HTMLElement>(root, '[data-role="profile-select"]').dataset.value ?? DEFAULT_INPUT.profileId,
41
+ soakDays: readNumber(root, 'soak-days'),
42
+ warmDays: readNumber(root, 'warm-days'),
43
+ coldDays: readNumber(root, 'cold-days'),
44
+ coldMinC: readTemperature(root, 'cold-min'),
45
+ coldMaxC: readTemperature(root, 'cold-max'),
46
+ };
47
+ }
48
+
49
+ function updateProfileFields(root: HTMLElement, input: CalendarInput): void {
50
+ const values: Record<string, number> = {
51
+ 'soak-days': input.soakDays,
52
+ 'warm-days': input.warmDays,
53
+ 'cold-days': input.coldDays,
54
+ 'cold-min': input.coldMinC,
55
+ 'cold-max': input.coldMaxC,
56
+ };
57
+ Object.entries(values).forEach(([role, value]) => { query<HTMLInputElement>(root, `[data-role="${role}"]`).value = String(value); });
58
+ setTemperatureInput(root, 'cold-min', input.coldMinC);
59
+ setTemperatureInput(root, 'cold-max', input.coldMaxC);
60
+ }
61
+
62
+ function setTemperatureInput(root: HTMLElement, role: string, celsius: number): void {
63
+ const input = query<HTMLInputElement>(root, `[data-role="${role}"]`);
64
+ input.value = String(getUnit(root) === 'metric' ? celsius : Math.round(celsiusToFahrenheit(celsius)));
65
+ }
66
+
67
+ function readUrlNumber(params: URLSearchParams, key: string, fallback: number): number {
68
+ const value = Number(params.get(key));
69
+ return Number.isFinite(value) && value >= 0 ? value : fallback;
70
+ }
71
+
72
+ function readUrlTemperature(params: URLSearchParams, key: string, fallback: number): number {
73
+ const value = Number(params.get(key));
74
+ return Number.isFinite(value) ? value : fallback;
75
+ }
76
+
77
+ function readUrlProfile(params: URLSearchParams, fallback: string): string {
78
+ const value = params.get('seed');
79
+ return value && SEED_PROFILES.some((profile) => profile.id === value) ? value : fallback;
80
+ }
81
+
82
+ function readUrlMode(params: URLSearchParams, fallback: CalendarMode): CalendarMode {
83
+ return params.get('mode') === 'ready-date' ? 'ready-date' : fallback;
84
+ }
85
+
86
+ function readUrlAnchorDate(params: URLSearchParams, mode: CalendarMode, fallback: string): string {
87
+ const value = params.get(mode === 'ready-date' ? 'ready' : 'sow');
88
+ return value ?? params.get('sow') ?? fallback;
89
+ }
90
+
91
+ function readUrlInput(fallback: CalendarInput): CalendarInput {
92
+ if (typeof window === 'undefined') return fallback;
93
+ const params = new URLSearchParams(window.location.search);
94
+ const mode = readUrlMode(params, fallback.mode);
95
+ return {
96
+ ...fallback,
97
+ mode,
98
+ anchorDate: readUrlAnchorDate(params, mode, fallback.anchorDate),
99
+ profileId: readUrlProfile(params, fallback.profileId),
100
+ soakDays: readUrlNumber(params, 'soak', fallback.soakDays),
101
+ warmDays: readUrlNumber(params, 'warm', fallback.warmDays),
102
+ coldDays: readUrlNumber(params, 'cold', fallback.coldDays),
103
+ coldMinC: readUrlTemperature(params, 'min', fallback.coldMinC),
104
+ coldMaxC: readUrlTemperature(params, 'max', fallback.coldMaxC),
105
+ };
106
+ }
107
+
108
+ function updateUrl(input: CalendarInput): void {
109
+ if (typeof window === 'undefined') return;
110
+ const url = new URL(window.location.href);
111
+ url.searchParams.set('seed', input.profileId);
112
+ url.searchParams.set('mode', input.mode);
113
+ url.searchParams.set(input.mode === 'ready-date' ? 'ready' : 'sow', input.anchorDate);
114
+ url.searchParams.delete(input.mode === 'ready-date' ? 'sow' : 'ready');
115
+ url.searchParams.set('soak', String(input.soakDays));
116
+ url.searchParams.set('warm', String(input.warmDays));
117
+ url.searchParams.set('cold', String(input.coldDays));
118
+ url.searchParams.set('min', String(input.coldMinC));
119
+ url.searchParams.set('max', String(input.coldMaxC));
120
+ window.history.replaceState(null, '', url);
121
+ }
122
+
123
+ function updateModeButtons(root: HTMLElement, ui: SeedStratificationCalendarUI): void {
124
+ const mode = getMode(root);
125
+ root.querySelectorAll<HTMLButtonElement>('[data-mode-option]').forEach((button) => {
126
+ const isActive = button.dataset.modeOption === mode;
127
+ button.classList.toggle('is-active', isActive);
128
+ button.setAttribute('aria-pressed', String(isActive));
129
+ });
130
+ query<HTMLElement>(root, '[data-role="date-label"]').textContent = mode === 'ready-date' ? ui.labelReadyDate : ui.labelSowingDate;
131
+ }
132
+
133
+ function setMode(root: HTMLElement, ui: SeedStratificationCalendarUI, mode: CalendarMode): void {
134
+ root.dataset.mode = mode;
135
+ updateModeButtons(root, ui);
136
+ }
137
+
138
+ function setupMode(root: HTMLElement, ui: SeedStratificationCalendarUI, initialMode: CalendarMode): void {
139
+ setMode(root, ui, initialMode);
140
+ root.querySelectorAll<HTMLButtonElement>('[data-mode-option]').forEach((button) => button.addEventListener('click', () => {
141
+ root.dataset.mode = button.dataset.modeOption === 'ready-date' ? 'ready-date' : 'sowing-date';
142
+ updateModeButtons(root, ui);
143
+ render(root, ui, root.dataset.locale ?? 'en');
144
+ }));
145
+ }
146
+
147
+ function render(root: HTMLElement, ui: SeedStratificationCalendarUI, locale: string): void {
148
+ try {
149
+ const input = readInput(root);
150
+ const today = todayISO();
151
+ const plan = createCalendarPlan({ ...input, referenceDate: today });
152
+ renderTimeline({ container: query(root, '[data-role="results"]'), plan, ui, locale, today, unit: getUnit(root) });
153
+ updatePrintLabel(root, plan, ui, locale);
154
+ updateUrl(input);
155
+ saveCalendarState(input, getUnit(root));
156
+ query<HTMLElement>(root, '[data-role="error"]').hidden = true;
157
+ } catch {
158
+ const error = query<HTMLElement>(root, '[data-role="error"]');
159
+ error.textContent = ui.warningTemperature;
160
+ error.hidden = false;
161
+ }
162
+ }
163
+
164
+ function setInputValues(root: HTMLElement, input: CalendarInput): void {
165
+ query<HTMLInputElement>(root, '[data-role="anchor-date"]').value = input.anchorDate;
166
+ updateProfileFields(root, input);
167
+ }
168
+
169
+ function updateRangeLabels(root: HTMLElement): void {
170
+ const minimum = readNumber(root, 'cold-min');
171
+ const maximum = readNumber(root, 'cold-max');
172
+ const minimumC = getUnit(root) === 'metric' ? minimum : fahrenheitToCelsius(minimum);
173
+ const maximumC = getUnit(root) === 'metric' ? maximum : fahrenheitToCelsius(maximum);
174
+ query<HTMLOutputElement>(root, '[data-role="cold-min-value"]').textContent = `${Math.round(minimumC * 10) / 10} °C / ${Math.round(celsiusToFahrenheit(minimumC))} °F`;
175
+ query<HTMLOutputElement>(root, '[data-role="cold-max-value"]').textContent = `${Math.round(maximumC * 10) / 10} °C / ${Math.round(celsiusToFahrenheit(maximumC))} °F`;
176
+ }
177
+
178
+ function updateUnitButtons(root: HTMLElement): void {
179
+ const unit = getUnit(root);
180
+ root.querySelectorAll<HTMLButtonElement>('[data-unit-option]').forEach((button) => {
181
+ const isActive = button.dataset.unitOption === unit;
182
+ button.classList.toggle('is-active', isActive);
183
+ button.setAttribute('aria-pressed', String(isActive));
184
+ });
185
+ }
186
+
187
+ function setTemperatureBounds(root: HTMLElement, unit: TemperatureUnit): void {
188
+ const min = query<HTMLInputElement>(root, '[data-role="cold-min"]');
189
+ const max = query<HTMLInputElement>(root, '[data-role="cold-max"]');
190
+ const bounds = unit === 'metric' ? { min: '-5', max: '15' } : { min: '23', max: '59' };
191
+ min.min = bounds.min; min.max = bounds.max; max.min = bounds.min; max.max = bounds.max;
192
+ }
193
+
194
+ function switchUnit(root: HTMLElement, ui: SeedStratificationCalendarUI, unit: TemperatureUnit): void {
195
+ const current = readInput(root);
196
+ root.dataset.unit = unit;
197
+ setTemperatureBounds(root, unit);
198
+ setTemperatureInput(root, 'cold-min', current.coldMinC);
199
+ setTemperatureInput(root, 'cold-max', current.coldMaxC);
200
+ updateUnitButtons(root);
201
+ updateRangeLabels(root);
202
+ render(root, ui, root.dataset.locale ?? 'en');
203
+ }
204
+
205
+ export function initializeSeedStratificationCalendar(options: ControllerOptions): void {
206
+ const { root, ui, locale } = options;
207
+ root.dataset.locale = locale;
208
+ root.dataset.unit = 'metric';
209
+ const stored = loadCalendarState(DEFAULT_INPUT);
210
+ const initialInput = readUrlInput(stored.input);
211
+ root.dataset.unit = stored.unit;
212
+ setTemperatureBounds(root, stored.unit);
213
+ setInputValues(root, initialInput);
214
+ setupMode(root, ui, initialInput.mode);
215
+ const onProfileSelect = (id: string): void => { updateProfileFields(root, { ...applyProfile(id, query<HTMLInputElement>(root, '[data-role="anchor-date"]').value), mode: getMode(root) }); render(root, ui, root.dataset.locale ?? 'en'); };
216
+ const profileSelect = setupSelect(root, ui, initialInput.profileId, onProfileSelect);
217
+ root.querySelectorAll<HTMLButtonElement>('[data-unit-option]').forEach((button) => button.addEventListener('click', () => switchUnit(root, ui, button.dataset.unitOption as TemperatureUnit)));
218
+ updateUnitButtons(root);
219
+ root.querySelectorAll<HTMLInputElement>('input').forEach((input) => input.addEventListener('input', () => { updateRangeLabels(root); render(root, ui, locale); }));
220
+ query<HTMLButtonElement>(root, '[data-role="reset"]').addEventListener('click', () => { setInputValues(root, DEFAULT_INPUT); setMode(root, ui, DEFAULT_INPUT.mode); profileSelect.choose(DEFAULT_INPUT.profileId); render(root, ui, locale); });
221
+ query<HTMLButtonElement>(root, '[data-role="generate"]').addEventListener('click', () => render(root, ui, locale));
222
+ query<HTMLButtonElement>(root, '[data-role="calendar"]').addEventListener('click', () => downloadCalendar(createCalendarPlan({ ...readInput(root), referenceDate: todayISO() }), ui));
223
+ query<HTMLButtonElement>(root, '[data-role="share"]').addEventListener('click', () => { updateUrl(readInput(root)); void copyShareLink(root, window.location.href, ui); });
224
+ query<HTMLButtonElement>(root, '[data-role="print"]').addEventListener('click', () => window.print());
225
+ updateRangeLabels(root);
226
+ render(root, ui, locale);
227
+ }
@@ -0,0 +1,130 @@
1
+ import type { SeedStratificationCalendarUI } from './ui';
2
+ import { celsiusToFahrenheit, type CalendarPhase, type CalendarPlan, type SeedPhaseKind, type TemperatureUnit } from './logic';
3
+ import { evaluatePlan } from './evaluator';
4
+
5
+ interface TimelineViewOptions {
6
+ container: HTMLElement;
7
+ plan: CalendarPlan;
8
+ ui: SeedStratificationCalendarUI;
9
+ locale: string;
10
+ today: string;
11
+ unit: TemperatureUnit;
12
+ }
13
+
14
+ const phaseLabels = (ui: SeedStratificationCalendarUI): Record<SeedPhaseKind, string> => ({
15
+ soak: ui.labelSoak,
16
+ warm: ui.labelWarm,
17
+ cold: ui.labelCold,
18
+ sow: ui.labelSow,
19
+ });
20
+
21
+ function makeElement<K extends keyof HTMLElementTagNameMap>(tag: K, className: string, text?: string): HTMLElementTagNameMap[K] {
22
+ const element = document.createElement(tag);
23
+ element.className = className;
24
+ if (text) element.textContent = text;
25
+ return element;
26
+ }
27
+
28
+ function statusLabel(status: string, ui: SeedStratificationCalendarUI): string {
29
+ if (status === 'complete') return ui.labelComplete;
30
+ if (status === 'sowing') return ui.labelReady;
31
+ if (status === 'active') return ui.labelActive;
32
+ return ui.labelUpcoming;
33
+ }
34
+
35
+ function makePhaseIcon(kind: SeedPhaseKind): SVGSVGElement {
36
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
37
+ svg.setAttribute('viewBox', '0 0 24 24');
38
+ svg.setAttribute('aria-hidden', 'true');
39
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
40
+ const paths: Record<SeedPhaseKind, string> = {
41
+ soak: 'M12 3C9 7 6 10 6 14a6 6 0 0 0 12 0c0-4-3-7-6-11Z',
42
+ warm: 'M12 4v3M12 17v3M4 12h3M17 12h3M6.3 6.3l2.1 2.1M15.6 15.6l2.1 2.1M17.7 6.3l-2.1 2.1M8.4 15.6l-2.1 2.1M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Z',
43
+ cold: 'M12 3v18M3 12h18M5.6 5.6l12.8 12.8M18.4 5.6 5.6 18.4',
44
+ sow: 'M12 21V11M12 15c-4 0-7-3-7-7 4 0 7 3 7 7ZM12 12c0-4 3-7 7-7 0 4-3 7-7 7Z',
45
+ };
46
+ path.setAttribute('d', paths[kind]);
47
+ path.setAttribute('fill', kind === 'soak' ? 'currentColor' : 'none');
48
+ path.setAttribute('stroke', 'currentColor');
49
+ path.setAttribute('stroke-linecap', 'round');
50
+ path.setAttribute('stroke-linejoin', 'round');
51
+ path.setAttribute('stroke-width', '1.7');
52
+ svg.append(path);
53
+ return svg;
54
+ }
55
+
56
+ function formatDate(value: string, locale: string): string {
57
+ return new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }).format(new Date(`${value}T00:00:00Z`));
58
+ }
59
+
60
+ function temperatureText(phase: CalendarPhase, ui: SeedStratificationCalendarUI, unit: TemperatureUnit): string {
61
+ if (phase.minTempC === undefined || phase.maxTempC === undefined) return ui.labelSowingDay;
62
+ const minimumC = Math.round(phase.minTempC * 10) / 10;
63
+ const maximumC = Math.round(phase.maxTempC * 10) / 10;
64
+ const celsius = `${minimumC} to ${maximumC} °C`;
65
+ const fahrenheit = `${Math.round(celsiusToFahrenheit(minimumC))} to ${Math.round(celsiusToFahrenheit(maximumC))} °F`;
66
+ return `${ui.labelTemperature}: ${unit === 'metric' ? `${celsius} (${fahrenheit})` : `${fahrenheit} (${celsius})`}`;
67
+ }
68
+
69
+ function renderHeader(container: HTMLElement, plan: CalendarPlan, ui: SeedStratificationCalendarUI, today: string): void {
70
+ const labels = phaseLabels(ui);
71
+ const evaluation = evaluatePlan(plan, today, labels);
72
+ const header = makeElement('div', 'n-calendar-header');
73
+ const status = makeElement('span', `n-status n-status-${evaluation.status}`, statusLabel(evaluation.status, ui));
74
+ const action = makeElement('p', 'n-next-action');
75
+ let dayText = ui.labelUpcoming;
76
+ if (evaluation.status === 'complete') dayText = ui.labelComplete;
77
+ if (evaluation.status === 'active' || evaluation.status === 'sowing') dayText = `${ui.labelTodayStatus}: ${evaluation.dayNumber} ${ui.labelDayOf} ${evaluation.durationDays}`;
78
+ action.append(makeElement('strong', '', ui.labelNextAction), document.createTextNode(` ${evaluation.phaseLabel}, ${dayText}`));
79
+ header.append(status, action);
80
+ container.append(header);
81
+ }
82
+
83
+ function renderSummary(container: HTMLElement, plan: CalendarPlan, ui: SeedStratificationCalendarUI, locale: string): void {
84
+ const summary = makeElement('div', 'n-calendar-summary');
85
+ const start = makeElement('div', 'n-summary-major');
86
+ start.append(makeElement('strong', '', formatDate(plan.startDate, locale)), makeElement('span', '', ui.labelCalendarStarts));
87
+ const prep = makeElement('div', 'n-summary-stat');
88
+ prep.append(makeElement('strong', '', `${plan.totalPreparationDays}`), makeElement('span', '', `${ui.labelDuration} ${ui.labelDays}`));
89
+ const sowing = makeElement('div', 'n-summary-stat');
90
+ sowing.append(makeElement('strong', '', formatDate(plan.sowingDate, locale)), makeElement('span', '', ui.labelSowingDay));
91
+ summary.append(start, prep, sowing);
92
+ container.append(summary);
93
+ }
94
+
95
+ interface PhaseViewOptions {
96
+ container: HTMLElement;
97
+ phase: CalendarPhase;
98
+ index: number;
99
+ ui: SeedStratificationCalendarUI;
100
+ locale: string;
101
+ unit: TemperatureUnit;
102
+ currentPhaseIndex: number | null;
103
+ }
104
+
105
+ function renderGanttSegment(options: PhaseViewOptions): void {
106
+ const { container, phase, index, ui, locale, unit, currentPhaseIndex } = options;
107
+ const labels = phaseLabels(ui);
108
+ const segment = makeElement('div', `n-gantt-segment n-gantt-${phase.kind}${index === currentPhaseIndex ? ' is-active' : ''}`);
109
+ segment.style.flex = `${phase.durationDays} 1 0`;
110
+ segment.setAttribute('title', `${labels[phase.kind]}, ${formatDate(phase.startDate, locale)} to ${formatDate(phase.endDate, locale)}`);
111
+ segment.append(makePhaseIcon(phase.kind));
112
+ const segmentText = makeElement('div', 'n-gantt-text');
113
+ segmentText.append(makeElement('strong', '', labels[phase.kind]), makeElement('span', '', `${phase.durationDays} ${ui.labelDays}`));
114
+ segment.append(segmentText);
115
+ segment.append(makeElement('p', 'n-phase-detail', `${temperatureText(phase, ui, unit)} · ${ui.labelTechnique}: ${phase.technique}`));
116
+ container.append(segment);
117
+ }
118
+
119
+ export function renderTimeline(options: TimelineViewOptions): void {
120
+ options.container.replaceChildren();
121
+ renderHeader(options.container, options.plan, options.ui, options.today);
122
+ renderSummary(options.container, options.plan, options.ui, options.locale);
123
+ const heading = makeElement('h2', 'n-gantt-heading', options.ui.labelGantt);
124
+ options.container.append(heading);
125
+ const gantt = makeElement('div', 'n-gantt');
126
+ const evaluation = evaluatePlan(options.plan, options.today, phaseLabels(options.ui));
127
+ const currentPhaseIndex = evaluation.status === 'active' || evaluation.status === 'sowing' ? evaluation.phaseIndex : null;
128
+ options.plan.phases.forEach((phase, index) => renderGanttSegment({ container: gantt, phase, index, ui: options.ui, locale: options.locale, unit: options.unit, currentPhaseIndex }));
129
+ options.container.append(gantt);
130
+ }
@@ -0,0 +1,29 @@
1
+ import type { NatureToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { SeedStratificationCalendarUI } from './ui';
3
+
4
+ export type SeedStratificationCalendarLocaleContent = ToolLocaleContent<SeedStratificationCalendarUI>;
5
+
6
+ export const seedStratificationCalendar: NatureToolEntry<SeedStratificationCalendarUI> = {
7
+ id: 'seed-stratification-calendar',
8
+ icons: {
9
+ bg: 'mdi:calendar-clock',
10
+ fg: 'mdi:sprout',
11
+ },
12
+ i18n: {
13
+ de: async () => (await import('./i18n/de')).content,
14
+ en: async () => (await import('./i18n/en')).content,
15
+ es: async () => (await import('./i18n/es')).content,
16
+ fr: async () => (await import('./i18n/fr')).content,
17
+ id: async () => (await import('./i18n/id')).content,
18
+ it: async () => (await import('./i18n/it')).content,
19
+ ja: async () => (await import('./i18n/ja')).content,
20
+ ko: async () => (await import('./i18n/ko')).content,
21
+ nl: async () => (await import('./i18n/nl')).content,
22
+ pl: async () => (await import('./i18n/pl')).content,
23
+ pt: async () => (await import('./i18n/pt')).content,
24
+ ru: async () => (await import('./i18n/ru')).content,
25
+ sv: async () => (await import('./i18n/sv')).content,
26
+ tr: async () => (await import('./i18n/tr')).content,
27
+ zh: async () => (await import('./i18n/zh')).content,
28
+ },
29
+ };
@@ -0,0 +1,32 @@
1
+ import type { CalendarPlan } from './logic';
2
+
3
+ export type CalendarStatus = 'upcoming' | 'active' | 'sowing' | 'complete';
4
+
5
+ export interface CalendarEvaluation {
6
+ status: CalendarStatus;
7
+ phaseIndex: number;
8
+ phaseLabel: string;
9
+ dayNumber: number;
10
+ durationDays: number;
11
+ }
12
+
13
+ function getStatus(phaseKind: string, today: string, startDate: string): CalendarStatus {
14
+ if (phaseKind === 'sow') return 'sowing';
15
+ return today < startDate ? 'upcoming' : 'active';
16
+ }
17
+
18
+ function phaseDay(startDate: string, today: string, durationDays: number): number {
19
+ if (today < startDate) return 0;
20
+ const elapsed = Math.floor((Date.parse(`${today}T00:00:00Z`) - Date.parse(`${startDate}T00:00:00Z`)) / 86_400_000) + 1;
21
+ return Math.min(elapsed, durationDays);
22
+ }
23
+
24
+ export function evaluatePlan(plan: CalendarPlan, today: string, labels: Record<string, string>): CalendarEvaluation {
25
+ if (today > plan.sowingDate) return { status: 'complete', phaseIndex: plan.phases.length - 1, phaseLabel: labels.sow ?? 'Sow seeds', dayNumber: 1, durationDays: 1 };
26
+ const phaseIndex = plan.phases.findIndex((phase) => phase.startDate <= today && today <= phase.endDate);
27
+ const resolvedIndex = phaseIndex >= 0 ? phaseIndex : plan.activePhaseIndex;
28
+ const phase = plan.phases[resolvedIndex];
29
+ if (!phase) return { status: 'complete', phaseIndex: plan.phases.length - 1, phaseLabel: labels.sow ?? 'Sow seeds', dayNumber: 1, durationDays: 1 };
30
+ const status = getStatus(phase.kind, today, phase.startDate);
31
+ return { status, phaseIndex: resolvedIndex, phaseLabel: labels[phase.kind] ?? phase.kind, dayNumber: phaseDay(phase.startDate, today, phase.durationDays), durationDays: phase.durationDays };
32
+ }