diff --git a/apps/web/src/components/group-form-drawer.tsx b/apps/web/src/components/group-form-drawer.tsx
new file mode 100644
index 0000000..b4ecc7d
--- /dev/null
+++ b/apps/web/src/components/group-form-drawer.tsx
@@ -0,0 +1,100 @@
+import { useState } from "react";
+import { useForm } from "@tanstack/react-form";
+import { useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+
+import { createTimelineGroup } from "@/functions/create-timeline-group";
+import { Button } from "./ui/button";
+import { FieldControl, FieldError, FieldLabel, FieldRoot } from "./ui/field";
+import {
+ DrawerRoot,
+ DrawerTrigger,
+ DrawerPortal,
+ DrawerViewport,
+ DrawerPopup,
+ DrawerTitle,
+ DrawerContent,
+ DrawerClose,
+} from "./ui/drawer";
+
+export default function GroupFormDrawer({ timelineId }: { timelineId: string }) {
+ const [open, setOpen] = useState(false);
+ const queryClient = useQueryClient();
+
+ const form = useForm({
+ defaultValues: {
+ title: "",
+ },
+ onSubmit: async ({ value }) => {
+ try {
+ await createTimelineGroup({
+ data: { title: value.title, timelineId },
+ });
+ await queryClient.invalidateQueries({ queryKey: ["timeline", timelineId] });
+ setOpen(false);
+ form.reset();
+ toast.success("Group created");
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to create group");
+ }
+ },
+ });
+
+ return (
+
+ }>
+ Add Group
+
+
+
+
+
+ New Group
+ }>
+ Close
+
+
+
+
+ {(field) => (
+
+ Title
+
+ field.handleChange((e.target as HTMLInputElement).value)
+ }
+ />
+ Title is required
+
+ )}
+
+
+ {(state) => (
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/item-form-drawer.tsx b/apps/web/src/components/item-form-drawer.tsx
new file mode 100644
index 0000000..fb0dde2
--- /dev/null
+++ b/apps/web/src/components/item-form-drawer.tsx
@@ -0,0 +1,166 @@
+import { useState } from "react";
+import { useForm } from "@tanstack/react-form";
+import { useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+
+import { createTimelineItem } from "@/functions/create-timeline-item";
+import { Button } from "./ui/button";
+import { FieldControl, FieldError, FieldLabel, FieldRoot } from "./ui/field";
+import {
+ DrawerRoot,
+ DrawerTrigger,
+ DrawerPortal,
+ DrawerViewport,
+ DrawerPopup,
+ DrawerTitle,
+ DrawerContent,
+ DrawerClose,
+} from "./ui/drawer";
+
+export default function ItemFormDrawer({
+ timelineGroupId,
+ timelineId,
+}: {
+ timelineGroupId: string;
+ timelineId: string;
+}) {
+ const [open, setOpen] = useState(false);
+ const queryClient = useQueryClient();
+
+ const form = useForm({
+ defaultValues: {
+ title: "",
+ description: "",
+ start: new Date().toISOString().split("T")[0],
+ end: "",
+ },
+ onSubmit: async ({ value }) => {
+ try {
+ await createTimelineItem({
+ data: {
+ title: value.title,
+ description: value.description,
+ start: value.start,
+ end: value.end || undefined,
+ timelineGroupId,
+ },
+ });
+ await queryClient.invalidateQueries({ queryKey: ["timeline", timelineId] });
+ setOpen(false);
+ form.reset();
+ toast.success("Item created");
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to create item");
+ }
+ },
+ });
+
+ return (
+
+ }>
+ Add Item
+
+
+
+
+
+ New Item
+ }>
+ Close
+
+
+
+
+ {(field) => (
+
+ Title
+
+ field.handleChange((e.target as HTMLInputElement).value)
+ }
+ />
+ Title is required
+
+ )}
+
+
+
+ {(field) => (
+
+ Description
+
+ field.handleChange((e.target as HTMLInputElement).value)
+ }
+ />
+
+ )}
+
+
+
+ {(field) => (
+
+ Start date
+
+ field.handleChange((e.target as HTMLInputElement).value)
+ }
+ />
+ Start date is required
+
+ )}
+
+
+
+ {(field) => (
+
+ End date (optional)
+
+ field.handleChange((e.target as HTMLInputElement).value)
+ }
+ />
+
+ )}
+
+
+
+ {(state) => (
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/functions/create-timeline-group.ts b/apps/web/src/functions/create-timeline-group.ts
new file mode 100644
index 0000000..c55f0c2
--- /dev/null
+++ b/apps/web/src/functions/create-timeline-group.ts
@@ -0,0 +1,22 @@
+import { db } from "@zendegi/db";
+import { timelineGroup } from "@zendegi/db/schema/timeline";
+import { createServerFn } from "@tanstack/react-start";
+import { z } from "zod";
+import { authMiddleware } from "@/middleware/auth";
+
+export const createTimelineGroup = createServerFn({ method: "POST" })
+ .middleware([authMiddleware])
+ .inputValidator(
+ z.object({ title: z.string().min(1), timelineId: z.string().uuid() })
+ )
+ .handler(async ({ data }) => {
+ const [newGroup] = await db
+ .insert(timelineGroup)
+ .values({
+ title: data.title,
+ timelineId: data.timelineId,
+ })
+ .returning();
+
+ return newGroup;
+ });
diff --git a/apps/web/src/functions/create-timeline-item.ts b/apps/web/src/functions/create-timeline-item.ts
new file mode 100644
index 0000000..bb6cfd2
--- /dev/null
+++ b/apps/web/src/functions/create-timeline-item.ts
@@ -0,0 +1,34 @@
+import { db } from "@zendegi/db";
+import { timelineItem } from "@zendegi/db/schema/timeline";
+import { createServerFn } from "@tanstack/react-start";
+import { z } from "zod";
+import { authMiddleware } from "@/middleware/auth";
+
+export const createTimelineItem = createServerFn({ method: "POST" })
+ .middleware([authMiddleware])
+ .inputValidator(
+ z.object({
+ title: z.string().min(1),
+ description: z.string().default(""),
+ start: z.string().transform((s) => new Date(s)),
+ end: z
+ .string()
+ .optional()
+ .transform((s) => (s ? new Date(s) : undefined)),
+ timelineGroupId: z.string().uuid(),
+ })
+ )
+ .handler(async ({ data }) => {
+ const [newItem] = await db
+ .insert(timelineItem)
+ .values({
+ title: data.title,
+ description: data.description,
+ start: data.start,
+ end: data.end,
+ timelineGroupId: data.timelineGroupId,
+ })
+ .returning();
+
+ return newItem;
+ });
diff --git a/apps/web/src/functions/create-timeline.ts b/apps/web/src/functions/create-timeline.ts
new file mode 100644
index 0000000..ac6498f
--- /dev/null
+++ b/apps/web/src/functions/create-timeline.ts
@@ -0,0 +1,20 @@
+import { db } from "@zendegi/db";
+import { timeline } from "@zendegi/db/schema/timeline";
+import { createServerFn } from "@tanstack/react-start";
+import { z } from "zod";
+import { authMiddleware } from "@/middleware/auth";
+
+export const createTimeline = createServerFn({ method: "POST" })
+ .middleware([authMiddleware])
+ .inputValidator(z.object({ title: z.string().min(1) }))
+ .handler(async ({ data, context }) => {
+ const [newTimeline] = await db
+ .insert(timeline)
+ .values({
+ title: data.title,
+ ownerId: context.session!.user.id,
+ })
+ .returning();
+
+ return newTimeline;
+ });
diff --git a/apps/web/src/functions/get-timeline.ts b/apps/web/src/functions/get-timeline.ts
new file mode 100644
index 0000000..68e637e
--- /dev/null
+++ b/apps/web/src/functions/get-timeline.ts
@@ -0,0 +1,36 @@
+import { eq } from "drizzle-orm";
+import { db } from "@zendegi/db";
+import { timeline } from "@zendegi/db/schema/timeline";
+import { createServerFn } from "@tanstack/react-start";
+import { queryOptions } from "@tanstack/react-query";
+import { z } from "zod";
+
+export const getTimeline = createServerFn({ method: "GET" })
+ .inputValidator(z.object({ id: z.string().uuid() }))
+ .handler(async ({ data }) => {
+ const result = await db.query.timeline.findFirst({
+ where: eq(timeline.id, data.id),
+ with: {
+ groups: {
+ orderBy: (g, { asc }) => [asc(g.sortOrder)],
+ with: {
+ items: {
+ orderBy: (i, { asc }) => [asc(i.start)],
+ },
+ },
+ },
+ },
+ });
+
+ if (!result) {
+ throw new Error("Timeline not found");
+ }
+
+ return result;
+ });
+
+export const timelineQueryOptions = (timelineId: string) =>
+ queryOptions({
+ queryKey: ["timeline", timelineId],
+ queryFn: () => getTimeline({ data: { id: timelineId } }),
+ });
diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx
index 2be3d9c..1b16eb4 100644
--- a/apps/web/src/routes/index.tsx
+++ b/apps/web/src/routes/index.tsx
@@ -1,10 +1,34 @@
-import { createFileRoute } from "@tanstack/react-router";
+import { createFileRoute, useNavigate } from "@tanstack/react-router";
+import { useForm } from "@tanstack/react-form";
+import { toast } from "sonner";
+
+import { authClient } from "@/lib/auth-client";
+import { createTimeline } from "@/functions/create-timeline";
+import { Button } from "@/components/ui/button";
+import { FieldControl, FieldError, FieldLabel, FieldRoot } from "@/components/ui/field";
export const Route = createFileRoute("/")({
component: HomeComponent,
});
function HomeComponent() {
+ const navigate = useNavigate();
+
+ const form = useForm({
+ defaultValues: {
+ title: "",
+ },
+ onSubmit: async ({ value }) => {
+ try {
+ await authClient.signIn.anonymous();
+ const timeline = await createTimeline({ data: { title: value.title } });
+ navigate({ to: "/timeline/$timelineId", params: { timelineId: timeline.id } });
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to create timeline");
+ }
+ },
+ });
+
return (
@@ -21,6 +45,44 @@ function HomeComponent() {
productivity, and transform how you experience your world.
+
+ {(field) => (
+
+ Timeline name
+
+ field.handleChange((e.target as HTMLInputElement).value)
+ }
+ />
+
+ Name is required
+
+
+ )}
+
+
+ {(state) => (
+
+ )}
+
+
);
diff --git a/apps/web/src/routes/timeline.$timelineId.tsx b/apps/web/src/routes/timeline.$timelineId.tsx
index 7cdb3d2..2c8c917 100644
--- a/apps/web/src/routes/timeline.$timelineId.tsx
+++ b/apps/web/src/routes/timeline.$timelineId.tsx
@@ -1,9 +1,64 @@
import { createFileRoute } from "@tanstack/react-router";
+import { useSuspenseQuery } from "@tanstack/react-query";
+import { timelineQueryOptions } from "@/functions/get-timeline";
+import GroupFormDrawer from "@/components/group-form-drawer";
+import ItemFormDrawer from "@/components/item-form-drawer";
export const Route = createFileRoute("/timeline/$timelineId")({
+ loader: async ({ context, params }) => {
+ await context.queryClient.ensureQueryData(
+ timelineQueryOptions(params.timelineId)
+ );
+ },
component: RouteComponent,
});
function RouteComponent() {
- return
Timeline detail
;
+ const { timelineId } = Route.useParams();
+ const timelineQuery = useSuspenseQuery(timelineQueryOptions(timelineId));
+ const timeline = timelineQuery.data;
+
+ return (
+
+
{timeline.title}
+
+ {timeline.groups.length === 0 && (
+
+ No groups yet. Add a group to start building your timeline.
+
+ )}
+
+
+ {timeline.groups.map((group) => (
+
+
+
{group.title}
+
+
+
+ {group.items.length === 0 && (
+
No items in this group yet.
+ )}
+
+
+
+ ))}
+
+
+
+
+ );
}
diff --git a/apps/web/src/routes/timeline.tsx b/apps/web/src/routes/timeline.tsx
index 7525046..ec869a2 100644
--- a/apps/web/src/routes/timeline.tsx
+++ b/apps/web/src/routes/timeline.tsx
@@ -5,10 +5,5 @@ export const Route = createFileRoute("/timeline")({
});
function RouteComponent() {
- return (
-
- Shell of timeline
-
-
- );
+ return ;
}
diff --git a/packages/db/src/schema/timeline.ts b/packages/db/src/schema/timeline.ts
index 7dec5c2..f582942 100644
--- a/packages/db/src/schema/timeline.ts
+++ b/packages/db/src/schema/timeline.ts
@@ -49,6 +49,17 @@ export const timelineRelations = relations(timeline, ({ many }) => ({
groups: many(timelineGroup),
}));
-export const groupRelations = relations(timelineGroup, ({ many }) => ({
+export const groupRelations = relations(timelineGroup, ({ one, many }) => ({
+ timeline: one(timeline, {
+ fields: [timelineGroup.timelineId],
+ references: [timeline.id],
+ }),
items: many(timelineItem),
}));
+
+export const itemRelations = relations(timelineItem, ({ one }) => ({
+ timelineGroup: one(timelineGroup, {
+ fields: [timelineItem.timelineGroupId],
+ references: [timelineGroup.id],
+ }),
+}));