teqo/src/utilities/activityUi.ts
Francisco Solla dff3a9e8b1 feat(C95): seletor de modo de visualização no header do app (agenda)
- view=week|day|month|list no contrato URL da agenda (canônico, fail-closed)
- seletor compacto 'Semana ▾' no header desktop e mobile (slot C94/C95)
- FullCalendar headerToolbar sem os 4 botões de modo; escolha vence resize
- effect normaliza a vista também quando a URL perde o param (sem vista órfã)
- useNarrowMeasured(breakpoint) em hooks/use-mobile (settle de hidratação)
- e2e reescrito no seletor + persistência + escolha-vence-resize + estabilização
  dos waits de slot (grid lazy do FullCalendar substitui gridcells no load)
2026-08-09 12:34:04 -03:00

396 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Where } from 'payload'
import { CAMPAIGN_AGENDA_HOME } from '@/lib/campaignPaths'
import { parseBahiaDateTimeInput } from '@/lib/campaignTime'
import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery'
import {
activityStatuses,
MAX_ACTIVITY_TAG_LENGTH,
type ActivityStatus,
} from '@/lib/schemas/activity'
import {
buildListHref,
firstValue,
inspectRawListParams,
normalizedText,
resolveListUrl,
strictDecimalInteger,
type RawSearchParams as CampaignListRawSearchParams,
} from '@/utilities/campaignListUrl'
export const activityPageSize = 25
export const activityTabs = ['proximos', 'todos', 'realizados'] as const
export type ActivityTab = (typeof activityTabs)[number]
export { type ActivityStatus }
export const activityTabLabels: Record<ActivityTab, string> = {
proximos: 'Próximos',
todos: 'Todos',
realizados: 'Realizados',
}
export type ActivityListState = {
page: number
tab: ActivityTab
q?: string
tag?: string
status?: ActivityStatus
municipality?: number
}
type RawSearchParams = CampaignListRawSearchParams
/**
* C95 — the agenda view modes behind the header selector. Short URL values map
* to FullCalendar view ids via `activityAgendaViewFcId` (deliberately decoupled
* from the calendar's internals).
*/
export const activityAgendaViews = ['week', 'day', 'month', 'list'] as const
export type ActivityAgendaView = (typeof activityAgendaViews)[number]
export const activityAgendaViewLabels: Record<ActivityAgendaView, string> = {
week: 'Semana',
day: 'Dia',
month: 'Mês',
list: 'Lista',
}
export const activityAgendaViewFcId: Record<ActivityAgendaView, string> = {
week: 'timeGridWeek',
day: 'timeGridDay',
month: 'dayGridMonth',
list: 'listMonth',
}
const isActivityAgendaView = (value: string | undefined): value is ActivityAgendaView =>
activityAgendaViews.includes(value as ActivityAgendaView)
export type ActivityAgendaState = {
municipality?: number
deputyPresent?: true
tag?: string
view?: ActivityAgendaView
}
const activityAgendaParamNames = ['municipality', 'deputyPresent', 'tag', 'view'] as const
const activityAgendaParamNameSet = new Set<string>(activityAgendaParamNames)
const activityTag = (value: string | undefined): string | undefined => {
const tag = normalizedText(value)
return tag && tag.length <= MAX_ACTIVITY_TAG_LENGTH ? tag : undefined
}
export const parseActivityAgendaParams = (params: RawSearchParams): ActivityAgendaState => {
const municipality = strictDecimalInteger(firstValue(params.municipality))
const deputyPresent = firstValue(params.deputyPresent) === '1' ? true : undefined
const tag = activityTag(firstValue(params.tag))
const rawView = firstValue(params.view)
const view = isActivityAgendaView(rawView) ? rawView : undefined
return {
...(municipality ? { municipality } : {}),
...(deputyPresent ? { deputyPresent } : {}),
...(tag ? { tag } : {}),
...(view ? { view } : {}),
}
}
export const buildActivityAgendaSearchParams = (state: ActivityAgendaState): URLSearchParams => {
const canonicalState = parseActivityAgendaParams({
municipality: state.municipality === undefined ? undefined : String(state.municipality),
deputyPresent: state.deputyPresent ? '1' : undefined,
tag: state.tag,
view: state.view,
})
const params = new URLSearchParams()
if (canonicalState.municipality) {
params.set('municipality', String(canonicalState.municipality))
}
if (canonicalState.deputyPresent) params.set('deputyPresent', '1')
if (canonicalState.tag) params.set('tag', canonicalState.tag)
if (canonicalState.view) params.set('view', canonicalState.view)
return params
}
export const buildActivityAgendaHref = (state: ActivityAgendaState): string => {
const query = buildActivityAgendaSearchParams(state).toString()
return query ? `${CAMPAIGN_AGENDA_HOME}?${query}` : CAMPAIGN_AGENDA_HOME
}
export const restrictActivityAgendaState = (
state: ActivityAgendaState,
accessibleMunicipalityIDs: ReadonlySet<number>,
accessibleTags: ReadonlySet<string>,
): ActivityAgendaState => ({
...(state.municipality && accessibleMunicipalityIDs.has(state.municipality)
? { municipality: state.municipality }
: {}),
...(state.deputyPresent ? { deputyPresent: true } : {}),
...(state.tag && accessibleTags.has(state.tag) ? { tag: state.tag } : {}),
...(state.view ? { view: state.view } : {}),
})
export const resolveActivityAgendaUrl = (
params: RawSearchParams,
): {
state: ActivityAgendaState
href: string
redirectHref?: string
} => {
const state = parseActivityAgendaParams(params)
const canonicalQuery = buildActivityAgendaSearchParams(state).toString()
const href = canonicalQuery ? `${CAMPAIGN_AGENDA_HOME}?${canonicalQuery}` : CAMPAIGN_AGENDA_HOME
const raw = inspectRawListParams(params, activityAgendaParamNameSet)
return {
state,
href,
...(raw.hasUnsupportedParams || raw.query !== canonicalQuery ? { redirectHref: href } : {}),
}
}
export const buildActivityAgendaWhere = (
state: ActivityAgendaState,
rangeStart: string,
rangeEnd: string,
): Where => {
const filters: Where[] = [
{ startAt: { less_than: rangeEnd } },
{
or: [
{ endAt: { greater_than: rangeStart } },
{
and: [{ endAt: { exists: false } }, { startAt: { greater_than_equal: rangeStart } }],
},
],
},
]
if (state.municipality) filters.push({ municipality: { equals: state.municipality } })
if (state.deputyPresent) filters.push({ deputyPresent: { equals: true } })
if (state.tag) filters.push({ tags: { contains: state.tag } })
return { and: filters }
}
export type ActivityCreatePrefill = {
startAt?: string
endAt?: string
municipalityId?: number
title?: string
}
export const buildActivityCreateHref = (
agendaState: ActivityAgendaState,
prefill: Partial<ActivityCreatePrefill> = {},
): string => {
const params = new URLSearchParams()
if (prefill.startAt) params.set('startAt', prefill.startAt)
if (prefill.endAt) params.set('endAt', prefill.endAt)
const municipalityId = prefill.municipalityId ?? agendaState.municipality
if (municipalityId) params.set('municipality', String(municipalityId))
if (prefill.title) params.set('title', prefill.title)
params.set('returnTo', buildActivityAgendaHref(agendaState))
return `/campanha/atividades/nova?${params.toString()}`
}
/**
* C91 — the inline create overlay prefills the slot the staff clicked. The
* FullCalendar `dateClick` already snaps the click to a slot boundary, so for
* the week/day grids the interval is the slot itself (30 min); an all-day
* click (month grid) falls back to the historical 09:00–10:00 window.
*/
export const activitySlotPrefill = (info: {
allDay: boolean
dateStr: string
}): { startAt: string; endAt: string } | null => {
if (info.allDay) {
const startAt = parseBahiaDateTimeInput(`${info.dateStr.slice(0, 10)}T09:00`)
if (!startAt) return null
return { startAt, endAt: new Date(new Date(startAt).getTime() + 3_600_000).toISOString() }
}
const start = new Date(info.dateStr)
if (Number.isNaN(start.getTime())) return null
return {
startAt: start.toISOString(),
endAt: new Date(start.getTime() + 1_800_000).toISOString(),
}
}
export const parseActivityAgendaReturnHref = (
value: string | string[] | undefined,
accessibleMunicipalityIDs: ReadonlySet<number>,
accessibleTags: ReadonlySet<string>,
): string => {
const raw = firstValue(value)
if (!raw) return CAMPAIGN_AGENDA_HOME
const base = new URL('https://campaign.invalid')
let returnUrl: URL
try {
returnUrl = new URL(raw, base)
} catch {
return CAMPAIGN_AGENDA_HOME
}
if (returnUrl.origin !== base.origin || returnUrl.pathname !== CAMPAIGN_AGENDA_HOME) {
return CAMPAIGN_AGENDA_HOME
}
const state = restrictActivityAgendaState(
parseActivityAgendaParams({
municipality: returnUrl.searchParams.get('municipality') ?? undefined,
deputyPresent: returnUrl.searchParams.get('deputyPresent') ?? undefined,
tag: returnUrl.searchParams.get('tag') ?? undefined,
view: returnUrl.searchParams.get('view') ?? undefined,
}),
accessibleMunicipalityIDs,
accessibleTags,
)
return buildActivityAgendaHref(state)
}
const isoInstantPattern = /^\d{4}-\d{2}-\d{2}T.+(?:Z|[+-]\d{2}:\d{2})$/
const normalizedIsoInstant = (value: string | undefined): string | undefined => {
if (!value || !isoInstantPattern.test(value)) return undefined
const date = new Date(value)
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
}
export const parseActivityCreatePrefill = (
params: RawSearchParams,
accessibleMunicipalityIDs: ReadonlySet<number>,
): ActivityCreatePrefill => {
const startAt = normalizedIsoInstant(firstValue(params.startAt))
const rawEndAt = normalizedIsoInstant(firstValue(params.endAt))
const endAt = startAt && rawEndAt && new Date(rawEndAt) > new Date(startAt) ? rawEndAt : undefined
const rawMunicipality = strictDecimalInteger(firstValue(params.municipality))
const municipalityId =
rawMunicipality && accessibleMunicipalityIDs.has(rawMunicipality) ? rawMunicipality : undefined
const title = normalizedText(firstValue(params.title))
const trimmedTitle = title && title.length <= 160 ? title : undefined
return {
...(startAt ? { startAt } : {}),
...(endAt ? { endAt } : {}),
...(municipalityId ? { municipalityId } : {}),
...(trimmedTitle ? { title: trimmedTitle } : {}),
}
}
const activityListParamNames = ['q', 'tab', 'tag', 'status', 'municipality', 'page'] as const
const activityListParamNameSet = new Set<string>(activityListParamNames)
const isActivityTab = (value: string | undefined): value is ActivityTab =>
activityTabs.includes(value as ActivityTab)
const isActivityStatus = (value: string | undefined): value is ActivityStatus =>
activityStatuses.includes(value as ActivityStatus)
const isActivityListSearchReady = (raw: string | undefined): string | undefined => {
if (!raw) return undefined
const { trimmed } = normalizeContactSearchQuery(raw)
return isContactSearchQueryReady(trimmed) ? trimmed : undefined
}
export const parseActivityListParams = (params: RawSearchParams): ActivityListState => {
const rawPage = strictDecimalInteger(firstValue(params.page))
const q = isActivityListSearchReady(normalizedText(firstValue(params.q)))
const rawTab = firstValue(params.tab)
const tab = isActivityTab(rawTab) ? rawTab : 'proximos'
const rawTag = firstValue(params.tag)
const tag = rawTag?.trim() ? rawTag.trim() : undefined
const rawStatus = tab === 'todos' ? firstValue(params.status) : undefined
const status = isActivityStatus(rawStatus) ? rawStatus : undefined
const municipality = strictDecimalInteger(firstValue(params.municipality))
return {
page: rawPage ?? 1,
tab,
...(q ? { q } : {}),
...(tag ? { tag } : {}),
...(status ? { status } : {}),
...(municipality ? { municipality } : {}),
}
}
export const buildActivityListWhere = (state: ActivityListState, now: Date): Where => {
const filters: Where[] = []
if (state.q) {
const q = isActivityListSearchReady(state.q)
if (q) {
// C90 — `responsible` is a polymorphic multi-relation; a nested
// `responsible.name` query is not supported on it, so the list search is
// title-only (see impl plan).
filters.push({ title: { contains: q } })
}
}
if (state.tag) filters.push({ tags: { contains: state.tag } })
if (state.municipality) filters.push({ municipality: { equals: state.municipality } })
if (state.tab === 'proximos') {
filters.push({ status: { equals: 'confirmado' } })
filters.push({ startAt: { greater_than_equal: now.toISOString() } })
} else if (state.tab === 'realizados') {
filters.push({ status: { equals: 'realizado' } })
} else if (state.status) {
filters.push({ status: { equals: state.status } })
}
return filters.length ? { and: filters } : {}
}
export const buildActivityListSearchParams = (
state: ActivityListState,
page = state.page,
): URLSearchParams => {
const canonicalState = parseActivityListParams({
page: String(page),
q: state.q,
tab: state.tab,
tag: state.tag,
status: state.status,
municipality: state.municipality === undefined ? undefined : String(state.municipality),
})
const params = new URLSearchParams()
if (canonicalState.q) params.set('q', canonicalState.q)
if (canonicalState.tab !== 'proximos') params.set('tab', canonicalState.tab)
if (canonicalState.tag) params.set('tag', canonicalState.tag)
if (canonicalState.status) params.set('status', canonicalState.status)
if (canonicalState.municipality) params.set('municipality', String(canonicalState.municipality))
if (canonicalState.page > 1) params.set('page', String(canonicalState.page))
return params
}
export const buildActivityFiltersKey = (state: ActivityListState): string =>
buildActivityListSearchParams(state).toString()
export const buildActivityListHref = (state: ActivityListState, page: number): string =>
buildListHref(state, buildActivityListSearchParams, '/campanha/atividades', page)
export const resolveActivityListUrl = (
params: RawSearchParams,
totalPages?: number,
): {
state: ActivityListState
href: string
redirectHref?: string
} =>
resolveListUrl({
params,
paramNameSet: activityListParamNameSet,
parse: parseActivityListParams,
buildSearchParams: buildActivityListSearchParams,
basePath: '/campanha/atividades',
totalPages,
})