diff --git a/migrations/20260730_000000_backfill_promise_categories.ts b/migrations/20260730_000000_backfill_promise_categories.ts new file mode 100644 index 00000000..5b103f64 --- /dev/null +++ b/migrations/20260730_000000_backfill_promise_categories.ts @@ -0,0 +1,125 @@ +import type { + MigrateDownArgs, + MigrateUpArgs, + MongooseAdapter, +} from "@payloadcms/db-mongodb"; + +const DEFAULT_BACKFILL_BATCH_SIZE = 100; + +type RawExtractionItem = { + category?: string; + checkMediaId?: string; +}; + +type RawAIExtraction = { + _id: unknown; + extractions?: RawExtractionItem[]; +}; + +type RawPromise = { + _id: unknown; + meedanId?: string; + category?: string; +}; + +const getBackfillBatchSize = (): number => { + const raw = Number( + process.env.PAYLOAD_PROMISE_CATEGORY_BACKFILL_BATCH_SIZE, + ); + return Number.isFinite(raw) && raw > 0 + ? Math.floor(raw) + : DEFAULT_BACKFILL_BATCH_SIZE; +}; + +const backfillPromiseCategories = async ({ + payload, +}: MigrateUpArgs): Promise => { + const db = payload.db as MongooseAdapter; + const aiExtractionsModel = db.collections["ai-extractions"]; + const promisesModel = db.collections.promises; + + if (!aiExtractionsModel || !promisesModel) { + throw new Error( + "Failed to resolve one or more collections for promise category backfill", + ); + } + + const categoryByCheckMediaId = new Map(); + + const extractionDocs = (await aiExtractionsModel.find( + {}, + { extractions: 1 }, + { lean: true }, + )) as RawAIExtraction[]; + + for (const extractionDoc of extractionDocs) { + for (const extraction of extractionDoc.extractions ?? []) { + const checkMediaId = extraction.checkMediaId?.trim(); + const category = extraction.category?.trim(); + if (checkMediaId && category) { + categoryByCheckMediaId.set(checkMediaId, category); + } + } + } + + const batchSize = getBackfillBatchSize(); + let lastID: unknown; + let updated = 0; + + payload.logger.info({ + msg: "promiseCategoryBackfill:: Starting migration backfill", + batchSize, + knownCategories: categoryByCheckMediaId.size, + }); + + while (true) { + const batch = (await promisesModel.find( + lastID ? { _id: { $gt: lastID } } : {}, + { meedanId: 1, category: 1 }, + { lean: true, limit: batchSize, sort: { _id: 1 } }, + )) as RawPromise[]; + + if (batch.length === 0) { + break; + } + + lastID = batch[batch.length - 1]?._id; + + for (const promise of batch) { + const meedanId = promise.meedanId?.trim(); + const category = meedanId ? categoryByCheckMediaId.get(meedanId) : undefined; + + if (!category || promise.category?.trim()) { + continue; + } + + await promisesModel.collection.updateOne( + { _id: promise._id } as Record, + { $set: { category } }, + ); + updated += 1; + } + } + + payload.logger.info({ + msg: "promiseCategoryBackfill:: Completed migration backfill", + updated, + }); +}; + +export async function up(args: MigrateUpArgs): Promise { + await backfillPromiseCategories(args); +} + +export async function down({ payload }: MigrateDownArgs): Promise { + const db = payload.db as MongooseAdapter; + const promisesModel = db.collections.promises; + + if (!promisesModel) { + throw new Error( + "Failed to resolve promises collection for migration rollback", + ); + } + + await promisesModel.collection.updateMany({}, { $unset: { category: "" } }); +} diff --git a/src/collections/Promises.ts b/src/collections/Promises.ts index 2918ea13..7850edad 100644 --- a/src/collections/Promises.ts +++ b/src/collections/Promises.ts @@ -92,6 +92,17 @@ export const Promises: CollectionConfig = { position: "sidebar", }, }, + { + name: "category", + type: "text", + label: { + en: "Category", + fr: "Catégorie", + }, + admin: { + position: "sidebar", + }, + }, { name: "politicalEntity", type: "relationship", diff --git a/src/components/Hero/Hero.Client.tsx b/src/components/Hero/Hero.Client.tsx index 9b850ea4..cc1c20d6 100644 --- a/src/components/Hero/Hero.Client.tsx +++ b/src/components/Hero/Hero.Client.tsx @@ -51,7 +51,6 @@ export const HeroClient = ({ data }: HeroClientProps) => { { { const dateLine = [updatedAtLabel?.trim(), updatedAtDisplay] .filter(Boolean) @@ -98,30 +93,6 @@ export const Profile = ({ }, }} > - {headline.tagline || headline.name ? ( - ({ - mb: theme.typography.pxToRem(12), - })} - > - {headline.tagline ? ( - <> - - {headline.tagline} - {" "} - {headline.name} - - ) : ( - headline.name - )} - - ) : null} ({ diff --git a/src/components/Hero/ProfileChart/DesktopChart/ProgressChart.tsx b/src/components/Hero/ProfileChart/DesktopChart/ProgressChart.tsx index 22068ac8..87ea49f4 100644 --- a/src/components/Hero/ProfileChart/DesktopChart/ProgressChart.tsx +++ b/src/components/Hero/ProfileChart/DesktopChart/ProgressChart.tsx @@ -23,7 +23,13 @@ export const ProgressChart = ({ const accentColor = statuses[0]?.color ?? ""; return ( - + ({ diff --git a/src/components/Hero/ProfileChart/index.tsx b/src/components/Hero/ProfileChart/index.tsx index 64b78663..307ae608 100644 --- a/src/components/Hero/ProfileChart/index.tsx +++ b/src/components/Hero/ProfileChart/index.tsx @@ -1,7 +1,7 @@ "use client"; -import { useMemo, useState } from "react"; -import { Box } from "@mui/material"; +import { useEffect, useMemo, useState } from "react"; +import { Box, Typography } from "@mui/material"; import type { HeroChartGroup, HeroStatusSummary } from "../index"; import DesktopChart from "./DesktopChart"; @@ -9,7 +9,13 @@ import MobileChart from "./MobileChart"; import ProfileDetails from "./ProfileDetails"; import RectChart from "./RectChart"; +const AUTO_TOGGLE_INTERVAL_MS = 6000; + type ProfileChartProps = { + headline: { + tagline?: string; + name: string; + }; promiseLabel: string; trailText: string; name: string; @@ -22,6 +28,7 @@ type ProfileChartProps = { }; export const ProfileChart = ({ + headline, promiseLabel, trailText, name, @@ -34,6 +41,14 @@ export const ProfileChart = ({ }: ProfileChartProps) => { const [showRectChart, setShowRectChart] = useState(false); + useEffect(() => { + const id = setInterval(() => { + setShowRectChart((prev) => !prev); + }, AUTO_TOGGLE_INTERVAL_MS); + + return () => clearInterval(id); + }, [showRectChart]); + const orderedRectStatuses = useMemo(() => { return statuses .slice() @@ -42,6 +57,30 @@ export const ProfileChart = ({ return ( + {headline.tagline || headline.name ? ( + ({ + mb: theme.typography.pxToRem(12), + })} + > + {headline.tagline ? ( + <> + + {headline.tagline} + {" "} + {headline.name} + + ) : ( + headline.name + )} + + ) : null} - ({ - typography: { xs: "h4", lg: "h2" }, - p: "0 !important", position: "relative", - overflow: "hidden", - display: "-webkit-box", - WebkitBoxOrient: "vertical", - WebkitLineClamp: { xs: 3, lg: 2 }, - minHeight: { lg: theme.typography.pxToRem(56) }, - textTransform: "none", - lineHeight: "48px", - pb: 1, + pb: theme.typography.pxToRem(20), "&::after": { content: '""', width: theme.typography.pxToRem(72), borderBottom: { lg: `8px solid ${statusColor}` }, - marginTop: { lg: theme.typography.pxToRem(8) }, position: "absolute", - bottom: -2, + bottom: 0, left: 0, }, })} > - {item.title} - + ({ + typography: { xs: "h4", lg: "h2" }, + p: "0 !important", + overflow: "hidden", + display: "-webkit-box", + WebkitBoxOrient: "vertical", + WebkitLineClamp: { xs: 3, lg: 2 }, + minHeight: { lg: theme.typography.pxToRem(56) }, + textTransform: "none", + lineHeight: "48px", + })} + > + {item.title} + + {item.description ? ( (function Partners( > {title} @@ -54,7 +58,7 @@ const Partners = React.forwardRef(function Partners( xs: "center", lg: "space-between", }} - rowSpacing={{ xs: 4, md: 5 }} + rowSpacing={{ xs: 4, md: 5, lg: 0 }} columnSpacing={{ lg: 4 }} > {partners.slice(0, 6).map((partner) => ( @@ -65,13 +69,24 @@ const Partners = React.forwardRef(function Partners( lg: 4, }} > - + 0 ? promiseCategories : (projectMeta?.tags ?? []); const filterStatusItems = promiseStatuses; const [items, setItems] = useState(itemsProp); const [selectedFilters, setSelectedFilters] = useState([]); @@ -139,9 +142,16 @@ function Promises({ return selectedFilters.some((c) => c.slug === promiseSlug); }; + const hasCategory = (item: PromiseWithHref) => { + const promiseSlug = slugify(item.category ?? ""); + return selectedFilters.some((c) => c.slug === promiseSlug); + }; + let filteredItems: PromiseWithHref[] = []; if (filterBy === "status") { filteredItems = itemsProp.filter(hasStatus); + } else if (filterBy === "category") { + filteredItems = itemsProp.filter(hasCategory); } const hasFilters = selectedFilters?.length; diff --git a/src/components/Promises/index.tsx b/src/components/Promises/index.tsx index d45b4718..89313c55 100644 --- a/src/components/Promises/index.tsx +++ b/src/components/Promises/index.tsx @@ -37,6 +37,16 @@ const resolveEntity = async ( return getPoliticalEntityBySlug(tenant, entitySlug); }; +const FILTER_LABELS: Record = { + status: "Status", + category: "Category", +}; + +const SORT_LABELS: Record = { + mostRecent: "Most Recent", + deadline: "Deadline", +}; + async function Index(props: PromiseListProps) { const { title, filterBy, sortBy, filterByLabel, sortByLabel, entitySlug } = props; @@ -90,13 +100,31 @@ async function Index(props: PromiseListProps) { }); const promiseStatuses = Array.from(promiseStatusesMap.values()); + + const promiseCategoriesMap = new Map(); + + promises.forEach((promise) => { + const categoryLabel = promise.category ?? ""; + const slug = slugify(categoryLabel); + + if (!slug || promiseCategoriesMap.has(slug)) { + return; + } + + promiseCategoriesMap.set(slug, { + slug, + name: categoryLabel, + }); + }); + + const promiseCategories = Array.from(promiseCategoriesMap.values()); const entityImage = typeof entity.image === "string" ? null : (entity.image ?? null); const filterByOptions = { label: filterByLabel ?? "", items: filterBy?.map((filter: string) => ({ - name: filter, + name: FILTER_LABELS[filter] ?? filter, slug: filter, })) ?? [], }; @@ -104,7 +132,7 @@ async function Index(props: PromiseListProps) { label: sortByLabel ?? "", items: sortBy?.map((sort: string) => ({ - name: sort, + name: SORT_LABELS[sort] ?? sort, slug: sort, })) ?? [], }; @@ -116,6 +144,7 @@ async function Index(props: PromiseListProps) { filterByConfig={filterByOptions} sortByConfig={sortByOptions} promiseStatuses={promiseStatuses} + promiseCategories={promiseCategories} entity={{ name: entity.name, slug: entity.slug, image: entityImage }} fallbackImage={fallbackImage} /> diff --git a/src/lib/syncMeedanReports.ts b/src/lib/syncMeedanReports.ts index e7967a43..4c1f09c6 100644 --- a/src/lib/syncMeedanReports.ts +++ b/src/lib/syncMeedanReports.ts @@ -20,6 +20,7 @@ type PromiseData = { status: string | null; publishStatus: string; politicalEntity: string | null; + category: string | null; image: string | null; url: string; }; @@ -28,6 +29,7 @@ const buildPromiseData = ( report: PublishedReport, statusId: string | null, politicalEntityId: string | null, + categoryValue: string | null, imageId: string | null, rawPublishStatus: string | null ): PromiseData => { @@ -39,6 +41,7 @@ const buildPromiseData = ( status: statusId, publishStatus: coerce(rawPublishStatus), politicalEntity: politicalEntityId, + category: categoryValue, image: imageId, url: coerce(report.url), }; @@ -77,6 +80,7 @@ const normaliseExistingDoc = (doc: PromiseDoc): PromiseData => { status: getRelationId(doc.status), publishStatus: coerce(doc.publishStatus), politicalEntity: getRelationId(doc.politicalEntity), + category: coerce(doc.category), image: getRelationId(doc.image as unknown), url: coerce(doc.url), }; @@ -193,6 +197,7 @@ export const syncMeedanReports = async ({ }; const extractionEntityIndex = new Map(); + const extractionCategoryIndex = new Map(); for (const extractionDoc of aiExtractionDocsRaw as AiExtractionDoc[]) { const document = await resolveDocument(extractionDoc.document); @@ -206,16 +211,20 @@ export const syncMeedanReports = async ({ : (politicalEntityValue.id ?? null); } - if (!politicalEntityId) { - continue; - } - for (const extraction of extractionDoc.extractions ?? []) { const checkMediaId = extraction.checkMediaId?.trim(); if (!checkMediaId) { continue; } - extractionEntityIndex.set(checkMediaId, politicalEntityId); + + const category = extraction.category?.trim(); + if (category) { + extractionCategoryIndex.set(checkMediaId, category); + } + + if (politicalEntityId) { + extractionEntityIndex.set(checkMediaId, politicalEntityId); + } } } @@ -392,11 +401,18 @@ export const syncMeedanReports = async ({ meedanId: report.meedanId, }); } + + const categoryValue = + extractionCategoryIndex.get(report.meedanId) ?? + existingData?.category ?? + null; + const imageId = await resolveImageForReport(report, existingData); const data = buildPromiseData( report, resolvedStatusId, politicalEntityId, + categoryValue, imageId, reportStatusValue ); diff --git a/src/payload-types.ts b/src/payload-types.ts index a1949a34..1b830227 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -466,6 +466,7 @@ export interface Promise { description?: string | null; url?: string | null; status?: (string | null) | PromiseStatus; + category?: string | null; politicalEntity?: (string | null) | PoliticalEntity; image?: (string | null) | Media; publishStatus?: string | null; @@ -1473,6 +1474,7 @@ export interface PromisesSelect { description?: T; url?: T; status?: T; + category?: T; politicalEntity?: T; image?: T; publishStatus?: T;