always set lane
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
35
apps/web/src/functions/update-timeline-item.ts
Normal file
35
apps/web/src/functions/update-timeline-item.ts
Normal 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;
|
||||
});
|
||||
26
apps/web/src/lib/assign-lane.ts
Normal file
26
apps/web/src/lib/assign-lane.ts
Normal 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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user