@jjlmoya/utils-aquarium 1.59.0 → 1.60.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 (25) hide show
  1. package/package.json +1 -1
  2. package/src/tool/aquariumLightingPhotoperiodPlanner/aquarium-lighting-photoperiod-planner.css +9 -9
  3. package/src/tool/aquariumLightingPhotoperiodPlanner/component.astro +3 -1
  4. package/src/tool/aquariumLightingPhotoperiodPlanner/controller.ts +21 -9
  5. package/src/tool/aquariumLightingPhotoperiodPlanner/dom-views.test.ts +2 -1
  6. package/src/tool/aquariumLightingPhotoperiodPlanner/dom-views.ts +20 -8
  7. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/de.ts +2 -0
  8. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/en.ts +2 -0
  9. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/es.ts +2 -0
  10. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/fr.ts +2 -0
  11. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/id.ts +2 -0
  12. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/it.ts +2 -0
  13. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/ja.ts +2 -0
  14. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/ko.ts +2 -0
  15. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/nl.ts +2 -0
  16. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/pl.ts +2 -0
  17. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/pt.ts +2 -0
  18. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/ru.ts +2 -0
  19. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/sv.ts +2 -0
  20. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/tr.ts +2 -0
  21. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/zh.ts +2 -0
  22. package/src/tool/aquariumLightingPhotoperiodPlanner/logic.test.ts +10 -0
  23. package/src/tool/aquariumLightingPhotoperiodPlanner/logic.ts +10 -0
  24. package/src/tool/aquariumLightingPhotoperiodPlanner/storage.ts +12 -8
  25. package/src/tool/aquariumLightingPhotoperiodPlanner/ui.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-aquarium",
3
- "version": "1.59.0",
3
+ "version": "1.60.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -259,7 +259,7 @@
259
259
 
260
260
  .aqlp-summary-row {
261
261
  display: grid;
262
- grid-template-columns: repeat(2, minmax(0, 1fr));
262
+ grid-template-columns: repeat(3, minmax(0, 1fr));
263
263
  gap: 24px;
264
264
  margin-top: 22px;
265
265
  padding: 14px 0;
@@ -617,6 +617,14 @@
617
617
  gap: 5px;
618
618
  }
619
619
 
620
+ .aqlp-summary-row {
621
+ gap: 12px;
622
+ }
623
+
624
+ .aqlp-summary-row div + div {
625
+ padding-left: 12px;
626
+ }
627
+
620
628
  .aqlp-event-row span {
621
629
  display: grid;
622
630
  grid-template-columns: auto minmax(0, 1fr);
@@ -633,14 +641,6 @@
633
641
  text-align: right;
634
642
  }
635
643
 
636
- .aqlp-summary-row {
637
- gap: 12px;
638
- }
639
-
640
- .aqlp-summary-row div + div {
641
- padding-left: 12px;
642
- }
643
-
644
644
  .aqlp-co2-row {
645
645
  grid-template-columns: auto minmax(0, 1fr);
646
646
  }
@@ -40,7 +40,7 @@ const initialFullPhase = initial.phases.find((phase) => phase.phase === 'full');
40
40
  <path class="aqlp-chart-fill" data-chart-fill d={initialChart.fill}></path>
41
41
  <path class="aqlp-chart-line" data-chart-line d={initialChart.line}></path>
42
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>
43
+ <circle class="aqlp-chart-marker aqlp-chart-marker-full" data-chart-marker-full cx={(initialMarkers.full / 1440) * 960} cy={220 - (initial.input.intensityPercent / 100) * 180} r="8"></circle>
44
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
45
  <text class="aqlp-chart-y-label" x="12" y="27">100%</text>
46
46
  <text class="aqlp-chart-y-label" x="12" y="126">50%</text>
@@ -62,6 +62,7 @@ const initialFullPhase = initial.phases.find((phase) => phase.phase === 'full');
62
62
  <div class="aqlp-summary-row">
63
63
  <div><span>{ui.targetBandLabel}</span><strong data-target-band>7 to 9 {ui.hoursUnit}</strong></div>
64
64
  <div><span>{ui.fullIntensityLabel}</span><strong data-full-intensity>7 {ui.hoursUnit}</strong></div>
65
+ <div><span>{ui.fixtureLevelLabel}</span><strong data-intensity>{initial.input.intensityPercent}%</strong></div>
65
66
  </div>
66
67
  <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
68
  <div class="aqlp-signal" aria-live="polite"><span class="aqlp-signal-mark" aria-hidden="true"></span><p data-adjustment>{ui.adjustmentInside}</p></div>
@@ -76,6 +77,7 @@ const initialFullPhase = initial.phases.find((phase) => phase.phase === 'full');
76
77
  <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
78
  <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
79
  <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>
80
+ <div class="aqlp-control aqlp-range-control"><div class="aqlp-label-line"><label for="aqlp-intensity">{ui.intensityLabel}</label><output data-output="intensityPercent">70%</output></div><input id="aqlp-intensity" data-field="intensityPercent" data-unit="%" type="range" min="10" max="100" step="5" value={initial.input.intensityPercent} /></div>
79
81
  </div>
80
82
 
81
83
  <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>
@@ -21,24 +21,29 @@ function readNumber(
21
21
  return Number.isFinite(value) ? value : fallback;
22
22
  }
23
23
 
24
+ function readChoice(root: ControllerRoot, field: string): string | undefined {
25
+ return root.querySelector<HTMLButtonElement>(
26
+ `[data-choice-group="${field}"] [aria-pressed="true"]`,
27
+ )?.dataset.value;
28
+ }
29
+
24
30
  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
+ const aquariumType = readChoice(root, "aquariumType") as AquariumType | undefined;
32
+ const plantProfile = readChoice(root, "plantProfile") as PlantProfile | undefined;
31
33
  return {
32
34
  aquariumType: aquariumType ?? DEFAULT_INPUT.aquariumType,
33
35
  plantProfile: plantProfile ?? DEFAULT_INPUT.plantProfile,
34
- startTime:
35
- root.querySelector<HTMLInputElement>('[data-field="startTime"]')?.value ??
36
- DEFAULT_INPUT.startTime,
36
+ startTime: root.querySelector<HTMLInputElement>('[data-field="startTime"]')?.value ?? DEFAULT_INPUT.startTime,
37
37
  photoperiodHours: readNumber(
38
38
  root,
39
39
  "photoperiodHours",
40
40
  DEFAULT_INPUT.photoperiodHours,
41
41
  ),
42
+ intensityPercent: readNumber(
43
+ root,
44
+ "intensityPercent",
45
+ DEFAULT_INPUT.intensityPercent,
46
+ ),
42
47
  rampUpMinutes: readNumber(
43
48
  root,
44
49
  "rampUpMinutes",
@@ -66,6 +71,7 @@ function setFields(root: ControllerRoot, input: LightingInput): void {
66
71
  const fields = [
67
72
  "startTime",
68
73
  "photoperiodHours",
74
+ "intensityPercent",
69
75
  "rampUpMinutes",
70
76
  "rampDownMinutes",
71
77
  ] as const;
@@ -88,6 +94,12 @@ function setSliderOutputs(
88
94
  String(input.photoperiodHours),
89
95
  ui.hoursUnit,
90
96
  );
97
+ updateSliderOutput(
98
+ root,
99
+ "intensityPercent",
100
+ String(input.intensityPercent),
101
+ "%",
102
+ );
91
103
  updateSliderOutput(
92
104
  root,
93
105
  "rampUpMinutes",
@@ -19,7 +19,8 @@ describe("aquarium lighting photoperiod planner chart", () => {
19
19
 
20
20
  expect(paths.line).toMatch(/^M 0\.00 220\.00/);
21
21
  expect(paths.line).toContain("320.00 220.00");
22
- expect(paths.line).toContain("340.00 40.00");
22
+ expect(paths.line).toContain("340.00 94.00");
23
+ expect(paths.line).toContain("620.00 94.00");
23
24
  expect(paths.line).toContain("640.00 220.00");
24
25
  expect(paths.fill).toMatch(/L 960 220 L 0 220 Z$/);
25
26
  });
@@ -18,22 +18,27 @@ function clockMinutes(value: string): number {
18
18
  return hours * 60 + minutes;
19
19
  }
20
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;
21
+ function levelForPhase(
22
+ phase: PhaseSegment["phase"],
23
+ progress: number,
24
+ peak: number,
25
+ ): number {
26
+ if (phase === "ramp-up") return progress * peak;
27
+ if (phase === "ramp-down") return (1 - progress) * peak;
28
+ if (phase === "full") return peak;
25
29
  return 0;
26
30
  }
27
31
 
28
32
  function chartSegment(
29
33
  phase: PhaseSegment,
34
+ peak: number,
30
35
  ): Array<{ time: number; level: number }> {
31
36
  const start = clockMinutes(phase.startTime);
32
37
  const firstDuration = Math.min(phase.duration, 1440 - start);
33
38
  const end = start + firstDuration;
34
- const startLevel = levelForPhase(phase.phase, 0);
39
+ const startLevel = levelForPhase(phase.phase, 0, peak);
35
40
  const endProgress = phase.duration ? firstDuration / phase.duration : 1;
36
- const endLevel = levelForPhase(phase.phase, endProgress);
41
+ const endLevel = levelForPhase(phase.phase, endProgress, peak);
37
42
  const points = [
38
43
  { time: start, level: startLevel },
39
44
  { time: end, level: endLevel },
@@ -42,7 +47,7 @@ function chartSegment(
42
47
  if (secondDuration > 0) {
43
48
  points.push(
44
49
  { time: 0, level: endLevel },
45
- { time: secondDuration, level: levelForPhase(phase.phase, 1) },
50
+ { time: secondDuration, level: levelForPhase(phase.phase, 1, peak) },
46
51
  );
47
52
  }
48
53
  return points;
@@ -58,8 +63,9 @@ export function getIntensityChartPaths(schedule: LightingSchedule): {
58
63
  fill: string;
59
64
  line: string;
60
65
  } {
66
+ const peak = schedule.input.intensityPercent / 100;
61
67
  const points = schedule.phases
62
- .flatMap(chartSegment)
68
+ .flatMap((phase) => chartSegment(phase, peak))
63
69
  .sort((left, right) => left.time - right.time);
64
70
  const unique = points.filter(
65
71
  (point, index) => index === 0 || point.time !== points[index - 1]?.time,
@@ -115,6 +121,7 @@ function updateAnswer(
115
121
  "[data-full-intensity]",
116
122
  `${formatHours(schedule.fullIntensityMinutes / 60)} ${ui.hoursUnit}`,
117
123
  );
124
+ updateText(root, "[data-intensity]", `${schedule.input.intensityPercent}%`);
118
125
  updateText(root, "[data-adjustment]", evaluation.adjustment);
119
126
  updateText(
120
127
  root,
@@ -171,6 +178,11 @@ function updateChart(root: RenderRoot, schedule: LightingSchedule): void {
171
178
  startMarker.setAttribute("cx", String((markers.start / 1440) * 960));
172
179
  if (fullMarker)
173
180
  fullMarker.setAttribute("cx", String((markers.full / 1440) * 960));
181
+ if (fullMarker)
182
+ fullMarker.setAttribute(
183
+ "cy",
184
+ String(220 - (schedule.input.intensityPercent / 100) * 180),
185
+ );
174
186
  if (endMarker)
175
187
  endMarker.setAttribute("cx", String((markers.end / 1440) * 960));
176
188
  }
@@ -44,6 +44,7 @@ const ui = {
44
44
  plantProfileHigh: 'Lichtintensive Pflanzen',
45
45
  startTimeLabel: 'Startzeit des Tageszyklus',
46
46
  photoperiodLabel: 'Photoperiode Gesamtdauer',
47
+ intensityLabel: 'Einstellung der Lampenleistung',
47
48
  rampUpLabel: 'Sonnenaufgang Aufdimmen',
48
49
  rampDownLabel: 'Sonnenuntergang Abdimmen',
49
50
  optionalRampsLabel: 'Optionale Dimmphasen',
@@ -56,6 +57,7 @@ const ui = {
56
57
  targetBandLabel: 'Orientierungsrahmen',
57
58
  lightWindowLabel: 'Beleuchtungsfenster',
58
59
  fullIntensityLabel: 'Volle Lichtintensität',
60
+ fixtureLevelLabel: 'Lampenstufe',
59
61
  lightsOnLabel: 'Licht an',
60
62
  fullLightLabel: 'Volllicht',
61
63
  lightsOffLabel: 'Licht aus',
@@ -44,6 +44,7 @@ const ui = {
44
44
  plantProfileHigh: 'High light plants',
45
45
  startTimeLabel: 'Cycle start time',
46
46
  photoperiodLabel: 'Photoperiod',
47
+ intensityLabel: 'Fixture output setting',
47
48
  rampUpLabel: 'Ramp up',
48
49
  rampDownLabel: 'Ramp down',
49
50
  optionalRampsLabel: 'Optional dimming ramps',
@@ -56,6 +57,7 @@ const ui = {
56
57
  targetBandLabel: 'Planning window',
57
58
  lightWindowLabel: 'Light window',
58
59
  fullIntensityLabel: 'Full intensity',
60
+ fixtureLevelLabel: 'Fixture level',
59
61
  lightsOnLabel: 'lights on',
60
62
  fullLightLabel: 'full light',
61
63
  lightsOffLabel: 'lights off',
@@ -29,6 +29,7 @@ const ui = {
29
29
  plantProfileHigh: 'Plantas exigentes en luz',
30
30
  startTimeLabel: 'Hora de inicio del ciclo',
31
31
  photoperiodLabel: 'Fotoperiodo total',
32
+ intensityLabel: 'Potencia configurada de la pantalla',
32
33
  rampUpLabel: 'Rampa de encendido amanecer',
33
34
  rampDownLabel: 'Rampa de apagado atardecer',
34
35
  optionalRampsLabel: 'Rampas de regulación opcionales',
@@ -41,6 +42,7 @@ const ui = {
41
42
  targetBandLabel: 'Banda de planificación',
42
43
  lightWindowLabel: 'Ventana de iluminación',
43
44
  fullIntensityLabel: 'Intensidad completa',
45
+ fixtureLevelLabel: 'Nivel de la pantalla',
44
46
  lightsOnLabel: 'encendido',
45
47
  fullLightLabel: 'luz completa',
46
48
  lightsOffLabel: 'apagado',
@@ -29,6 +29,7 @@ const ui = {
29
29
  plantProfileHigh: 'Plantes exigeantes en lumiere',
30
30
  startTimeLabel: 'Heure de début du cycle',
31
31
  photoperiodLabel: 'Photopériode totale',
32
+ intensityLabel: 'Réglage de puissance de la rampe',
32
33
  rampUpLabel: 'Rampe d allumage lever',
33
34
  rampDownLabel: 'Rampe d extinction coucher',
34
35
  optionalRampsLabel: 'Rampes de variation optionnelles',
@@ -41,6 +42,7 @@ const ui = {
41
42
  targetBandLabel: 'Plage de référence',
42
43
  lightWindowLabel: 'Fenêtre d eclairage',
43
44
  fullIntensityLabel: 'Pleine intensité',
45
+ fixtureLevelLabel: 'Niveau de la rampe',
44
46
  lightsOnLabel: 'allumage',
45
47
  fullLightLabel: 'pleine lumiere',
46
48
  lightsOffLabel: 'extinction',
@@ -29,6 +29,7 @@ const ui = {
29
29
  plantProfileHigh: 'Tanaman cahaya tinggi',
30
30
  startTimeLabel: 'Waktu mulai siklus',
31
31
  photoperiodLabel: 'Total fotoperiode',
32
+ intensityLabel: 'Tingkat daya lampu',
32
33
  rampUpLabel: 'Transisi redup terbit',
33
34
  rampDownLabel: 'Transisi redup terbenam',
34
35
  optionalRampsLabel: 'Transisi redup opsional',
@@ -41,6 +42,7 @@ const ui = {
41
42
  targetBandLabel: 'Rentang acuan',
42
43
  lightWindowLabel: 'Jendela pencahayaan',
43
44
  fullIntensityLabel: 'Intensitas penuh',
45
+ fixtureLevelLabel: 'Level lampu',
44
46
  lightsOnLabel: 'lampu menyala',
45
47
  fullLightLabel: 'cahaya penuh',
46
48
  lightsOffLabel: 'lampu padam',
@@ -29,6 +29,7 @@ const ui = {
29
29
  plantProfileHigh: 'Piante esigenti di luce',
30
30
  startTimeLabel: 'Ora di inizio del ciclo',
31
31
  photoperiodLabel: 'Fotoperiodo totale',
32
+ intensityLabel: 'Potenza impostata della lampada',
32
33
  rampUpLabel: 'Rampa di alba accensione',
33
34
  rampDownLabel: 'Rampa di tramonto spegnimento',
34
35
  optionalRampsLabel: 'Rampe di dimmerazione opzionali',
@@ -41,6 +42,7 @@ const ui = {
41
42
  targetBandLabel: 'Fascia di riferimento',
42
43
  lightWindowLabel: 'Finestra di illuminazione',
43
44
  fullIntensityLabel: 'Piena intensità',
45
+ fixtureLevelLabel: 'Livello della lampada',
44
46
  lightsOnLabel: 'accensione',
45
47
  fullLightLabel: 'piena luce',
46
48
  lightsOffLabel: 'spegnimento',
@@ -29,6 +29,7 @@ const ui = {
29
29
  plantProfileHigh: '高光量(有茎草など)',
30
30
  startTimeLabel: '点灯開始時間',
31
31
  photoperiodLabel: '合計照射時間',
32
+ intensityLabel: '照明の出力設定',
32
33
  rampUpLabel: '日の出(点灯調光)',
33
34
  rampDownLabel: '日の入り(消灯調光)',
34
35
  optionalRampsLabel: '調光設定(オプション)',
@@ -41,6 +42,7 @@ const ui = {
41
42
  targetBandLabel: '推奨照射範囲',
42
43
  lightWindowLabel: '照明タイマースケジュール',
43
44
  fullIntensityLabel: '全灯点灯時間',
45
+ fixtureLevelLabel: '出力レベル',
44
46
  lightsOnLabel: '点灯開始',
45
47
  fullLightLabel: '全灯照射',
46
48
  lightsOffLabel: '完全消灯',
@@ -29,6 +29,7 @@ const ui = {
29
29
  plantProfileHigh: '양성 수초 (고광량)',
30
30
  startTimeLabel: '조명 시작 시간',
31
31
  photoperiodLabel: '총 조명 시간',
32
+ intensityLabel: '조명 출력 설정',
32
33
  rampUpLabel: '일출 (점등 전환)',
33
34
  rampDownLabel: '일몰 (소등 전환)',
34
35
  optionalRampsLabel: '디밍 전환 설정 (선택)',
@@ -41,6 +42,7 @@ const ui = {
41
42
  targetBandLabel: '권장 시간 범위',
42
43
  lightWindowLabel: '조명 일정 시간표',
43
44
  fullIntensityLabel: '최대 광량 시간',
45
+ fixtureLevelLabel: '조명 단계',
44
46
  lightsOnLabel: '점등 시작',
45
47
  fullLightLabel: '최대 광량',
46
48
  lightsOffLabel: '완전 소등',
@@ -29,6 +29,7 @@ const ui = {
29
29
  plantProfileHigh: 'Lichtbehoeftige planten',
30
30
  startTimeLabel: 'Starttijd van de cyclus',
31
31
  photoperiodLabel: 'Totale fotoperiode',
32
+ intensityLabel: 'Ingesteld lampvermogen',
32
33
  rampUpLabel: 'Zonsopkomst (indimmen)',
33
34
  rampDownLabel: 'Zonsondergang (uitdimmen)',
34
35
  optionalRampsLabel: 'Optionele dimfasen',
@@ -41,6 +42,7 @@ const ui = {
41
42
  targetBandLabel: 'Richtlijn tijdsduur',
42
43
  lightWindowLabel: 'Verlichtingsvenster',
43
44
  fullIntensityLabel: 'Volle lichtsterkte',
45
+ fixtureLevelLabel: 'Lampniveau',
44
46
  lightsOnLabel: 'licht aan',
45
47
  fullLightLabel: 'vol licht',
46
48
  lightsOffLabel: 'licht uit',
@@ -29,6 +29,7 @@ const ui = {
29
29
  plantProfileHigh: 'Rosliny swiatlolubne',
30
30
  startTimeLabel: 'Czas startu cyklu',
31
31
  photoperiodLabel: 'Calkowity fotoperiod',
32
+ intensityLabel: 'Ustawiona moc lampy',
32
33
  rampUpLabel: 'Swit rozjasnianie',
33
34
  rampDownLabel: 'Zmierzch sciemnianie',
34
35
  optionalRampsLabel: 'Opcjonalne fazy sciemniania',
@@ -41,6 +42,7 @@ const ui = {
41
42
  targetBandLabel: 'Zakres docelowy',
42
43
  lightWindowLabel: 'Okno oswietlenia',
43
44
  fullIntensityLabel: 'Pelna moc oswietlenia',
45
+ fixtureLevelLabel: 'Poziom lampy',
44
46
  lightsOnLabel: 'wlaczanie',
45
47
  fullLightLabel: 'pelne swiatlo',
46
48
  lightsOffLabel: 'wylaczanie',
@@ -29,6 +29,7 @@ const ui = {
29
29
  plantProfileHigh: 'Plantas exigentes em luz',
30
30
  startTimeLabel: 'Hora de início do ciclo',
31
31
  photoperiodLabel: 'Fotoperíodo total',
32
+ intensityLabel: 'Potência definida da luminária',
32
33
  rampUpLabel: 'Rampa de amanhecer acendimento',
33
34
  rampDownLabel: 'Rampa de anoitecer desligamento',
34
35
  optionalRampsLabel: 'Rampas de regulação opcionais',
@@ -41,6 +42,7 @@ const ui = {
41
42
  targetBandLabel: 'Faixa de referência',
42
43
  lightWindowLabel: 'Janela de iluminação',
43
44
  fullIntensityLabel: 'Intensidade total',
45
+ fixtureLevelLabel: 'Nível da luminária',
44
46
  lightsOnLabel: 'acendimento',
45
47
  fullLightLabel: 'luz total',
46
48
  lightsOffLabel: 'desligamento',
@@ -29,6 +29,7 @@ const ui = {
29
29
  plantProfileHigh: 'Светолюбивые растения',
30
30
  startTimeLabel: 'Время начала светового дня',
31
31
  photoperiodLabel: 'Общий фотопериод',
32
+ intensityLabel: 'Настройка мощности светильника',
32
33
  rampUpLabel: 'Рассвет (плавное включение)',
33
34
  rampDownLabel: 'Закат (плавное выключение)',
34
35
  optionalRampsLabel: 'Фазы рассвета и заката (опционально)',
@@ -41,6 +42,7 @@ const ui = {
41
42
  targetBandLabel: 'Рекомендуемый диапазон',
42
43
  lightWindowLabel: 'График освещения',
43
44
  fullIntensityLabel: 'Полная мощность света',
45
+ fixtureLevelLabel: 'Уровень светильника',
44
46
  lightsOnLabel: 'включение',
45
47
  fullLightLabel: 'полный свет',
46
48
  lightsOffLabel: 'выключение',
@@ -29,6 +29,7 @@ const ui = {
29
29
  plantProfileHigh: 'Ljuskrävande växter',
30
30
  startTimeLabel: 'Starttid för cykeln',
31
31
  photoperiodLabel: 'Total fotoperiod',
32
+ intensityLabel: 'Inställd lampeffekt',
32
33
  rampUpLabel: 'Soluppgång (uppdimning)',
33
34
  rampDownLabel: 'Solnedgång (neddimning)',
34
35
  optionalRampsLabel: 'Valfria dimningsfaser',
@@ -41,6 +42,7 @@ const ui = {
41
42
  targetBandLabel: 'Rekommenderat intervall',
42
43
  lightWindowLabel: 'Belysningsfönster',
43
44
  fullIntensityLabel: 'Full ljusstyrka',
45
+ fixtureLevelLabel: 'Lampnivå',
44
46
  lightsOnLabel: 'tändning',
45
47
  fullLightLabel: 'fullt ljus',
46
48
  lightsOffLabel: 'släckning',
@@ -29,6 +29,7 @@ const ui = {
29
29
  plantProfileHigh: 'Yüksek ışık isteyen bitkiler',
30
30
  startTimeLabel: 'Döngü başlangıç saati',
31
31
  photoperiodLabel: 'Toplam fotoperiyod',
32
+ intensityLabel: 'Armatür güç ayarı',
32
33
  rampUpLabel: 'Gün doğumu geçişi (açılış)',
33
34
  rampDownLabel: 'Gün batımı geçişi (kapanış)',
34
35
  optionalRampsLabel: 'İsteğe bağlı karartma geçişleri',
@@ -41,6 +42,7 @@ const ui = {
41
42
  targetBandLabel: 'Hedef zaman aralığı',
42
43
  lightWindowLabel: 'Aydınlatma penceresi',
43
44
  fullIntensityLabel: 'Tam ışık gücü',
45
+ fixtureLevelLabel: 'Armatür seviyesi',
44
46
  lightsOnLabel: 'ışıklar açık',
45
47
  fullLightLabel: 'tam ışık',
46
48
  lightsOffLabel: 'ışıklar kapalı',
@@ -29,6 +29,7 @@ const ui = {
29
29
  plantProfileHigh: '高光照水草(阳性草)',
30
30
  startTimeLabel: '光照周期起始时间',
31
31
  photoperiodLabel: '光照总时长',
32
+ intensityLabel: '灯具输出设置',
32
33
  rampUpLabel: '日出渐进(开灯调光)',
33
34
  rampDownLabel: '日落渐进(关灯调光)',
34
35
  optionalRampsLabel: '可选渐变调光设置',
@@ -41,6 +42,7 @@ const ui = {
41
42
  targetBandLabel: '建议光照范围',
42
43
  lightWindowLabel: '光照时间窗口',
43
44
  fullIntensityLabel: '全功率光照时长',
45
+ fixtureLevelLabel: '灯具档位',
44
46
  lightsOnLabel: '开始开灯',
45
47
  fullLightLabel: '全光照',
46
48
  lightsOffLabel: '完全关灯',
@@ -16,6 +16,7 @@ describe("aquarium lighting photoperiod planner logic", () => {
16
16
  expect(result.band).toEqual({ low: 7, high: 9 });
17
17
  expect(result.photoperiodMinutes).toBe(480);
18
18
  expect(result.fullIntensityMinutes).toBe(420);
19
+ expect(result.input.intensityPercent).toBe(70);
19
20
  expect(result.phases.map((phase) => phase.phase)).toEqual([
20
21
  "ramp-up",
21
22
  "full",
@@ -112,4 +113,13 @@ describe("aquarium lighting photoperiod planner logic", () => {
112
113
  expect(formatHours(8)).toBe("8");
113
114
  expect(formatHours(8.5)).toBe("8.5");
114
115
  });
116
+
117
+ it("normalizes the fixture output setting to safe five percent steps", () => {
118
+ const result = calculateSchedule({
119
+ ...DEFAULT_INPUT,
120
+ intensityPercent: 73,
121
+ });
122
+
123
+ expect(result.input.intensityPercent).toBe(75);
124
+ });
115
125
  });
@@ -8,6 +8,7 @@ export interface LightingInput {
8
8
  plantProfile: PlantProfile;
9
9
  startTime: string;
10
10
  photoperiodHours: number;
11
+ intensityPercent: number;
11
12
  rampUpMinutes: number;
12
13
  rampDownMinutes: number;
13
14
  }
@@ -66,6 +67,7 @@ export const DEFAULT_INPUT: LightingInput = {
66
67
  plantProfile: "low",
67
68
  startTime: "08:00",
68
69
  photoperiodHours: 8,
70
+ intensityPercent: 70,
69
71
  rampUpMinutes: 30,
70
72
  rampDownMinutes: 30,
71
73
  };
@@ -76,6 +78,7 @@ export const PRESETS: Record<string, LightingInput> = {
76
78
  aquariumType: "tropical",
77
79
  plantProfile: "none",
78
80
  photoperiodHours: 7,
81
+ intensityPercent: 55,
79
82
  rampUpMinutes: 15,
80
83
  rampDownMinutes: 15,
81
84
  },
@@ -86,6 +89,7 @@ export const PRESETS: Record<string, LightingInput> = {
86
89
  plantProfile: "none",
87
90
  startTime: "10:00",
88
91
  photoperiodHours: 8,
92
+ intensityPercent: 80,
89
93
  rampUpMinutes: 60,
90
94
  rampDownMinutes: 60,
91
95
  },
@@ -124,6 +128,11 @@ function normalizePlantProfile(value: string): PlantProfile {
124
128
 
125
129
  function normalizeInput(input: LightingInput): LightingInput {
126
130
  const photoperiodHours = clamp(Number(input.photoperiodHours) || 8, 6, 12);
131
+ const intensityPercent = clamp(
132
+ Math.round((Number(input.intensityPercent) || 70) / 5) * 5,
133
+ 10,
134
+ 100,
135
+ );
127
136
  const photoperiodMinutes = Math.round(photoperiodHours * 60);
128
137
  const rampUpMinutes = clamp(Number(input.rampUpMinutes) || 0, 0, 180);
129
138
  const rampDownMinutes = clamp(Number(input.rampDownMinutes) || 0, 0, 180);
@@ -138,6 +147,7 @@ function normalizeInput(input: LightingInput): LightingInput {
138
147
  plantProfile: normalizePlantProfile(input.plantProfile),
139
148
  startTime: formatClock(parseClock(input.startTime)),
140
149
  photoperiodHours,
150
+ intensityPercent,
141
151
  rampUpMinutes: Math.round(rampUpMinutes * scale),
142
152
  rampDownMinutes: Math.round(rampDownMinutes * scale),
143
153
  };
@@ -2,17 +2,21 @@ import type { LightingInput } from "./logic";
2
2
 
3
3
  const STORAGE_KEY = "jjlmoya-aquarium-lighting-photoperiod-planner";
4
4
 
5
+ function hasLightingValues(candidate: Partial<LightingInput>): boolean {
6
+ const strings = [candidate.aquariumType, candidate.plantProfile, candidate.startTime];
7
+ const numbers = [
8
+ candidate.photoperiodHours,
9
+ candidate.intensityPercent,
10
+ candidate.rampUpMinutes,
11
+ candidate.rampDownMinutes,
12
+ ];
13
+ return strings.every((value) => typeof value === "string") && numbers.every((value) => typeof value === "number");
14
+ }
15
+
5
16
  function isLightingInput(value: unknown): value is LightingInput {
6
17
  if (!value || typeof value !== "object") return false;
7
18
  const candidate = value as Partial<LightingInput>;
8
- return (
9
- typeof candidate.aquariumType === "string" &&
10
- typeof candidate.plantProfile === "string" &&
11
- typeof candidate.startTime === "string" &&
12
- typeof candidate.photoperiodHours === "number" &&
13
- typeof candidate.rampUpMinutes === "number" &&
14
- typeof candidate.rampDownMinutes === "number"
15
- );
19
+ return hasLightingValues(candidate);
16
20
  }
17
21
 
18
22
  export function loadInput(): LightingInput | undefined {
@@ -13,6 +13,7 @@ export interface AquariumLightingPhotoperiodPlannerUI extends Record<
13
13
  plantProfileHigh: string;
14
14
  startTimeLabel: string;
15
15
  photoperiodLabel: string;
16
+ intensityLabel: string;
16
17
  rampUpLabel: string;
17
18
  rampDownLabel: string;
18
19
  optionalRampsLabel: string;
@@ -25,6 +26,7 @@ export interface AquariumLightingPhotoperiodPlannerUI extends Record<
25
26
  targetBandLabel: string;
26
27
  lightWindowLabel: string;
27
28
  fullIntensityLabel: string;
29
+ fixtureLevelLabel: string;
28
30
  lightsOnLabel: string;
29
31
  fullLightLabel: string;
30
32
  lightsOffLabel: string;