From 48a1734c84ea0907820e0b99fb9d34825ea72efb Mon Sep 17 00:00:00 2001 From: Alejandro Gil Date: Fri, 6 Feb 2026 08:57:33 -0800 Subject: [PATCH 1/2] Menu spreadsheet: distinct d-tags per row for same item in different submenus - Add computeItemDTag(name, menu, section, fallbackIndex) to derive d-tag from name + menu/section context so duplicate rows get distinct addresses. - Add uniquifyDTags() to suffix -2, -3 on duplicate base d-tags. - Precompute baseDTags and finalDTags; use index-based loops with finalDTags[i] for product events and collection membership. - Remove dTagByItemName; each row now gets its own 30402 event identity. Closes #306 Co-authored-by: Cursor --- client/src/lib/spreadsheet/menuSpreadsheet.ts | 57 ++++++++++++++++--- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/client/src/lib/spreadsheet/menuSpreadsheet.ts b/client/src/lib/spreadsheet/menuSpreadsheet.ts index a30f6d5..45c2480 100644 --- a/client/src/lib/spreadsheet/menuSpreadsheet.ts +++ b/client/src/lib/spreadsheet/menuSpreadsheet.ts @@ -49,6 +49,37 @@ function firstNonEmpty(...values: Array): string { return ""; } +/** + * Computes a d-tag for a menu item row so that the same item in different + * menus/sections gets a distinct identifier (supporting one 30402 event per row). + */ +function computeItemDTag( + name: string, + menu: string, + section: string, + fallbackIndex: number +): string { + const context = [section, menu].filter(Boolean).join("-"); + const base = context + ? (slugify(name) && slugify(context) ? `${slugify(name)}-${slugify(context)}` : slugify(name)) + : slugify(name); + return base || `item-${fallbackIndex + 1}`; +} + +/** Ensures each d-tag is unique; duplicates get -2, -3, ... suffix. */ +function uniquifyDTags(baseDTags: string[]): string[] { + const used = new Set(); + return baseDTags.map((d) => { + let s = d; + let c = 1; + while (used.has(s)) { + s = `${d}-${++c}`; + } + used.add(s); + return s; + }); +} + export async function parseMenuSpreadsheetXlsx(file: File): Promise<{ menus: MenuRow[]; items: MenuItemRow[]; @@ -107,19 +138,27 @@ export function buildSpreadsheetPreviewEvents(params: { titleByMenuName.set(name, description); } - // Build 30402 items first and keep a map name -> dTag for collection a-tags. + const baseDTags = items.map((row, i) => + computeItemDTag( + asString(row.Name), + asString((row as any)["Part of Menu"]), + asString((row as any)["Part of Menu Section"]), + i + ) + ); + const finalDTags = uniquifyDTags(baseDTags); + + // Build 30402 items first; each row gets its own d-tag for distinct events per menu/section. const itemEvents: SquareEventTemplate[] = []; - const dTagByItemName = new Map(); - for (const row of items) { + for (let i = 0; i < items.length; i++) { + const row = items[i]; const name = asString(row.Name); if (!name) continue; const description = asString(row.Description); const content = `**${name}**\n\n${description}`.trim(); - const dTag = slugify(name) || `item-${itemEvents.length + 1}`; - dTagByItemName.set(name, dTag); - + const dTag = finalDTags[i]; const tags: string[][] = []; tags.push(["d", dTag]); tags.push(["title", name]); @@ -180,11 +219,11 @@ export function buildSpreadsheetPreviewEvents(params: { // Build membership map: collection name -> item dTags const collectionToProductDTags = new Map(); - for (const row of items) { + for (let i = 0; i < items.length; i++) { + const row = items[i]; const name = asString(row.Name); if (!name) continue; - const dTag = dTagByItemName.get(name); - if (!dTag) continue; + const dTag = finalDTags[i]; const menu = asString((row as any)["Part of Menu"]); const section = asString((row as any)["Part of Menu Section"]); From 14ca2ad58639d82c881a16c82bf3a1399dcfc69e Mon Sep 17 00:00:00 2001 From: Alejandro Gil Date: Fri, 6 Feb 2026 09:00:29 -0800 Subject: [PATCH 2/2] Add unit tests for menu spreadsheet d-tag generation (duplicate rows) - Same item in different submenus: two 30402 events with distinct d-tags. - Identical (name, menu, section) rows: two events, second gets -2 suffix. - Single row without menu/section: one event with slug(name) d-tag. Closes #307 Co-authored-by: Cursor --- .../lib/spreadsheet/menuSpreadsheet.test.ts | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 client/src/lib/spreadsheet/menuSpreadsheet.test.ts diff --git a/client/src/lib/spreadsheet/menuSpreadsheet.test.ts b/client/src/lib/spreadsheet/menuSpreadsheet.test.ts new file mode 100644 index 0000000..77141c3 --- /dev/null +++ b/client/src/lib/spreadsheet/menuSpreadsheet.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest"; +import { buildSpreadsheetPreviewEvents } from "./menuSpreadsheet"; + +const MERCHANT_PUBKEY = "npub1merchant000000000000000000000000000000000000000000000000"; + +describe("buildSpreadsheetPreviewEvents", () => { + it("emits two 30402 events with distinct d-tags when same item is in different submenus", () => { + const menus = [ + { Name: "Lunch & Dinner", "Parent Menu": "", "Menu Type": "menu", Description: "Lunch & Dinner Menu" }, + { Name: "Brunch", "Parent Menu": "", "Menu Type": "menu", Description: "Brunch Menu" }, + { Name: "Sandos", "Parent Menu": "Lunch & Dinner", Description: "Sandos section" }, + { Name: "The 'Unch Part", "Parent Menu": "Brunch", Description: "The Unch Part section" }, + ]; + const items = [ + { + Name: "Bigfoot Burger", + Description: "A big burger", + "Part of Menu": "Lunch & Dinner", + "Part of Menu Section": "Sandos", + }, + { + Name: "Bigfoot Burger", + Description: "A big burger", + "Part of Menu": "Brunch", + "Part of Menu Section": "The 'Unch Part", + }, + ]; + + const events = buildSpreadsheetPreviewEvents({ + merchantPubkey: MERCHANT_PUBKEY, + menus, + items, + }); + + const productEvents = events.filter((e) => e.kind === 30402); + expect(productEvents).toHaveLength(2); + + const dTags = productEvents.map((e) => e.tags.find((t) => t[0] === "d")?.[1]).filter(Boolean); + expect(dTags).toHaveLength(2); + expect(dTags[0]).not.toBe(dTags[1]); + expect(new Set(dTags).size).toBe(2); + }); + + it("emits two 30402 events with distinct d-tags when two rows have identical name, menu, and section", () => { + const menus = [ + { Name: "Lunch", "Parent Menu": "", "Menu Type": "menu", Description: "Lunch" }, + { Name: "Mains", "Parent Menu": "Lunch", Description: "Mains section" }, + ]; + const items = [ + { Name: "Caesar Salad", Description: "Salad", "Part of Menu": "Lunch", "Part of Menu Section": "Mains" }, + { Name: "Caesar Salad", Description: "Salad", "Part of Menu": "Lunch", "Part of Menu Section": "Mains" }, + ]; + + const events = buildSpreadsheetPreviewEvents({ + merchantPubkey: MERCHANT_PUBKEY, + menus, + items, + }); + + const productEvents = events.filter((e) => e.kind === 30402); + expect(productEvents).toHaveLength(2); + + const dTags = productEvents.map((e) => e.tags.find((t) => t[0] === "d")?.[1]).filter(Boolean); + expect(dTags).toHaveLength(2); + expect(dTags[0]).not.toBe(dTags[1]); + expect(dTags[1]).toMatch(/-2$/); + }); + + it("emits one 30402 event with slug(name) d-tag when single row has no menu or section", () => { + const menus: Array> = []; + const items = [ + { Name: "House Coffee", Description: "Fresh brew" }, + ]; + + const events = buildSpreadsheetPreviewEvents({ + merchantPubkey: MERCHANT_PUBKEY, + menus, + items, + }); + + const productEvents = events.filter((e) => e.kind === 30402); + expect(productEvents).toHaveLength(1); + + const dTag = productEvents[0].tags.find((t) => t[0] === "d")?.[1]; + expect(dTag).toBe("house-coffee"); + }); +});