always set lane
This commit is contained in:
@@ -1,8 +1,10 @@
|
|||||||
import { db } from "@zendegi/db";
|
import { db } from "@zendegi/db";
|
||||||
import { timelineItem } from "@zendegi/db/schema/timeline";
|
import { timelineItem } from "@zendegi/db/schema/timeline";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { authMiddleware } from "@/middleware/auth";
|
import { authMiddleware } from "@/middleware/auth";
|
||||||
|
import { assignLane } from "@/lib/assign-lane";
|
||||||
|
|
||||||
export const createTimelineItem = createServerFn({ method: "POST" })
|
export const createTimelineItem = createServerFn({ method: "POST" })
|
||||||
.middleware([authMiddleware])
|
.middleware([authMiddleware])
|
||||||
@@ -19,6 +21,12 @@ export const createTimelineItem = createServerFn({ method: "POST" })
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.handler(async ({ data }) => {
|
.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
|
const [newItem] = await db
|
||||||
.insert(timelineItem)
|
.insert(timelineItem)
|
||||||
.values({
|
.values({
|
||||||
@@ -27,6 +35,7 @@ export const createTimelineItem = createServerFn({ method: "POST" })
|
|||||||
start: data.start,
|
start: data.start,
|
||||||
end: data.end,
|
end: data.end,
|
||||||
timelineGroupId: data.timelineGroupId,
|
timelineGroupId: data.timelineGroupId,
|
||||||
|
lane,
|
||||||
})
|
})
|
||||||
.returning();
|
.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";
|
} from "@zendegi/db/schema/timeline";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { assignLane } from "@/lib/assign-lane";
|
||||||
|
|
||||||
function createMcpServer(userId: string) {
|
function createMcpServer(userId: string) {
|
||||||
const server = new McpServer({
|
const server = new McpServer({
|
||||||
@@ -141,14 +142,23 @@ function createMcpServer(userId: string) {
|
|||||||
isError: true,
|
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
|
const [created] = await db
|
||||||
.insert(timelineItem)
|
.insert(timelineItem)
|
||||||
.values({
|
.values({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
start: new Date(start),
|
start: newStart,
|
||||||
end: end ? new Date(end) : undefined,
|
end: newEnd ?? undefined,
|
||||||
timelineGroupId,
|
timelineGroupId,
|
||||||
|
lane,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
return {
|
return {
|
||||||
@@ -173,9 +183,10 @@ function createMcpServer(userId: string) {
|
|||||||
.nullable()
|
.nullable()
|
||||||
.optional()
|
.optional()
|
||||||
.describe("New end date (ISO 8601, or null to remove)"),
|
.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
|
// Verify ownership through item → group → timeline
|
||||||
const item = await db.query.timelineItem.findFirst({
|
const item = await db.query.timelineItem.findFirst({
|
||||||
where: eq(timelineItem.id, id),
|
where: eq(timelineItem.id, id),
|
||||||
@@ -193,6 +204,7 @@ function createMcpServer(userId: string) {
|
|||||||
if (description !== undefined) updates.description = description;
|
if (description !== undefined) updates.description = description;
|
||||||
if (start !== undefined) updates.start = new Date(start);
|
if (start !== undefined) updates.start = new Date(start);
|
||||||
if (end !== undefined) updates.end = end ? new Date(end) : null;
|
if (end !== undefined) updates.end = end ? new Date(end) : null;
|
||||||
|
if (lane !== undefined) updates.lane = lane;
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(timelineItem)
|
.update(timelineItem)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
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 { timelineQueryOptions } from "@/functions/get-timeline";
|
||||||
|
import { updateTimelineItem } from "@/functions/update-timeline-item";
|
||||||
import { FlutterView } from "@/components/flutter-view";
|
import { FlutterView } from "@/components/flutter-view";
|
||||||
|
|
||||||
export const Route = createFileRoute("/timeline/$timelineId")({
|
export const Route = createFileRoute("/timeline/$timelineId")({
|
||||||
@@ -17,6 +18,7 @@ function RouteComponent() {
|
|||||||
const { timelineId } = Route.useParams();
|
const { timelineId } = Route.useParams();
|
||||||
const timelineQuery = useSuspenseQuery(timelineQueryOptions(timelineId));
|
const timelineQuery = useSuspenseQuery(timelineQueryOptions(timelineId));
|
||||||
const timeline = timelineQuery.data;
|
const timeline = timelineQuery.data;
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
|
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
|
||||||
|
|
||||||
const flutterState = useMemo(
|
const flutterState = useMemo(
|
||||||
@@ -34,6 +36,7 @@ function RouteComponent() {
|
|||||||
description: item.description,
|
description: item.description,
|
||||||
start: item.start,
|
start: item.start,
|
||||||
end: item.end,
|
end: item.end,
|
||||||
|
lane: item.lane,
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
@@ -56,9 +59,75 @@ function RouteComponent() {
|
|||||||
case "item_deselected":
|
case "item_deselected":
|
||||||
setSelectedItemId(null);
|
setSelectedItemId(null);
|
||||||
break;
|
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 (
|
return (
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"check-types": "tsc --noEmit",
|
"check-types": "tsc --noEmit",
|
||||||
"lint": "eslint .",
|
"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:start": "docker compose up -d",
|
||||||
"db:watch": "docker compose up",
|
"db:watch": "docker compose up",
|
||||||
"db:stop": "docker compose stop",
|
"db:stop": "docker compose stop",
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
"@zendegi/eslint-config": "workspace:*",
|
"@zendegi/eslint-config": "workspace:*",
|
||||||
"drizzle-kit": "^0.31.8",
|
"drizzle-kit": "^0.31.8",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export const timelineItem = pgTable("timeline_item", {
|
|||||||
start: timestamp("start", { withTimezone: true }).notNull().defaultNow(),
|
start: timestamp("start", { withTimezone: true }).notNull().defaultNow(),
|
||||||
// Allow null to denote a event without duration
|
// Allow null to denote a event without duration
|
||||||
end: timestamp("end", { withTimezone: true }),
|
end: timestamp("end", { withTimezone: true }),
|
||||||
|
lane: integer("lane").notNull().default(1),
|
||||||
...timestamps,
|
...timestamps,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
88
packages/db/src/scripts/fix-lanes.ts
Normal file
88
packages/db/src/scripts/fix-lanes.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
@@ -64,35 +64,25 @@ class _MainAppState extends State<MainApp> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<TimelineGroup> _convertGroups(List<TimelineGroupData> groups) {
|
List<TimelineGroup> _convertGroups(List<TimelineGroupData> groups) {
|
||||||
return [
|
return [for (final g in groups) TimelineGroup(id: g.id, title: g.title)];
|
||||||
for (final g in groups) TimelineGroup(id: g.id, title: g.title),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
List<TimelineEntry> _convertEntries(List<TimelineGroupData> groups) {
|
List<TimelineEntry> _convertEntries(List<TimelineGroupData> groups) {
|
||||||
final entries = <TimelineEntry>[];
|
final entries = <TimelineEntry>[];
|
||||||
for (final group in groups) {
|
for (final group in groups) {
|
||||||
// Collect all items for this group to compute lanes
|
for (final item in group.items) {
|
||||||
final groupItems = group.items;
|
|
||||||
final sorted = [...groupItems]..sort(
|
|
||||||
(a, b) => a.start.compareTo(b.start),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (final item in sorted) {
|
|
||||||
final start = DateTime.parse(item.start);
|
final start = DateTime.parse(item.start);
|
||||||
final end = item.end != null
|
final end = item.end != null
|
||||||
? DateTime.parse(item.end!)
|
? DateTime.parse(item.end!)
|
||||||
: start.add(const Duration(days: 1));
|
: start.add(const Duration(days: 1));
|
||||||
|
|
||||||
final lane = _assignLane(entries, group.id, start, end);
|
|
||||||
|
|
||||||
entries.add(
|
entries.add(
|
||||||
TimelineEntry(
|
TimelineEntry(
|
||||||
id: item.id,
|
id: item.id,
|
||||||
groupId: group.id,
|
groupId: group.id,
|
||||||
start: start,
|
start: start,
|
||||||
end: end,
|
end: end,
|
||||||
lane: lane,
|
lane: item.lane,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -100,26 +90,13 @@ class _MainAppState extends State<MainApp> {
|
|||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
int _assignLane(
|
|
||||||
List<TimelineEntry> 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<TimelineEntry> entries) {
|
({DateTime start, DateTime end}) _computeDomain(List<TimelineEntry> entries) {
|
||||||
if (entries.isEmpty) {
|
if (entries.isEmpty) {
|
||||||
final now = DateTime.now();
|
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;
|
var earliest = entries.first.start;
|
||||||
@@ -132,10 +109,7 @@ class _MainAppState extends State<MainApp> {
|
|||||||
// Add 10% padding on each side
|
// Add 10% padding on each side
|
||||||
final span = latest.difference(earliest);
|
final span = latest.difference(earliest);
|
||||||
final padding = Duration(milliseconds: (span.inMilliseconds * 0.1).round());
|
final padding = Duration(milliseconds: (span.inMilliseconds * 0.1).round());
|
||||||
return (
|
return (start: earliest.subtract(padding), end: latest.add(padding));
|
||||||
start: earliest.subtract(padding),
|
|
||||||
end: latest.add(padding),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onEntryMoved(
|
void _onEntryMoved(
|
||||||
@@ -144,34 +118,16 @@ class _MainAppState extends State<MainApp> {
|
|||||||
String newGroupId,
|
String newGroupId,
|
||||||
int newLane,
|
int newLane,
|
||||||
) {
|
) {
|
||||||
// Emit event to React via bridge
|
final duration = entry.end.difference(entry.start);
|
||||||
|
final newEnd = newStart.add(duration);
|
||||||
|
|
||||||
emitEvent('entry_moved', {
|
emitEvent('entry_moved', {
|
||||||
'entryId': entry.id,
|
'entryId': entry.id,
|
||||||
'newStart': newStart.toIso8601String(),
|
'newStart': newStart.toIso8601String(),
|
||||||
|
'newEnd': newEnd.toIso8601String(),
|
||||||
'newGroupId': newGroupId,
|
'newGroupId': newGroupId,
|
||||||
'newLane': newLane,
|
'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() {
|
void _emitContentHeight() {
|
||||||
@@ -184,9 +140,12 @@ class _MainAppState extends State<MainApp> {
|
|||||||
if (e.lane > maxLane) maxLane = e.lane;
|
if (e.lane > maxLane) maxLane = e.lane;
|
||||||
}
|
}
|
||||||
final lanesCount = maxLane.clamp(0, 1000);
|
final lanesCount = maxLane.clamp(0, 1000);
|
||||||
totalHeight += lanesCount * ZTimelineConstants.laneHeight
|
totalHeight +=
|
||||||
+ (lanesCount > 0 ? (lanesCount - 1) * ZTimelineConstants.laneVerticalSpacing : 0)
|
lanesCount * ZTimelineConstants.laneHeight +
|
||||||
+ ZTimelineConstants.verticalOuterPadding * 2;
|
(lanesCount > 0
|
||||||
|
? (lanesCount - 1) * ZTimelineConstants.laneVerticalSpacing
|
||||||
|
: 0) +
|
||||||
|
ZTimelineConstants.verticalOuterPadding * 2;
|
||||||
}
|
}
|
||||||
emitEvent('content_height', {'height': totalHeight});
|
emitEvent('content_height', {'height': totalHeight});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import 'timeline_view.dart';
|
|||||||
|
|
||||||
/// A drop target wrapper for a timeline group.
|
/// 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.
|
/// The ghost overlay is rendered by the parent widget in the same Stack.
|
||||||
class GroupDropTarget extends StatelessWidget {
|
class GroupDropTarget extends StatelessWidget {
|
||||||
const GroupDropTarget({
|
const GroupDropTarget({
|
||||||
@@ -24,6 +27,7 @@ class GroupDropTarget extends StatelessWidget {
|
|||||||
required this.laneHeight,
|
required this.laneHeight,
|
||||||
required this.lanesCount,
|
required this.lanesCount,
|
||||||
required this.onEntryMoved,
|
required this.onEntryMoved,
|
||||||
|
required this.verticalOffset,
|
||||||
required this.child,
|
required this.child,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
@@ -36,6 +40,11 @@ class GroupDropTarget extends StatelessWidget {
|
|||||||
final double laneHeight;
|
final double laneHeight;
|
||||||
final int lanesCount;
|
final int lanesCount;
|
||||||
final OnEntryMoved? onEntryMoved;
|
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;
|
final Widget child;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -65,8 +74,12 @@ class GroupDropTarget extends StatelessWidget {
|
|||||||
viewport.end,
|
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(
|
final rawLane = LayoutCoordinateService.yToLane(
|
||||||
y: local.dy,
|
y: adjustedY,
|
||||||
laneHeight: laneHeight,
|
laneHeight: laneHeight,
|
||||||
);
|
);
|
||||||
final maxAllowedLane = (lanesCount <= 0 ? 1 : lanesCount) + 1;
|
final maxAllowedLane = (lanesCount <= 0 ? 1 : lanesCount) + 1;
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ class ZTimelineView extends StatelessWidget {
|
|||||||
projected[group.id] ?? const <ProjectedEntry>[];
|
projected[group.id] ?? const <ProjectedEntry>[];
|
||||||
final lanesCount = _countLanes(groupEntries);
|
final lanesCount = _countLanes(groupEntries);
|
||||||
|
|
||||||
return Column(
|
final column = Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_GroupHeader(title: group.title, height: groupHeaderHeight),
|
_GroupHeader(title: group.title, height: groupHeaderHeight),
|
||||||
@@ -98,11 +98,31 @@ class ZTimelineView extends StatelessWidget {
|
|||||||
colorBuilder: colorBuilder,
|
colorBuilder: colorBuilder,
|
||||||
labelBuilder: labelBuilder,
|
labelBuilder: labelBuilder,
|
||||||
contentWidth: contentWidth,
|
contentWidth: contentWidth,
|
||||||
onEntryMoved: onEntryMoved,
|
|
||||||
enableDrag: enableDrag,
|
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.labelBuilder,
|
||||||
required this.colorBuilder,
|
required this.colorBuilder,
|
||||||
required this.contentWidth,
|
required this.contentWidth,
|
||||||
required this.onEntryMoved,
|
|
||||||
required this.enableDrag,
|
required this.enableDrag,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -170,7 +189,6 @@ class _GroupLanes extends StatelessWidget {
|
|||||||
final EntryLabelBuilder labelBuilder;
|
final EntryLabelBuilder labelBuilder;
|
||||||
final EntryColorBuilder colorBuilder;
|
final EntryColorBuilder colorBuilder;
|
||||||
final double contentWidth;
|
final double contentWidth;
|
||||||
final OnEntryMoved? onEntryMoved;
|
|
||||||
final bool enableDrag;
|
final bool enableDrag;
|
||||||
|
|
||||||
@override
|
@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(
|
return AnimatedSize(
|
||||||
duration: const Duration(milliseconds: 150),
|
duration: const Duration(milliseconds: 150),
|
||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ class TimelineItemData {
|
|||||||
final String? description;
|
final String? description;
|
||||||
final String start;
|
final String start;
|
||||||
final String? end;
|
final String? end;
|
||||||
|
final int lane;
|
||||||
|
|
||||||
TimelineItemData({
|
TimelineItemData({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -68,6 +69,7 @@ class TimelineItemData {
|
|||||||
this.description,
|
this.description,
|
||||||
required this.start,
|
required this.start,
|
||||||
this.end,
|
this.end,
|
||||||
|
required this.lane,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory TimelineItemData.fromJson(Map<String, dynamic> json) {
|
factory TimelineItemData.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -77,6 +79,7 @@ class TimelineItemData {
|
|||||||
description: json['description'] as String?,
|
description: json['description'] as String?,
|
||||||
start: json['start'] as String,
|
start: json['start'] as String,
|
||||||
end: json['end'] as String?,
|
end: json['end'] as String?,
|
||||||
|
lane: json['lane'] as int,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,9 +51,9 @@
|
|||||||
title: "Design",
|
title: "Design",
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
items: [
|
items: [
|
||||||
{ id: "e-1", title: "Brand identity", start: "2026-01-02", end: "2026-01-08" },
|
{ 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" },
|
{ 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" },
|
{ id: "e-3", title: "Design review", start: "2026-01-20", end: "2026-01-22", lane: 1 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -61,9 +61,9 @@
|
|||||||
title: "Engineering",
|
title: "Engineering",
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
items: [
|
items: [
|
||||||
{ id: "e-4", title: "API scaffolding", start: "2026-01-05", end: "2026-01-12" },
|
{ 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" },
|
{ 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" },
|
{ id: "e-6", title: "Dashboard UI", start: "2026-01-15", end: "2026-01-25", lane: 3 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -71,9 +71,9 @@
|
|||||||
title: "Launch",
|
title: "Launch",
|
||||||
sortOrder: 2,
|
sortOrder: 2,
|
||||||
items: [
|
items: [
|
||||||
{ id: "e-7", title: "QA testing", start: "2026-01-19", end: "2026-01-26" },
|
{ 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" },
|
{ 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" },
|
{ id: "e-9", title: "Marketing prep", start: "2026-01-08", end: "2026-01-15", lane: 1 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -111,15 +111,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (entry) {
|
if (entry) {
|
||||||
// Preserve duration, update start/end
|
// Update start/end from payload (ISO 8601 → date-only)
|
||||||
var oldStart = new Date(entry.start);
|
entry.start = new Date(p.newStart).toISOString().split("T")[0];
|
||||||
var oldEnd = new Date(entry.end);
|
entry.end = p.newEnd
|
||||||
var duration = oldEnd - oldStart;
|
? new Date(p.newEnd).toISOString().split("T")[0]
|
||||||
var newStart = new Date(p.newStart);
|
: new Date(new Date(p.newStart).getTime() + (new Date(entry.end) - new Date(entry.start))).toISOString().split("T")[0];
|
||||||
var newEnd = new Date(newStart.getTime() + duration);
|
|
||||||
|
|
||||||
entry.start = newStart.toISOString().split("T")[0];
|
// Preserve the lane from the drop target
|
||||||
entry.end = newEnd.toISOString().split("T")[0];
|
entry.lane = p.newLane;
|
||||||
|
|
||||||
// Add to target group
|
// Add to target group
|
||||||
var targetGroup = state.timeline.groups.find(
|
var targetGroup = state.timeline.groups.find(
|
||||||
@@ -129,6 +128,10 @@
|
|||||||
targetGroup.items.push(entry);
|
targetGroup.items.push(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push updated state back to Flutter
|
||||||
|
if (_updateState) {
|
||||||
|
_updateState(JSON.stringify(state));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (event.type === "content_height") {
|
} else if (event.type === "content_height") {
|
||||||
console.log("[z-timeline dev] content_height:", event.payload.height);
|
console.log("[z-timeline dev] content_height:", event.payload.height);
|
||||||
|
|||||||
Reference in New Issue
Block a user