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 15:34:34 -04:00
|
|
|
|
"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(
|
multi-tenancy: promote workspaces to top-level table
Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`,
which made it impossible to put a real RLS-friendly tenant boundary on the schema
or to give each workspace a stable URL slug. This commit:
- Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook).
- Migrates the 8 anchor tables (objects, workspace_members, object_type_defs,
property_definitions, templates, forms, markdown_backlog_items,
cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`,
with a hand-augmented data-copy migration that preserves IDs and slug-collision-
proofs on backfill.
- Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper
that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All
tenant-scoped routers (objects, types, properties, templates, forms, search,
ai, relations, favorites) now flow through it.
- Updates the web app to pass `workspace` slugs from the URL (or store) instead
of the old `workspaceId`, including a workspace-sync layer that rewrites
/<UUID>/... links to /<slug>/...
- Updates the MCP tools (list_objects, create_object, search_objects) and the
workspace://{handle}/tree resource to accept either a slug or UUID so existing
agents keep working.
- Adds a Create Workspace dialog and a Workspace Settings page (rename + slug
rename with redirect, owner-only archive).
Verified locally against a fresh Postgres: migration applies cleanly, slug
uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution
works in both directions, and ON DELETE CASCADE cleans up child rows in the
correct workspace only.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
|
|
|
|
{ workspace: workspaceSlug!, type: "task", limit: 200 },
|
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 15:34:34 -04:00
|
|
|
|
{ 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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|