@jjlmoya/utils-aquarium 1.65.0 → 1.66.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.
- package/package.json +1 -1
- package/src/category/index.ts +2 -1
- package/src/entries.ts +2 -1
- package/src/layouts/ProductionCategoryPage.astro +184 -184
- package/src/mfe/category-ui.ts +34 -34
- package/src/tests/qa-test-helpers.ts +32 -0
- package/src/tests/qa_bibliography_links.test.ts +48 -0
- package/src/tests/qa_claim_evidence.test.ts +69 -0
- package/src/tests/qa_logic_reference_coverage.test.ts +46 -0
- package/src/tests/qa_runtime_i18n.test.ts +138 -0
- package/src/tests/tool_validation.test.ts +4 -3
- package/src/tool/aquariumSaltDosingCalculator/aquarium-salt-dosing-calculator.css +503 -0
- package/src/tool/aquariumSaltDosingCalculator/bibliography.astro +6 -0
- package/src/tool/aquariumSaltDosingCalculator/bibliography.ts +5 -0
- package/src/tool/aquariumSaltDosingCalculator/component.astro +36 -0
- package/src/tool/aquariumSaltDosingCalculator/controller.ts +104 -0
- package/src/tool/aquariumSaltDosingCalculator/dom-views.ts +74 -0
- package/src/tool/aquariumSaltDosingCalculator/entry.ts +13 -0
- package/src/tool/aquariumSaltDosingCalculator/i18n/de.ts +2 -0
- package/src/tool/aquariumSaltDosingCalculator/i18n/en.ts +2 -0
- package/src/tool/aquariumSaltDosingCalculator/i18n/es.ts +2 -0
- package/src/tool/aquariumSaltDosingCalculator/i18n/fr.ts +2 -0
- package/src/tool/aquariumSaltDosingCalculator/i18n/id.ts +2 -0
- package/src/tool/aquariumSaltDosingCalculator/i18n/it.ts +2 -0
- package/src/tool/aquariumSaltDosingCalculator/i18n/ja.ts +2 -0
- package/src/tool/aquariumSaltDosingCalculator/i18n/ko.ts +2 -0
- package/src/tool/aquariumSaltDosingCalculator/i18n/nl.ts +2 -0
- package/src/tool/aquariumSaltDosingCalculator/i18n/pl.ts +2 -0
- package/src/tool/aquariumSaltDosingCalculator/i18n/pt.ts +2 -0
- package/src/tool/aquariumSaltDosingCalculator/i18n/ru.ts +2 -0
- package/src/tool/aquariumSaltDosingCalculator/i18n/sv.ts +2 -0
- package/src/tool/aquariumSaltDosingCalculator/i18n/tr.ts +2 -0
- package/src/tool/aquariumSaltDosingCalculator/i18n/zh.ts +2 -0
- package/src/tool/aquariumSaltDosingCalculator/index.ts +11 -0
- package/src/tool/aquariumSaltDosingCalculator/localized-content.ts +151 -0
- package/src/tool/aquariumSaltDosingCalculator/logic.test.ts +46 -0
- package/src/tool/aquariumSaltDosingCalculator/logic.ts +85 -0
- package/src/tool/aquariumSaltDosingCalculator/seo.astro +11 -0
- package/src/tool/aquariumSaltDosingCalculator/storage.ts +19 -0
- package/src/tool/aquariumSaltDosingCalculator/ui.ts +47 -0
- package/src/tools.ts +6 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_SALT_DOSING_INPUT,
|
|
4
|
+
calculateSaltDosing,
|
|
5
|
+
gallonsToLitres,
|
|
6
|
+
gramsPerLitreToOuncesPerGallon,
|
|
7
|
+
litresToGallons,
|
|
8
|
+
ouncesPerGallonToGramsPerLitre,
|
|
9
|
+
} from './logic';
|
|
10
|
+
|
|
11
|
+
describe('aquarium salt dosing logic', () => {
|
|
12
|
+
it('calculates product mass for preparing new water', () => {
|
|
13
|
+
const result = calculateSaltDosing(DEFAULT_SALT_DOSING_INPUT);
|
|
14
|
+
expect(result).toEqual({ ok: true, value: expect.objectContaining({ action: 'prepare', amountLitres: 20, amountGrams: 720 }) });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('estimates the salt needed to raise an existing tank', () => {
|
|
18
|
+
const result = calculateSaltDosing({ ...DEFAULT_SALT_DOSING_INPUT, mode: 'correct', volumeLitres: 100, currentSalinityPpt: 30, targetSalinityPpt: 35 });
|
|
19
|
+
expect(result.ok).toBe(true);
|
|
20
|
+
if (result.ok) {
|
|
21
|
+
expect(result.value.action).toBe('raise');
|
|
22
|
+
expect(result.value.amountGrams).toBeCloseTo(514.2857142857143, 8);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('calculates the matching water replacement to lower salinity', () => {
|
|
27
|
+
const result = calculateSaltDosing({ ...DEFAULT_SALT_DOSING_INPUT, mode: 'correct', volumeLitres: 100, currentSalinityPpt: 40, targetSalinityPpt: 35 });
|
|
28
|
+
expect(result).toEqual({ ok: true, value: expect.objectContaining({ action: 'lower', amountLitres: 12.5 }) });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns no action when the target already matches', () => {
|
|
32
|
+
const result = calculateSaltDosing({ ...DEFAULT_SALT_DOSING_INPUT, mode: 'correct', currentSalinityPpt: 35 });
|
|
33
|
+
expect(result).toEqual({ ok: true, value: expect.objectContaining({ action: 'target', amountLitres: 0, amountGrams: 0 }) });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('rejects invalid and non-finite values', () => {
|
|
37
|
+
expect(calculateSaltDosing({ ...DEFAULT_SALT_DOSING_INPUT, volumeLitres: 0 })).toEqual({ ok: false, error: 'volume' });
|
|
38
|
+
expect(calculateSaltDosing({ ...DEFAULT_SALT_DOSING_INPUT, targetSalinityPpt: Number.NaN })).toEqual({ ok: false, error: 'salinity' });
|
|
39
|
+
expect(calculateSaltDosing({ ...DEFAULT_SALT_DOSING_INPUT, productRateGramsPerLitre: 201 })).toEqual({ ok: false, error: 'productRate' });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('keeps imperial conversions reversible', () => {
|
|
43
|
+
expect(gallonsToLitres(litresToGallons(20))).toBeCloseTo(20, 8);
|
|
44
|
+
expect(ouncesPerGallonToGramsPerLitre(gramsPerLitreToOuncesPerGallon(36))).toBeCloseTo(36, 8);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export type SaltDosingMode = 'prepare' | 'correct';
|
|
2
|
+
export type UnitSystem = 'metric' | 'imperial';
|
|
3
|
+
export type SaltDosingAction = 'prepare' | 'raise' | 'lower' | 'target';
|
|
4
|
+
|
|
5
|
+
export interface SaltDosingInput {
|
|
6
|
+
mode: SaltDosingMode;
|
|
7
|
+
volumeLitres: number;
|
|
8
|
+
currentSalinityPpt: number;
|
|
9
|
+
targetSalinityPpt: number;
|
|
10
|
+
productRateGramsPerLitre: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SaltDosingResult {
|
|
14
|
+
action: SaltDosingAction;
|
|
15
|
+
amountLitres: number;
|
|
16
|
+
amountGrams: number;
|
|
17
|
+
volumeLitres: number;
|
|
18
|
+
currentSalinityPpt: number;
|
|
19
|
+
targetSalinityPpt: number;
|
|
20
|
+
productRateGramsPerLitre: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type SaltDosingError = 'volume' | 'salinity' | 'productRate';
|
|
24
|
+
export type CalculationResult = { ok: true; value: SaltDosingResult } | { ok: false; error: SaltDosingError };
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_SALT_DOSING_INPUT: SaltDosingInput = {
|
|
27
|
+
mode: 'prepare',
|
|
28
|
+
volumeLitres: 20,
|
|
29
|
+
currentSalinityPpt: 0,
|
|
30
|
+
targetSalinityPpt: 35,
|
|
31
|
+
productRateGramsPerLitre: 36,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const MAX_VOLUME_LITRES = 100_000;
|
|
35
|
+
const MAX_SALINITY_PPT = 60;
|
|
36
|
+
const MAX_PRODUCT_RATE_GRAMS_PER_LITRE = 200;
|
|
37
|
+
|
|
38
|
+
function isFiniteInRange(value: number, minimum: number, maximum: number): boolean {
|
|
39
|
+
return Number.isFinite(value) && value >= minimum && value <= maximum;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function validateInput(input: SaltDosingInput): SaltDosingError | null {
|
|
43
|
+
if (!isFiniteInRange(input.volumeLitres, 0.1, MAX_VOLUME_LITRES)) return 'volume';
|
|
44
|
+
if (!isFiniteInRange(input.targetSalinityPpt, 0, MAX_SALINITY_PPT) || !isFiniteInRange(input.currentSalinityPpt, 0, MAX_SALINITY_PPT)) return 'salinity';
|
|
45
|
+
if (input.mode === 'prepare' && input.targetSalinityPpt <= 0) return 'salinity';
|
|
46
|
+
if (!isFiniteInRange(input.productRateGramsPerLitre, 0.01, MAX_PRODUCT_RATE_GRAMS_PER_LITRE)) return 'productRate';
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function calculateSaltDosing(input: SaltDosingInput): CalculationResult {
|
|
51
|
+
const error = validateInput(input);
|
|
52
|
+
if (error !== null) return { ok: false, error };
|
|
53
|
+
|
|
54
|
+
if (input.mode === 'prepare') {
|
|
55
|
+
return {
|
|
56
|
+
ok: true,
|
|
57
|
+
value: {
|
|
58
|
+
action: 'prepare',
|
|
59
|
+
amountLitres: input.volumeLitres,
|
|
60
|
+
amountGrams: input.volumeLitres * input.productRateGramsPerLitre,
|
|
61
|
+
volumeLitres: input.volumeLitres,
|
|
62
|
+
currentSalinityPpt: 0,
|
|
63
|
+
targetSalinityPpt: input.targetSalinityPpt,
|
|
64
|
+
productRateGramsPerLitre: input.productRateGramsPerLitre,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const difference = input.targetSalinityPpt - input.currentSalinityPpt;
|
|
70
|
+
if (difference === 0) {
|
|
71
|
+
return { ok: true, value: { action: 'target', amountLitres: 0, amountGrams: 0, ...input } };
|
|
72
|
+
}
|
|
73
|
+
if (difference > 0) {
|
|
74
|
+
const amountGrams = input.volumeLitres * (difference / input.targetSalinityPpt) * input.productRateGramsPerLitre;
|
|
75
|
+
return { ok: true, value: { action: 'raise', amountLitres: 0, amountGrams, ...input } };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const amountLitres = input.volumeLitres * (Math.abs(difference) / input.currentSalinityPpt);
|
|
79
|
+
return { ok: true, value: { action: 'lower', amountLitres, amountGrams: 0, ...input } };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function litresToGallons(litres: number): number { return litres / 3.785411784; }
|
|
83
|
+
export function gallonsToLitres(gallons: number): number { return gallons * 3.785411784; }
|
|
84
|
+
export function gramsPerLitreToOuncesPerGallon(gramsPerLitre: number): number { return gramsPerLitre * 0.133526471; }
|
|
85
|
+
export function ouncesPerGallonToGramsPerLitre(ouncesPerGallon: number): number { return ouncesPerGallon / 0.133526471; }
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { aquariumSaltDosingCalculator } from './entry';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
const { locale = 'en' } = Astro.props as { locale?: KnownLocale };
|
|
7
|
+
const loader = aquariumSaltDosingCalculator.i18n[locale] ?? aquariumSaltDosingCalculator.i18n.en;
|
|
8
|
+
const content = loader === undefined ? undefined : await loader();
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
{content && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { DEFAULT_SALT_DOSING_INPUT, type SaltDosingInput, type UnitSystem } from './logic';
|
|
2
|
+
|
|
3
|
+
const STORAGE_KEY = 'jjlmoya-utils-aquarium:salt-dosing';
|
|
4
|
+
|
|
5
|
+
export interface SavedSaltDosingState { unit: UnitSystem; input: SaltDosingInput; }
|
|
6
|
+
|
|
7
|
+
export function defaultSaltDosingState(): SavedSaltDosingState { return { unit: 'metric', input: { ...DEFAULT_SALT_DOSING_INPUT } }; }
|
|
8
|
+
|
|
9
|
+
export function loadSaltDosingState(): SavedSaltDosingState | null {
|
|
10
|
+
try {
|
|
11
|
+
const value = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? 'null') as SavedSaltDosingState | null;
|
|
12
|
+
if (!value || (value.unit !== 'metric' && value.unit !== 'imperial') || !value.input) return null;
|
|
13
|
+
return value;
|
|
14
|
+
} catch { return null; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function saveSaltDosingState(state: SavedSaltDosingState): void {
|
|
18
|
+
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch {}
|
|
19
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface AquariumSaltDosingCalculatorUI {
|
|
2
|
+
[key: string]: string;
|
|
3
|
+
toolKicker: string;
|
|
4
|
+
resultTitle: string;
|
|
5
|
+
configureTitle: string;
|
|
6
|
+
modeLabel: string;
|
|
7
|
+
prepareMode: string;
|
|
8
|
+
correctMode: string;
|
|
9
|
+
unitsLabel: string;
|
|
10
|
+
metricLabel: string;
|
|
11
|
+
imperialLabel: string;
|
|
12
|
+
volumeLabel: string;
|
|
13
|
+
currentSalinityLabel: string;
|
|
14
|
+
targetSalinityLabel: string;
|
|
15
|
+
productRateLabel: string;
|
|
16
|
+
salinityUnit: string;
|
|
17
|
+
metricVolumeUnit: string;
|
|
18
|
+
imperialVolumeUnit: string;
|
|
19
|
+
metricRateUnit: string;
|
|
20
|
+
imperialRateUnit: string;
|
|
21
|
+
presetLabel: string;
|
|
22
|
+
brackishPreset: string;
|
|
23
|
+
marinePreset: string;
|
|
24
|
+
customPreset: string;
|
|
25
|
+
prepareHint: string;
|
|
26
|
+
correctHint: string;
|
|
27
|
+
productRateHint: string;
|
|
28
|
+
outputActionLabel: string;
|
|
29
|
+
prepareAction: string;
|
|
30
|
+
raiseAction: string;
|
|
31
|
+
lowerAction: string;
|
|
32
|
+
targetAction: string;
|
|
33
|
+
amountLabel: string;
|
|
34
|
+
saltAmountLabel: string;
|
|
35
|
+
waterAmountLabel: string;
|
|
36
|
+
noChangeLabel: string;
|
|
37
|
+
currentTargetLabel: string;
|
|
38
|
+
verificationNote: string;
|
|
39
|
+
gradualNote: string;
|
|
40
|
+
emptyState: string;
|
|
41
|
+
invalidState: string;
|
|
42
|
+
resetLabel: string;
|
|
43
|
+
gramsUnit: string;
|
|
44
|
+
ouncesUnit: string;
|
|
45
|
+
litresUnit: string;
|
|
46
|
+
gallonsUnit: string;
|
|
47
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { aquariumTankVolumeWaterChangeCalculator } from './tool/aquariumTankVolu
|
|
|
3
3
|
import { aquariumFilterTurnoverStockingPlanner } from './tool/aquariumFilterTurnoverStockingPlanner/entry';
|
|
4
4
|
import { aquariumLightingPhotoperiodPlanner } from './tool/aquariumLightingPhotoperiodPlanner/entry';
|
|
5
5
|
import { aquariumEvaporationTopOffPlanner } from './tool/aquariumEvaporationTopOffPlanner/entry';
|
|
6
|
+
import { aquariumSaltDosingCalculator } from './tool/aquariumSaltDosingCalculator/entry';
|
|
6
7
|
|
|
7
8
|
export const ALL_TOOLS = [{
|
|
8
9
|
entry: aquariumTankVolumeWaterChangeCalculator,
|
|
@@ -24,4 +25,9 @@ export const ALL_TOOLS = [{
|
|
|
24
25
|
Component: () => import('./tool/aquariumEvaporationTopOffPlanner/component.astro'),
|
|
25
26
|
SEOComponent: () => import('./tool/aquariumEvaporationTopOffPlanner/seo.astro'),
|
|
26
27
|
BibliographyComponent: () => import('./tool/aquariumEvaporationTopOffPlanner/bibliography.astro'),
|
|
28
|
+
}, {
|
|
29
|
+
entry: aquariumSaltDosingCalculator,
|
|
30
|
+
Component: () => import('./tool/aquariumSaltDosingCalculator/component.astro'),
|
|
31
|
+
SEOComponent: () => import('./tool/aquariumSaltDosingCalculator/seo.astro'),
|
|
32
|
+
BibliographyComponent: () => import('./tool/aquariumSaltDosingCalculator/bibliography.astro'),
|
|
27
33
|
}];
|