mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
B54 — Busca global: layout responsivo dos resultados (#60)
* feat(campanha): responsive grid and viewport cap for home search results (B54) Adds HomeSearchResultsLayout with 1/2/3-column breakpoints and allocateHitBudget so Municípios keep priority while total hits stay within per-viewport limits. Closes #8 * fix(campanha): un-export knip-unused home search budget helpers
This commit is contained in:
parent
784d4cd2e3
commit
142b0963e0
13 changed files with 495 additions and 16 deletions
|
|
@ -1,7 +1,7 @@
|
|||
# Busca global — layout dos resultados (grid + cap de viewport)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-29
|
||||
Status: entregue (2026-07-30)
|
||||
Atualizado em: 2026-07-30 — as-built: `HomeSearchResultsLayout` (grid 1/2/3 col + `HomeSearchHitBudgetContext`); cap v1 constante por breakpoint (`HOME_SEARCH_HIT_BUDGET` 8/12/15) via `allocateHitBudget` com prioridade Municípios; grupos B48–B53 fatiam via `useHomeSearchHitLimit`.
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B54 — busca global)
|
||||
Impeccable: B — layout responsivo da região de resultados B47
|
||||
Appetite: ~0,5–0,75 dia eng; CSS grid + política de cap; sem provider novo
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { HomeSearchAdvisorGroup } from '@/components/campaign/dashboard/HomeSear
|
|||
import { HomeSearchDemandGroup } from '@/components/campaign/dashboard/HomeSearchDemandGroup'
|
||||
import { HomeSearchLeadershipGroup } from '@/components/campaign/dashboard/HomeSearchLeadershipGroup'
|
||||
import { HomeSearchMunicipalityGroup } from '@/components/campaign/dashboard/HomeSearchMunicipalityGroup'
|
||||
import { HomeSearchResultsLayout } from '@/components/campaign/dashboard/HomeSearchResultsLayout'
|
||||
import { HomeSearchResultsShell } from '@/components/campaign/dashboard/HomeSearchResultsShell'
|
||||
import { HomeSearchStateDeputyGroup } from '@/components/campaign/dashboard/HomeSearchStateDeputyGroup'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
|
|
@ -50,12 +51,14 @@ export default async function CampaignHomePage() {
|
|||
actions={actions}
|
||||
searchResults={
|
||||
<HomeSearchResultsShell>
|
||||
<HomeSearchMunicipalityGroup />
|
||||
<HomeSearchLeadershipGroup />
|
||||
<HomeSearchAdvisorGroup />
|
||||
<HomeSearchActivityGroup />
|
||||
<HomeSearchStateDeputyGroup />
|
||||
<HomeSearchDemandGroup />
|
||||
<HomeSearchResultsLayout>
|
||||
<HomeSearchMunicipalityGroup />
|
||||
<HomeSearchLeadershipGroup />
|
||||
<HomeSearchAdvisorGroup />
|
||||
<HomeSearchActivityGroup />
|
||||
<HomeSearchStateDeputyGroup />
|
||||
<HomeSearchDemandGroup />
|
||||
</HomeSearchResultsLayout>
|
||||
</HomeSearchResultsShell>
|
||||
}
|
||||
summarySlot={summarySlot}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,28 @@
|
|||
'use client'
|
||||
|
||||
import { useHomeSearchHitLimit } from '@/components/campaign/dashboard/HomeSearchHitBudgetContext'
|
||||
import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow'
|
||||
import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext'
|
||||
import { homeSearchActivityGroupHasHits } from '@/lib/campaignHomeSearchHits'
|
||||
import { sliceHomeSearchHits } from '@/lib/homeSearchHitBudget'
|
||||
import { HOME_SEARCH_GROUP_HEADING_CLASS, HOME_SEARCH_GROUP_LIST_CLASS } from '@/lib/homeSearchUi'
|
||||
|
||||
export const HomeSearchActivityGroup = () => {
|
||||
const { results } = useHomeSearchResults()
|
||||
const hitLimit = useHomeSearchHitLimit('activities')
|
||||
|
||||
if (results.status !== 'success') return null
|
||||
if (!homeSearchActivityGroupHasHits(results.data)) return null
|
||||
|
||||
const { activities } = results.data
|
||||
const visibleActivities = sliceHomeSearchHits(activities, hitLimit)
|
||||
if (visibleActivities.length === 0) return null
|
||||
|
||||
return (
|
||||
<section aria-label="Atividades" className="flex flex-col gap-0.5">
|
||||
<h2 className={HOME_SEARCH_GROUP_HEADING_CLASS}>Atividades</h2>
|
||||
<ul className={HOME_SEARCH_GROUP_LIST_CLASS}>
|
||||
{activities.map((hit) => (
|
||||
{visibleActivities.map((hit) => (
|
||||
<li key={hit.id}>
|
||||
<HomeSearchHitRow
|
||||
href={`/campanha/atividades/${hit.slug}`}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,29 @@
|
|||
'use client'
|
||||
|
||||
import { useHomeSearchHitLimit } from '@/components/campaign/dashboard/HomeSearchHitBudgetContext'
|
||||
import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow'
|
||||
import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext'
|
||||
import { formatHomeSearchMunicipalityCount } from '@/lib/campaignHomeSearchHits'
|
||||
import { sliceHomeSearchHits } from '@/lib/homeSearchHitBudget'
|
||||
import { HOME_SEARCH_GROUP_HEADING_CLASS, HOME_SEARCH_GROUP_LIST_CLASS } from '@/lib/homeSearchUi'
|
||||
|
||||
export const HomeSearchAdvisorGroup = () => {
|
||||
const { results } = useHomeSearchResults()
|
||||
const hitLimit = useHomeSearchHitLimit('advisors')
|
||||
|
||||
if (results.status !== 'success') return null
|
||||
|
||||
const { advisors } = results.data
|
||||
if (advisors.length === 0) return null
|
||||
|
||||
const visibleAdvisors = sliceHomeSearchHits(advisors, hitLimit)
|
||||
if (visibleAdvisors.length === 0) return null
|
||||
|
||||
return (
|
||||
<section aria-label="Assessores" className="flex flex-col gap-0.5">
|
||||
<h2 className={HOME_SEARCH_GROUP_HEADING_CLASS}>Assessores</h2>
|
||||
<ul className={HOME_SEARCH_GROUP_LIST_CLASS}>
|
||||
{advisors.map((hit) => (
|
||||
{visibleAdvisors.map((hit) => (
|
||||
<li key={hit.id}>
|
||||
<HomeSearchHitRow
|
||||
href={`/campanha/assessores/${hit.id}`}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,28 @@
|
|||
'use client'
|
||||
|
||||
import { useHomeSearchHitLimit } from '@/components/campaign/dashboard/HomeSearchHitBudgetContext'
|
||||
import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow'
|
||||
import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext'
|
||||
import { homeSearchDemandGroupHasHits } from '@/lib/campaignHomeSearchHits'
|
||||
import { sliceHomeSearchHits } from '@/lib/homeSearchHitBudget'
|
||||
import { HOME_SEARCH_GROUP_HEADING_CLASS, HOME_SEARCH_GROUP_LIST_CLASS } from '@/lib/homeSearchUi'
|
||||
|
||||
export const HomeSearchDemandGroup = () => {
|
||||
const { results } = useHomeSearchResults()
|
||||
const hitLimit = useHomeSearchHitLimit('demands')
|
||||
|
||||
if (results.status !== 'success') return null
|
||||
if (!homeSearchDemandGroupHasHits(results.data)) return null
|
||||
|
||||
const { demands } = results.data
|
||||
const visibleDemands = sliceHomeSearchHits(demands, hitLimit)
|
||||
if (visibleDemands.length === 0) return null
|
||||
|
||||
return (
|
||||
<section aria-label="Demandas" className="flex flex-col gap-0.5">
|
||||
<h2 className={HOME_SEARCH_GROUP_HEADING_CLASS}>Demandas</h2>
|
||||
<ul className={HOME_SEARCH_GROUP_LIST_CLASS}>
|
||||
{demands.map((hit) => (
|
||||
{visibleDemands.map((hit) => (
|
||||
<li key={hit.id}>
|
||||
<HomeSearchHitRow
|
||||
href={`/campanha/demandas/${hit.slug}`}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
'use client'
|
||||
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
type HomeSearchGroupHitLimits,
|
||||
type HomeSearchGroupId,
|
||||
type HomeSearchViewportTier,
|
||||
} from '@/lib/homeSearchHitBudget'
|
||||
|
||||
const HomeSearchHitBudgetContext = createContext<HomeSearchGroupHitLimits | null>(null)
|
||||
|
||||
export const HomeSearchHitBudgetProvider = ({
|
||||
limits,
|
||||
children,
|
||||
}: {
|
||||
limits: HomeSearchGroupHitLimits
|
||||
children: ReactNode
|
||||
}) => (
|
||||
<HomeSearchHitBudgetContext.Provider value={limits}>
|
||||
{children}
|
||||
</HomeSearchHitBudgetContext.Provider>
|
||||
)
|
||||
|
||||
export const useHomeSearchHitLimit = (groupId: HomeSearchGroupId): number | undefined => {
|
||||
const limits = useContext(HomeSearchHitBudgetContext)
|
||||
return limits?.[groupId]
|
||||
}
|
||||
|
||||
const VIEWPORT_TIER_QUERIES: { tier: HomeSearchViewportTier; query: string }[] = [
|
||||
{ tier: 'desktop', query: '(min-width: 1024px)' },
|
||||
{ tier: 'tablet', query: '(min-width: 768px)' },
|
||||
]
|
||||
|
||||
const resolveHomeSearchViewportTier = (
|
||||
matchesDesktop: boolean,
|
||||
matchesTablet: boolean,
|
||||
): HomeSearchViewportTier => {
|
||||
if (matchesDesktop) return 'desktop'
|
||||
if (matchesTablet) return 'tablet'
|
||||
return 'mobile'
|
||||
}
|
||||
|
||||
const readHomeSearchViewportTier = (): HomeSearchViewportTier => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return 'mobile'
|
||||
}
|
||||
return resolveHomeSearchViewportTier(
|
||||
window.matchMedia(VIEWPORT_TIER_QUERIES[0].query).matches,
|
||||
window.matchMedia(VIEWPORT_TIER_QUERIES[1].query).matches,
|
||||
)
|
||||
}
|
||||
|
||||
export const useHomeSearchViewportTier = (): HomeSearchViewportTier => {
|
||||
const [tier, setTier] = useState<HomeSearchViewportTier>(readHomeSearchViewportTier)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== 'function') return
|
||||
|
||||
const desktopMql = window.matchMedia(VIEWPORT_TIER_QUERIES[0].query)
|
||||
const tabletMql = window.matchMedia(VIEWPORT_TIER_QUERIES[1].query)
|
||||
|
||||
const sync = () => {
|
||||
setTier(resolveHomeSearchViewportTier(desktopMql.matches, tabletMql.matches))
|
||||
}
|
||||
|
||||
sync()
|
||||
desktopMql.addEventListener('change', sync)
|
||||
tabletMql.addEventListener('change', sync)
|
||||
return () => {
|
||||
desktopMql.removeEventListener('change', sync)
|
||||
tabletMql.removeEventListener('change', sync)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return tier
|
||||
}
|
||||
|
|
@ -1,23 +1,29 @@
|
|||
'use client'
|
||||
|
||||
import { useHomeSearchHitLimit } from '@/components/campaign/dashboard/HomeSearchHitBudgetContext'
|
||||
import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow'
|
||||
import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext'
|
||||
import { homeSearchLeadershipGroupHasHits } from '@/lib/campaignHomeSearchHits'
|
||||
import { sliceHomeSearchHits } from '@/lib/homeSearchHitBudget'
|
||||
import { HOME_SEARCH_GROUP_HEADING_CLASS, HOME_SEARCH_GROUP_LIST_CLASS } from '@/lib/homeSearchUi'
|
||||
|
||||
export const HomeSearchLeadershipGroup = () => {
|
||||
const { results } = useHomeSearchResults()
|
||||
const hitLimit = useHomeSearchHitLimit('leaderships')
|
||||
|
||||
if (results.status !== 'success') return null
|
||||
|
||||
const { leaderships } = results.data
|
||||
if (!homeSearchLeadershipGroupHasHits(results.data)) return null
|
||||
|
||||
const visibleLeaderships = sliceHomeSearchHits(leaderships, hitLimit)
|
||||
if (visibleLeaderships.length === 0) return null
|
||||
|
||||
return (
|
||||
<section aria-label="Lideranças" className="flex flex-col gap-0.5">
|
||||
<h2 className={HOME_SEARCH_GROUP_HEADING_CLASS}>Lideranças</h2>
|
||||
<ul className={HOME_SEARCH_GROUP_LIST_CLASS}>
|
||||
{leaderships.map((hit) => (
|
||||
{visibleLeaderships.map((hit) => (
|
||||
<li key={`leadership-${hit.id}`}>
|
||||
<HomeSearchHitRow
|
||||
href={`/campanha/liderancas/${hit.id}`}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,37 @@
|
|||
'use client'
|
||||
|
||||
import { useHomeSearchHitLimit } from '@/components/campaign/dashboard/HomeSearchHitBudgetContext'
|
||||
import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow'
|
||||
import { HomeSearchMunicipalityVoteTrailing } from '@/components/campaign/dashboard/HomeSearchMunicipalityVoteTrailing'
|
||||
import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext'
|
||||
import { homeSearchMunicipalityGroupHasHits } from '@/lib/campaignHomeSearchHits'
|
||||
import { formatElectionNumber } from '@/lib/electionFormat'
|
||||
import { sliceHomeSearchMunicipalityGroup } from '@/lib/homeSearchHitBudget'
|
||||
import { HOME_SEARCH_GROUP_HEADING_CLASS, HOME_SEARCH_GROUP_LIST_CLASS } from '@/lib/homeSearchUi'
|
||||
import { buildTerritoryPageHref } from '@/lib/territoryAnchor'
|
||||
|
||||
export const HomeSearchMunicipalityGroup = () => {
|
||||
const { results, resultKind } = useHomeSearchResults()
|
||||
const hitLimit = useHomeSearchHitLimit('municipalities')
|
||||
|
||||
if (results.status !== 'success') return null
|
||||
|
||||
const { municipalities, territories } = results.data
|
||||
if (!homeSearchMunicipalityGroupHasHits(results.data)) return null
|
||||
|
||||
const includeTerritories = resultKind === 'search'
|
||||
const { municipalities: visibleMunicipalities, territories: visibleTerritories } =
|
||||
sliceHomeSearchMunicipalityGroup(municipalities, territories, hitLimit, includeTerritories)
|
||||
|
||||
if (visibleMunicipalities.length === 0 && visibleTerritories.length === 0) return null
|
||||
|
||||
const sectionTitle = resultKind === 'suggest' ? 'Sugestões' : 'Municípios'
|
||||
|
||||
return (
|
||||
<section aria-label={sectionTitle} className="flex flex-col gap-0.5">
|
||||
<h2 className={HOME_SEARCH_GROUP_HEADING_CLASS}>{sectionTitle}</h2>
|
||||
<ul className={HOME_SEARCH_GROUP_LIST_CLASS}>
|
||||
{municipalities.map((hit) => (
|
||||
{visibleMunicipalities.map((hit) => (
|
||||
<li key={`municipality-${hit.slug}`}>
|
||||
<HomeSearchHitRow
|
||||
href={`/campanha/municipios/${hit.slug}`}
|
||||
|
|
@ -33,8 +42,8 @@ export const HomeSearchMunicipalityGroup = () => {
|
|||
/>
|
||||
</li>
|
||||
))}
|
||||
{resultKind === 'search'
|
||||
? territories.map((hit) => (
|
||||
{includeTerritories
|
||||
? visibleTerritories.map((hit) => (
|
||||
<li key={`territory-${hit.region}`}>
|
||||
<HomeSearchHitRow
|
||||
href={buildTerritoryPageHref(hit.region)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
HomeSearchHitBudgetProvider,
|
||||
useHomeSearchViewportTier,
|
||||
} from '@/components/campaign/dashboard/HomeSearchHitBudgetContext'
|
||||
import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext'
|
||||
import {
|
||||
allocateHitBudget,
|
||||
buildHomeSearchGroupHitCounts,
|
||||
homeSearchHitBudgetForTier,
|
||||
type HomeSearchGroupHitCounts,
|
||||
} from '@/lib/homeSearchHitBudget'
|
||||
|
||||
const EMPTY_GROUP_COUNTS: HomeSearchGroupHitCounts = {
|
||||
municipalities: 0,
|
||||
leaderships: 0,
|
||||
advisors: 0,
|
||||
activities: 0,
|
||||
stateDeputies: 0,
|
||||
demands: 0,
|
||||
}
|
||||
|
||||
export const HomeSearchResultsLayout = ({ children }: { children: ReactNode }) => {
|
||||
const { results } = useHomeSearchResults()
|
||||
const viewportTier = useHomeSearchViewportTier()
|
||||
const budget = homeSearchHitBudgetForTier(viewportTier)
|
||||
|
||||
const limits =
|
||||
results.status === 'success'
|
||||
? allocateHitBudget(buildHomeSearchGroupHitCounts(results.data), budget)
|
||||
: allocateHitBudget(EMPTY_GROUP_COUNTS, budget)
|
||||
|
||||
return (
|
||||
<HomeSearchHitBudgetProvider limits={limits}>
|
||||
<div
|
||||
data-slot="home-search-results-layout"
|
||||
className="flex flex-col gap-4 md:grid md:grid-cols-2 md:gap-4 lg:grid-cols-3"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</HomeSearchHitBudgetProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
'use client'
|
||||
|
||||
import { useHomeSearchHitLimit } from '@/components/campaign/dashboard/HomeSearchHitBudgetContext'
|
||||
import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow'
|
||||
import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext'
|
||||
import {
|
||||
formatHomeSearchMunicipalityCount,
|
||||
homeSearchStateDeputyGroupHasHits,
|
||||
} from '@/lib/campaignHomeSearchHits'
|
||||
import { sliceHomeSearchHits } from '@/lib/homeSearchHitBudget'
|
||||
import { HOME_SEARCH_GROUP_HEADING_CLASS, HOME_SEARCH_GROUP_LIST_CLASS } from '@/lib/homeSearchUi'
|
||||
|
||||
const formatStateDeputySecondary = (
|
||||
|
|
@ -20,17 +22,21 @@ const formatStateDeputySecondary = (
|
|||
|
||||
export const HomeSearchStateDeputyGroup = () => {
|
||||
const { results } = useHomeSearchResults()
|
||||
const hitLimit = useHomeSearchHitLimit('stateDeputies')
|
||||
|
||||
if (results.status !== 'success') return null
|
||||
|
||||
const { stateDeputies } = results.data
|
||||
if (!homeSearchStateDeputyGroupHasHits(results.data)) return null
|
||||
|
||||
const visibleStateDeputies = sliceHomeSearchHits(stateDeputies, hitLimit)
|
||||
if (visibleStateDeputies.length === 0) return null
|
||||
|
||||
return (
|
||||
<section aria-label="Dobradinhas" className="flex flex-col gap-0.5">
|
||||
<h2 className={HOME_SEARCH_GROUP_HEADING_CLASS}>Dobradinhas</h2>
|
||||
<ul className={HOME_SEARCH_GROUP_LIST_CLASS}>
|
||||
{stateDeputies.map((hit) => (
|
||||
{visibleStateDeputies.map((hit) => (
|
||||
<li key={`state-deputy-${hit.slug}`}>
|
||||
<HomeSearchHitRow
|
||||
href={`/campanha/dobradinhas/${hit.slug}`}
|
||||
|
|
|
|||
105
src/lib/homeSearchHitBudget.ts
Normal file
105
src/lib/homeSearchHitBudget.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import type { HomeSearchSuccessResponse } from '@/lib/campaignHomeSearchHits'
|
||||
|
||||
/** v1 cap — constant per breakpoint (B54); revisit with ResizeObserver if critique finds blind cuts. */
|
||||
export const HOME_SEARCH_HIT_BUDGET = {
|
||||
mobile: 8,
|
||||
tablet: 12,
|
||||
desktop: 15,
|
||||
} as const
|
||||
|
||||
export type HomeSearchViewportTier = keyof typeof HOME_SEARCH_HIT_BUDGET
|
||||
|
||||
export type HomeSearchGroupId =
|
||||
| 'municipalities'
|
||||
| 'leaderships'
|
||||
| 'advisors'
|
||||
| 'activities'
|
||||
| 'stateDeputies'
|
||||
| 'demands'
|
||||
|
||||
const HOME_SEARCH_GROUP_ORDER: readonly HomeSearchGroupId[] = [
|
||||
'municipalities',
|
||||
'leaderships',
|
||||
'advisors',
|
||||
'activities',
|
||||
'stateDeputies',
|
||||
'demands',
|
||||
]
|
||||
|
||||
export type HomeSearchGroupHitCounts = Record<HomeSearchGroupId, number>
|
||||
|
||||
export type HomeSearchGroupHitLimits = Record<HomeSearchGroupId, number>
|
||||
|
||||
const zeroLimits = (): HomeSearchGroupHitLimits =>
|
||||
Object.fromEntries(HOME_SEARCH_GROUP_ORDER.map((id) => [id, 0])) as HomeSearchGroupHitLimits
|
||||
|
||||
export const homeSearchHitBudgetForTier = (tier: HomeSearchViewportTier): number =>
|
||||
HOME_SEARCH_HIT_BUDGET[tier]
|
||||
|
||||
export const buildHomeSearchGroupHitCounts = (
|
||||
data: HomeSearchSuccessResponse,
|
||||
): HomeSearchGroupHitCounts => ({
|
||||
municipalities:
|
||||
data.municipalities.length + (data.resultKind === 'search' ? data.territories.length : 0),
|
||||
leaderships: data.leaderships.length,
|
||||
advisors: data.advisors.length,
|
||||
activities: data.activities.length,
|
||||
stateDeputies: data.stateDeputies.length,
|
||||
demands: data.demands.length,
|
||||
})
|
||||
|
||||
/**
|
||||
* Municípios consume the budget first; remaining slots fill secondary groups in
|
||||
* display order (not round-robin — B54).
|
||||
*/
|
||||
export const allocateHitBudget = (
|
||||
counts: HomeSearchGroupHitCounts,
|
||||
budget: number,
|
||||
): HomeSearchGroupHitLimits => {
|
||||
const limits = zeroLimits()
|
||||
if (budget <= 0) return limits
|
||||
|
||||
let remaining = budget
|
||||
|
||||
const municipalitySlots = Math.min(counts.municipalities, remaining)
|
||||
limits.municipalities = municipalitySlots
|
||||
remaining -= municipalitySlots
|
||||
|
||||
for (const id of HOME_SEARCH_GROUP_ORDER) {
|
||||
if (id === 'municipalities' || remaining <= 0) continue
|
||||
const groupHits = counts[id]
|
||||
if (groupHits <= 0) continue
|
||||
const slots = Math.min(groupHits, remaining)
|
||||
limits[id] = slots
|
||||
remaining -= slots
|
||||
}
|
||||
|
||||
return limits
|
||||
}
|
||||
|
||||
export const sliceHomeSearchHits = <T>(hits: readonly T[], limit: number | undefined): T[] =>
|
||||
limit === undefined ? [...hits] : hits.slice(0, limit)
|
||||
|
||||
export const sliceHomeSearchMunicipalityGroup = (
|
||||
municipalities: HomeSearchSuccessResponse['municipalities'],
|
||||
territories: HomeSearchSuccessResponse['territories'],
|
||||
limit: number | undefined,
|
||||
includeTerritories: boolean,
|
||||
): {
|
||||
municipalities: HomeSearchSuccessResponse['municipalities']
|
||||
territories: HomeSearchSuccessResponse['territories']
|
||||
} => {
|
||||
if (limit === undefined) {
|
||||
return {
|
||||
municipalities: [...municipalities],
|
||||
territories: includeTerritories ? [...territories] : [],
|
||||
}
|
||||
}
|
||||
|
||||
const visibleMunicipalities = municipalities.slice(0, limit)
|
||||
const remaining = limit - visibleMunicipalities.length
|
||||
const visibleTerritories =
|
||||
includeTerritories && remaining > 0 ? territories.slice(0, remaining) : []
|
||||
|
||||
return { municipalities: visibleMunicipalities, territories: visibleTerritories }
|
||||
}
|
||||
162
tests/unit/homeSearchHitBudget.unit.spec.ts
Normal file
162
tests/unit/homeSearchHitBudget.unit.spec.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { HomeSearchSuccessResponse } from '@/lib/campaignHomeSearchHits'
|
||||
import {
|
||||
allocateHitBudget,
|
||||
buildHomeSearchGroupHitCounts,
|
||||
HOME_SEARCH_HIT_BUDGET,
|
||||
sliceHomeSearchMunicipalityGroup,
|
||||
} from '@/lib/homeSearchHitBudget'
|
||||
|
||||
const baseResponse = (
|
||||
overrides: Partial<HomeSearchSuccessResponse> = {},
|
||||
): HomeSearchSuccessResponse => ({
|
||||
status: 'success',
|
||||
resultKind: 'search',
|
||||
municipalities: [],
|
||||
territories: [],
|
||||
advisors: [],
|
||||
leaderships: [],
|
||||
stateDeputies: [],
|
||||
activities: [],
|
||||
demands: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('allocateHitBudget', () => {
|
||||
it('gives municipalities priority over secondary groups', () => {
|
||||
const counts = buildHomeSearchGroupHitCounts(
|
||||
baseResponse({
|
||||
municipalities: Array.from({ length: 10 }, (_, index) => ({
|
||||
kind: 'municipality' as const,
|
||||
slug: `m-${index}`,
|
||||
name: `M${index}`,
|
||||
region: 'R',
|
||||
priority: null,
|
||||
votePosition2022: null,
|
||||
})),
|
||||
demands: Array.from({ length: 10 }, (_, index) => ({
|
||||
id: index,
|
||||
slug: `d-${index}`,
|
||||
title: `Demand ${index}`,
|
||||
secondary: 'x',
|
||||
})),
|
||||
}),
|
||||
)
|
||||
|
||||
const limits = allocateHitBudget(counts, 8)
|
||||
|
||||
expect(limits.municipalities).toBe(8)
|
||||
expect(limits.demands).toBe(0)
|
||||
})
|
||||
|
||||
it('fills secondary groups in display order when budget remains', () => {
|
||||
const counts = buildHomeSearchGroupHitCounts(
|
||||
baseResponse({
|
||||
municipalities: [
|
||||
{
|
||||
kind: 'municipality',
|
||||
slug: 'cairu',
|
||||
name: 'Cairu',
|
||||
region: 'Recôncavo',
|
||||
priority: null,
|
||||
votePosition2022: null,
|
||||
},
|
||||
],
|
||||
leaderships: [
|
||||
{
|
||||
kind: 'leadership',
|
||||
id: 1,
|
||||
name: 'Leader A',
|
||||
municipalitiesSummary: 'Cairu',
|
||||
},
|
||||
{
|
||||
kind: 'leadership',
|
||||
id: 2,
|
||||
name: 'Leader B',
|
||||
municipalitiesSummary: 'Salvador',
|
||||
},
|
||||
],
|
||||
demands: [
|
||||
{ id: 1, slug: 'd-1', title: 'Demand 1', secondary: 'x' },
|
||||
{ id: 2, slug: 'd-2', title: 'Demand 2', secondary: 'x' },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const limits = allocateHitBudget(counts, 4)
|
||||
|
||||
expect(limits.municipalities).toBe(1)
|
||||
expect(limits.leaderships).toBe(2)
|
||||
expect(limits.demands).toBe(1)
|
||||
expect(limits.advisors).toBe(0)
|
||||
})
|
||||
|
||||
it('counts territories toward the municipality group in search mode', () => {
|
||||
const counts = buildHomeSearchGroupHitCounts(
|
||||
baseResponse({
|
||||
municipalities: [],
|
||||
territories: [
|
||||
{ kind: 'territory', region: 'Recôncavo', votes2022: 1000 },
|
||||
{ kind: 'territory', region: 'Sertão', votes2022: 500 },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const limits = allocateHitBudget(counts, HOME_SEARCH_HIT_BUDGET.mobile)
|
||||
|
||||
expect(counts.municipalities).toBe(2)
|
||||
expect(limits.municipalities).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sliceHomeSearchMunicipalityGroup', () => {
|
||||
it('slices municipalities before territories within the shared limit', () => {
|
||||
const municipalities = [
|
||||
{
|
||||
kind: 'municipality' as const,
|
||||
slug: 'a',
|
||||
name: 'A',
|
||||
region: 'R',
|
||||
priority: null,
|
||||
votePosition2022: null,
|
||||
},
|
||||
{
|
||||
kind: 'municipality' as const,
|
||||
slug: 'b',
|
||||
name: 'B',
|
||||
region: 'R',
|
||||
priority: null,
|
||||
votePosition2022: null,
|
||||
},
|
||||
]
|
||||
const territories = [{ kind: 'territory' as const, region: 'TI', votes2022: 1 }]
|
||||
|
||||
const sliced = sliceHomeSearchMunicipalityGroup(municipalities, territories, 2, true)
|
||||
|
||||
expect(sliced.municipalities).toHaveLength(2)
|
||||
expect(sliced.territories).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('uses remaining slots for territories after municipalities', () => {
|
||||
const municipalities = [
|
||||
{
|
||||
kind: 'municipality' as const,
|
||||
slug: 'a',
|
||||
name: 'A',
|
||||
region: 'R',
|
||||
priority: null,
|
||||
votePosition2022: null,
|
||||
},
|
||||
]
|
||||
const territories = [
|
||||
{ kind: 'territory' as const, region: 'TI 1', votes2022: 1 },
|
||||
{ kind: 'territory' as const, region: 'TI 2', votes2022: 2 },
|
||||
]
|
||||
|
||||
const sliced = sliceHomeSearchMunicipalityGroup(municipalities, territories, 3, true)
|
||||
|
||||
expect(sliced.municipalities).toHaveLength(1)
|
||||
expect(sliced.territories).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
49
tests/unit/homeSearchResultsLayout.unit.spec.tsx
Normal file
49
tests/unit/homeSearchResultsLayout.unit.spec.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { HomeSearchResultsProvider } from '@/components/campaign/dashboard/HomeSearchResultsContext'
|
||||
import { HomeSearchResultsLayout } from '@/components/campaign/dashboard/HomeSearchResultsLayout'
|
||||
import type { HomeSearchSuccessResponse } from '@/lib/campaignHomeSearchHits'
|
||||
|
||||
const searchResultsValue = (
|
||||
data: Partial<HomeSearchSuccessResponse> = {},
|
||||
resultKind: HomeSearchSuccessResponse['resultKind'] = 'search',
|
||||
) => ({
|
||||
results: {
|
||||
status: 'success' as const,
|
||||
data: {
|
||||
status: 'success' as const,
|
||||
resultKind,
|
||||
municipalities: [],
|
||||
territories: [],
|
||||
advisors: [],
|
||||
leaderships: [],
|
||||
stateDeputies: [],
|
||||
activities: [],
|
||||
demands: [],
|
||||
...data,
|
||||
},
|
||||
},
|
||||
isFetching: false,
|
||||
resultKind,
|
||||
})
|
||||
|
||||
describe('HomeSearchResultsLayout', () => {
|
||||
it('renders a responsive grid container for result groups', () => {
|
||||
const { container } = render(
|
||||
<HomeSearchResultsProvider value={searchResultsValue()}>
|
||||
<HomeSearchResultsLayout>
|
||||
<p>Group A</p>
|
||||
<p>Group B</p>
|
||||
</HomeSearchResultsLayout>
|
||||
</HomeSearchResultsProvider>,
|
||||
)
|
||||
|
||||
const layout = container.querySelector('[data-slot="home-search-results-layout"]')
|
||||
expect(layout?.className).toContain('flex-col')
|
||||
expect(layout?.className).toContain('md:grid-cols-2')
|
||||
expect(layout?.className).toContain('lg:grid-cols-3')
|
||||
expect(screen.getByText('Group A')).toBeTruthy()
|
||||
expect(screen.getByText('Group B')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue