teqo-backup/src/utilities/activityOmnibox.ts

305 lines
8.4 KiB
TypeScript

/**
* Activity list omnibox adapter (B128 + B138). Pure / client-safe.
* Window presets and text search live inside the omnibox.
*/
import {
createOmniboxSuggestionSeed,
filterOmniboxSuggestionSeeds,
type CampaignListOmniboxChip,
type CampaignListOmniboxSuggestion,
} from '@/lib/campaignListOmnibox'
import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery'
import { ACTIVITY_UNSCOPED_LABEL, activityStatusLabels } from '@/lib/schemas/activity'
import {
activityTabLabels,
activityTabs,
buildActivityListSearchParams,
parseActivityListParams,
type ActivityListState,
type ActivityTab,
} from '@/utilities/activityUi'
export type ActivityFilterOption = {
value: string
label: string
}
export type ActivityOmniboxAction =
| { kind: 'url'; state: ActivityListState }
| { kind: 'clear'; state: ActivityListState }
const chipLabel = (dimension: string, value: string): string => `${dimension}: ${value}`
const withPageReset = (state: ActivityListState): ActivityListState => ({ ...state, page: 1 })
const parseStateFromParams = (
raw: Record<string, string | string[] | undefined>,
): ActivityListState => parseActivityListParams(raw)
const setExclusiveField = (
state: ActivityListState,
field: 'tag' | 'status' | 'municipality' | 'unscoped',
value: string | undefined,
): ActivityListState => {
const params = buildActivityListSearchParams(withPageReset(state))
const raw: Record<string, string | string[] | undefined> = Object.fromEntries(params.entries())
if (field === 'tag') {
raw.tag = value
} else if (field === 'status') {
raw.status = value
} else if (field === 'municipality') {
// C165 — picking a município leaves the "Sem município" triage filter.
raw.municipality = value
delete raw.unscoped
} else if (field === 'unscoped') {
raw.unscoped = value
delete raw.municipality
}
return parseStateFromParams(raw)
}
const tabKeywords: Record<ActivityTab, string[]> = {
proximos: ['proximos', 'próximos', 'proximo', 'próximo'],
todos: ['todos', 'todas'],
realizados: ['realizados', 'realizado'],
}
const setTab = (state: ActivityListState, tab: ActivityTab): ActivityListState => {
const params = buildActivityListSearchParams(withPageReset({ ...state, tab }))
const raw: Record<string, string | string[] | undefined> = Object.fromEntries(params.entries())
if (tab !== 'todos') {
delete raw.status
}
return parseStateFromParams(raw)
}
export const buildActivityOmniboxChips = ({
state,
municipalityLabelsById,
}: {
state: ActivityListState
municipalityLabelsById: ReadonlyMap<number, string>
}): CampaignListOmniboxChip[] => {
const chips: CampaignListOmniboxChip[] = []
if (state.q) chips.push({ id: 'q', label: chipLabel('Busca', state.q) })
if (state.tab !== 'proximos') {
chips.push({
id: `tab:${state.tab}`,
label: activityTabLabels[state.tab],
})
}
if (state.tag) {
chips.push({
id: `tag:${state.tag}`,
label: chipLabel('Tag', state.tag),
})
}
if (state.tab === 'todos' && state.status) {
chips.push({
id: `status:${state.status}`,
label: chipLabel('Status', activityStatusLabels[state.status]),
})
}
if (state.unscoped) {
// C165 — the triage filter: activities without a município.
chips.push({ id: 'unscoped', label: ACTIVITY_UNSCOPED_LABEL })
}
if (state.municipality) {
chips.push({
id: `municipality:${state.municipality}`,
label: chipLabel(
'Município',
municipalityLabelsById.get(state.municipality) ?? `Município #${state.municipality}`,
),
})
}
return chips
}
export const buildActivityOmniboxSuggestionSeeds = ({
tab,
municipalityOptions,
knownTags,
}: {
tab: ActivityTab
municipalityOptions: readonly ActivityFilterOption[]
knownTags?: readonly string[]
}) => {
const seeds = []
for (const tabValue of activityTabs) {
seeds.push(
createOmniboxSuggestionSeed(
{
id: `tab:${tabValue}`,
group: 'Janela',
label: activityTabLabels[tabValue],
keywords: ['janela', ...tabKeywords[tabValue]],
},
{ emptyQueryVisible: true },
),
)
}
for (const tag of knownTags ?? []) {
seeds.push(
createOmniboxSuggestionSeed(
{
id: `tag:${tag}`,
group: 'Tag',
label: tag,
keywords: ['tag', 'tipo'],
},
{ emptyQueryVisible: true },
),
)
}
if (tab === 'todos') {
for (const [value, label] of Object.entries(activityStatusLabels)) {
seeds.push(
createOmniboxSuggestionSeed(
{
id: `status:${value}`,
group: 'Status',
label,
keywords: ['status'],
},
{ emptyQueryVisible: true },
),
)
}
}
for (const option of municipalityOptions) {
seeds.push(
createOmniboxSuggestionSeed({
id: `municipality:${option.value}`,
group: 'Município',
label: option.label,
keywords: ['municipio'],
}),
)
}
// C165 — the triage entry: imported Google activities awaiting a município.
seeds.push(
createOmniboxSuggestionSeed({
id: 'unscoped',
group: 'Município',
label: ACTIVITY_UNSCOPED_LABEL,
keywords: ['municipio', 'sem', 'importado', 'google'],
}),
)
return seeds
}
export const filterActivityOmniboxSuggestions = (
seeds: ReturnType<typeof buildActivityOmniboxSuggestionSeeds>,
query: string,
): CampaignListOmniboxSuggestion[] => filterOmniboxSuggestionSeeds(seeds, query)
export const applyActivityOmniboxSuggestion = ({
state,
suggestionId,
}: {
state: ActivityListState
suggestionId: string
}): ActivityOmniboxAction => {
if (suggestionId.startsWith('q:')) {
const { trimmed } = normalizeContactSearchQuery(suggestionId.slice(2))
const q = isContactSearchQueryReady(trimmed) ? trimmed : undefined
return { kind: 'url', state: withPageReset({ ...state, q }) }
}
if (suggestionId.startsWith('tab:')) {
const value = suggestionId.slice(4) as ActivityTab
if (!activityTabs.includes(value)) return { kind: 'url', state }
const next = state.tab === value ? setTab(state, 'proximos') : setTab(state, value)
return { kind: 'url', state: next }
}
if (suggestionId.startsWith('tag:')) {
const value = suggestionId.slice(4)
const next =
state.tag === value
? setExclusiveField(state, 'tag', undefined)
: setExclusiveField(state, 'tag', value)
return { kind: 'url', state: next }
}
if (suggestionId.startsWith('status:')) {
const value = suggestionId.slice(7)
const next =
state.status === value
? setExclusiveField(state, 'status', undefined)
: setExclusiveField(state, 'status', value)
return { kind: 'url', state: next }
}
if (suggestionId === 'unscoped') {
const next = state.unscoped
? setExclusiveField(state, 'unscoped', undefined)
: setExclusiveField(state, 'unscoped', '1')
return { kind: 'url', state: next }
}
if (suggestionId.startsWith('municipality:')) {
const value = suggestionId.slice(13)
const next =
state.municipality === Number(value)
? setExclusiveField(state, 'municipality', undefined)
: setExclusiveField(state, 'municipality', value)
return { kind: 'url', state: next }
}
return { kind: 'url', state }
}
export const removeActivityOmniboxChip = ({
state,
chipId,
}: {
state: ActivityListState
chipId: string
}): ActivityOmniboxAction => {
if (chipId === 'q') return { kind: 'url', state: withPageReset({ ...state, q: undefined }) }
if (chipId.startsWith('tab:')) {
return { kind: 'url', state: setTab(state, 'proximos') }
}
if (chipId.startsWith('tag:')) {
return { kind: 'url', state: setExclusiveField(state, 'tag', undefined) }
}
if (chipId.startsWith('status:')) {
return { kind: 'url', state: setExclusiveField(state, 'status', undefined) }
}
if (chipId === 'unscoped') {
return { kind: 'url', state: setExclusiveField(state, 'unscoped', undefined) }
}
if (chipId.startsWith('municipality:')) {
return { kind: 'url', state: setExclusiveField(state, 'municipality', undefined) }
}
return { kind: 'url', state }
}
export const clearActivityOmnibox = (state: ActivityListState): ActivityOmniboxAction => ({
kind: 'clear',
state: withPageReset({ page: 1, tab: state.tab }),
})