/// /** * Occurrence Tracker Module - Track timestamped events * * A composable pattern for tracking when things happen (medication, baby feedings, * exercise, etc.). Shows last occurrence, stats, and expandable history. * Works standalone or embedded in Record containers. */ import { computed, type Default, handler, ifElse, lift, NAME, pattern, UI, Writable, } from "commontools"; import type { ModuleMetadata } from "./container-protocol.ts"; // ===== Self-Describing Metadata ===== export const MODULE_METADATA: ModuleMetadata = { type: "occurrence-tracker", label: "Occurrence Tracker", icon: "📍", // pushpin allowMultiple: true, schema: { label: { type: "string", description: "What is being tracked" }, occurrences: { type: "array", items: { type: "object", properties: { timestamp: { type: "string", format: "date-time" }, note: { type: "string" }, }, }, description: "Timestamped occurrence events", }, }, fieldMapping: ["occurrences", "events", "logs", "tracking"], }; // ===== Types ===== interface Occurrence { timestamp: string; // ISO 8601 note: Default; } export interface OccurrenceTrackerInput { label: Default; occurrences: Writable>; } // ===== Helper Functions ===== function formatRelativeTime(timestamp: string): string { if (!timestamp) return ""; const diffMs = Date.now() - new Date(timestamp).getTime(); const mins = Math.floor(diffMs / 60000); const hours = Math.floor(diffMs / 3600000); const days = Math.floor(diffMs / 86400000); if (days > 0) return `${days}d ago`; if (hours > 0) return `${hours}h ago`; if (mins > 0) return `${mins}m ago`; return "just now"; } function formatAbsoluteTime(timestamp: string): string { if (!timestamp) return ""; return new Date(timestamp).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true, }); } function formatHistoryTime(timestamp: string): string { if (!timestamp) return ""; const d = new Date(timestamp); return ( d.toLocaleDateString("en-US", { month: "short", day: "numeric", }) + ", " + d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true, }) ); } function formatFrequency(avgMs: number | null): string { if (avgMs === null) return "Not enough data"; const hours = avgMs / 3600000; if (hours >= 24) return `~${(hours / 24).toFixed(1)} days`; if (hours >= 1) return `~${hours.toFixed(1)} hours`; const minutes = hours * 60; if (minutes < 1) return "< 1 minute"; return `~${Math.round(minutes)} minutes`; } function getSortedOccurrences(list: readonly Occurrence[]): Occurrence[] { return [...list].sort( (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), ); } function calculateAverageFrequency( sorted: readonly Occurrence[], ): number | null { if (sorted.length < 2) return null; let totalMs = 0; for (let i = 0; i < sorted.length - 1; i++) { totalMs += new Date(sorted[i].timestamp).getTime() - new Date(sorted[i + 1].timestamp).getTime(); } return totalMs / (sorted.length - 1); } // Lifted helper for frequency display const formatFrequencyFromList = lift((list: Occurrence[]): string => { const sorted = getSortedOccurrences(list || []); return formatFrequency(calculateAverageFrequency(sorted)); }); // ===== Handlers ===== // TODO(future): Replace Date.now()/new Date() with proper time service when available. // Date.now() will be blocked in patterns in the future. The wish({ query: "#now" }) mechanism // only captures time once at pattern creation, so it doesn't work for fresh timestamps // in handlers. When a handler-compatible time service is available (e.g., clock builtin // or transaction timestamp), update these handlers to use it instead. const recordNow = handler }>( (_, { occurrences }) => { occurrences.push({ timestamp: new Date().toISOString(), note: "", }); }, ); const deleteOccurrence = handler< unknown, { occurrences: Writable; timestamp: string } >((_, { occurrences, timestamp }) => { const current = occurrences.get() || []; const index = current.findIndex((o) => o.timestamp === timestamp); if (index >= 0) { occurrences.set(current.toSpliced(index, 1)); } }); // ===== LLM-Callable Handlers ===== const handleRecordNow = handler< { note?: string; result?: Writable }, { occurrences: Writable; label: string } >(({ note, result }, { occurrences, label }) => { const timestamp = new Date().toISOString(); occurrences.push({ timestamp, note: note?.trim() || "", }); const totalCount = (occurrences.get() || []).length; if (result) { result.set({ success: true, timestamp, totalCount, label: label || "Occurrences", message: `Recorded occurrence${note ? ` with note: "${note}"` : ""}`, }); } }); const handleGetStats = handler< { result?: Writable }, { occurrences: Writable; label: string } >(({ result }, { occurrences, label }) => { const list = occurrences.get() || []; const sorted = getSortedOccurrences(list); const totalCount = list.length; const lastOcc = sorted.length > 0 ? sorted[0] : null; const avgMs = calculateAverageFrequency(sorted); if (result) { result.set({ label: label || "Occurrences", totalCount, lastOccurrence: lastOcc ? { timestamp: lastOcc.timestamp, relativeTime: formatRelativeTime(lastOcc.timestamp), note: lastOcc.note || null, } : null, averageFrequency: avgMs !== null ? { milliseconds: avgMs, humanReadable: formatFrequency(avgMs), } : null, }); } }); const handleGetOccurrences = handler< { limit?: number; result?: Writable }, { occurrences: Writable; label: string } >(({ limit, result }, { occurrences, label }) => { const list = occurrences.get() || []; const sorted = getSortedOccurrences(list); const limitedList = limit && limit > 0 ? sorted.slice(0, limit) : sorted; if (result) { result.set({ label: label || "Occurrences", totalCount: list.length, returnedCount: limitedList.length, occurrences: limitedList.map((occ, index) => ({ index, timestamp: occ.timestamp, relativeTime: formatRelativeTime(occ.timestamp), absoluteTime: formatHistoryTime(occ.timestamp), note: occ.note || null, })), }); } }); const handleDeleteOccurrence = handler< { timestamp: string; result?: Writable }, { occurrences: Writable } >(({ timestamp, result }, { occurrences }) => { if (!timestamp) { if (result) { result.set({ success: false, error: "timestamp parameter is required" }); } return; } const current = occurrences.get() || []; const index = current.findIndex((o) => o.timestamp === timestamp); if (index < 0) { if (result) { result.set({ success: false, error: `No occurrence found with timestamp: ${timestamp}`, remainingCount: current.length, }); } return; } occurrences.set(current.toSpliced(index, 1)); if (result) { result.set({ success: true, deletedTimestamp: timestamp, remainingCount: current.length - 1, }); } }); // ===== The Pattern ===== export const OccurrenceTrackerModule = pattern< OccurrenceTrackerInput, OccurrenceTrackerInput >(({ label, occurrences }) => { // Computed: total count const totalCount = computed(() => (occurrences.get() || []).length); // Computed: has any occurrences const hasOccurrences = computed(() => (occurrences.get() || []).length > 0); // Computed: display name for NAME const displayName = computed(() => { const count = (occurrences.get() || []).length; const labelText = label || "Occurrences"; return `${MODULE_METADATA.icon} ${labelText}: ${count}`; }); return { [NAME]: displayName, [UI]: ( {/* Label input */} {/* Big Record Button */} Record Now {/* Last occurrence display */} {computed(() => { const list = occurrences.get() || []; const sorted = getSortedOccurrences(list); const last = sorted.length > 0 ? sorted[0] : null; if (!last) { return ( No occurrences recorded yet ); } return ( Last recorded: {formatRelativeTime(last.timestamp)} ·{" "} {formatAbsoluteTime(last.timestamp)} {/* Note for last occurrence */} { const newNote = e.detail?.value?.trim() || ""; const current = occurrences.get() || []; const idx = current.findIndex( (o) => o.timestamp === last.timestamp, ); if (idx >= 0) { const updated = [...current]; updated[idx] = { ...updated[idx], note: newNote }; occurrences.set(updated); } }} /> ); })} {/* Stats Section - using component attributes for layout */} {totalCount} Total {formatFrequencyFromList(occurrences)} Avg frequency {/* Expandable History - using ifElse to preserve details state */} {ifElse( hasOccurrences,
History ({totalCount}) {occurrences.map((occ) => ( {computed(() => `${formatRelativeTime(occ.timestamp)} · ${ formatHistoryTime(occ.timestamp) }` )} × ))}
, null, )}
), label, occurrences, // LLM-callable handlers for Omnibot recordNow: handleRecordNow({ occurrences, label }), getStats: handleGetStats({ occurrences, label }), getOccurrences: handleGetOccurrences({ occurrences, label }), deleteOccurrence: handleDeleteOccurrence({ occurrences }), }; }); export default OccurrenceTrackerModule;