diff --git a/src/brain/index.test.ts b/src/brain/index.test.ts new file mode 100644 index 0000000..f2b69e2 --- /dev/null +++ b/src/brain/index.test.ts @@ -0,0 +1,401 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { randomUUID } from "node:crypto"; +import { IdentityDB, type Space } from "identitydb"; + +const llmCalls: Array<{ model: unknown; options: any }> = []; +let customMonthlyDays: Array<{ day: number; summary: string }> | null = null; +let customDailySlots: Array<{ + start: string; + end: string; + activity: string; +}> | null = null; +let customAvailability: Array<{ + start: string; + end: string; + status: string; +}> | null = null; + +function build48Slots(): Array<{ + start: string; + end: string; + activity: string; +}> { + const slots: Array<{ start: string; end: string; activity: string }> = []; + for (let i = 0; i < 48; i++) { + const startHour = Math.floor(i / 2); + const startMin = (i % 2) * 30; + const start = `${String(startHour).padStart(2, "0")}:${String(startMin).padStart(2, "0")}`; + let end: string; + if (i === 47) { + end = "24:00"; + } else { + const endHour = Math.floor((i + 1) / 2); + const endMin = ((i + 1) % 2) * 30; + end = `${String(endHour).padStart(2, "0")}:${String(endMin).padStart(2, "0")}`; + } + slots.push({ start, end, activity: `slot-${i}` }); + } + return slots; +} + +function build30Days(): Array<{ day: number; summary: string }> { + return Array.from({ length: 30 }, (_, i) => ({ + day: i + 1, + summary: `Day ${i + 1} summary`, + })); +} + +function buildAvailability(): Array<{ + start: string; + end: string; + status: string; +}> { + return [ + { start: "00:00", end: "07:00", status: "offline" }, + { start: "07:00", end: "09:00", status: "online" }, + { start: "09:00", end: "17:00", status: "do-not-disturb" }, + { start: "17:00", end: "23:30", status: "online" }, + { start: "23:30", end: "24:00", status: "offline" }, + ]; +} + +const mockCall = mock(async (model: unknown, options: any): Promise => { + llmCalls.push({ model, options }); + if (options.jsonSchemaName === "daily-schedule") { + return (customDailySlots ?? build48Slots()) as unknown as T; + } + if (options.jsonSchemaName === "monthly-schedule") { + if (customMonthlyDays) return customMonthlyDays as unknown as T; + const match = options.message.match(/\((\d+) days\)/); + const days = match ? parseInt(match[1]!, 10) : 30; + return Array.from({ length: days }, (_, i) => ({ + day: i + 1, + summary: `Day ${i + 1} summary`, + })) as unknown as T; + } + if (options.jsonSchemaName === "availability") { + return (customAvailability ?? buildAvailability()) as unknown as T; + } + throw new Error(`unexpected jsonSchemaName: ${options.jsonSchemaName}`); +}); + +mock.module("@/openrouter", () => ({ + llm: { + models: { conversation: "test-conv", identity: "test-id" }, + call: mockCall, + }, +})); + +mock.module("@/config", () => ({ + config: { + openrouterApiKey: "test-key", + dbPath: ":memory:", + braindbPath: "/tmp/brainbox-test-braindb.json", + }, +})); + +const { Brain } = await import("./index"); +const { brainManager } = await import("./manager"); +const { formatDateKey, nextDay, nextMonth } = await import("./schedule"); +type BrainItem = import("./manager").BrainItem; + +beforeAll(async () => { + try { + await brainManager.deleteBrain("smoke-test-id"); + } catch {} +}); + +afterAll(async () => {}); + +async function makeBrain(): Promise> { + const db = await IdentityDB.connect({ + client: "sqlite", + filename: ":memory:", + }); + await db.initialize(); + const spaceName = `test-space-${randomUUID()}`; + const space: Space = await db.upsertSpace({ name: spaceName }); + const brainbase: BrainItem = { + brainId: randomUUID(), + spaceName, + displayName: "Test Brain", + baseSystemPrompt: + "Test personality: night owl, introverted, studies at midnight.", + }; + return new Brain(db, space, brainbase); +} + +beforeEach(() => { + llmCalls.length = 0; + customMonthlyDays = null; + customDailySlots = null; + customAvailability = null; +}); + +describe("Brain.createDailySchedule", () => { + test("S1: returns 48 slots in 30-min intervals and persists a fact", async () => { + const brain = await makeBrain(); + const today = new Date(2026, 5, 5); + const expectedTomorrow = nextDay(today); + const expectedKey = formatDateKey(expectedTomorrow); + + const result = await brain.createDailySchedule(today, "focus on writing"); + + expect(result).not.toBeNull(); + expect(result).toHaveLength(48); + expect(result![0]).toEqual({ + start: "00:00", + end: "00:30", + activity: "slot-0", + }); + expect(result![47]).toEqual({ + start: "23:30", + end: "24:00", + activity: "slot-47", + }); + + const llmCall = llmCalls.find( + (c) => c.options.jsonSchemaName === "daily-schedule", + ); + expect(llmCall).toBeDefined(); + expect(llmCall!.options.message).toContain(expectedKey); + expect(llmCall!.options.message).toContain("focus on writing"); + expect(llmCall!.options.message).toContain("Test personality"); + + const facts = await brain.db.getTopicFacts( + `daily-schedule:${expectedKey}`, + { + spaceName: brain.space.name, + }, + ); + expect(facts).toHaveLength(1); + expect(JSON.parse(facts[0]!.statement)).toHaveLength(48); + }); + + test("S4: month wrap (June 30 -> July 1)", async () => { + const brain = await makeBrain(); + const today = new Date(2026, 5, 30); + const expectedKey = formatDateKey(new Date(2026, 6, 1)); + + await brain.createDailySchedule(today, ""); + + const facts = await brain.db.getTopicFacts( + `daily-schedule:${expectedKey}`, + { + spaceName: brain.space.name, + }, + ); + expect(facts).toHaveLength(1); + }); + + test("S4b: year wrap (December 31 -> January 1 next year)", async () => { + const brain = await makeBrain(); + const today = new Date(2026, 11, 31); + const expectedKey = "2027-01-01"; + + await brain.createDailySchedule(today, ""); + + const facts = await brain.db.getTopicFacts( + `daily-schedule:${expectedKey}`, + { + spaceName: brain.space.name, + }, + ); + expect(facts).toHaveLength(1); + }); + + test("S6: consumes monthly summary for the target day when present", async () => { + const brain = await makeBrain(); + + customMonthlyDays = Array.from({ length: 30 }, (_, i) => ({ + day: i + 1, + summary: + i + 1 === 10 ? "UNIQUE_SUMMARY_FOR_DAY_10" : `Day ${i + 1} summary`, + })); + + const todayForMonthly = new Date(2026, 4, 15); + await brain.createMonthlySchedule(todayForMonthly, ""); + + const monthlyFacts = await brain.db.getTopicFacts( + `monthly-schedule:2026-06`, + { + spaceName: brain.space.name, + }, + ); + expect(monthlyFacts).toHaveLength(1); + + llmCalls.length = 0; + customDailySlots = build48Slots(); + + const todayForDaily = new Date(2026, 5, 9); + await brain.createDailySchedule(todayForDaily, ""); + + const dailyLlmCall = llmCalls.find( + (c) => c.options.jsonSchemaName === "daily-schedule", + ); + expect(dailyLlmCall).toBeDefined(); + expect(dailyLlmCall!.options.message).toContain( + "UNIQUE_SUMMARY_FOR_DAY_10", + ); + }); +}); + +describe("Brain.createMonthlySchedule", () => { + test("S2: returns N day summaries (N = days in next month) and persists a fact", async () => { + const brain = await makeBrain(); + const today = new Date(2026, 0, 15); + const expected = nextMonth(today); + const expectedKey = `${expected.year}-${String(expected.month + 1).padStart(2, "0")}`; + + const result = await brain.createMonthlySchedule(today, "study for GRE"); + + expect(result).not.toBeNull(); + expect(result).toHaveLength(expected.daysInMonth); + expect(result![0]!.day).toBe(1); + expect(result![result!.length - 1]!.day).toBe(expected.daysInMonth); + + const llmCall = llmCalls.find( + (c) => c.options.jsonSchemaName === "monthly-schedule", + ); + expect(llmCall).toBeDefined(); + expect(llmCall!.options.message).toContain("study for GRE"); + expect(llmCall!.options.message).toContain("Test personality"); + + const facts = await brain.db.getTopicFacts( + `monthly-schedule:${expectedKey}`, + { + spaceName: brain.space.name, + }, + ); + expect(facts).toHaveLength(1); + expect(JSON.parse(facts[0]!.statement)).toHaveLength(expected.daysInMonth); + }); + + test("S5: year wrap (December 15 -> January next year)", async () => { + const brain = await makeBrain(); + const today = new Date(2026, 11, 15); + const expectedKey = "2027-01"; + + const result = await brain.createMonthlySchedule(today, ""); + + expect(result).not.toBeNull(); + expect(result).toHaveLength(31); + + const facts = await brain.db.getTopicFacts( + `monthly-schedule:${expectedKey}`, + { + spaceName: brain.space.name, + }, + ); + expect(facts).toHaveLength(1); + }); +}); + +describe("Brain.getTodayScheduledAvailability", () => { + test("S3: returns availability windows when today's daily schedule exists", async () => { + const brain = await makeBrain(); + const today = new Date(2026, 5, 10); + const todayKey = formatDateKey(today); + await brain.db.addFact({ + spaceName: brain.space.name, + statement: JSON.stringify(build48Slots()), + summary: "test daily", + source: "test", + confidence: 1.0, + topics: [ + { + name: `daily-schedule:${todayKey}`, + category: "temporal", + granularity: "concrete", + }, + { + name: "daily-schedule", + category: "concept", + granularity: "abstract", + }, + { name: todayKey, category: "temporal", granularity: "concrete" }, + ], + }); + + const result = await brain.getTodayScheduledAvailability(today); + + expect(result).not.toBeNull(); + expect(result!.length).toBeGreaterThan(0); + for (const w of result!) { + expect(["online", "do-not-disturb", "offline"]).toContain(w.status); + expect(w.start).toMatch(/^([01][0-9]|2[0-3]):[0-5][0-9]$/); + expect(w.end).toMatch(/^([01][0-9]|2[0-3]):[0-5][0-9]$|^24:00$/); + } + + const availabilityCall = llmCalls.find( + (c) => c.options.jsonSchemaName === "availability", + ); + expect(availabilityCall).toBeDefined(); + }); + + test("returns null when no daily schedule exists for today", async () => { + const brain = await makeBrain(); + const today = new Date(2026, 5, 10); + const result = await brain.getTodayScheduledAvailability(today); + expect(result).toBeNull(); + expect(llmCalls).toHaveLength(0); + }); +}); + +describe("Brain.removeScheduledAvailability", () => { + test("S7: cache invalidated after removeScheduledAvailability()", async () => { + const brain = await makeBrain(); + const today = new Date(2026, 5, 10); + const todayKey = formatDateKey(today); + await brain.db.addFact({ + spaceName: brain.space.name, + statement: JSON.stringify(build48Slots()), + summary: "test daily", + source: "test", + confidence: 1.0, + topics: [ + { + name: `daily-schedule:${todayKey}`, + category: "temporal", + granularity: "concrete", + }, + { + name: "daily-schedule", + category: "concept", + granularity: "abstract", + }, + { name: todayKey, category: "temporal", granularity: "concrete" }, + ], + }); + + const r1 = await brain.getTodayScheduledAvailability(today); + expect(r1).not.toBeNull(); + const callCountAfterFirst = llmCalls.length; + expect(callCountAfterFirst).toBe(1); + + const r2 = await brain.getTodayScheduledAvailability(today); + expect(r2).not.toBeNull(); + expect(llmCalls.length).toBe(callCountAfterFirst); + + brain.removeScheduledAvailability(); + + const r3 = await brain.getTodayScheduledAvailability(today); + expect(r3).not.toBeNull(); + expect(llmCalls.length).toBe(callCountAfterFirst + 1); + }); +}); + +describe("S8: regression on existing methods", () => { + test("Brain.create and Brain.load are still defined as static methods", () => { + expect(typeof Brain.create).toBe("function"); + expect(typeof Brain.load).toBe("function"); + }); +}); diff --git a/src/brain/index.ts b/src/brain/index.ts index d2c39f8..0adc47a 100644 --- a/src/brain/index.ts +++ b/src/brain/index.ts @@ -3,17 +3,243 @@ import { config } from "@/config"; import { IdentityDB, type Space } from "identitydb"; import { llm } from "@/openrouter"; import { loadPrompt } from "@/openrouter/promptLoader"; +import { + availabilitySchema, + dailyScheduleSchema, + monthlyScheduleSchema, +} from "@/openrouter/schema"; import { logger } from "@/utils/logger"; import { factExtractor } from "./factExtractor"; import { brainManager, type BrainItem } from "./manager"; +import { + type Availability, + type DailySchedule, + type MonthlySchedule, + formatDateKey, + formatMonthKey, + nextDay, + nextMonth, + pad2, +} from "./schedule"; export class Brain { + private availabilityCache: Map = new Map(); + constructor( public db: IdentityDB, public space: Space, public brainbase: BrainItem, ) {} + async createDailySchedule( + datetime: Date, + message: string, + ): Promise { + try { + const target = nextDay(datetime); + const dateKey = formatDateKey(target); + const topicName = `daily-schedule:${dateKey}`; + + const monthlySummary = await this.getMonthlySummaryForDay(target); + const history = await this.getHistoryFacts(); + + const instruction = await loadPrompt("DAILY_SCHEDULE"); + const promptMessage = [ + `Target date: ${dateKey} (${target.toLocaleDateString("en-US", { weekday: "long" })})`, + `Personality: ${this.brainbase.baseSystemPrompt}`, + monthlySummary + ? `Monthly summary for this day: ${monthlySummary}` + : "(no monthly summary available for this date)", + `Recent history (facts):`, + history, + `User direction: ${message}`, + ].join("\n\n"); + + const schedule = await llm.call(llm.models.identity, { + instruction, + message: promptMessage, + jsonSchemaName: "daily-schedule", + jsonSchema: dailyScheduleSchema, + }); + + await this.db.addFact({ + spaceName: this.space.name, + statement: JSON.stringify(schedule), + summary: `Daily schedule for ${dateKey} (${schedule.length} slots)`, + source: "createDailySchedule", + confidence: 1.0, + topics: [ + { + name: topicName, + category: "temporal", + granularity: "concrete", + role: "schedule", + }, + { + name: "daily-schedule", + category: "concept", + granularity: "abstract", + role: "schedule", + }, + { + name: dateKey, + category: "temporal", + granularity: "concrete", + role: "date", + }, + ], + }); + + return schedule; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + logger.error(`createDailySchedule failed: ${reason}`); + return null; + } + } + + async createMonthlySchedule( + datetime: Date, + message: string, + ): Promise { + try { + const next = nextMonth(datetime); + const monthKey = `${next.year}-${pad2(next.month + 1)}`; + const topicName = `monthly-schedule:${monthKey}`; + + const history = await this.getHistoryFacts(); + + const instruction = await loadPrompt("MONTHLY_SCHEDULE"); + const promptMessage = [ + `Target month: ${monthKey} (${next.daysInMonth} days)`, + `Personality: ${this.brainbase.baseSystemPrompt}`, + `Recent history (facts):`, + history, + `User direction: ${message}`, + ].join("\n\n"); + + const schedule = await llm.call(llm.models.identity, { + instruction, + message: promptMessage, + jsonSchemaName: "monthly-schedule", + jsonSchema: monthlyScheduleSchema, + }); + + await this.db.addFact({ + spaceName: this.space.name, + statement: JSON.stringify(schedule), + summary: `Monthly schedule for ${monthKey} (${schedule.length} days)`, + source: "createMonthlySchedule", + confidence: 1.0, + topics: [ + { + name: topicName, + category: "temporal", + granularity: "concrete", + role: "schedule", + }, + { + name: "monthly-schedule", + category: "concept", + granularity: "abstract", + role: "schedule", + }, + { + name: monthKey, + category: "temporal", + granularity: "concrete", + role: "period", + }, + ], + }); + + return schedule; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + logger.error(`createMonthlySchedule failed: ${reason}`); + return null; + } + } + + async getTodayScheduledAvailability( + datetime: Date, + ): Promise { + try { + const dateKey = formatDateKey(datetime); + const cached = this.availabilityCache.get(dateKey); + if (cached) return cached; + + const topicName = `daily-schedule:${dateKey}`; + const facts = await this.db.getTopicFacts(topicName, { + spaceName: this.space.name, + }); + if (facts.length === 0) return null; + + const dailySchedule = JSON.parse(facts[0]!.statement) as DailySchedule; + + const instruction = await loadPrompt("SCHEDULE_AVAILABILITY"); + const promptMessage = JSON.stringify({ + schedule: dailySchedule, + personality: this.brainbase.baseSystemPrompt, + }); + + const availability = await llm.call(llm.models.identity, { + instruction, + message: promptMessage, + jsonSchemaName: "availability", + jsonSchema: availabilitySchema, + }); + + this.availabilityCache.set(dateKey, availability); + return availability; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + logger.error(`getTodayScheduledAvailability failed: ${reason}`); + return null; + } + } + + removeScheduledAvailability(): void { + this.availabilityCache.clear(); + } + + private async getMonthlySummaryForDay(target: Date): Promise { + try { + const monthKey = formatMonthKey(target); + const topicName = `monthly-schedule:${monthKey}`; + const facts = await this.db.getTopicFacts(topicName, { + spaceName: this.space.name, + }); + if (facts.length === 0) return null; + + const monthly = JSON.parse(facts[0]!.statement) as MonthlySchedule; + const day = target.getDate(); + const entry = monthly.find((d) => d.day === day); + return entry?.summary ?? null; + } catch { + return null; + } + } + + private async getHistoryFacts(): Promise { + try { + const topics = await this.db.listTopics({ + spaceName: this.space.name, + includeFacts: true, + }); + const statements: string[] = []; + for (const topic of topics) { + const t = topic as { facts?: Array<{ statement: string }> }; + if (t.facts) { + for (const f of t.facts) statements.push(f.statement); + } + } + return statements.slice(-30).join("\n"); + } catch { + return ""; + } + } + static async create( displayName: string, seed: string, diff --git a/src/brain/schedule.test.ts b/src/brain/schedule.test.ts new file mode 100644 index 0000000..63bd53a --- /dev/null +++ b/src/brain/schedule.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from "bun:test"; +import { + formatDateKey, + formatMonthKey, + nextDay, + nextMonth, + pad2, +} from "./schedule"; + +describe("pad2", () => { + test("zero-pads single digit", () => { + expect(pad2(0)).toBe("00"); + expect(pad2(1)).toBe("01"); + expect(pad2(9)).toBe("09"); + }); + test("does not pad two digits", () => { + expect(pad2(10)).toBe("10"); + expect(pad2(31)).toBe("31"); + expect(pad2(99)).toBe("99"); + }); +}); + +describe("formatDateKey", () => { + test("formats YYYY-MM-DD with zero padding", () => { + expect(formatDateKey(new Date(2026, 0, 5))).toBe("2026-01-05"); + expect(formatDateKey(new Date(2026, 11, 31))).toBe("2026-12-31"); + expect(formatDateKey(new Date(2026, 5, 30))).toBe("2026-06-30"); + }); + test("handles months 1-9 with zero padding", () => { + expect(formatDateKey(new Date(2026, 8, 9))).toBe("2026-09-09"); + }); +}); + +describe("formatMonthKey", () => { + test("formats YYYY-MM with zero padding", () => { + expect(formatMonthKey(new Date(2026, 0, 1))).toBe("2026-01"); + expect(formatMonthKey(new Date(2026, 11, 15))).toBe("2026-12"); + }); + test("handles month 9 with zero padding", () => { + expect(formatMonthKey(new Date(2026, 8, 30))).toBe("2026-09"); + }); +}); + +describe("nextDay", () => { + test("returns next day within the same month", () => { + const d = new Date(2026, 5, 5); + const n = nextDay(d); + expect(n.getFullYear()).toBe(2026); + expect(n.getMonth()).toBe(5); + expect(n.getDate()).toBe(6); + }); + test("wraps month on last day", () => { + const d = new Date(2026, 5, 30); + const n = nextDay(d); + expect(n.getFullYear()).toBe(2026); + expect(n.getMonth()).toBe(6); + expect(n.getDate()).toBe(1); + }); + test("wraps year on December 31", () => { + const d = new Date(2026, 11, 31); + const n = nextDay(d); + expect(n.getFullYear()).toBe(2027); + expect(n.getMonth()).toBe(0); + expect(n.getDate()).toBe(1); + }); + test("DST-safe: returns midnight in local time after US spring-forward", () => { + // US DST 2026 starts March 8, 2026 + const d = new Date(2026, 2, 8); + const n = nextDay(d); + expect(n.getFullYear()).toBe(2026); + expect(n.getMonth()).toBe(2); + expect(n.getDate()).toBe(9); + expect(n.getHours()).toBe(0); + expect(n.getMinutes()).toBe(0); + }); + test("DST-safe: returns midnight in local time after US fall-back", () => { + // US DST 2026 ends November 1, 2026 + const d = new Date(2026, 10, 1); + const n = nextDay(d); + expect(n.getFullYear()).toBe(2026); + expect(n.getMonth()).toBe(10); + expect(n.getDate()).toBe(2); + expect(n.getHours()).toBe(0); + expect(n.getMinutes()).toBe(0); + }); + test("handles February in leap year", () => { + const d = new Date(2024, 1, 28); + const n = nextDay(d); + expect(n.getFullYear()).toBe(2024); + expect(n.getMonth()).toBe(1); + expect(n.getDate()).toBe(29); + }); + test("handles February in non-leap year", () => { + const d = new Date(2026, 1, 28); + const n = nextDay(d); + expect(n.getFullYear()).toBe(2026); + expect(n.getMonth()).toBe(2); + expect(n.getDate()).toBe(1); + }); + test("does not mutate input date", () => { + const d = new Date(2026, 5, 15); + const originalTime = d.getTime(); + nextDay(d); + expect(d.getTime()).toBe(originalTime); + }); +}); + +describe("nextMonth", () => { + test("returns next month within the same year", () => { + const r = nextMonth(new Date(2026, 0, 15)); + expect(r.year).toBe(2026); + expect(r.month).toBe(1); + expect(r.daysInMonth).toBe(28); + }); + test("returns daysInMonth for 30-day months", () => { + expect(nextMonth(new Date(2026, 2, 15)).daysInMonth).toBe(30); // April + expect(nextMonth(new Date(2026, 3, 15)).daysInMonth).toBe(31); // May + }); + test("returns 29 for February in a leap year", () => { + const r = nextMonth(new Date(2024, 0, 15)); + expect(r.year).toBe(2024); + expect(r.month).toBe(1); + expect(r.daysInMonth).toBe(29); + }); + test("returns 28 for February in a non-leap year", () => { + const r = nextMonth(new Date(2026, 0, 15)); + expect(r.year).toBe(2026); + expect(r.month).toBe(1); + expect(r.daysInMonth).toBe(28); + }); + test("returns 31 for January", () => { + const r = nextMonth(new Date(2025, 11, 15)); + expect(r.year).toBe(2026); + expect(r.month).toBe(0); + expect(r.daysInMonth).toBe(31); + }); + test("wraps year on December 15", () => { + const r = nextMonth(new Date(2026, 11, 15)); + expect(r.year).toBe(2027); + expect(r.month).toBe(0); + expect(r.daysInMonth).toBe(31); + }); + test("month is zero-indexed (0 = January)", () => { + const r = nextMonth(new Date(2026, 6, 15)); + expect(r.month).toBe(7); + }); +}); diff --git a/src/brain/schedule.ts b/src/brain/schedule.ts new file mode 100644 index 0000000..37c80c5 --- /dev/null +++ b/src/brain/schedule.ts @@ -0,0 +1,48 @@ +export type DailySlot = { + start: string; + end: string; + activity: string; + notes?: string; +}; +export type DailySchedule = DailySlot[]; + +export type MonthlyDay = { + day: number; + summary: string; +}; +export type MonthlySchedule = MonthlyDay[]; + +export type AvailabilityStatus = "online" | "do-not-disturb" | "offline"; +export type Availability = { + start: string; + end: string; + status: AvailabilityStatus; +}; +export type AvailabilityWindows = Availability[]; + +export function pad2(n: number): string { + return n < 10 ? `0${n}` : `${n}`; +} + +export function formatDateKey(d: Date): string { + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; +} + +export function formatMonthKey(d: Date): string { + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`; +} + +export function nextDay(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1); +} + +export function nextMonth(d: Date): { + year: number; + month: number; + daysInMonth: number; +} { + const year = d.getMonth() === 11 ? d.getFullYear() + 1 : d.getFullYear(); + const month = (d.getMonth() + 1) % 12; + const daysInMonth = new Date(year, month + 1, 0).getDate(); + return { year, month, daysInMonth }; +}