teqo/src/utilities/activityUi.ts
Francisco Solla cd41b44de4 feat(C105): tags no sheet da agenda + rótulo no evento + prefill no "Mais detalhes"
O filtro por tag na omnibox já existia (C94); o C105 completa a superfície da
agenda: campo de tags no overlay de criação inline (chips, sugestões das tags
já usadas), rótulo leve #tag no evento do calendário (oculto no mês/mobile) e
tags via prefill do "Mais detalhes" (params repetidos, parse fail-closed).

- ActivityTagInput extraído do ActivityForm (compartilhado, aria-label novo)
- "Mais detalhes" espelha estado vivo (horários editados + tags)
- popover do sheet com max-h --radix-popper-available-height (shift-before-flip
  do Radix clipava os campos do topo em viewports baixos)
- submit lê tags do estado espelho (fonte única), 4 KB cap p/ responsáveisJson
- limites mantidos 20/80 (C14), sem migration/access/Consent

Impl: docs/plans/agenda-tags-eventos-impl.md (aprovado no gate humano)
2026-08-10 00:21:20 -03:00

518 lines
18 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 {
formatBahiaCivilDate,
parseBahiaDateTimeInput,
subtractBahiaCivilDays,
} from '@/lib/campaignTime'
import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery'
import {
activityStatuses,
MAX_ACTIVITY_TAG_LENGTH,
MAX_ACTIVITY_TAGS,
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',
}
/**
* Inverse of `activityAgendaViewFcId`: the FullCalendar view id reported by
* `datesSet` back to the agenda's own view vocabulary.
*/
export const activityAgendaViewFromFcId = (fcId: string): ActivityAgendaView | null => {
const match = activityAgendaViews.find((view) => activityAgendaViewFcId[view] === fcId)
return match ?? null
}
const ptBrMonthNames = [
'Janeiro',
'Fevereiro',
'Março',
'Abril',
'Maio',
'Junho',
'Julho',
'Agosto',
'Setembro',
'Outubro',
'Novembro',
'Dezembro',
] as const
const civilDateParts = (civilDate: string): { day: number; month: number } | null => {
const [year, month, day] = civilDate.split('-').map(Number)
if (
!Number.isInteger(year) ||
!Number.isInteger(month) ||
!Number.isInteger(day) ||
month < 1 ||
month > 12 ||
day < 1 ||
day > 31
) {
return null
}
return { day, month }
}
const civilDateLabel = (civilDate: string): string | null => {
const parts = civilDateParts(civilDate)
if (!parts) return null
return `${parts.day} ${ptBrMonthNames[parts.month - 1]}`
}
/**
* C101 — the period context the mobile app header shows while the calendar
* navigates: day → "9 Agosto"; week → "3–9 Agosto" (visible Mon–Sun, months
* spelled out when the range crosses one: "28 Julho – 3 Agosto"); month →
* "Agosto"; list → "Agenda" (the dates live in the list body). The month
* label uses the view's current date, NOT the grid range start: the month
* grid's visible range begins on the leading Sunday of the previous month.
* Returns null when the range cannot be read, so the header falls back to
* the catalog title instead of inventing a label.
*/
export const activityAgendaPeriodLabel = (
view: ActivityAgendaView,
startStr: string,
endStr: string,
currentDateStr: string,
): string | null => {
if (view === 'list') return 'Agenda'
const civilDateOf = (value: string): string | null => {
const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : formatBahiaCivilDate(date)
}
const start = civilDateOf(startStr)
const end = civilDateOf(endStr)
const startParts = start ? civilDateParts(start) : null
const endParts = end ? civilDateParts(end) : null
if (!start || !end || !startParts || !endParts) return null
if (view === 'day') return civilDateLabel(start)
// The anchor is ALREADY a Bahia civil date (`formatBahiaCivilDate` output,
// no timezone) — parsing it as an instant would re-derive it from UTC
// midnight and shift it back a day (2026-09-01 → 31 Agosto in Bahia).
const currentParts = civilDateParts(currentDateStr)
if (view === 'month') {
if (!currentParts) return null
return ptBrMonthNames[currentParts.month - 1]
}
const weekEnd = civilDateParts(subtractBahiaCivilDays(end, 1))
if (!weekEnd) return null
if (weekEnd.month === startParts.month) {
return `${startParts.day}–${weekEnd.day} ${ptBrMonthNames[startParts.month - 1]}`
}
return `${startParts.day} ${ptBrMonthNames[startParts.month - 1]} – ${weekEnd.day} ${ptBrMonthNames[weekEnd.month - 1]}`
}
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
tags?: 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)
// C105 — repeated `tags` params: a free-form tag may contain a comma, so a
// comma-joined single value would corrupt the boundary between tags.
for (const tag of prefill.tags ?? []) params.append('tags', tag)
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()
}
/**
* C105 — bounded prefill tags: repeated `tags` params, trimmed, deduped,
* capped at the same limits the create schema enforces. Anything out of bounds
* is dropped (same drop semantics as the agenda filter's `activityTag`),
* never rejected — a prefill is a convenience, not a contract.
*/
const parseActivityPrefillTags = (value: string | string[] | undefined): string[] | undefined => {
if (value === undefined) return undefined
const tags: string[] = []
for (const entry of Array.isArray(value) ? value : [value]) {
if (tags.length >= MAX_ACTIVITY_TAGS) break
const tag = activityTag(entry)
if (tag && !tags.includes(tag)) tags.push(tag)
}
return tags.length > 0 ? tags : undefined
}
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
const tags = parseActivityPrefillTags(params.tags)
return {
...(startAt ? { startAt } : {}),
...(endAt ? { endAt } : {}),
...(municipalityId ? { municipalityId } : {}),
...(trimmedTitle ? { title: trimmedTitle } : {}),
...(tags ? { tags } : {}),
}
}
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,
})