Address remaining QA items: index, validation, accessibility, guard

- S1: Add composite index (is_active, is_dismissed, remind_at) for
  /due query performance with multi-user scaling
- W3: Snooze endpoint rejects dismissed/inactive reminders (409)
- W4: Custom field_validator on ReminderSnooze for clear error message
- S2: aria-label on all snooze/dismiss buttons in banner and toasts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-24 01:02:19 +08:00
parent 5b1b9cc5b7
commit b7251b72c7
5 changed files with 21 additions and 1 deletions

View File

@ -18,7 +18,13 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
op.add_column("reminders", sa.Column("snoozed_until", sa.DateTime(), nullable=True)) op.add_column("reminders", sa.Column("snoozed_until", sa.DateTime(), nullable=True))
op.create_index(
"ix_reminders_due_lookup",
"reminders",
["is_active", "is_dismissed", "remind_at"],
)
def downgrade() -> None: def downgrade() -> None:
op.drop_index("ix_reminders_due_lookup", table_name="reminders")
op.drop_column("reminders", "snoozed_until") op.drop_column("reminders", "snoozed_until")

View File

@ -79,6 +79,9 @@ async def snooze_reminder(
if not reminder: if not reminder:
raise HTTPException(status_code=404, detail="Reminder not found") raise HTTPException(status_code=404, detail="Reminder not found")
if reminder.is_dismissed or not reminder.is_active:
raise HTTPException(status_code=409, detail="Cannot snooze a dismissed or inactive reminder")
reminder.snoozed_until = datetime.now() + timedelta(minutes=body.minutes) reminder.snoozed_until = datetime.now() + timedelta(minutes=body.minutes)
await db.commit() await db.commit()

View File

@ -1,4 +1,4 @@
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict, field_validator
from datetime import datetime from datetime import datetime
from typing import Literal, Optional from typing import Literal, Optional
@ -23,6 +23,13 @@ class ReminderUpdate(BaseModel):
class ReminderSnooze(BaseModel): class ReminderSnooze(BaseModel):
minutes: Literal[5, 10, 15] minutes: Literal[5, 10, 15]
@field_validator('minutes', mode='before')
@classmethod
def validate_minutes(cls, v: int) -> int:
if v not in (5, 10, 15):
raise ValueError('Snooze duration must be 5, 10, or 15 minutes')
return v
class ReminderResponse(BaseModel): class ReminderResponse(BaseModel):
id: int id: int

View File

@ -38,6 +38,7 @@ export default function AlertBanner({ alerts, onDismiss, onSnooze }: AlertBanner
<button <button
key={m} key={m}
onClick={() => onSnooze(alert.id, m)} onClick={() => onSnooze(alert.id, m)}
aria-label={`Snooze "${alert.title}" for ${m} minutes`}
className="px-1.5 py-0.5 rounded bg-secondary hover:bg-accent/10 hover:text-accent text-muted-foreground transition-colors" className="px-1.5 py-0.5 rounded bg-secondary hover:bg-accent/10 hover:text-accent text-muted-foreground transition-colors"
> >
{m}m {m}m
@ -46,6 +47,7 @@ export default function AlertBanner({ alerts, onDismiss, onSnooze }: AlertBanner
</div> </div>
<button <button
onClick={() => onDismiss(alert.id)} onClick={() => onDismiss(alert.id)}
aria-label={`Dismiss "${alert.title}"`}
className="p-1 rounded hover:bg-accent/10 hover:text-accent text-muted-foreground transition-colors shrink-0" className="p-1 rounded hover:bg-accent/10 hover:text-accent text-muted-foreground transition-colors shrink-0"
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />

View File

@ -108,6 +108,7 @@ export function AlertsProvider({ children }: { children: ReactNode }) {
<button <button
key={m} key={m}
onClick={() => snoozeRef.current(reminder.id, m as 5 | 10 | 15)} onClick={() => snoozeRef.current(reminder.id, m as 5 | 10 | 15)}
aria-label={`Snooze "${reminder.title}" for ${m} minutes`}
className="px-1.5 py-0.5 rounded bg-secondary hover:bg-card-elevated text-muted-foreground hover:text-foreground transition-colors" className="px-1.5 py-0.5 rounded bg-secondary hover:bg-card-elevated text-muted-foreground hover:text-foreground transition-colors"
> >
{m}m {m}m
@ -116,6 +117,7 @@ export function AlertsProvider({ children }: { children: ReactNode }) {
</div> </div>
<button <button
onClick={() => dismissRef.current(reminder.id)} onClick={() => dismissRef.current(reminder.id)}
aria-label={`Dismiss "${reminder.title}"`}
className="ml-auto px-2 py-0.5 rounded text-[11px] bg-secondary hover:bg-card-elevated text-muted-foreground hover:text-foreground transition-colors" className="ml-auto px-2 py-0.5 rounded text-[11px] bg-secondary hover:bg-card-elevated text-muted-foreground hover:text-foreground transition-colors"
> >
Dismiss Dismiss