Add 3D flight world, board view switcher, and quick-add to-dos

3D scene:
- Build a procedural world from seeded waypoint segments (per-segment
  step/turn/altitude params) joined into kink-free Catmull-Rom curves,
  using ghost points so boundaries stay smooth; world and textures are
  derived from the session seed for consistency.
- Carve the flight corridor out of the terrain (gaussian dip plus a
  height floor 32u below the path) and add city districts with instanced
  buildings driven by seeded glowing-window textures, plus tunnel
  segments rendered as textured tubes with light strips.

Board UI:
- Add a ViewSwitcher to the kanban header and a board view provider.
- Compact group cards place an inline "+" on the last visible to-do row
  (shrink-0, so it sits at the row's right edge) that opens the new
  TodoCreateDialog, alongside the existing AI-assisted creation flow.

Scheduled panel:
- Collapsed rail replaces the corner overdue dot with badges stacked
  below the calendar icon: overdue (destructive, ping ring) and
  due-today (neutral, calmer pulse). Today's count is a separate filter
  that excludes already-completed occurrences, unlike the overdue count.
This commit is contained in:
Brian Fertig 2026-08-19 09:23:05 -06:00
parent 6cfd244b41
commit 171ec92b74
8 changed files with 397 additions and 51 deletions

View File

@ -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
<SideNavProvider>
<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>
<BoardViewProvider>
<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>
</BoardViewProvider>
</ProjectsProvider>
</ScheduledPanelProvider>
</SideNavProvider>

View File

@ -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<BoardViewContextValue | null>(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<BoardView>("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 (
<BoardViewContext.Provider value={{ view, setView }}>
{children}
</BoardViewContext.Provider>
);
}
export function useBoardView() {
const ctx = useContext(BoardViewContext);
if (!ctx) throw new Error("useBoardView must be used within a BoardViewProvider");
return ctx;
}

View File

@ -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 }) {
)}
>
<SortableContext items={groupIds} strategy={verticalListSortingStrategy}>
{category.groups.map((group) => (
{visibleGroups.map((group) => (
<GroupCard key={group.id} group={group} />
))}
</SortableContext>

View File

@ -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 (
<DropdownMenu>
<DropdownMenuTrigger
render={
<button
className="flex size-6 shrink-0 items-center justify-center self-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
aria-label={`Add a to-do to ${group.title}`}
>
<Plus className="size-3.5" />
</button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onAddClick}>
<Plus className="size-4" />
Add to-do
</DropdownMenuItem>
{aiConfigured && (
<DropdownMenuItem onClick={onAiClick}>
<Sparkles className="size-4" />
Add using AI
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
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<TodoDTO | null>(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 }) {
</div>
<ul className="mt-1 space-y-1">
{group.todos.map((todo) => {
{visibleTodos.map((todo, index) => {
const todoButton = (
<button
type="button"
@ -175,6 +226,13 @@ export function GroupCard({ group }: { group: GroupDTO }) {
</button>
);
// In compact, the "+" rides along on the last visible row
// instead of taking a row of its own -- one less line per
// group. (A compact group always has at least one unchecked
// to-do -- CategoryLane hides it otherwise -- so there's always
// a last row to put it on.)
const isLastRow = compact && index === visibleTodos.length - 1;
return (
<li key={todo.id} className="flex items-start gap-2 py-0.5">
<TodoCheckbox
@ -192,44 +250,70 @@ export function GroupCard({ group }: { group: GroupDTO }) {
) : (
todoButton
)}
{isLastRow && (
<AddTodoMenu
group={group}
aiConfigured={aiConfigured}
onAddClick={() => setTodoCreateOpen(true)}
onAiClick={() => setTodoAiOpen(true)}
/>
)}
</li>
);
})}
</ul>
<div className="mt-1">
<TodoCreatePopover groupId={group.id} categoryId={group.categoryId} />
</div>
{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 && (
<div className="mt-1 flex justify-end">
<AddTodoMenu
group={group}
onAddClick={() => setTodoCreateOpen(true)}
onAiClick={() => setTodoAiOpen(true)}
aiConfigured={aiConfigured}
/>
</div>
)
) : (
<>
<div className="mt-1">
<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>
{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"
size="sm"
className="w-full justify-start gap-2 text-muted-foreground"
onClick={() => archiveGroup(group.id, group.categoryId)}
>
<Archive className="size-3.5" />
Archive
</Button>
)}
<TodoProgressPie
completed={completedCount}
total={group.todos.length}
accentColor={pieAccentColor}
/>
</>
)}
{canArchive && (
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-muted-foreground"
onClick={() => archiveGroup(group.id, group.categoryId)}
>
<Archive className="size-3.5" />
Archive
</Button>
)}
<TodoProgressPie
completed={completedCount}
total={group.todos.length}
accentColor={pieAccentColor}
/>
</div>
{editingTodo && (
@ -246,6 +330,13 @@ export function GroupCard({ group }: { group: GroupDTO }) {
<TodoAiDialog group={group} open={todoAiOpen} onOpenChange={setTodoAiOpen} />
<TodoCreateDialog
groupId={group.id}
categoryId={group.categoryId}
open={todoCreateOpen}
onOpenChange={setTodoCreateOpen}
/>
<StatusUpdateDialog group={group} open={statusUpdateOpen} onOpenChange={setStatusUpdateOpen} />
<ConfirmDeleteDialog

View File

@ -19,6 +19,7 @@ import { CategoryLane } from "@/components/board/category-lane";
import { AddCategoryLane } from "@/components/board/add-category-lane";
import { GroupCardOverlay } from "@/components/board/group-card-overlay";
import { EmptyState } from "@/components/board/empty-state";
import { ViewSwitcher } from "@/components/board/view-switcher";
import type { CategoryDTO, GroupDTO } from "@/types/board";
function Board({ title }: { title: string }) {
@ -115,7 +116,10 @@ function Board({ title }: { title: string }) {
onDragEnd={handleDragEnd}
>
<div className="flex h-full flex-col gap-4 p-4">
<h1 className="truncate px-1 text-2xl font-bold">{title}</h1>
<div className="flex items-center justify-between gap-2 px-1">
<h1 className="truncate text-2xl font-bold">{title}</h1>
<ViewSwitcher />
</div>
{categories.length === 0 ? (
<div className="flex flex-1 items-center gap-4 overflow-x-auto">

View File

@ -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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-lg" data-color-mode={colorMode}>
<DialogHeader>
<DialogTitle>Add to-do</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor={`create-todo-title-${groupId}`}>Title</Label>
<Input
id={`create-todo-title-${groupId}`}
value={title}
maxLength={TITLE_MAX}
autoFocus
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Buy milk"
/>
<p className="text-right text-xs text-muted-foreground">
{title.length}/{TITLE_MAX}
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`create-todo-details-${groupId}`}>Details (optional)</Label>
<MDEditor
value={details}
onChange={(v) => setDetails(v ?? "")}
height={160}
textareaProps={{ id: `create-todo-details-${groupId}` }}
/>
</div>
<DialogFooter>
<Button type="submit" disabled={pending || !title.trim()}>
{pending ? "Adding…" : "Add to-do"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -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 (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="sm" className="gap-2 text-muted-foreground">
<Icon className="size-4" />
{current.label}
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup value={view} onValueChange={(v) => setView(v as BoardView)}>
{OPTIONS.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
<option.icon className="size-4" />
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -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 ? (
<CollapsedRail overdueCount={overdueCount} onAdd={handleAdd} onExpand={toggle} />
<CollapsedRail
overdueCount={overdueCount}
todayCount={todayCount}
onAdd={handleAdd}
onExpand={toggle}
/>
) : (
<ExpandedPanel
board={board}
@ -128,38 +137,78 @@ export function ScheduledPanel() {
function CollapsedRail({
overdueCount,
todayCount,
onAdd,
onExpand,
}: {
overdueCount: number;
todayCount: number;
onAdd: () => void;
onExpand: () => void;
}) {
return (
<>
<div className="flex flex-col items-center p-3">
<div className="flex flex-col items-center gap-1.5 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"
className="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>
<TooltipContent side="left">Scheduled to-dos</TooltipContent>
</Tooltip>
{/* 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 && (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
onClick={onExpand}
className="relative flex size-5 items-center justify-center"
aria-label={`${overdueCount} overdue`}
>
<span className="absolute inline-flex size-full animate-ping rounded-full bg-destructive opacity-75" />
<span className="relative flex size-5 items-center justify-center rounded-full bg-destructive text-[10px] font-bold text-white">
{overdueCount > 9 ? "9+" : overdueCount}
</span>
</button>
}
/>
<TooltipContent side="left">{overdueCount} overdue</TooltipContent>
</Tooltip>
)}
{/* 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 && (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
onClick={onExpand}
className="flex size-5 animate-pulse items-center justify-center rounded-full bg-white text-[10px] font-bold text-black shadow-sm ring-1 ring-border"
aria-label={`${todayCount} due today`}
>
{todayCount > 9 ? "9+" : todayCount}
</button>
}
/>
<TooltipContent side="left">{todayCount} due today</TooltipContent>
</Tooltip>
)}
</div>
<div className="flex flex-1 flex-col items-center justify-end gap-1 p-2">