diff --git a/packages/runner/src/cell.ts b/packages/runner/src/cell.ts index 7a9199d685..7ab96e5159 100644 --- a/packages/runner/src/cell.ts +++ b/packages/runner/src/cell.ts @@ -24,7 +24,11 @@ import { diffAndUpdate } from "./data-updating.ts"; import { resolveLink } from "./link-resolution.ts"; import { ignoreReadForScheduling, txToReactivityLog } from "./scheduler.ts"; import { type Cancel, isCancel, useCancelGroup } from "./cancel.ts"; -import { validateAndTransform } from "./schema.ts"; +import { + processDefaultValue, + resolveSchema, + validateAndTransform, +} from "./schema.ts"; import { toURI } from "./uri-utils.ts"; import { type LegacyJSONCellLink, @@ -448,6 +452,9 @@ export class RegularCell implements Cell { // retry on conflict. if (!this.synced) this.sync(); + // Looks for arrays and makes sure each object gets its own doc. + newValue = recursivelyAddIDIfNeeded(newValue); + // TODO(@ubik2) investigate whether i need to check classified as i walk down my own obj diffAndUpdate( this.runtime, @@ -486,36 +493,37 @@ export class RegularCell implements Cell { const resolvedLink = resolveLink(this.tx, this.link); const currentValue = this.tx.readValueOrThrow(resolvedLink); - // If there's no current value, initialize based on schema + // If there's no current value, initialize based on schema, even if there is + // no default value. if (currentValue === undefined) { - if (isObject(this.schema)) { - // Check if schema allows objects - const allowsObject = ContextualFlowControl.isTrueSchema(this.schema) || - this.schema.type === "object" || - (Array.isArray(this.schema.type) && - this.schema.type.includes("object")) || - (this.schema.anyOf && - this.schema.anyOf.some((s) => - typeof s === "object" && s.type === "object" - )); - - if (!allowsObject) { - throw new Error( - "Cannot update with object value - schema does not allow objects", - ); - } - } else if (this.schema === false) { + const resolvedSchema = resolveSchema(this.schema, this.rootSchema); + + // TODO(seefeld,ubik2): This should all be moved to schema helpers. This + // just wants to know whether the value could be an object. + const allowsObject = resolvedSchema === undefined || + ContextualFlowControl.isTrueSchema(resolvedSchema) || + (isObject(resolvedSchema) && + (resolvedSchema.type === "object" || + (Array.isArray(resolvedSchema.type) && + resolvedSchema.type.includes("object")) || + (resolvedSchema.anyOf && + resolvedSchema.anyOf.some((s) => + typeof s === "object" && s.type === "object" + )))); + + if (!allowsObject) { throw new Error( "Cannot update with object value - schema does not allow objects", ); } + this.tx.writeValueOrThrow(resolvedLink, {}); } // Now update each property for (const [key, value] of Object.entries(values)) { // Workaround for type checking, since T can be Cell<> and that's fine. - (this.key as any)(key).set(value); + (this.key as any)(key).set(recursivelyAddIDIfNeeded(value)); } } @@ -546,14 +554,6 @@ export class RegularCell implements Cell { throw new Error("Can't push into non-array value"); } - // If this is an object and it doesn't have an ID, add one. - const valuesToWrite = value.map((val: any) => - (!isLink(val) && isObject(val) && - (val as { [ID]?: unknown })[ID] === undefined && getTopFrame()) - ? { [ID]: getTopFrame()!.generatedIdCounter++, ...val } - : val - ); - // If there is no array yet, create it first. We have to do this as a // separate operation, so that in the next steps [ID] is properly anchored // in the array. @@ -565,8 +565,14 @@ export class RegularCell implements Cell { [], cause, ); - array = isObject(this.schema) && Array.isArray(this.schema?.default) - ? this.schema.default + const resolvedSchema = resolveSchema(this.schema, this.rootSchema); + array = isObject(resolvedSchema) && Array.isArray(resolvedSchema?.default) + ? processDefaultValue( + this.runtime, + this.tx, + this.link, + resolvedSchema.default, + ) : []; } @@ -575,7 +581,7 @@ export class RegularCell implements Cell { this.runtime, this.tx, resolvedLink, - [...array, ...valuesToWrite], + recursivelyAddIDIfNeeded([...array, ...value]), cause, ); } @@ -877,6 +883,78 @@ function subscribeToReferencedDocs( }; } +/** + * Recursively adds IDs elements in arrays, unless they are already a link. + * + * This ensures that mutable arrays only consist of links to documents, at least + * when written to only via .set, .update and .push above. + * + * TODO(seefeld): When an array has default entries and is rewritten as [...old, + * new], this will still break, because the previous entries will point back to + * the array itself instead of being new entries. + * + * @param value - The value to add IDs to. + * @returns The value with IDs added. + */ +function recursivelyAddIDIfNeeded( + value: T, + seen: Map = new Map(), +): T { + // Can't add IDs without top frame. + if (!getTopFrame()) return value; + + // Already a link, no need to add IDs. Not a record, no need to add IDs. + if (!isRecord(value) || isLink(value)) return value; + + // Already seen, return previously annotated result. + if (seen.has(value)) return seen.get(value) as T; + + if (Array.isArray(value)) { + const result: unknown[] = []; + + // Set before traversing, otherwise we'll infinite recurse. + seen.set(value, result); + + result.push(...value.map((v) => { + const value = recursivelyAddIDIfNeeded(v, seen); + // For objects on arrays only: Add ID if not already present. + if ( + isObject(value) && !isLink(value) && !(ID in value) + ) { + return { [ID]: getTopFrame()!.generatedIdCounter++, ...value }; + } else { + return value; + } + })); + return result as T; + } else { + const result: Record = {}; + + // Set before traversing, otherwise we'll infinite recurse. + seen.set(value, result); + + Object.entries(value).forEach(([key, v]) => { + result[key] = recursivelyAddIDIfNeeded(v, seen); + }); + + // Copy supported symbols from original value. + if (ID in value) { + (result as { [ID]: unknown })[ID] = value[ID]; + } + if (ID_FIELD in value) { + (result as { [ID_FIELD]: unknown })[ID_FIELD] = value[ID_FIELD]; + } + + return result as T; + } +} + +/** + * Converts cells and objects that can be turned to cells to links. + * + * @param value - The value to convert. + * @returns The converted value. + */ export function convertCellsToLinks( value: readonly any[] | Record | any, path: string[] = [], diff --git a/packages/runner/src/data-updating.ts b/packages/runner/src/data-updating.ts index 53a9152682..d5f576bf83 100644 --- a/packages/runner/src/data-updating.ts +++ b/packages/runner/src/data-updating.ts @@ -24,6 +24,11 @@ import { import { type IRuntime } from "./runtime.ts"; import { toURI } from "./uri-utils.ts"; +const diffLogger = getLogger("normalizeAndDiff", { + enabled: false, + level: "debug", +}); + /** * Traverses newValue and updates `current` and any relevant linked documents. * @@ -86,34 +91,6 @@ type ChangeSet = { * @param context - The context of the change. * @returns An array of changes that should be written. */ -const diffLogger = getLogger("normalizeAndDiff", { - enabled: false, - level: "debug", -}); - -/** - * Returns true if `target` is the immediate parent of `base` in the same document. - * - * Example: - * - base.path = ["internal", "__#1", "next"] - * - target.path = ["internal", "__#1"] - * - * This is used to decide when to collapse a self/parent link that would create - * a tight self-loop (e.g., obj.next -> obj) while allowing references to - * higher ancestors (like an item's `items` pointing to its containing array). - */ -function isImmediateParent( - target: NormalizedFullLink, - base: NormalizedFullLink, -): boolean { - return ( - target.id === base.id && - target.space === base.space && - target.path.length === base.path.length - 1 && - target.path.every((seg, i) => seg === base.path[i]) - ); -} - export function normalizeAndDiff( runtime: IRuntime, tx: IExtendedStorageTransaction, @@ -232,6 +209,54 @@ export function normalizeAndDiff( newValue = newValue.getAsLink(); } + // Check for links that are data: URIs and inline them, by calling + // normalizeAndDiff on the contents of the link. + if (isLink(newValue)) { + const parsedLink = parseLink(newValue, link); + if (parsedLink.id.startsWith("data:")) { + diffLogger.debug(() => + `[BRANCH_CELL_LINK] Data link detected, treating as contents at path=${pathStr}` + ); + // Use the tx code to make sure we read it the same way + let dataValue: any = tx.readValueOrThrow({ + ...parsedLink, + path: [], + }, options); + const path = [...parsedLink.path]; + // If there is a link on the way to `path`, follow it, appending remaining + // path to the target link. + for (;;) { + if (isAnyCellLink(dataValue)) { + const dataLink = parseLink(dataValue, parsedLink); + dataValue = createSigilLinkFromParsedLink({ + ...dataLink, + path: [...dataLink.path, ...path], + }); + break; + } + if (path.length > 0) { + if (isRecord(dataValue)) { + dataValue = dataValue[path.shift()!]; + } else { + dataValue = undefined; + break; + } + } else { + break; + } + } + return normalizeAndDiff( + runtime, + tx, + link, + dataValue, + context, + options, + seen, + ); + } + } + // If we're about to create a reference to ourselves, no-op if (areMaybeLinkAndNormalizedLinkSame(newValue, link)) { diffLogger.debug(() => @@ -318,48 +343,6 @@ export function normalizeAndDiff( seen, ); } - if (parsedLink.id.startsWith("data:")) { - diffLogger.debug(() => - `[BRANCH_CELL_LINK] Data link detected, treating as contents at path=${pathStr}` - ); - // If there is a data link treat it as writing it's contents instead. - - // Use the tx code to make sure we read it the same way - let dataValue: any = tx.readValueOrThrow({ - ...parsedLink, - path: [], - }, options); - const path = [...parsedLink.path]; - for (;;) { - if (isAnyCellLink(dataValue)) { - const dataLink = parseLink(dataValue, parsedLink); - dataValue = createSigilLinkFromParsedLink({ - ...dataLink, - path: [...dataLink.path, ...path], - }); - break; - } - if (path.length > 0) { - if (isRecord(dataValue)) { - dataValue = dataValue[path.shift()!]; - } else { - dataValue = undefined; - break; - } - } else { - break; - } - } - return normalizeAndDiff( - runtime, - tx, - link, - dataValue, - context, - options, - seen, - ); - } if ( isAnyCellLink(currentValue) && areLinksSame(newValue, currentValue, link) @@ -634,3 +617,26 @@ export function addCommonIDfromObjectID( traverse(obj); } + +/** + * Returns true if `target` is the immediate parent of `base` in the same document. + * + * Example: + * - base.path = ["internal", "__#1", "next"] + * - target.path = ["internal", "__#1"] + * + * This is used to decide when to collapse a self/parent link that would create + * a tight self-loop (e.g., obj.next -> obj) while allowing references to + * higher ancestors (like an item's `items` pointing to its containing array). + */ +function isImmediateParent( + target: NormalizedFullLink, + base: NormalizedFullLink, +): boolean { + return ( + target.id === base.id && + target.space === base.space && + target.path.length === base.path.length - 1 && + target.path.every((seg, i) => seg === base.path[i]) + ); +} diff --git a/packages/runner/src/schema.ts b/packages/runner/src/schema.ts index 6859069ca7..fa7b8b56c7 100644 --- a/packages/runner/src/schema.ts +++ b/packages/runner/src/schema.ts @@ -102,7 +102,7 @@ export function resolveSchema( * * For `required` objects and arrays assume {} and [] as default value. */ -function processDefaultValue( +export function processDefaultValue( runtime: IRuntime, tx: IExtendedStorageTransaction | undefined, link: NormalizedFullLink, @@ -310,8 +310,15 @@ function annotateWithBackToCellSymbols( isRecord(value) && !isCell(value) && !isStream(value) && !isQueryResultForDereferencing(value) ) { - value[toCell] = () => createCell(runtime, link, tx); - value[toOpaqueRef] = () => makeOpaqueRef(link); + // Non-enumerable, so that {...obj} won't copy these symbols + Object.defineProperty(value, toCell, { + value: () => createCell(runtime, link, tx), + enumerable: false, + }); + Object.defineProperty(value, toOpaqueRef, { + value: () => makeOpaqueRef(link), + enumerable: false, + }); Object.freeze(value); } return value; diff --git a/packages/runner/test/cell.test.ts b/packages/runner/test/cell.test.ts index 7765717bd5..497a4d96fa 100644 --- a/packages/runner/test/cell.test.ts +++ b/packages/runner/test/cell.test.ts @@ -1850,13 +1850,7 @@ describe("asCell with schema", () => { expect(arrayCell.get()).toEqualIgnoringSymbols([10, 20, 30, 40]); }); - it("should push values to undefined array with schema default has stable IDs", () => { - const schema = { - type: "array", - items: { type: "object", properties: { value: { type: "number" } } }, - default: [{ [ID]: "test", value: 10 }, { [ID]: "test2", value: 20 }], - } as const satisfies JSONSchema; - + it("should push values to undefined array with reused IDs", () => { const c = runtime.getCell<{ items?: any[] }>( space, "push-to-undefined-schema-stable-id", @@ -1864,20 +1858,16 @@ describe("asCell with schema", () => { tx, ); c.set({}); - const arrayCell = c.key("items").asSchema(schema); + const arrayCell = c.key("items"); arrayCell.push({ [ID]: "test3", "value": 30 }); expect(arrayCell.get()).toEqualIgnoringSymbols([ - { "value": 10 }, - { "value": 20 }, { "value": 30 }, ]); - arrayCell.push({ [ID]: "test", "value": 40 }); + arrayCell.push({ [ID]: "test3", "value": 40 }); expect(arrayCell.get()).toEqualIgnoringSymbols([ { "value": 40 }, // happens to overwrite, because IDs are the same - { "value": 20 }, - { "value": 30 }, { "value": 40 }, ]); }); @@ -3327,4 +3317,176 @@ describe("Cell success callbacks", () => { const status = tx.status(); expect(status.status).toBe("error"); }); + + describe("set operations with arrays", () => { + it("should add IDs to objects when setting an array", () => { + const frame = pushFrame(); + const cell = runtime.getCell<{ name: string; value: number }[]>( + space, + "array-set-test", + { type: "array" }, + tx, + ); + + const objects = [ + { name: "first", value: 1 }, + { name: "second", value: 2 }, + ]; + + cell.set(objects); + popFrame(frame); + + const result = cell.asSchema({ + type: "array", + items: { + type: "object", + properties: { name: { type: "string" }, value: { type: "number" } }, + asCell: true, + }, + }).get(); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(2); + expect(isCell(result[0])).toBe(true); + expect(isCell(result[1])).toBe(true); + const link0 = result[0].getAsNormalizedFullLink(); + const link1 = result[1].getAsNormalizedFullLink(); + expect(link0.id).not.toBe(link1.id); + expect(link0.path).toEqual([]); + expect(link1.path).toEqual([]); + expect(result[0].get().name).toBe("first"); + expect(result[1].get().name).toBe("second"); + }); + + it("should preserve existing IDs when setting an array", () => { + const initialDataCell = runtime.getCell<{ name: string; value: number }>( + space, + "array-set-preserve-id-test-initial", + { + type: "object", + properties: { name: { type: "string" }, value: { type: "number" } }, + }, + tx, + ); + initialDataCell.set({ name: "first", value: 1 }); + + const frame = pushFrame(); + const cell = runtime.getCell<{ name: string; value: number }[]>( + space, + "array-set-preserve-id-test", + { type: "array" }, + tx, + ); + + const objects = [ + initialDataCell, + { name: "second", value: 2 }, + ]; + + cell.set(objects); + popFrame(frame); + + const result = cell.asSchema({ + type: "array", + items: { + type: "object", + properties: { name: { type: "string" }, value: { type: "number" } }, + asCell: true, + }, + }).get(); + expect(isCell(result[0])).toBe(true); + expect(isCell(result[1])).toBe(true); + const link0 = result[0].getAsNormalizedFullLink(); + const link1 = result[1].getAsNormalizedFullLink(); + expect(link0.id).toBe(initialDataCell.getAsNormalizedFullLink().id); + expect(link0.id).not.toBe(link1.id); + }); + }); + + describe("push operations with default values", () => { + it("should use default values from schema when pushing to empty array", () => { + const frame = pushFrame(); + const cell = runtime.getCell<{ name: string; count: number }[]>( + space, + "push-with-defaults-test", + { + type: "array", + default: [{ name: "default", count: 0 }], + }, + tx, + ); + + cell.push({ name: "new", count: 5 }); + popFrame(frame); + + const result = cell.get(); + expect(result.length).toBe(2); + expect(result[0].name).toBe("default"); + expect(result[0].count).toBe(0); + expect(result[1].name).toBe("new"); + expect(result[1].count).toBe(5); + }); + + it("should add IDs to default values from schema", () => { + const frame = pushFrame(); + const cell = runtime.getCell<{ name: string }[]>( + space, + "push-defaults-with-id-test", + { + type: "array", + default: [{ name: "default1" }, { name: "default2" }], + }, + tx, + ); + + cell.push({ name: "new" }); + popFrame(frame); + + const result = cell.asSchema({ + type: "array", + items: { + type: "object", + properties: { name: { type: "string" } }, + asCell: true, + }, + }).get(); + expect(result.length).toBe(3); + expect(isCell(result[0])).toBe(true); + expect(isCell(result[1])).toBe(true); + expect(isCell(result[2])).toBe(true); + const link0 = result[0].getAsNormalizedFullLink(); + const link1 = result[1].getAsNormalizedFullLink(); + const link2 = result[2].getAsNormalizedFullLink(); + expect(link0.id).not.toBe(link1.id); + expect(link1.id).not.toBe(link2.id); + expect(link0.id).not.toBe(link2.id); + }); + + it("should push objects with IDs even without schema defaults", () => { + const frame = pushFrame(); + const cell = runtime.getCell<{ value: number }[]>( + space, + "push-no-defaults-test", + { type: "array" }, + tx, + ); + + cell.push({ value: 1 }, { value: 2 }); + popFrame(frame); + + const result = cell.asSchema({ + type: "array", + items: { + type: "object", + properties: { value: { type: "number" } }, + asCell: true, + }, + }).get(); + expect(result.length).toBe(2); + expect(isCell(result[0])).toBe(true); + expect(isCell(result[1])).toBe(true); + const link0 = result[0].getAsNormalizedFullLink(); + const link1 = result[1].getAsNormalizedFullLink(); + expect(link0.id).not.toBe(link1.id); + }); + }); }); diff --git a/packages/runner/test/data-updating.test.ts b/packages/runner/test/data-updating.test.ts index ab5220429a..3f27920361 100644 --- a/packages/runner/test/data-updating.test.ts +++ b/packages/runner/test/data-updating.test.ts @@ -13,6 +13,7 @@ import { areNormalizedLinksSame, createSigilLinkFromParsedLink, isAnyCellLink, + isSigilLink, parseLink, } from "../src/link-utils.ts"; import { type IExtendedStorageTransaction } from "../src/storage/interface.ts"; @@ -1095,6 +1096,80 @@ describe("data-updating", () => { expect(value.result).toBe(100); }); + it("should inline data URI containing redirect without writing redirect to wrong location", () => { + // Setup: Create two separate cells - source and destination + const sourceCell = runtime.getCell<{ value: number }>( + space, + "data URI redirect source value", + undefined, + tx, + ); + sourceCell.set({ value: 99 }); + + const destinationCell = runtime.getCell<{ value: number }>( + space, + "data URI redirect destination value", + undefined, + tx, + ); + destinationCell.set({ value: 42 }); + + // Create a data URI that contains a redirect pointing to sourceCell + const redirectAlias = sourceCell.key("value").getAsWriteRedirectLink(); + const dataCell = runtime.getImmutableCell( + space, + redirectAlias, + undefined, + tx, + ); + + // Create a target cell that currently has an alias to destinationCell + const targetCell = runtime.getCell<{ result: any }>( + space, + "data URI redirect target cell", + undefined, + tx, + ); + targetCell.setRaw({ + result: destinationCell.key("value").getAsWriteRedirectLink(), + }); + + const current = targetCell.key("result").getAsNormalizedFullLink(); + + // Write the data cell (which contains a redirect to sourceCell) to the target + // Before the fix: data URI was not inlined early enough, and the redirect + // would be written to destinationCell.value instead of target.result + // After the fix: data URI is inlined first, exposing the redirect, which is + // then properly written to target.result + const changes = normalizeAndDiff( + runtime, + tx, + current, + dataCell.getAsLink(), + ); + + // Should write the new redirect to target Cell.result + // Note: The change writes a redirect (alias object with $alias key) + expect(changes.length).toBe(1); + expect(changes[0].location.id).toBe( + targetCell.getAsNormalizedFullLink().id, + ); + expect(changes[0].location.path).toEqual(["result"]); + // The value should be the redirect link + expect(isSigilLink(changes[0].value)).toBe(true); + const parsedLink = parseLink(changes[0].value); + expect(parsedLink?.overwrite).toBe("redirect"); + + applyChangeSet(tx, changes); + + // Verify that targetCell now points to sourceCell's value (99), not destinationCell's (42) + expect(targetCell.get().result).toBe(99); + + // Verify neither source nor destination cells were modified + expect(sourceCell.get()).toEqual({ value: 99 }); + expect(destinationCell.get()).toEqual({ value: 42 }); + }); + describe("addCommonIDfromObjectID", () => { it("should handle arrays", () => { const obj = { items: [{ id: "item1", name: "First Item" }] }; diff --git a/tutorials/code/calendar.tsx b/tutorials/code/calendar.tsx new file mode 100644 index 0000000000..332a32f769 --- /dev/null +++ b/tutorials/code/calendar.tsx @@ -0,0 +1,93 @@ +/// +import { + type Cell, + cell, + Default, + handler, + ifElse, + lift, + recipe, + UI, +} from "commontools"; +import CalendarTodo from "./calendar_todo.tsx"; + +interface CalendarState { + dates: Default< string[], [ ] >; + clickedDate: Default; +} + +const clickDate = handler< + unknown, + { clickedDate: Cell; date: string } +>( + (_, { clickedDate, date }) => { + clickedDate.set(date); + }, +); + +const addRandomDate = handler< + unknown, + { dates: Cell } +>( + (_, { dates }) => { + // Generate a random date in 2025 + const year = 2025; + const month = Math.floor(Math.random() * 12) + 1; + const day = Math.floor(Math.random() * 28) + 1; // Use 28 to avoid month length issues + const randomDate = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; + + dates.push(randomDate); + }, +); + +export default recipe("calendar", ({ dates, clickedDate }) => { + // Note: We use cell() instead of Default<> for the todos map because + // Default<> doesn't work reliably with Record/map types + const todos = cell>({ }); + + // Create lifted function to get todos for a given date + const getTodosForDate = lift( + ({ todos, date }: { todos: Record; date: string }) => + todos[date] || [], + ); + + // Get todos for the clicked date using the same lifted function + const clickedDateTodos = getTodosForDate({ todos, date: clickedDate }); + + // Create the CalendarTodo subrecipe with todos and date + const todoView = CalendarTodo({ + todos, + date: clickedDate, + }); + + return { + [UI]: ( +
+

Selected Date View

+ {todoView} +

The Calendar View

+ + Add Random Date + + {dates.map((date) => { + const dateTodos = getTodosForDate({ todos, date }); + return ( +
+

{date}

+ {dateTodos.length > 0 + ? ( +
    + {/* Note: key is not needed for Common Tools but linters require it */} + {dateTodos.map((todo, index) => ( +
  • {todo}
  • + ))} +
+ ) + :

No todos

} +
+ ); + })} +
+ ), + }; +}); diff --git a/tutorials/code/calendar_todo.tsx b/tutorials/code/calendar_todo.tsx new file mode 100644 index 0000000000..8de92ba6f5 --- /dev/null +++ b/tutorials/code/calendar_todo.tsx @@ -0,0 +1,101 @@ +/// +import { + type Cell, + Default, + handler, + lift, + recipe, + UI, +} from "commontools"; + +interface CalendarTodoState { + todos: Default, {}>; + date: Default; +} + +const addTodo = handler< + { detail: { message: string } }, + { todos: Cell>; date: string } +>( + (event, { todos, date }) => { + const message = event.detail.message?.trim(); + if (!message || !date) return; + const currentTodos = todos.get() || {}; + const dateTodos = currentTodos[date] || []; + const newTodos = [...dateTodos, message]; + const updatedTodos = { ...currentTodos, [date]: newTodos }; + console.log( + "New todos after addition:", + { message, date, currentTodos, newTodos, updatedTodos }, + ); + todos.set(updatedTodos); + }, +); + +const removeTodo = handler< + { target: { dataset: Record } }, + { todos: Cell>; date: string } +>( + (event, { todos, date }) => { + console.log("removeTodo event:", event); + + const index = parseInt(event.target?.dataset?.index || "-1", 10); + if (index === -1 || !date) return; + + const currentTodos = todos.get() || {}; + const dateTodos = currentTodos[date] || []; + + const newTodos = dateTodos.toSpliced(index, 1); + + const updatedTodos = { ...currentTodos, [date]: newTodos }; + console.log("New todos after removal:", updatedTodos); + + todos.set(updatedTodos); + }, +); + +export default recipe( + "calendar_todo", + ({ todos, date }) => { + // Get todos for the specific date + const todosForDate = lift( + ({ todos, date }: { todos: Record; date: string }) => { + if (!todos) return []; + return todos[date] || []; + }, + )({ todos, date }); + + return { + [UI]: ( +
+

Date: {date}

+
+ + {date && todosForDate && todosForDate.length > 0 && ( +
+

Todos for {date}:

+
    + {todosForDate.map((todo, index) => ( +
  • + {todo} +
    + Remove +
    +
  • + ))} +
+
+ )} +
+
+ ), + }; + }, +);