Reminders and Notifications -- on scheduled to dos
This commit is contained in:
parent
80176636b7
commit
304d750ec5
|
|
@ -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,14 +377,39 @@ 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>
|
||||||
<Button
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
variant="ghost"
|
{notificationsSupported && (
|
||||||
size="icon"
|
<Tooltip>
|
||||||
onClick={onCollapse}
|
<TooltipTrigger
|
||||||
aria-label="Collapse scheduled panel"
|
render={
|
||||||
>
|
<Button
|
||||||
<ChevronRight className="size-4" />
|
variant="ghost"
|
||||||
</Button>
|
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
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onCollapse}
|
||||||
|
aria-label="Collapse scheduled panel"
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
|
||||||
|
|
@ -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,17 +257,72 @@ export function ScheduledTodoDialog({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Label htmlFor="scheduled-date">
|
||||||
|
{isRecurring ? "Starts on" : "Date"}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="scheduled-date"
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="scheduled-date">
|
<Label htmlFor="scheduled-remind">Remind me</Label>
|
||||||
{isRecurring ? "Starts on" : "Date"}
|
<Select
|
||||||
</Label>
|
value={remindMinutesBefore === null ? "none" : String(remindMinutesBefore)}
|
||||||
<Input
|
onValueChange={(v) => {
|
||||||
id="scheduled-date"
|
const option = REMIND_OPTIONS.find((o) => o.value === v);
|
||||||
type="date"
|
if (option) setRemindMinutesBefore(option.minutesBefore);
|
||||||
value={startDate}
|
}}
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
disabled={!timeDue}
|
||||||
required
|
>
|
||||||
/>
|
<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>
|
</div>
|
||||||
|
|
||||||
<label className="flex items-center gap-2 text-sm font-medium">
|
<label className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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", [
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ScheduledTodo" ADD COLUMN "timeDue" VARCHAR(5);
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -100,9 +100,9 @@ model AiSettings {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Category {
|
model Category {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
order Int
|
order Int
|
||||||
// Exactly one of these is set: userId for a Home category (personal,
|
// Exactly one of these is set: userId for a Home category (personal,
|
||||||
// outside any project), projectId for a category inside a Project.
|
// outside any project), projectId for a category inside a Project.
|
||||||
// Enforced by a DB check constraint (see migration) as well as by every
|
// Enforced by a DB check constraint (see migration) as well as by every
|
||||||
|
|
@ -123,17 +123,17 @@ model Category {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Group {
|
model Group {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
// Short display title, enforced at <=20 chars in app-level validation too.
|
// Short display title, enforced at <=20 chars in app-level validation too.
|
||||||
title String @db.VarChar(20)
|
title String @db.VarChar(20)
|
||||||
// Palette key (see lib/colors.ts) rather than a raw hex value, so the
|
// Palette key (see lib/colors.ts) rather than a raw hex value, so the
|
||||||
// curated palette can be retinted centrally without a data migration.
|
// curated palette can be retinted centrally without a data migration.
|
||||||
color String
|
color String
|
||||||
// Position within its Category lane.
|
// Position within its Category lane.
|
||||||
order Int
|
order Int
|
||||||
// Single evolving markdown document for the group. Not a separate model:
|
// Single evolving markdown document for the group. Not a separate model:
|
||||||
// it's a 1:1, always-exists field with no independent lifecycle.
|
// it's a 1:1, always-exists field with no independent lifecycle.
|
||||||
noteContent String @default("") @db.Text
|
noteContent String @default("") @db.Text
|
||||||
// Set once a group is archived; archived groups are kept (not deleted)
|
// Set once a group is archived; archived groups are kept (not deleted)
|
||||||
// but excluded from the Home board query. Null = active.
|
// but excluded from the Home board query. Null = active.
|
||||||
archivedAt DateTime?
|
archivedAt DateTime?
|
||||||
|
|
@ -150,16 +150,16 @@ model Group {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Todo {
|
model Todo {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
title String @db.VarChar(20)
|
title String @db.VarChar(20)
|
||||||
details String? @db.Text
|
details String? @db.Text
|
||||||
completed Boolean @default(false)
|
completed Boolean @default(false)
|
||||||
// Set when `completed` flips to true, cleared back to null when it
|
// Set when `completed` flips to true, cleared back to null when it
|
||||||
// flips to false. Distinct from `updatedAt`, which bumps on *any* field
|
// flips to false. Distinct from `updatedAt`, which bumps on *any* field
|
||||||
// change (a title edit, a reorder, ...), not just completion.
|
// change (a title edit, a reorder, ...), not just completion.
|
||||||
completedAt DateTime?
|
completedAt DateTime?
|
||||||
// Position within its Group's to-do list.
|
// Position within its Group's to-do list.
|
||||||
order Int
|
order Int
|
||||||
|
|
||||||
groupId String
|
groupId String
|
||||||
|
|
||||||
|
|
@ -187,12 +187,26 @@ model ScheduledTodo {
|
||||||
|
|
||||||
// The only occurrence when rrule is null (a one-time to-do); the RRULE's
|
// The only occurrence when rrule is null (a one-time to-do); the RRULE's
|
||||||
// DTSTART otherwise.
|
// DTSTART otherwise.
|
||||||
startDate DateTime @db.Date
|
startDate DateTime @db.Date
|
||||||
// RFC 5545 recurrence rule string (e.g.
|
// RFC 5545 recurrence rule string (e.g.
|
||||||
// "FREQ=WEEKLY;BYDAY=MO,WE;INTERVAL=2;UNTIL=20261231"), built and parsed
|
// "FREQ=WEEKLY;BYDAY=MO,WE;INTERVAL=2;UNTIL=20261231"), built and parsed
|
||||||
// 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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue