Compare commits
2
Commits
2026.08.27.02
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b578c2aa3b | ||
|
|
6954c878d3 |
+1
-1
@@ -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',
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
name: Calendar and contacts
|
||||
name: calendar-and-contacts
|
||||
title: Calendar and contacts
|
||||
description: Use when the owner asks you to add, move, cancel or look up an event, or to add, remove or find a contact — "dinner Saturday with my wife", "move that to 9", "when am I seeing the CEO", "add Peter Griffin, this number". Carries the JSON routes, the one credential, and the timezone trap that puts every event an hour out.
|
||||
version: 2
|
||||
---
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [draftNew, setDraftNew] = useState<Draft | null>(null);
|
||||
const filter = useFilter('Filter events');
|
||||
|
||||
if (loadingCollections) return <Placeholder text="Loading calendars…" />;
|
||||
if (!selected) return <Placeholder text="No calendars yet. Create one from a CalDAV client." />;
|
||||
|
||||
// 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.`
|
||||
: 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 = () => {
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold leading-tight">{selected.displayName}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{events.length} {events.length === 1 ? 'event' : 'events'}
|
||||
{filter.query ? `${shown.length} of ${events.length}` : events.length}{' '}
|
||||
{events.length === 1 ? 'event' : 'events'}
|
||||
</div>
|
||||
</div>
|
||||
{filter.input}
|
||||
<button
|
||||
onClick={() => setDraftNew((d) => (d ? null : blankDraft()))}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/20"
|
||||
@@ -292,7 +310,10 @@ export const CalendarView = () => {
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={() => setConfirming(null)} className="rounded p-1 opacity-50 hover:opacity-100">
|
||||
<button
|
||||
onClick={() => setConfirming(null)}
|
||||
className="rounded p-1 opacity-50 hover:opacity-100"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [draftNew, setDraftNew] = useState<ReturnType<typeof blankDraft> | null>(null);
|
||||
const filter = useFilter('Filter contacts');
|
||||
|
||||
if (loadingCollections) return <Placeholder text="Loading address books…" />;
|
||||
if (!selected) return <Placeholder text="No address books yet. Create one from a CardDAV client." />;
|
||||
|
||||
// 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.`
|
||||
: shown.length === 0
|
||||
? `No contact matches “${filter.query}”.`
|
||||
: null;
|
||||
|
||||
const sorted = [...contacts].sort((a, b) => a.fullName.localeCompare(b.fullName));
|
||||
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 = () => {
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold leading-tight">{selected.displayName}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{contacts.length} {contacts.length === 1 ? 'contact' : 'contacts'}
|
||||
{filter.query ? `${shown.length} of ${contacts.length}` : contacts.length}{' '}
|
||||
{contacts.length === 1 ? 'contact' : 'contacts'}
|
||||
</div>
|
||||
</div>
|
||||
{filter.input}
|
||||
<button
|
||||
onClick={() => setDraftNew((d) => (d ? null : blankDraft()))}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/20"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 = (
|
||||
<div className="relative min-w-0 flex-1 sm:max-w-64">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full rounded-md border bg-background py-1 pl-7 pr-7 text-xs"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={() => setQuery('')}
|
||||
title="Clear"
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return { query, input };
|
||||
}
|
||||
Reference in New Issue
Block a user