@jjlmoya/utils-pets 1.28.0 → 1.30.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 (40) hide show
  1. package/package.json +2 -2
  2. package/scripts/validate-icons.mjs +57 -0
  3. package/src/category/index.ts +2 -1
  4. package/src/data.ts +2 -0
  5. package/src/entries.ts +4 -1
  6. package/src/index.ts +1 -0
  7. package/src/tests/i18n_coverage.test.ts +1 -1
  8. package/src/tests/locale_completeness.test.ts +1 -1
  9. package/src/tests/mfe_assets_contract.test.ts +54 -0
  10. package/src/tests/tool_validation.test.ts +3 -3
  11. package/src/tool/petCollarSizeCalculator/bibliography.astro +6 -0
  12. package/src/tool/petCollarSizeCalculator/bibliography.ts +12 -0
  13. package/src/tool/petCollarSizeCalculator/component.astro +65 -0
  14. package/src/tool/petCollarSizeCalculator/controller.ts +160 -0
  15. package/src/tool/petCollarSizeCalculator/dom-views.ts +111 -0
  16. package/src/tool/petCollarSizeCalculator/entry.ts +32 -0
  17. package/src/tool/petCollarSizeCalculator/evaluator.ts +18 -0
  18. package/src/tool/petCollarSizeCalculator/i18n/de.ts +44 -0
  19. package/src/tool/petCollarSizeCalculator/i18n/en.ts +153 -0
  20. package/src/tool/petCollarSizeCalculator/i18n/es.ts +44 -0
  21. package/src/tool/petCollarSizeCalculator/i18n/fr.ts +44 -0
  22. package/src/tool/petCollarSizeCalculator/i18n/id.ts +44 -0
  23. package/src/tool/petCollarSizeCalculator/i18n/it.ts +44 -0
  24. package/src/tool/petCollarSizeCalculator/i18n/ja.ts +44 -0
  25. package/src/tool/petCollarSizeCalculator/i18n/ko.ts +44 -0
  26. package/src/tool/petCollarSizeCalculator/i18n/nl.ts +44 -0
  27. package/src/tool/petCollarSizeCalculator/i18n/pl.ts +44 -0
  28. package/src/tool/petCollarSizeCalculator/i18n/pt.ts +44 -0
  29. package/src/tool/petCollarSizeCalculator/i18n/ru.ts +44 -0
  30. package/src/tool/petCollarSizeCalculator/i18n/sv.ts +44 -0
  31. package/src/tool/petCollarSizeCalculator/i18n/tr.ts +44 -0
  32. package/src/tool/petCollarSizeCalculator/i18n/zh.ts +44 -0
  33. package/src/tool/petCollarSizeCalculator/index.ts +11 -0
  34. package/src/tool/petCollarSizeCalculator/logic.test.ts +63 -0
  35. package/src/tool/petCollarSizeCalculator/logic.ts +72 -0
  36. package/src/tool/petCollarSizeCalculator/pet-collar-size-calculator.css +631 -0
  37. package/src/tool/petCollarSizeCalculator/seo.astro +14 -0
  38. package/src/tool/petCollarSizeCalculator/storage.ts +29 -0
  39. package/src/tool/petCollarSizeCalculator/ui.ts +33 -0
  40. package/src/tools.ts +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-pets",
3
- "version": "1.28.0",
3
+ "version": "1.30.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -36,7 +36,7 @@
36
36
  "postinstall": "node scripts/postinstall.mjs",
37
37
  "predev": "node scripts/postinstall.mjs",
38
38
  "prestart": "node scripts/postinstall.mjs",
39
- "prebuild": "node scripts/postinstall.mjs",
39
+ "prebuild": "node scripts/postinstall.mjs && node scripts/validate-icons.mjs",
40
40
  "qa": "npm run lint && npm run test && npm run build",
41
41
  "cf:dry-run": "npm run build && wrangler deploy --dry-run",
42
42
  "cf:preview": "npm run build && wrangler deploy --config wrangler.staging.jsonc",
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createRequire } from 'node:module';
4
+ import { readFileSync, readdirSync } from 'node:fs';
5
+ import { dirname, join, relative, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const require = createRequire(import.meta.url);
9
+ const packageJsonPath = require.resolve('@iconify-json/mdi/package.json');
10
+ const packageRoot = dirname(packageJsonPath);
11
+ const iconSet = JSON.parse(readFileSync(join(packageRoot, 'icons.json'), 'utf8'));
12
+ const availableIcons = new Set([
13
+ ...Object.keys(iconSet.icons ?? {}),
14
+ ...Object.keys(iconSet.aliases ?? {}),
15
+ ]);
16
+
17
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
18
+ const sourceRoot = resolve(repoRoot, 'src');
19
+ const extensions = new Set(['.astro', '.js', '.mjs', '.ts', '.tsx']);
20
+ const iconPattern = /\bmdi:([a-z0-9-]+)\b/g;
21
+ const failures = [];
22
+ let references = 0;
23
+
24
+ function walk(directory) {
25
+ const entries = readdirSync(directory, { withFileTypes: true });
26
+ for (const entry of entries) {
27
+ const path = join(directory, entry.name);
28
+ if (entry.isDirectory()) {
29
+ if (entry.name !== 'node_modules' && entry.name !== 'dist' && entry.name !== 'tests') {
30
+ walk(path);
31
+ }
32
+ continue;
33
+ }
34
+ if (!extensions.has(path.slice(path.lastIndexOf('.')))) continue;
35
+
36
+ const source = readFileSync(path, 'utf8');
37
+ for (const match of source.matchAll(iconPattern)) {
38
+ references += 1;
39
+ const iconName = match[1];
40
+ if (availableIcons.has(iconName)) continue;
41
+
42
+ const line = source.slice(0, match.index).split('\n').length;
43
+ failures.push(`${relative(repoRoot, path)}:${line} — mdi:${iconName}`);
44
+ }
45
+ }
46
+ }
47
+
48
+ walk(sourceRoot);
49
+
50
+ if (failures.length > 0) {
51
+ console.error('Invalid MDI icons found:');
52
+ for (const failure of failures) console.error(`- ${failure}`);
53
+ console.error(`Checked ${references} MDI icon references against @iconify-json/mdi.`);
54
+ process.exitCode = 1;
55
+ } else {
56
+ console.log(`MDI icon validation passed: ${references} references checked.`);
57
+ }
@@ -6,10 +6,11 @@ import { petToxicity } from '../tool/petToxicity/entry';
6
6
  import { petWaterIntake } from '../tool/petWaterIntake/entry';
7
7
  import { petCarrierCrateSizePlanner } from '../tool/petCarrierCrateSizePlanner/entry';
8
8
  import { petMedicationSchedulePlanner } from '../tool/petMedicationSchedulePlanner/entry';
9
+ import { petCollarSizeCalculator } from '../tool/petCollarSizeCalculator/entry';
9
10
 
10
11
  export const petsCategory: PetCategoryEntry = {
11
12
  icon: 'mdi:paw',
12
- tools: [petAge, petRation, petGestation, petToxicity, petWaterIntake, petCarrierCrateSizePlanner, petMedicationSchedulePlanner],
13
+ tools: [petAge, petRation, petGestation, petToxicity, petWaterIntake, petCarrierCrateSizePlanner, petMedicationSchedulePlanner, petCollarSizeCalculator],
13
14
  i18n: {
14
15
  en: () => import('./i18n/en').then((m) => m.content),
15
16
  es: () => import('./i18n/es').then((m) => m.content),
package/src/data.ts CHANGED
@@ -4,12 +4,14 @@ export { petRation } from './tool/petRation';
4
4
  export { petGestation } from './tool/petGestation';
5
5
  export { petToxicity } from './tool/petToxicity';
6
6
  export { petMedicationSchedulePlanner } from './tool/petMedicationSchedulePlanner';
7
+ export { petCollarSizeCalculator } from './tool/petCollarSizeCalculator';
7
8
 
8
9
  export type { PetAgeUI, PetAgeLocaleContent } from './tool/petAge';
9
10
  export type { PetRationUI, PetRationLocaleContent } from './tool/petRation';
10
11
  export type { PetGestationUI, PetGestationLocaleContent } from './tool/petGestation';
11
12
  export type { PetToxicityUI, PetToxicityLocaleContent } from './tool/petToxicity';
12
13
  export type { PetMedicationSchedulePlannerUI, PetMedicationSchedulePlannerLocaleContent } from './tool/petMedicationSchedulePlanner';
14
+ export type { PetCollarSizeCalculatorUI, PetCollarSizeCalculatorLocaleContent } from './tool/petCollarSizeCalculator';
13
15
 
14
16
  export type {
15
17
  KnownLocale,
package/src/entries.ts CHANGED
@@ -10,6 +10,8 @@ export { petCarrierCrateSizePlanner } from './tool/petCarrierCrateSizePlanner/en
10
10
  export type { PetCarrierCrateSizePlannerUI, PetCarrierCrateSizePlannerLocaleContent } from './tool/petCarrierCrateSizePlanner/entry';
11
11
  export { petMedicationSchedulePlanner } from './tool/petMedicationSchedulePlanner/entry';
12
12
  export type { PetMedicationSchedulePlannerUI, PetMedicationSchedulePlannerLocaleContent } from './tool/petMedicationSchedulePlanner/entry';
13
+ export { petCollarSizeCalculator } from './tool/petCollarSizeCalculator/entry';
14
+ export type { PetCollarSizeCalculatorUI, PetCollarSizeCalculatorLocaleContent } from './tool/petCollarSizeCalculator/entry';
13
15
  export { petsCategory } from './category';
14
16
  import { petAge } from './tool/petAge/entry';
15
17
  import { petRation } from './tool/petRation/entry';
@@ -18,4 +20,5 @@ import { petToxicity } from './tool/petToxicity/entry';
18
20
  import { petWaterIntake } from './tool/petWaterIntake/entry';
19
21
  import { petCarrierCrateSizePlanner } from './tool/petCarrierCrateSizePlanner/entry';
20
22
  import { petMedicationSchedulePlanner } from './tool/petMedicationSchedulePlanner/entry';
21
- export const ALL_ENTRIES = [petAge, petRation, petGestation, petToxicity, petWaterIntake, petCarrierCrateSizePlanner, petMedicationSchedulePlanner];
23
+ import { petCollarSizeCalculator } from './tool/petCollarSizeCalculator/entry';
24
+ export const ALL_ENTRIES = [petAge, petRation, petGestation, petToxicity, petWaterIntake, petCarrierCrateSizePlanner, petMedicationSchedulePlanner, petCollarSizeCalculator];
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export { petRation, PET_RATION_TOOL } from './tool/petRation';
3
3
  export { petGestation, PET_GESTATION_TOOL } from './tool/petGestation';
4
4
  export { petCarrierCrateSizePlanner, PET_CARRIER_CRATE_SIZE_PLANNER_TOOL } from './tool/petCarrierCrateSizePlanner';
5
5
  export { petMedicationSchedulePlanner, PET_MEDICATION_SCHEDULE_PLANNER_TOOL } from './tool/petMedicationSchedulePlanner';
6
+ export { petCollarSizeCalculator, PET_COLLAR_SIZE_CALCULATOR_TOOL } from './tool/petCollarSizeCalculator';
6
7
 
7
8
  export { petsCategory } from './category';
8
9
  export const PetsCategorySEO = () => import('./category/seo.astro').then((m) => m.default);
@@ -4,7 +4,7 @@ import { ALL_TOOLS } from '../tools';
4
4
  const EXPECTED_LOCALES = [
5
5
  'de', 'en', 'es', 'fr', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ru', 'sv', 'tr', 'zh'
6
6
  ];
7
- const ENGLISH_FIRST_TOOLS = new Set(['pet-carrier-crate-size-planner']);
7
+ const ENGLISH_FIRST_TOOLS = new Set(['pet-carrier-crate-size-planner', 'pet-collar-size-calculator']);
8
8
 
9
9
  describe('I18n Coverage Validation', () => {
10
10
  it('all tools should be registered', () => {
@@ -26,6 +26,6 @@ describe('Locale Completeness Validation', () => {
26
26
  });
27
27
 
28
28
  it('all tools registered', () => {
29
- expect(ALL_TOOLS.length).toBe(7);
29
+ expect(ALL_TOOLS.length).toBe(8);
30
30
  });
31
31
  });
@@ -0,0 +1,54 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { ALL_TOOLS } from '../tools';
5
+ import { CATEGORY_OG_IMAGE, getUtilityOgImage } from '../mfe/assets';
6
+
7
+ const categoryImageMatch = CATEGORY_OG_IMAGE.match(
8
+ /^(\/_utilities\/[^/]+\/images)\/([^/]+\.webp)\?version=(.+)$/,
9
+ );
10
+
11
+ if (!categoryImageMatch) {
12
+ throw new Error(`Unexpected CATEGORY_OG_IMAGE format: ${CATEGORY_OG_IMAGE}`);
13
+ }
14
+
15
+ const [, matchedImageUrlRoot, matchedCategoryImage, matchedAssetVersion] = categoryImageMatch;
16
+ if (!matchedImageUrlRoot || !matchedCategoryImage || !matchedAssetVersion) {
17
+ throw new Error('Unexpected CATEGORY_OG_IMAGE capture groups');
18
+ }
19
+ const imageUrlRoot = matchedImageUrlRoot;
20
+ const categoryImage = matchedCategoryImage;
21
+ const assetVersion = matchedAssetVersion;
22
+ const assetRoot = join(process.cwd(), 'public', imageUrlRoot.slice(1));
23
+ const categorySlug = basename(categoryImage, '.webp');
24
+
25
+ describe('MFE asset contract', () => {
26
+ it('has one non-empty English-slug OG image per category and registered tool', async () => {
27
+ const expectedSlugs = new Set([categorySlug]);
28
+
29
+ for (const { entry } of ALL_TOOLS) {
30
+ const englishLoader = entry.i18n.en;
31
+ if (!englishLoader) throw new Error(`Missing English locale for ${entry.id}`);
32
+
33
+ const englishContent = await englishLoader();
34
+ expectedSlugs.add(englishContent.slug);
35
+ }
36
+
37
+ const actualSlugs = new Set(
38
+ readdirSync(assetRoot)
39
+ .filter((filename) => filename.endsWith('.webp'))
40
+ .map((filename) => filename.slice(0, -'.webp'.length)),
41
+ );
42
+
43
+ expect(actualSlugs).toEqual(expectedSlugs);
44
+
45
+ for (const slug of expectedSlugs) {
46
+ const imagePath = join(assetRoot, `${slug}.webp`);
47
+ expect(existsSync(imagePath), `${imagePath} should exist`).toBe(true);
48
+ expect(statSync(imagePath).size, `${imagePath} should not be empty`).toBeGreaterThan(0);
49
+ expect(getUtilityOgImage(slug)).toBe(
50
+ `${imageUrlRoot}/${slug}.webp?version=${assetVersion}`,
51
+ );
52
+ }
53
+ }, 30000);
54
+ });
@@ -73,7 +73,7 @@ describe('Tool Validation Suite', () => {
73
73
  expect(content.slug).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
74
74
 
75
75
  if (locale === 'es') {
76
- const validSlugs = ['calculadora-edad-mascotas', 'calculadora-racion-diaria-mascotas', 'calculadora-gestacion-mascotas', 'buscador-alimentos-toxicos-perros-gatos', 'calculadora-agua-diaria-perros-gatos', 'planificador-dimensiones-transportin-mascotas', 'planificador-horario-medicacion-mascota'];
76
+ const validSlugs = ['calculadora-edad-mascotas', 'calculadora-racion-diaria-mascotas', 'calculadora-gestacion-mascotas', 'buscador-alimentos-toxicos-perros-gatos', 'calculadora-agua-diaria-perros-gatos', 'planificador-dimensiones-transportin-mascotas', 'planificador-horario-medicacion-mascota', 'calculadora-talla-collar-mascotas'];
77
77
  expect(validSlugs).toContain(content.slug);
78
78
  }
79
79
  });
@@ -99,11 +99,11 @@ describe('Tool Validation Suite', () => {
99
99
 
100
100
  describe('Library Registration', () => {
101
101
  it('should have 7 tools in ALL_TOOLS', () => {
102
- expect(ALL_TOOLS.length).toBe(7);
102
+ expect(ALL_TOOLS.length).toBe(8);
103
103
  });
104
104
 
105
105
  it('should have all tools in petsCategory', () => {
106
- expect(petsCategory.tools.length).toBe(7);
106
+ expect(petsCategory.tools.length).toBe(8);
107
107
  ALL_TOOLS.forEach(({ entry }) => {
108
108
  const exists = petsCategory.tools.some((t: any) => t.id === entry.id);
109
109
  expect(exists).toBe(true);
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { bibliography } from './bibliography';
4
+ ---
5
+
6
+ <SharedBibliography links={bibliography} />
@@ -0,0 +1,12 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'PDSA: How can I be a responsible dog owner?',
6
+ url: 'https://www.pdsa.org.uk/what-we-do/blog/vet-qa-how-can-i-be-a-responsible-dog-owner',
7
+ },
8
+ {
9
+ name: 'Deutscher Tierschutzbund: Hunde richtig halten',
10
+ url: 'https://www.tierschutzbund.de/tiere-themen/haustiere/hunde/hundehaltung/',
11
+ },
12
+ ];
@@ -0,0 +1,65 @@
1
+ ---
2
+ import './pet-collar-size-calculator.css';
3
+ import { calculatePetCollarSize } from './logic';
4
+ import { renderResult, renderScene } from './dom-views';
5
+ import { petCollarSizeCalculator } from './entry';
6
+
7
+ const content = await petCollarSizeCalculator.i18n.en?.();
8
+ if (!content) return null;
9
+ const { ui } = content;
10
+ const initialResult = calculatePetCollarSize({
11
+ neckCircumference: 32,
12
+ allowance: 2,
13
+ unit: 'cm',
14
+ collarType: 'flat',
15
+ });
16
+ const config = { ui };
17
+ ---
18
+
19
+ <section class="pet-collar-size-calculator" data-pet-collar-root aria-label={content.title}>
20
+ <div class="pet-collar-atelier">
21
+ <aside class="pet-collar-atelier-rail" aria-hidden="true">
22
+ <span class="pet-collar-rail-index">01</span>
23
+ <span class="pet-collar-rail-word">fit study</span>
24
+ <span class="pet-collar-rail-mark"></span>
25
+ </aside>
26
+ <div class="pet-collar-atelier-body">
27
+ <header class="pet-collar-atelier-header">
28
+ <span class="pet-collar-kicker">{ui.fitStudyLabel}</span>
29
+ <fieldset class="pet-collar-type-field">
30
+ <legend>{ui.collarTypeLabel}</legend>
31
+ <div class="pet-collar-type-options">
32
+ <label><input type="radio" name="pet-collar-type" data-collar-type="flat" value="flat" checked /><span>{ui.flatCollar}</span></label>
33
+ <label><input type="radio" name="pet-collar-type" data-collar-type="breakaway" value="breakaway" /><span>{ui.breakawayCollar}</span></label>
34
+ <label><input type="radio" name="pet-collar-type" data-collar-type="martingale" value="martingale" /><span>{ui.martingaleCollar}</span></label>
35
+ </div>
36
+ </fieldset>
37
+ </header>
38
+ <div class="pet-collar-scene" data-collar-scene set:html={renderScene(initialResult, ui)}></div>
39
+ <form class="pet-collar-controls" data-collar-form onsubmit="return false;">
40
+ <div class="pet-collar-control pet-collar-control-neck">
41
+ <label for="pet-collar-neck-input">{ui.neckCircumferenceLabel}</label>
42
+ <div class="pet-collar-number-wrap"><input id="pet-collar-neck-input" data-neck-input type="number" value="32" min="5" max="250" step="0.5" inputmode="decimal" /><span data-unit-suffix>cm</span></div>
43
+ </div>
44
+ <div class="pet-collar-control pet-collar-control-room">
45
+ <label for="pet-collar-allowance-range">{ui.allowanceLabel}</label>
46
+ <div class="pet-collar-range-row"><input id="pet-collar-allowance-range" data-allowance-range type="range" value="2" min="0.5" max="10" step="0.5" aria-label={ui.allowanceLabel} /><output data-allowance-output for="pet-collar-allowance-range">2 <span data-unit-suffix>cm</span></output></div>
47
+ </div>
48
+ <div class="pet-collar-control pet-collar-control-unit">
49
+ <span class="pet-collar-label">{ui.unitLabel}</span>
50
+ <div class="pet-collar-unit-switch" role="group" aria-label={ui.unitLabel}><button type="button" data-unit-choice="cm" aria-label={ui.centimetres} aria-pressed="true" class="is-active">cm</button><button type="button" data-unit-choice="in" aria-label={ui.inches} aria-pressed="false">in</button></div>
51
+ </div>
52
+ </form>
53
+ <div class="pet-collar-result" data-collar-result aria-live="polite" set:html={renderResult(initialResult, ui)}></div>
54
+ </div>
55
+ </div>
56
+ </section>
57
+
58
+ <script is:inline type="application/json" data-pet-collar-config set:html={JSON.stringify(config)}></script>
59
+ <script>
60
+ import { mountPetCollarSizeCalculator } from './controller';
61
+
62
+ const root = document.querySelector<HTMLElement>('[data-pet-collar-root]');
63
+ const configElement = document.querySelector<HTMLScriptElement>('[data-pet-collar-config]');
64
+ if (root && configElement?.textContent) mountPetCollarSizeCalculator(root, JSON.parse(configElement.textContent));
65
+ </script>
@@ -0,0 +1,160 @@
1
+ import { calculatePetCollarSize, fromCentimetres, toCentimetres, type CollarType, type CollarUnit } from './logic';
2
+ import { renderResult, renderScene } from './dom-views';
3
+ import { loadCollarState, saveCollarState } from './storage';
4
+ import type { PetCollarSizeCalculatorUI } from './ui';
5
+
6
+ interface ControllerConfig {
7
+ ui: PetCollarSizeCalculatorUI;
8
+ }
9
+
10
+ function toNumber(value: string | undefined, fallback: number): number {
11
+ const parsed = Number(value);
12
+ return Number.isFinite(parsed) ? parsed : fallback;
13
+ }
14
+
15
+ function setUnitConstraints(root: HTMLElement, unit: CollarUnit): void {
16
+ const neck = root.querySelector<HTMLInputElement>('[data-neck-input]');
17
+ const allowanceRange = root.querySelector<HTMLInputElement>('[data-allowance-range]');
18
+ if (!neck || !allowanceRange) return;
19
+
20
+ const constraints = unit === 'in'
21
+ ? { neckMin: '2', neckMax: '98.4', neckStep: '0.1', allowanceMin: '0.2', allowanceMax: '3.9', allowanceStep: '0.1' }
22
+ : { neckMin: '5', neckMax: '250', neckStep: '0.5', allowanceMin: '0.5', allowanceMax: '10', allowanceStep: '0.5' };
23
+ neck.min = constraints.neckMin;
24
+ neck.max = constraints.neckMax;
25
+ neck.step = constraints.neckStep;
26
+ allowanceRange.min = constraints.allowanceMin;
27
+ allowanceRange.max = constraints.allowanceMax;
28
+ allowanceRange.step = constraints.allowanceStep;
29
+ }
30
+
31
+ function updateAllowanceOutput(root: HTMLElement, value: string, unit: CollarUnit): void {
32
+ const output = root.querySelector<HTMLOutputElement>('[data-allowance-output]');
33
+ if (!output) return;
34
+ output.firstChild?.replaceWith(document.createTextNode(value));
35
+ output.querySelector('[data-unit-suffix]')?.replaceChildren(document.createTextNode(unit === 'in' ? 'in' : 'cm'));
36
+ }
37
+
38
+ function selectUnit(root: HTMLElement, unit: CollarUnit): void {
39
+ root.querySelectorAll<HTMLElement>('[data-unit-choice]').forEach((button) => {
40
+ const active = button.dataset.unitChoice === unit;
41
+ button.classList.toggle('is-active', active);
42
+ button.setAttribute('aria-pressed', String(active));
43
+ });
44
+ root.querySelectorAll<HTMLElement>('[data-unit-suffix]').forEach((suffix) => {
45
+ suffix.textContent = unit === 'in' ? 'in' : 'cm';
46
+ });
47
+ }
48
+
49
+ export function mountPetCollarSizeCalculator(root: HTMLElement, config: ControllerConfig): void {
50
+ const elements = getMountElements(root);
51
+ if (!elements) return;
52
+ const state = prepareInitialState(root, elements);
53
+ const update = createUpdater({ ...elements, config, state });
54
+ bindTypeInputs(root, state, update);
55
+ bindValueInputs({ root, neck: elements.neck, allowanceRange: elements.allowanceRange, unit: () => state.unit, update });
56
+ bindUnitButtons({ root, ...elements, state, update });
57
+ update();
58
+ }
59
+
60
+ interface MountElements {
61
+ neck: HTMLInputElement;
62
+ allowanceRange: HTMLInputElement;
63
+ scene: HTMLElement;
64
+ resultView: HTMLElement;
65
+ }
66
+
67
+ function getMountElements(root: HTMLElement): MountElements | null {
68
+ const neck = root.querySelector<HTMLInputElement>('[data-neck-input]');
69
+ const allowanceRange = root.querySelector<HTMLInputElement>('[data-allowance-range]');
70
+ const scene = root.querySelector<HTMLElement>('[data-collar-scene]');
71
+ const resultView = root.querySelector<HTMLElement>('[data-collar-result]');
72
+ if (!neck || !allowanceRange || !scene || !resultView) return null;
73
+ return { neck, allowanceRange, scene, resultView };
74
+ }
75
+
76
+ function prepareInitialState(root: HTMLElement, elements: MountElements): { unit: CollarUnit; collarType: CollarType } {
77
+ const stored = loadCollarState();
78
+ const state = { unit: stored.unit ?? 'cm', collarType: stored.collarType ?? 'flat' };
79
+ setUnitConstraints(root, state.unit);
80
+ elements.neck.value = String(stored.neckCircumference ?? 32);
81
+ elements.allowanceRange.value = String(stored.allowance ?? 2);
82
+ root.querySelector<HTMLInputElement>(`[data-collar-type="${state.collarType}"]`)?.setAttribute('checked', '');
83
+ selectUnit(root, state.unit);
84
+ updateAllowanceOutput(root, elements.allowanceRange.value, state.unit);
85
+ return state;
86
+ }
87
+
88
+ interface UpdaterArgs {
89
+ neck: HTMLInputElement;
90
+ allowanceRange: HTMLInputElement;
91
+ scene: HTMLElement;
92
+ resultView: HTMLElement;
93
+ config: ControllerConfig;
94
+ state: { unit: CollarUnit; collarType: CollarType };
95
+ }
96
+
97
+ function createUpdater(args: UpdaterArgs): () => void {
98
+ return () => {
99
+ const result = calculatePetCollarSize({
100
+ neckCircumference: toNumber(args.neck.value, 0),
101
+ allowance: toNumber(args.allowanceRange.value, 0),
102
+ unit: args.state.unit,
103
+ collarType: args.state.collarType,
104
+ });
105
+ args.scene.innerHTML = renderScene(result, args.config.ui);
106
+ args.resultView.innerHTML = renderResult(result, args.config.ui);
107
+ saveCollarState({
108
+ neckCircumference: toNumber(args.neck.value, 0),
109
+ allowance: toNumber(args.allowanceRange.value, 0),
110
+ unit: args.state.unit,
111
+ collarType: args.state.collarType,
112
+ });
113
+ };
114
+ }
115
+
116
+ function bindTypeInputs(root: HTMLElement, state: { collarType: CollarType }, update: () => void): void {
117
+ root.querySelectorAll<HTMLInputElement>('[data-collar-type]').forEach((input) => input.addEventListener('change', () => {
118
+ if (input.checked) state.collarType = input.value as CollarType;
119
+ update();
120
+ }));
121
+ }
122
+
123
+ interface ValueBindingArgs {
124
+ root: HTMLElement;
125
+ neck: HTMLInputElement;
126
+ allowanceRange: HTMLInputElement;
127
+ unit: () => CollarUnit;
128
+ update: () => void;
129
+ }
130
+
131
+ function bindValueInputs(args: ValueBindingArgs): void {
132
+ args.neck.addEventListener('input', args.update);
133
+ args.allowanceRange.addEventListener('input', () => {
134
+ updateAllowanceOutput(args.root, args.allowanceRange.value, args.unit());
135
+ args.update();
136
+ });
137
+ }
138
+
139
+ interface UnitBindingArgs {
140
+ root: HTMLElement;
141
+ neck: HTMLInputElement;
142
+ allowanceRange: HTMLInputElement;
143
+ state: { unit: CollarUnit };
144
+ update: () => void;
145
+ }
146
+
147
+ function bindUnitButtons(args: UnitBindingArgs): void {
148
+ args.root.querySelectorAll<HTMLElement>('[data-unit-choice]').forEach((button) => button.addEventListener('click', () => {
149
+ const nextUnit = button.dataset.unitChoice as CollarUnit;
150
+ const neckCm = toCentimetres(toNumber(args.neck.value, 0), args.state.unit);
151
+ const allowanceCm = toCentimetres(toNumber(args.allowanceRange.value, 0), args.state.unit);
152
+ args.state.unit = nextUnit;
153
+ setUnitConstraints(args.root, nextUnit);
154
+ args.neck.value = String(Number(fromCentimetres(neckCm, nextUnit).toFixed(1)));
155
+ args.allowanceRange.value = String(Number(fromCentimetres(allowanceCm, nextUnit).toFixed(1)));
156
+ selectUnit(args.root, nextUnit);
157
+ updateAllowanceOutput(args.root, args.allowanceRange.value, nextUnit);
158
+ args.update();
159
+ }));
160
+ }
@@ -0,0 +1,111 @@
1
+ import type { PetCollarSizeCalculatorUI } from './ui';
2
+ import type { CollarType, CollarUnit, PetCollarSizeResult } from './logic';
3
+ import { getFitReview } from './evaluator';
4
+
5
+ function escapeHtml(value: string): string {
6
+ return value.replace(/[&<>"']/g, (character) => ({
7
+ '&': '&amp;',
8
+ '<': '&lt;',
9
+ '>': '&gt;',
10
+ '"': '&quot;',
11
+ "'": '&#39;',
12
+ })[character] ?? character);
13
+ }
14
+
15
+ export function formatMeasurement(valueCm: number, unit: CollarUnit): string {
16
+ const value = unit === 'in' ? valueCm / 2.54 : valueCm;
17
+ return value.toFixed(1).replace(/\.0$/, '');
18
+ }
19
+
20
+ function unitLabel(unit: CollarUnit, ui: PetCollarSizeCalculatorUI): string {
21
+ return unit === 'in' ? ui.inches : ui.centimetres;
22
+ }
23
+
24
+ function collarTypeLabel(type: CollarType, ui: PetCollarSizeCalculatorUI): string {
25
+ return { flat: ui.flatCollar, breakaway: ui.breakawayCollar, martingale: ui.martingaleCollar }[type];
26
+ }
27
+
28
+ function collarCheckText(type: CollarType, ui: PetCollarSizeCalculatorUI): string {
29
+ return { flat: ui.checkFlat, breakaway: ui.checkBreakaway, martingale: ui.checkMartingale }[type];
30
+ }
31
+
32
+ function point(cx: number, cy: number, radius: number, degrees: number): { x: number; y: number } {
33
+ const radians = (degrees * Math.PI) / 180;
34
+ return { x: cx + Math.cos(radians) * radius, y: cy + Math.sin(radians) * radius };
35
+ }
36
+
37
+ interface ArcPathArgs {
38
+ cx: number;
39
+ cy: number;
40
+ radius: number;
41
+ start: number;
42
+ end: number;
43
+ }
44
+
45
+ function arcPath(args: ArcPathArgs): string {
46
+ const first = point(args.cx, args.cy, args.radius, args.start);
47
+ const last = point(args.cx, args.cy, args.radius, args.end);
48
+ const largeArc = Math.abs(args.end - args.start) > 180 ? 1 : 0;
49
+ return `M${first.x.toFixed(1)} ${first.y.toFixed(1)}A${args.radius} ${args.radius} 0 ${largeArc} 1 ${last.x.toFixed(1)} ${last.y.toFixed(1)}`;
50
+ }
51
+
52
+ function renderWheelTicks(): string {
53
+ const ticks = Array.from({ length: 12 }, (_, index) => {
54
+ const angle = index * 30;
55
+ const inner = point(196, 204, 153, angle);
56
+ const outer = point(196, 204, index % 3 === 0 ? 166 : 160, angle);
57
+ return `<line class="pet-collar-wheel-tick" x1="${inner.x.toFixed(1)}" y1="${inner.y.toFixed(1)}" x2="${outer.x.toFixed(1)}" y2="${outer.y.toFixed(1)}" />`;
58
+ });
59
+ return ticks.join('');
60
+ }
61
+
62
+ function renderBuckle(): string {
63
+ return '<path class="pet-collar-wheel-buckle" d="M325 82h27v45h-27m0-35h19m-19 25h19" />';
64
+ }
65
+
66
+ function renderWheel(result: PetCollarSizeResult, ui: PetCollarSizeCalculatorUI): string {
67
+ const roomOffset = Math.min(30, Math.max(2, result.allowanceCm * 3));
68
+ const collarStart = 104 + roomOffset;
69
+ const collarEnd = collarStart + 24;
70
+ const neckPath = arcPath({ cx: 196, cy: 204, radius: 80, start: 205, end: 505 });
71
+ const spanPath = arcPath({ cx: 196, cy: 204, radius: collarStart, start: 205, end: 505 });
72
+ const endPath = arcPath({ cx: 196, cy: 204, radius: collarEnd, start: 205, end: 505 });
73
+ const roomPoint = point(196, 204, 92, 252);
74
+ const spanPoint = point(196, 204, collarEnd + 10, 318);
75
+ return `<svg class="pet-collar-fit-wheel" viewBox="0 0 720 420" role="img" aria-label="${escapeHtml(ui.sceneAria)}">
76
+ <circle class="pet-collar-wheel-paper" cx="196" cy="204" r="173" />
77
+ ${renderWheelTicks()}
78
+ <path class="pet-collar-wheel-neck" d="${neckPath}" />
79
+ <path class="pet-collar-wheel-band" d="${spanPath}" />
80
+ <path class="pet-collar-wheel-edge" d="${endPath}" />
81
+ ${renderBuckle()}
82
+ <circle class="pet-collar-wheel-center" cx="196" cy="204" r="5" />
83
+ <path class="pet-collar-wheel-leader pet-collar-wheel-leader-room" d="M${roomPoint.x.toFixed(1)} ${roomPoint.y.toFixed(1)}L92 342H36" />
84
+ <path class="pet-collar-wheel-leader pet-collar-wheel-leader-span" d="M${spanPoint.x.toFixed(1)} ${spanPoint.y.toFixed(1)}L446 286H516" />
85
+ <text class="pet-collar-wheel-label pet-collar-wheel-label-neck" x="196" y="198" text-anchor="middle">${escapeHtml(ui.neckMarker)}</text>
86
+ <text class="pet-collar-wheel-sublabel" x="196" y="220" text-anchor="middle">fit circle</text>
87
+ <text class="pet-collar-wheel-label" x="36" y="362">${escapeHtml(ui.easeMarker)}</text>
88
+ <text class="pet-collar-wheel-sublabel" x="36" y="381">+${escapeHtml(formatMeasurement(result.allowanceCm, result.unit))} ${escapeHtml(unitLabel(result.unit, ui))}</text>
89
+ <text class="pet-collar-wheel-label pet-collar-wheel-label-span" x="516" y="306">${escapeHtml(ui.rangeMarker)}</text>
90
+ <text class="pet-collar-wheel-sublabel" x="516" y="325">${escapeHtml(ui.minimumMarker)} to ${escapeHtml(ui.maximumMarker)}</text>
91
+ </svg>`;
92
+ }
93
+
94
+ function renderReading(result: PetCollarSizeResult, ui: PetCollarSizeCalculatorUI): string {
95
+ const unit = unitLabel(result.unit, ui);
96
+ return `<div class="pet-collar-reading"><span class="pet-collar-reading-label">${escapeHtml(ui.fitWindowLabel)}</span><strong>${escapeHtml(formatMeasurement(result.minimumCm, result.unit))} <small>to</small> ${escapeHtml(formatMeasurement(result.maximumCm, result.unit))}<em>${escapeHtml(unit)}</em></strong><div class="pet-collar-reading-metrics"><div><span>${escapeHtml(ui.measuredLabel)}</span><b>${escapeHtml(formatMeasurement(result.neckCm, result.unit))} ${escapeHtml(unit)}</b></div><div><span>${escapeHtml(ui.roomLabel)}</span><b>+${escapeHtml(formatMeasurement(result.allowanceCm, result.unit))} ${escapeHtml(unit)}</b></div></div></div>`;
97
+ }
98
+
99
+ export function renderScene(result: PetCollarSizeResult, ui: PetCollarSizeCalculatorUI): string {
100
+ if (!result.valid) return `<div class="pet-collar-invalid" role="status">${escapeHtml(ui.invalidInput)}</div>`;
101
+ return `<div class="pet-collar-stage"><div class="pet-collar-wheel-wrap">${renderWheel(result, ui)}<span class="pet-collar-stage-index">fit / 01</span></div>${renderReading(result, ui)}</div>`;
102
+ }
103
+
104
+ export function renderResult(result: PetCollarSizeResult, ui: PetCollarSizeCalculatorUI): string {
105
+ if (!result.valid) return `<div class="pet-collar-result-invalid" role="status">${escapeHtml(ui.invalidInput)}</div>`;
106
+ const review = getFitReview(result);
107
+ const statusLabel = ui[review.labelKey];
108
+ const statusDetail = ui[review.detailKey];
109
+ const collarType = collarTypeLabel(result.collarType, ui);
110
+ return `<div class="pet-collar-guidance"><div class="pet-collar-guidance-main"><span class="pet-collar-status pet-collar-status-${review.status}">${escapeHtml(statusLabel)}</span><span>${escapeHtml(statusDetail)}</span></div><p><span>${escapeHtml(ui.checkLabel)}</span><strong>${escapeHtml(collarType)}</strong>${escapeHtml(collarCheckText(result.collarType, ui))}</p></div>`;
111
+ }
@@ -0,0 +1,32 @@
1
+ import type { PetToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { PetCollarSizeCalculatorUI } from './ui';
3
+
4
+ export type { PetCollarSizeCalculatorUI } from './ui';
5
+ export type PetCollarSizeCalculatorLocaleContent = ToolLocaleContent<PetCollarSizeCalculatorUI>;
6
+
7
+ export const petCollarSizeCalculator: PetToolEntry<PetCollarSizeCalculatorUI> = {
8
+ id: 'pet-collar-size-calculator',
9
+ icons: {
10
+ bg: 'mdi:paw',
11
+ fg: 'mdi:link-variant',
12
+ },
13
+ i18n: {
14
+ de: () => import('./i18n/de').then((module) => module.content),
15
+ en: () => import('./i18n/en').then((module) => module.content),
16
+ es: () => import('./i18n/es').then((module) => module.content),
17
+ fr: () => import('./i18n/fr').then((module) => module.content),
18
+ id: () => import('./i18n/id').then((module) => module.content),
19
+ it: () => import('./i18n/it').then((module) => module.content),
20
+ ja: () => import('./i18n/ja').then((module) => module.content),
21
+ ko: () => import('./i18n/ko').then((module) => module.content),
22
+ nl: () => import('./i18n/nl').then((module) => module.content),
23
+ pl: () => import('./i18n/pl').then((module) => module.content),
24
+ pt: () => import('./i18n/pt').then((module) => module.content),
25
+ ru: () => import('./i18n/ru').then((module) => module.content),
26
+ sv: () => import('./i18n/sv').then((module) => module.content),
27
+ tr: () => import('./i18n/tr').then((module) => module.content),
28
+ zh: () => import('./i18n/zh').then((module) => module.content),
29
+ },
30
+ };
31
+
32
+ export { bibliography } from './bibliography';
@@ -0,0 +1,18 @@
1
+ import type { AllowanceStatus, CollarType, PetCollarSizeResult } from './logic';
2
+
3
+ export interface FitReview {
4
+ status: AllowanceStatus;
5
+ collarType: CollarType;
6
+ labelKey: 'statusTight' | 'statusBalanced' | 'statusLoose';
7
+ detailKey: 'statusTightDetail' | 'statusBalancedDetail' | 'statusLooseDetail';
8
+ }
9
+
10
+ export function getFitReview(result: PetCollarSizeResult): FitReview {
11
+ const keys = {
12
+ tight: { labelKey: 'statusTight', detailKey: 'statusTightDetail' },
13
+ balanced: { labelKey: 'statusBalanced', detailKey: 'statusBalancedDetail' },
14
+ loose: { labelKey: 'statusLoose', detailKey: 'statusLooseDetail' },
15
+ } as const;
16
+
17
+ return { status: result.status, collarType: result.collarType, ...keys[result.status] };
18
+ }