@jjlmoya/utils-pets 1.17.0 → 1.19.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.
- package/package.json +1 -1
- package/src/category/index.ts +3 -1
- package/src/data.ts +4 -0
- package/src/entries.ts +5 -1
- package/src/index.ts +3 -2
- package/src/layouts/PreviewLayout.astro +2 -0
- package/src/pages/[locale]/[slug].astro +14 -5
- package/src/tests/diacritics_density.test.ts +1 -1
- package/src/tests/faq_count.test.ts +1 -1
- package/src/tests/inverted_punctuation.test.ts +1 -1
- package/src/tests/locale_completeness.test.ts +1 -2
- package/src/tests/script_density.test.ts +1 -1
- package/src/tests/seo_translation_completeness.test.ts +69 -0
- package/src/tests/title_quality.test.ts +1 -0
- package/src/tests/tool_validation.test.ts +4 -4
- package/src/tests/translation_copy.test.ts +14 -15
- package/src/tool/petAge/index.ts +1 -0
- package/src/tool/petGestation/bibliography.astro +6 -0
- package/src/tool/petGestation/bibliography.ts +12 -0
- package/src/tool/petGestation/component.astro +68 -0
- package/src/tool/petGestation/controller.ts +69 -0
- package/src/tool/petGestation/dom-views.ts +60 -0
- package/src/tool/petGestation/entry.ts +31 -0
- package/src/tool/petGestation/evaluator.ts +28 -0
- package/src/tool/petGestation/i18n/de.ts +209 -0
- package/src/tool/petGestation/i18n/en.ts +198 -0
- package/src/tool/petGestation/i18n/es.ts +198 -0
- package/src/tool/petGestation/i18n/fr.ts +209 -0
- package/src/tool/petGestation/i18n/id.ts +209 -0
- package/src/tool/petGestation/i18n/it.ts +209 -0
- package/src/tool/petGestation/i18n/ja.ts +209 -0
- package/src/tool/petGestation/i18n/ko.ts +209 -0
- package/src/tool/petGestation/i18n/nl.ts +209 -0
- package/src/tool/petGestation/i18n/pl.ts +209 -0
- package/src/tool/petGestation/i18n/pt.ts +209 -0
- package/src/tool/petGestation/i18n/ru.ts +209 -0
- package/src/tool/petGestation/i18n/sv.ts +209 -0
- package/src/tool/petGestation/i18n/tr.ts +209 -0
- package/src/tool/petGestation/i18n/zh.ts +209 -0
- package/src/tool/petGestation/index.ts +11 -0
- package/src/tool/petGestation/logic.test.ts +44 -0
- package/src/tool/petGestation/logic.ts +103 -0
- package/src/tool/petGestation/pet-gestation-calculator.css +401 -0
- package/src/tool/petGestation/seo.astro +15 -0
- package/src/tool/petGestation/storage.ts +25 -0
- package/src/tool/petGestation/ui.ts +42 -0
- package/src/tool/petRation/component.astro +1 -1
- package/src/tool/petRation/handlers.ts +1 -1
- package/src/tool/petRation/index.ts +1 -0
- package/src/tool/petToxicity/bibliography.astro +6 -0
- package/src/tool/petToxicity/bibliography.ts +16 -0
- package/src/tool/petToxicity/component.astro +53 -0
- package/src/tool/petToxicity/controller.ts +117 -0
- package/src/tool/petToxicity/dom-views.ts +61 -0
- package/src/tool/petToxicity/entry.ts +33 -0
- package/src/tool/petToxicity/evaluator.ts +23 -0
- package/src/tool/petToxicity/i18n/de.ts +181 -0
- package/src/tool/petToxicity/i18n/en.ts +175 -0
- package/src/tool/petToxicity/i18n/es.ts +181 -0
- package/src/tool/petToxicity/i18n/fr.ts +181 -0
- package/src/tool/petToxicity/i18n/id.ts +182 -0
- package/src/tool/petToxicity/i18n/it.ts +181 -0
- package/src/tool/petToxicity/i18n/ja.ts +181 -0
- package/src/tool/petToxicity/i18n/ko.ts +181 -0
- package/src/tool/petToxicity/i18n/nl.ts +182 -0
- package/src/tool/petToxicity/i18n/pl.ts +182 -0
- package/src/tool/petToxicity/i18n/pt.ts +181 -0
- package/src/tool/petToxicity/i18n/ru.ts +182 -0
- package/src/tool/petToxicity/i18n/sv.ts +182 -0
- package/src/tool/petToxicity/i18n/tr.ts +182 -0
- package/src/tool/petToxicity/i18n/zh.ts +181 -0
- package/src/tool/petToxicity/index.ts +11 -0
- package/src/tool/petToxicity/logic.test.ts +32 -0
- package/src/tool/petToxicity/logic.ts +192 -0
- package/src/tool/petToxicity/pet-food-toxicity-checker.css +450 -0
- package/src/tool/petToxicity/seo.astro +15 -0
- package/src/tool/petToxicity/storage.ts +37 -0
- package/src/tool/petToxicity/ui.ts +33 -0
- package/src/tools.ts +6 -1
- package/src/types.ts +1 -1
package/package.json
CHANGED
package/src/category/index.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
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';
|
|
5
|
+
import { petToxicity } from '../tool/petToxicity/entry';
|
|
4
6
|
|
|
5
7
|
export const petsCategory: PetCategoryEntry = {
|
|
6
8
|
icon: 'mdi:paw',
|
|
7
|
-
tools: [petAge, petRation],
|
|
9
|
+
tools: [petAge, petRation, petGestation, petToxicity],
|
|
8
10
|
i18n: {
|
|
9
11
|
en: () => import('./i18n/en').then((m) => m.content),
|
|
10
12
|
es: () => import('./i18n/es').then((m) => m.content),
|
package/src/data.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
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';
|
|
5
|
+
export { petToxicity } from './tool/petToxicity';
|
|
4
6
|
|
|
5
7
|
export type { PetAgeUI, PetAgeLocaleContent } from './tool/petAge';
|
|
6
8
|
export type { PetRationUI, PetRationLocaleContent } from './tool/petRation';
|
|
9
|
+
export type { PetGestationUI, PetGestationLocaleContent } from './tool/petGestation';
|
|
10
|
+
export type { PetToxicityUI, PetToxicityLocaleContent } from './tool/petToxicity';
|
|
7
11
|
|
|
8
12
|
export type {
|
|
9
13
|
KnownLocale,
|
package/src/entries.ts
CHANGED
|
@@ -2,7 +2,11 @@ export { petAge } from './tool/petAge/entry';
|
|
|
2
2
|
export type { PetAgeUI, PetAgeLocaleContent } from './tool/petAge/entry';
|
|
3
3
|
export { petRation } from './tool/petRation/entry';
|
|
4
4
|
export type { PetRationUI, PetRationLocaleContent } from './tool/petRation/entry';
|
|
5
|
+
export { petToxicity } from './tool/petToxicity/entry';
|
|
6
|
+
export type { PetToxicityUI, PetToxicityLocaleContent } from './tool/petToxicity/entry';
|
|
5
7
|
export { petsCategory } from './category';
|
|
6
8
|
import { petAge } from './tool/petAge/entry';
|
|
7
9
|
import { petRation } from './tool/petRation/entry';
|
|
8
|
-
|
|
10
|
+
import { petGestation } from './tool/petGestation/entry';
|
|
11
|
+
import { petToxicity } from './tool/petToxicity/entry';
|
|
12
|
+
export const ALL_ENTRIES = [petAge, petRation, petGestation, petToxicity];
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
export
|
|
2
|
-
export
|
|
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);
|
|
@@ -15,7 +15,8 @@ export async function getStaticPaths() {
|
|
|
15
15
|
const paths = [];
|
|
16
16
|
|
|
17
17
|
for (const { entry, Component: lazyComp } of ALL_TOOLS) {
|
|
18
|
-
const { default:
|
|
18
|
+
const componentLoader = lazyComp as () => Promise<{ default: ToolComponent }>;
|
|
19
|
+
const { default: Component } = await componentLoader();
|
|
19
20
|
const localeEntries = Object.entries(entry.i18n) as [
|
|
20
21
|
KnownLocale,
|
|
21
22
|
() => Promise<ToolLocaleContent>,
|
|
@@ -71,7 +72,7 @@ interface NavItem {
|
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
interface Props {
|
|
74
|
-
Component:
|
|
75
|
+
Component: ToolComponent;
|
|
75
76
|
locale: KnownLocale;
|
|
76
77
|
content: ToolLocaleContent;
|
|
77
78
|
localeUrls: Partial<Record<KnownLocale, string>>;
|
|
@@ -79,11 +80,14 @@ interface Props {
|
|
|
79
80
|
englishSlug: string;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
|
|
83
|
+
type ToolComponent = (props: { ui: Record<string, string> }) => unknown;
|
|
84
|
+
|
|
85
|
+
const { Component, locale, content, localeUrls, allToolsNav, englishSlug } = Astro.props as Props;
|
|
83
86
|
|
|
84
87
|
const cssFiles = import.meta.glob("../../tool/*/*.css", { query: "?raw", import: "default" });
|
|
85
88
|
const cssKey = Object.keys(cssFiles).find((k) => k.endsWith(`/${englishSlug}.css`));
|
|
86
|
-
const
|
|
89
|
+
const cssLoader = cssKey ? cssFiles[cssKey] : undefined;
|
|
90
|
+
const toolCss = cssLoader ? await cssLoader() as string : "";
|
|
87
91
|
|
|
88
92
|
const seoContent: UtilitySEOContent = { locale, sections: content.seo ?? [] };
|
|
89
93
|
|
|
@@ -132,7 +136,7 @@ const titleBase = words.slice(1).join(" ") || "";
|
|
|
132
136
|
</section>
|
|
133
137
|
|
|
134
138
|
<section class="section-faq">
|
|
135
|
-
<FAQSection items={content.faq}
|
|
139
|
+
<FAQSection items={content.faq} />
|
|
136
140
|
</section>
|
|
137
141
|
|
|
138
142
|
<section class="section-bibliography">
|
|
@@ -146,17 +150,22 @@ const titleBase = words.slice(1).join(" ") || "";
|
|
|
146
150
|
display: flex;
|
|
147
151
|
flex-direction: column;
|
|
148
152
|
gap: 2rem;
|
|
153
|
+
min-width: 0;
|
|
154
|
+
width: 100%;
|
|
149
155
|
}
|
|
150
156
|
|
|
151
157
|
.section-tool {
|
|
152
158
|
max-width: 1200px;
|
|
153
159
|
margin: 0 auto;
|
|
160
|
+
min-width: 0;
|
|
154
161
|
width: 100%;
|
|
155
162
|
}
|
|
156
163
|
|
|
157
164
|
.section-seo,
|
|
158
165
|
.section-faq,
|
|
159
166
|
.section-bibliography {
|
|
167
|
+
min-width: 0;
|
|
168
|
+
width: 100%;
|
|
160
169
|
padding-top: 2rem;
|
|
161
170
|
border-top: 1px solid var(--border-color);
|
|
162
171
|
}
|
|
@@ -96,7 +96,7 @@ describe('Diacritics density validation', () => {
|
|
|
96
96
|
if (!loader) return;
|
|
97
97
|
|
|
98
98
|
const content = await loader();
|
|
99
|
-
const text = normalizeText(translatableContent(content as Record<string, unknown>));
|
|
99
|
+
const text = normalizeText(translatableContent(content as unknown as Record<string, unknown>));
|
|
100
100
|
const rule = DIACRITIC_RULES[typedLocale];
|
|
101
101
|
const letters = letterCount(text);
|
|
102
102
|
const matches = diacriticCount(text, typedLocale);
|
|
@@ -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, DATA.petToxicity];
|
|
5
5
|
|
|
6
6
|
describe('FAQ Content Validation', () => {
|
|
7
7
|
TOOLS.forEach((entry) => {
|
|
@@ -60,7 +60,7 @@ describe('Inverted punctuation validation', () => {
|
|
|
60
60
|
|
|
61
61
|
const rule = INVERTED_PUNCTUATION_LOCALES[typedLocale];
|
|
62
62
|
const content = await loader();
|
|
63
|
-
const strings = translatableStrings(content as Record<string, unknown>);
|
|
63
|
+
const strings = translatableStrings(content as unknown as Record<string, unknown>);
|
|
64
64
|
const missingQuestions = strings.flatMap((text) =>
|
|
65
65
|
findMissingInvertedMarks(text, rule.questionStart, rule.questionEnd)
|
|
66
66
|
);
|
|
@@ -73,7 +73,7 @@ describe('Native script density validation', () => {
|
|
|
73
73
|
|
|
74
74
|
const content = await loader();
|
|
75
75
|
const rule = SCRIPT_RULES[typedLocale];
|
|
76
|
-
const text = normalizeText(translatableContent(content as Record<string, unknown>));
|
|
76
|
+
const text = normalizeText(translatableContent(content as unknown as Record<string, unknown>));
|
|
77
77
|
const letters = letterCount(text);
|
|
78
78
|
const matches = scriptCount(text, typedLocale);
|
|
79
79
|
const ratio = scriptRatio(text, typedLocale);
|
|
@@ -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
|
+
|
|
@@ -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', 'buscador-alimentos-toxicos-perros-gatos'];
|
|
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
|
|
100
|
-
expect(ALL_TOOLS.length).toBe(
|
|
99
|
+
it('should have 4 tools in ALL_TOOLS', () => {
|
|
100
|
+
expect(ALL_TOOLS.length).toBe(4);
|
|
101
101
|
});
|
|
102
102
|
|
|
103
103
|
it('should have all tools in petsCategory', () => {
|
|
104
|
-
expect(petsCategory.tools.length).toBe(
|
|
104
|
+
expect(petsCategory.tools.length).toBe(4);
|
|
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);
|
|
@@ -91,6 +91,19 @@ function copySimilarity(left: string, right: string): number {
|
|
|
91
91
|
return (2 * shared) / (leftTotal + rightTotal);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
function findViolations(locales: string[], corpora: Map<string, string>): string[] {
|
|
95
|
+
const violations: string[] = [];
|
|
96
|
+
for (let leftIndex = 0; leftIndex < locales.length; leftIndex += 1) {
|
|
97
|
+
for (let rightIndex = leftIndex + 1; rightIndex < locales.length; rightIndex += 1) {
|
|
98
|
+
const left = locales[leftIndex] ?? '';
|
|
99
|
+
const right = locales[rightIndex] ?? '';
|
|
100
|
+
const similarity = copySimilarity(corpora.get(left) ?? '', corpora.get(right) ?? '');
|
|
101
|
+
if (similarity >= COPY_THRESHOLD) violations.push(`${left} ↔ ${right}: ${(similarity * 100).toFixed(1)}%`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return violations;
|
|
105
|
+
}
|
|
106
|
+
|
|
94
107
|
describe('Locales must not copy another locale wholesale', () => {
|
|
95
108
|
ALL_ENTRIES.forEach((entry) => {
|
|
96
109
|
it(`${entry.id} is not at least ${COPY_THRESHOLD * 100}% identical to another locale`, async () => {
|
|
@@ -102,23 +115,9 @@ describe('Locales must not copy another locale wholesale', () => {
|
|
|
102
115
|
}
|
|
103
116
|
|
|
104
117
|
const locales = [...corpora.keys()];
|
|
105
|
-
const violations
|
|
106
|
-
|
|
107
|
-
for (let leftIndex = 0; leftIndex < locales.length; leftIndex += 1) {
|
|
108
|
-
for (let rightIndex = leftIndex + 1; rightIndex < locales.length; rightIndex += 1) {
|
|
109
|
-
const left = locales[leftIndex];
|
|
110
|
-
const right = locales[rightIndex];
|
|
111
|
-
const similarity = copySimilarity(corpora.get(left) ?? '', corpora.get(right) ?? '');
|
|
112
|
-
|
|
113
|
-
if (similarity >= COPY_THRESHOLD) {
|
|
114
|
-
violations.push(`${left} ↔ ${right}: ${(similarity * 100).toFixed(1)}%`);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
+
const violations = findViolations(locales, corpora);
|
|
118
119
|
|
|
119
120
|
expect(violations, `Locale copy threshold exceeded in ${entry.id}`).toEqual([]);
|
|
120
121
|
});
|
|
121
122
|
});
|
|
122
123
|
});
|
|
123
|
-
|
|
124
|
-
|
package/src/tool/petAge/index.ts
CHANGED
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
+
}
|