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 (
+ 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() {
| Image | Event | Organizer | When | @@ -2070,6 +2224,7 @@ function PivotLabPage() {Tags | Film | Status | +Catalog | TMDB |
+ |
{draft.name || '—'} | {draft.hostName || '—'} | {formatEventWhen(draft.start_time || draft.timeSlots?.[0]?.start_time)} | @@ -2097,6 +2255,26 @@ function PivotLabPage() { Needs review )} ++ {entry.duplicate ? ( + + {duplicateBadgeLabel(entry.duplicate)} + + ) : ( + '—' + )} + | {draft.movie?.tmdbId ? ( '—' @@ -2120,6 +2298,13 @@ function PivotLabPage() { |
|---|
- Cover image:{' '}
-
- preview
+ D2=(3%oO;h*lQ&_k@0?Zqv)xCuum+kJ;+9pDwY0A
z3V1m(@_ey(Lfb+?=%&^~J1}&X^i2f_F
diff --git a/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.scss b/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.scss
index e3464689..b2295630 100644
--- a/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.scss
+++ b/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.scss
@@ -677,11 +677,47 @@
}
&__import-image {
+ display: flex;
+ align-items: center;
+ gap: 10px;
margin: 0 0 8px;
font-size: 13px;
color: var(--la-text-secondary);
}
+ &__import-image-label {
+ color: var(--la-text-tertiary);
+ }
+
+ &__thumb {
+ width: 48px;
+ height: 48px;
+ border-radius: var(--la-radius-sm);
+ object-fit: cover;
+ display: block;
+ background: var(--la-bg);
+ border: 1px solid var(--la-border-subtle);
+ }
+
+ &__thumb--empty {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--la-text-tertiary);
+ background: var(--la-bg);
+ border-style: dashed;
+ }
+
+ &__thumb-cell {
+ width: 60px;
+ padding-right: 4px;
+ }
+
+ &__thumb-link {
+ display: inline-flex;
+ line-height: 0;
+ }
+
&__import-warnings {
margin: 12px 0 0;
padding-left: 18px;
@@ -876,6 +912,19 @@
min-height: 28px;
}
+ &__ai-done {
+ display: inline-block;
+ margin-top: 6px;
+ margin-left: 6px;
+ padding: 2px 6px;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--la-accent);
+ background: var(--la-accent-soft);
+ border-radius: var(--la-radius-sm);
+ vertical-align: top;
+ }
+
&__tmdb-btn {
padding-inline: 10px;
min-height: 28px;
From cf4384b5d38c02df5c340ae258bb560924cb9bac Mon Sep 17 00:00:00 2001
From: AZ0228 <53315675+AZ0228@users.noreply.github.com>
Date: Fri, 10 Jul 2026 17:01:57 -0700
Subject: [PATCH 2/7] =?UTF-8?q?MER-192:=20Phase=200=20=E2=80=94=20ingest?=
=?UTF-8?q?=20status=20contract=20and=20PivotBatch=20schema?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Centralize staged/published eligibility, add per-tenant batch records, and share ISO-week helpers used by the ops dashboard.
---
backend/migrations/sendPivotWeeklyPush.js | 3 +-
backend/schemas/pivotBatch.js | 51 +++++++++
backend/services/getModelService.js | 6 +
backend/services/pivotFeedService.js | 6 +-
backend/services/pivotFeedbackService.js | 3 +-
backend/services/pivotIntentService.js | 5 +-
backend/services/pivotLabEventsService.js | 14 ++-
backend/services/pivotWeeklyDropService.js | 3 +-
.../services/pivotWeeklySnapshotService.js | 3 +-
backend/tests/unit/pivotFeedService.test.js | 88 +++++++++++++++
backend/tests/unit/pivotIngestStatus.test.js | 34 ++++++
backend/tests/unit/pivotIsoWeek.test.js | 54 ++++++++-
backend/utilities/pivotIngestStatus.js | 41 +++++++
backend/utilities/pivotIsoWeek.js | 74 ++++++++++++
frontend/src/utils/pivotIsoWeek.js | 105 ++++++++++++++++++
15 files changed, 477 insertions(+), 13 deletions(-)
create mode 100644 backend/schemas/pivotBatch.js
create mode 100644 backend/tests/unit/pivotIngestStatus.test.js
create mode 100644 backend/utilities/pivotIngestStatus.js
diff --git a/backend/migrations/sendPivotWeeklyPush.js b/backend/migrations/sendPivotWeeklyPush.js
index 51f4353e..884af8cc 100644
--- a/backend/migrations/sendPivotWeeklyPush.js
+++ b/backend/migrations/sendPivotWeeklyPush.js
@@ -22,6 +22,7 @@ const {
isPivotTenant,
resolvePivotDropInstant,
} = require('../utilities/pivotDropSchedule');
+const { PIVOT_FEED_INGEST_STATUS } = require('../utilities/pivotIngestStatus');
const CONFIG_KEY = 'default';
const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send';
@@ -66,7 +67,7 @@ async function countPublishedEvents(req, batchWeek) {
const { Event } = getModels(req, 'Event');
return Event.countDocuments({
'customFields.pivot.batchWeek': batchWeek,
- 'customFields.pivot.ingestStatus': 'published',
+ 'customFields.pivot.ingestStatus': PIVOT_FEED_INGEST_STATUS,
});
}
diff --git a/backend/schemas/pivotBatch.js b/backend/schemas/pivotBatch.js
new file mode 100644
index 00000000..9e119a00
--- /dev/null
+++ b/backend/schemas/pivotBatch.js
@@ -0,0 +1,51 @@
+const mongoose = require('mongoose');
+const { isValidIsoWeek } = require('../utilities/pivotIsoWeek');
+
+/**
+ * Per-city week ops progress for Curation readiness and release auditing.
+ * Tenant-scoped (city DB). Does not gate the feed under Choice A —
+ * feed keys only off event `customFields.pivot.ingestStatus === 'published'`.
+ */
+const PIVOT_BATCH_STATUSES = Object.freeze(['curating', 'ready', 'released']);
+
+const pivotBatchSchema = new mongoose.Schema(
+ {
+ batchWeek: {
+ type: String,
+ required: true,
+ trim: true,
+ validate: {
+ validator(value) {
+ return isValidIsoWeek(value);
+ },
+ message: 'batchWeek must be ISO week format YYYY-Www',
+ },
+ },
+ status: {
+ type: String,
+ enum: PIVOT_BATCH_STATUSES,
+ required: true,
+ default: 'curating',
+ },
+ targetEventCount: {
+ type: Number,
+ min: 0,
+ default: 40,
+ },
+ releasedAt: {
+ type: Date,
+ default: null,
+ },
+ releasedBy: {
+ type: String,
+ default: null,
+ trim: true,
+ },
+ },
+ { timestamps: true },
+);
+
+pivotBatchSchema.index({ batchWeek: 1 }, { unique: true });
+
+module.exports = pivotBatchSchema;
+module.exports.PIVOT_BATCH_STATUSES = PIVOT_BATCH_STATUSES;
diff --git a/backend/services/getModelService.js b/backend/services/getModelService.js
index b93ca429..84126656 100644
--- a/backend/services/getModelService.js
+++ b/backend/services/getModelService.js
@@ -65,6 +65,7 @@ const orgEquipmentSchema = require('../schemas/OrgEquipment');
const analyticsEventSchema = require('../events/schemas/analyticsEvent');
const eventQRSchema = require('../events/schemas/eventQR');
const pivotEventIntentSchema = require('../schemas/pivotEventIntent');
+const pivotBatchSchema = require('../schemas/pivotBatch');
const registeredConnections = new WeakSet();
const MODEL_DEFINITIONS = Object.freeze({
BadgeGrant: { modelName: 'BadgeGrant', schema: badgeGrantSchema, collection: 'badgegrants' },
@@ -132,6 +133,11 @@ const MODEL_DEFINITIONS = Object.freeze({
schema: pivotEventIntentSchema,
collection: 'pivotEventIntents',
},
+ PivotBatch: {
+ modelName: 'PivotBatch',
+ schema: pivotBatchSchema,
+ collection: 'pivotBatches',
+ },
ResourcesConfig: { modelName: 'ResourcesConfig', schema: resourcesConfigSchema, collection: 'resourcesConfigs' },
ShuttleConfig: { modelName: 'ShuttleConfig', schema: shuttleConfigSchema, collection: 'shuttleConfigs' },
NoticeConfig: { modelName: 'NoticeConfig', schema: noticeConfigSchema, collection: 'noticeConfigs' },
diff --git a/backend/services/pivotFeedService.js b/backend/services/pivotFeedService.js
index cbb1a912..e80febb4 100644
--- a/backend/services/pivotFeedService.js
+++ b/backend/services/pivotFeedService.js
@@ -13,6 +13,7 @@ const {
resolvePivotCoverImageUrl,
} = require('../utilities/pivotMovieMetadata');
const { logPivot, pivotRequestContext } = require('../utilities/pivotLogger');
+const { PIVOT_FEED_INGEST_STATUS } = require('../utilities/pivotIngestStatus');
const FRIEND_CAP = 5;
const PIVOT_EVENT_STATUSES = ['approved', 'not-applicable'];
@@ -507,9 +508,10 @@ async function getPivotFeed(req, options = {}) {
const { Event } = getModels(req, 'Event');
const excludeEventIds = normalizeExcludeEventIds(options.excludeEventIds);
+ // Choice A: draft/staged never appear; only published after explicit Release.
const query = {
'customFields.pivot.batchWeek': batchWeek,
- 'customFields.pivot.ingestStatus': 'published',
+ 'customFields.pivot.ingestStatus': PIVOT_FEED_INGEST_STATUS,
status: { $in: PIVOT_EVENT_STATUSES },
isDeleted: { $ne: true },
'customFields.pivot.host.name': { $exists: true, $nin: [null, ''] },
@@ -624,7 +626,7 @@ async function getPivotEventFriends(req, eventId) {
const { Event } = getModels(req, 'Event');
const event = await Event.findOne({
_id: eventKey,
- 'customFields.pivot.ingestStatus': 'published',
+ 'customFields.pivot.ingestStatus': PIVOT_FEED_INGEST_STATUS,
status: { $in: PIVOT_EVENT_STATUSES },
isDeleted: { $ne: true },
'customFields.pivot.host.name': { $exists: true, $nin: [null, ''] },
diff --git a/backend/services/pivotFeedbackService.js b/backend/services/pivotFeedbackService.js
index dbbaa492..feea64dc 100644
--- a/backend/services/pivotFeedbackService.js
+++ b/backend/services/pivotFeedbackService.js
@@ -6,6 +6,7 @@ const {
serializeRecapEvent,
} = require('./pivotIntentService');
const { resolveDisplayHost, PIVOT_EVENT_STATUSES } = require('./pivotFeedService');
+const { PIVOT_FEED_INGEST_STATUS } = require('../utilities/pivotIngestStatus');
const PIVOT_EVENT_FEATURE = 'pivot_event';
const RECAP_EVENT_FIELDS =
@@ -56,7 +57,7 @@ async function getPendingEventFeedback(req, options = {}) {
const events = await Event.find({
_id: { $in: eventIds },
end_time: { $lt: now },
- 'customFields.pivot.ingestStatus': 'published',
+ 'customFields.pivot.ingestStatus': PIVOT_FEED_INGEST_STATUS,
status: { $in: PIVOT_EVENT_STATUSES },
isDeleted: { $ne: true },
'customFields.pivot.host.name': { $exists: true, $nin: [null, ''] },
diff --git a/backend/services/pivotIntentService.js b/backend/services/pivotIntentService.js
index 02efb09d..a823cc6e 100644
--- a/backend/services/pivotIntentService.js
+++ b/backend/services/pivotIntentService.js
@@ -14,6 +14,7 @@ const {
findTimeSlotById,
eventHasTimeSlots,
} = require('../utilities/pivotTimeSlots');
+const { PIVOT_FEED_INGEST_STATUS } = require('../utilities/pivotIngestStatus');
const FEED_ACTION_TO_STATUS = {
interested: 'interested',
@@ -38,7 +39,7 @@ async function findPublishedPivotEvent(req, eventId, { now, requireWindow } = {}
const baseQuery = {
_id: eventId,
- 'customFields.pivot.ingestStatus': 'published',
+ 'customFields.pivot.ingestStatus': PIVOT_FEED_INGEST_STATUS,
status: { $in: PIVOT_EVENT_STATUSES },
isDeleted: { $ne: true },
'customFields.pivot.host.name': { $exists: true, $nin: [null, ''] },
@@ -347,7 +348,7 @@ async function getWeekRecap(req, options = {}) {
const events = await Event.find({
_id: { $in: eventIds },
- 'customFields.pivot.ingestStatus': 'published',
+ 'customFields.pivot.ingestStatus': PIVOT_FEED_INGEST_STATUS,
status: { $in: PIVOT_EVENT_STATUSES },
isDeleted: { $ne: true },
'customFields.pivot.host.name': { $exists: true, $nin: [null, ''] },
diff --git a/backend/services/pivotLabEventsService.js b/backend/services/pivotLabEventsService.js
index 08a5ae6a..65160546 100644
--- a/backend/services/pivotLabEventsService.js
+++ b/backend/services/pivotLabEventsService.js
@@ -58,14 +58,22 @@ function serializeLabEvent(event, intentStatsByEventId) {
};
}
-/** Per-event intent counts so Lab can see which catalog events earned the swipes. */
-async function loadIntentStatsByEventId(PivotEventIntent, eventIds) {
+/**
+ * Per-event intent counts so Lab / tenant ops can see which catalog events earned the swipes.
+ * Optional `batchWeek` scopes intents to that ISO week (preferred for performance rankings).
+ */
+async function loadIntentStatsByEventId(PivotEventIntent, eventIds, options = {}) {
if (!eventIds.length) {
return new Map();
}
+ const match = { eventId: { $in: eventIds } };
+ if (options.batchWeek) {
+ match.batchWeek = options.batchWeek;
+ }
+
const rows = await PivotEventIntent.aggregate([
- { $match: { eventId: { $in: eventIds } } },
+ { $match: match },
{
$group: {
_id: '$eventId',
diff --git a/backend/services/pivotWeeklyDropService.js b/backend/services/pivotWeeklyDropService.js
index 8690221d..d52adf41 100644
--- a/backend/services/pivotWeeklyDropService.js
+++ b/backend/services/pivotWeeklyDropService.js
@@ -10,6 +10,7 @@ const {
DAY_NAMES,
isPivotTenant,
} = require('../utilities/pivotDropSchedule');
+const { PIVOT_FEED_INGEST_STATUS } = require('../utilities/pivotIngestStatus');
const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send';
const EXPO_BATCH_SIZE = 100;
@@ -46,7 +47,7 @@ async function countPublishedEvents(tenantKey, batchWeek) {
const { Event } = getModels(req, 'Event');
return Event.countDocuments({
'customFields.pivot.batchWeek': batchWeek,
- 'customFields.pivot.ingestStatus': 'published',
+ 'customFields.pivot.ingestStatus': PIVOT_FEED_INGEST_STATUS,
});
}
diff --git a/backend/services/pivotWeeklySnapshotService.js b/backend/services/pivotWeeklySnapshotService.js
index d2ca2047..2480ebc5 100644
--- a/backend/services/pivotWeeklySnapshotService.js
+++ b/backend/services/pivotWeeklySnapshotService.js
@@ -6,6 +6,7 @@ const { connectToDatabase } = require('../connectionsManager');
const { PIVOT_EVENT_STATUSES } = require('./pivotFeedService');
const { PIVOT_EVENT_FEATURE } = require('./pivotFeedbackService');
const { toIsoWeek, isValidIsoWeek, isoWeekToUtcRange } = require('../utilities/pivotIsoWeek');
+const { PIVOT_FEED_INGEST_STATUS } = require('../utilities/pivotIngestStatus');
/** Mobile analytics event names surfaced as Lab engagement metrics. */
const ENGAGEMENT_EVENTS = {
@@ -41,7 +42,7 @@ async function aggregateEngagementMetrics(tenantReq, batchWeek) {
const PUBLISHED_EVENT_QUERY = (batchWeek) => ({
'customFields.pivot.batchWeek': batchWeek,
- 'customFields.pivot.ingestStatus': 'published',
+ 'customFields.pivot.ingestStatus': PIVOT_FEED_INGEST_STATUS,
status: { $in: PIVOT_EVENT_STATUSES },
isDeleted: { $ne: true },
'customFields.pivot.host.name': { $exists: true, $nin: [null, ''] },
diff --git a/backend/tests/unit/pivotFeedService.test.js b/backend/tests/unit/pivotFeedService.test.js
index f19266ee..d170d3f4 100644
--- a/backend/tests/unit/pivotFeedService.test.js
+++ b/backend/tests/unit/pivotFeedService.test.js
@@ -460,6 +460,94 @@ describe('getPivotFeed', () => {
expect(result.data.events).toEqual([]);
});
+ it('queries only published ingestStatus (Choice A: staged/draft excluded at DB filter)', async () => {
+ const eventFind = mockEventFind([]);
+ const Event = { find: jest.fn(() => eventFind) };
+ getModels.mockReturnValue(withFeedModels({
+ Event,
+ Friendship: {
+ find: jest.fn(() => ({
+ select: jest.fn().mockReturnThis(),
+ lean: jest.fn().mockResolvedValue([]),
+ })),
+ },
+ PivotEventIntent: { find: jest.fn(() => mockIntentFind()) },
+ User: mockUserModel(),
+ }));
+
+ await getPivotFeed(req, { batchWeek: '2026-W22', now });
+
+ expect(Event.find).toHaveBeenCalledWith(
+ expect.objectContaining({
+ 'customFields.pivot.batchWeek': '2026-W22',
+ 'customFields.pivot.ingestStatus': 'published',
+ }),
+ );
+ expect(Event.find.mock.calls[0][0]['customFields.pivot.ingestStatus']).not.toBe('staged');
+ expect(Event.find.mock.calls[0][0]['customFields.pivot.ingestStatus']).not.toBe('draft');
+ });
+
+ it('includes published events for the requested batchWeek after release', async () => {
+ const events = [
+ {
+ _id: '665a1b2c3d4e5f6789012345',
+ name: 'Released Party',
+ start_time: new Date('2026-05-28T22:00:00.000Z'),
+ end_time: new Date('2026-05-29T02:00:00.000Z'),
+ registrationCount: 2,
+ customFields: {
+ pivot: {
+ batchWeek: '2026-W22',
+ ingestStatus: 'published',
+ host: { name: 'Roof Records' },
+ },
+ },
+ },
+ ];
+
+ const eventFind = mockEventFind(events);
+ getModels.mockReturnValue(withFeedModels({
+ Event: { find: jest.fn(() => eventFind) },
+ Friendship: {
+ find: jest.fn(() => ({
+ select: jest.fn().mockReturnThis(),
+ lean: jest.fn().mockResolvedValue([]),
+ })),
+ },
+ PivotEventIntent: { find: jest.fn(() => mockIntentFind()) },
+ User: mockUserModel(),
+ }));
+
+ const result = await getPivotFeed(req, { batchWeek: '2026-W22', now });
+
+ expect(result.data.events).toHaveLength(1);
+ expect(result.data.events[0].name).toBe('Released Party');
+ });
+
+ it('scopes feed to the requested batchWeek (wrong week excluded by query)', async () => {
+ const eventFind = mockEventFind([]);
+ const Event = { find: jest.fn(() => eventFind) };
+ getModels.mockReturnValue(withFeedModels({
+ Event,
+ Friendship: {
+ find: jest.fn(() => ({
+ select: jest.fn().mockReturnThis(),
+ lean: jest.fn().mockResolvedValue([]),
+ })),
+ },
+ PivotEventIntent: { find: jest.fn(() => mockIntentFind()) },
+ User: mockUserModel(),
+ }));
+
+ await getPivotFeed(req, { batchWeek: '2026-W22', now });
+
+ expect(Event.find).toHaveBeenCalledWith(
+ expect.objectContaining({
+ 'customFields.pivot.batchWeek': '2026-W22',
+ }),
+ );
+ });
+
it('rejects invalid batchWeek', async () => {
const result = await getPivotFeed(req, { batchWeek: '2026-W999', now });
expect(result.error).toMatch(/batchWeek/i);
diff --git a/backend/tests/unit/pivotIngestStatus.test.js b/backend/tests/unit/pivotIngestStatus.test.js
new file mode 100644
index 00000000..f3e05e04
--- /dev/null
+++ b/backend/tests/unit/pivotIngestStatus.test.js
@@ -0,0 +1,34 @@
+const {
+ PIVOT_INGEST_STATUSES,
+ PIVOT_FEED_INGEST_STATUS,
+ isValidIngestStatus,
+ isFeedEligibleIngestStatus,
+ normalizeIngestStatus,
+} = require('../../utilities/pivotIngestStatus');
+
+describe('pivotIngestStatus', () => {
+ it('allows draft, staged, and published', () => {
+ expect(PIVOT_INGEST_STATUSES).toEqual(['draft', 'staged', 'published']);
+ expect(isValidIngestStatus('draft')).toBe(true);
+ expect(isValidIngestStatus('staged')).toBe(true);
+ expect(isValidIngestStatus('published')).toBe(true);
+ expect(isValidIngestStatus('live')).toBe(false);
+ });
+
+ it('treats only published as feed-eligible (Choice A)', () => {
+ expect(PIVOT_FEED_INGEST_STATUS).toBe('published');
+ expect(isFeedEligibleIngestStatus('published')).toBe(true);
+ expect(isFeedEligibleIngestStatus('staged')).toBe(false);
+ expect(isFeedEligibleIngestStatus('draft')).toBe(false);
+ });
+
+ it('normalizes valid statuses and rejects others', () => {
+ expect(normalizeIngestStatus(' staged ')).toEqual({ ingestStatus: 'staged' });
+ expect(normalizeIngestStatus('bogus')).toEqual(
+ expect.objectContaining({
+ status: 400,
+ code: 'INVALID_INGEST_STATUS',
+ }),
+ );
+ });
+});
diff --git a/backend/tests/unit/pivotIsoWeek.test.js b/backend/tests/unit/pivotIsoWeek.test.js
index a8f173e2..ed253592 100644
--- a/backend/tests/unit/pivotIsoWeek.test.js
+++ b/backend/tests/unit/pivotIsoWeek.test.js
@@ -4,6 +4,8 @@ const {
isoWeekToMondayUtc,
isoWeekToUtcRange,
shiftIsoWeek,
+ batchWeekFromEventDate,
+ resolveEventBatchWeek,
} = require('../../utilities/pivotIsoWeek');
describe('pivotIsoWeek', () => {
@@ -43,9 +45,57 @@ describe('pivotIsoWeek', () => {
expect(shiftIsoWeek('2026-W27', 0)).toBe('2026-W27');
});
- it('crosses ISO year boundaries', () => {
+ it('crosses year boundaries', () => {
expect(shiftIsoWeek('2026-W01', -1)).toBe('2025-W52');
- expect(shiftIsoWeek('2025-W52', 1)).toBe('2026-W01');
+ });
+ });
+
+ describe('batchWeekFromEventDate', () => {
+ it('derives ISO week from a start datetime', () => {
+ // Monday 2026-06-29 is in 2026-W27.
+ expect(batchWeekFromEventDate('2026-06-29T20:00:00.000Z')).toBe('2026-W27');
+ expect(batchWeekFromEventDate(new Date('2026-07-10T18:00:00.000Z'))).toBe('2026-W28');
+ });
+
+ it('returns null for invalid values', () => {
+ expect(batchWeekFromEventDate(null)).toBeNull();
+ expect(batchWeekFromEventDate('not-a-date')).toBeNull();
+ });
+ });
+
+ describe('resolveEventBatchWeek', () => {
+ it('uses event start date by default', () => {
+ const result = resolveEventBatchWeek({
+ batchWeek: '2026-W30',
+ startTime: '2026-06-29T20:00:00.000Z',
+ });
+ expect(result).toEqual({ batchWeek: '2026-W27', source: 'event-date' });
+ });
+
+ it('uses first time-slot when startTime missing', () => {
+ const result = resolveEventBatchWeek({
+ timeSlots: [{ start_time: '2026-07-10T18:00:00.000Z' }],
+ });
+ expect(result).toEqual({ batchWeek: '2026-W28', source: 'event-date' });
+ });
+
+ it('honors forceBatchWeek override', () => {
+ const result = resolveEventBatchWeek({
+ forceBatchWeek: true,
+ batchWeek: '2026-W30',
+ startTime: '2026-06-29T20:00:00.000Z',
+ });
+ expect(result).toEqual({ batchWeek: '2026-W30', source: 'forced' });
+ });
+
+ it('requires batchWeek when forcing', () => {
+ const result = resolveEventBatchWeek({ forceBatchWeek: true });
+ expect(result.code).toBe('BATCH_WEEK_REQUIRED');
+ });
+
+ it('falls back to provided batchWeek when undated', () => {
+ const result = resolveEventBatchWeek({ batchWeek: '2026-W30' });
+ expect(result).toEqual({ batchWeek: '2026-W30', source: 'fallback' });
});
});
});
diff --git a/backend/utilities/pivotIngestStatus.js b/backend/utilities/pivotIngestStatus.js
new file mode 100644
index 00000000..bfcd1aa3
--- /dev/null
+++ b/backend/utilities/pivotIngestStatus.js
@@ -0,0 +1,41 @@
+/**
+ * Pivot catalog ingestStatus lifecycle (Choice A feed gate).
+ * @see Meridian-Mintlify/strategy/pivot-metadata-contract.mdx
+ */
+
+const PIVOT_INGEST_STATUSES = Object.freeze(['draft', 'staged', 'published']);
+
+/** Only `published` events are eligible for GET /pivot/feed (Choice A). */
+const PIVOT_FEED_INGEST_STATUS = 'published';
+
+function isValidIngestStatus(value) {
+ return PIVOT_INGEST_STATUSES.includes(value);
+}
+
+function isFeedEligibleIngestStatus(value) {
+ return value === PIVOT_FEED_INGEST_STATUS;
+}
+
+/**
+ * @param {unknown} value
+ * @returns {{ ingestStatus: string } | { error: string, status: number, code: string }}
+ */
+function normalizeIngestStatus(value) {
+ const ingestStatus = typeof value === 'string' ? value.trim() : '';
+ if (!isValidIngestStatus(ingestStatus)) {
+ return {
+ error: 'ingestStatus must be draft, staged, or published.',
+ status: 400,
+ code: 'INVALID_INGEST_STATUS',
+ };
+ }
+ return { ingestStatus };
+}
+
+module.exports = {
+ PIVOT_INGEST_STATUSES,
+ PIVOT_FEED_INGEST_STATUS,
+ isValidIngestStatus,
+ isFeedEligibleIngestStatus,
+ normalizeIngestStatus,
+};
diff --git a/backend/utilities/pivotIsoWeek.js b/backend/utilities/pivotIsoWeek.js
index eef56896..71dba6c6 100644
--- a/backend/utilities/pivotIsoWeek.js
+++ b/backend/utilities/pivotIsoWeek.js
@@ -69,11 +69,85 @@ function shiftIsoWeek(batchWeek, delta) {
return toIsoWeekUtc(monday);
}
+/**
+ * Derive batchWeek (YYYY-Www) from an event's actual start datetime.
+ * Uses the event's local calendar date via Date getters (same as toIsoWeek).
+ * @param {Date|string|number|null|undefined} value
+ * @returns {string|null}
+ */
+function batchWeekFromEventDate(value) {
+ if (value == null || value === '') return null;
+ const date = value instanceof Date ? value : new Date(value);
+ if (Number.isNaN(date.getTime())) return null;
+ return toIsoWeek(date);
+}
+
+/**
+ * Resolve which batch week an ingest should land in.
+ *
+ * Default: ISO week of the event start (or first time-slot start).
+ * Override: pass forceBatchWeek + batchWeek to pin every write to a week.
+ * Fallback: batchWeek (when no event date) → current ISO week.
+ *
+ * @param {{
+ * forceBatchWeek?: boolean,
+ * batchWeek?: string|null,
+ * startTime?: Date|string|null,
+ * timeSlots?: Array<{ start_time?: Date|string }>,
+ * now?: Date,
+ * }} options
+ * @returns {{ batchWeek: string, source: 'forced'|'event-date'|'fallback'|'current' } | { error, status, code }}
+ */
+function resolveEventBatchWeek(options = {}) {
+ const now = options.now || new Date();
+ const forced = Boolean(options.forceBatchWeek);
+ const explicit = typeof options.batchWeek === 'string' ? options.batchWeek.trim() : '';
+
+ if (forced) {
+ if (!explicit || !isValidIsoWeek(explicit)) {
+ return {
+ error: 'batchWeek is required when forceBatchWeek is set (YYYY-Www).',
+ status: 400,
+ code: 'BATCH_WEEK_REQUIRED',
+ };
+ }
+ return { batchWeek: explicit, source: 'forced' };
+ }
+
+ const fromStart = batchWeekFromEventDate(options.startTime);
+ if (fromStart) {
+ return { batchWeek: fromStart, source: 'event-date' };
+ }
+
+ const slots = Array.isArray(options.timeSlots) ? options.timeSlots : [];
+ for (const slot of slots) {
+ const fromSlot = batchWeekFromEventDate(slot?.start_time);
+ if (fromSlot) {
+ return { batchWeek: fromSlot, source: 'event-date' };
+ }
+ }
+
+ if (explicit) {
+ if (!isValidIsoWeek(explicit)) {
+ return {
+ error: 'batchWeek must be ISO format YYYY-Www (e.g. 2026-W21).',
+ status: 400,
+ code: 'INVALID_BATCH_WEEK',
+ };
+ }
+ return { batchWeek: explicit, source: 'fallback' };
+ }
+
+ return { batchWeek: toIsoWeek(now), source: 'current' };
+}
+
module.exports = {
toIsoWeek,
isValidIsoWeek,
isoWeekToMondayUtc,
isoWeekToUtcRange,
shiftIsoWeek,
+ batchWeekFromEventDate,
+ resolveEventBatchWeek,
ISO_WEEK_PATTERN,
};
diff --git a/frontend/src/utils/pivotIsoWeek.js b/frontend/src/utils/pivotIsoWeek.js
index b2757e08..38cd45ce 100644
--- a/frontend/src/utils/pivotIsoWeek.js
+++ b/frontend/src/utils/pivotIsoWeek.js
@@ -43,6 +43,14 @@ export function shiftIsoWeek(batchWeek, delta) {
return `${thursday.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
}
+/** Derive YYYY-Www from an event start datetime; null if unparseable. */
+export function batchWeekFromEventDate(value) {
+ if (value == null || value === '') return null;
+ const date = value instanceof Date ? value : new Date(value);
+ if (Number.isNaN(date.getTime())) return null;
+ return toIsoWeek(date);
+}
+
export function formatEventWhen(iso) {
if (!iso) return '—';
const d = new Date(iso);
@@ -96,3 +104,100 @@ export function formatSnapshotAge(iso) {
minute: '2-digit',
});
}
+
+/**
+ * [start, end) UTC range for an ISO week (Monday 00:00 → next Monday 00:00).
+ * @returns {{ start: Date, end: Date } | null}
+ */
+export function isoWeekToUtcRange(batchWeek) {
+ const start = isoWeekToMondayUtc(batchWeek);
+ if (!start) return null;
+ const end = new Date(start.getTime() + 7 * 24 * 60 * 60 * 1000);
+ return { start, end };
+}
+
+/**
+ * Human-readable Mon–Sun range for a batch week, e.g. "Jun 29 – Jul 5, 2026".
+ */
+export function formatIsoWeekRange(batchWeek, options = {}) {
+ const range = isoWeekToUtcRange(batchWeek);
+ if (!range) return '—';
+ const { start, end } = range;
+ const lastDay = new Date(end.getTime() - 1);
+ const sameYear = start.getUTCFullYear() === lastDay.getUTCFullYear();
+ const startLabel = start.toLocaleDateString(undefined, {
+ month: 'short',
+ day: 'numeric',
+ ...(sameYear ? {} : { year: 'numeric' }),
+ timeZone: options.timeZone || 'UTC',
+ });
+ const endLabel = lastDay.toLocaleDateString(undefined, {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ timeZone: options.timeZone || 'UTC',
+ });
+ return `${startLabel} – ${endLabel}`;
+}
+
+/**
+ * Derive live / curate / post-mortem anchor weeks from "now" and the current ISO week's drop instant.
+ * Mirrors backend resolveRunBatchWeek next-drop logic.
+ *
+ * @param {Date} [now]
+ * @param {string|Date|null} currentWeekDropAt - drop instant for the current ISO week
+ */
+export function resolveCurationStageWeeks(now = new Date(), currentWeekDropAt = null) {
+ const currentWeek = toIsoWeek(now);
+ const dropMs = currentWeekDropAt ? new Date(currentWeekDropAt).getTime() : NaN;
+ const dropPending = Number.isFinite(dropMs) && dropMs > now.getTime();
+
+ const liveWeek = dropPending ? shiftIsoWeek(currentWeek, -1) : currentWeek;
+ const curateWeek = dropPending ? currentWeek : shiftIsoWeek(currentWeek, 1);
+ const postMortemWeek = shiftIsoWeek(liveWeek, -1);
+
+ return {
+ currentWeek,
+ liveWeek,
+ curateWeek,
+ postMortemWeek,
+ dropPending,
+ };
+}
+
+/**
+ * Which curation mode a batch week should use, based on its date vs the live week.
+ * - past of live → post-mortem
+ * - equal to live → live monitoring
+ * - after live → curate (upcoming)
+ *
+ * @param {string} batchWeek
+ * @param {{ liveWeek: string }} stageWeeks
+ * @returns {'post-mortem'|'live'|'curate'}
+ */
+export function resolveCurationStageForWeek(batchWeek, stageWeeks) {
+ if (!isValidIsoWeek(batchWeek) || !stageWeeks?.liveWeek) {
+ return 'curate';
+ }
+ if (batchWeek === stageWeeks.liveWeek) return 'live';
+ if (batchWeek > stageWeeks.liveWeek) return 'curate';
+ return 'post-mortem';
+}
+
+export const CURATION_STAGE_META = {
+ 'post-mortem': {
+ id: 'post-mortem',
+ label: 'Post-mortem',
+ description: 'This batch already dropped — review how it performed.',
+ },
+ live: {
+ id: 'live',
+ label: 'Live batch',
+ description: 'This batch is in the feed — monitor interest rates and reach.',
+ },
+ curate: {
+ id: 'curate',
+ label: 'Curate',
+ description: 'This batch is upcoming — crawl, tag, stage, and release.',
+ },
+};
From 3215e4afad56cdb03bd5d0ad7b7c45d94cf67837 Mon Sep 17 00:00:00 2001
From: AZ0228 <53315675+AZ0228@users.noreply.github.com>
Date: Fri, 10 Jul 2026 17:02:05 -0700
Subject: [PATCH 3/7] =?UTF-8?q?MER-192:=20Phase=201=20=E2=80=94=20per-tena?=
=?UTF-8?q?nt=20dashboard=20shell=20and=20week=20picker?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add /platform-admin/pivot/:tenantKey routing, Tenant Management entry, city switcher, shared page chrome, and debounced batch-week picker (Task 6.1).
---
frontend/src/App.js | 2 +
.../src/assets/pivot/fonts/LesFlosSans.otf | Bin 0 -> 269320 bytes
frontend/src/assets/pivot/just-go-burst.svg | 10 +
.../assets/pivot/just-go-wordmark-dark.svg | 8 +
.../src/components/Dashboard/Dashboard.jsx | 18 +-
frontend/src/config/tenantRedirect.js | 2 +
.../PivotBatchWeekPicker.jsx | 278 ++++++++++++++++++
.../PivotBatchWeekPicker.scss | 159 ++++++++++
.../PivotTenantDashboard/PivotDashBurst.jsx | 34 +++
.../PivotTenantDashboard/PivotJustGoLogo.jsx | 79 +++++
.../PivotTenantDashboard/PivotJustGoLogo.scss | 40 +++
.../PivotScrapbookTitle.jsx | 244 +++++++++++++++
.../PivotScrapbookTitle.scss | 89 ++++++
.../PivotTenantDashboard.jsx | 171 +++++++++++
.../PivotTenantDashboard.scss | 79 +++++
.../PivotTenantDropdown.jsx | 163 ++++++++++
.../PivotTenantDropdown.scss | 21 ++
.../PivotTenantDashboard/PivotTenantPage.jsx | 107 +++++++
.../PivotTenantDashboard/PivotTenantPage.scss | 217 ++++++++++++++
.../usePivotBatchWeekState.js | 45 +++
.../usePivotTenantWeekKeybinds.js | 85 ++++++
.../TenantManagement/TenantManagementPage.jsx | 39 ++-
.../TenantManagementPage.scss | 7 +
23 files changed, 1890 insertions(+), 7 deletions(-)
create mode 100644 frontend/src/assets/pivot/fonts/LesFlosSans.otf
create mode 100644 frontend/src/assets/pivot/just-go-burst.svg
create mode 100644 frontend/src/assets/pivot/just-go-wordmark-dark.svg
create mode 100644 frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotBatchWeekPicker.jsx
create mode 100644 frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotBatchWeekPicker.scss
create mode 100644 frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotDashBurst.jsx
create mode 100644 frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotJustGoLogo.jsx
create mode 100644 frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotJustGoLogo.scss
create mode 100644 frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotScrapbookTitle.jsx
create mode 100644 frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotScrapbookTitle.scss
create mode 100644 frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantDashboard.jsx
create mode 100644 frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantDashboard.scss
create mode 100644 frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantDropdown.jsx
create mode 100644 frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantDropdown.scss
create mode 100644 frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantPage.jsx
create mode 100644 frontend/src/pages/PlatformAdmin/PivotTenantDashboard/PivotTenantPage.scss
create mode 100644 frontend/src/pages/PlatformAdmin/PivotTenantDashboard/usePivotBatchWeekState.js
create mode 100644 frontend/src/pages/PlatformAdmin/PivotTenantDashboard/usePivotTenantWeekKeybinds.js
diff --git a/frontend/src/App.js b/frontend/src/App.js
index e74c5252..775ed538 100644
--- a/frontend/src/App.js
+++ b/frontend/src/App.js
@@ -28,6 +28,7 @@ import QR from './pages/QR/QR';
import EventQRRedirect from './pages/QR/EventQRRedirect';
import Admin from './pages/Admin/Admin';
import PlatformAdmin from './pages/PlatformAdmin/PlatformAdmin';
+import PivotTenantDashboard from './pages/PlatformAdmin/PivotTenantDashboard/PivotTenantDashboard';
import PlatformProtectedRoute from './components/PlatformProtectedRoute/PlatformProtectedRoute';
import OIEDash from './pages/OIEDash/OIEDash';
import NewBadge from './pages/NewBadge/NewBadge';
@@ -230,6 +231,7 @@ function App() {
2v^gDZe^!ob*9h=@$gG+OeO(0J{DYqA)`Taxh#e-+M0
z`&Sykj$4`_uYLgCBbN2!t*Pmem2{(>uMq0#`I6j<001_~p?$CyoV9?7GLWhsBd$vq
zm#d4bbBV4-dt8bHwf2HnK?_VUN0L@e5FsGW>#p{e2m0jkTTZN<5(-va2Kb=e?&yBM
zKj<-FA&Y=k}3@5!5!-RCoAobDl@PgZ~WK|!@4{0?WM=3{8
z1pb?}nxv`TKn)fW?IJzz&Nu2y{N^|LZL!uoitLOEZWF=3hj
g`(2`k=(ivL-shCK(%+d!!A+
zeIz@QA7)IU^x!5;+=U>KStyt~olZEA1p#=YoF>c^NtnPjLbxdlQ=X!AgNRj32&&AP
zqFPTUNCix!RU#k+Rnp`HadGQ@R88myh)PBRzXd*93qf;MR)4Q{YAk~gR2sDa$jMw=
zlo%oFdWf)?3o>qkCD$z4;-_^66YDODJ#v%$O827!VAwGmljCIeJ>dtFmFZiIR){gG
zGuzW@z<{{v#Pw$3z8Lee;z2BmJO(HSn
cfs?nxx2b;Cx*s{