@jjlmoya/utils-aquarium 1.58.0 → 1.59.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 (36) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +4 -1
  4. package/src/tests/i18n_coverage.test.ts +1 -1
  5. package/src/tests/tool_validation.test.ts +4 -3
  6. package/src/tool/aquariumLightingPhotoperiodPlanner/aquarium-lighting-photoperiod-planner.css +667 -0
  7. package/src/tool/aquariumLightingPhotoperiodPlanner/bibliography.astro +6 -0
  8. package/src/tool/aquariumLightingPhotoperiodPlanner/bibliography.ts +12 -0
  9. package/src/tool/aquariumLightingPhotoperiodPlanner/component.astro +92 -0
  10. package/src/tool/aquariumLightingPhotoperiodPlanner/controller.ts +179 -0
  11. package/src/tool/aquariumLightingPhotoperiodPlanner/dom-views.test.ts +26 -0
  12. package/src/tool/aquariumLightingPhotoperiodPlanner/dom-views.ts +187 -0
  13. package/src/tool/aquariumLightingPhotoperiodPlanner/entry.ts +29 -0
  14. package/src/tool/aquariumLightingPhotoperiodPlanner/evaluator.ts +47 -0
  15. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/de.ts +125 -0
  16. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/en.ts +125 -0
  17. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/es.ts +110 -0
  18. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/fr.ts +110 -0
  19. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/id.ts +110 -0
  20. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/it.ts +110 -0
  21. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/ja.ts +110 -0
  22. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/ko.ts +110 -0
  23. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/nl.ts +110 -0
  24. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/pl.ts +110 -0
  25. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/pt.ts +110 -0
  26. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/ru.ts +110 -0
  27. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/sv.ts +110 -0
  28. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/tr.ts +110 -0
  29. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/zh.ts +110 -0
  30. package/src/tool/aquariumLightingPhotoperiodPlanner/index.ts +11 -0
  31. package/src/tool/aquariumLightingPhotoperiodPlanner/logic.test.ts +115 -0
  32. package/src/tool/aquariumLightingPhotoperiodPlanner/logic.ts +248 -0
  33. package/src/tool/aquariumLightingPhotoperiodPlanner/seo.astro +13 -0
  34. package/src/tool/aquariumLightingPhotoperiodPlanner/storage.ts +39 -0
  35. package/src/tool/aquariumLightingPhotoperiodPlanner/ui.ts +44 -0
  36. package/src/tools.ts +6 -0
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { bibliography } from './bibliography';
4
+ ---
5
+
6
+ <SharedBibliography links={bibliography} />
@@ -0,0 +1,12 @@
1
+ import type { BibliographyEntry } from "../../types";
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: "OATA, How to set up a planted aquarium",
6
+ url: "https://ornamentalfish.org/what-we-do/advice-information/care-sheets/caresheets-tropical-freshwater-fish/how-to-set-up-a-planted-aquarium/",
7
+ },
8
+ {
9
+ name: "Deutscher Tierschutzbund, Die Haltung von Aquarienfischen",
10
+ url: "https://www.tierschutzbund.de/fileadmin/Seiten/tierschutzbund.de/Downloads/Broschueren/Broschuere_Aquarienfische_Haltung.pdf",
11
+ },
12
+ ];
@@ -0,0 +1,92 @@
1
+ ---
2
+ import type { AquariumLightingPhotoperiodPlannerUI } from './ui';
3
+ import { calculateSchedule, DEFAULT_INPUT, getLightWindow } from './logic';
4
+ import { getIntensityChartMarkers, getIntensityChartPaths } from './dom-views';
5
+ import './aquarium-lighting-photoperiod-planner.css';
6
+
7
+ interface Props { ui: AquariumLightingPhotoperiodPlannerUI; }
8
+
9
+ const { ui } = Astro.props;
10
+ const initial = calculateSchedule(DEFAULT_INPUT);
11
+ const initialWindow = getLightWindow(initial);
12
+ const initialChart = getIntensityChartPaths(initial);
13
+ const initialMarkers = getIntensityChartMarkers(initial);
14
+ const initialFullPhase = initial.phases.find((phase) => phase.phase === 'full');
15
+ ---
16
+
17
+ <div class="aqlp-shell" data-aqlp-root>
18
+ <section class="aqlp-output" aria-label={ui.lightWindowLabel}>
19
+ <div class="aqlp-output-header">
20
+ <div class="aqlp-answer-copy">
21
+ <span class="aqlp-overline">{ui.lightWindowLabel}</span>
22
+ <strong class="aqlp-window-answer" data-light-window>{initialWindow.startTime} → {initialWindow.endTime}</strong>
23
+ <span class="aqlp-answer-subline"><strong data-photoperiod-value>{initial.input.photoperiodHours} {ui.hoursUnit}</strong> {ui.dailyRepeatLabel}</span>
24
+ </div>
25
+ <span class="aqlp-status" data-status-badge>{ui.statusInside}</span>
26
+ </div>
27
+
28
+ <div class="aqlp-visual-card">
29
+ <div class="aqlp-timeline-heading"><strong>{ui.dayNightLabel}</strong><span>{ui.fullDayLabel}</span></div>
30
+ <div class="aqlp-chart-wrap">
31
+ <svg class="aqlp-chart" viewBox="0 0 960 260" preserveAspectRatio="none" role="img" aria-label={ui.dayNightLabel}>
32
+ <line class="aqlp-chart-grid" x1="0" y1="40" x2="960" y2="40"></line>
33
+ <line class="aqlp-chart-grid" x1="0" y1="130" x2="960" y2="130"></line>
34
+ <line class="aqlp-chart-grid" x1="0" y1="220" x2="960" y2="220"></line>
35
+ <line class="aqlp-chart-tick" x1="0" y1="32" x2="0" y2="228"></line>
36
+ <line class="aqlp-chart-tick" x1="240" y1="32" x2="240" y2="228"></line>
37
+ <line class="aqlp-chart-tick" x1="480" y1="32" x2="480" y2="228"></line>
38
+ <line class="aqlp-chart-tick" x1="720" y1="32" x2="720" y2="228"></line>
39
+ <line class="aqlp-chart-tick" x1="960" y1="32" x2="960" y2="228"></line>
40
+ <path class="aqlp-chart-fill" data-chart-fill d={initialChart.fill}></path>
41
+ <path class="aqlp-chart-line" data-chart-line d={initialChart.line}></path>
42
+ <circle class="aqlp-chart-marker aqlp-chart-marker-edge" data-chart-marker-start cx={(initialMarkers.start / 1440) * 960} cy="220" r="8"></circle>
43
+ <circle class="aqlp-chart-marker aqlp-chart-marker-full" data-chart-marker-full cx={(initialMarkers.full / 1440) * 960} cy="40" r="8"></circle>
44
+ <circle class="aqlp-chart-marker aqlp-chart-marker-edge" data-chart-marker-end cx={(initialMarkers.end / 1440) * 960} cy="220" r="8"></circle>
45
+ <text class="aqlp-chart-y-label" x="12" y="27">100%</text>
46
+ <text class="aqlp-chart-y-label" x="12" y="126">50%</text>
47
+ <text class="aqlp-chart-y-label" x="38" y="216">0%</text>
48
+ <text class="aqlp-chart-x-label" x="0" y="258">00:00</text>
49
+ <text class="aqlp-chart-x-label" x="240" y="258">06:00</text>
50
+ <text class="aqlp-chart-x-label" x="480" y="258">12:00</text>
51
+ <text class="aqlp-chart-x-label" x="720" y="258">18:00</text>
52
+ <text class="aqlp-chart-x-label" x="960" y="258" text-anchor="end">24:00</text>
53
+ </svg>
54
+ </div>
55
+ <div class="aqlp-event-row" aria-live="polite">
56
+ <span><i class="aqlp-event-dot aqlp-event-start"></i><strong data-event-start>{initialWindow.startTime} {ui.lightsOnLabel}</strong></span>
57
+ <span><i class="aqlp-event-dot aqlp-event-full"></i><strong data-event-full>{initialFullPhase?.startTime ?? initialWindow.startTime} {ui.fullLightLabel}</strong></span>
58
+ <span><i class="aqlp-event-dot aqlp-event-end"></i><strong data-event-end>{initialWindow.endTime} {ui.lightsOffLabel}</strong></span>
59
+ </div>
60
+ </div>
61
+
62
+ <div class="aqlp-summary-row">
63
+ <div><span>{ui.targetBandLabel}</span><strong data-target-band>7 to 9 {ui.hoursUnit}</strong></div>
64
+ <div><span>{ui.fullIntensityLabel}</span><strong data-full-intensity>7 {ui.hoursUnit}</strong></div>
65
+ </div>
66
+ <div class="aqlp-co2-row" data-co2-row><span class="aqlp-co2-mark">CO₂</span><span><strong>{ui.co2Label}</strong><small>{ui.co2Hint}</small></span><strong data-co2-window>07:00 → 15:00</strong></div>
67
+ <div class="aqlp-signal" aria-live="polite"><span class="aqlp-signal-mark" aria-hidden="true"></span><p data-adjustment>{ui.adjustmentInside}</p></div>
68
+ <div class="aqlp-legend"><span><i class="aqlp-legend-swatch aqlp-legend-ramp"></i>{ui.phaseRampUp}</span><span><i class="aqlp-legend-swatch aqlp-legend-full"></i>{ui.phaseFull}</span><span><i class="aqlp-legend-swatch aqlp-legend-dark"></i>{ui.phaseDark}</span></div>
69
+ <p class="aqlp-footnote">{ui.limitsText}</p>
70
+ </section>
71
+
72
+ <section class="aqlp-controls" aria-label={ui.controlsLabel}>
73
+ <div class="aqlp-controls-heading"><span class="aqlp-overline">{ui.controlsLabel}</span></div>
74
+ <div class="aqlp-control-stack">
75
+ <fieldset class="aqlp-control aqlp-choice-control"><legend>{ui.aquariumTypeLabel}</legend><div class="aqlp-choice-group" data-choice-group="aquariumType" role="group" aria-label={ui.aquariumTypeLabel}><button type="button" class="aqlp-choice" data-choice="aquariumType" data-value="tropical" aria-pressed="true">{ui.aquariumTypeTropical}</button><button type="button" class="aqlp-choice" data-choice="aquariumType" data-value="coldwater" aria-pressed="false">{ui.aquariumTypeColdwater}</button><button type="button" class="aqlp-choice" data-choice="aquariumType" data-value="marine" aria-pressed="false">{ui.aquariumTypeMarine}</button></div></fieldset>
76
+ <fieldset class="aqlp-control aqlp-choice-control"><legend>{ui.plantProfileLabel}</legend><div class="aqlp-choice-group" data-choice-group="plantProfile" role="group" aria-label={ui.plantProfileLabel}><button type="button" class="aqlp-choice" data-choice="plantProfile" data-value="none" aria-pressed="false">{ui.plantProfileNone}</button><button type="button" class="aqlp-choice" data-choice="plantProfile" data-value="low" aria-pressed="true">{ui.plantProfileLow}</button><button type="button" class="aqlp-choice" data-choice="plantProfile" data-value="high" aria-pressed="false">{ui.plantProfileHigh}</button></div></fieldset>
77
+ <div class="aqlp-control"><label for="aqlp-start-time">{ui.startTimeLabel}</label><input id="aqlp-start-time" class="aqlp-input" data-field="startTime" type="time" value={initial.input.startTime} /></div>
78
+ <div class="aqlp-control aqlp-range-control"><div class="aqlp-label-line"><label for="aqlp-photoperiod">{ui.photoperiodLabel}</label><output data-output="photoperiodHours">8 {ui.hoursUnit}</output></div><input id="aqlp-photoperiod" data-field="photoperiodHours" data-unit={ui.hoursUnit} type="range" min="6" max="12" step="0.5" value={initial.input.photoperiodHours} /></div>
79
+ </div>
80
+
81
+ <details class="aqlp-advanced"><summary class="aqlp-details-summary">{ui.optionalRampsLabel}<span>{ui.optionalRampsSummary}</span></summary><div class="aqlp-ramp-grid"><div class="aqlp-control aqlp-range-control"><div class="aqlp-label-line"><label for="aqlp-ramp-up">{ui.rampUpLabel}</label><output data-output="rampUpMinutes">30 {ui.minutesUnit}</output></div><input id="aqlp-ramp-up" data-field="rampUpMinutes" data-unit={ui.minutesUnit} type="range" min="0" max="180" step="15" value={initial.input.rampUpMinutes} /></div><div class="aqlp-control aqlp-range-control"><div class="aqlp-label-line"><label for="aqlp-ramp-down">{ui.rampDownLabel}</label><output data-output="rampDownMinutes">30 {ui.minutesUnit}</output></div><input id="aqlp-ramp-down" data-field="rampDownMinutes" data-unit={ui.minutesUnit} type="range" min="0" max="180" step="15" value={initial.input.rampDownMinutes} /></div></div></details>
82
+ </section>
83
+ </div>
84
+
85
+ <script is:inline type="application/json" id="aqlp-data" set:html={JSON.stringify({ ui })}></script>
86
+ <script>
87
+ import { mountPlanner } from './controller';
88
+
89
+ const data = JSON.parse(document.getElementById('aqlp-data')?.textContent ?? '{}');
90
+ const root = document.querySelector<HTMLElement>('[data-aqlp-root]');
91
+ if (root && data.ui) mountPlanner(root, data.ui);
92
+ </script>
@@ -0,0 +1,179 @@
1
+ import {
2
+ calculateSchedule,
3
+ DEFAULT_INPUT,
4
+ type AquariumType,
5
+ type LightingInput,
6
+ type PlantProfile,
7
+ } from "./logic";
8
+ import { renderSchedule } from "./dom-views";
9
+ import { loadInput, saveInput } from "./storage";
10
+ import type { AquariumLightingPhotoperiodPlannerUI } from "./ui";
11
+
12
+ type ControllerRoot = HTMLElement;
13
+
14
+ function readNumber(
15
+ root: ControllerRoot,
16
+ field: string,
17
+ fallback: number,
18
+ ): number {
19
+ const input = root.querySelector<HTMLInputElement>(`[data-field="${field}"]`);
20
+ const value = Number(input?.value);
21
+ return Number.isFinite(value) ? value : fallback;
22
+ }
23
+
24
+ function readInput(root: ControllerRoot): LightingInput {
25
+ const aquariumType = root.querySelector<HTMLButtonElement>(
26
+ '[data-choice-group="aquariumType"] [aria-pressed="true"]',
27
+ )?.dataset.value as AquariumType | undefined;
28
+ const plantProfile = root.querySelector<HTMLButtonElement>(
29
+ '[data-choice-group="plantProfile"] [aria-pressed="true"]',
30
+ )?.dataset.value as PlantProfile | undefined;
31
+ return {
32
+ aquariumType: aquariumType ?? DEFAULT_INPUT.aquariumType,
33
+ plantProfile: plantProfile ?? DEFAULT_INPUT.plantProfile,
34
+ startTime:
35
+ root.querySelector<HTMLInputElement>('[data-field="startTime"]')?.value ??
36
+ DEFAULT_INPUT.startTime,
37
+ photoperiodHours: readNumber(
38
+ root,
39
+ "photoperiodHours",
40
+ DEFAULT_INPUT.photoperiodHours,
41
+ ),
42
+ rampUpMinutes: readNumber(
43
+ root,
44
+ "rampUpMinutes",
45
+ DEFAULT_INPUT.rampUpMinutes,
46
+ ),
47
+ rampDownMinutes: readNumber(
48
+ root,
49
+ "rampDownMinutes",
50
+ DEFAULT_INPUT.rampDownMinutes,
51
+ ),
52
+ };
53
+ }
54
+
55
+ function updateSliderOutput(
56
+ root: ControllerRoot,
57
+ field: string,
58
+ value: string,
59
+ unit: string,
60
+ ): void {
61
+ const output = root.querySelector(`[data-output="${field}"]`);
62
+ if (output) output.textContent = `${value} ${unit}`;
63
+ }
64
+
65
+ function setFields(root: ControllerRoot, input: LightingInput): void {
66
+ const fields = [
67
+ "startTime",
68
+ "photoperiodHours",
69
+ "rampUpMinutes",
70
+ "rampDownMinutes",
71
+ ] as const;
72
+ fields.forEach((field) => {
73
+ const element = root.querySelector<HTMLInputElement>(
74
+ `[data-field="${field}"]`,
75
+ );
76
+ if (element) element.value = String(input[field]);
77
+ });
78
+ }
79
+
80
+ function setSliderOutputs(
81
+ root: ControllerRoot,
82
+ input: LightingInput,
83
+ ui: AquariumLightingPhotoperiodPlannerUI,
84
+ ): void {
85
+ updateSliderOutput(
86
+ root,
87
+ "photoperiodHours",
88
+ String(input.photoperiodHours),
89
+ ui.hoursUnit,
90
+ );
91
+ updateSliderOutput(
92
+ root,
93
+ "rampUpMinutes",
94
+ String(input.rampUpMinutes),
95
+ ui.minutesUnit,
96
+ );
97
+ updateSliderOutput(
98
+ root,
99
+ "rampDownMinutes",
100
+ String(input.rampDownMinutes),
101
+ ui.minutesUnit,
102
+ );
103
+ }
104
+
105
+ function setInputValues(
106
+ root: ControllerRoot,
107
+ input: LightingInput,
108
+ ui: AquariumLightingPhotoperiodPlannerUI,
109
+ ): void {
110
+ setFields(root, input);
111
+ setSliderOutputs(root, input, ui);
112
+ selectChoice(root, "aquariumType", input.aquariumType);
113
+ selectChoice(root, "plantProfile", input.plantProfile);
114
+ }
115
+
116
+ function selectChoice(
117
+ root: ControllerRoot,
118
+ field: string,
119
+ value: string,
120
+ ): void {
121
+ root
122
+ .querySelectorAll<HTMLButtonElement>(
123
+ `[data-choice-group="${field}"] [data-choice]`,
124
+ )
125
+ .forEach((item) => {
126
+ item.setAttribute("aria-pressed", String(item.dataset.value === value));
127
+ });
128
+ }
129
+
130
+ function bindChoices(root: ControllerRoot, refresh: () => void): void {
131
+ root
132
+ .querySelectorAll<HTMLButtonElement>("[data-choice]")
133
+ .forEach((choice) => {
134
+ choice.addEventListener("click", () => {
135
+ selectChoice(
136
+ root,
137
+ choice.dataset.choice ?? "",
138
+ choice.dataset.value ?? "",
139
+ );
140
+ refresh();
141
+ });
142
+ });
143
+ }
144
+
145
+ function bindInputs(
146
+ root: ControllerRoot,
147
+ refresh: () => void,
148
+ ui: AquariumLightingPhotoperiodPlannerUI,
149
+ ): void {
150
+ root.querySelectorAll<HTMLInputElement>("[data-field]").forEach((input) => {
151
+ input.addEventListener("input", () => {
152
+ if (input.type === "range")
153
+ updateSliderOutput(
154
+ root,
155
+ input.dataset.field ?? "",
156
+ input.value,
157
+ input.dataset.unit ?? ui.hoursUnit,
158
+ );
159
+ refresh();
160
+ });
161
+ input.addEventListener("change", refresh);
162
+ });
163
+ }
164
+
165
+ export function mountPlanner(
166
+ root: ControllerRoot,
167
+ ui: AquariumLightingPhotoperiodPlannerUI,
168
+ ): void {
169
+ const saved = loadInput() ?? DEFAULT_INPUT;
170
+ setInputValues(root, saved, ui);
171
+ const refresh = () => {
172
+ const input = readInput(root);
173
+ saveInput(input);
174
+ renderSchedule(root, calculateSchedule(input), ui);
175
+ };
176
+ bindChoices(root, refresh);
177
+ bindInputs(root, refresh, ui);
178
+ refresh();
179
+ }
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getIntensityChartMarkers, getIntensityChartPaths } from "./dom-views";
3
+ import { calculateSchedule, DEFAULT_INPUT } from "./logic";
4
+
5
+ describe("aquarium lighting photoperiod planner chart", () => {
6
+ it("anchors chart markers to the calculated light phases", () => {
7
+ const schedule = calculateSchedule(DEFAULT_INPUT);
8
+
9
+ expect(getIntensityChartMarkers(schedule)).toEqual({
10
+ start: 480,
11
+ full: 510,
12
+ end: 960,
13
+ });
14
+ });
15
+
16
+ it("returns a filled trapecio path with the daily clock boundaries", () => {
17
+ const schedule = calculateSchedule(DEFAULT_INPUT);
18
+ const paths = getIntensityChartPaths(schedule);
19
+
20
+ expect(paths.line).toMatch(/^M 0\.00 220\.00/);
21
+ expect(paths.line).toContain("320.00 220.00");
22
+ expect(paths.line).toContain("340.00 40.00");
23
+ expect(paths.line).toContain("640.00 220.00");
24
+ expect(paths.fill).toMatch(/L 960 220 L 0 220 Z$/);
25
+ });
26
+ });
@@ -0,0 +1,187 @@
1
+ import { evaluateSchedule } from "./evaluator";
2
+ import {
3
+ formatHours,
4
+ getLightWindow,
5
+ type LightingSchedule,
6
+ type PhaseSegment,
7
+ } from "./logic";
8
+ import type { AquariumLightingPhotoperiodPlannerUI } from "./ui";
9
+
10
+ interface RenderRoot extends HTMLElement {
11
+ querySelector(selector: string): HTMLElement | null;
12
+ }
13
+
14
+ function clockMinutes(value: string): number {
15
+ const [hourText = "0", minuteText = "0"] = value.split(":");
16
+ const hours = Number(hourText);
17
+ const minutes = Number(minuteText);
18
+ return hours * 60 + minutes;
19
+ }
20
+
21
+ function levelForPhase(phase: PhaseSegment["phase"], progress: number): number {
22
+ if (phase === "ramp-up") return progress;
23
+ if (phase === "ramp-down") return 1 - progress;
24
+ if (phase === "full") return 1;
25
+ return 0;
26
+ }
27
+
28
+ function chartSegment(
29
+ phase: PhaseSegment,
30
+ ): Array<{ time: number; level: number }> {
31
+ const start = clockMinutes(phase.startTime);
32
+ const firstDuration = Math.min(phase.duration, 1440 - start);
33
+ const end = start + firstDuration;
34
+ const startLevel = levelForPhase(phase.phase, 0);
35
+ const endProgress = phase.duration ? firstDuration / phase.duration : 1;
36
+ const endLevel = levelForPhase(phase.phase, endProgress);
37
+ const points = [
38
+ { time: start, level: startLevel },
39
+ { time: end, level: endLevel },
40
+ ];
41
+ const secondDuration = phase.duration - firstDuration;
42
+ if (secondDuration > 0) {
43
+ points.push(
44
+ { time: 0, level: endLevel },
45
+ { time: secondDuration, level: levelForPhase(phase.phase, 1) },
46
+ );
47
+ }
48
+ return points;
49
+ }
50
+
51
+ function chartCoordinate(point: { time: number; level: number }): string {
52
+ const x = (point.time / 1440) * 960;
53
+ const y = 220 - point.level * 180;
54
+ return `${x.toFixed(2)} ${y.toFixed(2)}`;
55
+ }
56
+
57
+ export function getIntensityChartPaths(schedule: LightingSchedule): {
58
+ fill: string;
59
+ line: string;
60
+ } {
61
+ const points = schedule.phases
62
+ .flatMap(chartSegment)
63
+ .sort((left, right) => left.time - right.time);
64
+ const unique = points.filter(
65
+ (point, index) => index === 0 || point.time !== points[index - 1]?.time,
66
+ );
67
+ const line = unique.map(chartCoordinate).join(" L ");
68
+ return { line: `M ${line}`, fill: `M ${line} L 960 220 L 0 220 Z` };
69
+ }
70
+
71
+ export function getIntensityChartMarkers(schedule: LightingSchedule): {
72
+ start: number;
73
+ full: number;
74
+ end: number;
75
+ } {
76
+ const lightWindow = getLightWindow(schedule);
77
+ const fullPhase = schedule.phases.find((phase) => phase.phase === "full");
78
+ return {
79
+ start: clockMinutes(lightWindow.startTime),
80
+ full: clockMinutes(fullPhase?.startTime ?? lightWindow.startTime),
81
+ end: clockMinutes(lightWindow.endTime),
82
+ };
83
+ }
84
+
85
+ function subtractHour(value: string): string {
86
+ const minutes = (clockMinutes(value) + 1380) % 1440;
87
+ const hour = Math.floor(minutes / 60)
88
+ .toString()
89
+ .padStart(2, "0");
90
+ const minute = (minutes % 60).toString().padStart(2, "0");
91
+ return `${hour}:${minute}`;
92
+ }
93
+
94
+ function updateText(root: RenderRoot, selector: string, value: string): void {
95
+ const element = root.querySelector(selector);
96
+ if (element) element.textContent = value;
97
+ }
98
+
99
+ function updateAnswer(
100
+ root: RenderRoot,
101
+ schedule: LightingSchedule,
102
+ ui: AquariumLightingPhotoperiodPlannerUI,
103
+ ): void {
104
+ const evaluation = evaluateSchedule(schedule, ui);
105
+ const lightWindow = getLightWindow(schedule);
106
+ updateText(root, "[data-status-badge]", evaluation.badge);
107
+ updateText(root, "[data-target-band]", evaluation.differenceLabel);
108
+ updateText(
109
+ root,
110
+ "[data-light-window]",
111
+ `${lightWindow.startTime} → ${lightWindow.endTime}`,
112
+ );
113
+ updateText(
114
+ root,
115
+ "[data-full-intensity]",
116
+ `${formatHours(schedule.fullIntensityMinutes / 60)} ${ui.hoursUnit}`,
117
+ );
118
+ updateText(root, "[data-adjustment]", evaluation.adjustment);
119
+ updateText(
120
+ root,
121
+ "[data-photoperiod-value]",
122
+ `${formatHours(schedule.input.photoperiodHours)} ${ui.hoursUnit}`,
123
+ );
124
+ }
125
+
126
+ function updateEvents(
127
+ root: RenderRoot,
128
+ schedule: LightingSchedule,
129
+ ui: AquariumLightingPhotoperiodPlannerUI,
130
+ ): void {
131
+ const lightWindow = getLightWindow(schedule);
132
+ const fullPhase = schedule.phases.find((phase) => phase.phase === "full");
133
+ updateText(
134
+ root,
135
+ "[data-event-start]",
136
+ `${lightWindow.startTime} ${ui.lightsOnLabel}`,
137
+ );
138
+ updateText(
139
+ root,
140
+ "[data-event-full]",
141
+ `${fullPhase?.startTime ?? lightWindow.startTime} ${ui.fullLightLabel}`,
142
+ );
143
+ updateText(
144
+ root,
145
+ "[data-event-end]",
146
+ `${lightWindow.endTime} ${ui.lightsOffLabel}`,
147
+ );
148
+ updateText(
149
+ root,
150
+ "[data-co2-window]",
151
+ `${subtractHour(lightWindow.startTime)} → ${subtractHour(lightWindow.endTime)}`,
152
+ );
153
+ }
154
+
155
+ function updateCo2(root: RenderRoot, schedule: LightingSchedule): void {
156
+ const co2Row = root.querySelector("[data-co2-row]");
157
+ if (co2Row) co2Row.hidden = schedule.input.plantProfile === "none";
158
+ }
159
+
160
+ function updateChart(root: RenderRoot, schedule: LightingSchedule): void {
161
+ const chartPaths = getIntensityChartPaths(schedule);
162
+ const chartFill = root.querySelector("[data-chart-fill]");
163
+ const chartLine = root.querySelector("[data-chart-line]");
164
+ if (chartFill) chartFill.setAttribute("d", chartPaths.fill);
165
+ if (chartLine) chartLine.setAttribute("d", chartPaths.line);
166
+ const markers = getIntensityChartMarkers(schedule);
167
+ const startMarker = root.querySelector("[data-chart-marker-start]");
168
+ const fullMarker = root.querySelector("[data-chart-marker-full]");
169
+ const endMarker = root.querySelector("[data-chart-marker-end]");
170
+ if (startMarker)
171
+ startMarker.setAttribute("cx", String((markers.start / 1440) * 960));
172
+ if (fullMarker)
173
+ fullMarker.setAttribute("cx", String((markers.full / 1440) * 960));
174
+ if (endMarker)
175
+ endMarker.setAttribute("cx", String((markers.end / 1440) * 960));
176
+ }
177
+
178
+ export function renderSchedule(
179
+ root: RenderRoot,
180
+ schedule: LightingSchedule,
181
+ ui: AquariumLightingPhotoperiodPlannerUI,
182
+ ): void {
183
+ updateAnswer(root, schedule, ui);
184
+ updateEvents(root, schedule, ui);
185
+ updateCo2(root, schedule);
186
+ updateChart(root, schedule);
187
+ }
@@ -0,0 +1,29 @@
1
+ import type { AquariumToolEntry, ToolLocaleContent } from "../../types";
2
+ import type { AquariumLightingPhotoperiodPlannerUI } from "./ui";
3
+
4
+ export type { AquariumLightingPhotoperiodPlannerUI };
5
+ export type AquariumLightingPhotoperiodPlannerLocaleContent =
6
+ ToolLocaleContent<AquariumLightingPhotoperiodPlannerUI>;
7
+
8
+ export const aquariumLightingPhotoperiodPlanner: AquariumToolEntry<AquariumLightingPhotoperiodPlannerUI> =
9
+ {
10
+ id: "aquarium-lighting-photoperiod-schedule-planner",
11
+ icons: { bg: "mdi:lightbulb-on-outline", fg: "mdi:fishbowl-outline" },
12
+ i18n: {
13
+ de: () => import('./i18n/de').then((m) => m.content),
14
+ en: () => import('./i18n/en').then((m) => m.content),
15
+ es: () => import('./i18n/es').then((m) => m.content),
16
+ fr: () => import('./i18n/fr').then((m) => m.content),
17
+ id: () => import('./i18n/id').then((m) => m.content),
18
+ it: () => import('./i18n/it').then((m) => m.content),
19
+ ja: () => import('./i18n/ja').then((m) => m.content),
20
+ ko: () => import('./i18n/ko').then((m) => m.content),
21
+ nl: () => import('./i18n/nl').then((m) => m.content),
22
+ pl: () => import('./i18n/pl').then((m) => m.content),
23
+ pt: () => import('./i18n/pt').then((m) => m.content),
24
+ ru: () => import('./i18n/ru').then((m) => m.content),
25
+ sv: () => import('./i18n/sv').then((m) => m.content),
26
+ tr: () => import('./i18n/tr').then((m) => m.content),
27
+ zh: () => import('./i18n/zh').then((m) => m.content),
28
+ },
29
+ };
@@ -0,0 +1,47 @@
1
+ import type { AquariumLightingPhotoperiodPlannerUI } from "./ui";
2
+ import type { LightingSchedule, ScheduleStatus } from "./logic";
3
+ import { formatHours } from "./logic";
4
+
5
+ export interface ScheduleEvaluation {
6
+ badge: string;
7
+ adjustment: string;
8
+ differenceLabel: string;
9
+ }
10
+
11
+ function getStatusLabel(
12
+ status: ScheduleStatus,
13
+ ui: AquariumLightingPhotoperiodPlannerUI,
14
+ ): string {
15
+ if (status === "below") return ui.statusBelow;
16
+ if (status === "above") return ui.statusAbove;
17
+ return ui.statusInside;
18
+ }
19
+
20
+ function getAdjustment(
21
+ status: ScheduleStatus,
22
+ schedule: LightingSchedule,
23
+ ui: AquariumLightingPhotoperiodPlannerUI,
24
+ ): string {
25
+ if (status === "below")
26
+ return ui.adjustmentBelow.replace(
27
+ "{hours}",
28
+ formatHours(schedule.differenceHours),
29
+ );
30
+ if (status === "above")
31
+ return ui.adjustmentAbove.replace(
32
+ "{hours}",
33
+ formatHours(schedule.differenceHours),
34
+ );
35
+ return ui.adjustmentInside;
36
+ }
37
+
38
+ export function evaluateSchedule(
39
+ schedule: LightingSchedule,
40
+ ui: AquariumLightingPhotoperiodPlannerUI,
41
+ ): ScheduleEvaluation {
42
+ return {
43
+ badge: getStatusLabel(schedule.status, ui),
44
+ adjustment: getAdjustment(schedule.status, schedule, ui),
45
+ differenceLabel: `${formatHours(schedule.band.low)} to ${formatHours(schedule.band.high)} ${ui.hoursUnit}`,
46
+ };
47
+ }