diff --git a/apps/web/src/functions/create-timeline-item.ts b/apps/web/src/functions/create-timeline-item.ts index bb6cfd2..067a666 100644 --- a/apps/web/src/functions/create-timeline-item.ts +++ b/apps/web/src/functions/create-timeline-item.ts @@ -1,8 +1,10 @@ import { db } from "@zendegi/db"; import { timelineItem } from "@zendegi/db/schema/timeline"; +import { eq } from "drizzle-orm"; import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; import { authMiddleware } from "@/middleware/auth"; +import { assignLane } from "@/lib/assign-lane"; export const createTimelineItem = createServerFn({ method: "POST" }) .middleware([authMiddleware]) @@ -19,6 +21,12 @@ export const createTimelineItem = createServerFn({ method: "POST" }) }) ) .handler(async ({ data }) => { + const existing = await db.query.timelineItem.findMany({ + where: eq(timelineItem.timelineGroupId, data.timelineGroupId), + }); + + const lane = assignLane(existing, data.start, data.end ?? null); + const [newItem] = await db .insert(timelineItem) .values({ @@ -27,6 +35,7 @@ export const createTimelineItem = createServerFn({ method: "POST" }) start: data.start, end: data.end, timelineGroupId: data.timelineGroupId, + lane, }) .returning(); diff --git a/apps/web/src/functions/update-timeline-item.ts b/apps/web/src/functions/update-timeline-item.ts new file mode 100644 index 0000000..53834ed --- /dev/null +++ b/apps/web/src/functions/update-timeline-item.ts @@ -0,0 +1,35 @@ +import { db } from "@zendegi/db"; +import { timelineItem } from "@zendegi/db/schema/timeline"; +import { eq } from "drizzle-orm"; +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; +import { authMiddleware } from "@/middleware/auth"; + +export const updateTimelineItem = createServerFn({ method: "POST" }) + .middleware([authMiddleware]) + .inputValidator( + z.object({ + id: z.string().uuid(), + start: z.string().transform((s) => new Date(s)), + end: z + .string() + .nullable() + .transform((s) => (s ? new Date(s) : null)), + timelineGroupId: z.string().uuid(), + lane: z.number().int().min(1), + }) + ) + .handler(async ({ data }) => { + const [updated] = await db + .update(timelineItem) + .set({ + start: data.start, + end: data.end, + timelineGroupId: data.timelineGroupId, + lane: data.lane, + }) + .where(eq(timelineItem.id, data.id)) + .returning(); + + return updated; + }); diff --git a/apps/web/src/lib/assign-lane.ts b/apps/web/src/lib/assign-lane.ts new file mode 100644 index 0000000..c1a9bdd --- /dev/null +++ b/apps/web/src/lib/assign-lane.ts @@ -0,0 +1,26 @@ +/** + * Greedy lane assignment: finds the first lane (1-100) where the new item + * does not overlap any existing item in the same group. + * + * Port of the Flutter `_assignLane` algorithm so the server can compute + * lanes on item creation. + */ +export function assignLane( + existingItems: { start: Date; end: Date | null; lane: number }[], + newStart: Date, + newEnd: Date | null +): number { + const end = newEnd ?? new Date(newStart.getTime() + 24 * 60 * 60 * 1000); // default 1 day + + for (let lane = 1; lane <= 100; lane++) { + const hasConflict = existingItems.some((item) => { + if (item.lane !== lane) return false; + const itemEnd = + item.end ?? new Date(item.start.getTime() + 24 * 60 * 60 * 1000); + // Overlaps if: newStart < itemEnd && newEnd > itemStart + return newStart < itemEnd && end > item.start; + }); + if (!hasConflict) return lane; + } + return 1; +} diff --git a/apps/web/src/routes/api/mcp/$.ts b/apps/web/src/routes/api/mcp/$.ts index 52d32f0..4773536 100644 --- a/apps/web/src/routes/api/mcp/$.ts +++ b/apps/web/src/routes/api/mcp/$.ts @@ -11,6 +11,7 @@ import { } from "@zendegi/db/schema/timeline"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; +import { assignLane } from "@/lib/assign-lane"; function createMcpServer(userId: string) { const server = new McpServer({ @@ -141,14 +142,23 @@ function createMcpServer(userId: string) { isError: true, }; } + + const existing = await db.query.timelineItem.findMany({ + where: eq(timelineItem.timelineGroupId, timelineGroupId), + }); + const newStart = new Date(start); + const newEnd = end ? new Date(end) : null; + const lane = assignLane(existing, newStart, newEnd); + const [created] = await db .insert(timelineItem) .values({ title, description, - start: new Date(start), - end: end ? new Date(end) : undefined, + start: newStart, + end: newEnd ?? undefined, timelineGroupId, + lane, }) .returning(); return { @@ -173,9 +183,10 @@ function createMcpServer(userId: string) { .nullable() .optional() .describe("New end date (ISO 8601, or null to remove)"), + lane: z.number().int().min(1).optional().describe("Lane number"), }, }, - async ({ id, title, description, start, end }) => { + async ({ id, title, description, start, end, lane }) => { // Verify ownership through item → group → timeline const item = await db.query.timelineItem.findFirst({ where: eq(timelineItem.id, id), @@ -193,6 +204,7 @@ function createMcpServer(userId: string) { if (description !== undefined) updates.description = description; if (start !== undefined) updates.start = new Date(start); if (end !== undefined) updates.end = end ? new Date(end) : null; + if (lane !== undefined) updates.lane = lane; const [updated] = await db .update(timelineItem) diff --git a/apps/web/src/routes/timeline.$timelineId.tsx b/apps/web/src/routes/timeline.$timelineId.tsx index a320c41..212f9dd 100644 --- a/apps/web/src/routes/timeline.$timelineId.tsx +++ b/apps/web/src/routes/timeline.$timelineId.tsx @@ -1,7 +1,8 @@ import { useCallback, useMemo, useState } from "react"; import { createFileRoute } from "@tanstack/react-router"; -import { useSuspenseQuery } from "@tanstack/react-query"; +import { useSuspenseQuery, useQueryClient } from "@tanstack/react-query"; import { timelineQueryOptions } from "@/functions/get-timeline"; +import { updateTimelineItem } from "@/functions/update-timeline-item"; import { FlutterView } from "@/components/flutter-view"; export const Route = createFileRoute("/timeline/$timelineId")({ @@ -17,6 +18,7 @@ function RouteComponent() { const { timelineId } = Route.useParams(); const timelineQuery = useSuspenseQuery(timelineQueryOptions(timelineId)); const timeline = timelineQuery.data; + const queryClient = useQueryClient(); const [selectedItemId, setSelectedItemId] = useState(null); const flutterState = useMemo( @@ -34,6 +36,7 @@ function RouteComponent() { description: item.description, start: item.start, end: item.end, + lane: item.lane, })), })), }, @@ -56,9 +59,75 @@ function RouteComponent() { case "item_deselected": setSelectedItemId(null); break; + case "entry_moved": { + const p = event.payload; + if (!p) break; + const entryId = p.entryId as string; + const newStart = p.newStart as string; + const newEnd = p.newEnd as string; + const newGroupId = p.newGroupId as string; + const newLane = p.newLane as number; + + // Optimistic cache update + queryClient.setQueryData( + ["timeline", timelineId], + (old: typeof timeline | undefined) => { + if (!old) return old; + let movedItem: + | (typeof old.groups)[number]["items"][number] + | undefined; + + const groups = old.groups.map((group) => { + const filtered = group.items.filter((item) => { + if (item.id === entryId) { + movedItem = item; + return false; + } + return true; + }); + return { ...group, items: filtered }; + }); + + if (!movedItem) return old; + + return { + ...old, + groups: groups.map((group) => + group.id === newGroupId + ? { + ...group, + items: [ + ...group.items, + { + ...movedItem!, + start: newStart, + end: newEnd, + lane: newLane, + timelineGroupId: newGroupId, + }, + ], + } + : group + ), + }; + } + ); + + // Persist to DB + updateTimelineItem({ + data: { + id: entryId, + start: newStart, + end: newEnd, + timelineGroupId: newGroupId, + lane: newLane, + }, + }); + break; + } } }, - [] + [queryClient, timelineId] ); return ( diff --git a/packages/db/package.json b/packages/db/package.json index cdac939..a6c9dc7 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -16,6 +16,7 @@ "db:migrate": "drizzle-kit migrate", "check-types": "tsc --noEmit", "lint": "eslint .", + "db:fix-lanes": "node --env-file=../../apps/web/.env --import=tsx src/scripts/fix-lanes.ts", "db:start": "docker compose up -d", "db:watch": "docker compose up", "db:stop": "docker compose stop", @@ -34,6 +35,7 @@ "@zendegi/eslint-config": "workspace:*", "drizzle-kit": "^0.31.8", "eslint": "^9.17.0", + "tsx": "^4.21.0", "typescript": "catalog:" } } diff --git a/packages/db/src/schema/timeline.ts b/packages/db/src/schema/timeline.ts index f582942..9212b99 100644 --- a/packages/db/src/schema/timeline.ts +++ b/packages/db/src/schema/timeline.ts @@ -42,6 +42,7 @@ export const timelineItem = pgTable("timeline_item", { start: timestamp("start", { withTimezone: true }).notNull().defaultNow(), // Allow null to denote a event without duration end: timestamp("end", { withTimezone: true }), + lane: integer("lane").notNull().default(1), ...timestamps, }); diff --git a/packages/db/src/scripts/fix-lanes.ts b/packages/db/src/scripts/fix-lanes.ts new file mode 100644 index 0000000..b632ac3 --- /dev/null +++ b/packages/db/src/scripts/fix-lanes.ts @@ -0,0 +1,88 @@ +/** + * One-time script to backfill lane values for existing timeline items. + * + * For each group: fetches items ordered by start, assigns lanes with + * the greedy algorithm, and updates the DB. + * + * Run from repo root: + * node --env-file=apps/web/.env --import=tsx packages/db/src/scripts/fix-lanes.ts + */ +import { eq } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { timelineGroup, timelineItem } from "../schema/timeline"; + +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) { + throw new Error("DATABASE_URL is required"); +} + +interface ItemForLane { + id: string; + start: Date; + end: Date | null; + lane: number; +} + +function assignLane( + existing: ItemForLane[], + newStart: Date, + newEnd: Date | null +): number { + const end = newEnd ?? new Date(newStart.getTime() + 24 * 60 * 60 * 1000); + + for (let lane = 1; lane <= 100; lane++) { + const hasConflict = existing.some((item) => { + if (item.lane !== lane) return false; + const itemEnd = + item.end ?? new Date(item.start.getTime() + 24 * 60 * 60 * 1000); + return newStart < itemEnd && end > item.start; + }); + if (!hasConflict) return lane; + } + return 1; +} + +async function main() { + const db = drizzle(DATABASE_URL!); + + const groups = await db.select({ id: timelineGroup.id }).from(timelineGroup); + console.log(`Found ${groups.length} groups`); + + for (const group of groups) { + const items = await db + .select() + .from(timelineItem) + .where(eq(timelineItem.timelineGroupId, group.id)) + .orderBy(timelineItem.start); + + if (items.length === 0) continue; + + const assigned: ItemForLane[] = []; + let updated = 0; + + for (const item of items) { + const lane = assignLane(assigned, item.start, item.end); + assigned.push({ id: item.id, start: item.start, end: item.end, lane }); + + if (item.lane !== lane) { + await db + .update(timelineItem) + .set({ lane }) + .where(eq(timelineItem.id, item.id)); + updated++; + } + } + + console.log( + `Group ${group.id}: ${items.length} items, ${updated} lanes updated` + ); + } + + console.log("Done"); + process.exit(0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/z-timeline/lib/main.dart b/packages/z-timeline/lib/main.dart index 4907bf5..c83e748 100644 --- a/packages/z-timeline/lib/main.dart +++ b/packages/z-timeline/lib/main.dart @@ -64,35 +64,25 @@ class _MainAppState extends State { } List _convertGroups(List groups) { - return [ - for (final g in groups) TimelineGroup(id: g.id, title: g.title), - ]; + return [for (final g in groups) TimelineGroup(id: g.id, title: g.title)]; } List _convertEntries(List groups) { final entries = []; for (final group in groups) { - // Collect all items for this group to compute lanes - final groupItems = group.items; - final sorted = [...groupItems]..sort( - (a, b) => a.start.compareTo(b.start), - ); - - for (final item in sorted) { + for (final item in group.items) { final start = DateTime.parse(item.start); final end = item.end != null ? DateTime.parse(item.end!) : start.add(const Duration(days: 1)); - final lane = _assignLane(entries, group.id, start, end); - entries.add( TimelineEntry( id: item.id, groupId: group.id, start: start, end: end, - lane: lane, + lane: item.lane, ), ); } @@ -100,26 +90,13 @@ class _MainAppState extends State { return entries; } - int _assignLane( - List existing, - String groupId, - DateTime start, - DateTime end, - ) { - final groupEntries = existing.where((e) => e.groupId == groupId); - for (var lane = 1; lane <= 100; lane++) { - final hasConflict = groupEntries.any( - (e) => e.lane == lane && e.overlaps(start, end), - ); - if (!hasConflict) return lane; - } - return 1; - } - ({DateTime start, DateTime end}) _computeDomain(List entries) { if (entries.isEmpty) { final now = DateTime.now(); - return (start: now.subtract(const Duration(days: 30)), end: now.add(const Duration(days: 30))); + return ( + start: now.subtract(const Duration(days: 30)), + end: now.add(const Duration(days: 30)), + ); } var earliest = entries.first.start; @@ -132,10 +109,7 @@ class _MainAppState extends State { // Add 10% padding on each side final span = latest.difference(earliest); final padding = Duration(milliseconds: (span.inMilliseconds * 0.1).round()); - return ( - start: earliest.subtract(padding), - end: latest.add(padding), - ); + return (start: earliest.subtract(padding), end: latest.add(padding)); } void _onEntryMoved( @@ -144,34 +118,16 @@ class _MainAppState extends State { String newGroupId, int newLane, ) { - // Emit event to React via bridge + final duration = entry.end.difference(entry.start); + final newEnd = newStart.add(duration); + emitEvent('entry_moved', { 'entryId': entry.id, 'newStart': newStart.toIso8601String(), + 'newEnd': newEnd.toIso8601String(), 'newGroupId': newGroupId, 'newLane': newLane, }); - - // Update local state so Flutter UI reflects the move immediately - setState(() { - final duration = entry.end.difference(entry.start); - final newEnd = newStart.add(duration); - - _entries = [ - for (final e in _entries) - if (e.id == entry.id) - e.copyWith( - groupId: newGroupId, - start: newStart, - end: newEnd, - lane: newLane, - ) - else - e, - ]; - }); - - _emitContentHeight(); } void _emitContentHeight() { @@ -184,9 +140,12 @@ class _MainAppState extends State { if (e.lane > maxLane) maxLane = e.lane; } final lanesCount = maxLane.clamp(0, 1000); - totalHeight += lanesCount * ZTimelineConstants.laneHeight - + (lanesCount > 0 ? (lanesCount - 1) * ZTimelineConstants.laneVerticalSpacing : 0) - + ZTimelineConstants.verticalOuterPadding * 2; + totalHeight += + lanesCount * ZTimelineConstants.laneHeight + + (lanesCount > 0 + ? (lanesCount - 1) * ZTimelineConstants.laneVerticalSpacing + : 0) + + ZTimelineConstants.verticalOuterPadding * 2; } emitEvent('content_height', {'height': totalHeight}); } diff --git a/packages/z-timeline/lib/src/widgets/group_drop_target.dart b/packages/z-timeline/lib/src/widgets/group_drop_target.dart index c7275e8..26af004 100644 --- a/packages/z-timeline/lib/src/widgets/group_drop_target.dart +++ b/packages/z-timeline/lib/src/widgets/group_drop_target.dart @@ -12,7 +12,10 @@ import 'timeline_view.dart'; /// A drop target wrapper for a timeline group. /// -/// Wraps group lanes content and handles drag-and-drop operations. +/// Wraps the entire group column (header + lanes) and handles drag-and-drop +/// operations. The [verticalOffset] accounts for the header height and padding +/// so that lane calculations are correct relative to the lanes stack. +/// /// The ghost overlay is rendered by the parent widget in the same Stack. class GroupDropTarget extends StatelessWidget { const GroupDropTarget({ @@ -24,6 +27,7 @@ class GroupDropTarget extends StatelessWidget { required this.laneHeight, required this.lanesCount, required this.onEntryMoved, + required this.verticalOffset, required this.child, super.key, }); @@ -36,6 +40,11 @@ class GroupDropTarget extends StatelessWidget { final double laneHeight; final int lanesCount; final OnEntryMoved? onEntryMoved; + + /// The vertical offset from the top of this widget to the top of the lanes + /// stack. This accounts for the group header height and any padding. + final double verticalOffset; + final Widget child; @override @@ -65,8 +74,12 @@ class GroupDropTarget extends StatelessWidget { viewport.end, ); + // Subtract header + padding offset so Y is relative to the lanes stack. + // When the cursor is over the header, adjustedY is negative and clamps + // to lane 1. + final adjustedY = local.dy - verticalOffset; final rawLane = LayoutCoordinateService.yToLane( - y: local.dy, + y: adjustedY, laneHeight: laneHeight, ); final maxAllowedLane = (lanesCount <= 0 ? 1 : lanesCount) + 1; diff --git a/packages/z-timeline/lib/src/widgets/timeline_view.dart b/packages/z-timeline/lib/src/widgets/timeline_view.dart index 8927171..6215997 100644 --- a/packages/z-timeline/lib/src/widgets/timeline_view.dart +++ b/packages/z-timeline/lib/src/widgets/timeline_view.dart @@ -84,7 +84,7 @@ class ZTimelineView extends StatelessWidget { projected[group.id] ?? const []; final lanesCount = _countLanes(groupEntries); - return Column( + final column = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _GroupHeader(title: group.title, height: groupHeaderHeight), @@ -98,11 +98,31 @@ class ZTimelineView extends StatelessWidget { colorBuilder: colorBuilder, labelBuilder: labelBuilder, contentWidth: contentWidth, - onEntryMoved: onEntryMoved, enableDrag: enableDrag, ), ], ); + + // Wrap the entire group (header + lanes) in a DragTarget + // so dragging over headers still updates the ghost position. + if (enableDrag && onEntryMoved != null) { + return GroupDropTarget( + group: group, + entries: groupEntries, + allEntries: entries, + viewport: viewport, + contentWidth: contentWidth, + laneHeight: laneHeight, + lanesCount: lanesCount, + onEntryMoved: onEntryMoved, + verticalOffset: + groupHeaderHeight + + ZTimelineConstants.verticalOuterPadding, + child: column, + ); + } + + return column; }, ); }, @@ -157,7 +177,6 @@ class _GroupLanes extends StatelessWidget { required this.labelBuilder, required this.colorBuilder, required this.contentWidth, - required this.onEntryMoved, required this.enableDrag, }); @@ -170,7 +189,6 @@ class _GroupLanes extends StatelessWidget { final EntryLabelBuilder labelBuilder; final EntryColorBuilder colorBuilder; final double contentWidth; - final OnEntryMoved? onEntryMoved; final bool enableDrag; @override @@ -264,21 +282,6 @@ class _GroupLanes extends StatelessWidget { ), ); - // Wrap with DragTarget if drag is enabled - if (enableDrag && onEntryMoved != null) { - innerStack = GroupDropTarget( - group: group, - entries: entries, - allEntries: allEntries, - viewport: viewport, - contentWidth: contentWidth, - laneHeight: laneHeight, - lanesCount: lanesCount, - onEntryMoved: onEntryMoved, - child: innerStack, - ); - } - return AnimatedSize( duration: const Duration(milliseconds: 150), curve: Curves.easeInOut, diff --git a/packages/z-timeline/lib/state.dart b/packages/z-timeline/lib/state.dart index f90a059..81c4506 100644 --- a/packages/z-timeline/lib/state.dart +++ b/packages/z-timeline/lib/state.dart @@ -61,6 +61,7 @@ class TimelineItemData { final String? description; final String start; final String? end; + final int lane; TimelineItemData({ required this.id, @@ -68,6 +69,7 @@ class TimelineItemData { this.description, required this.start, this.end, + required this.lane, }); factory TimelineItemData.fromJson(Map json) { @@ -77,6 +79,7 @@ class TimelineItemData { description: json['description'] as String?, start: json['start'] as String, end: json['end'] as String?, + lane: json['lane'] as int, ); } } diff --git a/packages/z-timeline/web/index.html b/packages/z-timeline/web/index.html index 54a97a5..677296f 100644 --- a/packages/z-timeline/web/index.html +++ b/packages/z-timeline/web/index.html @@ -51,9 +51,9 @@ title: "Design", sortOrder: 0, items: [ - { id: "e-1", title: "Brand identity", start: "2026-01-02", end: "2026-01-08" }, - { id: "e-2", title: "UI mockups", start: "2026-01-06", end: "2026-01-14" }, - { id: "e-3", title: "Design review", start: "2026-01-20", end: "2026-01-22" }, + { id: "e-1", title: "Brand identity", start: "2026-01-02", end: "2026-01-08", lane: 1 }, + { id: "e-2", title: "UI mockups", start: "2026-01-06", end: "2026-01-14", lane: 2 }, + { id: "e-3", title: "Design review", start: "2026-01-20", end: "2026-01-22", lane: 1 }, ], }, { @@ -61,9 +61,9 @@ title: "Engineering", sortOrder: 1, items: [ - { id: "e-4", title: "API scaffolding", start: "2026-01-05", end: "2026-01-12" }, - { id: "e-5", title: "Auth flow", start: "2026-01-10", end: "2026-01-18" }, - { id: "e-6", title: "Dashboard UI", start: "2026-01-15", end: "2026-01-25" }, + { id: "e-4", title: "API scaffolding", start: "2026-01-05", end: "2026-01-12", lane: 1 }, + { id: "e-5", title: "Auth flow", start: "2026-01-10", end: "2026-01-18", lane: 2 }, + { id: "e-6", title: "Dashboard UI", start: "2026-01-15", end: "2026-01-25", lane: 3 }, ], }, { @@ -71,9 +71,9 @@ title: "Launch", sortOrder: 2, items: [ - { id: "e-7", title: "QA testing", start: "2026-01-19", end: "2026-01-26" }, - { id: "e-8", title: "Beta release", start: "2026-01-24", end: "2026-01-28" }, - { id: "e-9", title: "Marketing prep", start: "2026-01-08", end: "2026-01-15" }, + { id: "e-7", title: "QA testing", start: "2026-01-19", end: "2026-01-26", lane: 1 }, + { id: "e-8", title: "Beta release", start: "2026-01-24", end: "2026-01-28", lane: 2 }, + { id: "e-9", title: "Marketing prep", start: "2026-01-08", end: "2026-01-15", lane: 1 }, ], }, ], @@ -111,15 +111,14 @@ } if (entry) { - // Preserve duration, update start/end - var oldStart = new Date(entry.start); - var oldEnd = new Date(entry.end); - var duration = oldEnd - oldStart; - var newStart = new Date(p.newStart); - var newEnd = new Date(newStart.getTime() + duration); + // Update start/end from payload (ISO 8601 → date-only) + entry.start = new Date(p.newStart).toISOString().split("T")[0]; + entry.end = p.newEnd + ? new Date(p.newEnd).toISOString().split("T")[0] + : new Date(new Date(p.newStart).getTime() + (new Date(entry.end) - new Date(entry.start))).toISOString().split("T")[0]; - entry.start = newStart.toISOString().split("T")[0]; - entry.end = newEnd.toISOString().split("T")[0]; + // Preserve the lane from the drop target + entry.lane = p.newLane; // Add to target group var targetGroup = state.timeline.groups.find( @@ -129,6 +128,10 @@ targetGroup.items.push(entry); } + // Push updated state back to Flutter + if (_updateState) { + _updateState(JSON.stringify(state)); + } } } else if (event.type === "content_height") { console.log("[z-timeline dev] content_height:", event.payload.height);