From 22067c4904d223b8c6c35ef5ea9ddab52195c7d2 Mon Sep 17 00:00:00 2001 From: Jonatan Granqvist Date: Mon, 2 Mar 2026 09:09:00 +0100 Subject: [PATCH] handle drag n drop --- apps/web/src/components/flutter-view.tsx | 10 +- .../web/src/functions/update-timeline-item.ts | 2 +- .../web/src/hooks/use-entry-moved-mutation.ts | 93 ++++++++++++++++++ apps/web/src/lib/flutter-bridge.ts | 59 ++++++++++++ apps/web/src/routes/timeline.$timelineId.tsx | 94 +++---------------- packages/z-timeline/lib/main.dart | 18 ++-- .../lib/src/models/timeline_entry.dart | 11 ++- packages/z-timeline/web/index.html | 4 +- 8 files changed, 195 insertions(+), 96 deletions(-) create mode 100644 apps/web/src/hooks/use-entry-moved-mutation.ts create mode 100644 apps/web/src/lib/flutter-bridge.ts diff --git a/apps/web/src/components/flutter-view.tsx b/apps/web/src/components/flutter-view.tsx index c1aec5a..af425e6 100644 --- a/apps/web/src/components/flutter-view.tsx +++ b/apps/web/src/components/flutter-view.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import type { FlutterEvent, FlutterTimelineState } from "@/lib/flutter-bridge"; declare global { interface Window { @@ -28,8 +29,8 @@ declare global { } type FlutterViewProps = { - state: Record; - onEvent: (event: { type: string; payload?: Record }) => void; + state: FlutterTimelineState; + onEvent: (event: FlutterEvent) => void; className?: string; height?: number; }; @@ -60,10 +61,7 @@ export function FlutterView({ window.__zendegi__ = { getState: () => JSON.stringify(stateRef.current), onEvent: (json: string) => { - const event = JSON.parse(json) as { - type: string; - payload?: Record; - }; + const event = JSON.parse(json) as FlutterEvent; onEventRef.current(event); }, }; diff --git a/apps/web/src/functions/update-timeline-item.ts b/apps/web/src/functions/update-timeline-item.ts index 53834ed..31341c3 100644 --- a/apps/web/src/functions/update-timeline-item.ts +++ b/apps/web/src/functions/update-timeline-item.ts @@ -13,7 +13,7 @@ export const updateTimelineItem = createServerFn({ method: "POST" }) start: z.string().transform((s) => new Date(s)), end: z .string() - .nullable() + .nullish() .transform((s) => (s ? new Date(s) : null)), timelineGroupId: z.string().uuid(), lane: z.number().int().min(1), diff --git a/apps/web/src/hooks/use-entry-moved-mutation.ts b/apps/web/src/hooks/use-entry-moved-mutation.ts new file mode 100644 index 0000000..e04f753 --- /dev/null +++ b/apps/web/src/hooks/use-entry-moved-mutation.ts @@ -0,0 +1,93 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { getTimeline } from "@/functions/get-timeline"; +import { updateTimelineItem } from "@/functions/update-timeline-item"; + +type TimelineData = Awaited>; + +type EntryMovedVars = { + entryId: string; + newStart: string; + newEnd: string | null; + newGroupId: string; + newLane: number; +}; + +export function useEntryMovedMutation(timelineId: string) { + const queryClient = useQueryClient(); + const queryKey = ["timeline", timelineId]; + + return useMutation({ + mutationFn: (vars: EntryMovedVars) => + updateTimelineItem({ + data: { + id: vars.entryId, + start: vars.newStart, + end: vars.newEnd, + timelineGroupId: vars.newGroupId, + lane: vars.newLane, + }, + }), + + onMutate: async (vars) => { + // Cancel in-flight fetches so they don't overwrite our optimistic update + await queryClient.cancelQueries({ queryKey }); + const previous = queryClient.getQueryData(queryKey); + + queryClient.setQueryData(queryKey, (old) => { + if (!old) return old; + + let movedItem: + | TimelineData["groups"][number]["items"][number] + | undefined; + + const groups = old.groups.map((group) => { + const filtered = group.items.filter((item) => { + if (item.id === vars.entryId) { + movedItem = item; + return false; + } + return true; + }); + return { ...group, items: filtered }; + }); + + if (!movedItem) return old; + + return { + ...old, + groups: groups.map((group) => + group.id === vars.newGroupId + ? { + ...group, + items: [ + ...group.items, + { + ...movedItem!, + start: new Date(vars.newStart), + end: vars.newEnd ? new Date(vars.newEnd) : null, + lane: vars.newLane, + timelineGroupId: vars.newGroupId, + }, + ], + } + : group + ), + }; + }); + + return { previous }; + }, + + onError: (_err, _vars, context) => { + // Roll back to the previous cache state on server error + if (context?.previous) { + queryClient.setQueryData(queryKey, context.previous); + } + }, + + onSettled: () => { + // Re-fetch from server to ensure consistency + queryClient.invalidateQueries({ queryKey }); + }, + }); +} diff --git a/apps/web/src/lib/flutter-bridge.ts b/apps/web/src/lib/flutter-bridge.ts new file mode 100644 index 0000000..4614faf --- /dev/null +++ b/apps/web/src/lib/flutter-bridge.ts @@ -0,0 +1,59 @@ +/** + * Typed contract for React <-> Flutter communication. + * + * State : React -> Flutter (pushed on every render) + * Events : Flutter -> React (emitted by user interaction in Flutter) + * + * Keep this file in sync with `packages/z-timeline/lib/state.dart` and + * the `emitEvent()` calls in `packages/z-timeline/lib/main.dart`. + */ + +// --------------------------------------------------------------------------- +// State: React -> Flutter +// --------------------------------------------------------------------------- + +export type FlutterTimelineItem = { + id: string; + title: string; + description: string | null; + start: string; // ISO-8601 + end: string | null; // ISO-8601 | null for point-events + lane: number; +}; + +export type FlutterTimelineGroup = { + id: string; + title: string; + sortOrder: number; + items: Array; +}; + +export type FlutterTimelineData = { + id: string; + title: string; + groups: Array; +}; + +export type FlutterTimelineState = { + timeline: FlutterTimelineData; + selectedItemId: string | null; +}; + +// --------------------------------------------------------------------------- +// Events: Flutter -> React (discriminated union on `type`) +// --------------------------------------------------------------------------- + +export type FlutterEvent = + | { type: "content_height"; payload: { height: number } } + | { type: "item_selected"; payload: { itemId: string } } + | { type: "item_deselected" } + | { + type: "entry_moved"; + payload: { + entryId: string; + newStart: string; + newEnd: string | null; + newGroupId: string; + newLane: number; + }; + }; diff --git a/apps/web/src/routes/timeline.$timelineId.tsx b/apps/web/src/routes/timeline.$timelineId.tsx index 212f9dd..6565412 100644 --- a/apps/web/src/routes/timeline.$timelineId.tsx +++ b/apps/web/src/routes/timeline.$timelineId.tsx @@ -1,9 +1,10 @@ import { useCallback, useMemo, useState } from "react"; import { createFileRoute } from "@tanstack/react-router"; -import { useSuspenseQuery, useQueryClient } from "@tanstack/react-query"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import type { FlutterEvent, FlutterTimelineState } from "@/lib/flutter-bridge"; import { timelineQueryOptions } from "@/functions/get-timeline"; -import { updateTimelineItem } from "@/functions/update-timeline-item"; import { FlutterView } from "@/components/flutter-view"; +import { useEntryMovedMutation } from "@/hooks/use-entry-moved-mutation"; export const Route = createFileRoute("/timeline/$timelineId")({ loader: async ({ context, params }) => { @@ -16,12 +17,12 @@ export const Route = createFileRoute("/timeline/$timelineId")({ function RouteComponent() { const { timelineId } = Route.useParams(); - const timelineQuery = useSuspenseQuery(timelineQueryOptions(timelineId)); - const timeline = timelineQuery.data; - const queryClient = useQueryClient(); + const { data: timeline } = useSuspenseQuery(timelineQueryOptions(timelineId)); const [selectedItemId, setSelectedItemId] = useState(null); + const [flutterHeight, setFlutterHeight] = useState(); + const entryMoved = useEntryMovedMutation(timelineId); - const flutterState = useMemo( + const flutterState: FlutterTimelineState = useMemo( () => ({ timeline: { id: timeline.id, @@ -34,8 +35,8 @@ function RouteComponent() { id: item.id, title: item.title, description: item.description, - start: item.start, - end: item.end, + start: item.start.toISOString(), + end: item.end?.toISOString() ?? null, lane: item.lane, })), })), @@ -45,89 +46,24 @@ function RouteComponent() { [timeline, selectedItemId] ); - const [flutterHeight, setFlutterHeight] = useState(); - const handleEvent = useCallback( - (event: { type: string; payload?: Record }) => { + (event: FlutterEvent) => { switch (event.type) { case "content_height": - setFlutterHeight(event.payload?.height as number); + setFlutterHeight(event.payload.height); break; case "item_selected": - // setSelectedItemId((event.payload?.itemId as string) ?? null); + setSelectedItemId(event.payload.itemId); break; 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, - }, - }); + case "entry_moved": + entryMoved.mutate(event.payload); break; - } } }, - [queryClient, timelineId] + [entryMoved] ); return ( diff --git a/packages/z-timeline/lib/main.dart b/packages/z-timeline/lib/main.dart index c83e748..af4cc35 100644 --- a/packages/z-timeline/lib/main.dart +++ b/packages/z-timeline/lib/main.dart @@ -83,6 +83,7 @@ class _MainAppState extends State { start: start, end: end, lane: item.lane, + hasEnd: item.end != null, ), ); } @@ -118,16 +119,21 @@ class _MainAppState extends State { String newGroupId, int newLane, ) { - final duration = entry.end.difference(entry.start); - final newEnd = newStart.add(duration); - - emitEvent('entry_moved', { + final payload = { 'entryId': entry.id, 'newStart': newStart.toIso8601String(), - 'newEnd': newEnd.toIso8601String(), 'newGroupId': newGroupId, 'newLane': newLane, - }); + }; + + if (entry.hasEnd) { + final duration = entry.end.difference(entry.start); + payload['newEnd'] = newStart.add(duration).toIso8601String(); + } else { + payload['newEnd'] = null; + } + + emitEvent('entry_moved', payload); } void _emitContentHeight() { diff --git a/packages/z-timeline/lib/src/models/timeline_entry.dart b/packages/z-timeline/lib/src/models/timeline_entry.dart index d25e573..7274ee0 100644 --- a/packages/z-timeline/lib/src/models/timeline_entry.dart +++ b/packages/z-timeline/lib/src/models/timeline_entry.dart @@ -8,6 +8,7 @@ class TimelineEntry { required this.start, required this.end, required this.lane, + this.hasEnd = true, }) : assert(!end.isBefore(start), 'Entry end must be on/after start'); final String id; @@ -15,6 +16,7 @@ class TimelineEntry { final DateTime start; final DateTime end; final int lane; // provided by consumer for stacking + final bool hasEnd; // false for point-events (end is synthetic) bool overlaps(DateTime a, DateTime b) { return !(end.isBefore(a) || start.isAfter(b)); @@ -26,6 +28,7 @@ class TimelineEntry { DateTime? start, DateTime? end, int? lane, + bool? hasEnd, }) { return TimelineEntry( id: id ?? this.id, @@ -33,11 +36,12 @@ class TimelineEntry { start: start ?? this.start, end: end ?? this.end, lane: lane ?? this.lane, + hasEnd: hasEnd ?? this.hasEnd, ); } @override - int get hashCode => Object.hash(id, groupId, start, end, lane); + int get hashCode => Object.hash(id, groupId, start, end, lane, hasEnd); @override bool operator ==(Object other) { @@ -46,10 +50,11 @@ class TimelineEntry { other.groupId == groupId && other.start == start && other.end == end && - other.lane == lane; + other.lane == lane && + other.hasEnd == hasEnd; } @override String toString() => - 'TimelineEntry(id: $id, groupId: $groupId, start: $start, end: $end, lane: $lane)'; + 'TimelineEntry(id: $id, groupId: $groupId, start: $start, end: $end, lane: $lane, hasEnd: $hasEnd)'; } diff --git a/packages/z-timeline/web/index.html b/packages/z-timeline/web/index.html index 677296f..32dbcd4 100644 --- a/packages/z-timeline/web/index.html +++ b/packages/z-timeline/web/index.html @@ -54,6 +54,7 @@ { 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 }, + { id: "e-10", title: "Kickoff meeting", start: "2026-01-01", end: null, lane: 3 }, ], }, { @@ -74,6 +75,7 @@ { 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 }, + { id: "e-11", title: "Go-live", start: "2026-01-28", end: null, lane: 3 }, ], }, ], @@ -115,7 +117,7 @@ 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]; + : null; // Preserve the lane from the drop target entry.lane = p.newLane;