From cead772af0a6153eef44b0634e175b5061ad2a63 Mon Sep 17 00:00:00 2001 From: Daksh Shahani Date: Wed, 22 Jul 2026 13:57:25 -0700 Subject: [PATCH 1/5] feat(query): add categorical column metadata and value extraction --- src/services/query.ts | 58 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/services/query.ts b/src/services/query.ts index c6eea5f..9786230 100644 --- a/src/services/query.ts +++ b/src/services/query.ts @@ -331,6 +331,64 @@ export interface FlattenedApplicant { [key: string]: string | number | boolean | Date | null | Record | undefined; // extra keys for group-by results } +export const CATEGORICAL_COLUMNS: ReadonlySet = new Set([ + "applicationStatus", + "educationLevel", + "major", + "role", + "dietaryRestriction", + "culturalBackground", + "school", + "gender", + "countryOfResidence", + "academicYear", + "canadianStatus", + "disability", + "haveTransExperience", + "indigenousIdentification", + "jobPosition", + "travellingToHackathon", + "engagementSource", + "ageByHackathon", + "graduation", +]); + +export const MULTI_VALUE_COLUMNS: ReadonlySet = new Set([ + "major", + "gender", + "dietaryRestriction", + "culturalBackground", + "role", + "engagementSource", +]); + +export const extractColumnValues = ( + applicants: FlattenedApplicant[], +): Record => { + const result: Record = {}; + for (const column of CATEGORICAL_COLUMNS) { + const values = new Set(); + const isMultiValue = MULTI_VALUE_COLUMNS.has(column); + for (const applicant of applicants) { + const raw = applicant[column]; + if (raw === null || raw === undefined) continue; + if (isMultiValue) { + for (const token of String(raw).split(",")) { + const trimmed = token.trim(); + if (trimmed) values.add(trimmed); + } + } else { + const trimmed = String(raw).trim(); + if (trimmed) values.add(trimmed); + } + } + result[column] = [...values].sort((a, b) => + a.localeCompare(b, undefined, { sensitivity: "base" }), + ); + } + return result; +}; + /** * Calculates all hackers' points from day-of events asynchronously * From 1af7dbd2abde8ee8afa54b3935707fbe1662b29d Mon Sep 17 00:00:00 2001 From: Daksh Shahani Date: Wed, 22 Jul 2026 14:22:44 -0700 Subject: [PATCH 2/5] feat(query): expose columnValueOptions on QueryProvider and pass to FilterRows --- src/components/features/query/query-filters.tsx | 2 ++ src/providers/query-provider.tsx | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/src/components/features/query/query-filters.tsx b/src/components/features/query/query-filters.tsx index e52dd7d..e29596d 100644 --- a/src/components/features/query/query-filters.tsx +++ b/src/components/features/query/query-filters.tsx @@ -23,6 +23,7 @@ export function QueryFilters({ availableColumns }: QueryFiltersProps) { sorting, onSortingChange, applicants, + columnValueOptions, } = useQuery(); const handleColumnsChange = (columns: string[]) => { @@ -113,6 +114,7 @@ export function QueryFilters({ availableColumns }: QueryFiltersProps) { ; + // Actions onColumnToggle: (column: string) => void; onGroupByChange: (selection: GroupBySelection | undefined) => void; @@ -182,6 +185,8 @@ export function QueryProvider({ children }: QueryProviderProps) { const [filterSelections, setFilterSelections] = useState([]); const [sorting, setSorting] = useState([]); + const columnValueOptions = useMemo(() => extractColumnValues(applicants), [applicants]); + const tableData = useMemo(() => { let filtered = applicants; if (filterSelections.length > 0) { @@ -288,6 +293,7 @@ export function QueryProvider({ children }: QueryProviderProps) { filterSelections, sorting, tableData, + columnValueOptions, onColumnToggle: handleColumnToggle, onGroupByChange: setGroupBySelection, onFilterAdd: handleFilterAdd, From 8cbdd03343fd00811fb94a82ce2f25be22b44f70 Mon Sep 17 00:00:00 2001 From: Daksh Shahani Date: Wed, 22 Jul 2026 14:42:25 -0700 Subject: [PATCH 3/5] feat(query): prefix-filtered combobox for categorical filter values --- .../features/query/popovers/filter-rows.tsx | 45 +++++-- src/components/ui/combobox.tsx | 112 ++++++++++++++++++ 2 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 src/components/ui/combobox.tsx diff --git a/src/components/features/query/popovers/filter-rows.tsx b/src/components/features/query/popovers/filter-rows.tsx index d782e84..d8976c1 100644 --- a/src/components/features/query/popovers/filter-rows.tsx +++ b/src/components/features/query/popovers/filter-rows.tsx @@ -1,5 +1,6 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Combobox } from "@/components/ui/combobox"; import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { @@ -11,9 +12,12 @@ import { } from "@/components/ui/select"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { FilterRowsSelection } from "@/lib/firebase/types"; +import { CATEGORICAL_COLUMNS } from "@/services/query"; import { Check, Pencil, Plus, X } from "lucide-react"; import { useState } from "react"; +const BOOLEAN_OPTIONS = ["true", "false"]; + /** * Set of possible operators for filtering rows based on data type. */ @@ -39,6 +43,7 @@ const CONDITION_OPTIONS: Record = { interface FilterRowsProps { columns: string[]; columnTypes: Record; + columnValueOptions: Record; filterSelections: FilterRowsSelection[]; onAddFilter: (filter: FilterRowsSelection) => void; onRemoveFilter: (filterId: string) => void; @@ -53,6 +58,7 @@ interface FilterRowsProps { export function FilterRows({ columns, columnTypes, + columnValueOptions, filterSelections, onAddFilter, onRemoveFilter, @@ -283,13 +289,38 @@ export function FilterRows({ ))} - setNewFilterValue(e.target.value)} - disabled={!newFilterCondition} - /> + {(() => { + const isBoolean = type === "boolean"; + const isCategorical = CATEGORICAL_COLUMNS.has(newFilterColumn); + const options = isBoolean + ? BOOLEAN_OPTIONS + : isCategorical + ? (columnValueOptions[newFilterColumn] ?? []) + : []; + + if (isBoolean || isCategorical) { + return ( + + ); + } + + return ( + setNewFilterValue(e.target.value)} + disabled={!newFilterCondition} + /> + ); + })()} + ))} + + + + ); +} From 39d23543816b9668b6bb69c0fb89b469242503ae Mon Sep 17 00:00:00 2001 From: Daksh Shahani Date: Wed, 22 Jul 2026 14:57:10 -0700 Subject: [PATCH 4/5] fix(query): onClick fixed (ai fix lol) --- src/components/ui/combobox.tsx | 64 +++++++++++++++++++--------------- 1 file changed, 36 insertions(+), 28 deletions(-) diff --git a/src/components/ui/combobox.tsx b/src/components/ui/combobox.tsx index dc38358..84d39b9 100644 --- a/src/components/ui/combobox.tsx +++ b/src/components/ui/combobox.tsx @@ -26,8 +26,7 @@ export function Combobox({ const search = value.trim().toLowerCase(); const filtered = search ? options.filter((o) => o.toLowerCase().startsWith(search)) : options; - const showDropdown = - open && filtered.length > 0 && !(filtered.length === 1 && filtered[0] === value.trim()); + const showDropdown = open && filtered.length > 0; // biome-ignore lint/correctness/useExhaustiveDependencies: reset highlight when the search term changes useEffect(() => { @@ -61,7 +60,7 @@ export function Combobox({ }; return ( - + setOpen(true)} + onClick={() => setOpen(true)} onBlur={() => { setTimeout(() => { if (!inputRef.current?.contains(document.activeElement)) setOpen(false); @@ -82,31 +82,39 @@ export function Combobox({ onKeyDown={onKeyDown} /> - e.preventDefault()} - > -
- {filtered.map((opt, i) => ( - - ))} -
-
+ {filtered.length > 0 && ( + e.preventDefault()} + onPointerDownOutside={(e) => { + if (inputRef.current?.contains(e.target as Node)) e.preventDefault(); + }} + onFocusOutside={(e) => { + if (inputRef.current?.contains(e.target as Node)) e.preventDefault(); + }} + > +
+ {filtered.map((opt, i) => ( + + ))} +
+
+ )}
); } From bd81868d3909e2e68d730b53a49be324e2d8412e Mon Sep 17 00:00:00 2001 From: Daksh Shahani Date: Thu, 23 Jul 2026 16:36:49 -0700 Subject: [PATCH 5/5] refactor(query): clearer operator labels --- .../features/query/popovers/filter-rows.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/features/query/popovers/filter-rows.tsx b/src/components/features/query/popovers/filter-rows.tsx index d8976c1..646ade9 100644 --- a/src/components/features/query/popovers/filter-rows.tsx +++ b/src/components/features/query/popovers/filter-rows.tsx @@ -23,20 +23,20 @@ const BOOLEAN_OPTIONS = ["true", "false"]; */ const CONDITION_OPTIONS: Record = { string: [ - { value: "matches", label: "matches" }, - { value: "does_not_match", label: "does not match" }, - { value: "equals", label: "equals" }, - { value: "not_equals", label: "is not equal to" }, + { value: "matches", label: "contains" }, + { value: "does_not_match", label: "does not contain" }, + { value: "equals", label: "exactly equals" }, + { value: "not_equals", label: "is not exactly equal to" }, ], number: [ - { value: "equals", label: "equals" }, - { value: "not_equals", label: "is not equal to" }, + { value: "equals", label: "exactly equals" }, + { value: "not_equals", label: "is not exactly equal to" }, { value: "greater_than", label: "greater than" }, { value: "less_than", label: "less than" }, ], boolean: [ - { value: "equals", label: "equals" }, - { value: "not_equals", label: "is not equal to" }, + { value: "equals", label: "exactly equals" }, + { value: "not_equals", label: "is not exactly equal to" }, ], };