From df87dc7119921287f8b0101de4f1b12716efe2e1 Mon Sep 17 00:00:00 2001 From: AZ0228 <53315675+AZ0228@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:45:41 -0700 Subject: [PATCH 1/7] adding batche approach to tag suggestion, adding more import tooling --- backend/routes/pivotAdminRoutes.js | 21 ++ .../services/pivotIngestDuplicateService.js | 24 +- backend/services/pivotIngestPublishService.js | 37 +- backend/services/pivotTagSuggestService.js | 1 - .../unit/pivotIngestDuplicateService.test.js | 8 +- .../unit/pivotIngestPublishService.test.js | 57 +++- .../PlatformAdmin/PivotLab/PivotLabPage.jsx | 319 +++++++++++++++--- .../PlatformAdmin/PivotLab/PivotLabPage.scss | 49 +++ 8 files changed, 447 insertions(+), 69 deletions(-) diff --git a/backend/routes/pivotAdminRoutes.js b/backend/routes/pivotAdminRoutes.js index b08e424a..e22190e4 100644 --- a/backend/routes/pivotAdminRoutes.js +++ b/backend/routes/pivotAdminRoutes.js @@ -13,6 +13,7 @@ const { saveInterviewNotes, } = require('../services/pivotLabNotesService'); const { previewIngestUrl } = require('../services/pivotIngestPreviewService'); +const { annotateImportDuplicates } = require('../services/pivotIngestDuplicateService'); const { publishIngestEvent, publishBatchIngestEvents, @@ -371,6 +372,26 @@ router.post('/ingest/preview', verifyToken, requirePlatformAdmin, async (req, re } }); +router.post('/ingest/annotate-duplicates', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const result = await annotateImportDuplicates(req, { + tenantKey: req.body?.tenantKey, + drafts: req.body?.drafts, + }); + + return res.status(200).json({ + success: true, + data: result, + }); + } catch (err) { + logPivotRouteError('POST /admin/pivot/ingest/annotate-duplicates', err, req); + return res.status(500).json({ + success: false, + message: 'Unable to check for duplicate events.', + }); + } +}); + router.post('/ingest', verifyToken, requirePlatformAdmin, async (req, res) => { try { const result = await publishIngestEvent(req, { diff --git a/backend/services/pivotIngestDuplicateService.js b/backend/services/pivotIngestDuplicateService.js index 4cc7c6f9..9fdd877e 100644 --- a/backend/services/pivotIngestDuplicateService.js +++ b/backend/services/pivotIngestDuplicateService.js @@ -106,7 +106,7 @@ function findCatalogDuplicate(index, candidate) { if (fingerprint) { const byFingerprint = index.find((row) => row.fingerprint && row.fingerprint === fingerprint); if (byFingerprint) { - return duplicateSummary(byFingerprint, 'fingerprint'); + return duplicateSummary(byFingerprint, 'fingerprint', { willUpdate: true }); } } @@ -178,7 +178,7 @@ function formatDuplicateWarning(duplicate, candidateName) { return `${label} already exists in catalog and will update the existing row.`; } if (duplicate.matchType === 'fingerprint') { - return `${label} looks like a duplicate of "${duplicate.existingName}" (same title, time, and location).`; + return `${label} matches existing "${duplicate.existingName}" (same title, time, and location) — publishing will update it.`; } if (duplicate.matchType === 'batchSourceUrl') { return `${label} duplicates another row in this import batch (same source URL).`; @@ -191,8 +191,23 @@ function formatDuplicateWarning(duplicate, candidateName) { function isBlockingDuplicate(duplicate) { if (!duplicate) return false; - if (duplicate.matchType === 'sourceUrl') return false; - return true; + // sourceUrl and fingerprint matches resolve against an existing catalog event, so + // publishing updates it in place. Only collisions between two rows of the same import + // batch have nothing to update against and must block. + return ( + duplicate.matchType === 'batchSourceUrl' || duplicate.matchType === 'batchFingerprint' + ); +} + +async function annotateImportDuplicates(req, options = {}) { + const tenantKey = options.tenantKey?.trim()?.toLowerCase(); + const drafts = Array.isArray(options.drafts) ? options.drafts : []; + if (!tenantKey || !drafts.length) { + return { drafts, duplicateWarnings: [] }; + } + + const catalogIndex = await loadCatalogDuplicateIndex(tenantKey); + return annotateImportDrafts(drafts, catalogIndex); } async function resolveImportDuplicate(req, { tenantKey, candidate }) { @@ -212,6 +227,7 @@ module.exports = { loadCatalogDuplicateIndex, findCatalogDuplicate, annotateImportDrafts, + annotateImportDuplicates, formatDuplicateWarning, isBlockingDuplicate, resolveImportDuplicate, diff --git a/backend/services/pivotIngestPublishService.js b/backend/services/pivotIngestPublishService.js index 77a930d9..352a3273 100644 --- a/backend/services/pivotIngestPublishService.js +++ b/backend/services/pivotIngestPublishService.js @@ -258,10 +258,23 @@ function buildEventPayload(merged, { catalogOrgId, sourceUrl, batchWeek, importe }; } -async function savePublishedCatalogEvent(tenantReq, eventPayload, sourceUrl) { +async function savePublishedCatalogEvent(tenantReq, eventPayload, sourceUrl, updateEventId) { const { Event } = getModels(tenantReq, 'Event'); - const listingUrl = trimString(sourceUrl); + // A fuzzy (fingerprint) duplicate resolves to a specific existing event that may have a + // different or no source URL, so update it by id rather than upserting on sourceUrl. + if (updateEventId) { + const updated = await Event.findByIdAndUpdate( + updateEventId, + { $set: eventPayload }, + { new: true, runValidators: true }, + ).lean(); + if (updated) { + return updated; + } + } + + const listingUrl = trimString(sourceUrl); if (listingUrl) { return Event.findOneAndUpdate( { 'customFields.pivot.sourceUrl': listingUrl }, @@ -345,6 +358,7 @@ async function publishIngestEvent(req, options = {}) { }, }); + // Batch-internal collisions (two rows of the same import) have nothing to update against. if (isBlockingDuplicate(duplicate)) { return { error: formatDuplicateWarning(duplicate, validated.merged.name), @@ -354,6 +368,10 @@ async function publishIngestEvent(req, options = {}) { }; } + // sourceUrl and fingerprint matches resolve to an existing catalog event — update it. + const updateEventId = + duplicate?.willUpdate && duplicate?.existingEventId ? duplicate.existingEventId : null; + const catalogResult = await resolveCatalogOrgId(req, tenantResult.tenant); const importedBy = resolveImportedBy(req); const eventPayload = buildEventPayload(validated.merged, { @@ -366,22 +384,25 @@ async function publishIngestEvent(req, options = {}) { const db = await connectToDatabase(tenantResult.tenant.tenantKey); const tenantReq = { db }; - const event = await savePublishedCatalogEvent(tenantReq, eventPayload, listingUrl); + const event = await savePublishedCatalogEvent(tenantReq, eventPayload, listingUrl, updateEventId); + const updatedExisting = Boolean(updateEventId); - logPivot('info', 'catalog event published', { + logPivot('info', updatedExisting ? 'catalog event updated' : 'catalog event published', { tenantKey: tenantResult.tenant.tenantKey, batchWeek: batchNormalized.batchWeek, eventId: String(event._id), name: event.name, source: mergedInput.source, timeSlotCount: validated.merged.timeSlots?.length ?? 0, + duplicateMatch: duplicate?.matchType || null, importedBy, }); return { data: { event: serializeLabEvent(event), - created: true, + created: !updatedExisting, + updated: updatedExisting, }, }; } @@ -408,6 +429,7 @@ async function publishBatchIngestEvents(req, options = {}) { const published = []; const failures = []; + let updatedCount = 0; for (const entry of events) { const url = trimString(entry?.url) || undefined; @@ -425,6 +447,9 @@ async function publishBatchIngestEvents(req, options = {}) { continue; } + if (result.data.updated) { + updatedCount += 1; + } published.push(result.data.event); } @@ -441,6 +466,7 @@ async function publishBatchIngestEvents(req, options = {}) { tenantKey: tenantResult.tenant.tenantKey, batchWeek: batchNormalized.batchWeek, publishedCount: published.length, + updatedCount, failedCount: failures.length, }); @@ -449,6 +475,7 @@ async function publishBatchIngestEvents(req, options = {}) { published, failures, publishedCount: published.length, + updatedCount, failedCount: failures.length, }, }; diff --git a/backend/services/pivotTagSuggestService.js b/backend/services/pivotTagSuggestService.js index 86310140..038d836b 100644 --- a/backend/services/pivotTagSuggestService.js +++ b/backend/services/pivotTagSuggestService.js @@ -318,7 +318,6 @@ async function suggestPivotEventTagsBatch(req, rawEvents = []) { }); if (suggestedCount === 0 && failures.length > 0) { - console.log('failures', failures); return { error: failures[0].message, status: failures[0].code === 'LLM_NOT_CONFIGURED' ? 503 : 400, diff --git a/backend/tests/unit/pivotIngestDuplicateService.test.js b/backend/tests/unit/pivotIngestDuplicateService.test.js index da9ce46d..d336cfa3 100644 --- a/backend/tests/unit/pivotIngestDuplicateService.test.js +++ b/backend/tests/unit/pivotIngestDuplicateService.test.js @@ -73,7 +73,7 @@ describe('pivotIngestDuplicateService', () => { expect(isBlockingDuplicate(duplicate)).toBe(false); }); - it('flags fingerprint matches as blocking duplicates', () => { + it('treats fingerprint matches as fuzzy updates, not blockers', () => { const duplicate = findCatalogDuplicate(catalogIndex, { sourceUrl: 'https://partiful.com/e/different-slug', name: 'Sunset Listening Party', @@ -82,9 +82,11 @@ describe('pivotIngestDuplicateService', () => { }); expect(duplicate.matchType).toBe('fingerprint'); - expect(isBlockingDuplicate(duplicate)).toBe(true); + expect(duplicate.willUpdate).toBe(true); + expect(duplicate.existingEventId).toBe('existing-1'); + expect(isBlockingDuplicate(duplicate)).toBe(false); expect(formatDuplicateWarning(duplicate, 'Sunset Listening Party')).toMatch( - /duplicate of "Sunset Listening Party"/, + /will update it/, ); }); }); diff --git a/backend/tests/unit/pivotIngestPublishService.test.js b/backend/tests/unit/pivotIngestPublishService.test.js index 44aa663b..a6e2524d 100644 --- a/backend/tests/unit/pivotIngestPublishService.test.js +++ b/backend/tests/unit/pivotIngestPublishService.test.js @@ -102,6 +102,7 @@ describe('pivotIngestPublishService publishIngestEvent', () => { beforeEach(() => { Event = { findOneAndUpdate: jest.fn(), + findByIdAndUpdate: jest.fn(), create: jest.fn(), }; getModels.mockReturnValue({ Event }); @@ -263,11 +264,11 @@ describe('pivotIngestPublishService publishIngestEvent', () => { expect(Event.findOneAndUpdate.mock.calls[0][1].$set.hostingId).toBe('507f1f77bcf86cd799439099'); }); - it('rejects publish when a blocking duplicate is detected', async () => { + it('rejects publish when a blocking (batch-internal) duplicate is detected', async () => { isBlockingDuplicate.mockReturnValue(true); resolveImportDuplicate.mockResolvedValue({ duplicate: { - matchType: 'fingerprint', + matchType: 'batchFingerprint', existingName: 'Sunset Listening Party', }, catalogIndex: [], @@ -288,6 +289,58 @@ describe('pivotIngestPublishService publishIngestEvent', () => { expect(Event.findOneAndUpdate).not.toHaveBeenCalled(); }); + it('updates the existing event in place on a fuzzy (fingerprint) duplicate', async () => { + resolveImportDuplicate.mockResolvedValue({ + duplicate: { + matchType: 'fingerprint', + willUpdate: true, + existingEventId: '507f1f77bcf86cd799439055', + existingName: 'Sunset Listening Party', + }, + catalogIndex: [], + }); + Event.findByIdAndUpdate.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _id: '507f1f77bcf86cd799439055', + name: 'Sunset Listening Party', + start_time: new Date('2026-07-12T22:00:00.000Z'), + end_time: new Date('2026-07-13T00:00:00.000Z'), + location: 'Brooklyn Bridge Park', + customFields: { + pivot: { + batchWeek: '2026-W26', + ingestStatus: 'published', + host: { name: 'Brooklyn Board Game Cafe' }, + }, + }, + }), + }); + + const result = await publishIngestEvent( + { user: { email: 'ops@meridian.study' }, globalDb: {} }, + { + tenantKey: 'nyc', + batchWeek: '2026-W26', + overrides: { + hostName: 'Brooklyn Board Game Cafe', + name: 'Sunset Listening Party', + location: 'Brooklyn Bridge Park', + start_time: '2026-07-12T18:00:00-04:00', + tags: ['live-music'], + }, + }, + ); + + expect(result.data.updated).toBe(true); + expect(result.data.created).toBe(false); + expect(Event.findByIdAndUpdate).toHaveBeenCalledWith( + '507f1f77bcf86cd799439055', + expect.objectContaining({ $set: expect.any(Object) }), + expect.objectContaining({ new: true }), + ); + expect(Event.create).not.toHaveBeenCalled(); + }); + it('rejects publish when tags are missing', async () => { validatePivotEventTags.mockResolvedValue({ error: 'At least one catalog tag is required.', diff --git a/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.jsx b/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.jsx index aff49f05..052dfc72 100644 --- a/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.jsx +++ b/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.jsx @@ -32,6 +32,10 @@ import './PivotDeckCardPreview.scss'; const EMPTY_LIST = []; const NO_FETCH_CACHE = { enabled: false }; const PURGE_CONFIRM_TOKEN = 'PURGE'; +// Batch tag suggestion runs one Claude call per event server-side; sending the whole +// selection in a single request can exceed the production gateway timeout (bare 503). +// Split it into small chunks so each request stays short and results save incrementally. +const AI_TAG_CHUNK_SIZE = 4; const PIVOT_JSON_IMPORT_EXAMPLE = `{ "label": "Brooklyn week crawl", @@ -464,16 +468,18 @@ function buildDeckPreviewProps({ function isBlockingImportDuplicate(duplicate) { if (!duplicate) return false; - return duplicate.matchType !== 'sourceUrl'; + // sourceUrl and fingerprint matches update an existing catalog event; only collisions + // between two rows of the same import batch have nothing to update against. + return duplicate.matchType === 'batchSourceUrl' || duplicate.matchType === 'batchFingerprint'; } function duplicateBadgeLabel(duplicate) { if (!duplicate) return null; - if (duplicate.matchType === 'sourceUrl') return 'Will update'; if (duplicate.matchType === 'batchSourceUrl' || duplicate.matchType === 'batchFingerprint') { return 'Batch duplicate'; } - return 'Duplicate'; + // sourceUrl (exact) or fingerprint (fuzzy) → publishing updates the existing row. + return 'Will update'; } function createBatchImportRow(entry, index) { @@ -511,6 +517,7 @@ function createBatchImportRow(entry, index) { tags, timeSlots, movie, + aiTagged: false, warnings: entry?.warnings || [], duplicate, isBlockingDuplicate, @@ -557,6 +564,41 @@ function formatEventFilmStatus(draft) { return '—'; } +function PivotImportThumb({ src, alt }) { + const [failed, setFailed] = useState(false); + + useEffect(() => { + setFailed(false); + }, [src]); + + if (!src || failed) { + return ( + + ); + } + + return ( + {alt setFailed(true)} + /> + ); +} + function IngestStatusPill({ status }) { if (status === 'published') { return Published; @@ -599,6 +641,7 @@ function PivotLabPage() { const [batchApplyTags, setBatchApplyTags] = useState([]); const [tmdbMatchLoadingKey, setTmdbMatchLoadingKey] = useState(null); const [tagSuggestLoadingKey, setTagSuggestLoadingKey] = useState(null); + const [batchTagProgress, setBatchTagProgress] = useState(null); const [tagSeeding, setTagSeeding] = useState(false); const [importName, setImportName] = useState(''); const [importLocation, setImportLocation] = useState(''); @@ -1102,6 +1145,34 @@ function PivotLabPage() { ); }, []); + // Fuzzy/exact duplicate detection for JSON entries, matched against the selected city's + // catalog so publishing updates existing rows instead of creating duplicates. + const annotateJsonEntriesWithDuplicates = useCallback( + async (entries) => { + if (!selectedTenantKey || !entries.length) { + return { entries, duplicateWarnings: [] }; + } + + const { data, error } = await authenticatedRequest( + '/admin/pivot/ingest/annotate-duplicates', + { + method: 'POST', + data: { tenantKey: selectedTenantKey, drafts: entries }, + }, + ); + + if (error || !data?.success) { + return { entries, duplicateWarnings: [] }; + } + + return { + entries: data.data?.drafts || entries, + duplicateWarnings: data.data?.duplicateWarnings || [], + }; + }, + [selectedTenantKey], + ); + const handlePreviewJsonImport = useCallback(async () => { const result = parsePivotJsonImport(importJsonDraft); if (result.error) { @@ -1140,10 +1211,15 @@ function PivotLabPage() { } } + const { entries: annotatedEntries, duplicateWarnings } = + await annotateJsonEntriesWithDuplicates(entries); + entries = annotatedEntries; + setJsonImportPreview({ label: result.label, entries, tmdbMatch: { matched, failed, pending: pendingFilmCount }, + duplicateWarnings, }); if (pendingFilmCount) { @@ -1157,7 +1233,12 @@ function PivotLabPage() { type: matched ? (failed ? 'warning' : 'success') : 'warning', }); } - }, [addNotification, importJsonDraft, syncJsonImportDraftFromEntries]); + }, [ + addNotification, + annotateJsonEntriesWithDuplicates, + importJsonDraft, + syncJsonImportDraftFromEntries, + ]); const handleLoadJsonImport = useCallback(async () => { const result = @@ -1201,11 +1282,17 @@ function PivotLabPage() { } } + const { entries: annotatedEntries, duplicateWarnings } = + await annotateJsonEntriesWithDuplicates(entries); + entries = annotatedEntries; + const updateCount = entries.filter((entry) => entry.duplicate?.willUpdate).length; + setImportError(''); setJsonImportPreview({ label: result.label, entries, tmdbMatch: { matched, failed, pending: pendingFilmCount }, + duplicateWarnings, }); setImportMode('batch'); setImportUrl(''); @@ -1225,6 +1312,11 @@ function PivotLabPage() { `${failed} film event(s) could not be matched to TMDB — use Retry on those rows.`, ); } + if (updateCount) { + loadWarnings.push( + `${updateCount} event(s) match existing catalog rows and will update them on publish.`, + ); + } setImportWarnings(loadWarnings); setImportDuplicate(null); setDeckPreviewState(null); @@ -1235,10 +1327,18 @@ function PivotLabPage() { message: matched > 0 ? `${entries.length} event(s) loaded · ${matched} film(s) matched from TMDB.` - : `${entries.length} event(s) ready for review.`, + : `${entries.length} event(s) ready for review${ + updateCount ? ` · ${updateCount} will update existing rows` : '' + }.`, type: 'success', }); - }, [addNotification, importJsonDraft, jsonImportPreview, syncJsonImportDraftFromEntries]); + }, [ + addNotification, + annotateJsonEntriesWithDuplicates, + importJsonDraft, + jsonImportPreview, + syncJsonImportDraftFromEntries, + ]); const handleMatchTmdbForJsonEntry = useCallback( async (index) => { @@ -1460,7 +1560,7 @@ function PivotLabPage() { return; } - updateBatchImportRow(rowKey, { tags: result.tags }); + updateBatchImportRow(rowKey, { tags: result.tags, aiTagged: true }); }, [addNotification, buildTagSuggestPayload, importBatchRows, requestSuggestedTags, updateBatchImportRow], ); @@ -1468,58 +1568,105 @@ function PivotLabPage() { const suggestTagsForSelectedBatchRows = useCallback(async () => { if (!selectedBatchRows.length) return; + // Resume-friendly: target selected rows that haven't been through Claude yet. If every + // selected row is already AI-tagged, treat the click as a deliberate re-run of all of them. + const pending = selectedBatchRows.filter((row) => !row.aiTagged); + const targetRows = pending.length ? pending : selectedBatchRows; + const total = targetRows.length; + setTagSuggestLoadingKey('batch-all'); - const { data, error } = await authenticatedRequest('/admin/pivot/ingest/suggest-tags', { - method: 'POST', - data: { - events: selectedBatchRows.map((row) => - buildTagSuggestPayload({ - name: row.name, - description: row.description, - location: row.location, - organizerName: row.organizerName, - sourceTags: row.sourceTags, - }), + setBatchTagProgress({ done: 0, total }); + + let suggestedTotal = 0; + let failedTotal = 0; + let stoppedError = null; + + // Chunk the selection into several short requests so one slow/large call can't trip the + // production gateway timeout, and each chunk's tags are saved as soon as it returns. + for (let start = 0; start < targetRows.length; start += AI_TAG_CHUNK_SIZE) { + const chunk = targetRows.slice(start, start + AI_TAG_CHUNK_SIZE); + + const { data, error } = await authenticatedRequest('/admin/pivot/ingest/suggest-tags', { + method: 'POST', + data: { + events: chunk.map((row) => + buildTagSuggestPayload({ + name: row.name, + description: row.description, + location: row.location, + organizerName: row.organizerName, + sourceTags: row.sourceTags, + }), + ), + }, + }); + + if (error || !data?.success) { + // A transport-level failure (gateway 503/timeout) carries no structured body. Stop + // here so already-processed chunks stay saved and the user can click again to resume. + stoppedError = { + message: error || data?.message || 'Could not reach the tag suggestion service.', + type: data?.code === 'LLM_NOT_CONFIGURED' ? 'warning' : 'error', + }; + break; + } + + const suggestions = data.data?.suggestions || []; + failedTotal += data.data?.failedCount ?? 0; + + const tagsByKey = new Map(); + for (let i = 0; i < chunk.length; i += 1) { + const tags = suggestions[i]?.tags || []; + if (tags.length) suggestedTotal += 1; + tagsByKey.set(chunk[i].key, tags); + } + + setImportBatchRows((rows) => + rows.map((row) => + tagsByKey.has(row.key) + ? { + ...row, + tags: tagsByKey.get(row.key).length ? tagsByKey.get(row.key) : row.tags || [], + aiTagged: true, + } + : row, ), - }, - }); + ); + + setBatchTagProgress({ done: Math.min(start + chunk.length, total), total }); + } + setTagSuggestLoadingKey(null); + setBatchTagProgress(null); - if (error || !data?.success) { + if (stoppedError) { addNotification({ - title: 'Batch tag suggestion failed', - message: error || data?.message || 'Could not suggest tags.', - type: data?.code === 'LLM_NOT_CONFIGURED' ? 'warning' : 'error', + title: suggestedTotal + ? 'Tagging interrupted — partial results saved' + : 'Batch tag suggestion failed', + message: suggestedTotal + ? `${suggestedTotal} row(s) tagged before the error — click again to resume the rest. (${stoppedError.message})` + : stoppedError.message, + type: suggestedTotal ? 'warning' : stoppedError.type, }); return; } - const suggestions = data.data?.suggestions || []; - const failedCount = data.data?.failedCount ?? 0; - const suggestedCount = data.data?.suggestedCount ?? 0; - setImportBatchRows((rows) => - rows.map((row) => { - const selectedIndex = selectedBatchRows.findIndex((entry) => entry.key === row.key); - if (selectedIndex === -1) return row; - return { ...row, tags: suggestions[selectedIndex]?.tags || row.tags || [] }; - }), - ); - - if (suggestedCount === 0) { + if (suggestedTotal === 0) { addNotification({ title: 'No tags suggested', - message: data.data?.failures?.[0]?.message || 'Claude did not return valid catalog tags.', + message: 'Claude did not return valid catalog tags for the selected rows.', type: 'warning', }); return; } addNotification({ - title: failedCount ? 'Batch partially tagged' : 'Batch tags suggested', - message: failedCount - ? `${suggestedCount} row(s) tagged, ${failedCount} failed.` - : `${suggestedCount} row(s) tagged via Claude.`, - type: failedCount ? 'warning' : 'success', + title: failedTotal ? 'Batch partially tagged' : 'Batch tags suggested', + message: failedTotal + ? `${suggestedTotal} row(s) tagged, ${failedTotal} failed.` + : `${suggestedTotal} row(s) tagged via Claude.`, + type: failedTotal ? 'warning' : 'success', }); }, [addNotification, buildTagSuggestPayload, selectedBatchRows]); @@ -1657,14 +1804,17 @@ function PivotLabPage() { const publishedCount = data.data?.publishedCount ?? data.data?.published?.length ?? 0; const failedCount = data.data?.failedCount ?? data.data?.failures?.length ?? 0; + const updatedCount = data.data?.updatedCount ?? 0; + const createdCount = Math.max(publishedCount - updatedCount, 0); + const updatedSuffix = updatedCount ? ` (${updatedCount} updated existing)` : ''; refetchEvents(); refetchOverview(); addNotification({ title: failedCount ? 'Batch partially published' : 'Batch published', message: failedCount - ? `${publishedCount} event(s) published, ${failedCount} failed.` - : `${publishedCount} event(s) added to ${selectedTenantKey}.`, + ? `${createdCount} added, ${updatedCount} updated, ${failedCount} failed.` + : `${publishedCount} event(s) added to ${selectedTenantKey}${updatedSuffix}.`, type: failedCount ? 'warning' : 'success', }); }, [ @@ -1722,9 +1872,12 @@ function PivotLabPage() { refetchEvents(); refetchOverview(); + const wasUpdated = data.data?.updated; addNotification({ - title: 'Published', - message: `${data.data?.event?.name || 'Event'} added to ${selectedTenantKey}.`, + title: wasUpdated ? 'Updated' : 'Published', + message: `${data.data?.event?.name || 'Event'} ${ + wasUpdated ? 'updated in' : 'added to' + } ${selectedTenantKey}.`, type: 'success', }); }, [ @@ -2062,6 +2215,7 @@ function PivotLabPage() { + @@ -2070,6 +2224,7 @@ function PivotLabPage() { + @@ -2083,6 +2238,9 @@ function PivotLabPage() { key={`${draft.name || 'event'}-${index}`} className={ready ? '' : 'pivot-lab__json-preview-row--warn'} > + @@ -2097,6 +2255,26 @@ function PivotLabPage() { Needs review )} +
Image Event Organizer WhenTags Film StatusCatalog TMDB
+ + {draft.name || '—'} {draft.hostName || '—'} {formatEventWhen(draft.start_time || draft.timeSlots?.[0]?.start_time)} + {entry.duplicate ? ( + + {duplicateBadgeLabel(entry.duplicate)} + + ) : ( + '—' + )} + {draft.movie?.tmdbId ? ( '—' @@ -2120,6 +2298,13 @@ function PivotLabPage() {
+ {jsonImportPreview.duplicateWarnings?.length ? ( + + ) : null} {jsonImportPreview.entries.some((entry) => entry.warnings?.length) ? (