@jjlmoya/utils-cooking 1.36.0 → 1.37.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 (57) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +4 -0
  3. package/src/entries.ts +5 -1
  4. package/src/index.ts +2 -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/locale_completeness.test.ts +2 -2
  8. package/src/tests/tool_validation.test.ts +2 -2
  9. package/src/tool/brix-sorbet-density-calculator/bibliography.astro +6 -0
  10. package/src/tool/brix-sorbet-density-calculator/bibliography.ts +10 -0
  11. package/src/tool/brix-sorbet-density-calculator/brix-sorbet-density-calculator.css +878 -0
  12. package/src/tool/brix-sorbet-density-calculator/component.astro +220 -0
  13. package/src/tool/brix-sorbet-density-calculator/entry.ts +26 -0
  14. package/src/tool/brix-sorbet-density-calculator/helpers.ts +102 -0
  15. package/src/tool/brix-sorbet-density-calculator/i18n/de.ts +295 -0
  16. package/src/tool/brix-sorbet-density-calculator/i18n/en.ts +295 -0
  17. package/src/tool/brix-sorbet-density-calculator/i18n/es.ts +295 -0
  18. package/src/tool/brix-sorbet-density-calculator/i18n/fr.ts +295 -0
  19. package/src/tool/brix-sorbet-density-calculator/i18n/id.ts +295 -0
  20. package/src/tool/brix-sorbet-density-calculator/i18n/it.ts +295 -0
  21. package/src/tool/brix-sorbet-density-calculator/i18n/ja.ts +295 -0
  22. package/src/tool/brix-sorbet-density-calculator/i18n/ko.ts +295 -0
  23. package/src/tool/brix-sorbet-density-calculator/i18n/nl.ts +295 -0
  24. package/src/tool/brix-sorbet-density-calculator/i18n/pl.ts +295 -0
  25. package/src/tool/brix-sorbet-density-calculator/i18n/pt.ts +295 -0
  26. package/src/tool/brix-sorbet-density-calculator/i18n/ru.ts +295 -0
  27. package/src/tool/brix-sorbet-density-calculator/i18n/sv.ts +295 -0
  28. package/src/tool/brix-sorbet-density-calculator/i18n/tr.ts +295 -0
  29. package/src/tool/brix-sorbet-density-calculator/i18n/zh.ts +295 -0
  30. package/src/tool/brix-sorbet-density-calculator/index.ts +11 -0
  31. package/src/tool/brix-sorbet-density-calculator/logic.ts +180 -0
  32. package/src/tool/brix-sorbet-density-calculator/script.ts +114 -0
  33. package/src/tool/brix-sorbet-density-calculator/seo.astro +15 -0
  34. package/src/tool/oil-smoke-point-tracker/bibliography.astro +6 -0
  35. package/src/tool/oil-smoke-point-tracker/bibliography.ts +10 -0
  36. package/src/tool/oil-smoke-point-tracker/component.astro +445 -0
  37. package/src/tool/oil-smoke-point-tracker/entry.ts +26 -0
  38. package/src/tool/oil-smoke-point-tracker/i18n/de.ts +285 -0
  39. package/src/tool/oil-smoke-point-tracker/i18n/en.ts +285 -0
  40. package/src/tool/oil-smoke-point-tracker/i18n/es.ts +285 -0
  41. package/src/tool/oil-smoke-point-tracker/i18n/fr.ts +285 -0
  42. package/src/tool/oil-smoke-point-tracker/i18n/id.ts +244 -0
  43. package/src/tool/oil-smoke-point-tracker/i18n/it.ts +244 -0
  44. package/src/tool/oil-smoke-point-tracker/i18n/ja.ts +244 -0
  45. package/src/tool/oil-smoke-point-tracker/i18n/ko.ts +244 -0
  46. package/src/tool/oil-smoke-point-tracker/i18n/nl.ts +244 -0
  47. package/src/tool/oil-smoke-point-tracker/i18n/pl.ts +244 -0
  48. package/src/tool/oil-smoke-point-tracker/i18n/pt.ts +244 -0
  49. package/src/tool/oil-smoke-point-tracker/i18n/ru.ts +244 -0
  50. package/src/tool/oil-smoke-point-tracker/i18n/sv.ts +244 -0
  51. package/src/tool/oil-smoke-point-tracker/i18n/tr.ts +244 -0
  52. package/src/tool/oil-smoke-point-tracker/i18n/zh.ts +244 -0
  53. package/src/tool/oil-smoke-point-tracker/index.ts +11 -0
  54. package/src/tool/oil-smoke-point-tracker/logic.ts +70 -0
  55. package/src/tool/oil-smoke-point-tracker/oil-smoke-point-tracker.css +937 -0
  56. package/src/tool/oil-smoke-point-tracker/seo.astro +15 -0
  57. package/src/tools.ts +4 -0
@@ -0,0 +1,180 @@
1
+ import type { BrixState } from './helpers';
2
+ import { getUnitLabel, formatWeight, formatWeightVal, cToF, getTempStatusDash } from './helpers';
3
+
4
+ export type { BrixState, SorbetInputs, SorbetResult } from './helpers';
5
+ export { SorbetLogic } from './helpers';
6
+
7
+ const STORAGE_KEY = 'bsdc-v1';
8
+
9
+ const LOAD_FIELDS = [
10
+ { key: 'fw', elId: 'bsdc-fruit-weight' },
11
+ { key: 'fb', elId: 'bsdc-fruit-brix' },
12
+ { key: 'sw', elId: 'bsdc-sugar-weight' },
13
+ { key: 'dw', elId: 'bsdc-dextrose-weight' },
14
+ { key: 'ww', elId: 'bsdc-water-weight' },
15
+ { key: 'tb', elId: 'bsdc-target-brix' },
16
+ ] as const;
17
+
18
+ function updateDisplayValues(state: BrixState): void {
19
+ const { elements: el, activeUnit } = state;
20
+ el.fruitWeightVal.textContent = String(formatWeightVal(parseFloat(el.fruitWeight.value) || 0, state));
21
+ el.fruitBrixVal.textContent = el.fruitBrix.value;
22
+ el.sugarWeightVal.textContent = String(formatWeightVal(parseFloat(el.sugarWeight.value) || 0, state));
23
+ el.dextroseWeightVal.textContent = String(formatWeightVal(parseFloat(el.dextroseWeight.value) || 0, state));
24
+ el.waterWeightVal.textContent = String(formatWeightVal(parseFloat(el.waterWeight.value) || 0, state));
25
+ el.targetBrixVal.textContent = el.targetBrix.value;
26
+
27
+ const labelText = getUnitLabel(state);
28
+ document.querySelectorAll('.bsdc-weight-unit').forEach((el2) => { el2.textContent = labelText; });
29
+
30
+ const labelsEl = document.getElementById('bsdc-temp-labels');
31
+ if (labelsEl) {
32
+ labelsEl.innerHTML = activeUnit === 'imperial'
33
+ ? '<span>-13°F</span><span>-4°F</span><span>5°F</span><span>14°F</span><span>23°F</span>'
34
+ : '<span>-25°C</span><span>-20°C</span><span>-15°C</span><span>-10°C</span><span>-5°C</span>';
35
+ }
36
+ }
37
+
38
+ export function updatePresetDropdownUI(state: BrixState): void {
39
+ const { selectOptions, selectedIcon, selectedName } = state.elements;
40
+ selectOptions.forEach((opt) => {
41
+ const isSelected = opt.getAttribute('data-value') === state.activePreset;
42
+ opt.classList.toggle('selected', isSelected);
43
+ if (!isSelected) return;
44
+ selectedIcon.innerHTML = (opt.querySelector('.bsdc-preset-icon') as HTMLElement).innerHTML;
45
+ const nameSpan = opt.querySelector('span:not(.bsdc-preset-icon)');
46
+ if (nameSpan) selectedName.textContent = nameSpan.textContent;
47
+ });
48
+ }
49
+
50
+ function updateBadge(brix: number, state: BrixState): void {
51
+ const card = document.querySelector('.bsdc-card');
52
+ if (card) card.classList.remove('status-hard', 'status-soft', 'status-optimal');
53
+ const { statusBadge } = state.elements;
54
+ const s = state.ui;
55
+ if (brix < 25) {
56
+ statusBadge.textContent = s.statusHard;
57
+ statusBadge.className = 'bsdc-badge hard';
58
+ if (card) card.classList.add('status-hard');
59
+ } else if (brix > 30) {
60
+ statusBadge.textContent = s.statusSoft;
61
+ statusBadge.className = 'bsdc-badge soft';
62
+ if (card) card.classList.add('status-soft');
63
+ } else {
64
+ statusBadge.textContent = s.statusOptimal;
65
+ statusBadge.className = 'bsdc-badge optimal';
66
+ if (card) card.classList.add('status-optimal');
67
+ }
68
+ }
69
+
70
+ function updateTempMarker(tMin: number, tMax: number): void {
71
+ const marker = document.getElementById('bsdc-temp-marker');
72
+ if (!marker) return;
73
+ marker.classList.remove('out-of-range-cold', 'out-of-range-warm');
74
+ if (tMin < -25) {
75
+ marker.style.left = '0%';
76
+ marker.style.width = '12%';
77
+ marker.classList.add('out-of-range-cold');
78
+ } else if (tMax > -5) {
79
+ marker.style.left = '88%';
80
+ marker.style.width = '12%';
81
+ marker.classList.add('out-of-range-warm');
82
+ } else {
83
+ const pMin = Math.max(0, Math.min(100, ((tMin + 25) / 20) * 100));
84
+ const pMax = Math.max(0, Math.min(100, ((tMax + 25) / 20) * 100));
85
+ marker.style.left = pMin.toFixed(1) + '%';
86
+ marker.style.width = Math.max(8, pMax - pMin).toFixed(1) + '%';
87
+ }
88
+ }
89
+
90
+ function updateTempGauge(totalPAC: number, state: BrixState): void {
91
+ const tIdeal = -(totalPAC / 18);
92
+ const tMin = tIdeal - 2.5;
93
+ const tMax = tIdeal + 2.5;
94
+ updateTempMarker(tMin, tMax);
95
+
96
+ const rangeVal = document.getElementById('bsdc-temp-range-val');
97
+ if (rangeVal) {
98
+ if (state.activeUnit === 'imperial') {
99
+ const fIdeal = cToF(tIdeal);
100
+ rangeVal.textContent = (fIdeal - 4.5).toFixed(0) + '°F to ' + (fIdeal + 4.5).toFixed(0) + '°F';
101
+ } else {
102
+ rangeVal.textContent = tMin.toFixed(0) + '°C to ' + tMax.toFixed(0) + '°C';
103
+ }
104
+ }
105
+
106
+ const tempStatus = document.getElementById('bsdc-temp-status');
107
+ if (tempStatus) {
108
+ const st = getTempStatusDash(tIdeal);
109
+ tempStatus.textContent = st.text;
110
+ tempStatus.className = st.className;
111
+ }
112
+ }
113
+
114
+ function calculate(state: BrixState): void {
115
+ const { elements: el } = state;
116
+ const fw = parseFloat(el.fruitWeight.value) || 0;
117
+ const fb = parseFloat(el.fruitBrix.value) || 0;
118
+ const sw = parseFloat(el.sugarWeight.value) || 0;
119
+ const dw = parseFloat(el.dextroseWeight.value) || 0;
120
+ const ww = parseFloat(el.waterWeight.value) || 0;
121
+ const tb = parseFloat(el.targetBrix.value) || 28;
122
+ const fruitSugar = fw * (fb / 100);
123
+ const totalWeight = fw + ww + sw + dw;
124
+ const brix = totalWeight > 0 ? ((fruitSugar + sw + dw) / totalWeight) * 100 : 0;
125
+ const totalPAC = fruitSugar * 1.9 + sw + dw * 1.9;
126
+ el.totalBrixDisp.textContent = brix.toFixed(1) + '%';
127
+ el.totalWeightDisp.textContent = formatWeight(totalWeight, state);
128
+ el.totalPacDisp.textContent = totalPAC.toFixed(1);
129
+ el.lens.style.transform = `translateY(-${Math.min(50, (brix / 50) * 50) * 0.5}%)`;
130
+ updateBadge(brix, state);
131
+ updateTempGauge(totalPAC, state);
132
+ const recSugar = Math.max(0, fw * (2 * (tb / 100) - fb / 100));
133
+ const recWater = Math.max(0, fw - recSugar);
134
+ el.recSugarDisp.textContent = formatWeight(recSugar, state);
135
+ el.recWaterDisp.textContent = formatWeight(recWater, state);
136
+ }
137
+
138
+ function save(state: BrixState): void {
139
+ try {
140
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({
141
+ fw: state.elements.fruitWeight.value,
142
+ fb: state.elements.fruitBrix.value,
143
+ sw: state.elements.sugarWeight.value,
144
+ dw: state.elements.dextroseWeight.value,
145
+ ww: state.elements.waterWeight.value,
146
+ tb: state.elements.targetBrix.value,
147
+ pr: state.activePreset,
148
+ un: state.activeUnit,
149
+ }));
150
+ } catch {}
151
+ }
152
+
153
+ export function load(state: BrixState): void {
154
+ try {
155
+ const raw = localStorage.getItem(STORAGE_KEY);
156
+ if (!raw) return;
157
+ const data = JSON.parse(raw) as Record<string, string>;
158
+ if (data.un) state.activeUnit = data.un;
159
+ if (data.pr) state.activePreset = data.pr;
160
+ LOAD_FIELDS.forEach(({ key, elId }) => {
161
+ if (data[key] !== undefined) {
162
+ const el = document.getElementById(elId) as HTMLInputElement;
163
+ if (el) el.value = data[key];
164
+ }
165
+ });
166
+ } catch {}
167
+ }
168
+
169
+ export function refresh(state: BrixState): void {
170
+ updateDisplayValues(state);
171
+ calculate(state);
172
+ save(state);
173
+ }
174
+
175
+ export function toggleUnit(unit: string, state: BrixState): void {
176
+ state.activeUnit = unit;
177
+ state.elements.unitMetricBtn.classList.toggle('active', unit === 'metric');
178
+ state.elements.unitImperialBtn.classList.toggle('active', unit === 'imperial');
179
+ refresh(state);
180
+ }
@@ -0,0 +1,114 @@
1
+ import type { BrixState } from './logic';
2
+ import { toggleUnit, load, updatePresetDropdownUI, refresh } from './logic';
3
+
4
+ function createElements() {
5
+ const g = (id: string) => document.getElementById(id);
6
+ return {
7
+ selectTrigger: g('bsdc-select-trigger')!,
8
+ selectDropdown: g('bsdc-select-dropdown')!,
9
+ selectOptions: document.querySelectorAll('.bsdc-select-option'),
10
+ selectedIcon: g('bsdc-selected-icon')!,
11
+ selectedName: g('bsdc-selected-name')!,
12
+ fruitWeight: g('bsdc-fruit-weight') as HTMLInputElement,
13
+ fruitBrix: g('bsdc-fruit-brix') as HTMLInputElement,
14
+ sugarWeight: g('bsdc-sugar-weight') as HTMLInputElement,
15
+ dextroseWeight: g('bsdc-dextrose-weight') as HTMLInputElement,
16
+ waterWeight: g('bsdc-water-weight') as HTMLInputElement,
17
+ targetBrix: g('bsdc-target-brix') as HTMLInputElement,
18
+ fruitWeightVal: g('bsdc-fruit-weight-val')!,
19
+ fruitBrixVal: g('bsdc-fruit-brix-val')!,
20
+ sugarWeightVal: g('bsdc-sugar-weight-val')!,
21
+ dextroseWeightVal: g('bsdc-dextrose-weight-val')!,
22
+ waterWeightVal: g('bsdc-water-weight-val')!,
23
+ targetBrixVal: g('bsdc-target-brix-val')!,
24
+ totalBrixDisp: g('bsdc-total-brix')!,
25
+ totalWeightDisp: g('bsdc-total-weight')!,
26
+ totalPacDisp: g('bsdc-total-pac')!,
27
+ statusBadge: g('bsdc-status-badge')!,
28
+ lens: g('bsdc-lens')!,
29
+ recSugarDisp: g('bsdc-rec-sugar')!,
30
+ recWaterDisp: g('bsdc-rec-water')!,
31
+ unitMetricBtn: g('bsdc-unit-metric')!, unitImperialBtn: g('bsdc-unit-imperial')!,
32
+ };
33
+ }
34
+
35
+ function bindSelectDropdown(el: BrixState['elements']): void {
36
+ el.selectTrigger.addEventListener('click', (e) => {
37
+ e.stopPropagation();
38
+ el.selectTrigger.classList.toggle('open');
39
+ el.selectDropdown.classList.toggle('open');
40
+ });
41
+ document.addEventListener('click', (e) => {
42
+ if (!el.selectTrigger.contains(e.target as Node) && !el.selectDropdown.contains(e.target as Node)) {
43
+ el.selectTrigger.classList.remove('open');
44
+ el.selectDropdown.classList.remove('open');
45
+ }
46
+ });
47
+ }
48
+
49
+ function bindSelectOptions(state: BrixState): void {
50
+ const { elements: el } = state;
51
+ el.selectOptions.forEach((opt) => {
52
+ opt.addEventListener('click', () => {
53
+ const val = opt.getAttribute('data-value')!;
54
+ state.activePreset = val;
55
+ if (val !== 'custom') el.fruitBrix.value = val;
56
+ el.selectTrigger.classList.remove('open');
57
+ el.selectDropdown.classList.remove('open');
58
+ updatePresetDropdownUI(state);
59
+ refresh(state);
60
+ });
61
+ });
62
+ }
63
+
64
+ function bindAdjusters(state: BrixState): void {
65
+ document.querySelectorAll('.bsdc-adjuster-btn').forEach((btn) => {
66
+ btn.addEventListener('click', () => {
67
+ const targetId = btn.getAttribute('data-target')!;
68
+ const slider = document.getElementById(targetId) as HTMLInputElement;
69
+ if (!slider) return;
70
+ const step = parseFloat(slider.getAttribute('step') || '1');
71
+ const cur = parseFloat(slider.value) || 0;
72
+ const dir = btn.getAttribute('data-dir');
73
+ slider.value = String(Math.max(parseFloat(slider.min), Math.min(parseFloat(slider.max), cur + (dir === 'up' ? step : -step))));
74
+ if (targetId === 'bsdc-fruit-brix') {
75
+ state.activePreset = 'custom';
76
+ updatePresetDropdownUI(state);
77
+ }
78
+ refresh(state);
79
+ });
80
+ });
81
+ }
82
+
83
+ function bindSliderInputs(state: BrixState): void {
84
+ const { elements: el } = state;
85
+ el.fruitWeight.addEventListener('input', () => refresh(state));
86
+ el.fruitBrix.addEventListener('input', () => {
87
+ state.activePreset = 'custom';
88
+ updatePresetDropdownUI(state);
89
+ refresh(state);
90
+ });
91
+ el.sugarWeight.addEventListener('input', () => refresh(state));
92
+ el.dextroseWeight.addEventListener('input', () => refresh(state));
93
+ el.waterWeight.addEventListener('input', () => refresh(state));
94
+ el.targetBrix.addEventListener('input', () => refresh(state));
95
+ }
96
+
97
+ function bindEvents(state: BrixState): void {
98
+ const { elements: el } = state;
99
+ el.unitMetricBtn.addEventListener('click', () => toggleUnit('metric', state));
100
+ el.unitImperialBtn.addEventListener('click', () => toggleUnit('imperial', state));
101
+ bindSelectDropdown(el);
102
+ bindSelectOptions(state);
103
+ bindAdjusters(state);
104
+ bindSliderInputs(state);
105
+ }
106
+
107
+ export function initBrixCalculator(ui: Record<string, string>): void {
108
+ const elements = createElements();
109
+ const state: BrixState = { activeUnit: 'metric', activePreset: '14', ui, elements };
110
+ bindEvents(state);
111
+ load(state);
112
+ updatePresetDropdownUI(state);
113
+ toggleUnit(state.activeUnit, state);
114
+ }
@@ -0,0 +1,15 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { brixSorbetDensity } from './entry';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'en' } = Astro.props;
11
+ const content = await brixSorbetDensity.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
+ ];