fix: address Copilot review — keyboard trap, midnight formatting, nprofile hint

- MonthGrid: exactly one gridcell must stay tab-focusable. When no day is
  selected and the displayed month doesn't contain today (e.g. after a
  PageUp/PageDown jump), every cell previously got tabIndex=-1, trapping
  keyboard users out of the grid. Falls back to the 1st of the month.
- calendarEvents: formatEventTimeRange treated `end` as inclusive when
  checking same-day, so a time-based event ending exactly at local
  midnight formatted as a cross-day range even though eventDateKeys()
  attributes it to the start day only. Now compares against end-1ms,
  consistent with eventDateKeys().
- CalendarFilters: the author placeholder/error text only mentioned
  npub/hex even though nprofile is accepted (resolveAuthorInput handles
  it) — updated both to mention all three.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
This commit is contained in:
2026-09-06 21:34:28 +02:00
parent 7c9b150c53
commit 84a9781a03
4 changed files with 37 additions and 5 deletions

View File

@@ -80,13 +80,13 @@ export function CalendarFilters({ filters, onChange, authorError, resolvedAuthor
</Label>
<Input
id="calendar-filter-author"
placeholder="npub or hex pubkey"
placeholder="npub, nprofile, or hex pubkey"
value={filters.authorInput}
onChange={(event) => set('authorInput', event.target.value)}
aria-invalid={authorError || undefined}
className="h-8 text-xs"
/>
{authorError && <p className="text-[11px] text-destructive">Not a valid npub or hex pubkey.</p>}
{authorError && <p className="text-[11px] text-destructive">Not a valid npub, nprofile, or hex pubkey.</p>}
</div>
<div className="space-y-1.5">

View File

@@ -48,6 +48,13 @@ export function MonthGrid({
const todayKey = localDateKey(today);
const cellRefs = useRef(new Map<string, HTMLButtonElement>());
// Exactly one cell must be tab-focusable, or a keyboard user who tabs away
// and back can never re-enter the grid. Prefer the selected day, then
// today if it's in the displayed month, and only otherwise fall back to
// the 1st — e.g. after PageUp/PageDown lands on a month with neither.
const isTodayInMonth = today.getFullYear() === monthAnchor.getFullYear() && today.getMonth() === monthIndex;
const focusKey = selectedDate ?? (isTodayInMonth ? todayKey : localDateKey(new Date(monthAnchor.getFullYear(), monthIndex, 1)));
const focusDate = (date: Date) => {
const key = localDateKey(date);
onSelectDate(key, { focus: true });
@@ -121,7 +128,7 @@ export function MonthGrid({
const isCurrentMonth = date.getMonth() === monthIndex;
const isToday = key === todayKey;
const isSelected = key === selectedDate;
const isFocusable = selectedDate ? isSelected : isToday && isCurrentMonth;
const isFocusable = key === focusKey;
return (
<button

View File

@@ -199,4 +199,25 @@ describe('formatEventTimeRange', () => {
)!;
expect(formatEventTimeRange(parsed)).toContain('');
});
it('treats a time-based event ending exactly at local midnight as ending on the start day, not the next', () => {
// `end` is exclusive, so this instant belongs to the start day, same as eventDateKeys() would attribute it.
const start = new Date(2026, 5, 1, 23, 0, 0);
const end = new Date(2026, 5, 2, 0, 0, 0);
const parsed = parseCalendarEvent(
makeEvent({
kind: TIME_BASED_KIND,
tags: [
['d', 'a'],
['title', 'T'],
['start', String(Math.floor(start.getTime() / 1000))],
['end', String(Math.floor(end.getTime() / 1000))],
],
}),
)!;
const startDate = start.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
const expected = `${startDate}, ${start.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })} ${end.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}`;
expect(formatEventTimeRange(parsed)).toBe(expected);
});
});

View File

@@ -225,14 +225,18 @@ export function formatEventTimeRange(parsed: ParsedCalendarEvent): string {
return `${formatAllDayDate(parsed.start)} ${formatAllDayDate(lastDay)}`;
}
const sameDay = localDateKey(parsed.start) === localDateKey(parsed.end);
const startDate = parsed.start.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
if (parsed.start.getTime() === parsed.end.getTime()) {
return `${startDate} at ${formatTime(parsed.start)}`;
}
// `end` is exclusive, so an event ending exactly at midnight belongs to
// the *previous* instant's day — the same day `eventDateKeys()` puts it
// on — not the day `end` technically ticks over into.
const lastInstant = new Date(parsed.end.getTime() - 1);
const sameDay = localDateKey(parsed.start) === localDateKey(lastInstant);
if (sameDay) {
return `${startDate}, ${formatTime(parsed.start)} ${formatTime(parsed.end)}`;
}
const endDate = parsed.end.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
const endDate = lastInstant.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
return `${startDate} ${formatTime(parsed.start)} ${endDate} ${formatTime(parsed.end)}`;
}