@jjlmoya/utils-cooking 1.38.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 (63) hide show
  1. package/package.json +1 -1
  2. package/scripts/fix-translations.js +51 -0
  3. package/src/category/index.ts +5 -0
  4. package/src/entries.ts +6 -1
  5. package/src/index.ts +3 -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/pectin-jam-setting-calculator/bibliography.astro +6 -0
  11. package/src/tool/pectin-jam-setting-calculator/bibliography.ts +10 -0
  12. package/src/tool/pectin-jam-setting-calculator/component.astro +170 -0
  13. package/src/tool/pectin-jam-setting-calculator/components/CalculatorInputs.astro +44 -0
  14. package/src/tool/pectin-jam-setting-calculator/components/DropTestVisualizer.astro +40 -0
  15. package/src/tool/pectin-jam-setting-calculator/components/FruitSelector.astro +38 -0
  16. package/src/tool/pectin-jam-setting-calculator/components/RecipeResults.astro +72 -0
  17. package/src/tool/pectin-jam-setting-calculator/entry.ts +26 -0
  18. package/src/tool/pectin-jam-setting-calculator/i18n/de.ts +248 -0
  19. package/src/tool/pectin-jam-setting-calculator/i18n/en.ts +248 -0
  20. package/src/tool/pectin-jam-setting-calculator/i18n/es.ts +248 -0
  21. package/src/tool/pectin-jam-setting-calculator/i18n/fr.ts +248 -0
  22. package/src/tool/pectin-jam-setting-calculator/i18n/id.ts +248 -0
  23. package/src/tool/pectin-jam-setting-calculator/i18n/it.ts +248 -0
  24. package/src/tool/pectin-jam-setting-calculator/i18n/ja.ts +248 -0
  25. package/src/tool/pectin-jam-setting-calculator/i18n/ko.ts +248 -0
  26. package/src/tool/pectin-jam-setting-calculator/i18n/nl.ts +248 -0
  27. package/src/tool/pectin-jam-setting-calculator/i18n/pl.ts +248 -0
  28. package/src/tool/pectin-jam-setting-calculator/i18n/pt.ts +248 -0
  29. package/src/tool/pectin-jam-setting-calculator/i18n/ru.ts +248 -0
  30. package/src/tool/pectin-jam-setting-calculator/i18n/sv.ts +248 -0
  31. package/src/tool/pectin-jam-setting-calculator/i18n/tr.ts +248 -0
  32. package/src/tool/pectin-jam-setting-calculator/i18n/zh.ts +248 -0
  33. package/src/tool/pectin-jam-setting-calculator/index.ts +11 -0
  34. package/src/tool/pectin-jam-setting-calculator/logic.ts +96 -0
  35. package/src/tool/pectin-jam-setting-calculator/pectin-jam-setting-calculator.css +730 -0
  36. package/src/tool/pectin-jam-setting-calculator/seo.astro +15 -0
  37. package/src/tool/sous-vide-pasteurization-curves/bibliography.astro +6 -0
  38. package/src/tool/sous-vide-pasteurization-curves/bibliography.ts +10 -0
  39. package/src/tool/sous-vide-pasteurization-curves/component.astro +275 -0
  40. package/src/tool/sous-vide-pasteurization-curves/components/Controls.astro +90 -0
  41. package/src/tool/sous-vide-pasteurization-curves/components/LethalityChart.astro +28 -0
  42. package/src/tool/sous-vide-pasteurization-curves/components/ResultsDisplay.astro +36 -0
  43. package/src/tool/sous-vide-pasteurization-curves/entry.ts +26 -0
  44. package/src/tool/sous-vide-pasteurization-curves/i18n/de.ts +323 -0
  45. package/src/tool/sous-vide-pasteurization-curves/i18n/en.ts +323 -0
  46. package/src/tool/sous-vide-pasteurization-curves/i18n/es.ts +323 -0
  47. package/src/tool/sous-vide-pasteurization-curves/i18n/fr.ts +323 -0
  48. package/src/tool/sous-vide-pasteurization-curves/i18n/id.ts +323 -0
  49. package/src/tool/sous-vide-pasteurization-curves/i18n/it.ts +323 -0
  50. package/src/tool/sous-vide-pasteurization-curves/i18n/ja.ts +323 -0
  51. package/src/tool/sous-vide-pasteurization-curves/i18n/ko.ts +323 -0
  52. package/src/tool/sous-vide-pasteurization-curves/i18n/nl.ts +323 -0
  53. package/src/tool/sous-vide-pasteurization-curves/i18n/pl.ts +154 -0
  54. package/src/tool/sous-vide-pasteurization-curves/i18n/pt.ts +323 -0
  55. package/src/tool/sous-vide-pasteurization-curves/i18n/ru.ts +154 -0
  56. package/src/tool/sous-vide-pasteurization-curves/i18n/sv.ts +154 -0
  57. package/src/tool/sous-vide-pasteurization-curves/i18n/tr.ts +154 -0
  58. package/src/tool/sous-vide-pasteurization-curves/i18n/zh.ts +323 -0
  59. package/src/tool/sous-vide-pasteurization-curves/index.ts +11 -0
  60. package/src/tool/sous-vide-pasteurization-curves/logic.ts +89 -0
  61. package/src/tool/sous-vide-pasteurization-curves/seo.astro +15 -0
  62. package/src/tool/sous-vide-pasteurization-curves/sous-vide-pasteurization-curves.css +456 -0
  63. package/src/tools.ts +5 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-cooking",
3
- "version": "1.38.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
+ });
@@ -22,6 +22,8 @@ import { macaronDrying } from '../tool/macaron-drying-predictor/entry';
22
22
  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
+ import { pectinJam } from '../tool/pectin-jam-setting-calculator/entry';
26
+ import { sousVidePasteurization } from '../tool/sous-vide-pasteurization-curves/entry';
25
27
 
26
28
  export const cookingCategory: CookingCategoryEntry = {
27
29
  icon: 'mdi:chef-hat',
@@ -49,8 +51,11 @@ export const cookingCategory: CookingCategoryEntry = {
49
51
  brixSorbetDensity,
50
52
  oilSmokePoint,
51
53
  leavenerAcidNeutralizer,
54
+ pectinJam,
55
+ sousVidePasteurization,
52
56
  ],
53
57
 
58
+
54
59
  i18n: {
55
60
  es: () => import('./i18n/es').then((m) => m.content),
56
61
  en: () => import('./i18n/en').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -22,6 +22,8 @@ export { brixSorbetDensity } from './tool/brix-sorbet-density-calculator/entry';
22
22
  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
+ export { pectinJam } from './tool/pectin-jam-setting-calculator/entry';
26
+ export { sousVidePasteurization } from './tool/sous-vide-pasteurization-curves/entry';
25
27
  export { cookingCategory } from './category';
26
28
  import { americanKitchenConverter } from './tool/american-kitchen-converter/entry';
27
29
  import { bananaCare } from './tool/banana-ripeness/entry';
@@ -47,5 +49,8 @@ import { macaronDrying } from './tool/macaron-drying-predictor/entry';
47
49
  import { brixSorbetDensity } from './tool/brix-sorbet-density-calculator/entry';
48
50
  import { oilSmokePoint } from './tool/oil-smoke-point-tracker/entry';
49
51
  import { leavenerAcidNeutralizer } from './tool/leavener-acid-neutralizer/entry';
50
- 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];
52
+ import { pectinJam } from './tool/pectin-jam-setting-calculator/entry';
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
+
51
56
 
package/src/index.ts CHANGED
@@ -25,6 +25,9 @@ export { MACARON_DRYING_TOOL } from './tool/macaron-drying-predictor';
25
25
  export { BRIX_SORBET_DENSITY_CALCULATOR_TOOL } from './tool/brix-sorbet-density-calculator';
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
+ export { PECTIN_JAM_SETTING_TOOL } from './tool/pectin-jam-setting-calculator';
29
+ export { SOUS_VIDE_PASTEURIZATION_TOOL } from './tool/sous-vide-pasteurization-curves';
30
+
28
31
 
29
32
 
30
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(24);
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 24 tools registered', () => {
28
- expect(ALL_TOOLS.length).toBe(24);
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 24 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(24);
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: 'Pectin based gels and their advanced application in food: From hydrogel to emulsion gel',
4
+ url: 'https://www.sciencedirect.com/science/article/abs/pii/S0268005X24011159',
5
+ },
6
+ {
7
+ name: 'Pectin: Technological and Physiological Properties (Springer)',
8
+ url: 'https://link.springer.com/book/10.1007/978-3-030-53421-9',
9
+ },
10
+ ];
@@ -0,0 +1,170 @@
1
+ ---
2
+ import FruitSelector from './components/FruitSelector.astro';
3
+ import CalculatorInputs from './components/CalculatorInputs.astro';
4
+ import DropTestVisualizer from './components/DropTestVisualizer.astro';
5
+ import RecipeResults from './components/RecipeResults.astro';
6
+
7
+ interface Props {
8
+ ui: Record<string, string>;
9
+ }
10
+
11
+ const { ui } = Astro.props;
12
+
13
+ const fruits = [
14
+ { id: 'apple', name: 'Apple', pectin: 'high', acidity: 'high', color: '#dc2626' },
15
+ { id: 'quince', name: 'Quince', pectin: 'high', acidity: 'medium', color: '#ea580c' },
16
+ { id: 'blackberry', name: 'Blackberry', pectin: 'high', acidity: 'low', color: '#7c3aed' },
17
+ { id: 'cranberry', name: 'Cranberry', pectin: 'high', acidity: 'high', color: '#be123c' },
18
+ { id: 'gooseberry', name: 'Gooseberry', pectin: 'high', acidity: 'high', color: '#65a30d' },
19
+ { id: 'plum', name: 'Plum', pectin: 'medium', acidity: 'medium', color: '#7c3aed' },
20
+ { id: 'apricot', name: 'Apricot', pectin: 'medium', acidity: 'medium', color: '#d97706' },
21
+ { id: 'blueberry', name: 'Blueberry', pectin: 'medium', acidity: 'low', color: '#4f46e5' },
22
+ { id: 'raspberry', name: 'Raspberry', pectin: 'medium', acidity: 'high', color: '#e11d48' },
23
+ { id: 'peach', name: 'Peach', pectin: 'low', acidity: 'medium', color: '#d97706' },
24
+ { id: 'strawberry', name: 'Strawberry', pectin: 'low', acidity: 'low', color: '#e11d48' },
25
+ { id: 'pear', name: 'Pear', pectin: 'low', acidity: 'low', color: '#65a30d' },
26
+ { id: 'fig', name: 'Fig', pectin: 'low', acidity: 'low', color: '#92400e' },
27
+ { id: 'cherry', name: 'Cherry', pectin: 'low', acidity: 'high', color: '#be123c' },
28
+ { id: 'grape', name: 'Grape', pectin: 'low', acidity: 'high', color: '#7c3aed' },
29
+ { id: 'mango', name: 'Mango', pectin: 'low', acidity: 'medium', color: '#d97706' },
30
+ ];
31
+ ---
32
+
33
+ <div class="jam-container">
34
+ <div class="jam-card">
35
+ <div class="jam-glow-1"></div>
36
+ <div class="jam-glow-2"></div>
37
+ <div class="jam-glow-3"></div>
38
+
39
+ <div class="jam-main-grid">
40
+ <FruitSelector ui={ui} fruits={fruits} />
41
+ <CalculatorInputs ui={ui} />
42
+ <DropTestVisualizer ui={ui} />
43
+ <RecipeResults ui={ui} />
44
+ </div>
45
+ </div>
46
+ </div>
47
+
48
+ <script is:inline define:vars={{ ui }}>
49
+ const STORAGE_KEY = 'jamcalc_state';
50
+ let aP = 'HM', aSM = 'auto', aF = 'all';
51
+ const $ = function(id){ return document.getElementById(id); };
52
+ const $$ = function(sel){ return document.querySelectorAll(sel); };
53
+ const fs = $('fruit-scroll'), fw = $('fruit-weight'), sw = $('sugar-weight');
54
+ const smr = $('sugar-manual-row'), sah = $('sugar-auto-hint'), sh = $('sugar-hint');
55
+ const jd = $('jam-drop'), jdh = $('jam-drop-highlight'), dst = $('drop-status-text');
56
+ const ds = $('drop-status'), dsi = $('drop-status-icon'), rs = $('recipe-section');
57
+
58
+ const ICONS = {
59
+ perfect: '<svg width="18" height="18" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2.5"/><path d="M7 12l3 3 7-7" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg>',
60
+ warn: '<svg width="18" height="18" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2.5"/><line x1="12" y1="8" x2="12" y2="13" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/><circle cx="12" cy="17" r="1.2" fill="currentColor"/></svg>',
61
+ fail: '<svg width="18" height="18" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2.5"/><line x1="15" y1="9" x2="9" y2="15" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/><line x1="9" y1="9" x2="15" y2="15" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></svg>'
62
+ };
63
+
64
+ function gd(p){ return { id: p.getAttribute('data-fruit'), pectin: p.getAttribute('data-pectin'), acidity: p.getAttribute('data-acidity'), color: p.getAttribute('data-color') }; }
65
+ function rgb(h){ return parseInt(h.slice(1,3),16)+','+parseInt(h.slice(3,5),16)+','+parseInt(h.slice(5,7),16); }
66
+ function lc(h,f){ const c=function(i){ return Math.min(255,parseInt(h.slice(i,i+2),16)+Math.round((255-parseInt(h.slice(i,i+2),16))*f)); }; return '#'+c(1).toString(16).padStart(2,'0')+c(3).toString(16).padStart(2,'0')+c(5).toString(16).padStart(2,'0'); }
67
+ function dc(h,f){ const c=function(i){ return Math.max(0,Math.round(parseInt(h.slice(i,i+2),16)*(1-f))); }; return '#'+c(1).toString(16).padStart(2,'0')+c(3).toString(16).padStart(2,'0')+c(5).toString(16).padStart(2,'0'); }
68
+
69
+ function save(){
70
+ const ae = fs.querySelector('.jam-fruit-pill.active');
71
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ fruit: ae?ae.getAttribute('data-fruit'):'strawberry', weight: fw.value, pectin: aP, sugarMode: aSM, sugarWeight: sw.value, filter: aF })); } catch {}
72
+ }
73
+
74
+ function setTgl(id, v){ $$('#'+id+' .jam-toggle-btn').forEach(function(b){ b.classList.remove('active'); if(b.getAttribute('data-value')===v) b.classList.add('active'); }); }
75
+ function setFl(v){ aF=v; $$('#filter-pills .jam-filter-pill').forEach(function(p){ p.classList.remove('active'); if(p.getAttribute('data-filter')===v) p.classList.add('active'); }); $$('.jam-fruit-pill').forEach(function(p){ p.style.display=(v==='all'||p.getAttribute('data-pectin')===v)?'':'none'; }); }
76
+
77
+ function restoreFruit(s){
78
+ if(!s.fruit) return;
79
+ const t=document.querySelector('.jam-fruit-pill[data-fruit="'+s.fruit+'"]');
80
+ if(!t) return;
81
+ $$('.jam-fruit-pill').forEach(function(p){ p.classList.remove('active'); });
82
+ t.classList.add('active');
83
+ }
84
+
85
+ function applyState(s){
86
+ if(s.pectin){ setTgl('pectin-toggle',s.pectin); aP=s.pectin; }
87
+ if(s.sugarMode){ setTgl('sugar-mode-toggle',s.sugarMode); aSM=s.sugarMode; }
88
+ if(s.filter) setFl(s.filter);
89
+ if(s.weight) fw.value=s.weight;
90
+ if(s.sugarWeight) sw.value=s.sugarWeight;
91
+ if(s.sugarMode==='manual'){ smr.classList.remove('jam-hidden'); sah.style.display='none'; }
92
+ restoreFruit(s);
93
+ }
94
+
95
+ function restore(){
96
+ try {
97
+ const raw = localStorage.getItem(STORAGE_KEY); if(!raw) return;
98
+ applyState(JSON.parse(raw));
99
+ } catch {}
100
+ }
101
+
102
+ function gf(){
103
+ const p = fs.querySelector('.jam-fruit-pill.active');
104
+ return p ? gd(p) : { id:'strawberry',pectin:'low',acidity:'low',color:'#e11d48' };
105
+ }
106
+
107
+ function cp(fwG, p){ if(p==='high') return 0; const r = p==='medium'?0.003:0.006; return parseFloat((fwG*r).toFixed(1)); }
108
+ function cc(fwG, a){ if(a==='high') return 0; const r = a==='medium'?0.003:0.008; return parseFloat((fwG*r).toFixed(1)); }
109
+ function csg(fwG){ if(aSM==='manual') return parseFloat(sw.value)||0; const r=aP==='LM'?0.1:1.0; return parseFloat((fwG*r).toFixed(0)); }
110
+ function cst(sp, hp, pg){
111
+ if(aP==='LM'){ if(hp) return 'perfect'; return pg>0?'slightly-thin':'too-thin'; }
112
+ const t = aSM==='auto'?55:60;
113
+ if(sp>=t && hp) return 'perfect';
114
+ if(sp>=50) return 'slightly-thin';
115
+ return 'too-thin';
116
+ }
117
+
118
+ function calc(){
119
+ const fr = gf(), fwG = Math.max(10,parseFloat(fw.value)||1000);
120
+ const pg = cp(fwG, fr.pectin), ca = cc(fwG, fr.acidity);
121
+ const sg = csg(fwG), cooked = parseFloat((fwG*0.55).toFixed(0));
122
+ const ty = parseFloat((cooked+sg+pg+ca).toFixed(0));
123
+ const sp = ty>0?parseFloat(((sg/ty)*100).toFixed(1)):0, spF = parseFloat(((sg/fwG)*100).toFixed(0));
124
+ const hp = fr.pectin!=='low'||pg>0, st = cst(sp, hp, pg);
125
+ return { pectinGrams:pg, citricGrams:ca, lemonMl:parseFloat((ca*15).toFixed(0)), sugarGrams:sg, totalYield:ty, sugarPct:sp, sugarPctFruit:spF, status:st, fruitColor:fr.color };
126
+ }
127
+
128
+ function upd(r){
129
+ $('pectin-val').textContent=r.pectinGrams.toFixed(1); $('citric-val').textContent=r.citricGrams.toFixed(1);
130
+ $('lemon-val').textContent=ui.lemonJuiceNeeded+': '+r.lemonMl+' ml';
131
+ $('sugar-val').textContent=r.sugarGrams; $('yield-val').textContent=r.totalYield;
132
+ $('sugar-pct-val').textContent=r.sugarPct; $('sugar-pct-context').textContent=ui.sugarOfTotal+' | '+r.sugarPctFruit+'% '+ui.sugarOfFruit;
133
+ const bar=$('sugar-bar-fill'); bar.style.width=Math.min(100,Math.max(0,r.sugarPct))+'%';
134
+ bar.style.setProperty('--fruit-color',r.fruitColor);
135
+ bar.className='jam-sugar-bar-fill '+(r.status==='perfect'?'good':'warn');
136
+ sh.textContent = aSM==='auto'?'('+ui.sugarAutoHint+')':'';
137
+ rs.style.setProperty('--fruit-color',r.fruitColor); rs.style.setProperty('--fruit-color-rgb',rgb(r.fruitColor));
138
+ }
139
+
140
+ function anim(r){
141
+ const s=$('plate-stage'); s.style.setProperty('--jam-fruit-color',r.fruitColor);
142
+ s.style.setProperty('--jam-fruit-color-light',lc(r.fruitColor,0.3)); s.style.setProperty('--jam-fruit-color-dark',dc(r.fruitColor,0.3));
143
+ jd.setAttribute('cy','30'); jd.style.transition='none'; jdh.setAttribute('cy','22'); jdh.style.transition='none';
144
+ requestAnimationFrame(function(){ requestAnimationFrame(function(){ jd.style.transition='cy 0.5s cubic-bezier(0.25,0.46,0.45,0.94)'; jdh.style.transition='cy 0.5s cubic-bezier(0.25,0.46,0.45,0.94)'; jd.setAttribute('cy','110'); jdh.setAttribute('cy','102'); }); });
145
+ setTimeout(function(){
146
+ let cy, txt, cls, ico;
147
+ if(r.status==='perfect'){ cy='118'; txt=ui.dropTestPerfect; cls='perfect'; ico='perfect'; }
148
+ else if(r.status==='too-thin'){ cy='145'; txt=ui.dropTestThin; cls='too-thin'; ico='fail'; }
149
+ else { cy='128'; txt=ui.dropTestThin; cls='slightly-thin'; ico='warn'; }
150
+ jd.style.transition='cy 0.7s cubic-bezier(0.25,0.1,0.25,1)'; jdh.style.transition='cy 0.7s cubic-bezier(0.25,0.1,0.25,1)';
151
+ jd.setAttribute('cy',cy); jdh.setAttribute('cy',(parseFloat(cy)-8).toString());
152
+ dst.textContent=txt; ds.className='jam-drop-status visible '+cls; dsi.innerHTML=ICONS[ico];
153
+ },500);
154
+ }
155
+
156
+ function ua(){ const r=calc(); upd(r); anim(r); if(aSM==='auto') sw.value=Math.round(r.sugarGrams); save(); }
157
+
158
+ fw.addEventListener('input',ua); sw.addEventListener('input',function(){ if(aSM==='manual') ua(); });
159
+
160
+ function st(id,cb){ $$('#'+id+' .jam-toggle-btn').forEach(function(b){ b.addEventListener('click',function(){ $$('#'+id+' .jam-toggle-btn').forEach(function(x){ x.classList.remove('active'); }); b.classList.add('active'); cb(b.getAttribute('data-value')); }); }); }
161
+ st('pectin-toggle',function(v){ aP=v; ua(); });
162
+ st('sugar-mode-toggle',function(v){ aSM=v; if(v==='manual'){ smr.classList.remove('jam-hidden'); sah.style.display='none'; } else { smr.classList.add('jam-hidden'); sah.style.display=''; } ua(); });
163
+
164
+ $$('#filter-pills .jam-filter-pill').forEach(function(p){ p.addEventListener('click',function(){ $$('#filter-pills .jam-filter-pill').forEach(function(x){ x.classList.remove('active'); }); p.classList.add('active'); setFl(p.getAttribute('data-filter')); }); });
165
+ $$('.jam-fruit-pill').forEach(function(p){ p.addEventListener('click',function(){ $$('.jam-fruit-pill').forEach(function(x){ x.classList.remove('active'); }); p.classList.add('active'); ua(); }); });
166
+
167
+ restore();
168
+ if(!fs.querySelector('.jam-fruit-pill.active')){ const f = fs.querySelector('[data-fruit="strawberry"]'); if(f) f.classList.add('active'); }
169
+ ua();
170
+ </script>
@@ -0,0 +1,44 @@
1
+ ---
2
+ interface Props {
3
+ ui: Record<string, string>;
4
+ }
5
+
6
+ const { ui } = Astro.props;
7
+ ---
8
+
9
+ <div class="jam-inputs-section">
10
+ <div class="jam-input-row">
11
+ <label class="jam-input-label" for="fruit-weight">{ui.weightLabel}</label>
12
+ <div class="jam-weight-input-wrap">
13
+ <input type="number" id="fruit-weight" class="jam-weight-input" value="1000" min="10" max="99999"
14
+ placeholder={ui.weightPlaceholder} />
15
+ <span class="jam-weight-unit" id="weight-unit-label">{ui.weightUnitMetric}</span>
16
+ </div>
17
+ </div>
18
+
19
+ <div class="jam-input-row">
20
+ <label class="jam-input-label">{ui.pectinTypeLabel}</label>
21
+ <div class="jam-toggle-wrap" id="pectin-toggle">
22
+ <button class="jam-toggle-btn active" data-value="HM">{ui.pectinHM}</button>
23
+ <button class="jam-toggle-btn" data-value="LM">{ui.pectinLM}</button>
24
+ </div>
25
+ </div>
26
+
27
+ <div class="jam-input-row">
28
+ <label class="jam-input-label">{ui.sugarModeLabel}</label>
29
+ <div class="jam-toggle-wrap" id="sugar-mode-toggle">
30
+ <button class="jam-toggle-btn active" data-value="auto">{ui.sugarModeAuto}</button>
31
+ <button class="jam-toggle-btn" data-value="manual">{ui.sugarModeManual}</button>
32
+ </div>
33
+ <span class="jam-sugar-auto-hint" id="sugar-auto-hint">{ui.sugarAutoHint}</span>
34
+ </div>
35
+
36
+ <div class="jam-input-row jam-hidden" id="sugar-manual-row">
37
+ <label class="jam-input-label" for="sugar-weight">{ui.sugarLabel}</label>
38
+ <div class="jam-weight-input-wrap">
39
+ <input type="number" id="sugar-weight" class="jam-weight-input" value="650" min="0" max="99999"
40
+ placeholder={ui.sugarPlaceholder} />
41
+ <span class="jam-weight-unit">{ui.weightUnitMetric}</span>
42
+ </div>
43
+ </div>
44
+ </div>
@@ -0,0 +1,40 @@
1
+ ---
2
+ interface Props {
3
+ ui: Record<string, string>;
4
+ }
5
+
6
+ const { ui } = Astro.props;
7
+ ---
8
+
9
+ <div class="jam-visual-section">
10
+ <div class="jam-section-label">{ui.dropTestTitle}</div>
11
+ <div class="jam-drop-hint">{ui.dropTestHow}</div>
12
+ <div class="jam-plate-stage" id="plate-stage">
13
+ <svg class="jam-plate-svg" viewBox="0 0 240 160" xmlns="http://www.w3.org/2000/svg">
14
+ <line x1="20" y1="135" x2="220" y2="45" stroke="currentColor" stroke-width="3" stroke-linecap="round"
15
+ opacity="0.35" class="jam-plate-line" />
16
+ <line x1="20" y1="130" x2="220" y2="40" stroke="currentColor" stroke-width="1" stroke-linecap="round"
17
+ opacity="0.12" class="jam-plate-line-bottom" />
18
+
19
+ <defs>
20
+ <radialGradient id="drop-grad" cx="40%" cy="35%">
21
+ <stop offset="0%" stop-color="var(--jam-fruit-color-light)" />
22
+ <stop offset="60%" stop-color="var(--jam-fruit-color)" />
23
+ <stop offset="100%" stop-color="var(--jam-fruit-color-dark)" />
24
+ </radialGradient>
25
+ <filter id="drop-shadow">
26
+ <feDropShadow dx="0" dy="2" stdDeviation="3" flood-opacity="0.3" />
27
+ </filter>
28
+ </defs>
29
+
30
+ <ellipse id="jam-drop" class="jam-drop" cx="120" cy="30" rx="18" ry="15" fill="url(#drop-grad)"
31
+ filter="url(#drop-shadow)" />
32
+ <ellipse id="jam-drop-highlight" class="jam-drop-highlight" cx="114" cy="22" rx="6" ry="3.5" fill="white"
33
+ opacity="0.45" />
34
+ </svg>
35
+ <div class="jam-drop-status visible perfect" id="drop-status">
36
+ <span class="jam-drop-status-icon" id="drop-status-icon"></span>
37
+ <span class="jam-drop-status-text" id="drop-status-text"></span>
38
+ </div>
39
+ </div>
40
+ </div>
@@ -0,0 +1,38 @@
1
+ ---
2
+ interface Fruit {
3
+ id: string;
4
+ name: string;
5
+ pectin: string;
6
+ acidity: string;
7
+ color: string;
8
+ }
9
+
10
+ interface Props {
11
+ ui: Record<string, string>;
12
+ fruits: Fruit[];
13
+ }
14
+
15
+ const { ui, fruits } = Astro.props;
16
+ ---
17
+
18
+ <div class="jam-selector-section">
19
+ <div class="jam-section-label">{ui.fruitLabel}</div>
20
+ <div class="jam-fruit-scroll" id="fruit-scroll">
21
+ {fruits.map((fruit) => (
22
+ <button class="jam-fruit-pill"
23
+ data-fruit={fruit.id} data-pectin={fruit.pectin} data-acidity={fruit.acidity}
24
+ data-color={fruit.color}
25
+ style={`--fruit-color:${fruit.color}`}
26
+ aria-label={fruit.name}>
27
+ <span class="jam-fruit-dot" style={`background:${fruit.color}`}></span>
28
+ <span class="jam-fruit-name">{fruit.name}</span>
29
+ </button>
30
+ ))}
31
+ </div>
32
+ <div class="jam-filter-pills" id="filter-pills">
33
+ <button class="jam-filter-pill active" data-filter="all">{ui.allFruits}</button>
34
+ <button class="jam-filter-pill" data-filter="high">{ui.highPectin}</button>
35
+ <button class="jam-filter-pill" data-filter="medium">{ui.mediumPectin}</button>
36
+ <button class="jam-filter-pill" data-filter="low">{ui.lowPectin}</button>
37
+ </div>
38
+ </div>
@@ -0,0 +1,72 @@
1
+ ---
2
+ interface Props {
3
+ ui: Record<string, string>;
4
+ }
5
+
6
+ const { ui } = Astro.props;
7
+ ---
8
+
9
+ <div class="jam-recipe-section" id="recipe-section">
10
+ <div class="jam-section-label">{ui.recipeTitle}</div>
11
+ <div class="jam-recipe-grid">
12
+ <div class="jam-recipe-item">
13
+ <div class="jam-recipe-icon">
14
+ <svg width="20" height="20" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="none"
15
+ stroke="currentColor" stroke-width="2" /><circle cx="12" cy="12" r="3" fill="currentColor" /></svg>
16
+ </div>
17
+ <div class="jam-recipe-info">
18
+ <div class="jam-recipe-val"><span id="pectin-val">0.0</span><span class="jam-recipe-unit"> g</span></div>
19
+ <div class="jam-recipe-label">{ui.pectinNeeded}</div>
20
+ </div>
21
+ </div>
22
+ <div class="jam-recipe-item">
23
+ <div class="jam-recipe-icon">
24
+ <svg width="20" height="20" viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"
25
+ fill="none" stroke="currentColor" stroke-width="2" /></svg>
26
+ </div>
27
+ <div class="jam-recipe-info">
28
+ <div class="jam-recipe-val"><span id="citric-val">0.0</span><span class="jam-recipe-unit"> g</span></div>
29
+ <div class="jam-recipe-label">{ui.citricAcidNeeded}</div>
30
+ <div class="jam-recipe-sub"><span id="lemon-val">{ui.lemonJuiceNeeded}: 0 ml</span></div>
31
+ </div>
32
+ </div>
33
+ <div class="jam-recipe-item">
34
+ <div class="jam-recipe-icon">
35
+ <svg width="20" height="20" viewBox="0 0 24 24"><rect x="4" y="8" width="16" height="12" rx="2" fill="none"
36
+ stroke="currentColor" stroke-width="2" /><circle cx="12" cy="5" r="3" fill="none" stroke="currentColor"
37
+ stroke-width="2" /></svg>
38
+ </div>
39
+ <div class="jam-recipe-info">
40
+ <div class="jam-recipe-val"><span id="sugar-val">650</span><span class="jam-recipe-unit"> g</span></div>
41
+ <div class="jam-recipe-label">{ui.sugarNeeded} <span class="jam-sugar-hint" id="sugar-hint"></span></div>
42
+ </div>
43
+ </div>
44
+ <div class="jam-recipe-item">
45
+ <div class="jam-recipe-icon">
46
+ <svg width="20" height="20" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="none"
47
+ stroke="currentColor" stroke-width="2" /><circle cx="12" cy="12" r="6" fill="none" stroke="currentColor"
48
+ stroke-width="2" /><circle cx="12" cy="12" r="2" fill="currentColor" /></svg>
49
+ </div>
50
+ <div class="jam-recipe-info">
51
+ <div class="jam-recipe-val"><span id="yield-val">1650</span><span class="jam-recipe-unit"> g</span></div>
52
+ <div class="jam-recipe-label">{ui.totalYield}</div>
53
+ </div>
54
+ </div>
55
+ </div>
56
+ <div class="jam-sugar-bar-wrap">
57
+ <div class="jam-sugar-bar-header">
58
+ <span>{ui.sugarPercent}</span>
59
+ <span class="jam-sugar-bar-val"><span id="sugar-pct-val">39</span>% <span id="sugar-pct-context"></span></span>
60
+ </div>
61
+ <div class="jam-sugar-bar">
62
+ <div class="jam-sugar-bar-fill" id="sugar-bar-fill" style="width:39%"></div>
63
+ <div class="jam-sugar-bar-marker" style="left:60%"></div>
64
+ </div>
65
+ <div class="jam-sugar-bar-labels">
66
+ <span>0%</span>
67
+ <span>{ui.sugarLow}</span>
68
+ <span class="jam-sugar-bar-ideal">{ui.sugarIdeal}</span>
69
+ <span>{ui.sugarHigh}</span>
70
+ </div>
71
+ </div>
72
+ </div>
@@ -0,0 +1,26 @@
1
+ import type { CookingToolEntry } from '../../types';
2
+
3
+ export const pectinJam: CookingToolEntry = {
4
+ id: 'pectin-jam-setting-calculator',
5
+ icons: {
6
+ bg: 'mdi:fruit-cherries',
7
+ fg: 'mdi:beaker-outline',
8
+ },
9
+ i18n: {
10
+ en: () => import('./i18n/en').then((m) => m.content),
11
+ es: () => import('./i18n/es').then((m) => m.content),
12
+ de: () => import('./i18n/de').then((m) => m.content),
13
+ fr: () => import('./i18n/fr').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
+ };