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:
parent
088da63de2
commit
eb32aee653
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'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've said so far
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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}` : "/";
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
@ -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() };
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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` (Mon–Sun 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);
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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>;
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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)
|
||||||
|
);
|
||||||
|
|
@ -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])
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue