@jjlmoya/utils-aquarium 1.60.0 → 1.61.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 (42) 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/index.ts +2 -0
  5. package/src/tests/i18n_coverage.test.ts +1 -1
  6. package/src/tests/registry_contract.test.ts +67 -0
  7. package/src/tests/tool_validation.test.ts +4 -3
  8. package/src/tool/aquariumEvaporationTopOffPlanner/aquarium-evaporation-top-off-planner.css +490 -0
  9. package/src/tool/aquariumEvaporationTopOffPlanner/bibliography.astro +6 -0
  10. package/src/tool/aquariumEvaporationTopOffPlanner/bibliography.ts +10 -0
  11. package/src/tool/aquariumEvaporationTopOffPlanner/component.astro +61 -0
  12. package/src/tool/aquariumEvaporationTopOffPlanner/controller.ts +232 -0
  13. package/src/tool/aquariumEvaporationTopOffPlanner/dom-views.ts +39 -0
  14. package/src/tool/aquariumEvaporationTopOffPlanner/entry.ts +27 -0
  15. package/src/tool/aquariumEvaporationTopOffPlanner/evaluator.ts +9 -0
  16. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/de.ts +3 -0
  17. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/en.ts +179 -0
  18. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/es.ts +3 -0
  19. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/fr.ts +3 -0
  20. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/id.ts +3 -0
  21. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/it.ts +3 -0
  22. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/ja.ts +3 -0
  23. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/ko.ts +3 -0
  24. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/nl.ts +3 -0
  25. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/pl.ts +3 -0
  26. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/pt.ts +3 -0
  27. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/ru.ts +3 -0
  28. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/shared.ts +79 -0
  29. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/sv.ts +3 -0
  30. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/tr.ts +3 -0
  31. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/translations.ts +509 -0
  32. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/zh.ts +3 -0
  33. package/src/tool/aquariumEvaporationTopOffPlanner/index.ts +11 -0
  34. package/src/tool/aquariumEvaporationTopOffPlanner/logic.test.ts +61 -0
  35. package/src/tool/aquariumEvaporationTopOffPlanner/logic.ts +102 -0
  36. package/src/tool/aquariumEvaporationTopOffPlanner/seo.astro +13 -0
  37. package/src/tool/aquariumEvaporationTopOffPlanner/storage.ts +28 -0
  38. package/src/tool/aquariumEvaporationTopOffPlanner/ui.ts +49 -0
  39. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/zh.ts +2 -2
  40. package/src/tool/aquariumTankVolumeWaterChangeCalculator/i18n/fr.ts +1 -1
  41. package/src/tool/aquariumTankVolumeWaterChangeCalculator/i18n/it.ts +1 -1
  42. package/src/tools.ts +6 -0
@@ -0,0 +1,3 @@
1
+ import { localizedContents } from './translations';
2
+
3
+ export const content = localizedContents.zh;
@@ -0,0 +1,11 @@
1
+ import { aquariumEvaporationTopOffPlanner } from './entry';
2
+ import type { ToolDefinition } from '../../types';
3
+
4
+ export * from './entry';
5
+
6
+ export const AQUARIUM_EVAPORATION_TOP_OFF_PLANNER_TOOL: ToolDefinition = {
7
+ entry: aquariumEvaporationTopOffPlanner,
8
+ Component: () => import('./component.astro'),
9
+ SEOComponent: () => import('./seo.astro'),
10
+ BibliographyComponent: () => import('./bibliography.astro'),
11
+ };
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { calculateEvaporation } from './logic';
3
+
4
+ describe('calculateEvaporation', () => {
5
+ it('converts an observed loss into daily and scheduled top off volumes', () => {
6
+ const result = calculateEvaporation({
7
+ tankVolumeLitres: 120,
8
+ measuredLossMillilitres: 1200,
9
+ observationDays: 7,
10
+ refillIntervalDays: 3,
11
+ });
12
+ expect(result?.dailyLossLitres).toBeCloseTo(0.1714286, 6);
13
+ expect(result?.nextRefillLitres).toBeCloseTo(0.5142857, 6);
14
+ expect(result?.thirtyDayLossLitres).toBeCloseTo(5.142857, 5);
15
+ expect(result?.levelAfterIntervalPercent).toBeCloseTo(99.5714286, 6);
16
+ });
17
+
18
+ it('shows the level before refill and the amount added on refill days', () => {
19
+ const result = calculateEvaporation({
20
+ tankVolumeLitres: 100,
21
+ measuredLossMillilitres: 700,
22
+ observationDays: 7,
23
+ refillIntervalDays: 2,
24
+ });
25
+ expect(result).not.toBeNull();
26
+ if (result === null) return;
27
+ expect(result.schedule[1]?.levelPercent).toBeCloseTo(99.9, 6);
28
+ expect(result.schedule[2]?.levelPercent).toBeCloseTo(99.8, 6);
29
+ expect(result.schedule[2]?.refillLitres).toBeCloseTo(0.2, 6);
30
+ expect(result.schedule[3]?.levelPercent).toBeCloseTo(99.9, 6);
31
+ });
32
+
33
+ it('estimates reservoir autonomy, marine concentration and critical swings', () => {
34
+ const result = calculateEvaporation({
35
+ tankVolumeLitres: 100,
36
+ measuredLossMillilitres: 3000,
37
+ observationDays: 10,
38
+ refillIntervalDays: 10,
39
+ reservoirCapacityLitres: 12,
40
+ startingSpecificGravity: 1.025,
41
+ });
42
+ expect(result?.reservoirDays).toBeCloseTo(40, 6);
43
+ expect(result?.salinityAfterInterval).toBeCloseTo(1.025773, 6);
44
+ expect(result?.isCritical).toBe(true);
45
+ });
46
+
47
+ it('rejects impossible or unsupported values', () => {
48
+ expect(calculateEvaporation({
49
+ tankVolumeLitres: 0,
50
+ measuredLossMillilitres: 500,
51
+ observationDays: 7,
52
+ refillIntervalDays: 3,
53
+ })).toBeNull();
54
+ expect(calculateEvaporation({
55
+ tankVolumeLitres: 100,
56
+ measuredLossMillilitres: 500,
57
+ observationDays: 91,
58
+ refillIntervalDays: 3,
59
+ })).toBeNull();
60
+ });
61
+ });
@@ -0,0 +1,102 @@
1
+ export interface EvaporationInputs {
2
+ tankVolumeLitres: number;
3
+ measuredLossMillilitres: number;
4
+ observationDays: number;
5
+ refillIntervalDays: number;
6
+ reservoirCapacityLitres?: number;
7
+ startingSpecificGravity?: number;
8
+ }
9
+
10
+ export interface SchedulePoint {
11
+ day: number;
12
+ levelPercent: number;
13
+ lostLitres: number;
14
+ refillLitres: number;
15
+ }
16
+
17
+ export interface EvaporationResult {
18
+ tankVolumeLitres: number;
19
+ dailyLossLitres: number;
20
+ dailyRatePercent: number;
21
+ nextRefillLitres: number;
22
+ thirtyDayLossLitres: number;
23
+ levelAfterIntervalPercent: number;
24
+ twoPercentDays: number;
25
+ reservoirDays?: number;
26
+ salinityAfterInterval?: number;
27
+ isCritical: boolean;
28
+ schedule: SchedulePoint[];
29
+ }
30
+
31
+ function isPositive(value: number): boolean {
32
+ return Number.isFinite(value) && value > 0;
33
+ }
34
+
35
+ function validLimits(input: EvaporationInputs): boolean {
36
+ return input.observationDays <= 90 && input.refillIntervalDays <= 30;
37
+ }
38
+
39
+ function validOptionalFields(input: EvaporationInputs): boolean {
40
+ const validReservoir = input.reservoirCapacityLitres === undefined || isPositive(input.reservoirCapacityLitres);
41
+ const validSalinity = input.startingSpecificGravity === undefined || (input.startingSpecificGravity >= 1 && input.startingSpecificGravity <= 1.04);
42
+ return validReservoir && validSalinity;
43
+ }
44
+
45
+ function isValidInput(input: EvaporationInputs): boolean {
46
+ return isPositive(input.tankVolumeLitres)
47
+ && isPositive(input.measuredLossMillilitres)
48
+ && isPositive(input.observationDays)
49
+ && isPositive(input.refillIntervalDays)
50
+ && validLimits(input)
51
+ && validOptionalFields(input);
52
+ }
53
+
54
+ function daysSinceRefill(day: number, intervalDays: number): number {
55
+ if (day === 0) return 0;
56
+ const remainder = day % intervalDays;
57
+ return remainder === 0 ? intervalDays : remainder;
58
+ }
59
+
60
+ function createSchedule(
61
+ dailyLossLitres: number,
62
+ tankVolumeLitres: number,
63
+ intervalDays: number,
64
+ ): SchedulePoint[] {
65
+ const schedule: SchedulePoint[] = [];
66
+ for (let day = 0; day <= 14; day += 1) {
67
+ const daySinceRefill = daysSinceRefill(day, intervalDays);
68
+ const refillDue = day > 0 && day % intervalDays === 0;
69
+ const lostLitres = daySinceRefill * dailyLossLitres;
70
+ schedule.push({
71
+ day,
72
+ levelPercent: Math.max(0, 100 - (lostLitres / tankVolumeLitres) * 100),
73
+ lostLitres,
74
+ refillLitres: refillDue ? intervalDays * dailyLossLitres : 0,
75
+ });
76
+ }
77
+ return schedule;
78
+ }
79
+
80
+ export function calculateEvaporation(input: EvaporationInputs): EvaporationResult | null {
81
+ if (!isValidInput(input)) return null;
82
+ const dailyLossLitres = input.measuredLossMillilitres / 1000 / input.observationDays;
83
+ const dailyRatePercent = (dailyLossLitres / input.tankVolumeLitres) * 100;
84
+ const nextRefillLitres = dailyLossLitres * input.refillIntervalDays;
85
+ const remainingVolumeLitres = input.tankVolumeLitres - nextRefillLitres;
86
+ const result: EvaporationResult = {
87
+ tankVolumeLitres: input.tankVolumeLitres,
88
+ dailyLossLitres,
89
+ dailyRatePercent,
90
+ nextRefillLitres,
91
+ thirtyDayLossLitres: dailyLossLitres * 30,
92
+ levelAfterIntervalPercent: Math.max(0, 100 - dailyRatePercent * input.refillIntervalDays),
93
+ twoPercentDays: 2 / dailyRatePercent,
94
+ isCritical: dailyRatePercent * input.refillIntervalDays >= 2,
95
+ schedule: createSchedule(dailyLossLitres, input.tankVolumeLitres, input.refillIntervalDays),
96
+ };
97
+ if (input.reservoirCapacityLitres !== undefined) result.reservoirDays = input.reservoirCapacityLitres / dailyLossLitres;
98
+ if (input.startingSpecificGravity !== undefined && remainingVolumeLitres > 0) {
99
+ result.salinityAfterInterval = 1 + ((input.startingSpecificGravity - 1) * input.tankVolumeLitres) / remainingVolumeLitres;
100
+ }
101
+ return result;
102
+ }
@@ -0,0 +1,13 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { aquariumEvaporationTopOffPlanner } from './entry';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props { locale?: KnownLocale; }
7
+
8
+ const { locale = 'en' } = Astro.props;
9
+ const loader = aquariumEvaporationTopOffPlanner.i18n[locale] ?? aquariumEvaporationTopOffPlanner.i18n.en;
10
+ const content = loader === undefined ? undefined : await loader();
11
+ ---
12
+
13
+ {content && <SEORenderer content={{ locale, sections: content.seo }} />}
@@ -0,0 +1,28 @@
1
+ import type { EvaporationInputs } from './logic';
2
+
3
+ const STORAGE_KEY = 'aquarium-evaporation-top-off-planner';
4
+
5
+ export interface StoredEvaporationState {
6
+ units: 'metric' | 'imperial';
7
+ lossUnit?: 'millilitres' | 'litres' | 'fluidOunces';
8
+ waterType?: 'freshwater' | 'marine';
9
+ input: EvaporationInputs;
10
+ }
11
+
12
+ export function readEvaporationState(): StoredEvaporationState | null {
13
+ if (typeof window === 'undefined') return null;
14
+ try {
15
+ const raw = window.localStorage.getItem(STORAGE_KEY);
16
+ if (raw === null) return null;
17
+ return JSON.parse(raw) as StoredEvaporationState;
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ export function writeEvaporationState(state: StoredEvaporationState): void {
24
+ if (typeof window === 'undefined') return;
25
+ try {
26
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
27
+ } catch {}
28
+ }
@@ -0,0 +1,49 @@
1
+ export interface AquariumEvaporationTopOffPlannerUI {
2
+ [key: string]: string;
3
+ unitsLabel: string;
4
+ metricLabel: string;
5
+ imperialLabel: string;
6
+ tankVolumeLabel: string;
7
+ measuredLossLabel: string;
8
+ lossUnitLabel: string;
9
+ observationDaysLabel: string;
10
+ refillIntervalLabel: string;
11
+ reservoirCapacityLabel: string;
12
+ advancedLabel: string;
13
+ waterTypeLabel: string;
14
+ freshwaterLabel: string;
15
+ marineLabel: string;
16
+ startingSalinityLabel: string;
17
+ estimatedSalinityLabel: string;
18
+ reservoirDaysLabel: string;
19
+ criticalAlert: string;
20
+ volumeHint: string;
21
+ lossHint: string;
22
+ intervalHint: string;
23
+ resultTitle: string;
24
+ dailyLossLabel: string;
25
+ dailyRateLabel: string;
26
+ nextRefillLabel: string;
27
+ thirtyDayLabel: string;
28
+ levelAfterIntervalLabel: string;
29
+ twoPercentDaysLabel: string;
30
+ scheduleTitle: string;
31
+ dayLabel: string;
32
+ topOffLabel: string;
33
+ everyLabel: string;
34
+ levelLabel: string;
35
+ stableStatus: string;
36
+ watchStatus: string;
37
+ frequentStatus: string;
38
+ emptyState: string;
39
+ invalidState: string;
40
+ measurementNote: string;
41
+ saltwaterNote: string;
42
+ resetLabel: string;
43
+ litreUnit: string;
44
+ gallonUnit: string;
45
+ millilitreUnit: string;
46
+ fluidOunceUnit: string;
47
+ dayUnit: string;
48
+ percentUnit: string;
49
+ }
@@ -67,7 +67,7 @@ const softwareApplicationSchema: WithContext<SoftwareApplication> = {
67
67
  name: '水族箱灯光光照周期规划器',
68
68
  applicationCategory: 'UtilitiesApplication',
69
69
  operatingSystem: 'Web',
70
- description: '根据水族箱类型、水草需求及调光功能,计算并规划水族灯自动定时时间表。',
70
+ description: '根据水族箱类型、水草需求及调光功能,计算并规划水族灯自动定时时间表,方便日常维护和安排。',
71
71
  offers: { '@type': 'Offer', price: '0', priceCurrency: 'CNY' },
72
72
  };
73
73
 
@@ -87,7 +87,7 @@ const howToSchema: WithContext<HowTo> = {
87
87
  export const content: AquariumLightingPhotoperiodPlannerLocaleContent = {
88
88
  slug: 'aquarium-lighting-photoperiod-schedule-planner',
89
89
  title: '水族箱灯光与光照周期(Photoperiod)规划器',
90
- description: '结合水族箱类型、水草环境、开灯时间及调光渐变,制定稳定高效的水族箱定时开灯时间表。',
90
+ description: '结合水族箱类型、水草环境、开灯时间及调光渐变,制定稳定高效的水族箱定时开灯时间表,帮助安排日常养护。',
91
91
  ui,
92
92
  seo: [
93
93
  { type: 'title', level: 2, text: '为什么规律的光照周期至关重要' },
@@ -4,7 +4,7 @@ export const content = createAquariumContent({
4
4
  locale: 'fr',
5
5
  slug: 'calculateur-volume-aquarium-changement-eau',
6
6
  title: "Calculateur du volume d'aquarium et du changement d'eau",
7
- description: "Calculez le volume d'eau utile, le déplacement du substrat et la quantité d'eau à remplacer à partir des dimensions intérieures de votre aquarium.",
7
+ description: "Calculez le volume d'eau utile, le déplacement du substrat et la quantité d'eau à remplacer à partir des dimensions intérieures de votre aquarium pour planifier l'entretien.",
8
8
  faq: [
9
9
  { question: "Le calculateur utilise-t-il la capacité indiquée sur l'aquarium ?", answer: "Non. Il retire la hauteur du substrat du volume géométrique et fournit une estimation de l'eau réellement disponible." },
10
10
  { question: "Comment fonctionne l'option façade bombée ?", answer: "Elle ajoute un segment circulaire approximatif à une base rectangulaire. Le résultat reste indicatif, car les profils de verre varient selon les aquariums." },
@@ -4,7 +4,7 @@ export const content = createAquariumContent({
4
4
  locale: 'it',
5
5
  slug: 'calcolatore-volume-acquario-cambio-acqua',
6
6
  title: "Calcolatore del volume dell'acquario e del cambio d'acqua",
7
- description: "Calcola il volume d'acqua utile, lo spazio occupato dal substrato e la quantità da sostituire usando le misure interne del tuo acquario.",
7
+ description: "Calcola il volume d'acqua utile, lo spazio occupato dal substrato e la quantità da sostituire usando le misure interne del tuo acquario per pianificare la manutenzione.",
8
8
  faq: [
9
9
  { question: "Il calcolatore usa la capacità indicata sulla confezione?", answer: "No. Sottrae l'altezza del substrato dal volume geometrico e fornisce una stima dell'acqua effettivamente disponibile." },
10
10
  { question: "Come funziona l'opzione con fronte curvo?", answer: "Aggiunge un segmento circolare approssimato alla base rettangolare. Il risultato è indicativo perché i profili del vetro curvo cambiano da una vasca all'altra." },
package/src/tools.ts CHANGED
@@ -2,6 +2,7 @@ export { ALL_ENTRIES } from './entries';
2
2
  import { aquariumTankVolumeWaterChangeCalculator } from './tool/aquariumTankVolumeWaterChangeCalculator/entry';
3
3
  import { aquariumFilterTurnoverStockingPlanner } from './tool/aquariumFilterTurnoverStockingPlanner/entry';
4
4
  import { aquariumLightingPhotoperiodPlanner } from './tool/aquariumLightingPhotoperiodPlanner/entry';
5
+ import { aquariumEvaporationTopOffPlanner } from './tool/aquariumEvaporationTopOffPlanner/entry';
5
6
 
6
7
  export const ALL_TOOLS = [{
7
8
  entry: aquariumTankVolumeWaterChangeCalculator,
@@ -18,4 +19,9 @@ export const ALL_TOOLS = [{
18
19
  Component: () => import('./tool/aquariumLightingPhotoperiodPlanner/component.astro'),
19
20
  SEOComponent: () => import('./tool/aquariumLightingPhotoperiodPlanner/seo.astro'),
20
21
  BibliographyComponent: () => import('./tool/aquariumLightingPhotoperiodPlanner/bibliography.astro'),
22
+ }, {
23
+ entry: aquariumEvaporationTopOffPlanner,
24
+ Component: () => import('./tool/aquariumEvaporationTopOffPlanner/component.astro'),
25
+ SEOComponent: () => import('./tool/aquariumEvaporationTopOffPlanner/seo.astro'),
26
+ BibliographyComponent: () => import('./tool/aquariumEvaporationTopOffPlanner/bibliography.astro'),
21
27
  }];