always set lane

This commit is contained in:
2026-02-25 20:00:43 +01:00
parent ea22da9e5a
commit f3b645ac53
13 changed files with 325 additions and 102 deletions

View File

@@ -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();

View File

@@ -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;
});

View File

@@ -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;
}

View File

@@ -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)

View File

@@ -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<string | null>(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 (