From 1b5a168cdae4772d9520232424dd592680a5dc23 Mon Sep 17 00:00:00 2001 From: kelvin <43873157+kelvinkipruto@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:50:02 +0300 Subject: [PATCH 1/5] refactor: move headline rendering to ProfileChart Remove headline prop and rendering from Profile, add headline support to ProfileChart, fix prop passing in HeroClient, and update ProgressChart Stack styles for proper layout. Fixes Promise Tracker | Political entity hero: fix column alignment and title position Fixes #607 --- src/components/Hero/Hero.Client.tsx | 2 +- src/components/Hero/Profile/index.tsx | 29 ----------------- .../DesktopChart/ProgressChart.tsx | 8 ++++- src/components/Hero/ProfileChart/index.tsx | 31 ++++++++++++++++++- 4 files changed, 38 insertions(+), 32 deletions(-) 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..d4d09b97 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 { Box, Typography } from "@mui/material"; import type { HeroChartGroup, HeroStatusSummary } from "../index"; import DesktopChart from "./DesktopChart"; @@ -10,6 +10,10 @@ import ProfileDetails from "./ProfileDetails"; import RectChart from "./RectChart"; type ProfileChartProps = { + headline: { + tagline?: string; + name: string; + }; promiseLabel: string; trailText: string; name: string; @@ -22,6 +26,7 @@ type ProfileChartProps = { }; export const ProfileChart = ({ + headline, promiseLabel, trailText, name, @@ -42,6 +47,30 @@ export const ProfileChart = ({ return ( + {headline.tagline || headline.name ? ( + ({ + mb: theme.typography.pxToRem(12), + })} + > + {headline.tagline ? ( + <> + + {headline.tagline} + {" "} + {headline.name} + + ) : ( + headline.name + )} + + ) : null} Date: Thu, 30 Jul 2026 11:13:15 +0300 Subject: [PATCH 2/5] feat: add category support for promises Add category field to the Promises collection, implement UI filtering for categories, add logic to sync category data from AI extraction records during report synchronization, and create a database migration to backfill existing promise categories. Update type definitions and component props to support the new category field across the codebase. Fixes Promise Tracker | Filter by Category Fixes #606 --- ...0730_000000_backfill_promise_categories.ts | 125 ++++++++++++++++++ src/collections/Promises.ts | 11 ++ src/components/Promises/Promises.tsx | 12 +- src/components/Promises/index.tsx | 33 ++++- src/lib/syncMeedanReports.ts | 26 +++- src/payload-types.ts | 2 + 6 files changed, 201 insertions(+), 8 deletions(-) create mode 100644 migrations/20260730_000000_backfill_promise_categories.ts 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/Promises/Promises.tsx b/src/components/Promises/Promises.tsx index 43fbf3b9..e7e47a17 100644 --- a/src/components/Promises/Promises.tsx +++ b/src/components/Promises/Promises.tsx @@ -30,6 +30,7 @@ interface PromisesProps { withFilter?: boolean; projectMeta?: ProjectMeta; promiseStatuses?: SortItem[]; + promiseCategories?: SortItem[]; sortLabels?: { sortByDeadline: SortItem; sortByMostRecent: SortItem; @@ -81,6 +82,7 @@ function Promises({ withFilter = true, projectMeta, promiseStatuses = [], + promiseCategories = [], sortLabels, filterByConfig, sortByConfig, @@ -89,7 +91,8 @@ function Promises({ }: PromisesProps) { const sortByDeadline = sortLabels?.sortByDeadline; const sortByMostRecent = sortLabels?.sortByMostRecent; - const filterCategoryItems = projectMeta?.tags ?? []; + const filterCategoryItems = + promiseCategories.length > 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; From be16930e6cd979d3df5804e9337c4483703d7225 Mon Sep 17 00:00:00 2001 From: kelvin <43873157+kelvinkipruto@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:19:12 +0300 Subject: [PATCH 3/5] feat(ProfileChart): add auto-toggling chart views Update React imports to include useEffect, add a reusable interval duration constant, set up a 6-second interval to automatically toggle between chart views, and include cleanup logic to clear the interval on component unmount to avoid memory leaks. Fiexes Promise Tracker | Auto slide between the two hero stat views for Political Entity Fixes #612 --- src/components/Hero/ProfileChart/index.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/components/Hero/ProfileChart/index.tsx b/src/components/Hero/ProfileChart/index.tsx index d4d09b97..307ae608 100644 --- a/src/components/Hero/ProfileChart/index.tsx +++ b/src/components/Hero/ProfileChart/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Box, Typography } from "@mui/material"; import type { HeroChartGroup, HeroStatusSummary } from "../index"; @@ -9,6 +9,8 @@ import MobileChart from "./MobileChart"; import ProfileDetails from "./ProfileDetails"; import RectChart from "./RectChart"; +const AUTO_TOGGLE_INTERVAL_MS = 6000; + type ProfileChartProps = { headline: { tagline?: string; @@ -39,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() From bbd1b6d4adfc13424fefda1247780cccfe951976 Mon Sep 17 00:00:00 2001 From: kelvin <43873157+kelvinkipruto@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:43:29 +0300 Subject: [PATCH 4/5] style(KeyPromises): fix title spacing & underline wrap title Typography in a Box component, move relevant styles to improve spacing and align the underline properly Fixes Promise Tracker | Add breathing room under the promise title line Fixes #613 --- .../KeyPromises/KeyPromises.Client.tsx | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/src/components/KeyPromises/KeyPromises.Client.tsx b/src/components/KeyPromises/KeyPromises.Client.tsx index 356070d2..0fb9a2e5 100644 --- a/src/components/KeyPromises/KeyPromises.Client.tsx +++ b/src/components/KeyPromises/KeyPromises.Client.tsx @@ -102,33 +102,37 @@ const KeyPromiseCard = ({ sx={{ minHeight: { lg: theme.typography.pxToRem(387) } }} > - ({ - 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 ? ( Date: Thu, 30 Jul 2026 12:57:22 +0300 Subject: [PATCH 5/5] chore(Partners): update partners component styling adjust heading typography, grid row spacing for large screens, and partner card sizing and link styles FIxes Promise Tracker | Partner section takes up a full screen Fixes #611 --- src/components/Partners/Partners.tsx | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/components/Partners/Partners.tsx b/src/components/Partners/Partners.tsx index fdab4541..6a81e4e0 100644 --- a/src/components/Partners/Partners.tsx +++ b/src/components/Partners/Partners.tsx @@ -38,10 +38,14 @@ const Partners = React.forwardRef(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, }} > - +