///
/**
* Age Category Module - Pattern for categorizing age groups
*
* A composable pattern that can be used standalone or embedded in containers
* like Record. Provides two-tier categorization: Adult/Child with optional
* specific subcategories (Senior, Young Adult, Teenager, etc.).
*
* Design: Single enum prevents invalid states (e.g., adult + baby subcategory).
*/
import {
Cell,
computed,
type Default,
handler,
NAME,
recipe,
UI,
} from "commontools";
import type { ModuleMetadata } from "./container-protocol.ts";
// ===== Types =====
/**
* Single enum for age categories - invalid states are impossible by construction.
* "adult" and "child" are general categories without specific subcategory.
*/
export type AgeCategory =
| "adult"
| "child" // General categories
| "senior"
| "adult-specific"
| "young-adult" // Adult subcategories
| "teenager"
| "school-age"
| "toddler"
| "baby"; // Child subcategories
/**
* Category metadata with age ranges for inference and display.
*/
export const AGE_CATEGORY_INFO = {
// General categories
adult: { label: "Adult (general)", ageMin: 18, ageMax: 150, isAdult: true },
child: { label: "Child (general)", ageMin: 0, ageMax: 17, isAdult: false },
// Adult subcategories
senior: { label: "Senior", ageMin: 65, ageMax: 150, isAdult: true },
"adult-specific": { label: "Adult", ageMin: 26, ageMax: 64, isAdult: true },
"young-adult": {
label: "Young Adult",
ageMin: 18,
ageMax: 25,
isAdult: true,
},
// Child subcategories
teenager: { label: "Teenager", ageMin: 13, ageMax: 17, isAdult: false },
"school-age": { label: "School-age", ageMin: 5, ageMax: 12, isAdult: false },
toddler: { label: "Toddler", ageMin: 1, ageMax: 4, isAdult: false },
baby: { label: "Baby", ageMin: 0, ageMax: 1, isAdult: false },
} as const;
/**
* Helper to determine if a category belongs to the Adult group.
*/
export const isAdultCategory = (cat: AgeCategory): boolean =>
AGE_CATEGORY_INFO[cat]?.isAdult ?? true;
// ===== Self-Describing Metadata =====
export const MODULE_METADATA: ModuleMetadata = {
type: "age-category",
label: "Age Category",
icon: "\u{1F464}", // bust silhouette emoji
schema: {
ageCategory: {
type: "string",
enum: [
"adult",
"child",
"senior",
"adult-specific",
"young-adult",
"teenager",
"school-age",
"toddler",
"baby",
],
description: "Age category",
},
},
fieldMapping: ["ageCategory"],
};
// ===== Interface =====
export interface AgeCategoryModuleInput {
ageCategory: Default;
}
// ===== Constants =====
const ADULT_CATEGORY_OPTIONS = [
{ value: "adult", label: "Adult (general)" },
{ value: "senior", label: "Senior (65+)" },
{ value: "adult-specific", label: "Adult (26-64)" },
{ value: "young-adult", label: "Young Adult (18-25)" },
];
const CHILD_CATEGORY_OPTIONS = [
{ value: "child", label: "Child (general)" },
{ value: "teenager", label: "Teenager (13-17)" },
{ value: "school-age", label: "School-age (5-12)" },
{ value: "toddler", label: "Toddler (1-4)" },
{ value: "baby", label: "Baby (0-1)" },
];
// ===== Utility Functions =====
/**
* Calculate current age from birth year and optional birth date.
* Handles MM-DD and YYYY-MM-DD formats.
*/
export function calculateAge(
birthYear: number | null,
birthDate?: string,
): number | null {
if (!birthYear) return null;
const today = new Date();
const currentYear = today.getFullYear();
let age = currentYear - birthYear;
// If we have MM-DD or YYYY-MM-DD, check if birthday has passed this year
if (birthDate) {
const match = birthDate.match(/(\d{2})-(\d{2})$/);
if (match) {
const [, month, day] = match;
const birthdayThisYear = new Date(
currentYear,
parseInt(month) - 1,
parseInt(day),
);
if (today < birthdayThisYear) {
age--;
}
}
}
return age;
}
/**
* Infer age category from a given age.
*/
export function inferCategoryFromAge(age: number): AgeCategory {
if (age >= 65) return "senior";
if (age >= 26) return "adult-specific";
if (age >= 18) return "young-adult";
if (age >= 13) return "teenager";
if (age >= 5) return "school-age";
if (age >= 1) return "toddler";
return "baby";
}
// ===== Handlers =====
// Handle age group radio change
const handleGroupChange = handler<
{ detail: { value: string } },
{ ageCategory: Cell }
>(({ detail }, { ageCategory }) => {
const newGroup = detail.value;
const current = ageCategory.get();
const currentIsAdult = isAdultCategory(current);
// Only change if switching groups
if (newGroup === "adult" && !currentIsAdult) {
ageCategory.set("adult");
} else if (newGroup === "child" && currentIsAdult) {
ageCategory.set("child");
}
});
// ===== The Pattern =====
export const AgeCategoryModule = recipe<
AgeCategoryModuleInput,
AgeCategoryModuleInput
>("AgeCategoryModule", ({ ageCategory }) => {
// Compute whether current category is in Adult group
const currentIsAdult = computed(() => isAdultCategory(ageCategory));
// Compute display text for NAME
const displayText = computed(() => {
const cat = ageCategory as AgeCategory;
const info = AGE_CATEGORY_INFO[cat];
return info?.label || "Adult";
});
// Get current category options based on group
const categoryOptions = computed(() =>
currentIsAdult ? ADULT_CATEGORY_OPTIONS : CHILD_CATEGORY_OPTIONS
);
// Compute current group for radio display
const currentGroup = computed(() => (currentIsAdult ? "adult" : "child"));
// Age group options for radio
const ageGroupOptions = [
{ label: "Adult", value: "adult" },
{ label: "Child", value: "child" },
];
return {
[NAME]: computed(() => `${MODULE_METADATA.icon} Age: ${displayText}`),
[UI]: (
{/* Main category toggle */}
{/* Specific category selection */}
),
ageCategory,
};
});
export default AgeCategoryModule;