diff --git a/manifest.ts b/manifest.ts index de459e9..c839e59 100644 --- a/manifest.ts +++ b/manifest.ts @@ -9,7 +9,7 @@ import type { PluginManifest } from '@@/plugins/manifest'; */ export const manifest: PluginManifest = { publisher: 'officerdev', - version: '2026.08.27.02', + version: '2026.08.27.03', platform: '>=1.0.0', /** diff --git a/web/dav/CalendarView.tsx b/web/dav/CalendarView.tsx index dc8c81b..13b885a 100644 --- a/web/dav/CalendarView.tsx +++ b/web/dav/CalendarView.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { CalendarDays, MapPin, Repeat, Pencil, Trash2, Check, X, Plus } from 'lucide-react'; import { useCalendarEvents, useSelectedCollection } from './useDavData'; import { useDavEdit, CREATING, type NewEvent } from './useDavEdit'; +import { useFilter, matches } from './useFilter'; import { useTranslation } from 'officerdev'; // Right panel of /calendar. Deliberately a grouped agenda list, not a month grid: the sidecar returns @@ -39,7 +40,14 @@ const blankDraft = (): Draft => { const start = new Date(Date.now() + 60 * 60 * 1000); start.setMinutes(0, 0, 0); const end = new Date(start.getTime() + 60 * 60 * 1000); - return { summary: '', location: '', date: localDate(start), start: localTime(start), end: localTime(end), allDay: false }; + return { + summary: '', + location: '', + date: localDate(start), + start: localTime(start), + end: localTime(end), + allDay: false, + }; }; /** @@ -71,21 +79,29 @@ export const CalendarView = () => { const [draft, setDraft] = useState({ summary: '', location: '' }); const [confirming, setConfirming] = useState(null); const [draftNew, setDraftNew] = useState(null); + const filter = useFilter('Filter events'); if (loadingCollections) return ; if (!selected) return ; + // Title, place, notes — and the DAY, so "saturday" or "2026-08-29" narrows to a date the way a person + // expects. `start` is an ISO string, which is why the date is searchable at all without parsing it. + const shown = events.filter((ev) => matches(filter.query, ev.summary, ev.location, ev.description, ev.start)); + // Not an early return any more: the header carries the only way to ADD an event, and a calendar that - // is empty or still loading is exactly when somebody wants it. + // is empty or still loading is exactly when somebody wants it. A filter that matches nothing says so, + // rather than looking like a calendar that lost its events. const notice = error ? t('dav.readFailed', { name: selected.displayName, reason: String(error) }) : isLoading ? 'Loading events…' : events.length === 0 ? `${selected.displayName} has no events.` - : null; + : shown.length === 0 + ? `No event matches “${filter.query}”.` + : null; - const sorted = [...events].sort((a, b) => (a.start ?? '').localeCompare(b.start ?? '')); + const sorted = [...shown].sort((a, b) => (a.start ?? '').localeCompare(b.start ?? '')); const days: Array<{ key: string; events: CalendarEvent[] }> = []; for (const ev of sorted) { const key = dayKey(ev); @@ -110,9 +126,11 @@ export const CalendarView = () => {
{selected.displayName}
- {events.length} {events.length === 1 ? 'event' : 'events'} + {filter.query ? `${shown.length} of ${events.length}` : events.length}{' '} + {events.length === 1 ? 'event' : 'events'}
+ {filter.input} - + {editing === ev.href && ( + { + e.preventDefault(); + if (!ev.href) return; + // `null` removes the line; an empty string would leave an empty LOCATION behind. + void edit + .save(ev.href, { summary: draft.summary, location: draft.location || null }) + .then((ok) => ok && setEditing(null)); + }} + > + setDraft((d) => ({ ...d, summary: e.target.value }))} + placeholder="Title" + className="rounded-md border bg-background px-2 py-1 text-sm" + /> + setDraft((d) => ({ ...d, location: e.target.value }))} + placeholder="Where" + className="rounded-md border bg-background px-2 py-1 text-xs" + /> +
+ + +
+ + )} + {ev.location && ( +
+ + {ev.location}
- - )} - {ev.location && ( -
- - {ev.location} -
- )} - {ev.description && ( -

{ev.description}

- )} - - {/* Only for an event we can actually address. An item with no href came from somewhere - that did not give us one, and offering a delete that cannot work is worse than not - offering it. */} - {ev.href && editing !== ev.href && ( -
- {confirming === ev.href ? ( - <> - - - - ) : ( - <> - - - + )} + {ev.description && ( +

{ev.description}

)}
- )} - - ))} - + {/* Only for an event we can actually address. An item with no href came from somewhere + that did not give us one, and offering a delete that cannot work is worse than not + offering it. */} + {ev.href && editing !== ev.href && ( +
+ {confirming === ev.href ? ( + <> + + + + ) : ( + <> + + + + )} +
+ )} + + ))} + ))} diff --git a/web/dav/ContactsView.tsx b/web/dav/ContactsView.tsx index 52e5761..089bceb 100644 --- a/web/dav/ContactsView.tsx +++ b/web/dav/ContactsView.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { Building2, Mail, Phone, User, Pencil, Trash2, Check, X, Plus } from 'lucide-react'; import { useDavContacts, useSelectedCollection } from './useDavData'; import { useDavEdit, CREATING } from './useDavEdit'; +import { useFilter, matches } from './useFilter'; import { useTranslation } from 'officerdev'; // Right panel of /contacts: the selected address book as cards, sorted by name. @@ -31,21 +32,27 @@ export const ContactsView = () => { const [draft, setDraft] = useState({ fullName: '', email: '', phone: '' }); const [confirming, setConfirming] = useState(null); const [draftNew, setDraftNew] = useState | null>(null); + const filter = useFilter('Filter contacts'); if (loadingCollections) return ; if (!selected) return ; + // Everything a person might half-remember about someone: the name, either address, the company. + const shown = contacts.filter((c) => matches(filter.query, c.fullName, c.org, c.title, ...c.emails, ...c.phones)); + // Not an early return: the header carries the only way to ADD a card, and an empty address book is - // exactly when somebody wants it. + // exactly when somebody wants it. A filter that matches nothing says SO — otherwise it reads as an + // address book that lost its contacts. const notice = error ? t('dav.readFailed', { name: selected.displayName, reason: String(error) }) : isLoading ? 'Loading contacts…' : contacts.length === 0 ? `${selected.displayName} has no contacts.` - : null; - - const sorted = [...contacts].sort((a, b) => a.fullName.localeCompare(b.fullName)); + : shown.length === 0 + ? `No contact matches “${filter.query}”.` + : null; + const sorted = [...shown].sort((a, b) => a.fullName.localeCompare(b.fullName)); const submitNew = (e: React.FormEvent) => { e.preventDefault(); @@ -65,9 +72,11 @@ export const ContactsView = () => {
{selected.displayName}
- {contacts.length} {contacts.length === 1 ? 'contact' : 'contacts'} + {filter.query ? `${shown.length} of ${contacts.length}` : contacts.length}{' '} + {contacts.length === 1 ? 'contact' : 'contacts'}
+ {filter.input} - - - - )} - - - {contact.href && editing !== contact.href && ( -
- {confirming === contact.href ? ( - <> - - - - ) : ( - <> - - - + void edit + .save(contact.href, { + fullName: draft.fullName, + emails: [draft.email, ...contact.emails.slice(1)].filter(Boolean), + phones: [draft.phone, ...contact.phones.slice(1)].filter(Boolean), + }) + .then((ok) => ok && setEditing(null)); + }} + > + setDraft((d) => ({ ...d, fullName: e.target.value }))} + placeholder="Name" + className="rounded-md border bg-background px-2 py-1 text-sm" + /> + setDraft((d) => ({ ...d, email: e.target.value }))} + placeholder="Email" + className="rounded-md border bg-background px-2 py-1 text-xs" + /> + setDraft((d) => ({ ...d, phone: e.target.value }))} + placeholder="Phone" + className="rounded-md border bg-background px-2 py-1 text-xs tabular-nums" + /> +
+ + +
+ )}
- )} + + {contact.href && editing !== contact.href && ( +
+ {confirming === contact.href ? ( + <> + + + + ) : ( + <> + + + + )} +
+ )} ))} diff --git a/web/dav/useFilter.test.ts b/web/dav/useFilter.test.ts new file mode 100644 index 0000000..ac6d6b5 --- /dev/null +++ b/web/dav/useFilter.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'bun:test'; +import { matches } from './useFilter'; + +// The accent rule is the one that matters on this machine: an address book of Portuguese names is +// unsearchable if "jose" does not find "José". + +describe('matches', () => { + test('an empty query keeps everything', () => { + expect(matches('', 'anything')).toBe(true); + expect(matches(' ', 'anything')).toBe(true); + }); + + test('is case insensitive', () => { + expect(matches('RAMIRO', 'Cervejaria Ramiro')).toBe(true); + }); + + test('ignores accents in BOTH directions', () => { + expect(matches('jose', 'José Conceição')).toBe(true); + expect(matches('josé', 'Jose Conceicao')).toBe(true); + expect(matches('conceicao', 'José Conceição')).toBe(true); + }); + + test('every term must match, but they may land in different fields', () => { + expect(matches('ana lisboa', 'Ana Silva', 'Lisboa')).toBe(true); + expect(matches('ana porto', 'Ana Silva', 'Lisboa')).toBe(false); + }); + + test('skips absent fields rather than matching on "undefined"', () => { + expect(matches('undefined', 'Ana Silva', undefined)).toBe(false); + }); + + test('a date is searchable because start is an ISO string', () => { + expect(matches('2026-08-29', 'Dinner', undefined, undefined, '2026-08-29T19:00:00Z')).toBe(true); + }); +}); diff --git a/web/dav/useFilter.tsx b/web/dav/useFilter.tsx new file mode 100644 index 0000000..194d67f --- /dev/null +++ b/web/dav/useFilter.tsx @@ -0,0 +1,53 @@ +import { useState } from 'react'; +import { Search, X } from 'lucide-react'; + +/** + * A filter box for a list that is already in the browser. + * + * Client-side, deliberately. Both screens load a whole collection in one request and hold it — a + * calendar of a few hundred events, an address book of a few hundred cards — so filtering is a string + * comparison over data already in hand. A server round trip per keystroke would be slower, would need a + * search endpoint on the sidecar, and would show a spinner where a person expects a list to narrow. + * + * Matching is CASE- AND ACCENT-INSENSITIVE, and that second half is not decoration here: this owner's + * contacts are Portuguese. Typing "jose" must find "José" or the box is useless for half the address + * book. `NFD` splits a letter from its accent and the range strips what is left behind. + * + * Every term must match SOMEWHERE, not all in one field — so "ana lisboa" finds the Ana whose address is + * in Lisbon, which is how people actually search a contact list. + */ +const fold = (value: string) => value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase(); + +export const matches = (query: string, ...fields: (string | undefined)[]): boolean => { + const terms = fold(query).split(/\s+/).filter(Boolean); + if (!terms.length) return true; + const haystack = fold(fields.filter(Boolean).join(' ')); + return terms.every((term) => haystack.includes(term)); +}; + +export function useFilter(placeholder: string) { + const [query, setQuery] = useState(''); + + const input = ( +
+ + setQuery(e.target.value)} + placeholder={placeholder} + className="w-full rounded-md border bg-background py-1 pl-7 pr-7 text-xs" + /> + {query && ( + + )} +
+ ); + + return { query, input }; +}