@jjlmoya/utils-cooking 1.39.0 → 1.40.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/scripts/fix-translations.js +51 -0
  3. package/src/category/index.ts +3 -0
  4. package/src/entries.ts +4 -1
  5. package/src/index.ts +2 -0
  6. package/src/tests/i18n-titles.test.ts +2 -2
  7. package/src/tests/locale_completeness.test.ts +2 -2
  8. package/src/tests/sous-vide-pasteurization-curves.test.ts +66 -0
  9. package/src/tests/tool_validation.test.ts +2 -2
  10. package/src/tool/sous-vide-pasteurization-curves/bibliography.astro +6 -0
  11. package/src/tool/sous-vide-pasteurization-curves/bibliography.ts +10 -0
  12. package/src/tool/sous-vide-pasteurization-curves/component.astro +275 -0
  13. package/src/tool/sous-vide-pasteurization-curves/components/Controls.astro +90 -0
  14. package/src/tool/sous-vide-pasteurization-curves/components/LethalityChart.astro +28 -0
  15. package/src/tool/sous-vide-pasteurization-curves/components/ResultsDisplay.astro +36 -0
  16. package/src/tool/sous-vide-pasteurization-curves/entry.ts +26 -0
  17. package/src/tool/sous-vide-pasteurization-curves/i18n/de.ts +323 -0
  18. package/src/tool/sous-vide-pasteurization-curves/i18n/en.ts +323 -0
  19. package/src/tool/sous-vide-pasteurization-curves/i18n/es.ts +323 -0
  20. package/src/tool/sous-vide-pasteurization-curves/i18n/fr.ts +323 -0
  21. package/src/tool/sous-vide-pasteurization-curves/i18n/id.ts +323 -0
  22. package/src/tool/sous-vide-pasteurization-curves/i18n/it.ts +323 -0
  23. package/src/tool/sous-vide-pasteurization-curves/i18n/ja.ts +323 -0
  24. package/src/tool/sous-vide-pasteurization-curves/i18n/ko.ts +323 -0
  25. package/src/tool/sous-vide-pasteurization-curves/i18n/nl.ts +323 -0
  26. package/src/tool/sous-vide-pasteurization-curves/i18n/pl.ts +154 -0
  27. package/src/tool/sous-vide-pasteurization-curves/i18n/pt.ts +323 -0
  28. package/src/tool/sous-vide-pasteurization-curves/i18n/ru.ts +154 -0
  29. package/src/tool/sous-vide-pasteurization-curves/i18n/sv.ts +154 -0
  30. package/src/tool/sous-vide-pasteurization-curves/i18n/tr.ts +154 -0
  31. package/src/tool/sous-vide-pasteurization-curves/i18n/zh.ts +323 -0
  32. package/src/tool/sous-vide-pasteurization-curves/index.ts +11 -0
  33. package/src/tool/sous-vide-pasteurization-curves/logic.ts +89 -0
  34. package/src/tool/sous-vide-pasteurization-curves/seo.astro +15 -0
  35. package/src/tool/sous-vide-pasteurization-curves/sous-vide-pasteurization-curves.css +456 -0
  36. package/src/tools.ts +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-cooking",
3
- "version": "1.39.0",
3
+ "version": "1.40.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -0,0 +1,51 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const i18nDir = 'd:/code/jjlmoya-utils-cooking/src/tool/sous-vide-pasteurization-curves/i18n';
5
+ const files = fs.readdirSync(i18nDir).filter(f => f.endsWith('.ts'));
6
+
7
+ const replacements = [
8
+ { search: /\u2013/g, replace: '-' },
9
+ { search: /\u2014/g, replace: ' - ' },
10
+ { search: /\u2026/g, replace: '...' },
11
+ { search: /\u201C/g, replace: '"' },
12
+ { search: /\u201D/g, replace: '"' },
13
+ { search: /\u2018/g, replace: "'" },
14
+ { search: /\u2019/g, replace: "'" },
15
+ { search: /\u200B/g, replace: '' },
16
+ { search: /\u201E/g, replace: '"' },
17
+ { search: / : /g, replace: ': ' }
18
+ ];
19
+
20
+ files.forEach(file => {
21
+ const filePath = path.join(i18nDir, file);
22
+ let content = fs.readFileSync(filePath, 'utf-8');
23
+
24
+ replacements.forEach(rep => {
25
+ content = content.replace(rep.search, rep.replace);
26
+ });
27
+
28
+ if (file === 'es.ts') {
29
+ content = content.replace("slug: 'sous-vide-pasteurization-curves'", "slug: 'curvas-pasteurizacion-sous-vide'");
30
+ } else if (file === 'nl.ts') {
31
+ content = content.replace("slug: 'sous-vide-pasteurization-curves'", "slug: 'sous-vide-pasteurisatiecurven'");
32
+ } else if (file === 'id.ts') {
33
+ content = content.replace("slug: 'sous-vide-pasteurization-curves'", "slug: 'kurva-pasteurisasi-sous-vide'");
34
+ } else if (file === 'sv.ts') {
35
+ content = content.replace("slug: 'sous-vide-pasteurization-curves-sv'", "slug: 'sous-vide-pasteuriseringskurvor'");
36
+ } else if (file === 'tr.ts') {
37
+ content = content.replace("slug: 'sous-vide-pasteurization-curves-tr'", "slug: 'sous-vide-pastorizasyon-egrileri'");
38
+ } else if (file === 'ru.ts') {
39
+ content = content.replace("slug: 'sous-vide-pasteurization-curves-ru'", "slug: 'kalkulyator-pasterizacii-su-vid'");
40
+ content = content.replace("Калькулятор Кривых Пастеризации Су-Вид", "Калькулятор Кривых Пастеризации Су Вид");
41
+ content = content.replace("Кривые Пастеризации Су-Вид", "Кривые Пастеризации Су Вид");
42
+ } else if (file === 'pl.ts') {
43
+ content = content.replace("slug: 'sous-vide-pasteurization-curves-pl'", "slug: 'krzywe-pasteryzacji-sous-vide'");
44
+ } else if (file === 'de.ts') {
45
+ content = content.replace("Sous Vide Pasteurisierungs-Kurvenrechner", "Sous Vide Pasteurisierungs Kurvenrechner");
46
+ content = content.replace("Sous Vide Pasteurisierungs-Kurven", "Sous Vide Pasteurisierungs Kurven");
47
+ content = content.replace("Thermische Letalitätskinetik und Kerntemperatur-Kompensation", "Thermische Letalitätskinetik und Kerntemperatur Kompensation");
48
+ }
49
+
50
+ fs.writeFileSync(filePath, content, 'utf-8');
51
+ });
@@ -23,6 +23,7 @@ import { brixSorbetDensity } from '../tool/brix-sorbet-density-calculator/entry'
23
23
  import { oilSmokePoint } from '../tool/oil-smoke-point-tracker/entry';
24
24
  import { leavenerAcidNeutralizer } from '../tool/leavener-acid-neutralizer/entry';
25
25
  import { pectinJam } from '../tool/pectin-jam-setting-calculator/entry';
26
+ import { sousVidePasteurization } from '../tool/sous-vide-pasteurization-curves/entry';
26
27
 
27
28
  export const cookingCategory: CookingCategoryEntry = {
28
29
  icon: 'mdi:chef-hat',
@@ -51,8 +52,10 @@ export const cookingCategory: CookingCategoryEntry = {
51
52
  oilSmokePoint,
52
53
  leavenerAcidNeutralizer,
53
54
  pectinJam,
55
+ sousVidePasteurization,
54
56
  ],
55
57
 
58
+
56
59
  i18n: {
57
60
  es: () => import('./i18n/es').then((m) => m.content),
58
61
  en: () => import('./i18n/en').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -23,6 +23,7 @@ export { botulismCanningSafety } from './tool/botulism-canning-safety/entry';
23
23
  export { oilSmokePoint } from './tool/oil-smoke-point-tracker/entry';
24
24
  export { leavenerAcidNeutralizer } from './tool/leavener-acid-neutralizer/entry';
25
25
  export { pectinJam } from './tool/pectin-jam-setting-calculator/entry';
26
+ export { sousVidePasteurization } from './tool/sous-vide-pasteurization-curves/entry';
26
27
  export { cookingCategory } from './category';
27
28
  import { americanKitchenConverter } from './tool/american-kitchen-converter/entry';
28
29
  import { bananaCare } from './tool/banana-ripeness/entry';
@@ -49,5 +50,7 @@ import { brixSorbetDensity } from './tool/brix-sorbet-density-calculator/entry';
49
50
  import { oilSmokePoint } from './tool/oil-smoke-point-tracker/entry';
50
51
  import { leavenerAcidNeutralizer } from './tool/leavener-acid-neutralizer/entry';
51
52
  import { pectinJam } from './tool/pectin-jam-setting-calculator/entry';
52
- export const ALL_ENTRIES = [americanKitchenConverter, bananaCare, brine, cookwareGuide, eggTimer, ingredientRescaler, kitchenTimer, meringuePeak, moldScaler, pizza, rouxGuide, sourdoughCalculator, yeastConverter, lactoFermentationSalt, spherificationBath, iceCreamPacPod, botulismCanningSafety, meatBinder, carryOverCooking, maillardReaction, macaronDrying, brixSorbetDensity, oilSmokePoint, leavenerAcidNeutralizer, pectinJam];
53
+ import { sousVidePasteurization } from './tool/sous-vide-pasteurization-curves/entry';
54
+ export const ALL_ENTRIES = [americanKitchenConverter, bananaCare, brine, cookwareGuide, eggTimer, ingredientRescaler, kitchenTimer, meringuePeak, moldScaler, pizza, rouxGuide, sourdoughCalculator, yeastConverter, lactoFermentationSalt, spherificationBath, iceCreamPacPod, botulismCanningSafety, meatBinder, carryOverCooking, maillardReaction, macaronDrying, brixSorbetDensity, oilSmokePoint, leavenerAcidNeutralizer, pectinJam, sousVidePasteurization];
55
+
53
56
 
package/src/index.ts CHANGED
@@ -26,6 +26,8 @@ export { BRIX_SORBET_DENSITY_CALCULATOR_TOOL } from './tool/brix-sorbet-density-
26
26
  export { OIL_SMOKE_POINT_TRACKER_TOOL } from './tool/oil-smoke-point-tracker';
27
27
  export { LEAVENER_ACID_NEUTRALIZER_TOOL } from './tool/leavener-acid-neutralizer';
28
28
  export { PECTIN_JAM_SETTING_TOOL } from './tool/pectin-jam-setting-calculator';
29
+ export { SOUS_VIDE_PASTEURIZATION_TOOL } from './tool/sous-vide-pasteurization-curves';
30
+
29
31
 
30
32
 
31
33
  export type {
@@ -34,9 +34,9 @@ describe("i18n titles for FAQ", () => {
34
34
  }
35
35
  });
36
36
 
37
- it("should have 18 tools with complete i18n setup", async () => {
37
+ it("should have 19 tools with complete i18n setup", async () => {
38
38
  const completeTools = ALL_TOOLS.filter((t) => Object.keys(t.entry.i18n).length > 1);
39
- expect(completeTools.length).toBe(25);
39
+ expect(completeTools.length).toBe(26);
40
40
  });
41
41
 
42
42
  it("tool IDs should be correctly registered", () => {
@@ -24,8 +24,8 @@ describe('Locale Completeness Validation', () => {
24
24
  });
25
25
  });
26
26
 
27
- it('all 25 tools registered', () => {
28
- expect(ALL_TOOLS.length).toBe(25);
27
+ it('all 26 tools registered', () => {
28
+ expect(ALL_TOOLS.length).toBe(26);
29
29
  });
30
30
 
31
31
  });
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { SousVideLogic } from '../tool/sous-vide-pasteurization-curves/logic';
3
+
4
+ describe('Sous Vide Pasteurization Curves Logic', () => {
5
+ it('calculates correct heating time for slab, cylinder and sphere', () => {
6
+ const slabRes = SousVideLogic.calculate({
7
+ bathTemp: 60,
8
+ thickness: 25,
9
+ shape: 'slab',
10
+ pathogen: 'salmonella'
11
+ });
12
+ expect(slabRes.heatingMinutes).toBe(75);
13
+
14
+ const cylRes = SousVideLogic.calculate({
15
+ bathTemp: 60,
16
+ thickness: 25,
17
+ shape: 'cylinder',
18
+ pathogen: 'salmonella'
19
+ });
20
+ expect(cylRes.heatingMinutes).toBe(29.4);
21
+
22
+ const sphereRes = SousVideLogic.calculate({
23
+ bathTemp: 60,
24
+ thickness: 25,
25
+ shape: 'sphere',
26
+ pathogen: 'salmonella'
27
+ });
28
+ expect(sphereRes.heatingMinutes).toBe(18.8);
29
+ });
30
+
31
+ it('calculates correct pathogen lethality times', () => {
32
+ const salmRes = SousVideLogic.calculate({
33
+ bathTemp: 60,
34
+ thickness: 25,
35
+ shape: 'slab',
36
+ pathogen: 'salmonella'
37
+ });
38
+ expect(salmRes.lethalityMinutes).toBe(9);
39
+
40
+ const listRes = SousVideLogic.calculate({
41
+ bathTemp: 60,
42
+ thickness: 25,
43
+ shape: 'slab',
44
+ pathogen: 'listeria'
45
+ });
46
+ expect(listRes.lethalityMinutes).toBe(18);
47
+ });
48
+
49
+ it('correctly identifies safe vs danger zone temperatures', () => {
50
+ const dangerRes = SousVideLogic.calculate({
51
+ bathTemp: 52,
52
+ thickness: 25,
53
+ shape: 'slab',
54
+ pathogen: 'salmonella'
55
+ });
56
+ expect(dangerRes.isDangerZone).toBe(true);
57
+
58
+ const safeRes = SousVideLogic.calculate({
59
+ bathTemp: 55,
60
+ thickness: 25,
61
+ shape: 'slab',
62
+ pathogen: 'salmonella'
63
+ });
64
+ expect(safeRes.isDangerZone).toBe(false);
65
+ });
66
+ });
@@ -4,8 +4,8 @@ import { cookingCategory } from '../data';
4
4
 
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
- it('should have 25 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(25);
7
+ it('should have 26 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(26);
9
9
  });
10
10
 
11
11
 
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography as BibliographyComponent } from '@jjlmoya/utils-shared';
3
+ import { bibliography } from './bibliography';
4
+ ---
5
+
6
+ <BibliographyComponent links={bibliography} />
@@ -0,0 +1,10 @@
1
+ export const bibliography = [
2
+ {
3
+ name: 'A Practical Guide to Sous Vide Cooking (Douglas Baldwin)',
4
+ url: 'https://douglasbaldwin.com/sous-vide.html',
5
+ },
6
+ {
7
+ name: 'USDA FSIS Compliance Guidelines for Meeting Lethality Performance Standards',
8
+ url: 'https://www.canr.msu.edu/smprv/uploads/files/Appendix_A_and_Compliance_Guidelines.pdf'
9
+ },
10
+ ];
@@ -0,0 +1,275 @@
1
+ ---
2
+ import Controls from './components/Controls.astro';
3
+ import ResultsDisplay from './components/ResultsDisplay.astro';
4
+ import LethalityChart from './components/LethalityChart.astro';
5
+
6
+ interface Props {
7
+ ui: Record<string, string>;
8
+ }
9
+
10
+ const { ui } = Astro.props;
11
+ ---
12
+
13
+ <div class="sv-container">
14
+ <div class="sv-dashboard">
15
+ <div class="sv-bg-particles" id="sv-particles-container"></div>
16
+
17
+ <div class="sv-row-top">
18
+ <LethalityChart ui={ui} />
19
+ </div>
20
+
21
+ <div class="sv-row-bottom">
22
+ <Controls ui={ui} />
23
+ <ResultsDisplay ui={ui} />
24
+ </div>
25
+ </div>
26
+
27
+ <p class="sv-disclaimer">{ui.disclaimer}</p>
28
+ </div>
29
+
30
+ <script>
31
+ import { SousVideLogic } from './logic';
32
+
33
+ const $ = (id: string) => document.getElementById(id);
34
+ const $$ = (sel: string) => document.querySelectorAll(sel);
35
+
36
+ const STORAGE_KEY = 'sous_vide_pasteurization_state';
37
+
38
+ let system: 'metric' | 'imperial' = 'metric';
39
+ let bathTemp = 56.0;
40
+ let thickness = 25;
41
+ let shape: 'slab' | 'cylinder' | 'sphere' = 'slab';
42
+ let pathogen: 'salmonella' | 'listeria' = 'salmonella';
43
+
44
+ const tempSlider = $('sv-temp-slider') as HTMLInputElement;
45
+ const tempBadge = $('sv-temp-badge');
46
+ const thicknessSlider = $('sv-thickness-slider') as HTMLInputElement;
47
+ const thicknessBadge = $('sv-thickness-badge');
48
+ const systemButtons = $$('#sv-system-toggle .sv-toggle-btn');
49
+ const shapeButtons = $$('#sv-shape-toggle .sv-toggle-btn');
50
+ const pathogenButtons = $$('#sv-pathogen-toggle .sv-toggle-btn');
51
+ const heatingVal = $('sv-heating-val');
52
+ const lethalityVal = $('sv-lethality-val');
53
+ const totalVal = $('sv-total-val');
54
+ const dangerAlert = $('sv-danger-alert');
55
+ const curveLine = $('sv-curve-line');
56
+ const yGrid = $('sv-y-grid');
57
+ const xGrid = $('sv-x-grid');
58
+ const particlesContainer = $('sv-particles-container');
59
+
60
+ function initParticles() {
61
+ if (!particlesContainer) return;
62
+ particlesContainer.innerHTML = '';
63
+ const numParticles = 15;
64
+ for (let i = 0; i < numParticles; i++) {
65
+ const bubble = document.createElement('div');
66
+ bubble.className = 'sv-bg-bubble';
67
+ const size = Math.random() * 8 + 4;
68
+ bubble.style.width = `${size}px`;
69
+ bubble.style.height = `${size}px`;
70
+ bubble.style.left = `${Math.random() * 100}%`;
71
+ bubble.style.animationDelay = `${Math.random() * 8}s`;
72
+ particlesContainer.appendChild(bubble);
73
+ }
74
+ }
75
+
76
+ function updateParticlesSpeed() {
77
+ const bubbles = $$('.sv-bg-bubble') as NodeListOf<HTMLElement>;
78
+ const speedFactor = Math.max(0.5, (bathTemp - 50) / 10);
79
+ bubbles.forEach(b => {
80
+ const duration = 8 / speedFactor;
81
+ b.style.animationDuration = `${duration}s`;
82
+ });
83
+ }
84
+
85
+ function drawGrid(totalMinutes: number) {
86
+ if (!yGrid || !xGrid) return;
87
+
88
+ let yHTML = '';
89
+ for (let i = 0; i <= 7; i++) {
90
+ const y = 170 - (i * (140 / 7));
91
+ yHTML += `<line x1="50" y1="${y}" x2="360" y2="${y}" class="sv-grid-line" />`;
92
+ yHTML += `<text x="40" y="${y + 4}" class="sv-axis-text" text-anchor="end">${i}D</text>`;
93
+ }
94
+ yGrid.innerHTML = yHTML;
95
+
96
+ let xHTML = '';
97
+ const intervals = 5;
98
+ const intervalMinutes = totalMinutes / intervals;
99
+ for (let i = 0; i <= intervals; i++) {
100
+ const x = 50 + (i * (310 / intervals));
101
+ const label = Math.round(i * intervalMinutes);
102
+ xHTML += `<line x1="${x}" y1="30" x2="${x}" y2="170" class="sv-grid-line" />`;
103
+ xHTML += `<text x="${x}" y="188" class="sv-axis-text" text-anchor="middle">${label}</text>`;
104
+ }
105
+ xGrid.innerHTML = xHTML;
106
+ }
107
+
108
+ function updateChart(heatingMinutes: number, totalMinutes: number) {
109
+ if (!curveLine) return;
110
+
111
+ const xStart = 50;
112
+ const xEnd = 360;
113
+ const yStart = 170;
114
+ const yEnd = 30;
115
+
116
+ const heatRatio = totalMinutes > 0 ? (heatingMinutes / totalMinutes) : 0;
117
+ const xMid = xStart + (xEnd - xStart) * heatRatio;
118
+ const yMid = yStart - 5;
119
+
120
+ let d = `M ${xStart} ${yStart}`;
121
+ d += ` Q ${(xStart + xMid) / 2} ${yStart}, ${xMid} ${yMid}`;
122
+ d += ` Q ${(xMid + xEnd) / 2} ${yMid}, ${xEnd} ${yEnd}`;
123
+
124
+ curveLine.setAttribute('d', d);
125
+ }
126
+
127
+ function save() {
128
+ try {
129
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({
130
+ system,
131
+ sliderTemp: tempSlider.value,
132
+ sliderThick: thicknessSlider.value,
133
+ shape,
134
+ pathogen
135
+ }));
136
+ } catch {}
137
+ }
138
+
139
+ function restoreSystem(sys: string) {
140
+ system = sys;
141
+ systemButtons.forEach(b => {
142
+ b.classList.remove('active');
143
+ if (b.getAttribute('data-value') === system) b.classList.add('active');
144
+ });
145
+ if (system === 'metric') {
146
+ tempSlider.min = '50'; tempSlider.max = '70'; tempSlider.step = '0.5';
147
+ thicknessSlider.min = '5'; thicknessSlider.max = '100'; thicknessSlider.step = '1';
148
+ } else {
149
+ tempSlider.min = '122'; tempSlider.max = '158'; tempSlider.step = '1';
150
+ thicknessSlider.min = '0.2'; thicknessSlider.max = '4.0'; thicknessSlider.step = '0.1';
151
+ }
152
+ }
153
+
154
+ function restore() {
155
+ try {
156
+ const raw = localStorage.getItem(STORAGE_KEY);
157
+ if (!raw) return;
158
+ const data = JSON.parse(raw);
159
+ if (data.system) restoreSystem(data.system);
160
+ if (data.sliderTemp) tempSlider.value = data.sliderTemp;
161
+ if (data.sliderThick) thicknessSlider.value = data.sliderThick;
162
+ } catch {}
163
+ }
164
+
165
+ function readSliders() {
166
+ if (system === 'metric') {
167
+ bathTemp = parseFloat(tempSlider.value);
168
+ thickness = parseFloat(thicknessSlider.value);
169
+ if (tempBadge) tempBadge.textContent = bathTemp.toFixed(1) + uniTemp;
170
+ if (thicknessBadge) thicknessBadge.textContent = thickness + uniThick;
171
+ } else {
172
+ const f = parseFloat(tempSlider.value);
173
+ const inch = parseFloat(thicknessSlider.value);
174
+ bathTemp = (f - 32) / 1.8;
175
+ thickness = inch * 25.4;
176
+ if (tempBadge) tempBadge.textContent = f.toFixed(0) + uniTemp;
177
+ if (thicknessBadge) thicknessBadge.textContent = inch.toFixed(1) + uniThick;
178
+ }
179
+ }
180
+
181
+ function showResults(result: ReturnType<typeof SousVideLogic.calculate>) {
182
+ if (heatingVal) heatingVal.textContent = result.heatingMinutes + ' min';
183
+ if (lethalityVal) lethalityVal.textContent = result.lethalityMinutes + ' min';
184
+ if (totalVal) totalVal.textContent = String(result.totalMinutes);
185
+ if (dangerAlert) dangerAlert.style.display = result.isDangerZone ? 'flex' : 'none';
186
+ drawGrid(result.totalMinutes);
187
+ updateChart(result.heatingMinutes, result.totalMinutes);
188
+ updateParticlesSpeed();
189
+ }
190
+
191
+ function update() {
192
+ readSliders();
193
+ const result = SousVideLogic.calculate({ bathTemp, thickness, shape, pathogen });
194
+ showResults(result);
195
+ save();
196
+ }
197
+
198
+ function handleSystemChange(newSystem: 'metric' | 'imperial') {
199
+ if (system === newSystem) return;
200
+
201
+ const oldSliderTemp = parseFloat(tempSlider.value);
202
+ const oldSliderThick = parseFloat(thicknessSlider.value);
203
+
204
+ system = newSystem;
205
+
206
+ if (system === 'metric') {
207
+ const tempC = (oldSliderTemp - 32) / 1.8;
208
+ tempSlider.min = '50';
209
+ tempSlider.max = '70';
210
+ tempSlider.step = '0.5';
211
+ tempSlider.value = Math.max(50, Math.min(70, tempC)).toFixed(1);
212
+
213
+ const thickMm = oldSliderThick * 25.4;
214
+ thicknessSlider.min = '5';
215
+ thicknessSlider.max = '100';
216
+ thicknessSlider.step = '1';
217
+ thicknessSlider.value = Math.max(5, Math.min(100, thickMm)).toFixed(0);
218
+ } else {
219
+ const tempF = oldSliderTemp * 1.8 + 32;
220
+ tempSlider.min = '122';
221
+ tempSlider.max = '158';
222
+ tempSlider.step = '1';
223
+ tempSlider.value = Math.max(122, Math.min(158, tempF)).toFixed(0);
224
+
225
+ const thickIn = oldSliderThick / 25.4;
226
+ thicknessSlider.min = '0.2';
227
+ thicknessSlider.max = '4.0';
228
+ thicknessSlider.step = '0.1';
229
+ thicknessSlider.value = Math.max(0.2, Math.min(4.0, thickIn)).toFixed(1);
230
+ }
231
+
232
+ update();
233
+ }
234
+
235
+ if (tempSlider) {
236
+ tempSlider.addEventListener('input', update);
237
+ }
238
+
239
+ if (thicknessSlider) {
240
+ thicknessSlider.addEventListener('input', update);
241
+ }
242
+
243
+ systemButtons.forEach(btn => {
244
+ btn.addEventListener('click', (e) => {
245
+ systemButtons.forEach(b => b.classList.remove('active'));
246
+ const target = e.currentTarget as HTMLButtonElement;
247
+ target.classList.add('active');
248
+ handleSystemChange(target.getAttribute('data-value') as 'metric' | 'imperial');
249
+ });
250
+ });
251
+
252
+ shapeButtons.forEach(btn => {
253
+ btn.addEventListener('click', (e) => {
254
+ shapeButtons.forEach(b => b.classList.remove('active'));
255
+ const target = e.currentTarget as HTMLButtonElement;
256
+ target.classList.add('active');
257
+ shape = target.getAttribute('data-value') as 'slab' | 'cylinder' | 'sphere';
258
+ update();
259
+ });
260
+ });
261
+
262
+ pathogenButtons.forEach(btn => {
263
+ btn.addEventListener('click', (e) => {
264
+ pathogenButtons.forEach(b => b.classList.remove('active'));
265
+ const target = e.currentTarget as HTMLButtonElement;
266
+ target.classList.add('active');
267
+ pathogen = target.getAttribute('data-value') as 'salmonella' | 'listeria';
268
+ update();
269
+ });
270
+ });
271
+
272
+ restore();
273
+ initParticles();
274
+ update();
275
+ </script>
@@ -0,0 +1,90 @@
1
+ ---
2
+ interface Props {
3
+ ui: Record<string, string>;
4
+ }
5
+
6
+ const { ui } = Astro.props;
7
+ ---
8
+
9
+ <div class="sv-controls-panel">
10
+ <div class="sv-glass-card">
11
+ <div class="sv-input-group">
12
+ <span class="sv-label">{ui.systemLabel}</span>
13
+ <div id="sv-system-toggle" class="sv-toggle-grid">
14
+ <button type="button" class="sv-toggle-btn active" data-value="metric">
15
+ {ui.systemMetric}
16
+ </button>
17
+ <button type="button" class="sv-toggle-btn" data-value="imperial">
18
+ {ui.systemImperial}
19
+ </button>
20
+ </div>
21
+ </div>
22
+ </div>
23
+
24
+ <div class="sv-glass-card">
25
+ <div class="sv-input-group">
26
+ <div class="sv-label-container">
27
+ <label for="sv-temp-slider" class="sv-label">{ui.bathTempLabel}</label>
28
+ <span id="sv-temp-badge" class="sv-value-badge">56.0{ui.tempUnitC}</span>
29
+ </div>
30
+ <input
31
+ type="range"
32
+ id="sv-temp-slider"
33
+ class="sv-slider"
34
+ min="50"
35
+ max="70"
36
+ step="0.5"
37
+ value="56.0"
38
+ />
39
+ </div>
40
+ </div>
41
+
42
+ <div class="sv-glass-card">
43
+ <div class="sv-input-group">
44
+ <div class="sv-label-container">
45
+ <label for="sv-thickness-slider" class="sv-label">{ui.thicknessLabel}</label>
46
+ <span id="sv-thickness-badge" class="sv-value-badge">25{ui.mmUnit}</span>
47
+ </div>
48
+ <input
49
+ type="range"
50
+ id="sv-thickness-slider"
51
+ class="sv-slider"
52
+ min="5"
53
+ max="100"
54
+ step="1"
55
+ value="25"
56
+ />
57
+ </div>
58
+ </div>
59
+
60
+ <div class="sv-glass-card">
61
+ <div class="sv-input-group">
62
+ <span class="sv-label">{ui.shapeLabel}</span>
63
+ <div id="sv-shape-toggle" class="sv-toggle-grid">
64
+ <button type="button" class="sv-toggle-btn active" data-value="slab">
65
+ {ui.shapeSlab.split(' ')[0]}
66
+ </button>
67
+ <button type="button" class="sv-toggle-btn" data-value="cylinder">
68
+ {ui.shapeCylinder.split(' ')[0]}
69
+ </button>
70
+ <button type="button" class="sv-toggle-btn" data-value="sphere">
71
+ {ui.shapeSphere.split(' ')[0]}
72
+ </button>
73
+ </div>
74
+ </div>
75
+ </div>
76
+
77
+ <div class="sv-glass-card">
78
+ <div class="sv-input-group">
79
+ <span class="sv-label">{ui.pathogenLabel}</span>
80
+ <div id="sv-pathogen-toggle" class="sv-pathogen-grid sv-toggle-grid">
81
+ <button type="button" class="sv-toggle-btn active" data-value="salmonella">
82
+ {ui.pathogenSalmonella.split(' ')[0]}
83
+ </button>
84
+ <button type="button" class="sv-toggle-btn" data-value="listeria">
85
+ Listeria
86
+ </button>
87
+ </div>
88
+ </div>
89
+ </div>
90
+ </div>
@@ -0,0 +1,28 @@
1
+ ---
2
+ interface Props {
3
+ ui: Record<string, string>;
4
+ }
5
+
6
+ const { ui } = Astro.props;
7
+ ---
8
+
9
+ <div class="sv-chart-panel">
10
+ <h3 class="sv-chart-title">{ui.chartTitle}</h3>
11
+
12
+ <svg class="sv-svg-chart" viewBox="0 0 400 220" id="sv-chart-svg">
13
+ <line x1="40" y1="20" x2="40" y2="180" stroke="rgba(226,232,240,0.2)" stroke-width="1.5" />
14
+ <line x1="40" y1="180" x2="380" y2="180" stroke="rgba(226,232,240,0.2)" stroke-width="1.5" />
15
+
16
+ <g id="sv-y-grid"></g>
17
+ <g id="sv-x-grid"></g>
18
+
19
+ <text x="15" y="100" class="sv-axis-text" transform="rotate(-90 15 100)" text-anchor="middle">
20
+ {ui.chartYLabel}
21
+ </text>
22
+ <text x="210" y="210" class="sv-axis-text" text-anchor="middle">
23
+ {ui.chartXLabel}
24
+ </text>
25
+
26
+ <path id="sv-curve-line" class="sv-curve-path" d="" />
27
+ </svg>
28
+ </div>
@@ -0,0 +1,36 @@
1
+ ---
2
+ interface Props {
3
+ ui: Record<string, string>;
4
+ }
5
+
6
+ const { ui } = Astro.props;
7
+ ---
8
+
9
+ <div class="sv-glass-card sv-results-panel">
10
+ <h3 class="sv-chart-title">{ui.resultsTitle}</h3>
11
+
12
+ <div class="sv-results-hero">
13
+ <div class="sv-hero-ring">
14
+ <span id="sv-total-val" class="sv-hero-value">--</span>
15
+ <span class="sv-hero-unit">{ui.minutesUnit}</span>
16
+ </div>
17
+ <span class="sv-hero-label">{ui.totalTime}</span>
18
+ </div>
19
+
20
+ <div class="sv-results-grid">
21
+ <div class="sv-result-card">
22
+ <span class="sv-card-metric-label">{ui.heatingTime}</span>
23
+ <span id="sv-heating-val" class="sv-card-metric-value">--</span>
24
+ </div>
25
+
26
+ <div class="sv-result-card">
27
+ <span class="sv-card-metric-label">{ui.lethalityTime}</span>
28
+ <span id="sv-lethality-val" class="sv-card-metric-value">--</span>
29
+ </div>
30
+ </div>
31
+
32
+ <div id="sv-danger-alert" class="sv-danger-alert" style="display: none;">
33
+ <h4 class="sv-danger-title">{ui.dangerZoneTitle}</h4>
34
+ <p class="sv-danger-desc">{ui.dangerZoneDesc}</p>
35
+ </div>
36
+ </div>