Add scheduled (recurring) to-dos, AI to-do generation, and in-place building upgrades

- Scheduled to-dos: new ScheduledTodo + ScheduledTodoCompletion models
  (owner-XOR-project ownership like Category, per-occurrence completion
  so a recurring to-do tracks done state per calendar date), recurrence
  built server-side from validated parts via rrule-utils, CRUD +
  occurrence-toggle server actions, add/edit dialog, and a collapsible
  Scheduled side panel (overdue / today / upcoming) with UTC-safe
  date-key formatting.
- AI to-dos: group-level AI dialog (lib/actions/todo-ai.ts) that chats
  then proposes a finalize list of to-dos; editable proposals and bulk
  add via BoardProvider.addTodos; speech recognition gains better error
  handling (network/firewall message, error logging).
- Building upgrades: new `upgradesFrom` rule — Nuclear Power Plant can
  replace a friendly Energy Generator and Advanced Metal Generator a
  Mass Generator (same tile + footprint, per TALogic.findUpgradeTarget),
  crediting a 50% refund of the replaced building's OWN cost;
  compileRules validates the references, topperFrame gets a generic
  spinning-glyph fallback in TAArt.
- Movement rework: pushOutOfObstacles replaces the hard tile-snap with a
  damped circle-vs-tile overlap push, and pathfinding routes at exact-fit
  clearance instead of +1 padding (fixes corner grinding/stalling).
- New map m06 "Annihilation" (medium, snowfields, Klaxon skill 5, seed
  90123 — pre-vetted for early-economy balance).
- Tests: upgrade placement (ownership, type match, exact footprint,
  refund, army-less fallback), obstacle routing/settling, corridor
  moves; sprites.md updated for upgradesFrom and toppers.
This commit is contained in:
Brian Fertig 2026-08-17 15:03:13 -06:00
parent 088da63de2
commit eb32aee653
29 changed files with 2159 additions and 68 deletions

View File

@ -5,6 +5,8 @@ import { prisma } from "@/lib/db";
import { SideNavProvider } from "@/components/nav/side-nav-provider"; import { SideNavProvider } from "@/components/nav/side-nav-provider";
import { SideNav } from "@/components/nav/side-nav"; import { SideNav } from "@/components/nav/side-nav";
import { ProjectsProvider } from "@/components/projects/projects-context"; import { ProjectsProvider } from "@/components/projects/projects-context";
import { ScheduledPanelProvider } from "@/components/scheduled/scheduled-panel-provider";
import { ScheduledPanel } from "@/components/scheduled/scheduled-panel";
export default async function AppLayout({ children }: { children: React.ReactNode }) { export default async function AppLayout({ children }: { children: React.ReactNode }) {
const session = await auth(); const session = await auth();
@ -22,12 +24,15 @@ export default async function AppLayout({ children }: { children: React.ReactNod
return ( return (
<SideNavProvider> <SideNavProvider>
<ScheduledPanelProvider>
<ProjectsProvider initialProjects={projects}> <ProjectsProvider initialProjects={projects}>
<div className="flex h-screen overflow-hidden"> <div className="flex h-screen overflow-hidden">
<SideNav userEmail={session.user.email ?? ""} role={session.user.role} /> <SideNav userEmail={session.user.email ?? ""} role={session.user.role} />
<main className="flex-1 overflow-auto">{children}</main> <main className="flex-1 overflow-auto">{children}</main>
<ScheduledPanel />
</div> </div>
</ProjectsProvider> </ProjectsProvider>
</ScheduledPanelProvider>
</SideNavProvider> </SideNavProvider>
); );
} }

View File

@ -2,13 +2,18 @@ import { redirect } from "next/navigation";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { getBoard } from "@/lib/board"; import { getBoard } from "@/lib/board";
import { getAiSettingsView } from "@/lib/ai-settings";
import { KanbanBoard } from "@/components/board/kanban-board"; import { KanbanBoard } from "@/components/board/kanban-board";
export default async function HomePage() { export default async function HomePage() {
const session = await auth(); const session = await auth();
if (!session?.user) redirect("/login"); if (!session?.user) redirect("/login");
const board = await getBoard({ userId: session.user.id, projectId: null }); const [board, aiSettings] = await Promise.all([
getBoard({ userId: session.user.id, projectId: null }),
getAiSettingsView(),
]);
const aiConfigured = !!(aiSettings.apiUrl && aiSettings.model);
return <KanbanBoard initialCategories={board} />; return <KanbanBoard initialCategories={board} aiConfigured={aiConfigured} />;
} }

View File

@ -4,6 +4,7 @@ import { auth } from "@/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { projectAccessFilter } from "@/lib/access"; import { projectAccessFilter } from "@/lib/access";
import { getBoard } from "@/lib/board"; import { getBoard } from "@/lib/board";
import { getAiSettingsView } from "@/lib/ai-settings";
import { KanbanBoard } from "@/components/board/kanban-board"; import { KanbanBoard } from "@/components/board/kanban-board";
export default async function ProjectPage({ export default async function ProjectPage({
@ -23,7 +24,18 @@ export default async function ProjectPage({
// user's -- no need to distinguish "not found" from "not yours". // user's -- no need to distinguish "not found" from "not yours".
if (!project) notFound(); if (!project) notFound();
const board = await getBoard({ projectId: project.id }); const [board, aiSettings] = await Promise.all([
getBoard({ projectId: project.id }),
getAiSettingsView(),
]);
const aiConfigured = !!(aiSettings.apiUrl && aiSettings.model);
return <KanbanBoard initialCategories={board} projectId={project.id} title={project.title} />; return (
<KanbanBoard
initialCategories={board}
projectId={project.id}
title={project.title}
aiConfigured={aiConfigured}
/>
);
} }

View File

@ -19,6 +19,7 @@ import {
} from "@/lib/actions/groups"; } from "@/lib/actions/groups";
import { import {
createTodo, createTodo,
createTodos,
updateTodo as updateTodoAction, updateTodo as updateTodoAction,
toggleTodo as toggleTodoAction, toggleTodo as toggleTodoAction,
deleteTodo as deleteTodoAction, deleteTodo as deleteTodoAction,
@ -27,6 +28,9 @@ import { updateGroupNote } from "@/lib/actions/notes";
interface BoardContextValue { interface BoardContextValue {
categories: CategoryDTO[]; categories: CategoryDTO[];
// Whether the admin-configured AI provider has an API URL + model saved
// -- gates AI-powered actions like the "Add using AI" to-do button.
aiConfigured: boolean;
addCategory: (name: string) => Promise<void>; addCategory: (name: string) => Promise<void>;
removeCategory: (categoryId: string) => Promise<void>; removeCategory: (categoryId: string) => Promise<void>;
reorderLanes: (orderedIds: string[]) => Promise<void>; reorderLanes: (orderedIds: string[]) => Promise<void>;
@ -53,6 +57,11 @@ interface BoardContextValue {
title: string, title: string,
details?: string details?: string
) => Promise<boolean>; ) => Promise<boolean>;
addTodos: (
groupId: string,
categoryId: string,
todos: { title: string; details?: string }[]
) => Promise<boolean>;
editTodo: ( editTodo: (
todoId: string, todoId: string,
groupId: string, groupId: string,
@ -73,6 +82,7 @@ const BoardContext = createContext<BoardContextValue | null>(null);
export function BoardProvider({ export function BoardProvider({
initialCategories, initialCategories,
projectId, projectId,
aiConfigured,
children, children,
}: { }: {
initialCategories: CategoryDTO[]; initialCategories: CategoryDTO[];
@ -81,6 +91,7 @@ export function BoardProvider({
// on are the two that operate over "all of this board's categories" // on are the two that operate over "all of this board's categories"
// rather than a specific existing category/group/todo id. // rather than a specific existing category/group/todo id.
projectId?: string; projectId?: string;
aiConfigured: boolean;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const [categories, setCategories] = useState(initialCategories); const [categories, setCategories] = useState(initialCategories);
@ -299,6 +310,20 @@ export function BoardProvider({
[updateGroupInState] [updateGroupInState]
); );
const addTodos = useCallback(
async (groupId: string, categoryId: string, todos: { title: string; details?: string }[]) => {
try {
const created = await createTodos(groupId, todos);
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: [...g.todos, ...created] }));
return true;
} catch {
toast.error("Couldn't add to-dos. Try again.");
return false;
}
},
[updateGroupInState]
);
const editTodo = useCallback( const editTodo = useCallback(
async ( async (
todoId: string, todoId: string,
@ -379,6 +404,7 @@ export function BoardProvider({
<BoardContext.Provider <BoardContext.Provider
value={{ value={{
categories, categories,
aiConfigured,
addCategory, addCategory,
removeCategory, removeCategory,
reorderLanes, reorderLanes,
@ -390,6 +416,7 @@ export function BoardProvider({
moveGroup, moveGroup,
saveNote, saveNote,
addTodo, addTodo,
addTodos,
editTodo, editTodo,
toggleTodoDone, toggleTodoDone,
removeTodo, removeTodo,

View File

@ -25,10 +25,10 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { getComplementaryColor, getGroupColor } from "@/lib/colors"; import { getBrighterColor, getComplementaryColor, getGroupColor } from "@/lib/colors";
import { useBoard } from "@/components/board/board-context"; import { useBoard } from "@/components/board/board-context";
import { NotesDialog } from "@/components/board/notes-dialog"; import { NotesDialog } from "@/components/board/notes-dialog";
import { GroupAiDialog } from "@/components/board/group-ai-dialog"; import { TodoAiDialog } from "@/components/board/todo-ai-dialog";
import { TodoCreatePopover } from "@/components/board/todo-create-popover"; import { TodoCreatePopover } from "@/components/board/todo-create-popover";
import { TodoEditDialog } from "@/components/board/todo-edit-dialog"; import { TodoEditDialog } from "@/components/board/todo-edit-dialog";
import { TodoProgressPie } from "@/components/board/todo-progress-pie"; import { TodoProgressPie } from "@/components/board/todo-progress-pie";
@ -37,12 +37,12 @@ import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog";
import type { GroupDTO, TodoDTO } from "@/types/board"; import type { GroupDTO, TodoDTO } from "@/types/board";
export function GroupCard({ group }: { group: GroupDTO }) { export function GroupCard({ group }: { group: GroupDTO }) {
const { toggleTodoDone, removeGroup, archiveGroup } = useBoard(); const { toggleTodoDone, removeGroup, archiveGroup, aiConfigured } = useBoard();
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
const [editingTodo, setEditingTodo] = useState<TodoDTO | null>(null); const [editingTodo, setEditingTodo] = useState<TodoDTO | null>(null);
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [aiOpen, setAiOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
const [todoAiOpen, setTodoAiOpen] = useState(false);
const { const {
attributes, attributes,
@ -79,6 +79,10 @@ export function GroupCard({ group }: { group: GroupDTO }) {
isDark ? color.dark : color.light, isDark ? color.dark : color.light,
isDark ? "dark" : "light" isDark ? "dark" : "light"
); );
// Un-checked to-do text: a brighter tint of the group's own color rather
// than the default (near-white in dark mode) foreground, so the group
// title reads as the most prominent thing on the card.
const todoTextColor = getBrighterColor(borderColor, isDark ? "dark" : "light");
// Only offer archiving once there's actually something done -- a group // Only offer archiving once there's actually something done -- a group
// with no to-dos yet, or with any still open, isn't "finished" yet. // with no to-dos yet, or with any still open, isn't "finished" yet.
@ -133,10 +137,6 @@ export function GroupCard({ group }: { group: GroupDTO }) {
<Pencil className="size-4" /> <Pencil className="size-4" />
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setAiOpen(true)}>
<Sparkles className="size-4" />
Ask AI
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => setConfirmOpen(true)}> <DropdownMenuItem variant="destructive" onClick={() => setConfirmOpen(true)}>
<Trash2 className="size-4" /> <Trash2 className="size-4" />
@ -153,6 +153,7 @@ export function GroupCard({ group }: { group: GroupDTO }) {
<button <button
type="button" type="button"
onClick={() => setEditingTodo(todo)} onClick={() => setEditingTodo(todo)}
style={todo.completed ? undefined : { color: todoTextColor }}
className={cn( className={cn(
"flex min-w-0 flex-1 items-center gap-1 text-left text-sm hover:underline", "flex min-w-0 flex-1 items-center gap-1 text-left text-sm hover:underline",
todo.completed && "text-muted-foreground line-through" todo.completed && "text-muted-foreground line-through"
@ -191,6 +192,18 @@ export function GroupCard({ group }: { group: GroupDTO }) {
<TodoCreatePopover groupId={group.id} categoryId={group.categoryId} /> <TodoCreatePopover groupId={group.id} categoryId={group.categoryId} />
</div> </div>
{aiConfigured && (
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-muted-foreground"
onClick={() => setTodoAiOpen(true)}
>
<Sparkles className="size-3.5" />
Add using AI
</Button>
)}
{canArchive && ( {canArchive && (
<Button <Button
variant="ghost" variant="ghost"
@ -222,7 +235,7 @@ export function GroupCard({ group }: { group: GroupDTO }) {
<EditGroupDialog group={group} open={editOpen} onOpenChange={setEditOpen} /> <EditGroupDialog group={group} open={editOpen} onOpenChange={setEditOpen} />
<GroupAiDialog group={group} open={aiOpen} onOpenChange={setAiOpen} /> <TodoAiDialog group={group} open={todoAiOpen} onOpenChange={setTodoAiOpen} />
<ConfirmDeleteDialog <ConfirmDeleteDialog
open={confirmOpen} open={confirmOpen}

View File

@ -143,6 +143,7 @@ export function KanbanBoard({
initialCategories, initialCategories,
projectId, projectId,
title = "Home", title = "Home",
aiConfigured,
}: { }: {
initialCategories: CategoryDTO[]; initialCategories: CategoryDTO[];
// Undefined renders the caller's Home board; set it to scope the whole // Undefined renders the caller's Home board; set it to scope the whole
@ -150,9 +151,17 @@ export function KanbanBoard({
// them -- to that Project instead. // them -- to that Project instead.
projectId?: string; projectId?: string;
title?: string; title?: string;
// Whether the admin-configured AI provider has an API URL + model saved
// -- gates the "Add using AI" to-do button. Computed once server-side
// per page load rather than re-checked per group card.
aiConfigured: boolean;
}) { }) {
return ( return (
<BoardProvider initialCategories={initialCategories} projectId={projectId}> <BoardProvider
initialCategories={initialCategories}
projectId={projectId}
aiConfigured={aiConfigured}
>
<Board title={title} /> <Board title={title} />
</BoardProvider> </BoardProvider>
); );

View File

@ -2,7 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { NotebookPen, Pencil } from "lucide-react"; import { NotebookPen, Pencil, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -14,6 +14,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { MDEditor, MarkdownPreview } from "@/components/markdown/markdown-widgets"; import { MDEditor, MarkdownPreview } from "@/components/markdown/markdown-widgets";
import { useBoard } from "@/components/board/board-context"; import { useBoard } from "@/components/board/board-context";
import { GroupAiDialog } from "@/components/board/group-ai-dialog";
import type { GroupDTO } from "@/types/board"; import type { GroupDTO } from "@/types/board";
export function NotesDialog({ group }: { group: GroupDTO }) { export function NotesDialog({ group }: { group: GroupDTO }) {
@ -25,6 +26,7 @@ export function NotesDialog({ group }: { group: GroupDTO }) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(group.noteContent); const [draft, setDraft] = useState(group.noteContent);
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const [aiOpen, setAiOpen] = useState(false);
function handleOpenChange(next: boolean) { function handleOpenChange(next: boolean) {
setOpen(next); setOpen(next);
@ -42,6 +44,7 @@ export function NotesDialog({ group }: { group: GroupDTO }) {
} }
return ( return (
<>
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<Button <Button
variant="ghost" variant="ghost"
@ -80,13 +83,22 @@ export function NotesDialog({ group }: { group: GroupDTO }) {
</Button> </Button>
</> </>
) : ( ) : (
<>
<Button variant="outline" onClick={() => setAiOpen(true)} className="gap-2">
<Sparkles className="size-4" />
Ask AI
</Button>
<Button variant="outline" onClick={() => setEditing(true)} className="gap-2"> <Button variant="outline" onClick={() => setEditing(true)} className="gap-2">
<Pencil className="size-4" /> <Pencil className="size-4" />
Edit Edit
</Button> </Button>
</>
)} )}
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<GroupAiDialog group={group} open={aiOpen} onOpenChange={setAiOpen} />
</>
); );
} }

View File

@ -0,0 +1,290 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Mic, Send, Sparkles, Square, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useBoard } from "@/components/board/board-context";
import { useSpeechRecognition } from "@/hooks/use-speech-recognition";
import { converseAboutTodos, type TodoAiMessage, type TodoAiProposal } from "@/lib/actions/todo-ai";
import type { GroupDTO } from "@/types/board";
const TITLE_MAX = 20;
export function TodoAiDialog({
group,
open,
onOpenChange,
}: {
group: GroupDTO;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { addTodos } = useBoard();
const [messages, setMessages] = useState<TodoAiMessage[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Set once the model finalizes -- an editable review list the user has
// to explicitly accept before any of it becomes real to-dos.
const [pendingTodos, setPendingTodos] = useState<TodoAiProposal[] | null>(null);
const { supported: micSupported, listening, start, stop } = useSpeechRecognition({
onFinalResult: (transcript) =>
setInput((prev) => (prev ? `${prev} ${transcript}` : transcript)),
onError: setError,
});
// Starts fresh every time it's opened -- a half-finished conversation
// from last time would be confusing to resume out of context.
function handleOpenChange(next: boolean) {
if (!next) {
stop();
setMessages([]);
setInput("");
setError(null);
setPendingTodos(null);
}
onOpenChange(next);
}
async function callAi(nextMessages: TodoAiMessage[], forceFinalize: boolean) {
setError(null);
setSending(true);
const result = await converseAboutTodos(
group.id,
group.title,
group.todos.map((t) => t.title),
nextMessages,
forceFinalize
);
setSending(false);
if ("error" in result) {
setError(result.error);
return;
}
if (result.action === "ask") {
setMessages([...nextMessages, { role: "assistant", content: result.question }]);
} else {
setMessages(nextMessages);
setPendingTodos(result.todos);
}
}
async function handleSend() {
const text = input.trim();
if (!text || sending) return;
if (listening) stop();
const next: TodoAiMessage[] = [...messages, { role: "user", content: text }];
setMessages(next);
setInput("");
await callAi(next, false);
}
async function handleGenerateNow() {
if (sending || messages.length === 0) return;
if (listening) stop();
await callAi(messages, true);
}
function updatePendingTodo(index: number, patch: Partial<TodoAiProposal>) {
setPendingTodos((prev) => prev && prev.map((t, i) => (i === index ? { ...t, ...patch } : t)));
}
function removePendingTodo(index: number) {
setPendingTodos((prev) => prev && prev.filter((_, i) => i !== index));
}
function handleDiscard() {
setPendingTodos(null);
}
async function handleAccept() {
if (!pendingTodos || pendingTodos.length === 0) return;
setSaving(true);
const ok = await addTodos(
group.id,
group.categoryId,
pendingTodos.map((t) => ({
title: t.title.trim(),
details: t.details?.trim() || undefined,
}))
);
setSaving(false);
if (ok) {
toast.success(`Added ${pendingTodos.length} to-do${pendingTodos.length === 1 ? "" : "s"}.`);
handleOpenChange(false);
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="flex max-h-[80vh] flex-col sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="size-4 text-primary" />
Add using AI {group.title}
</DialogTitle>
</DialogHeader>
{pendingTodos ? (
<>
<div className="flex-1 space-y-3 overflow-y-auto">
{pendingTodos.map((todo, index) => (
<div key={index} className="space-y-2 rounded-md border p-3">
<div className="flex items-start gap-2">
<div className="flex-1 space-y-1">
<Label htmlFor={`ai-todo-title-${index}`} className="sr-only">
Title
</Label>
<Input
id={`ai-todo-title-${index}`}
value={todo.title}
maxLength={TITLE_MAX}
onChange={(e) => updatePendingTodo(index, { title: e.target.value })}
/>
<p className="text-right text-xs text-muted-foreground">
{todo.title.length}/{TITLE_MAX}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => removePendingTodo(index)}
aria-label={`Remove "${todo.title}"`}
>
<X className="size-4" />
</Button>
</div>
<Textarea
value={todo.details ?? ""}
onChange={(e) => updatePendingTodo(index, { details: e.target.value })}
placeholder="Details (optional)"
rows={2}
className="resize-none"
/>
</div>
))}
{pendingTodos.length === 0 && (
<p className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
All proposed to-dos were removed. Keep chatting to generate more.
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleDiscard} disabled={saving}>
Keep chatting
</Button>
<Button onClick={handleAccept} disabled={saving || pendingTodos.length === 0}>
{saving
? "Adding…"
: `Add ${pendingTodos.length} to-do${pendingTodos.length === 1 ? "" : "s"}`}
</Button>
</DialogFooter>
</>
) : (
<>
<div className="flex-1 space-y-3 overflow-y-auto">
{messages.length === 0 ? (
<p className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
Tell me about the to-dos you need I&apos;ll ask follow-ups if I need more
detail.
</p>
) : (
messages.map((message, index) => (
<div
key={index}
className={cn(
"max-w-[85%] rounded-lg px-3 py-2 text-sm",
message.role === "user"
? "ml-auto bg-primary text-primary-foreground"
: "bg-muted text-foreground"
)}
>
{message.content}
</div>
))
)}
{sending && (
<div className="max-w-[85%] rounded-lg bg-muted px-3 py-2 text-sm text-muted-foreground">
Thinking
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<div className="space-y-2">
<div className="flex items-end gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
placeholder={listening ? "Listening…" : "Type or use the microphone…"}
rows={2}
className="flex-1 resize-none"
disabled={sending}
/>
<div className="flex flex-col gap-2">
{micSupported && (
<Button
type="button"
variant={listening ? "destructive" : "outline"}
size="icon"
onClick={listening ? stop : start}
disabled={sending}
aria-label={listening ? "Stop recording" : "Record voice input"}
>
{listening ? <Square className="size-4" /> : <Mic className="size-4" />}
</Button>
)}
<Button
type="button"
size="icon"
onClick={handleSend}
disabled={sending || !input.trim()}
aria-label="Send"
>
<Send className="size-4" />
</Button>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground"
onClick={handleGenerateNow}
disabled={sending || messages.length === 0}
>
Generate now, using what I&apos;ve said so far
</Button>
</div>
</>
)}
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,45 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
// Separate from SideNav's own key -- the two panels collapse independently.
const STORAGE_KEY = "organize:scheduled-collapsed";
interface ScheduledPanelContextValue {
collapsed: boolean;
toggle: () => void;
}
const ScheduledPanelContext = createContext<ScheduledPanelContextValue | null>(null);
export function ScheduledPanelProvider({ children }: { children: React.ReactNode }) {
// Same hydration-safe approach as SideNavProvider: default expanded on
// both server and first client render, then apply the persisted value
// right after mount.
const [collapsed, setCollapsed] = useState(false);
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored !== null) setCollapsed(stored === "true");
}, []);
const toggle = () => {
setCollapsed((prev) => {
const next = !prev;
localStorage.setItem(STORAGE_KEY, String(next));
return next;
});
};
return (
<ScheduledPanelContext.Provider value={{ collapsed, toggle }}>
{children}
</ScheduledPanelContext.Provider>
);
}
export function useScheduledPanel() {
const ctx = useContext(ScheduledPanelContext);
if (!ctx) throw new Error("useScheduledPanel must be used within a ScheduledPanelProvider");
return ctx;
}

View File

@ -0,0 +1,304 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { toast } from "sonner";
import { AlertTriangle, CalendarClock, ChevronLeft, ChevronRight, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { formatWeekdayDate } from "@/lib/format";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useScheduledPanel } from "@/components/scheduled/scheduled-panel-provider";
import { ScheduledTodoItem } from "@/components/scheduled/scheduled-todo-item";
import { ScheduledTodoDialog } from "@/components/scheduled/scheduled-todo-dialog";
import { getScheduledBoard, toggleScheduledOccurrence } from "@/lib/actions/scheduled-todos";
import type { ScheduledBoardDTO, ScheduledOccurrenceDTO } from "@/types/scheduled-todo";
/** `layout.tsx` is shared by Home and every Project board, so the panel
* derives its own scope from the URL rather than a prop threaded down from
* a page it doesn't render (see the plan notes on why this fetches
* client-side instead of taking initial data as a prop like KanbanBoard). */
function projectIdFromPathname(pathname: string): string | null {
const match = /^\/projects\/([^/]+)/.exec(pathname);
return match ? match[1] : null;
}
function matchesOccurrence(o: ScheduledOccurrenceDTO, scheduledTodoId: string, occurrenceDate: string) {
return o.scheduledTodoId === scheduledTodoId && o.occurrenceDate === occurrenceDate;
}
function withToggledOccurrence(
board: ScheduledBoardDTO,
scheduledTodoId: string,
occurrenceDate: string,
completed: boolean
): ScheduledBoardDTO {
const flip = (o: ScheduledOccurrenceDTO) =>
matchesOccurrence(o, scheduledTodoId, occurrenceDate) ? { ...o, completed } : o;
return {
// Overdue only ever lists incomplete occurrences -- completing one
// removes it rather than leaving a checked-off item in the flashy list.
overdue: completed
? board.overdue.filter((o) => !matchesOccurrence(o, scheduledTodoId, occurrenceDate))
: board.overdue.map(flip),
today: { ...board.today, occurrences: board.today.occurrences.map(flip) },
upcoming: board.upcoming.map((day) => ({ ...day, occurrences: day.occurrences.map(flip) })),
};
}
export function ScheduledPanel() {
const { collapsed, toggle } = useScheduledPanel();
const pathname = usePathname();
const projectId = projectIdFromPathname(pathname);
const [board, setBoard] = useState<ScheduledBoardDTO | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const refresh = useCallback(() => {
getScheduledBoard(projectId)
.then(setBoard)
.catch(() => setBoard(null));
}, [projectId]);
useEffect(() => {
setBoard(null);
refresh();
}, [refresh]);
async function handleToggle(occurrence: ScheduledOccurrenceDTO, completed: boolean) {
setBoard((prev) =>
prev ? withToggledOccurrence(prev, occurrence.scheduledTodoId, occurrence.occurrenceDate, completed) : prev
);
try {
await toggleScheduledOccurrence(occurrence.scheduledTodoId, occurrence.occurrenceDate, completed);
} catch {
toast.error("Couldn't update scheduled to-do. Try again.");
refresh();
}
}
function handleEdit(scheduledTodoId: string) {
setEditingId(scheduledTodoId);
setDialogOpen(true);
}
function handleAdd() {
setEditingId(null);
setDialogOpen(true);
}
const overdueCount = board?.overdue.length ?? 0;
return (
<>
<aside
className={cn(
"flex h-screen flex-col border-l bg-sidebar text-sidebar-foreground transition-[width] duration-200",
collapsed ? "w-16" : "w-80"
)}
>
{collapsed ? (
<CollapsedRail overdueCount={overdueCount} onAdd={handleAdd} onExpand={toggle} />
) : (
<ExpandedPanel
board={board}
onToggleOccurrence={handleToggle}
onEditOccurrence={handleEdit}
onAdd={handleAdd}
onCollapse={toggle}
/>
)}
</aside>
<ScheduledTodoDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
projectId={projectId}
scheduledTodoId={editingId}
onSaved={refresh}
onDeleted={refresh}
/>
</>
);
}
function CollapsedRail({
overdueCount,
onAdd,
onExpand,
}: {
overdueCount: number;
onAdd: () => void;
onExpand: () => void;
}) {
return (
<>
<div className="flex flex-col items-center p-3">
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
onClick={onExpand}
className="relative flex size-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
aria-label="Expand scheduled to-dos"
>
<CalendarClock className="size-5" />
{overdueCount > 0 && (
<span className="absolute -top-1 -right-1 flex size-4 animate-pulse items-center justify-center rounded-full bg-destructive text-[10px] font-bold text-white">
{overdueCount > 9 ? "9+" : overdueCount}
</span>
)}
</button>
}
/>
<TooltipContent side="left">
{overdueCount > 0 ? `${overdueCount} overdue` : "Scheduled to-dos"}
</TooltipContent>
</Tooltip>
</div>
<div className="flex flex-1 flex-col items-center justify-end gap-1 p-2">
<Tooltip>
<TooltipTrigger
render={
<Button variant="ghost" size="icon" onClick={onAdd} aria-label="Add scheduled to-do">
<Plus className="size-4" />
</Button>
}
/>
<TooltipContent side="left">Add Scheduled To-Do</TooltipContent>
</Tooltip>
<Button variant="ghost" size="icon" onClick={onExpand} aria-label="Expand scheduled panel">
<ChevronLeft className="size-4" />
</Button>
</div>
</>
);
}
function ExpandedPanel({
board,
onToggleOccurrence,
onEditOccurrence,
onAdd,
onCollapse,
}: {
board: ScheduledBoardDTO | null;
onToggleOccurrence: (occurrence: ScheduledOccurrenceDTO, completed: boolean) => void;
onEditOccurrence: (scheduledTodoId: string) => void;
onAdd: () => void;
onCollapse: () => void;
}) {
return (
<>
<div className="flex items-center justify-between gap-2 p-3">
<div className="flex min-w-0 items-center gap-2">
<CalendarClock className="size-5 shrink-0 text-primary" />
<span className="truncate text-lg font-semibold">Scheduled</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={onCollapse}
aria-label="Collapse scheduled panel"
>
<ChevronRight className="size-4" />
</Button>
</div>
<Separator />
<div className="flex-1 overflow-y-auto p-3">
{!board ? (
<p className="p-2 text-center text-sm text-muted-foreground">Loading</p>
) : (
<div className="space-y-5">
{board.overdue.length > 0 && (
<section className="rounded-lg border-2 border-destructive bg-destructive/10 p-3">
<div className="mb-1.5 flex items-center gap-1.5 text-sm font-bold text-destructive">
<AlertTriangle className="size-4 animate-pulse" />
Overdue ({board.overdue.length})
</div>
<ul>
{board.overdue.map((o) => (
<ScheduledTodoItem
key={`${o.scheduledTodoId}-${o.occurrenceDate}`}
occurrence={o}
emphasis="overdue"
onToggle={(completed) => onToggleOccurrence(o, completed)}
onEdit={() => onEditOccurrence(o.scheduledTodoId)}
/>
))}
</ul>
</section>
)}
<section>
<h3 className="mb-1.5 text-sm font-bold text-foreground">
Today {formatWeekdayDate(board.today.date)}
</h3>
{board.today.occurrences.length > 0 ? (
<ul>
{board.today.occurrences.map((o) => (
<ScheduledTodoItem
key={`${o.scheduledTodoId}-${o.occurrenceDate}`}
occurrence={o}
emphasis="today"
onToggle={(completed) => onToggleOccurrence(o, completed)}
onEdit={() => onEditOccurrence(o.scheduledTodoId)}
/>
))}
</ul>
) : (
<p className="text-xs text-muted-foreground">Nothing scheduled today.</p>
)}
</section>
{board.upcoming.map((day, index) => (
// Fades a little more for each day further from today, so
// today reads as the loudest thing in the column.
<section key={day.date} style={{ opacity: Math.max(0.35, 1 - (index + 1) * 0.13) }}>
<h3 className="mb-1.5 text-sm font-semibold text-muted-foreground">
{formatWeekdayDate(day.date)}
</h3>
{day.occurrences.length > 0 ? (
<ul>
{day.occurrences.map((o) => (
<ScheduledTodoItem
key={`${o.scheduledTodoId}-${o.occurrenceDate}`}
occurrence={o}
onToggle={(completed) => onToggleOccurrence(o, completed)}
onEdit={() => onEditOccurrence(o.scheduledTodoId)}
/>
))}
</ul>
) : (
<p className="text-xs text-muted-foreground">Nothing scheduled.</p>
)}
</section>
))}
</div>
)}
</div>
<Separator />
<div className="p-2">
<Button
variant="ghost"
className="w-full justify-start gap-2 text-muted-foreground"
onClick={onAdd}
>
<Plus className="size-4" />
Add Scheduled To-Do
</Button>
</div>
</>
);
}

View File

@ -0,0 +1,428 @@
"use client";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog";
import {
createScheduledTodo,
deleteScheduledTodo,
getScheduledTodoForEdit,
updateScheduledTodo,
} from "@/lib/actions/scheduled-todos";
import type { RecurrenceInput } from "@/lib/validation/scheduled-todo";
const TITLE_MAX = 100;
const WEEKDAY_LABELS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
const MONTH_LABELS = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
const FREQUENCY_UNIT: Record<RecurrenceInput["frequency"], string> = {
DAILY: "day(s)",
WEEKLY: "week(s)",
MONTHLY: "month(s)",
YEARLY: "year(s)",
};
/** "YYYY-MM-DD" for today in the browser's own local calendar -- just a
* sensible default for the date input, which the user can change anyway. */
function todayDateKey(): string {
const d = new Date();
const localMidnight = new Date(d.getTime() - d.getTimezoneOffset() * 60_000);
return localMidnight.toISOString().slice(0, 10);
}
export function ScheduledTodoDialog({
open,
onOpenChange,
projectId,
scheduledTodoId,
onSaved,
onDeleted,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
projectId: string | null;
// null = creating a new one; set = editing an existing one.
scheduledTodoId: string | null;
onSaved: () => void;
onDeleted: () => void;
}) {
const [title, setTitle] = useState("");
const [details, setDetails] = useState("");
const [startDate, setStartDate] = useState(todayDateKey());
const [isRecurring, setIsRecurring] = useState(false);
const [frequency, setFrequency] = useState<RecurrenceInput["frequency"]>("WEEKLY");
const [interval, setInterval] = useState(1);
const [daysOfWeek, setDaysOfWeek] = useState<number[]>([]);
const [dayOfMonth, setDayOfMonth] = useState(1);
const [month, setMonth] = useState(1);
const [endType, setEndType] = useState<"never" | "until" | "count">("never");
const [endDate, setEndDate] = useState("");
const [endCount, setEndCount] = useState(10);
const [loading, setLoading] = useState(false);
const [pending, setPending] = useState(false);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
useEffect(() => {
if (!open) return;
if (!scheduledTodoId) {
setTitle("");
setDetails("");
setStartDate(todayDateKey());
setIsRecurring(false);
setFrequency("WEEKLY");
setInterval(1);
setDaysOfWeek([]);
setDayOfMonth(1);
setMonth(1);
setEndType("never");
setEndDate("");
setEndCount(10);
return;
}
setLoading(true);
getScheduledTodoForEdit(scheduledTodoId)
.then((todo) => {
setTitle(todo.title);
setDetails(todo.details ?? "");
setStartDate(todo.startDate);
const recurrence = todo.recurrence;
setIsRecurring(!!recurrence);
setFrequency(recurrence?.frequency ?? "WEEKLY");
setInterval(recurrence?.interval ?? 1);
setDaysOfWeek(recurrence?.daysOfWeek ?? []);
setDayOfMonth(recurrence?.dayOfMonth ?? 1);
setMonth(recurrence?.month ?? 1);
setEndType(recurrence?.end.type ?? "never");
setEndDate(recurrence?.end.type === "until" ? recurrence.end.date : "");
setEndCount(recurrence?.end.type === "count" ? recurrence.end.count : 10);
})
.catch(() => toast.error("Couldn't load scheduled to-do."))
.finally(() => setLoading(false));
}, [open, scheduledTodoId]);
function toggleDayOfWeek(day: number) {
setDaysOfWeek((prev) => (prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day].sort()));
}
function buildRecurrence(): RecurrenceInput | undefined {
if (!isRecurring) return undefined;
return {
frequency,
interval,
daysOfWeek: frequency === "WEEKLY" ? daysOfWeek : undefined,
dayOfMonth: frequency === "MONTHLY" || frequency === "YEARLY" ? dayOfMonth : undefined,
month: frequency === "YEARLY" ? month : undefined,
end:
endType === "until"
? { type: "until", date: endDate }
: endType === "count"
? { type: "count", count: endCount }
: { type: "never" },
};
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const trimmedTitle = title.trim();
if (!trimmedTitle) return;
if (isRecurring && frequency === "WEEKLY" && daysOfWeek.length === 0) {
toast.error("Pick at least one day of the week.");
return;
}
if (isRecurring && endType === "until" && !endDate) {
toast.error("Pick an end date.");
return;
}
setPending(true);
try {
if (scheduledTodoId) {
await updateScheduledTodo(scheduledTodoId, {
title: trimmedTitle,
details: details.trim() || null,
startDate,
recurrence: buildRecurrence() ?? null,
});
} else {
await createScheduledTodo(
{ projectId },
{ title: trimmedTitle, details: details.trim() || undefined, startDate, recurrence: buildRecurrence() }
);
}
onSaved();
onOpenChange(false);
} catch {
toast.error(`Couldn't ${scheduledTodoId ? "update" : "add"} scheduled to-do. Try again.`);
} finally {
setPending(false);
}
}
async function handleDelete() {
if (!scheduledTodoId) return;
setPending(true);
try {
await deleteScheduledTodo(scheduledTodoId);
onDeleted();
onOpenChange(false);
} catch {
toast.error("Couldn't delete scheduled to-do. Try again.");
} finally {
setPending(false);
}
}
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{scheduledTodoId ? "Edit scheduled to-do" : "Add scheduled to-do"}</DialogTitle>
</DialogHeader>
{loading ? (
<p className="p-6 text-center text-sm text-muted-foreground">Loading</p>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="scheduled-title">Title</Label>
<Input
id="scheduled-title"
value={title}
maxLength={TITLE_MAX}
autoFocus
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Take out the trash"
/>
</div>
<div className="space-y-2">
<Label htmlFor="scheduled-details">Details (optional)</Label>
<Textarea
id="scheduled-details"
value={details}
onChange={(e) => setDetails(e.target.value)}
rows={2}
className="resize-none"
/>
</div>
<div className="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>
<label className="flex items-center gap-2 text-sm font-medium">
<Checkbox checked={isRecurring} onCheckedChange={(c) => setIsRecurring(!!c)} />
Repeat on a recurring schedule
</label>
{isRecurring && (
<div className="space-y-4 rounded-lg border p-3">
<div className="flex items-end gap-2">
<div className="space-y-2">
<Label>Every</Label>
<Input
type="number"
min={1}
max={365}
value={interval}
onChange={(e) => setInterval(Math.max(1, Number(e.target.value) || 1))}
className="w-20"
/>
</div>
<div className="space-y-2">
<Label className="sr-only">Frequency</Label>
<Select
value={frequency}
onValueChange={(v) => v && setFrequency(v as RecurrenceInput["frequency"])}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="DAILY">{FREQUENCY_UNIT.DAILY}</SelectItem>
<SelectItem value="WEEKLY">{FREQUENCY_UNIT.WEEKLY}</SelectItem>
<SelectItem value="MONTHLY">{FREQUENCY_UNIT.MONTHLY}</SelectItem>
<SelectItem value="YEARLY">{FREQUENCY_UNIT.YEARLY}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{frequency === "WEEKLY" && (
<div className="space-y-2">
<Label>On these days</Label>
<div className="flex gap-1">
{WEEKDAY_LABELS.map((label, day) => (
<button
key={day}
type="button"
aria-pressed={daysOfWeek.includes(day)}
onClick={() => toggleDayOfWeek(day)}
className={cn(
"flex size-8 items-center justify-center rounded-md border text-xs font-medium transition-colors",
daysOfWeek.includes(day)
? "border-primary bg-primary text-primary-foreground"
: "border-input text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{label}
</button>
))}
</div>
</div>
)}
{(frequency === "MONTHLY" || frequency === "YEARLY") && (
<div className="flex items-end gap-2">
{frequency === "YEARLY" && (
<div className="space-y-2">
<Label className="sr-only">Month</Label>
<Select
value={String(month)}
onValueChange={(v) => v && setMonth(Number(v))}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MONTH_LABELS.map((label, i) => (
<SelectItem key={label} value={String(i + 1)}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label>Day</Label>
<Input
type="number"
min={1}
max={31}
value={dayOfMonth}
onChange={(e) =>
setDayOfMonth(Math.min(31, Math.max(1, Number(e.target.value) || 1)))
}
className="w-20"
/>
</div>
</div>
)}
<div className="space-y-2">
<Label>Ends</Label>
<RadioGroup
value={endType}
onValueChange={(v) => v && setEndType(v as "never" | "until" | "count")}
>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="never" />
Never
</label>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="until" />
On date
{endType === "until" && (
<Input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="ml-1 w-auto"
/>
)}
</label>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="count" />
After
{endType === "count" && (
<Input
type="number"
min={1}
max={999}
value={endCount}
onChange={(e) => setEndCount(Math.max(1, Number(e.target.value) || 1))}
className="ml-1 w-20"
/>
)}
occurrence(s)
</label>
</RadioGroup>
</div>
</div>
)}
<DialogFooter className={cn(scheduledTodoId && "sm:justify-between")}>
{scheduledTodoId && (
<Button
type="button"
variant="ghost"
className="text-destructive hover:text-destructive gap-2"
onClick={() => setConfirmDeleteOpen(true)}
disabled={pending}
>
<Trash2 className="size-4" />
Delete
</Button>
)}
<Button type="submit" disabled={pending || !title.trim()}>
{pending ? "Saving…" : scheduledTodoId ? "Save" : "Add scheduled to-do"}
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
<ConfirmDeleteDialog
open={confirmDeleteOpen}
onOpenChange={setConfirmDeleteOpen}
title="Delete scheduled to-do?"
description={
isRecurring
? `Delete "${title}" and all of its remaining occurrences? This can't be undone.`
: `Delete "${title}"? This can't be undone.`
}
onConfirm={handleDelete}
/>
</>
);
}

View File

@ -0,0 +1,61 @@
"use client";
import { StickyNote } from "lucide-react";
import { cn } from "@/lib/utils";
import { Checkbox } from "@/components/ui/checkbox";
import { MouseFollowTooltip } from "@/components/board/mouse-follow-tooltip";
import type { ScheduledOccurrenceDTO } from "@/types/scheduled-todo";
/** One occurrence row -- "emphasis" is how loud this date's section reads
* (Overdue/Today get their own text treatment; every other day relies on
* ScheduledPanel fading its whole section via opacity instead). */
export function ScheduledTodoItem({
occurrence,
emphasis = "normal",
onToggle,
onEdit,
}: {
occurrence: ScheduledOccurrenceDTO;
emphasis?: "overdue" | "today" | "normal";
onToggle: (completed: boolean) => void;
onEdit: () => void;
}) {
const titleButton = (
<button
type="button"
onClick={onEdit}
className={cn(
"min-w-0 flex-1 truncate text-left text-sm hover:underline",
occurrence.completed
? "text-muted-foreground line-through"
: emphasis === "overdue"
? "font-semibold text-destructive"
: emphasis === "today"
? "font-medium text-foreground"
: "text-foreground"
)}
>
{occurrence.title}
</button>
);
return (
<li className="flex items-start gap-2 py-1">
<Checkbox
checked={occurrence.completed}
onCheckedChange={(checked) => onToggle(!!checked)}
className="mt-0.5"
aria-label={`Mark "${occurrence.title}" ${occurrence.completed ? "incomplete" : "complete"}`}
/>
{occurrence.details ? (
<MouseFollowTooltip content={occurrence.details}>{titleButton}</MouseFollowTooltip>
) : (
titleButton
)}
{occurrence.details && (
<StickyNote className="mt-0.5 size-3 shrink-0 text-muted-foreground/70" />
)}
</li>
);
}

View File

@ -114,6 +114,11 @@ export function useSpeechRecognition({
}; };
recognition.onerror = (event) => { recognition.onerror = (event) => {
const errorType = event.error; const errorType = event.error;
// Logged (not just surfaced in the UI) because the friendly fallback
// message below deliberately can't describe every one of the API's
// ~10 error codes -- this is the fast path to finding out which one
// is actually firing when a user reports "it just stops".
console.error("SpeechRecognition error:", errorType);
erroredMessage = erroredMessage =
errorType === "not-allowed" || errorType === "service-not-allowed" errorType === "not-allowed" || errorType === "service-not-allowed"
? "Microphone access was blocked." ? "Microphone access was blocked."
@ -121,7 +126,14 @@ export function useSpeechRecognition({
? "No microphone was found." ? "No microphone was found."
: errorType === "no-speech" : errorType === "no-speech"
? null // silence isn't an error worth surfacing -- just let it end/restart ? null // silence isn't an error worth surfacing -- just let it end/restart
: "Voice input stopped unexpectedly."; : errorType === "network"
? // The browser's built-in speech recognition isn't local --
// it streams audio to the browser vendor's cloud service
// and gets text back. This fires when that round trip
// fails, most often a firewall/proxy blocking it rather
// than a real outage (nothing this app can route around).
"Couldn't reach the browser's speech recognition service -- this is usually a network/firewall restriction, not an app problem. Try a different network, or type instead."
: `Voice input stopped unexpectedly (${errorType}).`;
}; };
recognition.onend = () => { recognition.onend = () => {
if (userStoppedRef.current) { if (userStoppedRef.current) {

View File

@ -23,6 +23,15 @@ export function projectAccessFilter(userId: string) {
return { ownerId: userId }; return { ownerId: userId };
} }
/**
* Same ownership pattern as categoryAccessFilter, but simpler: a
* ScheduledTodo carries userId/projectId directly (no Category/Group join
* to traverse).
*/
export function scheduledTodoAccessFilter(userId: string) {
return { OR: [{ userId }, { project: projectAccessFilter(userId) }] };
}
/** The board route a given scope's mutations should revalidate. */ /** The board route a given scope's mutations should revalidate. */
export function boardPath(projectId: string | null | undefined): string { export function boardPath(projectId: string | null | undefined): string {
return projectId ? `/projects/${projectId}` : "/"; return projectId ? `/projects/${projectId}` : "/";

View File

@ -0,0 +1,154 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { requireUserId } from "@/lib/auth-helpers";
import { boardPath, projectAccessFilter, scheduledTodoAccessFilter } from "@/lib/access";
import {
RecurrenceInputSchema,
ScheduledTodoDetailsSchema,
ScheduledTodoTitleSchema,
type RecurrenceInput,
} from "@/lib/validation/scheduled-todo";
import { buildRRuleString, parseRRuleString } from "@/lib/rrule-utils";
import { getScheduledTodoBoard } from "@/lib/scheduled-todos";
import type { ScheduledBoardDTO, ScheduledTodoEditDTO } from "@/types/scheduled-todo";
/** Verifies the project (Home scope is always allowed for its own user). */
async function assertProjectAccess(userId: string, projectId: string) {
const project = await prisma.project.findFirst({
where: { id: projectId, ...projectAccessFilter(userId) },
select: { id: true },
});
if (!project) throw new Error("Project not found");
}
async function findOwnedScheduledTodo(userId: string, id: string) {
const todo = await prisma.scheduledTodo.findFirst({
where: { id, ...scheduledTodoAccessFilter(userId) },
select: { id: true, projectId: true },
});
if (!todo) throw new Error("Scheduled to-do not found");
return todo;
}
/** The action the client Scheduled panel calls -- it derives Home vs.
* Project scope from the current route itself (see ScheduledPanel), so this
* is the single entry point regardless of which board is showing. */
export async function getScheduledBoard(projectId: string | null): Promise<ScheduledBoardDTO> {
const userId = await requireUserId();
if (projectId) {
await assertProjectAccess(userId, projectId);
return getScheduledTodoBoard({ projectId });
}
return getScheduledTodoBoard({ userId, projectId: null });
}
export async function getScheduledTodoForEdit(id: string): Promise<ScheduledTodoEditDTO> {
const userId = await requireUserId();
const todo = await prisma.scheduledTodo.findFirst({
where: { id, ...scheduledTodoAccessFilter(userId) },
});
if (!todo) throw new Error("Scheduled to-do not found");
return {
id: todo.id,
title: todo.title,
details: todo.details,
startDate: todo.startDate.toISOString().slice(0, 10),
recurrence: todo.rrule ? parseRRuleString(todo.rrule) : null,
};
}
export async function createScheduledTodo(
scope: { projectId: string | null },
data: { title: string; details?: string; startDate: string; recurrence?: RecurrenceInput }
): Promise<void> {
const userId = await requireUserId();
const title = ScheduledTodoTitleSchema.parse(data.title);
const details = data.details ? ScheduledTodoDetailsSchema.parse(data.details) : undefined;
const recurrence = data.recurrence ? RecurrenceInputSchema.parse(data.recurrence) : undefined;
if (scope.projectId) await assertProjectAccess(userId, scope.projectId);
await prisma.scheduledTodo.create({
data: {
title,
details: details ?? null,
userId: scope.projectId ? null : userId,
projectId: scope.projectId,
startDate: new Date(data.startDate),
rrule: recurrence ? buildRRuleString(recurrence) : null,
},
});
revalidatePath(boardPath(scope.projectId));
}
export async function updateScheduledTodo(
id: string,
data: {
title?: string;
details?: string | null;
startDate?: string;
recurrence?: RecurrenceInput | null;
}
): Promise<void> {
const userId = await requireUserId();
const todo = await findOwnedScheduledTodo(userId, id);
const update: {
title?: string;
details?: string | null;
startDate?: Date;
rrule?: string | null;
} = {};
if (data.title !== undefined) update.title = ScheduledTodoTitleSchema.parse(data.title);
if (data.details !== undefined) {
update.details = data.details ? ScheduledTodoDetailsSchema.parse(data.details) : null;
}
if (data.startDate !== undefined) update.startDate = new Date(data.startDate);
if (data.recurrence !== undefined) {
update.rrule = data.recurrence ? buildRRuleString(RecurrenceInputSchema.parse(data.recurrence)) : null;
}
await prisma.scheduledTodo.update({ where: { id }, data: update });
revalidatePath(boardPath(todo.projectId));
}
export async function deleteScheduledTodo(id: string): Promise<void> {
const userId = await requireUserId();
const todo = await findOwnedScheduledTodo(userId, id);
await prisma.scheduledTodo.delete({ where: { id } });
revalidatePath(boardPath(todo.projectId));
}
/** Toggles just one date's occurrence -- a recurring to-do's other dates
* are unaffected (see ScheduledTodoCompletion). */
export async function toggleScheduledOccurrence(
scheduledTodoId: string,
occurrenceDate: string,
completed: boolean
): Promise<void> {
const userId = await requireUserId();
const todo = await findOwnedScheduledTodo(userId, scheduledTodoId);
const date = new Date(occurrenceDate);
if (completed) {
await prisma.scheduledTodoCompletion.upsert({
where: { scheduledTodoId_occurrenceDate: { scheduledTodoId, occurrenceDate: date } },
create: { scheduledTodoId, occurrenceDate: date },
update: {},
});
} else {
await prisma.scheduledTodoCompletion.deleteMany({
where: { scheduledTodoId, occurrenceDate: date },
});
}
revalidatePath(boardPath(todo.projectId));
}

192
lib/actions/todo-ai.ts Normal file
View File

@ -0,0 +1,192 @@
"use server";
import { prisma } from "@/lib/db";
import { requireUserId } from "@/lib/auth-helpers";
import { categoryAccessFilter } from "@/lib/access";
import { getAiCredentials, getAiSettingsView, joinApiUrl } from "@/lib/ai-settings";
export interface TodoAiMessage {
role: "user" | "assistant";
content: string;
}
export interface TodoAiProposal {
title: string;
details?: string;
}
export type TodoAiResult =
| { action: "ask"; question: string }
| { action: "finalize"; todos: TodoAiProposal[] }
| { error: string };
const TITLE_MAX = 20;
// Same "respond with exactly one JSON shape" contract as converseAboutGroup
// (lib/actions/group-ai.ts) -- the AI backend is a user-configured
// OpenAI-compatible endpoint reached over raw fetch, so there's no
// function-calling/JSON-mode to lean on; the format has to be held
// together by the prompt plus defensive parsing below.
const SYSTEM_PROMPT = `You are helping a user create one or more to-dos for a group called "{{GROUP_TITLE}}" in a to-do list app. Have a brief conversation to understand what they need to track. Ask at most one short, specific follow-up question at a time if genuinely needed. A to-do's title is hard-capped at ${TITLE_MAX} characters -- keep every title short and punchy, and put anything else worth remembering in that to-do's own "details" (short markdown notes, optional). Split unrelated tasks into separate to-dos rather than cramming them into one.{{EXISTING_TITLES_CLAUSE}}
You must respond with ONLY a single JSON object and nothing else -- no preamble, no code block, no text before or after it. It must match exactly one of these two shapes:
Still gathering information:
{"action": "ask", "question": "<one short, specific question>"}
Ready to finalize:
{"action": "finalize", "todos": [{"title": "<${TITLE_MAX} characters or fewer>", "details": "<optional short markdown notes, omit or leave empty if none>"}]}
Respond with the raw JSON object only.`;
function extractMessageContent(body: unknown): string | null {
if (!body || typeof body !== "object") return null;
const choices = (body as { choices?: unknown }).choices;
if (!Array.isArray(choices) || !choices[0]) return null;
const message = (choices[0] as { message?: unknown }).message;
if (!message || typeof message !== "object") return null;
const content = (message as { content?: unknown }).content;
return typeof content === "string" ? content : null;
}
/** Model output sometimes arrives fenced in a ```json code block despite
* instructions not to -- strip that before parsing rather than failing. */
function stripCodeFence(text: string): string {
const trimmed = text.trim();
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
return fenced ? fenced[1] : trimmed;
}
function parseTodos(value: unknown): TodoAiProposal[] | null {
if (!Array.isArray(value)) return null;
const todos: TodoAiProposal[] = [];
for (const entry of value) {
if (!entry || typeof entry !== "object") continue;
const rawTitle = (entry as { title?: unknown }).title;
if (typeof rawTitle !== "string") continue;
const title = rawTitle.trim().slice(0, TITLE_MAX);
if (!title) continue;
const rawDetails = (entry as { details?: unknown }).details;
const details = typeof rawDetails === "string" ? rawDetails.trim() : "";
todos.push(details ? { title, details } : { title });
}
return todos.length > 0 ? todos : null;
}
function parseModelOutput(content: string): TodoAiResult | null {
let json: unknown;
try {
json = JSON.parse(stripCodeFence(content));
} catch {
return null;
}
if (!json || typeof json !== "object") return null;
const action = (json as { action?: unknown }).action;
if (action === "ask") {
const question = (json as { question?: unknown }).question;
return typeof question === "string" && question.trim()
? { action: "ask", question: question.trim() }
: null;
}
if (action === "finalize") {
const todos = parseTodos((json as { todos?: unknown }).todos);
return todos ? { action: "finalize", todos } : null;
}
return null;
}
/**
* Sends the interview's conversation so far to the configured AI provider
* and returns either a follow-up question or a finalized list of to-do
* proposals. Reuses the same admin-configured endpoint/key as the group
* notes "Ask AI" feature (see lib/ai-settings.ts).
*/
export async function converseAboutTodos(
groupId: string,
groupTitle: string,
existingTodoTitles: string[],
messages: TodoAiMessage[],
forceFinalize: boolean
): Promise<TodoAiResult> {
const userId = await requireUserId();
const group = await prisma.group.findFirst({
where: { id: groupId, category: categoryAccessFilter(userId) },
select: { id: true },
});
if (!group) return { error: "Group not found." };
const [{ apiUrl, apiKey }, settings] = await Promise.all([
getAiCredentials(),
getAiSettingsView(),
]);
if (!apiUrl || !settings.model) {
return { error: "AI generation isn't configured yet. Ask an administrator to set it up." };
}
let chatUrl: URL;
try {
chatUrl = joinApiUrl(apiUrl, "chat/completions");
} catch {
return { error: "The configured AI API URL is invalid." };
}
const existingTitlesClause =
existingTodoTitles.length > 0
? ` This group already has these to-dos, don't duplicate them: ${existingTodoTitles
.map((t) => `"${t}"`)
.join(", ")}.`
: "";
let systemPrompt = SYSTEM_PROMPT.replace("{{GROUP_TITLE}}", groupTitle).replace(
"{{EXISTING_TITLES_CLAUSE}}",
existingTitlesClause
);
if (forceFinalize) {
systemPrompt +=
'\n\nThe user has asked you to wrap up now with whatever information you have, even if it feels incomplete. Do not ask another question -- respond with {"action": "finalize", ...} using your best effort.';
}
let response: Response;
try {
response = await fetch(chatUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
body: JSON.stringify({
model: settings.model,
messages: [{ role: "system", content: systemPrompt }, ...messages],
temperature: 0.4,
}),
signal: AbortSignal.timeout(30_000),
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return { error: `Couldn't reach the AI provider -- ${message}` };
}
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
return { error: "The AI provider rejected the configured API key." };
}
return { error: `The AI provider responded with ${response.status} ${response.statusText}.` };
}
let body: unknown;
try {
body = await response.json();
} catch {
return { error: "The AI provider didn't return valid JSON." };
}
const content = extractMessageContent(body);
if (!content) return { error: "The AI provider's response was empty." };
const parsed = parseModelOutput(content);
// Not every backend reliably follows the "JSON only" instruction --
// rather than erroring out, treat unparseable output as a follow-up
// question so the conversation degrades gracefully instead of dying.
return parsed ?? { action: "ask", question: content.trim() };
}

View File

@ -43,6 +43,53 @@ export async function createTodo(
}; };
} }
/**
* Bulk-inserts several to-dos in one transaction, e.g. from the "Add
* using AI" interview. Unlike calling `createTodo` in a loop, `order` is
* computed once up front and assigned sequentially within the same
* transaction, so concurrent items in this batch never race each other's
* `prisma.todo.count` read.
*/
export async function createTodos(
groupId: string,
todos: { title: string; details?: string }[]
): Promise<TodoDTO[]> {
const userId = await requireUserId();
const parsed = todos.map((t) => ({
title: TodoTitleSchema.parse(t.title),
details: t.details ? TodoDetailsSchema.parse(t.details) : undefined,
}));
const group = await prisma.group.findFirst({
where: { id: groupId, category: categoryAccessFilter(userId) },
select: { id: true, category: { select: { projectId: true } } },
});
if (!group) throw new Error("Group not found");
const count = await prisma.todo.count({ where: { groupId } });
const created = await prisma.$transaction(
parsed.map((t, index) =>
prisma.todo.create({
data: { title: t.title, details: t.details ?? null, order: count + index, groupId },
})
)
);
revalidatePath(boardPath(group.category.projectId));
return created.map((todo) => ({
id: todo.id,
title: todo.title,
details: todo.details,
completed: todo.completed,
order: todo.order,
groupId: todo.groupId,
createdAt: todo.createdAt.toISOString(),
updatedAt: todo.updatedAt.toISOString(),
completedAt: todo.completedAt?.toISOString() ?? null,
}));
}
export async function updateTodo( export async function updateTodo(
todoId: string, todoId: string,
data: { title?: string; details?: string | null } data: { title?: string; details?: string | null }

View File

@ -131,3 +131,32 @@ export function getComplementaryColor(hex: string, variant: "light" | "dark"): s
complementaryCache.set(cacheKey, result); complementaryCache.set(cacheKey, result);
return result; return result;
} }
const brighterCache = new Map<string, string>();
/**
* A brighter tint of a Group's own color, same hue, for un-checked to-do
* text. Deliberately distinct from the plain foreground color (which would
* otherwise render pure white in dark mode) so the group title -- which
* keeps the default foreground color -- stays the most prominent text on
* the card instead of competing with its own to-dos.
*
* The lightness nudge is relative to the base color's *own* lightness
* (not a flat target) so the result stays visibly tied to that specific
* hue instead of every color washing out toward the same pale value.
*/
export function getBrighterColor(hex: string, variant: "light" | "dark"): string {
const cacheKey = `${hex}:${variant}`;
const cached = brighterCache.get(cacheKey);
if (cached) return cached;
const [hue, saturation, lightness] = rgbToHsl(...hexToRgb(hex));
const delta = variant === "dark" ? 8 : -8;
const result = hslToHex(
hue,
saturation,
Math.min(Math.max(lightness + delta, 15), 88)
);
brighterCache.set(cacheKey, result);
return result;
}

35
lib/dates.ts Normal file
View File

@ -0,0 +1,35 @@
// Plain UTC calendar-date math, shared by the scheduled-todo server logic
// and its client components. Deliberately UTC-based rather than local-time
// -- Prisma's `@db.Date` columns round-trip as UTC-midnight `Date`s, and
// `rrule`'s occurrence math is documented to want the same, so keeping
// every date here anchored to UTC midnight avoids DST/timezone drift
// between "today" on the server and an occurrence date from the database.
/** Truncates a Date to UTC midnight of the same calendar day. */
export function startOfUTCDate(date: Date): Date {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
}
/** Adds (or subtracts, if negative) whole days, staying UTC-midnight-aligned. */
export function addUTCDays(date: Date, days: number): Date {
const d = startOfUTCDate(date);
d.setUTCDate(d.getUTCDate() + days);
return d;
}
/** Monday of the calendar week containing `date` (MonSun weeks). */
export function getWeekStart(date: Date): Date {
const d = startOfUTCDate(date);
const day = d.getUTCDay(); // 0 = Sun .. 6 = Sat
const diffToMonday = day === 0 ? -6 : 1 - day;
return addUTCDays(d, diffToMonday);
}
/** "YYYY-MM-DD", used as both a display value and a stable map/comparison key. */
export function toDateKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
export function isSameUTCDate(a: Date, b: Date): boolean {
return toDateKey(a) === toDateKey(b);
}

View File

@ -9,3 +9,17 @@ export function formatDateTime(iso: string): string {
minute: "2-digit", minute: "2-digit",
}); });
} }
/** e.g. "Monday, Aug 17" from a "YYYY-MM-DD" date key. Forces UTC
* interpretation -- these keys are calendar dates, not instants, so
* rendering them in the viewer's local timezone could shift midnight UTC
* to the wrong day (e.g. showing "Sunday" for a Monday date key west of
* UTC). */
export function formatWeekdayDate(dateKey: string): string {
return new Date(`${dateKey}T00:00:00Z`).toLocaleDateString(undefined, {
weekday: "long",
month: "short",
day: "numeric",
timeZone: "UTC",
});
}

100
lib/rrule-utils.ts Normal file
View File

@ -0,0 +1,100 @@
import { RRule, type Options, type Weekday } from "rrule";
import type { RecurrenceInput } from "@/lib/validation/scheduled-todo";
const FREQUENCY_TO_RRULE: Record<RecurrenceInput["frequency"], Options["freq"]> = {
DAILY: RRule.DAILY,
WEEKLY: RRule.WEEKLY,
MONTHLY: RRule.MONTHLY,
YEARLY: RRule.YEARLY,
};
const RRULE_TO_FREQUENCY: Partial<Record<Options["freq"], RecurrenceInput["frequency"]>> = {
[RRule.DAILY]: "DAILY",
[RRule.WEEKLY]: "WEEKLY",
[RRule.MONTHLY]: "MONTHLY",
[RRule.YEARLY]: "YEARLY",
};
// rrule's own Weekday constants, indexed here to match JS's
// Date#getUTCDay() (0 = Sun .. 6 = Sat) -- rrule internally numbers MO=0
// .. SU=6 instead, which is what Weekday#getJsWeekday() converts back from.
const WEEKDAYS_BY_JS_DAY = [RRule.SU, RRule.MO, RRule.TU, RRule.WE, RRule.TH, RRule.FR, RRule.SA];
/**
* Rebuilds an RFC 5545 RRULE string from validated recurrence parts. This
* is the only path that should ever produce a stored `ScheduledTodo.rrule`
* value -- never trust a client-supplied RRULE string directly. The
* to-do's own `startDate` column is the anchor/DTSTART and is deliberately
* left out of this string (see expandOccurrences below).
*/
export function buildRRuleString(recurrence: RecurrenceInput): string {
const options: Partial<Options> = {
freq: FREQUENCY_TO_RRULE[recurrence.frequency],
interval: recurrence.interval,
};
if (recurrence.frequency === "WEEKLY" && recurrence.daysOfWeek?.length) {
options.byweekday = recurrence.daysOfWeek.map((d) => WEEKDAYS_BY_JS_DAY[d]);
}
if (
(recurrence.frequency === "MONTHLY" || recurrence.frequency === "YEARLY") &&
recurrence.dayOfMonth
) {
options.bymonthday = recurrence.dayOfMonth;
}
if (recurrence.frequency === "YEARLY" && recurrence.month) {
options.bymonth = recurrence.month;
}
if (recurrence.end.type === "until") options.until = new Date(recurrence.end.date);
if (recurrence.end.type === "count") options.count = recurrence.end.count;
return RRule.optionsToString(options);
}
/** Reverses buildRRuleString, to pre-fill the edit dialog from a stored rule. */
export function parseRRuleString(rrule: string): RecurrenceInput {
const parsed = RRule.parseString(rrule);
const frequency = RRULE_TO_FREQUENCY[parsed.freq ?? RRule.DAILY] ?? "DAILY";
const daysOfWeek = Array.isArray(parsed.byweekday)
? parsed.byweekday
.map((w) => (typeof w === "number" ? undefined : (w as Weekday).getJsWeekday()))
.filter((d): d is number => d !== undefined)
: undefined;
const dayOfMonth = Array.isArray(parsed.bymonthday) ? parsed.bymonthday[0] : parsed.bymonthday;
const month = Array.isArray(parsed.bymonth) ? parsed.bymonth[0] : parsed.bymonth;
return {
frequency,
interval: parsed.interval ?? 1,
daysOfWeek,
dayOfMonth: dayOfMonth ?? undefined,
month: month ?? undefined,
end: parsed.count
? { type: "count", count: parsed.count }
: parsed.until
? { type: "until", date: parsed.until.toISOString().slice(0, 10) }
: { type: "never" },
};
}
/**
* Every occurrence date of a ScheduledTodo within [windowStart, windowEnd]
* (inclusive), combining the stored `rrule` (if any) with its own
* `startDate` as DTSTART. A null rrule means a one-time to-do: its only
* occurrence is startDate itself.
*/
export function expandOccurrences(
startDate: Date,
rrule: string | null,
windowStart: Date,
windowEnd: Date
): Date[] {
if (!rrule) {
return startDate >= windowStart && startDate <= windowEnd ? [startDate] : [];
}
const parsed = RRule.parseString(rrule);
const rule = new RRule({ ...parsed, dtstart: startDate });
return rule.between(windowStart, windowEnd, true);
}

81
lib/scheduled-todos.ts Normal file
View File

@ -0,0 +1,81 @@
import "server-only";
import { prisma } from "@/lib/db";
import { addUTCDays, getWeekStart, startOfUTCDate, toDateKey } from "@/lib/dates";
import { expandOccurrences } from "@/lib/rrule-utils";
import type { ScheduledBoardDTO, ScheduledDayDTO, ScheduledOccurrenceDTO } from "@/types/scheduled-todo";
// A recurring to-do that's never checked off would otherwise surface a
// missed occurrence for every day since it was created, forever -- cap how
// far back "overdue" looks so the column stays meaningful instead of
// endless.
const OVERDUE_LOOKBACK_DAYS = 30;
/**
* Fetches one Scheduled board's worth of occurrences, bucketed the way the
* panel renders them -- shared by the Home board and every Project board,
* which differ only in which ScheduledTodos they scope to (mirrors
* lib/board.ts's getBoard).
*/
export async function getScheduledTodoBoard(
where: { userId: string; projectId: null } | { projectId: string }
): Promise<ScheduledBoardDTO> {
const today = startOfUTCDate(new Date());
const todayKey = toDateKey(today);
const weekStart = getWeekStart(today);
const weekEnd = addUTCDays(weekStart, 6);
const windowStart = addUTCDays(today, -OVERDUE_LOOKBACK_DAYS);
const scheduledTodos = await prisma.scheduledTodo.findMany({
where,
include: { completions: true },
orderBy: { createdAt: "asc" },
});
const overdue: ScheduledOccurrenceDTO[] = [];
const todayOccurrences: ScheduledOccurrenceDTO[] = [];
// Seeded up front so every remaining weekday gets a header even with
// nothing scheduled that day.
const upcomingByDate = new Map<string, ScheduledOccurrenceDTO[]>();
for (let d = addUTCDays(today, 1); d <= weekEnd; d = addUTCDays(d, 1)) {
upcomingByDate.set(toDateKey(d), []);
}
for (const todo of scheduledTodos) {
const completedDates = new Set(todo.completions.map((c) => toDateKey(c.occurrenceDate)));
const occurrences = expandOccurrences(todo.startDate, todo.rrule, windowStart, weekEnd);
for (const occurrenceDate of occurrences) {
const key = toDateKey(occurrenceDate);
const dto: ScheduledOccurrenceDTO = {
scheduledTodoId: todo.id,
title: todo.title,
details: todo.details,
occurrenceDate: key,
completed: completedDates.has(key),
isRecurring: !!todo.rrule,
};
if (key < todayKey) {
if (!dto.completed) overdue.push(dto);
} else if (key === todayKey) {
todayOccurrences.push(dto);
} else {
upcomingByDate.get(key)?.push(dto);
}
}
}
overdue.sort((a, b) => a.occurrenceDate.localeCompare(b.occurrenceDate));
const upcoming: ScheduledDayDTO[] = Array.from(upcomingByDate, ([date, occurrences]) => ({
date,
occurrences,
})).sort((a, b) => a.date.localeCompare(b.date));
return {
overdue,
today: { date: todayKey, occurrences: todayOccurrences },
upcoming,
};
}

View File

@ -0,0 +1,51 @@
import { z } from "zod";
export const ScheduledTodoTitleSchema = z
.string()
.trim()
.min(1, "Title is required")
.max(100, "Title must be 100 characters or fewer");
export const ScheduledTodoDetailsSchema = z.string().max(5_000).optional();
const DateStringSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Invalid date");
const RecurrenceEndSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("never") }),
z.object({ type: z.literal("until"), date: DateStringSchema }),
z.object({ type: z.literal("count"), count: z.number().int().min(1).max(999) }),
]);
/**
* What the "Add/Edit Scheduled To-Do" dialog submits for the recurring
* case -- validated here and only here turned into an RFC 5545 RRULE
* string (see lib/rrule-utils.ts). Never trust a client-supplied RRULE
* string directly.
*/
export const RecurrenceInputSchema = z
.object({
frequency: z.enum(["DAILY", "WEEKLY", "MONTHLY", "YEARLY"]),
interval: z.number().int().min(1).max(365),
// 0 = Sunday .. 6 = Saturday, matching Date#getUTCDay().
daysOfWeek: z.array(z.number().int().min(0).max(6)).max(7).optional(),
dayOfMonth: z.number().int().min(1).max(31).optional(),
month: z.number().int().min(1).max(12).optional(),
end: RecurrenceEndSchema,
})
.superRefine((value, ctx) => {
if (value.frequency === "WEEKLY" && !value.daysOfWeek?.length) {
ctx.addIssue({
code: "custom",
path: ["daysOfWeek"],
message: "Pick at least one day of the week",
});
}
if ((value.frequency === "MONTHLY" || value.frequency === "YEARLY") && !value.dayOfMonth) {
ctx.addIssue({ code: "custom", path: ["dayOfMonth"], message: "Day of month is required" });
}
if (value.frequency === "YEARLY" && !value.month) {
ctx.addIssue({ code: "custom", path: ["month"], message: "Month is required" });
}
});
export type RecurrenceInput = z.infer<typeof RecurrenceInputSchema>;

10
package-lock.json generated
View File

@ -29,6 +29,7 @@
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"react-hook-form": "^7.85.0", "react-hook-form": "^7.85.0",
"rrule": "^2.8.1",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"shadcn": "^4.16.2", "shadcn": "^4.16.2",
"sharp": "^0.35.3", "sharp": "^0.35.3",
@ -12248,6 +12249,15 @@
"node": ">= 18" "node": ">= 18"
} }
}, },
"node_modules/rrule": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz",
"integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/run-applescript": { "node_modules/run-applescript": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",

View File

@ -30,6 +30,7 @@
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"react-hook-form": "^7.85.0", "react-hook-form": "^7.85.0",
"rrule": "^2.8.1",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"shadcn": "^4.16.2", "shadcn": "^4.16.2",
"sharp": "^0.35.3", "sharp": "^0.35.3",

View File

@ -0,0 +1,42 @@
-- CreateTable
CREATE TABLE "ScheduledTodo" (
"id" TEXT NOT NULL,
"title" VARCHAR(100) NOT NULL,
"details" TEXT,
"userId" TEXT,
"projectId" TEXT,
"startDate" DATE NOT NULL,
"rrule" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ScheduledTodo_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ScheduledTodoCompletion" (
"id" TEXT NOT NULL,
"scheduledTodoId" TEXT NOT NULL,
"occurrenceDate" DATE NOT NULL,
"completedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ScheduledTodoCompletion_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ScheduledTodo_userId_idx" ON "ScheduledTodo"("userId");
-- CreateIndex
CREATE INDEX "ScheduledTodo_projectId_idx" ON "ScheduledTodo"("projectId");
-- CreateIndex
CREATE UNIQUE INDEX "ScheduledTodoCompletion_scheduledTodoId_occurrenceDate_key" ON "ScheduledTodoCompletion"("scheduledTodoId", "occurrenceDate");
-- AddForeignKey
ALTER TABLE "ScheduledTodo" ADD CONSTRAINT "ScheduledTodo_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ScheduledTodo" ADD CONSTRAINT "ScheduledTodo_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ScheduledTodoCompletion" ADD CONSTRAINT "ScheduledTodoCompletion_scheduledTodoId_fkey" FOREIGN KEY ("scheduledTodoId") REFERENCES "ScheduledTodo"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,9 @@
-- Every existing ScheduledTodo row already has exactly one of
-- userId/projectId set (both columns were just added together with this
-- invariant already true by construction), so this holds immediately with
-- no backfill -- enforce it at the database level too, same as
-- Category_owner_xor_project.
ALTER TABLE "ScheduledTodo" ADD CONSTRAINT "ScheduledTodo_owner_xor_project" CHECK (
("userId" IS NOT NULL AND "projectId" IS NULL) OR
("userId" IS NULL AND "projectId" IS NOT NULL)
);

View File

@ -45,6 +45,7 @@ model User {
categories Category[] categories Category[]
projects Project[] @relation("ProjectOwner") projects Project[] @relation("ProjectOwner")
scheduledTodos ScheduledTodo[]
} }
/** /**
@ -66,6 +67,7 @@ model Project {
owner User @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: Cascade) owner User @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: Cascade)
categories Category[] categories Category[]
scheduledTodos ScheduledTodo[]
@@index([ownerId, createdAt]) @@index([ownerId, createdAt])
} }
@ -168,3 +170,54 @@ model Todo {
@@index([groupId, order]) @@index([groupId, order])
} }
/**
* A date-based or recurring to-do, shown in the right-hand "Scheduled"
* column rather than on a Group's kanban list -- entirely separate from
* Todo. Exactly one of userId/projectId is set, same ownership pattern as
* Category (enforced by a DB check constraint + lib/access.ts).
*/
model ScheduledTodo {
id String @id @default(cuid())
title String @db.VarChar(100)
details String? @db.Text
userId String?
projectId String?
// The only occurrence when rrule is null (a one-time to-do); the RRULE's
// DTSTART otherwise.
startDate DateTime @db.Date
// RFC 5545 recurrence rule string (e.g.
// "FREQ=WEEKLY;BYDAY=MO,WE;INTERVAL=2;UNTIL=20261231"), built and parsed
// via the `rrule` package -- never trust a client-supplied string, it's
// always rebuilt server-side from validated parts. Null = one-time.
rrule String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
completions ScheduledTodoCompletion[]
@@index([userId])
@@index([projectId])
}
/**
* Per-occurrence completion for a ScheduledTodo -- a recurring to-do needs
* independent completed state per calendar date (this Tuesday done, next
* Tuesday not), so this isn't a single boolean on ScheduledTodo itself.
* One-time to-dos also get exactly one row here once completed.
*/
model ScheduledTodoCompletion {
id String @id @default(cuid())
scheduledTodoId String
occurrenceDate DateTime @db.Date
completedAt DateTime @default(now())
scheduledTodo ScheduledTodo @relation(fields: [scheduledTodoId], references: [id], onDelete: Cascade)
@@unique([scheduledTodoId, occurrenceDate])
}

41
types/scheduled-todo.ts Normal file
View File

@ -0,0 +1,41 @@
import type { RecurrenceInput } from "@/lib/validation/scheduled-todo";
/** One calendar occurrence of a ScheduledTodo -- a recurring to-do produces
* many of these, one per matching date, each independently completable. */
export interface ScheduledOccurrenceDTO {
scheduledTodoId: string;
title: string;
details: string | null;
// "YYYY-MM-DD"
occurrenceDate: string;
completed: boolean;
isRecurring: boolean;
}
export interface ScheduledDayDTO {
// "YYYY-MM-DD"
date: string;
occurrences: ScheduledOccurrenceDTO[];
}
/** Shape the Scheduled panel renders directly. */
export interface ScheduledBoardDTO {
// Missed occurrences from the last 30 days, oldest first. Empty when
// there's nothing outstanding -- the panel renders nothing for it then.
overdue: ScheduledOccurrenceDTO[];
today: ScheduledDayDTO;
// Tomorrow through Sunday of the current week, always one entry per day
// (even if empty) so the panel can render every remaining day's header.
upcoming: ScheduledDayDTO[];
}
/** The raw to-do, for the edit dialog -- distinct from ScheduledOccurrenceDTO,
* which is one date's occurrence rather than the whole series. */
export interface ScheduledTodoEditDTO {
id: string;
title: string;
details: string | null;
// "YYYY-MM-DD"
startDate: string;
recurrence: RecurrenceInput | null;
}