@jjlmoya/utils-aquarium 1.69.0 → 1.70.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 (39) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +13 -4
  3. package/src/entries.ts +18 -10
  4. package/src/index.ts +2 -0
  5. package/src/tests/mfe_assets_contract.test.ts +3 -1
  6. package/src/tests/tool_validation.test.ts +10 -8
  7. package/src/tool/aquariumNitrateDilutionCalculator/aquarium-nitrate-dilution-calculator.css +887 -0
  8. package/src/tool/aquariumNitrateDilutionCalculator/bibliography.astro +6 -0
  9. package/src/tool/aquariumNitrateDilutionCalculator/bibliography.ts +12 -0
  10. package/src/tool/aquariumNitrateDilutionCalculator/component.astro +120 -0
  11. package/src/tool/aquariumNitrateDilutionCalculator/controller.ts +96 -0
  12. package/src/tool/aquariumNitrateDilutionCalculator/dom-views.ts +151 -0
  13. package/src/tool/aquariumNitrateDilutionCalculator/entry.ts +27 -0
  14. package/src/tool/aquariumNitrateDilutionCalculator/evaluator.ts +17 -0
  15. package/src/tool/aquariumNitrateDilutionCalculator/i18n/de.ts +30 -0
  16. package/src/tool/aquariumNitrateDilutionCalculator/i18n/en.ts +124 -0
  17. package/src/tool/aquariumNitrateDilutionCalculator/i18n/es.ts +30 -0
  18. package/src/tool/aquariumNitrateDilutionCalculator/i18n/fr.ts +30 -0
  19. package/src/tool/aquariumNitrateDilutionCalculator/i18n/id.ts +30 -0
  20. package/src/tool/aquariumNitrateDilutionCalculator/i18n/it.ts +30 -0
  21. package/src/tool/aquariumNitrateDilutionCalculator/i18n/ja.ts +30 -0
  22. package/src/tool/aquariumNitrateDilutionCalculator/i18n/ko.ts +30 -0
  23. package/src/tool/aquariumNitrateDilutionCalculator/i18n/nl.ts +30 -0
  24. package/src/tool/aquariumNitrateDilutionCalculator/i18n/pl.ts +30 -0
  25. package/src/tool/aquariumNitrateDilutionCalculator/i18n/pt.ts +30 -0
  26. package/src/tool/aquariumNitrateDilutionCalculator/i18n/ru.ts +30 -0
  27. package/src/tool/aquariumNitrateDilutionCalculator/i18n/sv.ts +30 -0
  28. package/src/tool/aquariumNitrateDilutionCalculator/i18n/tr.ts +30 -0
  29. package/src/tool/aquariumNitrateDilutionCalculator/i18n/zh.ts +30 -0
  30. package/src/tool/aquariumNitrateDilutionCalculator/index.ts +11 -0
  31. package/src/tool/aquariumNitrateDilutionCalculator/localized-content.ts +138 -0
  32. package/src/tool/aquariumNitrateDilutionCalculator/logic.test.ts +47 -0
  33. package/src/tool/aquariumNitrateDilutionCalculator/logic.ts +122 -0
  34. package/src/tool/aquariumNitrateDilutionCalculator/seo.astro +13 -0
  35. package/src/tool/aquariumNitrateDilutionCalculator/storage.ts +36 -0
  36. package/src/tool/aquariumNitrateDilutionCalculator/ui.ts +53 -0
  37. package/src/tool/aquariumNitrateDilutionCalculator/units.test.ts +17 -0
  38. package/src/tool/aquariumNitrateDilutionCalculator/units.ts +19 -0
  39. package/src/tools.ts +16 -37
@@ -0,0 +1,138 @@
1
+ import type { FAQPage, HowTo, SoftwareApplication, Thing, WithContext } from 'schema-dts';
2
+ import { bibliography } from './bibliography';
3
+ import type { ToolLocaleContent } from '../../types';
4
+ import type { AquariumNitrateDilutionCalculatorUI } from './ui';
5
+
6
+ export interface AquariumNitrateLocaleCopy {
7
+ locale: string;
8
+ slug: string;
9
+ title: string;
10
+ description: string;
11
+ faq: { question: string; answer: string }[];
12
+ howTo: { name: string; text: string }[];
13
+ seo: ToolLocaleContent['seo'];
14
+ ui: AquariumNitrateDilutionCalculatorUI;
15
+ }
16
+
17
+ const additionalSeoByLocale: Record<string, ToolLocaleContent['seo']> = {
18
+ de: [
19
+ { type: 'title', text: 'So liest du wiederholte Wechsel', level: 2 },
20
+ { type: 'paragraph', html: 'Die Szenarien zeigen, wie der Nitratwert nach jedem identischen Wechsel rechnerisch sinkt. Jeder Schritt beginnt mit dem niedrigeren Restwert des vorherigen Schritts.' },
21
+ { type: 'title', text: 'Warum Nachmessen wichtig bleibt', level: 2 },
22
+ { type: 'paragraph', html: 'Das Becken produziert zwischen den Wechseln weiter Nitrat. Nutze die nächste Messung deshalb als neue Grundlage und nicht nur die Modellzahl.' },
23
+ ],
24
+ es: [
25
+ { type: 'title', text: 'Cómo leer los cambios repetidos', level: 2 },
26
+ { type: 'paragraph', html: 'Los escenarios muestran cómo bajaría el nitrato después de cada cambio idéntico. Cada paso empieza con el valor residual del paso anterior.' },
27
+ { type: 'title', text: 'Por qué sigue siendo necesario medir', level: 2 },
28
+ { type: 'paragraph', html: 'El acuario continúa produciendo nitratos entre cambios. Usa la próxima medición como nueva base y no solo la cifra del modelo.' },
29
+ ],
30
+ fr: [
31
+ { type: 'title', text: 'Lire les changements répétés', level: 2 },
32
+ { type: 'paragraph', html: 'Les scénarios montrent la baisse calculée après chaque changement identique. Chaque étape part de la concentration résiduelle de la précédente.' },
33
+ { type: 'title', text: 'Pourquoi il faut toujours remesurer', level: 2 },
34
+ { type: 'paragraph', html: "L'aquarium continue de produire des nitrates entre les changements. Utilisez donc la prochaine mesure comme nouvelle base, plutôt que le seul modèle." },
35
+ ],
36
+ id: [
37
+ { type: 'title', text: 'Cara membaca penggantian berulang', level: 2 },
38
+ { type: 'paragraph', html: 'Skenario menunjukkan penurunan nitrat yang diperkirakan setelah setiap penggantian yang sama. Setiap langkah dimulai dari nilai sisa langkah sebelumnya.' },
39
+ { type: 'title', text: 'Mengapa pengukuran ulang tetap penting', level: 2 },
40
+ { type: 'paragraph', html: 'Akuarium terus menghasilkan nitrat di antara penggantian air. Jadikan hasil pengukuran berikutnya sebagai dasar baru, bukan hanya angka model.' },
41
+ ],
42
+ it: [
43
+ { type: 'title', text: 'Come leggere i cambi ripetuti', level: 2 },
44
+ { type: 'paragraph', html: 'Gli scenari mostrano la riduzione stimata dopo ogni cambio identico. Ogni fase parte dalla concentrazione residua della fase precedente.' },
45
+ { type: 'title', text: 'Perché serve sempre una nuova misura', level: 2 },
46
+ { type: 'paragraph', html: "L'acquario continua a produrre nitrati tra un cambio e l'altro. Usa quindi la prossima misurazione come nuova base, non solo il numero del modello." },
47
+ ],
48
+ ja: [
49
+ { type: 'title', text: '繰り返す水換えの見方', level: 2 },
50
+ { type: 'paragraph', html: 'シナリオでは、同じ割合の水換えを繰り返した場合の硝酸塩の残り方を示します。各段階は前の段階で残った値から始まります。' },
51
+ { type: 'title', text: '再測定が必要な理由', level: 2 },
52
+ { type: 'paragraph', html: '水換えの間も水槽では硝酸塩が増え続けます。次の測定値を新しい基準として使い、モデルの数値だけで判断しないでください。' },
53
+ ],
54
+ ko: [
55
+ { type: 'title', text: '반복 물갈이 결과 읽기', level: 2 },
56
+ { type: 'paragraph', html: '시나리오는 같은 비율의 물갈이를 반복했을 때 예상되는 질산염 잔량을 보여 줍니다. 각 단계는 이전 단계에서 남은 값으로 시작합니다.' },
57
+ { type: 'title', text: '다시 측정해야 하는 이유', level: 2 },
58
+ { type: 'paragraph', html: '물갈이 사이에도 수조에서는 질산염이 계속 만들어집니다. 다음 측정값을 새로운 기준으로 삼고 모델의 숫자만으로 판단하지 마세요.' },
59
+ ],
60
+ nl: [
61
+ { type: 'title', text: 'Herhaalde wissels lezen', level: 2 },
62
+ { type: 'paragraph', html: "De scenario's tonen de berekende daling na elke identieke wissel. Elke stap begint met de restwaarde van de vorige stap." },
63
+ { type: 'title', text: 'Waarom opnieuw meten nodig blijft', level: 2 },
64
+ { type: 'paragraph', html: 'Het aquarium blijft tussen wissels nitraat produceren. Gebruik daarom de volgende meting als nieuwe basis en niet alleen het modelgetal.' },
65
+ ],
66
+ pl: [
67
+ { type: 'title', text: 'Jak czytać powtarzane podmiany', level: 2 },
68
+ { type: 'paragraph', html: 'Scenariusze pokazują obliczany spadek azotanów po każdej identycznej podmianie. Każdy etap zaczyna się od wartości pozostałej po poprzednim.' },
69
+ { type: 'title', text: 'Dlaczego trzeba ponownie mierzyć', level: 2 },
70
+ { type: 'paragraph', html: 'Akwarium nadal wytwarza azotany pomiędzy podmianami. Kolejny pomiar traktuj więc jako nową podstawę, a nie jako potwierdzenie samego modelu.' },
71
+ ],
72
+ pt: [
73
+ { type: 'title', text: 'Como ler as trocas repetidas', level: 2 },
74
+ { type: 'paragraph', html: 'Os cenários mostram a descida estimada dos nitratos após cada troca igual. Cada etapa começa com o valor residual da etapa anterior.' },
75
+ { type: 'title', text: 'Porque é importante medir de novo', level: 2 },
76
+ { type: 'paragraph', html: 'O aquário continua a produzir nitratos entre trocas. Use a medição seguinte como nova referência, e não apenas o número do modelo.' },
77
+ ],
78
+ ru: [
79
+ { type: 'title', text: 'Как читать повторные подмены', level: 2 },
80
+ { type: 'paragraph', html: 'Сценарии показывают расчетное снижение нитратов после каждой одинаковой подмены. Каждый этап начинается с остаточного значения предыдущего этапа.' },
81
+ { type: 'title', text: 'Почему важно измерять снова', level: 2 },
82
+ { type: 'paragraph', html: 'Между подменами аквариум продолжает вырабатывать нитраты. Поэтому используйте следующий замер как новую основу, а не только число из модели.' },
83
+ ],
84
+ sv: [
85
+ { type: 'title', text: 'Så läser du upprepade byten', level: 2 },
86
+ { type: 'paragraph', html: 'Scenarierna visar den beräknade minskningen efter varje identiskt vattenbyte. Varje steg börjar med restvärdet från föregående steg.' },
87
+ { type: 'title', text: 'Varför du fortfarande måste mäta igen', level: 2 },
88
+ { type: 'paragraph', html: 'Akvariet fortsätter att bilda nitrat mellan vattenbytena. Använd därför nästa mätning som ny grund och inte bara modellens siffra.' },
89
+ ],
90
+ tr: [
91
+ { type: 'title', text: 'Tekrarlanan değişimleri okuma', level: 2 },
92
+ { type: 'paragraph', html: 'Senaryolar, aynı değişim tekrarlandığında nitratın tahmini olarak nasıl azaldığını gösterir. Her adım önceki adımda kalan değerden başlar.' },
93
+ { type: 'title', text: 'Neden yeniden ölçüm gerekir', level: 2 },
94
+ { type: 'paragraph', html: 'Akvaryum değişimler arasında nitrat üretmeye devam eder. Bu nedenle sonraki ölçümü yeni temel olarak kullanın ve yalnızca model sayısına güvenmeyin.' },
95
+ ],
96
+ zh: [
97
+ { type: 'title', text: '如何阅读重复换水结果', level: 2 },
98
+ { type: 'paragraph', html: '情景会显示重复进行相同比例换水后预计剩余的硝酸盐。每一步都从上一步留下的数值开始计算。' },
99
+ { type: 'title', text: '为什么仍然要重新检测', level: 2 },
100
+ { type: 'paragraph', html: '换水之间鱼缸仍会产生硝酸盐。因此请把下一次检测值作为新的依据,不要只依赖模型数字。' },
101
+ ],
102
+ };
103
+
104
+ function createFaqSchema(copy: AquariumNitrateLocaleCopy): WithContext<FAQPage> {
105
+ return {
106
+ '@context': 'https://schema.org',
107
+ '@type': 'FAQPage',
108
+ mainEntity: copy.faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })),
109
+ };
110
+ }
111
+
112
+ function createHowToSchema(copy: AquariumNitrateLocaleCopy): WithContext<HowTo> {
113
+ return {
114
+ '@context': 'https://schema.org',
115
+ '@type': 'HowTo',
116
+ name: copy.title,
117
+ description: copy.description,
118
+ step: copy.howTo.map((step, index) => ({ '@type': 'HowToStep', position: index + 1, name: step.name, text: step.text })),
119
+ };
120
+ }
121
+
122
+ function createAppSchema(copy: AquariumNitrateLocaleCopy): WithContext<SoftwareApplication> {
123
+ return {
124
+ '@context': 'https://schema.org',
125
+ '@type': 'SoftwareApplication',
126
+ name: copy.title,
127
+ description: copy.description,
128
+ applicationCategory: 'UtilityApplication',
129
+ operatingSystem: 'All',
130
+ offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
131
+ inLanguage: copy.locale,
132
+ };
133
+ }
134
+
135
+ export function createAquariumNitrateContent(copy: AquariumNitrateLocaleCopy): ToolLocaleContent<AquariumNitrateDilutionCalculatorUI> {
136
+ const schemas: WithContext<Thing>[] = [createFaqSchema(copy), createHowToSchema(copy), createAppSchema(copy)];
137
+ return { ...copy, seo: [...copy.seo, ...(additionalSeoByLocale[copy.locale] ?? [])], schemas, bibliography };
138
+ }
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { calculateNitrateDilution, DEFAULT_NITRATE_INPUT } from './logic';
3
+
4
+ describe('calculateNitrateDilution', () => {
5
+ it('calculates the exact change needed to reach the target', () => {
6
+ const result = calculateNitrateDilution(DEFAULT_NITRATE_INPUT);
7
+ expect(result.ok).toBe(true);
8
+ if (!result.ok) return;
9
+ expect(result.value.requiredPercent).toBeCloseTo(57.142857, 5);
10
+ expect(result.value.requiredLitres).toBeCloseTo(68.571428, 5);
11
+ expect(result.value.afterRequired).toBeCloseTo(20, 5);
12
+ });
13
+
14
+ it('models repeated partial changes against the same source water', () => {
15
+ const result = calculateNitrateDilution(DEFAULT_NITRATE_INPUT);
16
+ expect(result.ok).toBe(true);
17
+ if (!result.ok) return;
18
+ const scenario = result.value.scenarios.find((item) => item.percent === 50);
19
+ expect(scenario?.afterOne).toBeCloseTo(22.5, 5);
20
+ expect(scenario?.afterTwo).toBeCloseTo(13.75, 5);
21
+ expect(scenario?.sequence).toHaveLength(8);
22
+ expect(scenario?.sequence[2]).toBeCloseTo(9.375, 5);
23
+ expect(scenario?.reachesTargetAfter).toBe(2);
24
+ });
25
+
26
+ it('marks a target at or below the source concentration as unreachable', () => {
27
+ const result = calculateNitrateDilution({ ...DEFAULT_NITRATE_INPUT, sourceNitrate: 25 });
28
+ expect(result.ok).toBe(true);
29
+ if (!result.ok) return;
30
+ expect(result.value.status).toBe('unreachable');
31
+ });
32
+
33
+ it('recognizes when the current reading is already below target', () => {
34
+ const result = calculateNitrateDilution({ ...DEFAULT_NITRATE_INPUT, currentNitrate: 15 });
35
+ expect(result.ok).toBe(true);
36
+ if (!result.ok) return;
37
+ expect(result.value.status).toBe('already-below');
38
+ expect(result.value.requiredLitres).toBe(0);
39
+ });
40
+
41
+ it('rejects missing or out of range measurements', () => {
42
+ expect(calculateNitrateDilution({ ...DEFAULT_NITRATE_INPUT, volumeLitres: 0 })).toEqual({ ok: false, error: 'volume' });
43
+ expect(calculateNitrateDilution({ ...DEFAULT_NITRATE_INPUT, currentNitrate: -1 })).toEqual({ ok: false, error: 'current' });
44
+ expect(calculateNitrateDilution({ ...DEFAULT_NITRATE_INPUT, targetNitrate: 1001 })).toEqual({ ok: false, error: 'target' });
45
+ expect(calculateNitrateDilution({ ...DEFAULT_NITRATE_INPUT, sourceNitrate: Number.NaN })).toEqual({ ok: false, error: 'source' });
46
+ });
47
+ });
@@ -0,0 +1,122 @@
1
+ export interface NitrateDilutionInput {
2
+ volumeLitres: number;
3
+ currentNitrate: number;
4
+ targetNitrate: number;
5
+ sourceNitrate: number;
6
+ }
7
+
8
+ export interface NitrateScenario {
9
+ percent: number;
10
+ litres: number;
11
+ afterOne: number;
12
+ afterTwo: number;
13
+ afterThree: number;
14
+ sequence: number[];
15
+ reachesTargetAfter: number | null;
16
+ }
17
+
18
+ export type NitrateError = 'volume' | 'current' | 'target' | 'source';
19
+
20
+ export type NitrateResult =
21
+ | { ok: true; value: NitrateCalculation }
22
+ | { ok: false; error: NitrateError };
23
+
24
+ export interface NitrateCalculation {
25
+ status: 'ready' | 'already-below' | 'unreachable';
26
+ currentNitrate: number;
27
+ targetNitrate: number;
28
+ sourceNitrate: number;
29
+ requiredPercent: number;
30
+ requiredLitres: number;
31
+ afterRequired: number;
32
+ scenarios: NitrateScenario[];
33
+ }
34
+
35
+ export const DEFAULT_NITRATE_INPUT: NitrateDilutionInput = {
36
+ volumeLitres: 120,
37
+ currentNitrate: 40,
38
+ targetNitrate: 20,
39
+ sourceNitrate: 5,
40
+ };
41
+
42
+ export const SCENARIO_PERCENTAGES = [25, 40, 50, 75] as const;
43
+
44
+ function isValid(value: number, maximum: number): boolean {
45
+ return Number.isFinite(value) && value >= 0 && value <= maximum;
46
+ }
47
+
48
+ function validateInput(input: NitrateDilutionInput): NitrateError | null {
49
+ if (!Number.isFinite(input.volumeLitres) || input.volumeLitres <= 0 || input.volumeLitres > 100000) return 'volume';
50
+ if (!isValid(input.currentNitrate, 1000)) return 'current';
51
+ if (!isValid(input.targetNitrate, 1000)) return 'target';
52
+ if (!isValid(input.sourceNitrate, 1000)) return 'source';
53
+ return null;
54
+ }
55
+
56
+ function concentrationAfterChange(current: number, source: number, fraction: number): number {
57
+ return current * (1 - fraction) + source * fraction;
58
+ }
59
+
60
+ function repeatsToTarget(input: NitrateDilutionInput, fraction: number): number | null {
61
+ for (let count = 1; count <= 8; count += 1) {
62
+ const totalFraction = 1 - (1 - fraction) ** count;
63
+ const after = concentrationAfterChange(input.currentNitrate, input.sourceNitrate, totalFraction);
64
+ if (after <= input.targetNitrate + 0.000001) return count;
65
+ }
66
+ return null;
67
+ }
68
+
69
+ function repeatedConcentrations(input: NitrateDilutionInput, fraction: number): number[] {
70
+ return Array.from({ length: 8 }, (_, index) => {
71
+ const totalFraction = 1 - (1 - fraction) ** (index + 1);
72
+ return concentrationAfterChange(input.currentNitrate, input.sourceNitrate, totalFraction);
73
+ });
74
+ }
75
+
76
+ function makeScenario(input: NitrateDilutionInput, percent: number): NitrateScenario {
77
+ const fraction = percent / 100;
78
+ const sequence = repeatedConcentrations(input, fraction);
79
+ return {
80
+ percent,
81
+ litres: input.volumeLitres * fraction,
82
+ afterOne: sequence[0]!,
83
+ afterTwo: sequence[1]!,
84
+ afterThree: sequence[2]!,
85
+ sequence,
86
+ reachesTargetAfter: repeatsToTarget(input, fraction),
87
+ };
88
+ }
89
+
90
+ function requiredFraction(input: NitrateDilutionInput): number {
91
+ if (input.currentNitrate <= input.targetNitrate) return 0;
92
+ if (input.sourceNitrate === input.targetNitrate) return 1;
93
+ if (input.sourceNitrate > input.targetNitrate) return Number.NaN;
94
+ return (input.currentNitrate - input.targetNitrate) / (input.currentNitrate - input.sourceNitrate);
95
+ }
96
+
97
+ function calculationStatus(input: NitrateDilutionInput, fraction: number): NitrateCalculation['status'] {
98
+ if (Number.isNaN(fraction)) return 'unreachable';
99
+ if (input.currentNitrate <= input.targetNitrate) return 'already-below';
100
+ return 'ready';
101
+ }
102
+
103
+ export function calculateNitrateDilution(input: NitrateDilutionInput): NitrateResult {
104
+ const error = validateInput(input);
105
+ if (error !== null) return { ok: false, error };
106
+ const fraction = requiredFraction(input);
107
+ const status = calculationStatus(input, fraction);
108
+ const safeFraction = Number.isNaN(fraction) ? 0 : Math.min(1, Math.max(0, fraction));
109
+ return {
110
+ ok: true,
111
+ value: {
112
+ status,
113
+ currentNitrate: input.currentNitrate,
114
+ targetNitrate: input.targetNitrate,
115
+ sourceNitrate: input.sourceNitrate,
116
+ requiredPercent: safeFraction * 100,
117
+ requiredLitres: input.volumeLitres * safeFraction,
118
+ afterRequired: concentrationAfterChange(input.currentNitrate, input.sourceNitrate, safeFraction),
119
+ scenarios: SCENARIO_PERCENTAGES.map((percent) => makeScenario(input, percent)),
120
+ },
121
+ };
122
+ }
@@ -0,0 +1,13 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { aquariumNitrateDilutionCalculator } from './entry';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props { locale?: KnownLocale; }
7
+
8
+ const { locale = 'en' } = Astro.props;
9
+ const loader = aquariumNitrateDilutionCalculator.i18n[locale] ?? aquariumNitrateDilutionCalculator.i18n.en;
10
+ const content = loader === undefined ? undefined : await loader();
11
+ ---
12
+
13
+ {content && <SEORenderer content={{ locale, sections: content.seo }} />}
@@ -0,0 +1,36 @@
1
+ import { DEFAULT_NITRATE_INPUT, type NitrateDilutionInput } from './logic';
2
+ import type { UnitSystem } from './units';
3
+
4
+ const STORAGE_KEY = 'aquarium-nitrate-dilution-calculator';
5
+
6
+ export interface SavedNitrateState {
7
+ input: NitrateDilutionInput;
8
+ scenarioPercent: number;
9
+ unitSystem: UnitSystem;
10
+ }
11
+
12
+ export function defaultNitrateState(): SavedNitrateState {
13
+ return { input: { ...DEFAULT_NITRATE_INPUT }, scenarioPercent: 25, unitSystem: 'metric' };
14
+ }
15
+
16
+ export function loadNitrateState(): SavedNitrateState {
17
+ try {
18
+ const raw = localStorage.getItem(STORAGE_KEY);
19
+ if (raw === null) return defaultNitrateState();
20
+ const parsed = JSON.parse(raw) as Partial<SavedNitrateState>;
21
+ if (parsed.input === undefined) return defaultNitrateState();
22
+ return {
23
+ input: { ...DEFAULT_NITRATE_INPUT, ...parsed.input },
24
+ scenarioPercent: typeof parsed.scenarioPercent === 'number' ? parsed.scenarioPercent : 25,
25
+ unitSystem: parsed.unitSystem === 'imperial' ? 'imperial' : 'metric',
26
+ };
27
+ } catch {
28
+ return defaultNitrateState();
29
+ }
30
+ }
31
+
32
+ export function saveNitrateState(state: SavedNitrateState): void {
33
+ try {
34
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
35
+ } catch {}
36
+ }
@@ -0,0 +1,53 @@
1
+ export interface AquariumNitrateDilutionCalculatorUI extends Record<string, string> {
2
+ toolKicker: string;
3
+ inputInstruction: string;
4
+ unitSystemLabel: string;
5
+ metricLabel: string;
6
+ imperialLabel: string;
7
+ metricUnitSummary: string;
8
+ imperialUnitSummary: string;
9
+ volumeHint: string;
10
+ readingHint: string;
11
+ readingsTitle: string;
12
+ decisionTitle: string;
13
+ actionLabel: string;
14
+ sceneCaption: string;
15
+ newWaterLabel: string;
16
+ inputsLegend: string;
17
+ volumeLabel: string;
18
+ volumeUnit: string;
19
+ currentLabel: string;
20
+ targetLabel: string;
21
+ sourceLabel: string;
22
+ nitrateUnit: string;
23
+ sourceHint: string;
24
+ currentMarker: string;
25
+ targetMarker: string;
26
+ targetReached: string;
27
+ alreadyBelow: string;
28
+ unreachable: string;
29
+ requiredChangeLabel: string;
30
+ requiredLitresLabel: string;
31
+ afterChangeLabel: string;
32
+ safetyLabel: string;
33
+ safetyHint: string;
34
+ criticalSafetyLabel: string;
35
+ criticalSafetyHint: string;
36
+ scenarioLabel: string;
37
+ scenarioHint: string;
38
+ planLabel: string;
39
+ planHint: string;
40
+ planChangeLabel: string;
41
+ biologyHint: string;
42
+ impossibleHint: string;
43
+ belowTargetHint: string;
44
+ measureAgainHint: string;
45
+ resetLabel: string;
46
+ currentScenarioLabel: string;
47
+ repeatLabel: string;
48
+ notReached: string;
49
+ scenarioTwentyFive: string;
50
+ scenarioForty: string;
51
+ scenarioFifty: string;
52
+ scenarioSeventyFive: string;
53
+ }
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { nitrateUnit, volumeFromDisplay, volumeToDisplay, volumeUnit } from './units';
3
+
4
+ describe('aquarium nitrate dilution units', () => {
5
+ it('converts displayed imperial volume without changing the physical volume', () => {
6
+ const gallons = volumeToDisplay(120, 'imperial');
7
+ expect(gallons).toBeCloseTo(31.7006, 3);
8
+ expect(volumeFromDisplay(gallons, 'imperial')).toBeCloseTo(120, 8);
9
+ });
10
+
11
+ it('labels both unit systems consistently', () => {
12
+ expect(volumeUnit('metric')).toBe('L');
13
+ expect(nitrateUnit('metric')).toBe('mg/L NO3');
14
+ expect(volumeUnit('imperial')).toBe('US gal');
15
+ expect(nitrateUnit('imperial')).toBe('ppm NO3');
16
+ });
17
+ });
@@ -0,0 +1,19 @@
1
+ export type UnitSystem = 'metric' | 'imperial';
2
+
3
+ const LITRES_PER_US_GALLON = 3.785411784;
4
+
5
+ export function volumeToDisplay(volumeLitres: number, unitSystem: UnitSystem): number {
6
+ return unitSystem === 'imperial' ? volumeLitres / LITRES_PER_US_GALLON : volumeLitres;
7
+ }
8
+
9
+ export function volumeFromDisplay(volume: number, unitSystem: UnitSystem): number {
10
+ return unitSystem === 'imperial' ? volume * LITRES_PER_US_GALLON : volume;
11
+ }
12
+
13
+ export function volumeUnit(unitSystem: UnitSystem): string {
14
+ return unitSystem === 'imperial' ? 'US gal' : 'L';
15
+ }
16
+
17
+ export function nitrateUnit(unitSystem: UnitSystem): string {
18
+ return unitSystem === 'imperial' ? 'ppm NO3' : 'mg/L NO3';
19
+ }
package/src/tools.ts CHANGED
@@ -1,39 +1,18 @@
1
1
  export { ALL_ENTRIES } from './entries';
2
- import { aquariumTankVolumeWaterChangeCalculator } from './tool/aquariumTankVolumeWaterChangeCalculator/entry';
3
- import { aquariumFilterTurnoverStockingPlanner } from './tool/aquariumFilterTurnoverStockingPlanner/entry';
4
- import { aquariumLightingPhotoperiodPlanner } from './tool/aquariumLightingPhotoperiodPlanner/entry';
5
- import { aquariumEvaporationTopOffPlanner } from './tool/aquariumEvaporationTopOffPlanner/entry';
6
- import { aquariumSaltDosingCalculator } from './tool/aquariumSaltDosingCalculator/entry';
7
- import { aquariumCalciumAlkalinityDosingCalculator } from './tool/aquariumCalciumAlkalinityDosingCalculator/entry';
2
+ import { AQUARIUM_CALCIUM_ALKALINITY_DOSING_CALCULATOR_TOOL } from './tool/aquariumCalciumAlkalinityDosingCalculator/index';
3
+ import { AQUARIUM_EVAPORATION_TOP_OFF_PLANNER_TOOL } from './tool/aquariumEvaporationTopOffPlanner/index';
4
+ import { AQUARIUM_FILTER_TURNOVER_STOCKING_PLANNER_TOOL } from './tool/aquariumFilterTurnoverStockingPlanner/index';
5
+ import { AQUARIUM_LIGHTING_PHOTOPERIOD_PLANNER_TOOL } from './tool/aquariumLightingPhotoperiodPlanner/index';
6
+ import { AQUARIUM_NITRATE_DILUTION_CALCULATOR_TOOL } from './tool/aquariumNitrateDilutionCalculator/index';
7
+ import { AQUARIUM_SALT_DOSING_CALCULATOR_TOOL } from './tool/aquariumSaltDosingCalculator/index';
8
+ import { AQUARIUM_TANK_VOLUME_WATER_CHANGE_CALCULATOR_TOOL } from './tool/aquariumTankVolumeWaterChangeCalculator/index';
8
9
 
9
- export const ALL_TOOLS = [{
10
- entry: aquariumTankVolumeWaterChangeCalculator,
11
- Component: () => import('./tool/aquariumTankVolumeWaterChangeCalculator/component.astro'),
12
- SEOComponent: () => import('./tool/aquariumTankVolumeWaterChangeCalculator/seo.astro'),
13
- BibliographyComponent: () => import('./tool/aquariumTankVolumeWaterChangeCalculator/bibliography.astro'),
14
- }, {
15
- entry: aquariumFilterTurnoverStockingPlanner,
16
- Component: () => import('./tool/aquariumFilterTurnoverStockingPlanner/component.astro'),
17
- SEOComponent: () => import('./tool/aquariumFilterTurnoverStockingPlanner/seo.astro'),
18
- BibliographyComponent: () => import('./tool/aquariumFilterTurnoverStockingPlanner/bibliography.astro'),
19
- }, {
20
- entry: aquariumLightingPhotoperiodPlanner,
21
- Component: () => import('./tool/aquariumLightingPhotoperiodPlanner/component.astro'),
22
- SEOComponent: () => import('./tool/aquariumLightingPhotoperiodPlanner/seo.astro'),
23
- BibliographyComponent: () => import('./tool/aquariumLightingPhotoperiodPlanner/bibliography.astro'),
24
- }, {
25
- entry: aquariumEvaporationTopOffPlanner,
26
- Component: () => import('./tool/aquariumEvaporationTopOffPlanner/component.astro'),
27
- SEOComponent: () => import('./tool/aquariumEvaporationTopOffPlanner/seo.astro'),
28
- BibliographyComponent: () => import('./tool/aquariumEvaporationTopOffPlanner/bibliography.astro'),
29
- }, {
30
- entry: aquariumSaltDosingCalculator,
31
- Component: () => import('./tool/aquariumSaltDosingCalculator/component.astro'),
32
- SEOComponent: () => import('./tool/aquariumSaltDosingCalculator/seo.astro'),
33
- BibliographyComponent: () => import('./tool/aquariumSaltDosingCalculator/bibliography.astro'),
34
- }, {
35
- entry: aquariumCalciumAlkalinityDosingCalculator,
36
- Component: () => import('./tool/aquariumCalciumAlkalinityDosingCalculator/component.astro'),
37
- SEOComponent: () => import('./tool/aquariumCalciumAlkalinityDosingCalculator/seo.astro'),
38
- BibliographyComponent: () => import('./tool/aquariumCalciumAlkalinityDosingCalculator/bibliography.astro'),
39
- }];
10
+ export const ALL_TOOLS = [
11
+ AQUARIUM_CALCIUM_ALKALINITY_DOSING_CALCULATOR_TOOL,
12
+ AQUARIUM_EVAPORATION_TOP_OFF_PLANNER_TOOL,
13
+ AQUARIUM_FILTER_TURNOVER_STOCKING_PLANNER_TOOL,
14
+ AQUARIUM_LIGHTING_PHOTOPERIOD_PLANNER_TOOL,
15
+ AQUARIUM_NITRATE_DILUTION_CALCULATOR_TOOL,
16
+ AQUARIUM_SALT_DOSING_CALCULATOR_TOOL,
17
+ AQUARIUM_TANK_VOLUME_WATER_CHANGE_CALCULATOR_TOOL,
18
+ ];