@jjlmoya/utils-pets 1.16.0 → 1.18.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 (45) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/data.ts +2 -0
  4. package/src/entries.ts +2 -1
  5. package/src/index.ts +3 -2
  6. package/src/tests/faq_count.test.ts +1 -1
  7. package/src/tests/locale_completeness.test.ts +1 -2
  8. package/src/tests/seo_translation_completeness.test.ts +69 -0
  9. package/src/tests/spanish_leakage.test.ts +25 -3
  10. package/src/tests/tool_validation.test.ts +4 -4
  11. package/src/tool/petAge/index.ts +1 -0
  12. package/src/tool/petGestation/bibliography.astro +6 -0
  13. package/src/tool/petGestation/bibliography.ts +12 -0
  14. package/src/tool/petGestation/component.astro +68 -0
  15. package/src/tool/petGestation/controller.ts +69 -0
  16. package/src/tool/petGestation/dom-views.ts +60 -0
  17. package/src/tool/petGestation/entry.ts +31 -0
  18. package/src/tool/petGestation/evaluator.ts +28 -0
  19. package/src/tool/petGestation/i18n/de.ts +209 -0
  20. package/src/tool/petGestation/i18n/en.ts +197 -0
  21. package/src/tool/petGestation/i18n/es.ts +197 -0
  22. package/src/tool/petGestation/i18n/fr.ts +209 -0
  23. package/src/tool/petGestation/i18n/id.ts +209 -0
  24. package/src/tool/petGestation/i18n/it.ts +209 -0
  25. package/src/tool/petGestation/i18n/ja.ts +209 -0
  26. package/src/tool/petGestation/i18n/ko.ts +209 -0
  27. package/src/tool/petGestation/i18n/nl.ts +209 -0
  28. package/src/tool/petGestation/i18n/pl.ts +209 -0
  29. package/src/tool/petGestation/i18n/pt.ts +209 -0
  30. package/src/tool/petGestation/i18n/ru.ts +209 -0
  31. package/src/tool/petGestation/i18n/sv.ts +209 -0
  32. package/src/tool/petGestation/i18n/tr.ts +209 -0
  33. package/src/tool/petGestation/i18n/zh.ts +209 -0
  34. package/src/tool/petGestation/index.ts +11 -0
  35. package/src/tool/petGestation/logic.test.ts +44 -0
  36. package/src/tool/petGestation/logic.ts +103 -0
  37. package/src/tool/petGestation/pet-gestation-calculator.css +401 -0
  38. package/src/tool/petGestation/seo.astro +15 -0
  39. package/src/tool/petGestation/storage.ts +25 -0
  40. package/src/tool/petGestation/ui.ts +42 -0
  41. package/src/tool/petRation/component.astro +1 -1
  42. package/src/tool/petRation/handlers.ts +1 -1
  43. package/src/tool/petRation/index.ts +1 -0
  44. package/src/tools.ts +3 -1
  45. package/src/types.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-pets",
3
- "version": "1.16.0",
3
+ "version": "1.18.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -1,10 +1,11 @@
1
1
  import type { PetCategoryEntry } from '../types';
2
2
  import { petAge } from '../tool/petAge/entry';
3
3
  import { petRation } from '../tool/petRation/entry';
4
+ import { petGestation } from '../tool/petGestation/entry';
4
5
 
5
6
  export const petsCategory: PetCategoryEntry = {
6
7
  icon: 'mdi:paw',
7
- tools: [petAge, petRation],
8
+ tools: [petAge, petRation, petGestation],
8
9
  i18n: {
9
10
  en: () => import('./i18n/en').then((m) => m.content),
10
11
  es: () => import('./i18n/es').then((m) => m.content),
package/src/data.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  export { petsCategory } from './category';
2
2
  export { petAge } from './tool/petAge';
3
3
  export { petRation } from './tool/petRation';
4
+ export { petGestation } from './tool/petGestation';
4
5
 
5
6
  export type { PetAgeUI, PetAgeLocaleContent } from './tool/petAge';
6
7
  export type { PetRationUI, PetRationLocaleContent } from './tool/petRation';
8
+ export type { PetGestationUI, PetGestationLocaleContent } from './tool/petGestation';
7
9
 
8
10
  export type {
9
11
  KnownLocale,
package/src/entries.ts CHANGED
@@ -5,4 +5,5 @@ export type { PetRationUI, PetRationLocaleContent } from './tool/petRation/entry
5
5
  export { petsCategory } from './category';
6
6
  import { petAge } from './tool/petAge/entry';
7
7
  import { petRation } from './tool/petRation/entry';
8
- export const ALL_ENTRIES = [petAge, petRation];
8
+ import { petGestation } from './tool/petGestation/entry';
9
+ export const ALL_ENTRIES = [petAge, petRation, petGestation];
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
- export * from './tool/petAge';
2
- export * from './tool/petRation';
1
+ export { petAge, PET_AGE_TOOL } from './tool/petAge';
2
+ export { petRation, PET_RATION_TOOL } from './tool/petRation';
3
+ export { petGestation, PET_GESTATION_TOOL } from './tool/petGestation';
3
4
 
4
5
  export { petsCategory } from './category';
5
6
  export const PetsCategorySEO = () => import('./category/seo.astro').then((m) => m.default);
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import * as DATA from '../data';
3
3
 
4
- const TOOLS = [DATA.petAge, DATA.petRation];
4
+ const TOOLS = [DATA.petAge, DATA.petRation, DATA.petGestation];
5
5
 
6
6
  describe('FAQ Content Validation', () => {
7
7
  TOOLS.forEach((entry) => {
@@ -26,7 +26,6 @@ describe('Locale Completeness Validation', () => {
26
26
  });
27
27
 
28
28
  it('all tools registered', () => {
29
- expect(ALL_TOOLS.length).toBe(2);
29
+ expect(ALL_TOOLS.length).toBe(3);
30
30
  });
31
31
  });
32
-
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+
4
+ const ASIAN_LOCALES = ['ja', 'ko', 'zh'];
5
+
6
+ function getSimpleSectionLength(section: any): number {
7
+ if (section.type === 'title') return section.text ? section.text.length : 0;
8
+ if (section.type === 'paragraph') return section.html ? section.html.replace(/<[^>]*>/g, '').length : 0;
9
+ if (section.type === 'list') return section.items ? section.items.join('').length : 0;
10
+ return 0;
11
+ }
12
+
13
+ function getComplexSectionLength(section: any): number {
14
+ if (section.type === 'diagnostic' || section.type === 'tip') {
15
+ return (section.title ? section.title.length : 0) + (section.html ? section.html.replace(/<[^>]*>/g, '').length : 0);
16
+ }
17
+ if (section.type === 'table') {
18
+ return ((section.headers || []).join('').length) + ((section.rows || []).flat().join('').length);
19
+ }
20
+ return 0;
21
+ }
22
+
23
+ function getSectionLength(section: any): number {
24
+ return getSimpleSectionLength(section) || getComplexSectionLength(section);
25
+ }
26
+
27
+ function calculateSeoTextLength(seoSections: any[]): number {
28
+ return seoSections.reduce((acc, section) => acc + getSectionLength(section), 0);
29
+ }
30
+
31
+ function checkLocaleSeoLength(toolId: string, locale: string, localeLen: number, enLen: number): void {
32
+ const isAsian = ASIAN_LOCALES.includes(locale);
33
+ const minLength = Math.floor(enLen * (isAsian ? 0.25 : 0.70));
34
+ const msgType = isAsian ? 'suspiciously short' : 'truncated/lazy';
35
+
36
+ expect(
37
+ localeLen,
38
+ `[LAZY SEO TRANSLATION] Tool "${toolId}" locale "${locale}" SEO text is ${msgType} (${localeLen} chars vs EN ${enLen} chars, expected min ${minLength})`,
39
+ ).toBeGreaterThanOrEqual(minLength);
40
+ }
41
+
42
+ async function auditSingleLocale(toolId: string, locale: string, loader: any, enSeoLength: number): Promise<void> {
43
+ if (locale === 'en' || !loader) return;
44
+ const content = await loader();
45
+ if (!content.seo || !Array.isArray(content.seo)) return;
46
+
47
+ checkLocaleSeoLength(toolId, locale, calculateSeoTextLength(content.seo), enSeoLength);
48
+ }
49
+
50
+ describe('SEO Translation Completeness & Laziness Audit', () => {
51
+ ALL_TOOLS.forEach(({ entry }) => {
52
+ describe(`Tool: ${entry.id}`, () => {
53
+ it('should not have lazy or truncated SEO content compared to English reference', async () => {
54
+ const enLoader = entry.i18n['en' as keyof typeof entry.i18n];
55
+ if (!enLoader) return;
56
+
57
+ const enContent = await enLoader();
58
+ if (!enContent.seo || !Array.isArray(enContent.seo)) return;
59
+
60
+ const enSeoLength = calculateSeoTextLength(enContent.seo);
61
+
62
+ for (const [locale, loader] of Object.entries(entry.i18n)) {
63
+ await auditSingleLocale(entry.id, locale, loader, enSeoLength);
64
+ }
65
+ });
66
+ });
67
+ });
68
+ });
69
+
@@ -28,6 +28,8 @@ const TRANSLATABLE_KEYS = [
28
28
  'schemas',
29
29
  ] as const;
30
30
 
31
+ const COPY_THRESHOLD = 0.9;
32
+
31
33
  const SPANISH_MARKERS = [
32
34
  ['sangre', /\bsangre\b/gi],
33
35
  ['molino', /\bmolino\b/gi],
@@ -65,12 +67,16 @@ function normalize(value: string): string {
65
67
  }
66
68
 
67
69
  function isTechnicalInvariant(text: string): boolean {
70
+ const ledUnitMatches = text.match(/\b\d+(?:-\d+)?\s*(?:w|lm)\b/g) ?? [];
68
71
  return [
69
72
  'data:image/svg+xml;base64',
70
73
  'background-image: url',
71
74
  '.layout-playground {',
72
75
  'const samplerate =',
73
- ].some((pattern) => text.includes(pattern)) || (text.includes('presets') && text.includes('hz'));
76
+ ].some((pattern) => text.includes(pattern)) ||
77
+ (text.includes('presets') && text.includes('hz')) ||
78
+ (text.includes('t_rectal') && text.includes('exp(-k * t)')) ||
79
+ (text.includes('led') && ledUnitMatches.length >= 3);
74
80
  }
75
81
 
76
82
  function collectString(value: string, output: string[]): void {
@@ -111,10 +117,26 @@ function findSpanishMarkers(text: string[]): string[] {
111
117
  );
112
118
  }
113
119
 
120
+ function similarity(left: string, right: string): number {
121
+ const leftTokens = left.split(/\s+/);
122
+ const rightCounts = new Map<string, number>();
123
+ right.split(/\s+/).forEach((token) => rightCounts.set(token, (rightCounts.get(token) ?? 0) + 1));
124
+ const matches = leftTokens.reduce((total, token) => {
125
+ const count = rightCounts.get(token) ?? 0;
126
+ if (count > 0) rightCounts.set(token, count - 1);
127
+ return total + (count > 0 ? 1 : 0);
128
+ }, 0);
129
+ return (2 * matches) / (leftTokens.length + right.split(/\s+/).length);
130
+ }
131
+
114
132
  function findCopiedFragments(spanish: string[], translated: string[]): string[] {
115
- const corpus = translated.join(' ');
116
133
  return spanish
117
- .filter((fragment) => fragment.length >= 80 && corpus.includes(fragment))
134
+ .filter((fragment) => fragment.length >= 80)
135
+ .filter((fragment) => translated.some((candidate) =>
136
+ candidate.length >= 80 &&
137
+ Math.min(fragment.length, candidate.length) / Math.max(fragment.length, candidate.length) >= COPY_THRESHOLD &&
138
+ similarity(fragment, candidate) >= COPY_THRESHOLD,
139
+ ))
118
140
  .sort((a, b) => b.length - a.length)
119
141
  .slice(0, 3);
120
142
  }
@@ -71,7 +71,7 @@ describe('Tool Validation Suite', () => {
71
71
  expect(content.slug).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
72
72
 
73
73
  if (locale === 'es') {
74
- const validSlugs = ['calculadora-edad-mascotas', 'calculadora-racion-diaria-mascotas'];
74
+ const validSlugs = ['calculadora-edad-mascotas', 'calculadora-racion-diaria-mascotas', 'calculadora-gestacion-mascotas'];
75
75
  expect(validSlugs).toContain(content.slug);
76
76
  }
77
77
  });
@@ -96,12 +96,12 @@ describe('Tool Validation Suite', () => {
96
96
  });
97
97
 
98
98
  describe('Library Registration', () => {
99
- it('should have 2 tools in ALL_TOOLS', () => {
100
- expect(ALL_TOOLS.length).toBe(2);
99
+ it('should have 3 tools in ALL_TOOLS', () => {
100
+ expect(ALL_TOOLS.length).toBe(3);
101
101
  });
102
102
 
103
103
  it('should have all tools in petsCategory', () => {
104
- expect(petsCategory.tools.length).toBe(2);
104
+ expect(petsCategory.tools.length).toBe(3);
105
105
  ALL_TOOLS.forEach(({ entry }) => {
106
106
  const exists = petsCategory.tools.some((t: any) => t.id === entry.id);
107
107
  expect(exists).toBe(true);
@@ -1,3 +1,4 @@
1
+ import type { ToolDefinition } from '../../types';
1
2
  import { petAge } from './entry';
2
3
  export * from './entry';
3
4
  export const PET_AGE_TOOL: ToolDefinition = {
@@ -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: 'MSD Veterinary Manual: Whelping and Queening in Bitches and Queens',
6
+ url: 'https://www.msdvetmanual.com/management-and-nutrition/management-of-reproduction-dogs-and-cats/whelping-and-queening-in-bitches-and-queens',
7
+ },
8
+ {
9
+ name: 'MSD Veterinary Manual: Approximate Gestation Periods',
10
+ url: 'https://www.msdvetmanual.com/veterinary/multimedia/table/approximate-gestation-periods',
11
+ },
12
+ ];
@@ -0,0 +1,68 @@
1
+ ---
2
+ import { Icon } from 'astro-icon/components';
3
+ import type { PetGestationUI } from './index';
4
+
5
+ interface Props {
6
+ ui: PetGestationUI;
7
+ }
8
+
9
+ const { ui } = Astro.props;
10
+ ---
11
+
12
+ <div class="pet-gestation-tool" data-gestation-root>
13
+ <script is:inline type="application/json" data-gestation-ui set:html={JSON.stringify(ui)}></script>
14
+ <div class="gestation-tool-intro">
15
+ <p class="gestation-journey">{ui.journeyHint}</p>
16
+ <p class="gestation-instant-hint">{ui.instantHint}</p>
17
+ </div>
18
+ <div class="gestation-form">
19
+ <fieldset class="gestation-species-fieldset">
20
+ <legend>{ui.speciesLegend}</legend>
21
+ <div class="gestation-species-grid">
22
+ <button type="button" data-species="dog" aria-pressed="true"><Icon name="mdi:dog-side" /><span>{ui.speciesDog}</span><small>{ui.speciesDogMeta}</small></button>
23
+ <button type="button" data-species="cat" aria-pressed="false"><Icon name="mdi:cat" /><span>{ui.speciesCat}</span><small>{ui.speciesCatMeta}</small></button>
24
+ <button type="button" data-species="rabbit" aria-pressed="false"><Icon name="mdi:rabbit" /><span>{ui.speciesRabbit}</span><small>{ui.speciesRabbitMeta}</small></button>
25
+ <button type="button" data-species="ferret" aria-pressed="false"><Icon name="mdi:rodent" /><span>{ui.speciesFerret}</span><small>{ui.speciesFerretMeta}</small></button>
26
+ </div>
27
+ </fieldset>
28
+ <div class="gestation-date-block">
29
+ <label for="gestation-mating-date">{ui.matingDateLabel}</label>
30
+ <input id="gestation-mating-date" data-mating-date type="date" required />
31
+ <span>{ui.matingDateHint}</span>
32
+ <div class="gestation-presets" aria-label="Quick dates">
33
+ <button type="button" data-offset="0">{ui.presetToday}</button>
34
+ <button type="button" data-offset="-7">{ui.presetWeekAgo}</button>
35
+ <button type="button" data-offset="-14">{ui.presetFortnightAgo}</button>
36
+ </div>
37
+ </div>
38
+ <p class="gestation-error" data-error role="alert"></p>
39
+ </div>
40
+ <section class="gestation-result" data-result aria-live="polite">
41
+ <div class="gestation-result-ribbon">{ui.sampleLabel}</div>
42
+ <div class="gestation-scene" data-scene>
43
+ <div class="gestation-scene-placeholder">{ui.resultEmpty}</div>
44
+ </div>
45
+ <div class="gestation-result-copy">
46
+ <p class="gestation-eyebrow">{ui.resultEyebrow}</p>
47
+ <p class="gestation-status" data-status></p>
48
+ <p class="gestation-status-detail" data-status-detail></p>
49
+ <div class="gestation-facts">
50
+ <div><span>{ui.dueDateLabel}</span><strong data-due-date></strong></div>
51
+ <div><span>{ui.windowLabel}</span><strong data-window-date></strong></div>
52
+ <div><span>{ui.remainingLabel}</span><strong data-remaining></strong></div>
53
+ <div><span>{ui.elapsedLabel}</span><strong data-elapsed></strong></div>
54
+ </div>
55
+ </div>
56
+ </section>
57
+ <aside class="gestation-note">
58
+ <strong>{ui.noteTitle}</strong>
59
+ <span>{ui.noteText}</span>
60
+ </aside>
61
+ <p class="gestation-source-note"><strong>{ui.sourceTitle}</strong> {ui.sourceText}</p>
62
+ </div>
63
+
64
+ <script>
65
+ import { initGestationController } from './controller';
66
+ const root = document.querySelector<HTMLElement>('[data-gestation-root]');
67
+ if (root) initGestationController(root);
68
+ </script>
@@ -0,0 +1,69 @@
1
+ import { calculateGestation, getDateOffset, type GestationSpecies } from './logic';
2
+ import { renderEvaluation, renderScene } from './dom-views';
3
+ import { loadGestationState, saveGestationState } from './storage';
4
+ import type { PetGestationUI } from './ui';
5
+
6
+ interface ControllerState {
7
+ species: GestationSpecies;
8
+ matingDate: string;
9
+ }
10
+
11
+ function readUI(root: HTMLElement): PetGestationUI {
12
+ const script = root.querySelector<HTMLScriptElement>('[data-gestation-ui]');
13
+ return JSON.parse(script?.textContent || '{}') as PetGestationUI;
14
+ }
15
+
16
+ function setSpecies(root: HTMLElement, species: GestationSpecies): void {
17
+ root.querySelectorAll<HTMLButtonElement>('[data-species]').forEach((button) => {
18
+ const active = button.dataset.species === species;
19
+ button.classList.toggle('is-active', active);
20
+ button.setAttribute('aria-pressed', String(active));
21
+ });
22
+ }
23
+
24
+ function calculate(root: HTMLElement, state: ControllerState, ui: PetGestationUI): void {
25
+ const dateInput = root.querySelector<HTMLInputElement>('[data-mating-date]');
26
+ const error = root.querySelector<HTMLElement>('[data-error]');
27
+ if (!dateInput?.value) {
28
+ if (error) error.textContent = ui.invalidDate;
29
+ return;
30
+ }
31
+ try {
32
+ state.matingDate = dateInput.value;
33
+ const result = calculateGestation(state);
34
+ saveGestationState(state);
35
+ if (error) error.textContent = '';
36
+ renderScene(root.querySelector<HTMLElement>('[data-scene]') as HTMLElement, result, ui);
37
+ renderEvaluation(root, result, ui);
38
+ } catch {
39
+ if (error) error.textContent = ui.invalidDate;
40
+ }
41
+ }
42
+
43
+ export function initGestationController(root: HTMLElement): void {
44
+ const ui = readUI(root);
45
+ const stored = loadGestationState();
46
+ const state: ControllerState = {
47
+ species: stored.species || 'dog',
48
+ matingDate: stored.matingDate || '',
49
+ };
50
+ const dateInput = root.querySelector<HTMLInputElement>('[data-mating-date]');
51
+ setSpecies(root, state.species);
52
+ if (dateInput) dateInput.value = state.matingDate || getDateOffset(-14);
53
+ root.querySelectorAll<HTMLButtonElement>('[data-species]').forEach((button) => {
54
+ button.addEventListener('click', () => {
55
+ state.species = (button.dataset.species || 'dog') as GestationSpecies;
56
+ setSpecies(root, state.species);
57
+ saveGestationState(state);
58
+ calculate(root, state, ui);
59
+ });
60
+ });
61
+ root.querySelectorAll<HTMLButtonElement>('[data-offset]').forEach((button) => {
62
+ button.addEventListener('click', () => {
63
+ if (dateInput) dateInput.value = getDateOffset(Number(button.dataset.offset || 0));
64
+ calculate(root, state, ui);
65
+ });
66
+ });
67
+ dateInput?.addEventListener('input', () => calculate(root, state, ui));
68
+ calculate(root, state, ui);
69
+ }
@@ -0,0 +1,60 @@
1
+ import type { GestationResult } from './logic';
2
+ import type { PetGestationUI } from './ui';
3
+ import { evaluateGestation } from './evaluator';
4
+
5
+ function escapeText(value: string): string {
6
+ return value.replace(/[&<>"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[character] || character));
7
+ }
8
+
9
+ function formatDate(value: string): string {
10
+ return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }).format(new Date(`${value}T00:00:00Z`));
11
+ }
12
+
13
+ function sceneMarkup(result: GestationResult, ui: PetGestationUI): string {
14
+ const markerY = 142 - Math.round(result.progress * 94);
15
+ const windowTop = 142 - Math.min(94, Math.round(result.progress * 94) + 14);
16
+ return `<svg viewBox="0 0 280 190" role="img" aria-label="${escapeText(ui.sceneToday)}" class="gestation-nest-scene">
17
+ <path class="scene-reed" d="M140 151 C136 126 144 101 137 77 C132 58 142 38 140 27" />
18
+ <path class="scene-window" d="M125 ${windowTop} Q140 ${windowTop - 5} 155 ${windowTop} L155 ${Math.min(143, windowTop + 22)} Q140 ${Math.min(148, windowTop + 27)} 125 ${Math.min(143, windowTop + 22)} Z" />
19
+ <circle class="scene-marker" cx="140" cy="${markerY}" r="7" />
20
+ <circle class="scene-marker-ring" cx="140" cy="${markerY}" r="12" />
21
+ <path class="scene-nest" d="M66 145 Q140 177 214 145 Q207 174 140 181 Q73 174 66 145 Z" />
22
+ <path class="scene-nest-line" d="M83 153 Q140 169 197 153 M94 162 Q140 175 186 162" />
23
+ <ellipse class="scene-egg scene-egg-left" cx="123" cy="151" rx="13" ry="18" />
24
+ <ellipse class="scene-egg scene-egg-right" cx="157" cy="151" rx="13" ry="18" />
25
+ <text x="140" y="18" class="scene-label">${escapeText(ui.sceneMating)}</text>
26
+ <text x="179" y="58" class="scene-label scene-label-accent scene-label-right">${escapeText(ui.sceneDue)}</text>
27
+ <text x="140" y="104" class="scene-day">${result.elapsedDays < 0 ? '0' : result.elapsedDays}</text>
28
+ <text x="140" y="117" class="scene-caption">${escapeText(ui.elapsedLabel)}</text>
29
+ </svg>`;
30
+ }
31
+
32
+ export function renderScene(element: HTMLElement, result: GestationResult, ui: PetGestationUI): void {
33
+ element.innerHTML = sceneMarkup(result, ui);
34
+ }
35
+
36
+ function setText(root: HTMLElement, selector: string, value: string): void {
37
+ const element = root.querySelector<HTMLElement>(selector);
38
+ if (element) element.textContent = value;
39
+ }
40
+
41
+ function setStatus(root: HTMLElement, result: GestationResult, ui: PetGestationUI): void {
42
+ const evaluation = evaluateGestation(result, ui);
43
+ const status = root.querySelector<HTMLElement>('[data-status]');
44
+ if (!status) return;
45
+ status.textContent = evaluation.label;
46
+ status.dataset.tone = evaluation.tone;
47
+ setText(root, '[data-status-detail]', evaluation.detail);
48
+ }
49
+
50
+ export function renderEvaluation(root: HTMLElement, result: GestationResult, ui: PetGestationUI): void {
51
+ const remainingUnit = result.remainingDays === 1 ? ui.dayLabel : ui.daysLabel;
52
+ const elapsedUnit = result.elapsedDays === 1 ? ui.dayLabel : ui.daysLabel;
53
+ setText(root, '[data-due-date]', formatDate(result.dueDate));
54
+ setText(root, '[data-window-date]', `${formatDate(result.windowStart)} to ${formatDate(result.windowEnd)}`);
55
+ setText(root, '[data-remaining]', `${Math.max(0, result.remainingDays)} ${remainingUnit}`);
56
+ setText(root, '[data-elapsed]', `${Math.max(0, result.elapsedDays)} ${elapsedUnit}`);
57
+ setStatus(root, result, ui);
58
+ const resultPanel = root.querySelector<HTMLElement>('[data-result]');
59
+ if (resultPanel) resultPanel.hidden = false;
60
+ }
@@ -0,0 +1,31 @@
1
+ import type { PetToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { PetGestationUI } from './ui';
3
+
4
+ export type { PetGestationUI } from './ui';
5
+
6
+ export type PetGestationLocaleContent = ToolLocaleContent<PetGestationUI>;
7
+
8
+ export const petGestation: PetToolEntry<PetGestationUI> = {
9
+ id: 'pet-gestation',
10
+ icons: {
11
+ bg: 'mdi:paw',
12
+ fg: 'mdi:calendar-heart',
13
+ },
14
+ i18n: {
15
+ de: () => import('./i18n/de').then((m) => m.content),
16
+ en: () => import('./i18n/en').then((m) => m.content),
17
+ es: () => import('./i18n/es').then((m) => m.content),
18
+ fr: () => import('./i18n/fr').then((m) => m.content),
19
+ id: () => import('./i18n/id').then((m) => m.content),
20
+ it: () => import('./i18n/it').then((m) => m.content),
21
+ ja: () => import('./i18n/ja').then((m) => m.content),
22
+ ko: () => import('./i18n/ko').then((m) => m.content),
23
+ nl: () => import('./i18n/nl').then((m) => m.content),
24
+ pl: () => import('./i18n/pl').then((m) => m.content),
25
+ pt: () => import('./i18n/pt').then((m) => m.content),
26
+ ru: () => import('./i18n/ru').then((m) => m.content),
27
+ sv: () => import('./i18n/sv').then((m) => m.content),
28
+ tr: () => import('./i18n/tr').then((m) => m.content),
29
+ zh: () => import('./i18n/zh').then((m) => m.content),
30
+ },
31
+ };
@@ -0,0 +1,28 @@
1
+ import type { PetGestationUI } from './ui';
2
+ import { GESTATION_PROFILES, type GestationResult } from './logic';
3
+
4
+ export interface GestationEvaluation {
5
+ label: string;
6
+ detail: string;
7
+ tone: 'future' | 'quiet' | 'ready' | 'late';
8
+ }
9
+
10
+ function dayText(value: number, ui: PetGestationUI): string {
11
+ const absolute = Math.abs(value);
12
+ const unit = absolute === 1 ? ui.dayLabel : ui.daysLabel;
13
+ return `${absolute} ${unit}`;
14
+ }
15
+
16
+ export function evaluateGestation(result: GestationResult, ui: PetGestationUI): GestationEvaluation {
17
+ if (result.state === 'future') {
18
+ return { label: ui.statusFuture, detail: `${dayText(result.elapsedDays, ui)} before the mating date`, tone: 'future' };
19
+ }
20
+ if (result.state === 'waiting') {
21
+ return { label: ui.statusWaiting, detail: `${dayText(result.remainingDays, ui)} until the typical due date`, tone: 'quiet' };
22
+ }
23
+ if (result.state === 'window') {
24
+ return { label: ui.statusWindow, detail: 'The expected delivery window is open', tone: 'ready' };
25
+ }
26
+ const outerEstimate = GESTATION_PROFILES[result.species].maxDays;
27
+ return { label: ui.statusLate, detail: `${dayText(result.elapsedDays - outerEstimate, ui)} beyond the outer estimate`, tone: 'late' };
28
+ }