///
import {
type Cell,
cell,
Default,
handler,
lift,
recipe,
str,
} from "commontools";
const WEEKDAY_LABELS = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
] as const;
type Weekday = typeof WEEKDAY_LABELS[number];
interface SleepSessionSeed {
id?: string;
date?: string;
hours?: number;
tags?: string[];
weekday?: Weekday;
}
interface SleepSessionEntry {
id: string;
date: string;
hours: number;
tags: string[];
weekday: Weekday;
}
interface TagAverage {
tag: string;
averageHours: number;
sessionCount: number;
}
interface WeekdayAverage {
weekday: Weekday;
averageHours: number;
sessionCount: number;
}
interface SleepMetrics {
sessionCount: number;
totalHours: number;
averageHours: number;
}
interface SleepJournalArgs {
sessions: Default;
}
const roundHours = (value: number): number => {
return Math.round(value * 100) / 100;
};
const toFiniteHours = (value: unknown): number => {
if (typeof value === "number" && Number.isFinite(value)) {
return roundHours(Math.max(0, value));
}
const parsed = typeof value === "string" ? Number(value) : 0;
if (Number.isFinite(parsed)) {
return roundHours(Math.max(0, parsed));
}
return 0;
};
const isoDatePattern = /^\d{4}-\d{2}-\d{2}$/;
const toIsoDate = (value: unknown): string => {
if (typeof value === "string" && isoDatePattern.test(value)) {
return value;
}
if (typeof value === "string") {
const attempt = new Date(value);
if (!Number.isNaN(attempt.getTime())) {
return attempt.toISOString().slice(0, 10);
}
}
return "1970-01-01";
};
const weekdayFromDate = (date: string): Weekday => {
const parsed = new Date(`${date}T00:00:00Z`);
if (Number.isNaN(parsed.getTime())) {
return "Sunday";
}
const index = parsed.getUTCDay();
return WEEKDAY_LABELS[index] ?? "Sunday";
};
const sanitizeTags = (value: unknown): string[] => {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set();
const tags: string[] = [];
for (const tag of value) {
if (typeof tag !== "string") continue;
const trimmed = tag.trim();
if (trimmed.length === 0) continue;
if (seen.has(trimmed)) continue;
seen.add(trimmed);
tags.push(trimmed);
}
return tags;
};
const sanitizeId = (
id: unknown,
fallback: string,
date: string,
hours: number,
tags: string[],
): string => {
if (typeof id === "string") {
const trimmed = id.trim();
if (trimmed.length > 0) {
return trimmed;
}
}
const tagHint = tags[0] ?? "untagged";
return `${date}-${tagHint}-${hours.toFixed(2)}-${fallback}`;
};
const toSessionEntry = (
seed: SleepSessionSeed | undefined,
fallback: string,
): SleepSessionEntry => {
const isoDate = toIsoDate(seed?.date);
const hours = toFiniteHours(seed?.hours);
const tags = sanitizeTags(seed?.tags);
const weekday = WEEKDAY_LABELS.includes(seed?.weekday as Weekday)
? seed?.weekday as Weekday
: weekdayFromDate(isoDate);
const id = sanitizeId(seed?.id, fallback, isoDate, hours, tags);
return { id, date: isoDate, hours, tags, weekday };
};
const sanitizeSessionList = (
entries: readonly SleepSessionSeed[] | undefined,
): SleepSessionEntry[] => {
if (!Array.isArray(entries)) {
return [];
}
return Array.from(
entries,
(entry, index) => toSessionEntry(entry, `seed-${index + 1}`),
);
};
const computeTagSummaries = (
entries: readonly SleepSessionEntry[],
): TagAverage[] => {
const buckets = new Map();
for (const entry of entries) {
for (const tag of entry.tags) {
const bucket = buckets.get(tag) ?? { total: 0, count: 0 };
bucket.total += entry.hours;
bucket.count += 1;
buckets.set(tag, bucket);
}
}
return Array.from(buckets.entries())
.map(([tag, bucket]) => ({
tag,
averageHours: roundHours(
bucket.count === 0 ? 0 : bucket.total / bucket.count,
),
sessionCount: bucket.count,
}))
.sort((a, b) => a.tag.localeCompare(b.tag));
};
const computeWeekdaySummaries = (
entries: readonly SleepSessionEntry[],
): WeekdayAverage[] => {
const buckets = new Map();
for (const entry of entries) {
const bucket = buckets.get(entry.weekday) ?? { total: 0, count: 0 };
bucket.total += entry.hours;
bucket.count += 1;
buckets.set(entry.weekday, bucket);
}
const summaries: WeekdayAverage[] = [];
for (const weekday of WEEKDAY_LABELS) {
const bucket = buckets.get(weekday);
if (!bucket) continue;
summaries.push({
weekday,
averageHours: roundHours(
bucket.count === 0 ? 0 : bucket.total / bucket.count,
),
sessionCount: bucket.count,
});
}
return summaries;
};
const computeMetrics = (
entries: readonly SleepSessionEntry[],
): SleepMetrics => {
const sessionCount = entries.length;
const totalHours = roundHours(
entries.reduce((sum, entry) => sum + entry.hours, 0),
);
const averageHours = sessionCount === 0
? 0
: roundHours(totalHours / sessionCount);
return { sessionCount, totalHours, averageHours };
};
const logSleepSession = handler(
(
event: SleepSessionSeed | undefined,
context: {
sessions: Cell;
idSeed: Cell;
},
) => {
const priorCount = context.idSeed.get() ?? 0;
const existing = sanitizeSessionList(context.sessions.get());
const nextIndex = Math.max(priorCount, existing.length) + 1;
const entry = toSessionEntry(event, `runtime-${nextIndex}`);
const nextSeeds = [...existing, entry];
context.sessions.set(nextSeeds);
context.idSeed.set(nextIndex);
},
);
export const sleepJournalPattern = recipe(
"Sleep Journal Pattern",
({ sessions }) => {
const idSeed = cell(0);
const sessionLog = lift((
entries: readonly SleepSessionSeed[] | undefined,
) => sanitizeSessionList(entries))(sessions);
const tagAverages = lift((entries: readonly SleepSessionEntry[]) =>
computeTagSummaries(entries)
)(sessionLog);
const weekdayAverages = lift((entries: readonly SleepSessionEntry[]) =>
computeWeekdaySummaries(entries)
)(sessionLog);
const metrics = lift((entries: readonly SleepSessionEntry[]) =>
computeMetrics(entries)
)(sessionLog);
const sessionCount = lift((value: SleepMetrics) => value.sessionCount)(
metrics,
);
const totalHours = lift((value: SleepMetrics) => value.totalHours)(metrics);
const averageHours = lift((value: SleepMetrics) => value.averageHours)(
metrics,
);
const summary =
str`${sessionCount} sessions averaging ${averageHours} hours`;
const totalsLabel = str`${totalHours} total hours slept`;
const latestView = lift((entries: readonly SleepSessionEntry[]) =>
entries.length === 0 ? null : entries[entries.length - 1]
)(sessionLog);
return {
sessionLog,
tagAverages,
weekdayAverages,
metrics,
summary,
totalsLabel,
latestEntry: latestView,
log: logSleepSession({ sessions, idSeed }),
};
},
);
export type { SleepSessionEntry };