Reminders and Notifications -- on scheduled to dos

This commit is contained in:
Brian Fertig 2026-08-28 13:19:25 -06:00
parent 80176636b7
commit 304d750ec5
12 changed files with 521 additions and 46 deletions

View File

@ -1,12 +1,13 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
import { AlertTriangle, CalendarClock, ChevronLeft, ChevronRight, Plus } from "lucide-react"; import { AlertTriangle, Bell, BellOff, CalendarClock, ChevronLeft, ChevronRight, Plus } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { formatWeekdayDate } from "@/lib/format"; import { formatWeekdayDate } from "@/lib/format";
import { isTimeDuePast } from "@/lib/time-of-day";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@ -14,8 +15,14 @@ import { useScheduledPanel } from "@/components/scheduled/scheduled-panel-provid
import { ScheduledTodoItem } from "@/components/scheduled/scheduled-todo-item"; import { ScheduledTodoItem } from "@/components/scheduled/scheduled-todo-item";
import { ScheduledTodoDialog } from "@/components/scheduled/scheduled-todo-dialog"; import { ScheduledTodoDialog } from "@/components/scheduled/scheduled-todo-dialog";
import { getScheduledBoard, toggleScheduledOccurrence } from "@/lib/actions/scheduled-todos"; import { getScheduledBoard, toggleScheduledOccurrence } from "@/lib/actions/scheduled-todos";
import { useHoldOnComplete } from "@/components/hold-on-complete"; import { useHoldOnComplete, type HoldPhase } from "@/components/hold-on-complete";
import type { ScheduledBoardDTO, ScheduledOccurrenceDTO } from "@/types/scheduled-todo"; import { useScheduledNotifications } from "@/components/scheduled/use-scheduled-notifications";
import type { ScheduledBoardDTO, ScheduledDayDTO, ScheduledOccurrenceDTO } from "@/types/scheduled-todo";
// How often the client re-checks whether any of today's occurrences have
// crossed their time due -- fine-grained enough that "overdue" shows up
// within a minute of actually being overdue, without polling constantly.
const TIME_DUE_CHECK_INTERVAL_MS = 60_000;
function occurrenceKey(scheduledTodoId: string, occurrenceDate: string) { function occurrenceKey(scheduledTodoId: string, occurrenceDate: string) {
return `${scheduledTodoId}-${occurrenceDate}`; return `${scheduledTodoId}-${occurrenceDate}`;
@ -34,6 +41,38 @@ function matchesOccurrence(o: ScheduledOccurrenceDTO, scheduledTodoId: string, o
return o.scheduledTodoId === scheduledTodoId && o.occurrenceDate === occurrenceDate; return o.scheduledTodoId === scheduledTodoId && o.occurrenceDate === occurrenceDate;
} }
/**
* Promotes today's own occurrences into Overdue once their time due has
* passed, without waiting for the date to roll over -- the server only
* buckets by date (it doesn't know the viewer's timezone), so same-day
* time comparisons happen here against the browser's own clock instead.
* `holds` keeps a freshly-checked-off one here through its fade, same as
* ExpandedPanel's own visibleOverdue filter does for "real" overdue rows
* (see useHoldOnComplete) -- once the hold ends it settles back into
* `remaining` for good, indistinguishable from any other completed
* Today item.
*/
function splitTodayByTimeDue(
today: ScheduledDayDTO,
now: Date,
holds: Map<string, HoldPhase>
): { overdueToday: ScheduledOccurrenceDTO[]; remaining: ScheduledOccurrenceDTO[] } {
const overdueToday: ScheduledOccurrenceDTO[] = [];
const remaining: ScheduledOccurrenceDTO[] = [];
for (const o of today.occurrences) {
const pastDue = !!o.timeDue && isTimeDuePast(today.date, o.timeDue, now);
const key = occurrenceKey(o.scheduledTodoId, o.occurrenceDate);
if (pastDue && (!o.completed || holds.has(key))) {
overdueToday.push(o);
} else {
remaining.push(o);
}
}
return { overdueToday, remaining };
}
function withToggledOccurrence( function withToggledOccurrence(
board: ScheduledBoardDTO, board: ScheduledBoardDTO,
scheduledTodoId: string, scheduledTodoId: string,
@ -63,7 +102,7 @@ export function ScheduledPanel() {
const [board, setBoard] = useState<ScheduledBoardDTO | null>(null); const [board, setBoard] = useState<ScheduledBoardDTO | null>(null);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const { beginHold, cancelHold } = useHoldOnComplete(); const { holds, beginHold, cancelHold } = useHoldOnComplete();
const refresh = useCallback(() => { const refresh = useCallback(() => {
getScheduledBoard(projectId) getScheduledBoard(projectId)
@ -76,6 +115,50 @@ export function ScheduledPanel() {
refresh(); refresh();
}, [refresh]); }, [refresh]);
// Ticks on its own so a today occurrence with a time due crosses into
// Overdue live, without needing a click or a page refresh to notice.
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), TIME_DUE_CHECK_INTERVAL_MS);
return () => clearInterval(id);
}, []);
// The board actually rendered -- board.today with anything past its time
// due moved into board.overdue (see splitTodayByTimeDue). Recomputed from
// the raw board on every render rather than stored, so toggling an
// occurrence or the clock ticking forward both stay in sync automatically.
const displayBoard = useMemo<ScheduledBoardDTO | null>(() => {
if (!board) return null;
const { overdueToday, remaining } = splitTodayByTimeDue(board.today, now, holds);
return {
...board,
overdue: [...board.overdue, ...overdueToday],
today: { ...board.today, occurrences: remaining },
};
}, [board, now, holds]);
// Fed the raw board (not displayBoard) -- it does its own pastDue check
// against board.today directly, independent of the hold-aware promotion
// above.
const notifications = useScheduledNotifications(board, now);
async function handleToggleNotifications() {
if (notifications.enabled) {
notifications.disable();
return;
}
if (notifications.permission === "denied") {
toast.error("Notifications are blocked for this site -- enable them in your browser's site settings.");
return;
}
const granted = await notifications.requestEnable();
if (granted) {
toast.success("You'll get a notification when a scheduled to-do's time due passes.");
} else {
toast.error("Notification permission wasn't granted.");
}
}
async function handleToggle(occurrence: ScheduledOccurrenceDTO, completed: boolean) { async function handleToggle(occurrence: ScheduledOccurrenceDTO, completed: boolean) {
setBoard((prev) => setBoard((prev) =>
prev ? withToggledOccurrence(prev, occurrence.scheduledTodoId, occurrence.occurrenceDate, completed) : prev prev ? withToggledOccurrence(prev, occurrence.scheduledTodoId, occurrence.occurrenceDate, completed) : prev
@ -107,12 +190,14 @@ export function ScheduledPanel() {
// Reflects true remaining work, not the hold-delayed view -- the // Reflects true remaining work, not the hold-delayed view -- the
// collapsed rail's badge should drop the instant something's checked off, // collapsed rail's badge should drop the instant something's checked off,
// even while Overdue's own list still shows it fading out. // even while Overdue's own list still shows it fading out. Counted off
const overdueCount = board?.overdue.filter((o) => !o.completed).length ?? 0; // displayBoard so a today item past its time due counts as overdue here
// too, not as still-due-today.
const overdueCount = displayBoard?.overdue.filter((o) => !o.completed).length ?? 0;
// Today's occurrences list includes already-checked-off ones (so they can // Today's occurrences list includes already-checked-off ones (so they can
// still be toggled back), unlike overdue -- so this needs its own filter // still be toggled back), unlike overdue -- so this needs its own filter
// rather than just `board.today.occurrences.length`. // rather than just `board.today.occurrences.length`.
const todayCount = board?.today.occurrences.filter((o) => !o.completed).length ?? 0; const todayCount = displayBoard?.today.occurrences.filter((o) => !o.completed).length ?? 0;
return ( return (
<> <>
@ -131,11 +216,14 @@ export function ScheduledPanel() {
/> />
) : ( ) : (
<ExpandedPanel <ExpandedPanel
board={board} board={displayBoard}
onToggleOccurrence={handleToggle} onToggleOccurrence={handleToggle}
onEditOccurrence={handleEdit} onEditOccurrence={handleEdit}
onAdd={handleAdd} onAdd={handleAdd}
onCollapse={toggle} onCollapse={toggle}
notificationsSupported={notifications.supported}
notificationsEnabled={notifications.enabled}
onToggleNotifications={handleToggleNotifications}
/> />
)} )}
</aside> </aside>
@ -254,12 +342,18 @@ function ExpandedPanel({
onEditOccurrence, onEditOccurrence,
onAdd, onAdd,
onCollapse, onCollapse,
notificationsSupported,
notificationsEnabled,
onToggleNotifications,
}: { }: {
board: ScheduledBoardDTO | null; board: ScheduledBoardDTO | null;
onToggleOccurrence: (occurrence: ScheduledOccurrenceDTO, completed: boolean) => void; onToggleOccurrence: (occurrence: ScheduledOccurrenceDTO, completed: boolean) => void;
onEditOccurrence: (scheduledTodoId: string) => void; onEditOccurrence: (scheduledTodoId: string) => void;
onAdd: () => void; onAdd: () => void;
onCollapse: () => void; onCollapse: () => void;
notificationsSupported: boolean;
notificationsEnabled: boolean;
onToggleNotifications: () => void;
}) { }) {
const { holds } = useHoldOnComplete(); const { holds } = useHoldOnComplete();
@ -283,6 +377,30 @@ function ExpandedPanel({
</span> </span>
<span className="truncate font-heading text-[15px] font-semibold tracking-tight">Scheduled</span> <span className="truncate font-heading text-[15px] font-semibold tracking-tight">Scheduled</span>
</div> </div>
<div className="flex shrink-0 items-center gap-1">
{notificationsSupported && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon"
onClick={onToggleNotifications}
aria-label={notificationsEnabled ? "Turn off notifications" : "Turn on notifications"}
>
{notificationsEnabled ? (
<Bell className="size-4" />
) : (
<BellOff className="size-4 text-muted-foreground" />
)}
</Button>
}
/>
<TooltipContent side="left">
{notificationsEnabled ? "Notifications on" : "Notify me when something's due"}
</TooltipContent>
</Tooltip>
)}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -292,6 +410,7 @@ function ExpandedPanel({
<ChevronRight className="size-4" /> <ChevronRight className="size-4" />
</Button> </Button>
</div> </div>
</div>
<Separator /> <Separator />

View File

@ -2,7 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Trash2 } from "lucide-react"; import { Trash2, X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -33,6 +33,7 @@ import {
updateScheduledTodo, updateScheduledTodo,
} from "@/lib/actions/scheduled-todos"; } from "@/lib/actions/scheduled-todos";
import type { RecurrenceInput } from "@/lib/validation/scheduled-todo"; import type { RecurrenceInput } from "@/lib/validation/scheduled-todo";
import { REMIND_OPTIONS } from "@/lib/time-of-day";
const TITLE_MAX = 100; const TITLE_MAX = 100;
const WEEKDAY_LABELS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; const WEEKDAY_LABELS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
@ -74,6 +75,14 @@ export function ScheduledTodoDialog({
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [details, setDetails] = useState(""); const [details, setDetails] = useState("");
const [startDate, setStartDate] = useState(todayDateKey()); const [startDate, setStartDate] = useState(todayDateKey());
// "HH:MM" (24-hour) or "" for no time due -- exactly what <input
// type="time"> reads and writes, so it needs no reformatting either way.
const [timeDue, setTimeDue] = useState("");
// Only meaningful (and only shown, enabled) while timeDue is set --
// defaults to "When due" the moment a time gets set, since that's what a
// to-do with a time due already did before "Remind me" existed. Null =
// "Don't remind me", picked explicitly.
const [remindMinutesBefore, setRemindMinutesBefore] = useState<number | null>(0);
const [isRecurring, setIsRecurring] = useState(false); const [isRecurring, setIsRecurring] = useState(false);
const [frequency, setFrequency] = useState<RecurrenceInput["frequency"]>("WEEKLY"); const [frequency, setFrequency] = useState<RecurrenceInput["frequency"]>("WEEKLY");
const [interval, setInterval] = useState(1); const [interval, setInterval] = useState(1);
@ -95,6 +104,8 @@ export function ScheduledTodoDialog({
setTitle(""); setTitle("");
setDetails(""); setDetails("");
setStartDate(todayDateKey()); setStartDate(todayDateKey());
setTimeDue("");
setRemindMinutesBefore(0);
setIsRecurring(false); setIsRecurring(false);
setFrequency("WEEKLY"); setFrequency("WEEKLY");
setInterval(1); setInterval(1);
@ -113,6 +124,8 @@ export function ScheduledTodoDialog({
setTitle(todo.title); setTitle(todo.title);
setDetails(todo.details ?? ""); setDetails(todo.details ?? "");
setStartDate(todo.startDate); setStartDate(todo.startDate);
setTimeDue(todo.timeDue ?? "");
setRemindMinutesBefore(todo.timeDue ? todo.remindMinutesBefore : 0);
const recurrence = todo.recurrence; const recurrence = todo.recurrence;
setIsRecurring(!!recurrence); setIsRecurring(!!recurrence);
setFrequency(recurrence?.frequency ?? "WEEKLY"); setFrequency(recurrence?.frequency ?? "WEEKLY");
@ -170,11 +183,20 @@ export function ScheduledTodoDialog({
details: details.trim() || null, details: details.trim() || null,
startDate, startDate,
recurrence: buildRecurrence() ?? null, recurrence: buildRecurrence() ?? null,
timeDue: timeDue || null,
remindMinutesBefore: timeDue ? remindMinutesBefore : null,
}); });
} else { } else {
await createScheduledTodo( await createScheduledTodo(
{ projectId }, { projectId },
{ title: trimmedTitle, details: details.trim() || undefined, startDate, recurrence: buildRecurrence() } {
title: trimmedTitle,
details: details.trim() || undefined,
startDate,
recurrence: buildRecurrence(),
timeDue: timeDue || undefined,
remindMinutesBefore: timeDue ? remindMinutesBefore : null,
}
); );
} }
onSaved(); onSaved();
@ -235,7 +257,8 @@ export function ScheduledTodoDialog({
/> />
</div> </div>
<div className="space-y-2"> <div className="flex gap-3">
<div className="flex-1 space-y-2">
<Label htmlFor="scheduled-date"> <Label htmlFor="scheduled-date">
{isRecurring ? "Starts on" : "Date"} {isRecurring ? "Starts on" : "Date"}
</Label> </Label>
@ -247,6 +270,60 @@ export function ScheduledTodoDialog({
required required
/> />
</div> </div>
<div className="flex-1 space-y-2">
<Label htmlFor="scheduled-time-due">Time due (optional)</Label>
<div className="flex items-center gap-1">
{/* Native time input: its own picker already steps by
15 minutes (`step`) and it's freely typable, so no
custom combobox is needed for either requirement. */}
<Input
id="scheduled-time-due"
type="time"
step={60 * 15}
value={timeDue}
onChange={(e) => setTimeDue(e.target.value)}
/>
{timeDue && (
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => setTimeDue("")}
aria-label="Clear time due"
>
<X className="size-4" />
</Button>
)}
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="scheduled-remind">Remind me</Label>
<Select
value={remindMinutesBefore === null ? "none" : String(remindMinutesBefore)}
onValueChange={(v) => {
const option = REMIND_OPTIONS.find((o) => o.value === v);
if (option) setRemindMinutesBefore(option.minutesBefore);
}}
disabled={!timeDue}
>
<SelectTrigger id="scheduled-remind" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REMIND_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{!timeDue && (
<p className="text-xs text-muted-foreground">Set a time due to enable reminders.</p>
)}
</div>
<label className="flex items-center gap-2 text-sm font-medium"> <label className="flex items-center gap-2 text-sm font-medium">
<Checkbox checked={isRecurring} onCheckedChange={(c) => setIsRecurring(!!c)} /> <Checkbox checked={isRecurring} onCheckedChange={(c) => setIsRecurring(!!c)} />

View File

@ -3,6 +3,7 @@
import { StickyNote } from "lucide-react"; import { StickyNote } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { formatTimeDue } from "@/lib/time-of-day";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { MouseFollowTooltip } from "@/components/board/mouse-follow-tooltip"; import { MouseFollowTooltip } from "@/components/board/mouse-follow-tooltip";
import type { HoldPhase } from "@/components/hold-on-complete"; import type { HoldPhase } from "@/components/hold-on-complete";
@ -67,6 +68,16 @@ export function ScheduledTodoItem({
) : ( ) : (
titleButton titleButton
)} )}
{occurrence.timeDue && !occurrence.completed && (
<span
className={cn(
"mt-0.5 shrink-0 text-xs",
emphasis === "overdue" ? "font-semibold text-destructive" : "text-muted-foreground"
)}
>
{formatTimeDue(occurrence.timeDue)}
</span>
)}
{occurrence.details && ( {occurrence.details && (
<StickyNote className="mt-0.5 size-3 shrink-0 text-muted-foreground/70" /> <StickyNote className="mt-0.5 size-3 shrink-0 text-muted-foreground/70" />
)} )}

View File

@ -0,0 +1,114 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { formatTimeDue, isReminderDue } from "@/lib/time-of-day";
import type { ScheduledBoardDTO } from "@/types/scheduled-todo";
// Separate from the panel's own collapsed-state key (scheduled-panel-provider.tsx).
const STORAGE_KEY = "organize:scheduled-notifications-enabled";
/**
* Browser `Notification`s for scheduled to-dos, fired per each one's own
* "Remind me" lead time (see lib/time-of-day.ts's REMIND_OPTIONS) -- which
* may be before its time due, not necessarily at the same moment it's
* promoted into Overdue (see ScheduledPanel; that's driven by timeDue
* itself, unaffected by the reminder offset). This only reaches the user
* while some copy of this tab is open somewhere (foreground or
* backgrounded); there's no service worker `push` handler behind it, so
* nothing fires once the tab itself is closed. That would need real Web
* Push infrastructure (VAPID keys, a subscription table, a server-side
* sender) -- a bigger step this intentionally doesn't take.
*/
export function useScheduledNotifications(board: ScheduledBoardDTO | null, now: Date) {
// Whether the user has opted in *within the app* -- distinct from the
// browser's own permission, which can only be granted or revoked through
// its own UI, never by us. Same hydration-safe pattern as
// ScheduledPanelProvider: default off on both server and first client
// render, then read the persisted value (and the live permission) right
// after mount.
const [enabled, setEnabled] = useState(false);
const [permission, setPermission] = useState<NotificationPermission | "unsupported">("unsupported");
useEffect(() => {
if (!("Notification" in window)) return;
setPermission(Notification.permission);
const stored = localStorage.getItem(STORAGE_KEY);
// Only actually on if the browser still agrees -- a permission the user
// revoked from browser settings since last time shouldn't look enabled.
setEnabled(stored === "true" && Notification.permission === "granted");
}, []);
const requestEnable = useCallback(async () => {
if (!("Notification" in window)) return false;
let result = Notification.permission;
if (result === "default") {
result = await Notification.requestPermission();
setPermission(result);
}
const granted = result === "granted";
if (granted) {
localStorage.setItem(STORAGE_KEY, "true");
setEnabled(true);
}
return granted;
}, []);
const disable = useCallback(() => {
localStorage.setItem(STORAGE_KEY, "false");
setEnabled(false);
}, []);
// Occurrences already notified about this session, so the once-a-minute
// tick in ScheduledPanel doesn't re-fire the same notification every time
// it re-checks. Keyed the same way as everywhere else in this panel;
// never pruned since occurrenceDate makes each key unique per calendar
// day anyway, and the set only lives as long as the tab does.
const notifiedRef = useRef<Set<string> | null>(null);
useEffect(() => {
if (!enabled || !board) return;
const dueForReminder = board.today.occurrences.filter(
(o) =>
!o.completed &&
o.timeDue &&
o.remindMinutesBefore !== null &&
isReminderDue(board.today.date, o.timeDue, o.remindMinutesBefore, now)
);
if (notifiedRef.current === null) {
// First run after mounting (or reconnecting to a fresh board) --
// baseline whatever's already due for a reminder instead of
// notifying for old news the Overdue section (or an already-seen
// reminder) is already showing.
notifiedRef.current = new Set(dueForReminder.map((o) => `${o.scheduledTodoId}-${o.occurrenceDate}`));
return;
}
for (const o of dueForReminder) {
const key = `${o.scheduledTodoId}-${o.occurrenceDate}`;
if (notifiedRef.current.has(key)) continue;
notifiedRef.current.add(key);
const notification = new Notification(o.title, {
body: o.timeDue ? `Due at ${formatTimeDue(o.timeDue)}` : undefined,
icon: "/icons/icon-192.png",
tag: key, // replaces rather than stacking if this somehow runs twice
});
notification.onclick = () => {
window.focus();
notification.close();
};
}
}, [enabled, board, now]);
return {
// "unsupported" hides the toggle entirely -- nothing to offer.
supported: permission !== "unsupported",
enabled,
permission,
requestEnable,
disable,
};
}

View File

@ -8,6 +8,8 @@ import { boardPath, projectAccessFilter, scheduledTodoAccessFilter } from "@/lib
import { import {
RecurrenceInputSchema, RecurrenceInputSchema,
ScheduledTodoDetailsSchema, ScheduledTodoDetailsSchema,
ScheduledTodoRemindMinutesBeforeSchema,
ScheduledTodoTimeDueSchema,
ScheduledTodoTitleSchema, ScheduledTodoTitleSchema,
type RecurrenceInput, type RecurrenceInput,
} from "@/lib/validation/scheduled-todo"; } from "@/lib/validation/scheduled-todo";
@ -27,7 +29,10 @@ async function assertProjectAccess(userId: string, projectId: string) {
async function findOwnedScheduledTodo(userId: string, id: string) { async function findOwnedScheduledTodo(userId: string, id: string) {
const todo = await prisma.scheduledTodo.findFirst({ const todo = await prisma.scheduledTodo.findFirst({
where: { id, ...scheduledTodoAccessFilter(userId) }, where: { id, ...scheduledTodoAccessFilter(userId) },
select: { id: true, projectId: true }, // timeDue is included so updateScheduledTodo can tell whether a
// reminder is still meaningful even when this particular call doesn't
// touch timeDue itself.
select: { id: true, projectId: true, timeDue: true },
}); });
if (!todo) throw new Error("Scheduled to-do not found"); if (!todo) throw new Error("Scheduled to-do not found");
return todo; return todo;
@ -58,17 +63,32 @@ export async function getScheduledTodoForEdit(id: string): Promise<ScheduledTodo
details: todo.details, details: todo.details,
startDate: todo.startDate.toISOString().slice(0, 10), startDate: todo.startDate.toISOString().slice(0, 10),
recurrence: todo.rrule ? parseRRuleString(todo.rrule) : null, recurrence: todo.rrule ? parseRRuleString(todo.rrule) : null,
timeDue: todo.timeDue,
remindMinutesBefore: todo.remindMinutesBefore,
}; };
} }
export async function createScheduledTodo( export async function createScheduledTodo(
scope: { projectId: string | null }, scope: { projectId: string | null },
data: { title: string; details?: string; startDate: string; recurrence?: RecurrenceInput } data: {
title: string;
details?: string;
startDate: string;
recurrence?: RecurrenceInput;
timeDue?: string | null;
remindMinutesBefore?: number | null;
}
): Promise<void> { ): Promise<void> {
const userId = await requireUserId(); const userId = await requireUserId();
const title = ScheduledTodoTitleSchema.parse(data.title); const title = ScheduledTodoTitleSchema.parse(data.title);
const details = data.details ? ScheduledTodoDetailsSchema.parse(data.details) : undefined; const details = data.details ? ScheduledTodoDetailsSchema.parse(data.details) : undefined;
const recurrence = data.recurrence ? RecurrenceInputSchema.parse(data.recurrence) : undefined; const recurrence = data.recurrence ? RecurrenceInputSchema.parse(data.recurrence) : undefined;
const timeDue = data.timeDue ? ScheduledTodoTimeDueSchema.parse(data.timeDue) : null;
// No reminder without a time to count down from, regardless of what's passed.
const remindMinutesBefore =
timeDue && data.remindMinutesBefore != null
? ScheduledTodoRemindMinutesBeforeSchema.parse(data.remindMinutesBefore)
: null;
if (scope.projectId) await assertProjectAccess(userId, scope.projectId); if (scope.projectId) await assertProjectAccess(userId, scope.projectId);
@ -80,6 +100,8 @@ export async function createScheduledTodo(
projectId: scope.projectId, projectId: scope.projectId,
startDate: new Date(data.startDate), startDate: new Date(data.startDate),
rrule: recurrence ? buildRRuleString(recurrence) : null, rrule: recurrence ? buildRRuleString(recurrence) : null,
timeDue,
remindMinutesBefore,
}, },
}); });
@ -93,6 +115,8 @@ export async function updateScheduledTodo(
details?: string | null; details?: string | null;
startDate?: string; startDate?: string;
recurrence?: RecurrenceInput | null; recurrence?: RecurrenceInput | null;
timeDue?: string | null;
remindMinutesBefore?: number | null;
} }
): Promise<void> { ): Promise<void> {
const userId = await requireUserId(); const userId = await requireUserId();
@ -103,6 +127,8 @@ export async function updateScheduledTodo(
details?: string | null; details?: string | null;
startDate?: Date; startDate?: Date;
rrule?: string | null; rrule?: string | null;
timeDue?: string | null;
remindMinutesBefore?: number | null;
} = {}; } = {};
if (data.title !== undefined) update.title = ScheduledTodoTitleSchema.parse(data.title); if (data.title !== undefined) update.title = ScheduledTodoTitleSchema.parse(data.title);
if (data.details !== undefined) { if (data.details !== undefined) {
@ -112,6 +138,24 @@ export async function updateScheduledTodo(
if (data.recurrence !== undefined) { if (data.recurrence !== undefined) {
update.rrule = data.recurrence ? buildRRuleString(RecurrenceInputSchema.parse(data.recurrence)) : null; update.rrule = data.recurrence ? buildRRuleString(RecurrenceInputSchema.parse(data.recurrence)) : null;
} }
if (data.timeDue !== undefined) {
update.timeDue = data.timeDue ? ScheduledTodoTimeDueSchema.parse(data.timeDue) : null;
}
// The time due this reminder would count down from, after this update --
// whatever this call sets it to, or (if this call doesn't touch it) the
// value already on the row. No reminder without one, regardless of what's
// passed.
const effectiveTimeDue = data.timeDue !== undefined ? update.timeDue : todo.timeDue;
if (data.remindMinutesBefore !== undefined) {
update.remindMinutesBefore =
effectiveTimeDue && data.remindMinutesBefore != null
? ScheduledTodoRemindMinutesBeforeSchema.parse(data.remindMinutesBefore)
: null;
} else if (!effectiveTimeDue && todo.timeDue) {
// Time due was just cleared and this call didn't say anything about
// the reminder -- don't leave a stale reminder pointing at nothing.
update.remindMinutesBefore = null;
}
await prisma.scheduledTodo.update({ where: { id }, data: update }); await prisma.scheduledTodo.update({ where: { id }, data: update });

View File

@ -54,6 +54,8 @@ export async function getScheduledTodoBoard(
occurrenceDate: key, occurrenceDate: key,
completed: completedDates.has(key), completed: completedDates.has(key),
isRecurring: !!todo.rrule, isRecurring: !!todo.rrule,
timeDue: todo.timeDue,
remindMinutesBefore: todo.remindMinutesBefore,
}; };
if (key < todayKey) { if (key < todayKey) {

65
lib/time-of-day.ts Normal file
View File

@ -0,0 +1,65 @@
// Plain "HH:MM" (24-hour) wall-clock time-of-day helpers for a
// ScheduledTodo's optional time due. Deliberately just a string, not a
// Date -- there's no timezone or calendar date attached until it's paired
// with one occurrence's date key (see isTimeDuePast). This is local wall
// time, not UTC -- unlike lib/dates.ts, which is calendar-date math the
// server and client must agree on byte-for-byte.
const TIME_DUE_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
export function isValidTimeDue(value: string): boolean {
return TIME_DUE_REGEX.test(value);
}
/** "17:05" -> "5:05 PM", for display next to a scheduled occurrence. */
export function formatTimeDue(hhmm: string): string {
const [hours, minutes] = hhmm.split(":").map(Number);
const period = hours < 12 ? "AM" : "PM";
const hour12 = hours % 12 === 0 ? 12 : hours % 12;
return `${hour12}:${String(minutes).padStart(2, "0")} ${period}`;
}
/**
* True once the viewer's local wall clock has passed `hhmm` on the local
* calendar day named by `dateKey` ("YYYY-MM-DD") -- used to promote
* today's own occurrence into Overdue as soon as its time due passes,
* without waiting for the date to roll over. Always evaluated client-side:
* the server doesn't know the viewer's timezone, so it only buckets by
* date (see lib/scheduled-todos.ts) and leaves same-day time comparisons
* to the browser's own clock. `now` defaults to the real clock but is
* injectable for tests.
*/
export function isTimeDuePast(dateKey: string, hhmm: string, now: Date = new Date()): boolean {
const due = new Date(`${dateKey}T${hhmm}:00`); // no "Z" -- local time
return now.getTime() > due.getTime();
}
// The "Remind me" dropdown's choices -- lead time before `timeDue` to fire
// a notification (see use-scheduled-notifications.ts). `minutesBefore:
// null` is "Don't remind me" -- a to-do can have a time due with no
// reminder at all, distinct from "When due" (0 minutes early).
export const REMIND_OPTIONS: { value: string; label: string; minutesBefore: number | null }[] = [
{ value: "none", label: "Don't remind me", minutesBefore: null },
{ value: "0", label: "When due", minutesBefore: 0 },
{ value: "5", label: "5 minutes before", minutesBefore: 5 },
{ value: "10", label: "10 minutes before", minutesBefore: 10 },
{ value: "15", label: "15 minutes before", minutesBefore: 15 },
{ value: "30", label: "30 minutes before", minutesBefore: 30 },
{ value: "60", label: "1 hour before", minutesBefore: 60 },
{ value: "120", label: "2 hours before", minutesBefore: 120 },
{ value: "1440", label: "1 day before", minutesBefore: 1440 },
];
/** True once the viewer's local wall clock has reached `minutesBefore`
* minutes ahead of `hhmm` on `dateKey` -- i.e. it's time to fire the
* reminder. Distinct from isTimeDuePast: a reminder can (and typically
* does) fire before the to-do is actually due. */
export function isReminderDue(
dateKey: string,
hhmm: string,
minutesBefore: number,
now: Date = new Date()
): boolean {
const due = new Date(`${dateKey}T${hhmm}:00`); // no "Z" -- local time
return now.getTime() >= due.getTime() - minutesBefore * 60_000;
}

View File

@ -8,6 +8,15 @@ export const ScheduledTodoTitleSchema = z
export const ScheduledTodoDetailsSchema = z.string().max(5_000).optional(); export const ScheduledTodoDetailsSchema = z.string().max(5_000).optional();
// "HH:MM", 24-hour -- exactly what an <input type="time"> gives back, so
// the client never needs to reformat before sending it.
export const ScheduledTodoTimeDueSchema = z
.string()
.regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Invalid time");
// Minutes of lead time before timeDue -- 0 ("When due") up to a week.
export const ScheduledTodoRemindMinutesBeforeSchema = z.number().int().min(0).max(10_080);
const DateStringSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Invalid date"); const DateStringSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Invalid date");
const RecurrenceEndSchema = z.discriminatedUnion("type", [ const RecurrenceEndSchema = z.discriminatedUnion("type", [

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ScheduledTodo" ADD COLUMN "timeDue" VARCHAR(5);

View File

@ -0,0 +1,7 @@
-- AlterTable
ALTER TABLE "ScheduledTodo" ADD COLUMN "remindMinutesBefore" INTEGER;
-- Backfill: to-dos that already had a time due before this column existed
-- were, in effect, always reminded "when due" -- preserve that behavior
-- for them rather than silently turning reminders off.
UPDATE "ScheduledTodo" SET "remindMinutesBefore" = 0 WHERE "timeDue" IS NOT NULL;

View File

@ -193,6 +193,20 @@ model ScheduledTodo {
// via the `rrule` package -- never trust a client-supplied string, it's // via the `rrule` package -- never trust a client-supplied string, it's
// always rebuilt server-side from validated parts. Null = one-time. // always rebuilt server-side from validated parts. Null = one-time.
rrule String? rrule String?
// Optional "HH:MM" (24-hour) wall-clock time this to-do is due by --
// the same value applies to every occurrence of a recurring to-do. Null
// = due sometime that day, no specific time. It's local wall time with
// no timezone of its own; "is this occurrence's time due already past"
// is only ever evaluated client-side, against the viewer's own clock
// (see lib/time-of-day.ts) -- the server just stores and passes it through.
timeDue String? @db.VarChar(5)
// Minutes before `timeDue` to fire the "Remind me" notification -- 0 =
// "When due", a larger number = that many minutes earlier. Null = no
// reminder even though a time due is set (the user hasn't picked one, or
// explicitly chose "Don't remind me"). Always null whenever timeDue
// itself is null -- there's nothing to count down from otherwise
// (enforced in lib/actions/scheduled-todos.ts, not by a DB constraint).
remindMinutesBefore Int?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@ -10,6 +10,13 @@ export interface ScheduledOccurrenceDTO {
occurrenceDate: string; occurrenceDate: string;
completed: boolean; completed: boolean;
isRecurring: boolean; isRecurring: boolean;
// "HH:MM" (24-hour), or null if this to-do has no specific time due.
// See lib/time-of-day.ts -- comparing it to "now" is a client-only concern.
timeDue: string | null;
// Minutes of lead time before timeDue to notify at, null = no reminder.
// Only meaningful when timeDue is set. See lib/time-of-day.ts's
// REMIND_OPTIONS for the selectable values.
remindMinutesBefore: number | null;
} }
export interface ScheduledDayDTO { export interface ScheduledDayDTO {
@ -38,4 +45,8 @@ export interface ScheduledTodoEditDTO {
// "YYYY-MM-DD" // "YYYY-MM-DD"
startDate: string; startDate: string;
recurrence: RecurrenceInput | null; recurrence: RecurrenceInput | null;
// "HH:MM" (24-hour), or null if this to-do has no specific time due.
timeDue: string | null;
// Minutes of lead time before timeDue to notify at, null = no reminder.
remindMinutesBefore: number | null;
} }