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

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

View File

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

View File

@@ -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:"
} }
} }

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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