ubiquitous-invention/apps/web/app/(app)/[workspaceSlug]/planner/page.tsx
Randall Stillwell 663bc77afe feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:

App
- New routes: ai, forms, planner, settings (templates/types), teams,
  doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
  builder/renderer/responses, types manager, objects creation dialog,
  card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
  health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in

Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
  (uses CT 102 shared services), removes host port mappings, adds
  Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
  metadata title flipped to ECHODO

Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
  credentials.*, *.key, *.crt, *.pem, ssh keys

Made-with: Cursor
2026-04-26 14:34:34 -05:00

251 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useMemo } from "react";
import { useParams } from "next/navigation";
import type { inferRouterOutputs } from "@trpc/server";
import { Calendar } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { api } from "@/lib/trpc";
import type { AppRouter } from "@/server/root";
import { cn } from "@/lib/utils";
type ListObject = inferRouterOutputs<AppRouter>["objects"]["list"]["objects"][number];
type PlannerTask = ListObject & {
dueDate?: unknown;
properties?: unknown;
};
function readDueDateString(task: PlannerTask): string | null {
if (typeof task.dueDate === "string" && task.dueDate.trim()) {
return task.dueDate.trim();
}
const props = task.properties;
if (props && typeof props === "object" && props !== null) {
const v = (props as Record<string, unknown>).dueDate;
if (typeof v === "string" && v.trim()) return v.trim();
}
const content = task.content;
if (content && typeof content === "object" && content !== null) {
const v = (content as Record<string, unknown>).dueDate;
if (typeof v === "string" && v.trim()) return v.trim();
}
return null;
}
function toLocalDateKey(iso: string): string | null {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function formatGroupHeading(dateKey: string): string {
const [y, mo, da] = dateKey.split("-").map(Number);
const date = new Date(y, mo - 1, da);
return date.toLocaleDateString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
}
function formatWeekRangeLabel(dateKey: string): string {
const [y, mo, da] = dateKey.split("-").map(Number);
const start = new Date(y, mo - 1, da);
const day = start.getDay();
const diff = start.getDate() - day + (day === 0 ? -6 : 1);
const weekStart = new Date(start);
weekStart.setDate(diff);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
const opts: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" };
const a = weekStart.toLocaleDateString(undefined, opts);
const b = weekEnd.toLocaleDateString(undefined, {
...opts,
year: weekEnd.getFullYear() !== weekStart.getFullYear() ? "numeric" : undefined,
});
return `Week of ${a} ${b}`;
}
const NO_DATE_KEY = "__no_date__";
function statusLabel(status: string | null | undefined): string {
switch (status) {
case "in_progress":
return "In progress";
case "done":
return "Done";
case "closed":
return "Closed";
case "open":
default:
return "Open";
}
}
function StatusBadge({ status }: { status: string | null | undefined }) {
const s = status ?? "open";
if (s === "in_progress") {
return (
<Badge
className={cn(
"shrink-0 border-transparent bg-blue-500/15 font-medium text-blue-700",
"hover:bg-blue-500/20 dark:text-blue-300",
)}
>
{statusLabel(s)}
</Badge>
);
}
if (s === "done") {
return (
<Badge
className={cn(
"shrink-0 border-transparent bg-green-500/15 font-medium text-green-700",
"hover:bg-green-500/20 dark:text-green-300",
)}
>
{statusLabel(s)}
</Badge>
);
}
return (
<Badge variant="outline" className="shrink-0 font-medium">
{statusLabel(s)}
</Badge>
);
}
export default function PlannerPage() {
const params = useParams();
const workspaceSlug =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
const listQuery = api.objects.list.useQuery(
{ workspaceId: workspaceSlug!, type: "task", limit: 200 },
{ enabled: Boolean(workspaceSlug) },
);
const grouped = useMemo(() => {
const rows = (listQuery.data?.objects ?? []) as PlannerTask[];
const map = new Map<string, PlannerTask[]>();
for (const task of rows) {
const raw = readDueDateString(task);
const key = raw ? toLocalDateKey(raw) : null;
const groupKey = key ?? NO_DATE_KEY;
const list = map.get(groupKey) ?? [];
list.push(task);
map.set(groupKey, list);
}
const keys = [...map.keys()].sort((a, b) => {
if (a === NO_DATE_KEY) return 1;
if (b === NO_DATE_KEY) return -1;
return a.localeCompare(b);
});
for (const k of keys) {
const list = map.get(k)!;
list.sort((a, b) => a.title.localeCompare(b.title));
}
return { keys, map };
}, [listQuery.data?.objects]);
const taskCount = listQuery.data?.objects?.length ?? 0;
const isEmpty =
Boolean(workspaceSlug) && !listQuery.isLoading && taskCount === 0;
if (!workspaceSlug) {
return (
<div className="mx-auto max-w-3xl px-8 py-10">
<p className="text-sm text-muted-foreground">No workspace selected.</p>
</div>
);
}
return (
<div className="mx-auto flex h-full max-w-3xl flex-col px-8 py-10">
<header className="flex flex-wrap items-start gap-4 border-b border-border pb-6">
<div
className={cn(
"flex size-11 shrink-0 items-center justify-center rounded-xl",
"border border-border bg-muted/40 text-muted-foreground",
)}
>
<Calendar className="size-5" strokeWidth={1.75} />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">Planner</h1>
<Badge variant="secondary" className="font-normal text-muted-foreground">
Calendar view coming soon
</Badge>
</div>
</div>
</header>
{listQuery.isLoading ? (
<p className="mt-8 text-sm text-muted-foreground">Loading tasks</p>
) : isEmpty ? (
<div
className={cn(
"mt-8 rounded-lg border border-dashed border-border bg-muted/30",
"p-12 text-center text-sm text-muted-foreground",
)}
>
No tasks in this workspace yet. Create tasks in a space to see them here.
</div>
) : (
<ScrollArea className="mt-6 min-h-[min(480px,calc(100vh-14rem))] flex-1 pr-3">
<div className="space-y-8 pb-6">
{grouped.keys.map((key) => {
const tasks = grouped.map.get(key)!;
const isNoDate = key === NO_DATE_KEY;
return (
<section key={key} className="space-y-3">
<div>
<h2 className="text-sm font-semibold tracking-tight text-foreground">
{isNoDate ? "No date" : formatGroupHeading(key)}
</h2>
{!isNoDate ? (
<p className="text-xs text-muted-foreground">
{formatWeekRangeLabel(key)}
</p>
) : null}
</div>
<ul
className={cn(
"divide-y divide-border overflow-hidden rounded-lg border border-border",
"bg-card shadow-sm",
)}
>
{tasks.map((task) => (
<li
key={task.id}
className="flex items-center gap-3 px-4 py-3 text-sm"
>
<span className="min-w-0 flex-1 truncate font-medium">
{task.title || "Untitled"}
</span>
<StatusBadge status={task.status} />
</li>
))}
</ul>
</section>
);
})}
</div>
</ScrollArea>
)}
</div>
);
}