diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 79ea0e5..eff22f4 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -7,6 +7,7 @@ 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"; +import { BoardViewProvider } from "@/components/board/board-view-provider"; export default async function AppLayout({ children }: { children: React.ReactNode }) { const session = await auth(); @@ -26,11 +27,13 @@ export default async function AppLayout({ children }: { children: React.ReactNod -
- -
{children}
- -
+ +
+ +
{children}
+ +
+
diff --git a/components/board/board-view-provider.tsx b/components/board/board-view-provider.tsx new file mode 100644 index 0000000..6991168 --- /dev/null +++ b/components/board/board-view-provider.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { createContext, useContext, useEffect, useState } from "react"; + +const STORAGE_KEY = "organize:board-view"; + +// A plain string union rather than an enum -- adding a future view is just +// one more literal here plus one more radio item in ViewSwitcher. +export type BoardView = "default" | "compact"; + +interface BoardViewContextValue { + view: BoardView; + setView: (view: BoardView) => void; +} + +const BoardViewContext = createContext(null); + +export function BoardViewProvider({ children }: { children: React.ReactNode }) { + // Default to "default" on both server and first client render to avoid a + // hydration mismatch; the real persisted value is applied right after + // mount, trading a one-frame flash for zero hydration warnings. + const [view, setViewState] = useState("default"); + + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === "default" || stored === "compact") setViewState(stored); + }, []); + + function setView(next: BoardView) { + setViewState(next); + localStorage.setItem(STORAGE_KEY, next); + } + + return ( + + {children} + + ); +} + +export function useBoardView() { + const ctx = useContext(BoardViewContext); + if (!ctx) throw new Error("useBoardView must be used within a BoardViewProvider"); + return ctx; +} diff --git a/components/board/category-lane.tsx b/components/board/category-lane.tsx index 095b748..59c04a6 100644 --- a/components/board/category-lane.tsx +++ b/components/board/category-lane.tsx @@ -18,13 +18,23 @@ import { GroupCard } from "@/components/board/group-card"; import { AddGroupPopover } from "@/components/board/add-group-popover"; import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog"; import { useBoard } from "@/components/board/board-context"; +import { useBoardView } from "@/components/board/board-view-provider"; import type { CategoryDTO } from "@/types/board"; export function CategoryLane({ category }: { category: CategoryDTO }) { const { removeCategory } = useBoard(); + const { view } = useBoardView(); const [confirmOpen, setConfirmOpen] = useState(false); const isEmpty = category.groups.length === 0; + // Compact hides any group with no unchecked to-dos left -- which also + // covers a brand-new group with no to-dos at all, since it has zero + // unchecked ones too. + const visibleGroups = + view === "compact" + ? category.groups.filter((g) => g.todos.some((t) => !t.completed)) + : category.groups; + // The lane itself is sortable (so lanes can be reordered), which // registers its *entire* rect -- header, cards, empty space, all of it -- // as a droppable. Dragging a group card over empty lane space then had @@ -64,7 +74,7 @@ export function CategoryLane({ category }: { category: CategoryDTO }) { data: { type: "category-dropzone", categoryId: category.id }, }); - const groupIds = category.groups.map((g) => g.id); + const groupIds = visibleGroups.map((g) => g.id); return ( <> @@ -124,7 +134,7 @@ export function CategoryLane({ category }: { category: CategoryDTO }) { )} > - {category.groups.map((group) => ( + {visibleGroups.map((group) => ( ))} diff --git a/components/board/group-card.tsx b/components/board/group-card.tsx index 60a4368..7858017 100644 --- a/components/board/group-card.tsx +++ b/components/board/group-card.tsx @@ -10,6 +10,7 @@ import { GripVertical, MoreVertical, Pencil, + Plus, Sparkles, StickyNote, Trash2, @@ -28,8 +29,10 @@ import { } from "@/components/ui/dropdown-menu"; import { getBrighterColor, getComplementaryColor, getGroupColor } from "@/lib/colors"; import { useBoard } from "@/components/board/board-context"; +import { useBoardView } from "@/components/board/board-view-provider"; import { NotesDialog } from "@/components/board/notes-dialog"; import { TodoAiDialog } from "@/components/board/todo-ai-dialog"; +import { TodoCreateDialog } from "@/components/board/todo-create-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"; @@ -38,13 +41,58 @@ import { StatusUpdateDialog } from "@/components/board/status-update-dialog"; import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog"; import type { GroupDTO, TodoDTO } from "@/types/board"; +// Compact's "+" -- sits inline on the last visible to-do row (its sibling +// text button is flex-1, so this shrink-0 trigger naturally lands at the +// row's right edge) instead of on a row of its own. +function AddTodoMenu({ + group, + aiConfigured, + onAddClick, + onAiClick, +}: { + group: GroupDTO; + aiConfigured: boolean; + onAddClick: () => void; + onAiClick: () => void; +}) { + return ( + + + + + } + /> + + + + Add to-do + + {aiConfigured && ( + + + Add using AI + + )} + + + ); +} + export function GroupCard({ group }: { group: GroupDTO }) { const { toggleTodoDone, removeGroup, archiveGroup, aiConfigured } = useBoard(); + const { view } = useBoardView(); + const compact = view === "compact"; const { resolvedTheme } = useTheme(); const [editingTodo, setEditingTodo] = useState(null); const [editOpen, setEditOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false); const [todoAiOpen, setTodoAiOpen] = useState(false); + const [todoCreateOpen, setTodoCreateOpen] = useState(false); const [statusUpdateOpen, setStatusUpdateOpen] = useState(false); const { @@ -91,6 +139,9 @@ export function GroupCard({ group }: { group: GroupDTO }) { // with no to-dos yet, or with any still open, isn't "finished" yet. const canArchive = group.todos.length > 0 && group.todos.every((t) => t.completed); const completedCount = group.todos.filter((t) => t.completed).length; + // Compact only ever surfaces open work -- checked-off todos are dropped + // from the list entirely rather than shown crossed-out. + const visibleTodos = compact ? group.todos.filter((t) => !t.completed) : group.todos; return ( <> @@ -157,7 +208,7 @@ export function GroupCard({ group }: { group: GroupDTO }) {
    - {group.todos.map((todo) => { + {visibleTodos.map((todo, index) => { const todoButton = (
-
- -
+ {compact ? ( + // Defensive fallback -- shouldn't normally happen, since + // CategoryLane already hides a compact group with zero visible + // to-dos, but keeps the "+" reachable if that ever changes. + visibleTodos.length === 0 && ( +
+ setTodoCreateOpen(true)} + onAiClick={() => setTodoAiOpen(true)} + aiConfigured={aiConfigured} + /> +
+ ) + ) : ( + <> +
+ +
- {aiConfigured && ( - + {aiConfigured && ( + + )} + + {canArchive && ( + + )} + + + )} - - {canArchive && ( - - )} - - {editingTodo && ( @@ -246,6 +330,13 @@ export function GroupCard({ group }: { group: GroupDTO }) { + +
-

{title}

+
+

{title}

+ +
{categories.length === 0 ? (
diff --git a/components/board/todo-create-dialog.tsx b/components/board/todo-create-dialog.tsx new file mode 100644 index 0000000..c659fdd --- /dev/null +++ b/components/board/todo-create-dialog.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useState } from "react"; +import { useTheme } from "next-themes"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { MDEditor } from "@/components/markdown/markdown-widgets"; +import { useBoard } from "@/components/board/board-context"; + +const TITLE_MAX = 20; + +// Same fields and `addTodo` call as TodoCreatePopover's inline form, but as +// a controlled Dialog instead of owning its own popover trigger -- this is +// opened from the compact view's "+" dropdown menu rather than from an +// inline "+ Add to-do" button. +export function TodoCreateDialog({ + groupId, + categoryId, + open, + onOpenChange, +}: { + groupId: string; + categoryId: string; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { addTodo } = useBoard(); + const { resolvedTheme } = useTheme(); + const colorMode = resolvedTheme === "dark" ? "dark" : "light"; + + const [title, setTitle] = useState(""); + const [details, setDetails] = useState(""); + const [pending, setPending] = useState(false); + + function handleOpenChange(next: boolean) { + if (!next) { + setTitle(""); + setDetails(""); + } + onOpenChange(next); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const trimmed = title.trim(); + if (!trimmed) return; + setPending(true); + const ok = await addTodo(groupId, categoryId, trimmed, details.trim() || undefined); + setPending(false); + if (ok) handleOpenChange(false); + } + + return ( + + + + Add to-do + +
+
+ + setTitle(e.target.value)} + placeholder="e.g. Buy milk" + /> +

+ {title.length}/{TITLE_MAX} +

+
+
+ + setDetails(v ?? "")} + height={160} + textareaProps={{ id: `create-todo-details-${groupId}` }} + /> +
+ + + +
+
+
+ ); +} diff --git a/components/board/view-switcher.tsx b/components/board/view-switcher.tsx new file mode 100644 index 0000000..1b1829e --- /dev/null +++ b/components/board/view-switcher.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { LayoutList, Rows3 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useBoardView, type BoardView } from "@/components/board/board-view-provider"; + +// New views just need one more entry here (and one more BoardView literal) +// -- no redesign of the trigger or menu required. +const OPTIONS: { value: BoardView; label: string; icon: typeof LayoutList }[] = [ + { value: "default", label: "Default", icon: LayoutList }, + { value: "compact", label: "Compact", icon: Rows3 }, +]; + +export function ViewSwitcher() { + const { view, setView } = useBoardView(); + const current = OPTIONS.find((o) => o.value === view) ?? OPTIONS[0]; + const Icon = current.icon; + + return ( + + + + {current.label} + + } + /> + + setView(v as BoardView)}> + {OPTIONS.map((option) => ( + + + {option.label} + + ))} + + + + ); +} diff --git a/components/scheduled/scheduled-panel.tsx b/components/scheduled/scheduled-panel.tsx index 94e6162..e1fc504 100644 --- a/components/scheduled/scheduled-panel.tsx +++ b/components/scheduled/scheduled-panel.tsx @@ -92,6 +92,10 @@ export function ScheduledPanel() { } const overdueCount = board?.overdue.length ?? 0; + // Today's occurrences list includes already-checked-off ones (so they can + // still be toggled back), unlike overdue -- so this needs its own filter + // rather than just `board.today.occurrences.length`. + const todayCount = board?.today.occurrences.filter((o) => !o.completed).length ?? 0; return ( <> @@ -102,7 +106,12 @@ export function ScheduledPanel() { )} > {collapsed ? ( - + ) : ( void; onExpand: () => void; }) { return ( <> -
+
- {overdueCount > 0 && ( - - {overdueCount > 9 ? "9+" : overdueCount} - - )} } /> - - {overdueCount > 0 ? `${overdueCount} overdue` : "Scheduled to-dos"} - + Scheduled to-dos + + {/* Sits right below the calendar icon rather than as a corner + overlay -- a ping ring behind the solid circle makes overdue + work as loud/eye-catching as possible. */} + {overdueCount > 0 && ( + + + + + {overdueCount > 9 ? "9+" : overdueCount} + + + } + /> + {overdueCount} overdue + + )} + + {/* Below the overdue circle when both are present, otherwise right + below the calendar icon -- a plain opacity pulse (no ping ring) + keeps it visibly calmer than overdue's. */} + {todayCount > 0 && ( + + + {todayCount > 9 ? "9+" : todayCount} + + } + /> + {todayCount} due today + + )}