From a52c858fe1fa14a5d4be4724460d9829ec766e98 Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:19:45 +0200 Subject: [PATCH] feat(frontend): find a person on Manage Teams Type part of a name to highlight everyone who matches, on teams and in Unassigned, and jump between matches with Enter / Shift+Enter or the arrow buttons. Matching ignores case and accents and finds the text anywhere in the name. It only highlights: nobody is hidden or reordered, and it works independently of the pool filters. The search row sits right above the teams and sticks below the save bar. Browsers keep the field being typed in on screen, so a field that scrolled away with the page pulled every jump back up to itself. --- CHANGELOG.md | 3 + .../src/lib/utils/personSearch.test.ts | 72 ++++++++ .../frontend/src/lib/utils/personSearch.ts | 110 ++++++++++++ .../hackathon/[id]/teams/manage/+page.svelte | 163 +++++++++++++++++- 4 files changed, 344 insertions(+), 4 deletions(-) create mode 100644 components/frontend/src/lib/utils/personSearch.test.ts create mode 100644 components/frontend/src/lib/utils/personSearch.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 233a2ac2..a6d04376 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ written while it was being built. See [RELEASING.md](RELEASING.md). ### Added +- Manage Teams can find a person by name: type part of a name to highlight + everyone who matches, on teams and in Unassigned, and press Enter to jump from + one to the next. Accents and case do not matter. - Manage Teams makes unsaved changes hard to lose: a bar at the top stays in view while you scroll and shows what is unsaved with Save and Discard, the people and teams that changed are marked until saved, and following a link diff --git a/components/frontend/src/lib/utils/personSearch.test.ts b/components/frontend/src/lib/utils/personSearch.test.ts new file mode 100644 index 00000000..3ffd8f24 --- /dev/null +++ b/components/frontend/src/lib/utils/personSearch.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from "vitest" +import { nameSegments, stepMatch } from "./personSearch" + +/** The name with each marked piece in brackets, for readable expectations. */ +const marked = (name: string, query: string) => + nameSegments(name, query) + ?.map((s) => (s.hit ? `[${s.text}]` : s.text)) + .join("") ?? null + +describe("nameSegments", () => { + it("marks a match anywhere in the name", () => { + expect(marked("Joana Meier", "ana")).toBe("Jo[ana] Meier") + expect(marked("Joana Meier", "meier")).toBe("Joana [Meier]") + }) + + it("ignores case", () => { + expect(marked("Joana Meier", "JOANA")).toBe("[Joana] Meier") + }) + + it("ignores accents on either side, marking the name as written", () => { + expect(marked("José Núñez", "jose")).toBe("[José] Núñez") + expect(marked("Jose Nunez", "josé")).toBe("[Jose] Nunez") + expect(marked("José Núñez", "nunez")).toBe("José [Núñez]") + }) + + it("keeps the marks in place when the name is already decomposed", () => { + // "é" as "e" plus a combining accent: two code units, one letter. + const name = "José Meier" + expect(marked(name, "jose")).toBe("[José] Meier") + expect(marked(name, "meier")).toBe("José [Meier]") + }) + + it("marks every occurrence", () => { + expect(marked("Anna Hanna", "an")).toBe("[An]na H[an]na") + }) + + it("ignores the spaces around the query, not inside it", () => { + expect(marked("Joana Meier", " ana ")).toBe("Jo[ana] Meier") + expect(marked("Joana Meier", "a m")).toBe("Joan[a M]eier") + expect(nameSegments("Joana Meier", "joanam")).toBeNull() + }) + + it("is null for a blank query or no match", () => { + expect(nameSegments("Joana Meier", "")).toBeNull() + expect(nameSegments("Joana Meier", " ")).toBeNull() + expect(nameSegments("Joana Meier", "xyz")).toBeNull() + }) +}) + +describe("stepMatch", () => { + const ids = ["a", "b", "c"] + + it("steps forward and back", () => { + expect(stepMatch(ids, "a", 1)).toBe("b") + expect(stepMatch(ids, "b", -1)).toBe("a") + }) + + it("wraps at either end", () => { + expect(stepMatch(ids, "c", 1)).toBe("a") + expect(stepMatch(ids, "a", -1)).toBe("c") + }) + + it("starts at the first going forward and the last going back", () => { + expect(stepMatch(ids, null, 1)).toBe("a") + expect(stepMatch(ids, null, -1)).toBe("c") + expect(stepMatch(ids, "gone", 1)).toBe("a") + }) + + it("is null when nothing matches", () => { + expect(stepMatch([], "a", 1)).toBeNull() + }) +}) diff --git a/components/frontend/src/lib/utils/personSearch.ts b/components/frontend/src/lib/utils/personSearch.ts new file mode 100644 index 00000000..e3fa25b4 --- /dev/null +++ b/components/frontend/src/lib/utils/personSearch.ts @@ -0,0 +1,110 @@ +/** + * "Find a person" on the team-assignment page: which names match what was + * typed, which letters to mark, and stepping from one match to the next. + * + * Kept out of the page so it can be tested directly: the page only keeps the + * query, draws the marks and scrolls. + * + * A search highlights and never hides — unlike the pool filters it does not + * even reorder, so nobody moves while the organizer is looking for them. + */ + +/** A piece of a name, marked when it is part of a match. */ +export type NameSegment = { text: string; hit: boolean } + +/** + * Lower case without accents, so "jose" finds "José" and "JOSE". + * Decomposing splits "é" into "e" plus a combining accent; dropping the + * combining marks leaves the plain letter. + */ +function fold(text: string): string { + return text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase() +} + +/** + * The name split into marked and unmarked pieces, or null when the query is + * blank or not in the name. Matches anywhere in the name, ignoring case, + * accents and the spaces around the query, and marks every occurrence. + * + * The name is folded one character at a time so each folded letter knows + * which original characters it came from: folding can change the length + * (an "é" written as "e" plus a combining accent folds to one letter), and + * the marks belong on the name as written. + */ +export function nameSegments( + name: string, + query: string, +): NameSegment[] | null { + const needle = fold(query.trim()) + if (needle === "") return null + + let folded = "" + const starts: number[] = [] + const ends: number[] = [] + let at = 0 + for (const char of name) { + const f = fold(char) + // A lone combining accent folds to nothing; it belongs to the letter + // before it, so a mark ending on that letter takes it in too. + for (let i = ends.length - 1; f === "" && i >= 0 && ends[i] === at; i--) { + ends[i] = at + char.length + } + folded += f + for (let i = 0; i < f.length; i++) { + starts.push(at) + ends.push(at + char.length) + } + at += char.length + } + + const ranges: [number, number][] = [] + for ( + let i = folded.indexOf(needle); + i !== -1; + i = folded.indexOf(needle, i + needle.length) + ) { + const start = starts[i] + const end = ends[i + needle.length - 1] + // Always set — `folded` and both maps grow together — but the compiler + // cannot know that. + if (start === undefined || end === undefined) continue + const last = ranges[ranges.length - 1] + // Two matches can end and start inside the same original character. + if (last && start <= last[1]) last[1] = Math.max(last[1], end) + else ranges.push([start, end]) + } + if (ranges.length === 0) return null + + const segments: NameSegment[] = [] + let from = 0 + for (const [start, end] of ranges) { + if (start > from) + segments.push({ text: name.slice(from, start), hit: false }) + segments.push({ text: name.slice(start, end), hit: true }) + from = end + } + if (from < name.length) segments.push({ text: name.slice(from), hit: false }) + + return segments +} + +/** + * The match one step forward (+1) or back (-1) from the current one, wrapping + * at either end. With no current match — or one that is gone — forward starts + * at the first match and back at the last. Null when nothing matches. + */ +export function stepMatch( + ids: readonly string[], + currentId: string | null, + step: 1 | -1, +): string | null { + const at = currentId === null ? -1 : ids.indexOf(currentId) + const next = + at === -1 + ? step === 1 + ? ids[0] + : ids.at(-1) + : ids[(at + step + ids.length) % ids.length] + + return next ?? null +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.svelte index fec03e06..a17dbd63 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/teams/manage/+page.svelte @@ -2,14 +2,18 @@ import { enhance } from '$app/forms'; import { beforeNavigate, goto } from '$app/navigation'; import { resolve } from '$app/paths'; + import { tick } from 'svelte'; import { Check, + ChevronDown, + ChevronUp, CircleHelp, Download, Eraser, GripVertical, Lock as LockIcon, Pencil, + Search, Trash2, Upload, X @@ -17,6 +21,7 @@ import ManageHubBackLink from '$lib/components/hackathon/ManageHubBackLink.svelte'; import ConfirmDialog from '$lib/components/layout/ConfirmDialog.svelte'; import { applyAssignmentCsv, type ImportResult } from '$lib/utils/teamAssignmentCsv'; + import { nameSegments, stepMatch, type NameSegment } from '$lib/utils/personSearch'; import { initialsOf } from '$lib/utils/teamDistribution'; import { LABEL_MAX, @@ -354,6 +359,72 @@ const pool = $derived(matchesFirst(unassigned, filters)); const filtering = $derived(isFiltering(filters)); + // "Find a person": highlights and jumps, never hides or reorders — see + // `personSearch`. Independent of the filters, and not stored: a search + // left over from yesterday would read as a bug. + let query = $state(''); + /** The match last jumped to, by person id so dragging does not lose it. */ + let currentId: string | null = $state(null); + /** The sticky save bar's height, so the search row can stick below it. */ + let saveBarHeight = $state(0); + + /** Every matching person's name, cut into marked and unmarked pieces. */ + const searchHits = $derived.by(() => { + const hits: Record = {}; + for (const p of peopleById.values()) { + const segments = nameSegments(p.name, query); + if (segments) hits[p.id] = segments; + } + + return hits; + }); + + /** The matches in the order the page shows them, top to bottom. */ + const searchOrder = $derived( + [ + ...projectRows.flatMap((p) => + (teamsByProject[p.id] ?? []).flatMap((t) => t.memberIds) + ), + ...pool.matching.map((p) => p.id), + ...pool.rest.map((p) => p.id) + ].filter((id) => id in searchHits) + ); + + const searchPosition = $derived.by(() => { + if (query.trim() === '') return ''; + if (searchOrder.length === 0) return 'No match'; + const at = currentId === null ? -1 : searchOrder.indexOf(currentId); + + return at === -1 ? `${searchOrder.length} found` : `${at + 1} of ${searchOrder.length}`; + }); + + /** + * Makes a match the current one and scrolls it into view. `center` keeps + * it clear of the sticky save bar, and scrolls the Unassigned column too + * when that is where it is. + */ + async function showMatch(id: string | null) { + currentId = id; + if (id === null) return; + await tick(); + document + .querySelector(`[data-person-id="${CSS.escape(id)}"]`) + ?.scrollIntoView({ block: 'center' }); + } + + function clearSearch() { + query = ''; + currentId = null; + } + + function searchKeys(e: KeyboardEvent) { + if (e.key === 'Enter') { + e.preventDefault(); + showMatch(stepMatch(searchOrder, currentId, e.shiftKey ? -1 : 1)); + } + if (e.key === 'Escape') clearSearch(); + } + // How many unassigned people gave each answer, over the whole pool rather // than the matching part, so a number beside an answer does not change as // other answers are picked. @@ -669,23 +740,37 @@ {@const matches = projectId !== null && person.preferredProjectIds.includes(projectId)} {@const answers = answersFor(person)} {@const unsaved = changes.movedIds.has(person.id)} + {@const hit = searchHits[person.id]} +
startDrag(e, person.id, from)} ondragend={endDrag} class="flex cursor-grab items-center gap-1.5 rounded-card border px-2 py-1 active:cursor-grabbing {unsaved ? 'border-warning bg-warning/10' - : 'border-line bg-raised'}" + : 'border-line bg-raised'} {!hit + ? '' + : currentId === person.id + ? 'ring-2 ring-accent ring-inset' + : 'ring-1 ring-accent/50 ring-inset'}" class:opacity-40={draggedId === person.id} title={unsaved ? 'Moved — not saved yet' : undefined} >
- {person.name} + {#if hit}{#each hit as s, i (i)}{#if s.hit}{s.text}{:else}{s.text}{/if}{/each}{:else}{person.name}{/if} {#if !showPreferences} {:else if person.preferredNumbers.length > 0} @@ -766,6 +851,7 @@ changed on the page. A failed save's message is repeated here, where it is in view. -->
+
+
+ {#if query.trim() !== ''} + {searchPosition} + + + + {/if} +
+

Projects

@@ -1404,11 +1554,16 @@ {@render personRow(person, POOL, null, null)} {/each} + draggable, and full strength under the pointer — or + when "Find a person" found them. --> {#if pool.rest.length > 0}

Not matching ({pool.rest.length})

{#each pool.rest as person (person.id)} -
+
{@render personRow(person, POOL, null, null)}
{/each}