@jjlmoya/utils-cooking 1.36.0 → 1.38.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 (81) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +6 -0
  3. package/src/entries.ts +7 -1
  4. package/src/index.ts +3 -0
  5. package/src/tests/brix-sorbet-density-calculator.test.ts +53 -0
  6. package/src/tests/i18n-titles.test.ts +2 -2
  7. package/src/tests/leavener-acid-neutralizer.test.ts +42 -0
  8. package/src/tests/locale_completeness.test.ts +2 -2
  9. package/src/tests/tool_validation.test.ts +2 -2
  10. package/src/tool/brix-sorbet-density-calculator/bibliography.astro +6 -0
  11. package/src/tool/brix-sorbet-density-calculator/bibliography.ts +10 -0
  12. package/src/tool/brix-sorbet-density-calculator/brix-sorbet-density-calculator.css +878 -0
  13. package/src/tool/brix-sorbet-density-calculator/component.astro +220 -0
  14. package/src/tool/brix-sorbet-density-calculator/entry.ts +26 -0
  15. package/src/tool/brix-sorbet-density-calculator/helpers.ts +102 -0
  16. package/src/tool/brix-sorbet-density-calculator/i18n/de.ts +295 -0
  17. package/src/tool/brix-sorbet-density-calculator/i18n/en.ts +295 -0
  18. package/src/tool/brix-sorbet-density-calculator/i18n/es.ts +295 -0
  19. package/src/tool/brix-sorbet-density-calculator/i18n/fr.ts +295 -0
  20. package/src/tool/brix-sorbet-density-calculator/i18n/id.ts +295 -0
  21. package/src/tool/brix-sorbet-density-calculator/i18n/it.ts +295 -0
  22. package/src/tool/brix-sorbet-density-calculator/i18n/ja.ts +295 -0
  23. package/src/tool/brix-sorbet-density-calculator/i18n/ko.ts +295 -0
  24. package/src/tool/brix-sorbet-density-calculator/i18n/nl.ts +295 -0
  25. package/src/tool/brix-sorbet-density-calculator/i18n/pl.ts +295 -0
  26. package/src/tool/brix-sorbet-density-calculator/i18n/pt.ts +295 -0
  27. package/src/tool/brix-sorbet-density-calculator/i18n/ru.ts +295 -0
  28. package/src/tool/brix-sorbet-density-calculator/i18n/sv.ts +295 -0
  29. package/src/tool/brix-sorbet-density-calculator/i18n/tr.ts +295 -0
  30. package/src/tool/brix-sorbet-density-calculator/i18n/zh.ts +295 -0
  31. package/src/tool/brix-sorbet-density-calculator/index.ts +11 -0
  32. package/src/tool/brix-sorbet-density-calculator/logic.ts +180 -0
  33. package/src/tool/brix-sorbet-density-calculator/script.ts +114 -0
  34. package/src/tool/brix-sorbet-density-calculator/seo.astro +15 -0
  35. package/src/tool/leavener-acid-neutralizer/bibliography.astro +6 -0
  36. package/src/tool/leavener-acid-neutralizer/bibliography.ts +10 -0
  37. package/src/tool/leavener-acid-neutralizer/component.astro +335 -0
  38. package/src/tool/leavener-acid-neutralizer/entry.ts +26 -0
  39. package/src/tool/leavener-acid-neutralizer/i18n/de.ts +279 -0
  40. package/src/tool/leavener-acid-neutralizer/i18n/en.ts +279 -0
  41. package/src/tool/leavener-acid-neutralizer/i18n/es.ts +275 -0
  42. package/src/tool/leavener-acid-neutralizer/i18n/fr.ts +279 -0
  43. package/src/tool/leavener-acid-neutralizer/i18n/id.ts +279 -0
  44. package/src/tool/leavener-acid-neutralizer/i18n/it.ts +275 -0
  45. package/src/tool/leavener-acid-neutralizer/i18n/ja.ts +279 -0
  46. package/src/tool/leavener-acid-neutralizer/i18n/ko.ts +279 -0
  47. package/src/tool/leavener-acid-neutralizer/i18n/nl.ts +279 -0
  48. package/src/tool/leavener-acid-neutralizer/i18n/pl.ts +279 -0
  49. package/src/tool/leavener-acid-neutralizer/i18n/pt.ts +279 -0
  50. package/src/tool/leavener-acid-neutralizer/i18n/ru.ts +279 -0
  51. package/src/tool/leavener-acid-neutralizer/i18n/sv.ts +279 -0
  52. package/src/tool/leavener-acid-neutralizer/i18n/tr.ts +279 -0
  53. package/src/tool/leavener-acid-neutralizer/i18n/zh.ts +279 -0
  54. package/src/tool/leavener-acid-neutralizer/index.ts +10 -0
  55. package/src/tool/leavener-acid-neutralizer/leavener-acid-neutralizer.css +424 -0
  56. package/src/tool/leavener-acid-neutralizer/logic.ts +57 -0
  57. package/src/tool/leavener-acid-neutralizer/seo.astro +15 -0
  58. package/src/tool/oil-smoke-point-tracker/bibliography.astro +6 -0
  59. package/src/tool/oil-smoke-point-tracker/bibliography.ts +10 -0
  60. package/src/tool/oil-smoke-point-tracker/component.astro +445 -0
  61. package/src/tool/oil-smoke-point-tracker/entry.ts +26 -0
  62. package/src/tool/oil-smoke-point-tracker/i18n/de.ts +285 -0
  63. package/src/tool/oil-smoke-point-tracker/i18n/en.ts +285 -0
  64. package/src/tool/oil-smoke-point-tracker/i18n/es.ts +285 -0
  65. package/src/tool/oil-smoke-point-tracker/i18n/fr.ts +285 -0
  66. package/src/tool/oil-smoke-point-tracker/i18n/id.ts +244 -0
  67. package/src/tool/oil-smoke-point-tracker/i18n/it.ts +244 -0
  68. package/src/tool/oil-smoke-point-tracker/i18n/ja.ts +244 -0
  69. package/src/tool/oil-smoke-point-tracker/i18n/ko.ts +244 -0
  70. package/src/tool/oil-smoke-point-tracker/i18n/nl.ts +244 -0
  71. package/src/tool/oil-smoke-point-tracker/i18n/pl.ts +244 -0
  72. package/src/tool/oil-smoke-point-tracker/i18n/pt.ts +244 -0
  73. package/src/tool/oil-smoke-point-tracker/i18n/ru.ts +244 -0
  74. package/src/tool/oil-smoke-point-tracker/i18n/sv.ts +244 -0
  75. package/src/tool/oil-smoke-point-tracker/i18n/tr.ts +244 -0
  76. package/src/tool/oil-smoke-point-tracker/i18n/zh.ts +244 -0
  77. package/src/tool/oil-smoke-point-tracker/index.ts +11 -0
  78. package/src/tool/oil-smoke-point-tracker/logic.ts +70 -0
  79. package/src/tool/oil-smoke-point-tracker/oil-smoke-point-tracker.css +937 -0
  80. package/src/tool/oil-smoke-point-tracker/seo.astro +15 -0
  81. package/src/tools.ts +6 -0
@@ -0,0 +1,57 @@
1
+ export interface AcidIngredient {
2
+ type: string;
3
+ weight: number;
4
+ }
5
+
6
+ export interface NeutralizerInput {
7
+ acidIngredients: AcidIngredient[];
8
+ flour: number;
9
+ }
10
+
11
+ export interface NeutralizerResult {
12
+ neutralizedBakingSoda: number;
13
+ requiredBakingPowder: number;
14
+ providedBakingPowderEquivalent: number;
15
+ boosterBakingPowder: number;
16
+ bakingSodaTeaspoons: number;
17
+ boosterBakingPowderTeaspoons: number;
18
+ }
19
+
20
+ export const ACID_RATIOS: Record<string, number> = {
21
+ buttermilk: 1.5 / 120,
22
+ yogurt: 1.5 / 120,
23
+ sour_cream: 1.5 / 120,
24
+ honey: 3 / 340,
25
+ molasses: 3 / 328,
26
+ cocoa: 3 / 80,
27
+ lemon_juice: 1.5 / 15,
28
+ vinegar: 1.5 / 15,
29
+ };
30
+
31
+ export class LeavenerLogic {
32
+ static calculate(input: NeutralizerInput): NeutralizerResult {
33
+ let neutralizedBakingSoda = 0;
34
+ for (const ingredient of input.acidIngredients) {
35
+ const ratio = ACID_RATIOS[ingredient.type] || 0;
36
+ neutralizedBakingSoda += ingredient.weight * ratio;
37
+ }
38
+
39
+ neutralizedBakingSoda = parseFloat(neutralizedBakingSoda.toFixed(2));
40
+
41
+ const requiredBakingPowder = parseFloat((input.flour * 0.04).toFixed(2));
42
+ const providedBakingPowderEquivalent = parseFloat((neutralizedBakingSoda * 4).toFixed(2));
43
+ const boosterBakingPowder = parseFloat(Math.max(0, requiredBakingPowder - providedBakingPowderEquivalent).toFixed(2));
44
+
45
+ const bakingSodaTeaspoons = parseFloat((neutralizedBakingSoda / 6).toFixed(2));
46
+ const boosterBakingPowderTeaspoons = parseFloat((boosterBakingPowder / 4.8).toFixed(2));
47
+
48
+ return {
49
+ neutralizedBakingSoda,
50
+ requiredBakingPowder,
51
+ providedBakingPowderEquivalent,
52
+ boosterBakingPowder,
53
+ bakingSodaTeaspoons,
54
+ boosterBakingPowderTeaspoons,
55
+ };
56
+ }
57
+ }
@@ -0,0 +1,15 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { leavenerAcidNeutralizer } from './entry';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'es' } = Astro.props;
11
+ const content = await leavenerAcidNeutralizer.i18n[locale]?.();
12
+ if (!content) return null;
13
+ ---
14
+
15
+ {content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
@@ -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: 'Chemical Changes in Food During Processing',
4
+ url: 'https://www.semanticscholar.org/paper/Chemical-Changes-in-Food-during-Processing-Richardson-Finley/db911ee24c10e6f490c86b869adcb0232f035c0c',
5
+ },
6
+ {
7
+ name: 'Quality assessment and degradative changes of deep-fried oils in street fried food chain',
8
+ url: 'https://www.sciencedirect.com/science/article/abs/pii/S0956713522003772',
9
+ }
10
+ ];
@@ -0,0 +1,445 @@
1
+ ---
2
+ import { OIL_PRESETS } from './logic';
3
+
4
+ interface Props {
5
+ ui: Record<string, string>;
6
+ }
7
+
8
+ const { ui } = Astro.props;
9
+ ---
10
+
11
+ <div class="ospt">
12
+ <div class="ospt-card status-good">
13
+ <div class="ospt-unit-row">
14
+ <button type="button" id="ospt-unit-metric" class="ospt-unit-btn active">Celsius</button>
15
+ <button type="button" id="ospt-unit-imperial" class="ospt-unit-btn">Fahrenheit</button>
16
+ </div>
17
+ <div class="ospt-body">
18
+ <div class="ospt-controls">
19
+ <div class="ospt-input-group">
20
+ <label class="ospt-label">{ui.oilPresetLabel}</label>
21
+ <div class="ospt-oil-list" id="ospt-presets">
22
+ {OIL_PRESETS.map((oil, idx) => (
23
+ <button type="button" class={`ospt-oil-item ${idx === 1 ? 'active' : ''}`} data-id={oil.id}>
24
+ <span class={`ospt-oil-color color-${oil.id}`}></span>
25
+ <span>{ui[oil.nameKey] || oil.id}</span>
26
+ </button>
27
+ ))}
28
+ </div>
29
+ </div>
30
+
31
+ <div class="ospt-card-group">
32
+ <div class="ospt-input-group">
33
+ <label class="ospt-label" for="ospt-uses">{ui.usesLabel}</label>
34
+ <div class="ospt-slider-wrap">
35
+ <button type="button" class="ospt-adjuster-btn" data-dir="down" data-target="ospt-uses">-</button>
36
+ <input type="range" id="ospt-uses" class="ospt-slider" min="0" max="15" step="1" value="0" />
37
+ <button type="button" class="ospt-adjuster-btn" data-dir="up" data-target="ospt-uses">+</button>
38
+ <span class="ospt-value-display"><span id="ospt-uses-val">0</span></span>
39
+ </div>
40
+ </div>
41
+ </div>
42
+
43
+ <div class="ospt-card-group">
44
+ <div class="ospt-input-group">
45
+ <label class="ospt-label" for="ospt-temp">{ui.tempLabel}</label>
46
+ <div class="ospt-slider-wrap">
47
+ <button type="button" class="ospt-adjuster-btn" data-dir="down" data-target="ospt-temp">-</button>
48
+ <input type="range" id="ospt-temp" class="ospt-slider" min="100" max="230" step="5" value="175" />
49
+ <button type="button" class="ospt-adjuster-btn" data-dir="up" data-target="ospt-temp">+</button>
50
+ <span class="ospt-value-display"><span id="ospt-temp-val">175</span><span class="ospt-unit ospt-temp-unit">°C</span></span>
51
+ </div>
52
+ </div>
53
+ </div>
54
+
55
+ <div class="ospt-card-group">
56
+ <div class="ospt-input-group">
57
+ <label class="ospt-label">{ui.foodTypeLabel}</label>
58
+ <div class="ospt-radio-group">
59
+ <button type="button" class="ospt-radio-btn active" data-type="starch">
60
+ <span class="ospt-radio-dot"></span>
61
+ <span class="ospt-food-icon">
62
+ <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M5 6v14a2 2 0 002 2h10a2 2 0 002-2V6M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
63
+ </span>
64
+ <span>{ui.optionStarch}</span>
65
+ </button>
66
+ <button type="button" class="ospt-radio-btn" data-type="breaded">
67
+ <span class="ospt-radio-dot"></span>
68
+ <span class="ospt-food-icon">
69
+ <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10zM9 13.5a1.5 1.5 0 110-3 1.5 1.5 0 010 3zm6 0a1.5 1.5 0 110-3 1.5 1.5 0 010 3z"/></svg>
70
+ </span>
71
+ <span>{ui.optionBreading}</span>
72
+ </button>
73
+ </div>
74
+ </div>
75
+ </div>
76
+ </div>
77
+
78
+ <div class="ospt-result-panel">
79
+ <div class="ospt-visuals-container">
80
+ <div class="ospt-thermometer-box">
81
+ <div class="ospt-smoke-emitter" id="ospt-smoke-container">
82
+ <span class="ospt-smoke-particle"></span>
83
+ <span class="ospt-smoke-particle"></span>
84
+ <span class="ospt-smoke-particle"></span>
85
+ <span class="ospt-smoke-particle"></span>
86
+ </div>
87
+ <div class="ospt-thermo-wrapper">
88
+ <div class="ospt-thermo-scale">
89
+ <div class="ospt-scale-tick" style="bottom: 92.3%;"><span class="ospt-tick-val" data-c="220">220°C</span></div>
90
+ <div class="ospt-scale-tick" style="bottom: 69.2%;"><span class="ospt-tick-val" data-c="190">190°C</span></div>
91
+ <div class="ospt-scale-tick" style="bottom: 46.1%;"><span class="ospt-tick-val" data-c="160">160°C</span></div>
92
+ <div class="ospt-scale-tick" style="bottom: 23.0%;"><span class="ospt-tick-val" data-c="130">130°C</span></div>
93
+ <div class="ospt-scale-tick" style="bottom: 0%;"><span class="ospt-tick-val" data-c="100">100°C</span></div>
94
+ </div>
95
+ <div class="ospt-thermo-graphic">
96
+ <div class="ospt-thermo-fluid" id="ospt-thermo-fluid"></div>
97
+ <div class="ospt-thermo-bulb"></div>
98
+ <div class="ospt-thermo-smoke-point-marker" id="ospt-smoke-marker">
99
+ <span class="ospt-thermo-smoke-point-label" id="ospt-smoke-label">232°C</span>
100
+ </div>
101
+ </div>
102
+ </div>
103
+ </div>
104
+
105
+ <div class="ospt-health-box">
106
+ <div class="ospt-gauge-title">{ui.polymerizationLabel}</div>
107
+ <div class="ospt-radial-gauge">
108
+ <svg viewBox="0 0 100 55" class="ospt-radial-svg">
109
+ <path d="M 10 50 A 40 40 0 0 1 90 50" fill="none" stroke-width="8" stroke-linecap="round" class="ospt-radial-bg"/>
110
+ <path d="M 10 50 A 40 40 0 0 1 90 50" fill="none" stroke="var(--ospt-green)" stroke-width="8" stroke-dasharray="53.8 197.52" stroke-dashoffset="0" stroke-linecap="round"/>
111
+ <path d="M 10 50 A 40 40 0 0 1 90 50" fill="none" stroke="var(--ospt-gold)" stroke-width="8" stroke-dasharray="36.0 215.32" stroke-dashoffset="-53.8"/>
112
+ <path d="M 10 50 A 40 40 0 0 1 90 50" fill="none" stroke="var(--ospt-red)" stroke-width="8" stroke-dasharray="36.0 215.32" stroke-dashoffset="-89.8" stroke-linecap="round"/>
113
+ <g id="ospt-radial-needle-group" class="ospt-radial-needle-group" transform="translate(50, 50) rotate(-90)">
114
+ <line x1="0" y1="0" x2="0" y2="-42" stroke="var(--ospt-ink)" stroke-width="1.5" stroke-linecap="round"/>
115
+ <circle cx="0" cy="0" r="4" fill="var(--ospt-ink)"/>
116
+ <circle cx="0" cy="0" r="1.5" fill="#ffffff"/>
117
+ </g>
118
+ </svg>
119
+ <div class="ospt-radial-value-container">
120
+ <span class="ospt-radial-val" id="ospt-health-val">100%</span>
121
+ <span class="ospt-radial-desc" id="ospt-health-desc">Safe</span>
122
+ </div>
123
+ </div>
124
+ </div>
125
+ </div>
126
+
127
+ <div class="ospt-grid-values">
128
+ <div class="ospt-stat-box">
129
+ <span class="ospt-stat-label">{ui.baseSmokePointLabel}</span>
130
+ <span class="ospt-stat-value" id="ospt-base-smoke-point">232°C</span>
131
+ </div>
132
+
133
+ <div class="ospt-stat-box">
134
+ <span class="ospt-stat-label">{ui.currentSmokePointLabel}</span>
135
+ <span class="ospt-stat-value" id="ospt-current-smoke-point">232°C</span>
136
+ </div>
137
+ </div>
138
+
139
+ <div class="ospt-tpc-curve-container">
140
+ <div class="ospt-tpc-title">{ui.polarCompoundsLabel}</div>
141
+ <div class="ospt-tpc-bar-wrap">
142
+ <div class="ospt-tpc-limit-line"></div>
143
+ <div class="ospt-tpc-current-marker" id="ospt-tpc-marker"></div>
144
+ </div>
145
+ <div class="ospt-tpc-labels">
146
+ <span style="left: 5.7%;">2%</span>
147
+ <span style="left: 42.8%;">15%</span>
148
+ <span class="limit" style="left: 71.4%;">25% (Limit)</span>
149
+ <span style="left: 100%;">35%</span>
150
+ </div>
151
+ </div>
152
+
153
+ <div class="ospt-status-card good" id="ospt-status-card">
154
+ <div class="ospt-status-header">
155
+ <span class="ospt-stat-label">{ui.statusLabel}</span>
156
+ <span class="ospt-badge good" id="ospt-status-badge">SAFE TO REUSE</span>
157
+ </div>
158
+ <div class="ospt-advice" id="ospt-advice-text">
159
+ {ui.adviceGood}
160
+ </div>
161
+ </div>
162
+ </div>
163
+ </div>
164
+ </div>
165
+ </div>
166
+
167
+ <script is:inline define:vars={{ ui }}>
168
+ const getEl = (id) => document.getElementById(id);
169
+
170
+ const presets = document.querySelectorAll('.ospt-oil-item');
171
+ const usesInput = getEl('ospt-uses');
172
+ const tempInput = getEl('ospt-temp');
173
+ const radioButtons = document.querySelectorAll('.ospt-radio-btn');
174
+
175
+ const usesVal = getEl('ospt-uses-val');
176
+ const tempVal = getEl('ospt-temp-val');
177
+
178
+ const unitMetricBtn = getEl('ospt-unit-metric');
179
+ const unitImperialBtn = getEl('ospt-unit-imperial');
180
+
181
+ const thermoFluid = getEl('ospt-thermo-fluid');
182
+ const smokeMarker = getEl('ospt-smoke-marker');
183
+ const smokeLabel = getEl('ospt-smoke-label');
184
+ const smokeContainer = getEl('ospt-smoke-container');
185
+
186
+ const thermoDanger = getEl('ospt-thermo-danger');
187
+ const needleGroup = getEl('ospt-radial-needle-group');
188
+ const tpcMarker = getEl('ospt-tpc-marker');
189
+ const healthVal = getEl('ospt-health-val');
190
+ const healthDesc = getEl('ospt-health-desc');
191
+
192
+ const statusCard = getEl('ospt-status-card');
193
+ const statusBadge = getEl('ospt-status-badge');
194
+ const baseSmokePointDisp = getEl('ospt-base-smoke-point');
195
+ const currentSmokePointDisp = getEl('ospt-current-smoke-point');
196
+ const adviceTextDisp = getEl('ospt-advice-text');
197
+
198
+ let activeOilId = 'sunflower-refined';
199
+ let activeFoodType = 'starch';
200
+ let activeUnit = 'metric';
201
+
202
+ const presetsData = {
203
+ 'avocado-refined': { baseC: 270, refined: true },
204
+ 'sunflower-refined': { baseC: 232, refined: true },
205
+ 'peanut-refined': { baseC: 232, refined: true },
206
+ 'canola-refined': { baseC: 204, refined: true },
207
+ 'olive-extra-virgin': { baseC: 190, refined: false },
208
+ 'olive-pomace': { baseC: 238, refined: true },
209
+ 'coconut-unrefined': { baseC: 177, refined: false },
210
+ 'sunflower-unrefined': { baseC: 107, refined: false }
211
+ };
212
+
213
+ function cToF(c) {
214
+ return Math.round(c * 1.8 + 32);
215
+ }
216
+
217
+ function formatTemp(c) {
218
+ const r = Math.round(c);
219
+ if (activeUnit === 'imperial') {
220
+ return cToF(r) + '°F';
221
+ }
222
+ return r + '°C';
223
+ }
224
+
225
+ function getTempC() {
226
+ const val = parseInt(tempInput.value) || 175;
227
+ return activeUnit === 'imperial' ? Math.round((val - 32) / 1.8) : val;
228
+ }
229
+
230
+ function updateDisplayValues() {
231
+ usesVal.textContent = usesInput.value;
232
+ tempVal.textContent = tempInput.value;
233
+
234
+ const unitLabels = document.querySelectorAll('.ospt-temp-unit');
235
+ unitLabels.forEach((el) => {
236
+ el.textContent = activeUnit === 'imperial' ? '°F' : '°C';
237
+ });
238
+
239
+ const ticks = document.querySelectorAll('.ospt-tick-val');
240
+ ticks.forEach((tick) => {
241
+ const c = parseInt(tick.getAttribute('data-c'));
242
+ tick.textContent = activeUnit === 'imperial' ? cToF(c) + '°F' : c + '°C';
243
+ });
244
+ }
245
+
246
+ function getStatus(polarCompounds, currentC) {
247
+ if (polarCompounds >= 25 || currentC <= 170) {
248
+ return 'discard';
249
+ }
250
+ if (polarCompounds >= 15 || currentC <= 190) {
251
+ return 'caution';
252
+ }
253
+ return 'good';
254
+ }
255
+
256
+ function updateUI(data) {
257
+ const { baseC, currentC, polarCompounds, healthPercent, status, uses, tempC } = data;
258
+ if (statusCard) {
259
+ statusCard.className = 'ospt-status-card ' + status;
260
+ }
261
+ statusBadge.textContent = ui['status' + status.charAt(0).toUpperCase() + status.slice(1)];
262
+ statusBadge.className = 'ospt-badge ' + status;
263
+
264
+ baseSmokePointDisp.textContent = formatTemp(baseC);
265
+ currentSmokePointDisp.textContent = formatTemp(currentC);
266
+ adviceTextDisp.textContent = ui['advice' + status.charAt(0).toUpperCase() + status.slice(1)];
267
+
268
+ healthVal.textContent = healthPercent + '%';
269
+ const descMap = { good: 'Safe', caution: 'Caution', discard: 'Discard' };
270
+ healthDesc.textContent = descMap[status];
271
+
272
+ if (needleGroup) {
273
+ const angle = 90 - (healthPercent / 100) * 180;
274
+ needleGroup.setAttribute('transform', `translate(50, 50) rotate(${angle})`);
275
+ }
276
+ updateGaugeAndSliders(currentC, polarCompounds, uses, tempC);
277
+ }
278
+
279
+ function updateGaugeAndSliders(currentC, polarCompounds, uses, tempC) {
280
+ const fluidPct = Math.max(0, Math.min(100, (tempC - 100) / 130 * 100));
281
+ thermoFluid.style.height = fluidPct + '%';
282
+
283
+ const markerPct = Math.max(0, Math.min(100, (currentC - 100) / 130 * 100));
284
+ smokeMarker.style.bottom = markerPct + '%';
285
+ smokeLabel.textContent = formatTemp(currentC);
286
+
287
+ if (thermoDanger) {
288
+ thermoDanger.style.bottom = markerPct + '%';
289
+ }
290
+
291
+ if (tpcMarker) {
292
+ const tpcPct = Math.max(0, Math.min(100, (polarCompounds / 35) * 100));
293
+ tpcMarker.style.left = tpcPct + '%';
294
+ }
295
+
296
+ usesInput.style.setProperty('--uses-pct', (uses / 15 * 100) + '%');
297
+
298
+ const minC = 100;
299
+ const maxC = 230;
300
+ const rangeC = maxC - minC;
301
+ const safeTemp = currentC - 30;
302
+ const safePct = Math.max(0, Math.min(100, (safeTemp - minC) / rangeC * 100));
303
+ const dangerPct = Math.max(0, Math.min(100, (currentC - minC) / rangeC * 100));
304
+
305
+ tempInput.style.setProperty('--temp-safe-pct', safePct + '%');
306
+ tempInput.style.setProperty('--temp-danger-pct', dangerPct + '%');
307
+
308
+ if (tempC >= currentC) {
309
+ smokeContainer.classList.add('smoking');
310
+ } else {
311
+ smokeContainer.classList.remove('smoking');
312
+ }
313
+ }
314
+
315
+ function calculate() {
316
+ const preset = presetsData[activeOilId] || presetsData['sunflower-refined'];
317
+ const baseC = preset.baseC;
318
+ const uses = parseInt(usesInput.value) || 0;
319
+ const tempC = getTempC();
320
+
321
+ const baseDegradation = preset.refined ? 4 : 9;
322
+ const tempFactor = 1 + Math.max(0, tempC - 180) * 0.06;
323
+ const foodFactor = activeFoodType === 'breaded' ? 2.2 : 1.0;
324
+
325
+ const degradationPerUse = baseDegradation * tempFactor * foodFactor;
326
+ const currentC = Math.max(60, baseC - uses * degradationPerUse);
327
+
328
+ const basePolarIncrease = preset.refined ? 1.5 : 3.0;
329
+ const polarCompounds = Math.min(100, 2 + uses * basePolarIncrease * tempFactor * foodFactor);
330
+ const healthPercent = Math.max(0, Math.round(100 - (polarCompounds / 35) * 100));
331
+
332
+ const status = getStatus(polarCompounds, currentC);
333
+
334
+ updateUI({ baseC, currentC, polarCompounds, healthPercent, status, uses, tempC });
335
+ }
336
+
337
+ const STORAGE_KEY = 'ospt-v1';
338
+
339
+ function save() {
340
+ try {
341
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({
342
+ oil: activeOilId,
343
+ uses: usesInput.value,
344
+ temp: getTempC(),
345
+ food: activeFoodType,
346
+ unit: activeUnit
347
+ }));
348
+ } catch {}
349
+ }
350
+
351
+ function restoreSettings(v) {
352
+ if (v.oil) {
353
+ activeOilId = v.oil;
354
+ presets.forEach((btn) => {
355
+ btn.classList.toggle('active', btn.getAttribute('data-id') === activeOilId);
356
+ });
357
+ }
358
+ if (v.uses) usesInput.value = v.uses;
359
+ if (v.food) {
360
+ activeFoodType = v.food;
361
+ radioButtons.forEach((btn) => {
362
+ btn.classList.toggle('active', btn.getAttribute('data-type') === activeFoodType);
363
+ });
364
+ }
365
+ if (v.unit) activeUnit = v.unit;
366
+ if (v.temp) {
367
+ tempInput.value = activeUnit === 'imperial' ? cToF(parseInt(v.temp)) : parseInt(v.temp);
368
+ }
369
+ }
370
+
371
+ function load() {
372
+ try {
373
+ const d = localStorage.getItem(STORAGE_KEY);
374
+ if (!d) return;
375
+ const v = JSON.parse(d);
376
+ restoreSettings(v);
377
+ } catch {}
378
+ }
379
+
380
+ function refresh() {
381
+ updateDisplayValues();
382
+ calculate();
383
+ save();
384
+ }
385
+
386
+ function toggleUnit(unit) {
387
+ const tempC = getTempC();
388
+ activeUnit = unit;
389
+ unitMetricBtn.classList.toggle('active', unit === 'metric');
390
+ unitImperialBtn.classList.toggle('active', unit === 'imperial');
391
+ if (unit === 'imperial') {
392
+ tempInput.min = 212;
393
+ tempInput.max = 446;
394
+ tempInput.step = 10;
395
+ tempInput.value = cToF(tempC);
396
+ } else {
397
+ tempInput.min = 100;
398
+ tempInput.max = 230;
399
+ tempInput.step = 5;
400
+ tempInput.value = tempC;
401
+ }
402
+ refresh();
403
+ }
404
+
405
+ unitMetricBtn.addEventListener('click', () => toggleUnit('metric'));
406
+ unitImperialBtn.addEventListener('click', () => toggleUnit('imperial'));
407
+
408
+ presets.forEach((btn) => {
409
+ btn.addEventListener('click', () => {
410
+ presets.forEach((p) => p.classList.remove('active'));
411
+ btn.classList.add('active');
412
+ activeOilId = btn.getAttribute('data-id');
413
+ refresh();
414
+ });
415
+ });
416
+
417
+ radioButtons.forEach((btn) => {
418
+ btn.addEventListener('click', () => {
419
+ radioButtons.forEach((r) => r.classList.remove('active'));
420
+ btn.classList.add('active');
421
+ activeFoodType = btn.getAttribute('data-type');
422
+ refresh();
423
+ });
424
+ });
425
+
426
+ document.querySelectorAll('.ospt-adjuster-btn').forEach((btn) => {
427
+ btn.addEventListener('click', () => {
428
+ const targetId = btn.getAttribute('data-target');
429
+ const dir = btn.getAttribute('data-dir');
430
+ const slider = getEl(targetId);
431
+ if (!slider) return;
432
+ const step = parseFloat(slider.getAttribute('step')) || 1;
433
+ const current = parseFloat(slider.value) || 0;
434
+ const next = dir === 'up' ? current + step : current - step;
435
+ slider.value = Math.max(parseFloat(slider.min), Math.min(parseFloat(slider.max), next));
436
+ refresh();
437
+ });
438
+ });
439
+
440
+ usesInput.addEventListener('input', refresh);
441
+ tempInput.addEventListener('input', refresh);
442
+
443
+ load();
444
+ toggleUnit(activeUnit);
445
+ </script>
@@ -0,0 +1,26 @@
1
+ import type { CookingToolEntry } from '../../types';
2
+
3
+ export const oilSmokePoint: CookingToolEntry = {
4
+ id: 'oil-smoke-point-tracker',
5
+ icons: {
6
+ bg: 'mdi:oil',
7
+ fg: 'mdi:fire-alert',
8
+ },
9
+ i18n: {
10
+ es: () => import('./i18n/es').then((m) => m.content),
11
+ en: () => import('./i18n/en').then((m) => m.content),
12
+ fr: () => import('./i18n/fr').then((m) => m.content),
13
+ de: () => import('./i18n/de').then((m) => m.content),
14
+ it: () => import('./i18n/it').then((m) => m.content),
15
+ pt: () => import('./i18n/pt').then((m) => m.content),
16
+ nl: () => import('./i18n/nl').then((m) => m.content),
17
+ sv: () => import('./i18n/sv').then((m) => m.content),
18
+ ru: () => import('./i18n/ru').then((m) => m.content),
19
+ tr: () => import('./i18n/tr').then((m) => m.content),
20
+ pl: () => import('./i18n/pl').then((m) => m.content),
21
+ id: () => import('./i18n/id').then((m) => m.content),
22
+ ja: () => import('./i18n/ja').then((m) => m.content),
23
+ zh: () => import('./i18n/zh').then((m) => m.content),
24
+ ko: () => import('./i18n/ko').then((m) => m.content),
25
+ },
26
+ };