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 { SideNav } from "@/components/nav/side-nav";
|
||||
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 }) {
|
||||
const session = await auth();
|
||||
|
|
@ -22,12 +24,15 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
|||
|
||||
return (
|
||||
<SideNavProvider>
|
||||
<ProjectsProvider initialProjects={projects}>
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<SideNav userEmail={session.user.email ?? ""} role={session.user.role} />
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
</div>
|
||||
</ProjectsProvider>
|
||||
<ScheduledPanelProvider>
|
||||
<ProjectsProvider initialProjects={projects}>
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<SideNav userEmail={session.user.email ?? ""} role={session.user.role} />
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
<ScheduledPanel />
|
||||
</div>
|
||||
</ProjectsProvider>
|
||||
</ScheduledPanelProvider>
|
||||
</SideNavProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,18 @@ import { redirect } from "next/navigation";
|
|||
|
||||
import { auth } from "@/auth";
|
||||
import { getBoard } from "@/lib/board";
|
||||
import { getAiSettingsView } from "@/lib/ai-settings";
|
||||
import { KanbanBoard } from "@/components/board/kanban-board";
|
||||
|
||||
export default async function HomePage() {
|
||||
const session = await auth();
|
||||
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 { projectAccessFilter } from "@/lib/access";
|
||||
import { getBoard } from "@/lib/board";
|
||||
import { getAiSettingsView } from "@/lib/ai-settings";
|
||||
import { KanbanBoard } from "@/components/board/kanban-board";
|
||||
|
||||
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".
|
||||
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";
|
||||
import {
|
||||
createTodo,
|
||||
createTodos,
|
||||
updateTodo as updateTodoAction,
|
||||
toggleTodo as toggleTodoAction,
|
||||
deleteTodo as deleteTodoAction,
|
||||
|
|
@ -27,6 +28,9 @@ import { updateGroupNote } from "@/lib/actions/notes";
|
|||
|
||||
interface BoardContextValue {
|
||||
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>;
|
||||
removeCategory: (categoryId: string) => Promise<void>;
|
||||
reorderLanes: (orderedIds: string[]) => Promise<void>;
|
||||
|
|
@ -53,6 +57,11 @@ interface BoardContextValue {
|
|||
title: string,
|
||||
details?: string
|
||||
) => Promise<boolean>;
|
||||
addTodos: (
|
||||
groupId: string,
|
||||
categoryId: string,
|
||||
todos: { title: string; details?: string }[]
|
||||
) => Promise<boolean>;
|
||||
editTodo: (
|
||||
todoId: string,
|
||||
groupId: string,
|
||||
|
|
@ -73,6 +82,7 @@ const BoardContext = createContext<BoardContextValue | null>(null);
|
|||
export function BoardProvider({
|
||||
initialCategories,
|
||||
projectId,
|
||||
aiConfigured,
|
||||
children,
|
||||
}: {
|
||||
initialCategories: CategoryDTO[];
|
||||
|
|
@ -81,6 +91,7 @@ export function BoardProvider({
|
|||
// on are the two that operate over "all of this board's categories"
|
||||
// rather than a specific existing category/group/todo id.
|
||||
projectId?: string;
|
||||
aiConfigured: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [categories, setCategories] = useState(initialCategories);
|
||||
|
|
@ -299,6 +310,20 @@ export function BoardProvider({
|
|||
[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(
|
||||
async (
|
||||
todoId: string,
|
||||
|
|
@ -379,6 +404,7 @@ export function BoardProvider({
|
|||
<BoardContext.Provider
|
||||
value={{
|
||||
categories,
|
||||
aiConfigured,
|
||||
addCategory,
|
||||
removeCategory,
|
||||
reorderLanes,
|
||||
|
|
@ -390,6 +416,7 @@ export function BoardProvider({
|
|||
moveGroup,
|
||||
saveNote,
|
||||
addTodo,
|
||||
addTodos,
|
||||
editTodo,
|
||||
toggleTodoDone,
|
||||
removeTodo,
|
||||
|
|
|
|||
|
|
@ -25,10 +25,10 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} 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 { 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 { TodoEditDialog } from "@/components/board/todo-edit-dialog";
|
||||
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";
|
||||
|
||||
export function GroupCard({ group }: { group: GroupDTO }) {
|
||||
const { toggleTodoDone, removeGroup, archiveGroup } = useBoard();
|
||||
const { toggleTodoDone, removeGroup, archiveGroup, aiConfigured } = useBoard();
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [editingTodo, setEditingTodo] = useState<TodoDTO | null>(null);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [aiOpen, setAiOpen] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [todoAiOpen, setTodoAiOpen] = useState(false);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
|
|
@ -79,6 +79,10 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
|||
isDark ? color.dark : color.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
|
||||
// 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" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setAiOpen(true)}>
|
||||
<Sparkles className="size-4" />
|
||||
Ask AI
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onClick={() => setConfirmOpen(true)}>
|
||||
<Trash2 className="size-4" />
|
||||
|
|
@ -153,6 +153,7 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingTodo(todo)}
|
||||
style={todo.completed ? undefined : { color: todoTextColor }}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-center gap-1 text-left text-sm hover:underline",
|
||||
todo.completed && "text-muted-foreground line-through"
|
||||
|
|
@ -191,6 +192,18 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
|||
<TodoCreatePopover groupId={group.id} categoryId={group.categoryId} />
|
||||
</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 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -222,7 +235,7 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
|||
|
||||
<EditGroupDialog group={group} open={editOpen} onOpenChange={setEditOpen} />
|
||||
|
||||
<GroupAiDialog group={group} open={aiOpen} onOpenChange={setAiOpen} />
|
||||
<TodoAiDialog group={group} open={todoAiOpen} onOpenChange={setTodoAiOpen} />
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
open={confirmOpen}
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ export function KanbanBoard({
|
|||
initialCategories,
|
||||
projectId,
|
||||
title = "Home",
|
||||
aiConfigured,
|
||||
}: {
|
||||
initialCategories: CategoryDTO[];
|
||||
// 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.
|
||||
projectId?: 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 (
|
||||
<BoardProvider initialCategories={initialCategories} projectId={projectId}>
|
||||
<BoardProvider
|
||||
initialCategories={initialCategories}
|
||||
projectId={projectId}
|
||||
aiConfigured={aiConfigured}
|
||||
>
|
||||
<Board title={title} />
|
||||
</BoardProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useState } from "react";
|
||||
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 {
|
||||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from "@/components/ui/dialog";
|
||||
import { MDEditor, MarkdownPreview } from "@/components/markdown/markdown-widgets";
|
||||
import { useBoard } from "@/components/board/board-context";
|
||||
import { GroupAiDialog } from "@/components/board/group-ai-dialog";
|
||||
import type { GroupDTO } from "@/types/board";
|
||||
|
||||
export function NotesDialog({ group }: { group: GroupDTO }) {
|
||||
|
|
@ -25,6 +26,7 @@ export function NotesDialog({ group }: { group: GroupDTO }) {
|
|||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(group.noteContent);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [aiOpen, setAiOpen] = useState(false);
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
setOpen(next);
|
||||
|
|
@ -42,51 +44,61 @@ export function NotesDialog({ group }: { group: GroupDTO }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
aria-label={`Notes for ${group.title}`}
|
||||
onClick={() => handleOpenChange(true)}
|
||||
>
|
||||
<NotebookPen className="size-4" />
|
||||
</Button>
|
||||
<DialogContent className="sm:max-w-2xl" data-color-mode={colorMode}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{group.title} — Notes</DialogTitle>
|
||||
</DialogHeader>
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
aria-label={`Notes for ${group.title}`}
|
||||
onClick={() => handleOpenChange(true)}
|
||||
>
|
||||
<NotebookPen className="size-4" />
|
||||
</Button>
|
||||
<DialogContent className="sm:max-w-2xl" data-color-mode={colorMode}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{group.title} — Notes</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{editing ? (
|
||||
<MDEditor value={draft} onChange={(v) => setDraft(v ?? "")} height={360} />
|
||||
) : draft ? (
|
||||
<div className="max-h-[60vh] overflow-y-auto rounded-md border p-4">
|
||||
<MarkdownPreview source={draft} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
|
||||
No notes yet. Click Edit to add some.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{editing ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setEditing(false)} disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={pending}>
|
||||
{pending ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</>
|
||||
<MDEditor value={draft} onChange={(v) => setDraft(v ?? "")} height={360} />
|
||||
) : draft ? (
|
||||
<div className="max-h-[60vh] overflow-y-auto rounded-md border p-4">
|
||||
<MarkdownPreview source={draft} />
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => setEditing(true)} className="gap-2">
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<p className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
|
||||
No notes yet. Click Edit to add some.
|
||||
</p>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DialogFooter>
|
||||
{editing ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setEditing(false)} disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={pending}>
|
||||
{pending ? "Saving…" : "Save"}
|
||||
</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">
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</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) => {
|
||||
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 =
|
||||
errorType === "not-allowed" || errorType === "service-not-allowed"
|
||||
? "Microphone access was blocked."
|
||||
|
|
@ -121,7 +126,14 @@ export function useSpeechRecognition({
|
|||
? "No microphone was found."
|
||||
: errorType === "no-speech"
|
||||
? 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 = () => {
|
||||
if (userStoppedRef.current) {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,15 @@ export function projectAccessFilter(userId: string) {
|
|||
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. */
|
||||
export function boardPath(projectId: string | null | undefined): string {
|
||||
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(
|
||||
todoId: string,
|
||||
data: { title?: string; details?: string | null }
|
||||
|
|
|
|||
|
|
@ -131,3 +131,32 @@ export function getComplementaryColor(hex: string, variant: "light" | "dark"): s
|
|||
complementaryCache.set(cacheKey, 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",
|
||||
});
|
||||
}
|
||||
|
||||
/** 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-dom": "19.2.8",
|
||||
"react-hook-form": "^7.85.0",
|
||||
"rrule": "^2.8.1",
|
||||
"server-only": "^0.0.1",
|
||||
"shadcn": "^4.16.2",
|
||||
"sharp": "^0.35.3",
|
||||
|
|
@ -12248,6 +12249,15 @@
|
|||
"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": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-hook-form": "^7.85.0",
|
||||
"rrule": "^2.8.1",
|
||||
"server-only": "^0.0.1",
|
||||
"shadcn": "^4.16.2",
|
||||
"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)
|
||||
);
|
||||
|
|
@ -43,8 +43,9 @@ model User {
|
|||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
categories Category[]
|
||||
projects Project[] @relation("ProjectOwner")
|
||||
categories Category[]
|
||||
projects Project[] @relation("ProjectOwner")
|
||||
scheduledTodos ScheduledTodo[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -64,8 +65,9 @@ model Project {
|
|||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
owner User @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
categories Category[]
|
||||
owner User @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
categories Category[]
|
||||
scheduledTodos ScheduledTodo[]
|
||||
|
||||
@@index([ownerId, createdAt])
|
||||
}
|
||||
|
|
@ -168,3 +170,54 @@ model Todo {
|
|||
|
||||
@@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