From dbfb29703c667e9e34c9d35d1602c67814d106bb Mon Sep 17 00:00:00 2001 From: Jonatan Granqvist Date: Mon, 2 Mar 2026 11:05:57 +0100 Subject: [PATCH] normalize data --- apps/web/src/functions/get-timeline.ts | 80 +++++++++++++++- .../web/src/hooks/use-entry-moved-mutation.ts | 52 +++------- apps/web/src/lib/flutter-bridge.ts | 16 ++-- apps/web/src/routes/timeline.$timelineId.tsx | 22 ++--- packages/z-timeline/lib/main.dart | 54 +++++------ packages/z-timeline/lib/state.dart | 44 ++++++--- packages/z-timeline/web/index.html | 96 ++++++------------- 7 files changed, 197 insertions(+), 167 deletions(-) diff --git a/apps/web/src/functions/get-timeline.ts b/apps/web/src/functions/get-timeline.ts index 68e637e..44ad4f7 100644 --- a/apps/web/src/functions/get-timeline.ts +++ b/apps/web/src/functions/get-timeline.ts @@ -5,6 +5,40 @@ import { createServerFn } from "@tanstack/react-start"; import { queryOptions } from "@tanstack/react-query"; import { z } from "zod"; +// --------------------------------------------------------------------------- +// Normalized timeline shape stored in the TanStack Query cache. +// Groups and items are keyed by ID for O(1) lookups; groupOrder preserves +// the display ordering returned by the DB. +// --------------------------------------------------------------------------- + +export type NormalizedGroup = { + id: string; + title: string; + sortOrder: number; +}; + +export type NormalizedItem = { + id: string; + groupId: string; + title: string; + description: string; + start: Date; + end: Date | null; + lane: number; +}; + +export type NormalizedTimeline = { + id: string; + title: string; + groups: Record; + items: Record; + groupOrder: Array; +}; + +// --------------------------------------------------------------------------- +// Server function — returns the raw nested Drizzle result (efficient query). +// --------------------------------------------------------------------------- + export const getTimeline = createServerFn({ method: "GET" }) .inputValidator(z.object({ id: z.string().uuid() })) .handler(async ({ data }) => { @@ -29,8 +63,52 @@ export const getTimeline = createServerFn({ method: "GET" }) return result; }); +// --------------------------------------------------------------------------- +// Normalization — flattens the nested DB result into the cache shape. +// --------------------------------------------------------------------------- + +type NestedTimeline = Awaited>; + +function normalizeTimeline(nested: NestedTimeline): NormalizedTimeline { + const groups: Record = {}; + const items: Record = {}; + const groupOrder: Array = []; + + for (const g of nested.groups) { + groupOrder.push(g.id); + groups[g.id] = { id: g.id, title: g.title, sortOrder: g.sortOrder }; + + for (const item of g.items) { + items[item.id] = { + id: item.id, + groupId: g.id, + title: item.title, + description: item.description, + start: item.start, + end: item.end, + lane: item.lane, + }; + } + } + + return { + id: nested.id, + title: nested.title, + groups, + items, + groupOrder, + }; +} + +// --------------------------------------------------------------------------- +// Query options — cache stores normalized data. +// --------------------------------------------------------------------------- + export const timelineQueryOptions = (timelineId: string) => queryOptions({ queryKey: ["timeline", timelineId], - queryFn: () => getTimeline({ data: { id: timelineId } }), + queryFn: async () => { + const nested = await getTimeline({ data: { id: timelineId } }); + return normalizeTimeline(nested); + }, }); diff --git a/apps/web/src/hooks/use-entry-moved-mutation.ts b/apps/web/src/hooks/use-entry-moved-mutation.ts index e04f753..ce4fe8a 100644 --- a/apps/web/src/hooks/use-entry-moved-mutation.ts +++ b/apps/web/src/hooks/use-entry-moved-mutation.ts @@ -1,9 +1,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; -import type { getTimeline } from "@/functions/get-timeline"; +import type { NormalizedTimeline } from "@/functions/get-timeline"; import { updateTimelineItem } from "@/functions/update-timeline-item"; -type TimelineData = Awaited>; - type EntryMovedVars = { entryId: string; newStart: string; @@ -31,47 +29,23 @@ export function useEntryMovedMutation(timelineId: string) { onMutate: async (vars) => { // Cancel in-flight fetches so they don't overwrite our optimistic update await queryClient.cancelQueries({ queryKey }); - const previous = queryClient.getQueryData(queryKey); + const previous = queryClient.getQueryData(queryKey); - queryClient.setQueryData(queryKey, (old) => { + 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 - ), + items: { + ...old.items, + [vars.entryId]: { + ...old.items[vars.entryId], + start: new Date(vars.newStart), + end: vars.newEnd ? new Date(vars.newEnd) : null, + groupId: vars.newGroupId, + lane: vars.newLane, + }, + }, }; }); diff --git a/apps/web/src/lib/flutter-bridge.ts b/apps/web/src/lib/flutter-bridge.ts index 4614faf..a8e7711 100644 --- a/apps/web/src/lib/flutter-bridge.ts +++ b/apps/web/src/lib/flutter-bridge.ts @@ -4,6 +4,9 @@ * State : React -> Flutter (pushed on every render) * Events : Flutter -> React (emitted by user interaction in Flutter) * + * The bridge uses a **normalized** shape: groups and items are stored as + * Record maps keyed by ID, with `groupOrder` preserving display order. + * * Keep this file in sync with `packages/z-timeline/lib/state.dart` and * the `emitEvent()` calls in `packages/z-timeline/lib/main.dart`. */ @@ -14,6 +17,7 @@ export type FlutterTimelineItem = { id: string; + groupId: string; title: string; description: string | null; start: string; // ISO-8601 @@ -25,17 +29,13 @@ 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; + timeline: { id: string; title: string }; + groups: Record; + items: Record; + groupOrder: Array; selectedItemId: string | null; }; diff --git a/apps/web/src/routes/timeline.$timelineId.tsx b/apps/web/src/routes/timeline.$timelineId.tsx index 6565412..6502d9a 100644 --- a/apps/web/src/routes/timeline.$timelineId.tsx +++ b/apps/web/src/routes/timeline.$timelineId.tsx @@ -24,23 +24,23 @@ function RouteComponent() { const flutterState: FlutterTimelineState = useMemo( () => ({ - timeline: { - id: timeline.id, - title: timeline.title, - groups: timeline.groups.map((group) => ({ - id: group.id, - title: group.title, - sortOrder: group.sortOrder, - items: group.items.map((item) => ({ + timeline: { id: timeline.id, title: timeline.title }, + groups: timeline.groups, + items: Object.fromEntries( + Object.entries(timeline.items).map(([id, item]) => [ + id, + { id: item.id, + groupId: item.groupId, title: item.title, description: item.description, start: item.start.toISOString(), end: item.end?.toISOString() ?? null, lane: item.lane, - })), - })), - }, + }, + ]) + ), + groupOrder: timeline.groupOrder, selectedItemId, }), [timeline, selectedItemId] diff --git a/packages/z-timeline/lib/main.dart b/packages/z-timeline/lib/main.dart index af4cc35..b90a466 100644 --- a/packages/z-timeline/lib/main.dart +++ b/packages/z-timeline/lib/main.dart @@ -45,8 +45,8 @@ class _MainAppState extends State { } void _applyState(TimelineState state) { - final groups = _convertGroups(state.timeline.groups); - final entries = _convertEntries(state.timeline.groups); + final groups = _convertGroups(state); + final entries = _convertEntries(state); final domain = _computeDomain(entries); setState(() { @@ -63,32 +63,35 @@ class _MainAppState extends State { _emitContentHeight(); } - List _convertGroups(List groups) { - return [for (final g in groups) TimelineGroup(id: g.id, title: g.title)]; + /// Build an ordered list of [TimelineGroup] using [groupOrder]. + List _convertGroups(TimelineState state) { + return [ + for (final id in state.groupOrder) + if (state.groups[id] case final g?) + TimelineGroup(id: g.id, title: g.title), + ]; } - List _convertEntries(List groups) { - final entries = []; - for (final group in groups) { - for (final item in group.items) { - final start = DateTime.parse(item.start); - final end = item.end != null - ? DateTime.parse(item.end!) - : start.add(const Duration(days: 1)); + /// Build a flat list of [TimelineEntry] from the normalized items map. + List _convertEntries(TimelineState state) { + return [ + for (final item in state.items.values) + () { + final start = DateTime.parse(item.start); + final end = item.end != null + ? DateTime.parse(item.end!) + : start.add(const Duration(days: 1)); - entries.add( - TimelineEntry( + return TimelineEntry( id: item.id, - groupId: group.id, + groupId: item.groupId, start: start, end: end, lane: item.lane, hasEnd: item.end != null, - ), - ); - } - } - return entries; + ); + }(), + ]; } ({DateTime start, DateTime end}) _computeDomain(List entries) { @@ -156,16 +159,9 @@ class _MainAppState extends State { emitEvent('content_height', {'height': totalHeight}); } + /// O(1) label lookup from the normalized items map. String _labelForEntry(TimelineEntry entry) { - final state = _state; - if (state == null) return entry.id; - - for (final group in state.timeline.groups) { - for (final item in group.items) { - if (item.id == entry.id) return item.title; - } - } - return entry.id; + return _state?.items[entry.id]?.title ?? entry.id; } static const _groupColors = [ diff --git a/packages/z-timeline/lib/state.dart b/packages/z-timeline/lib/state.dart index 81c4506..6cb43d5 100644 --- a/packages/z-timeline/lib/state.dart +++ b/packages/z-timeline/lib/state.dart @@ -1,12 +1,40 @@ +// Bridge data-transfer types deserialized from JSON pushed by the React host. +// +// The shape is normalized: groups and items are stored as maps keyed by +// ID, with groupOrder preserving display ordering. +// +// Keep in sync with `apps/web/src/lib/flutter-bridge.ts`. + class TimelineState { final TimelineData timeline; + final Map groups; + final Map items; + final List groupOrder; final String? selectedItemId; - TimelineState({required this.timeline, this.selectedItemId}); + TimelineState({ + required this.timeline, + required this.groups, + required this.items, + required this.groupOrder, + this.selectedItemId, + }); factory TimelineState.fromJson(Map json) { + final rawGroups = json['groups'] as Map; + final rawItems = json['items'] as Map; + return TimelineState( timeline: TimelineData.fromJson(json['timeline'] as Map), + groups: rawGroups.map( + (k, v) => + MapEntry(k, TimelineGroupData.fromJson(v as Map)), + ), + items: rawItems.map( + (k, v) => + MapEntry(k, TimelineItemData.fromJson(v as Map)), + ), + groupOrder: (json['groupOrder'] as List).cast(), selectedItemId: json['selectedItemId'] as String?, ); } @@ -15,17 +43,13 @@ class TimelineState { class TimelineData { final String id; final String title; - final List groups; - TimelineData({required this.id, required this.title, required this.groups}); + TimelineData({required this.id, required this.title}); factory TimelineData.fromJson(Map json) { return TimelineData( id: json['id'] as String, title: json['title'] as String, - groups: (json['groups'] as List) - .map((g) => TimelineGroupData.fromJson(g as Map)) - .toList(), ); } } @@ -34,13 +58,11 @@ class TimelineGroupData { final String id; final String title; final int sortOrder; - final List items; TimelineGroupData({ required this.id, required this.title, required this.sortOrder, - required this.items, }); factory TimelineGroupData.fromJson(Map json) { @@ -48,15 +70,13 @@ class TimelineGroupData { id: json['id'] as String, title: json['title'] as String, sortOrder: json['sortOrder'] as int, - items: (json['items'] as List) - .map((i) => TimelineItemData.fromJson(i as Map)) - .toList(), ); } } class TimelineItemData { final String id; + final String groupId; final String title; final String? description; final String start; @@ -65,6 +85,7 @@ class TimelineItemData { TimelineItemData({ required this.id, + required this.groupId, required this.title, this.description, required this.start, @@ -75,6 +96,7 @@ class TimelineItemData { factory TimelineItemData.fromJson(Map json) { return TimelineItemData( id: json['id'] as String, + groupId: json['groupId'] as String, title: json['title'] as String, description: json['description'] as String?, start: json['start'] as String, diff --git a/packages/z-timeline/web/index.html b/packages/z-timeline/web/index.html index 32dbcd4..61b81a9 100644 --- a/packages/z-timeline/web/index.html +++ b/packages/z-timeline/web/index.html @@ -42,44 +42,26 @@ if (window.__zendegi__) return; var state = { - timeline: { - id: "tl-1", - title: "My Project", - groups: [ - { - id: "g-1", - title: "Design", - sortOrder: 0, - items: [ - { 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 }, - ], - }, - { - id: "g-2", - title: "Engineering", - sortOrder: 1, - items: [ - { 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", lane: 2 }, - { id: "e-6", title: "Dashboard UI", start: "2026-01-15", end: "2026-01-25", lane: 3 }, - ], - }, - { - id: "g-3", - title: "Launch", - sortOrder: 2, - items: [ - { 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 }, - ], - }, - ], + timeline: { id: "tl-1", title: "My Project" }, + groups: { + "g-1": { id: "g-1", title: "Design", sortOrder: 0 }, + "g-2": { id: "g-2", title: "Engineering", sortOrder: 1 }, + "g-3": { id: "g-3", title: "Launch", sortOrder: 2 }, }, + items: { + "e-1": { id: "e-1", groupId: "g-1", title: "Brand identity", start: "2026-01-02", end: "2026-01-08", lane: 1 }, + "e-2": { id: "e-2", groupId: "g-1", title: "UI mockups", start: "2026-01-06", end: "2026-01-14", lane: 2 }, + "e-3": { id: "e-3", groupId: "g-1", title: "Design review", start: "2026-01-20", end: "2026-01-22", lane: 1 }, + "e-10": { id: "e-10", groupId: "g-1", title: "Kickoff meeting", start: "2026-01-01", end: null, lane: 3 }, + "e-4": { id: "e-4", groupId: "g-2", title: "API scaffolding", start: "2026-01-05", end: "2026-01-12", lane: 1 }, + "e-5": { id: "e-5", groupId: "g-2", title: "Auth flow", start: "2026-01-10", end: "2026-01-18", lane: 2 }, + "e-6": { id: "e-6", groupId: "g-2", title: "Dashboard UI", start: "2026-01-15", end: "2026-01-25", lane: 3 }, + "e-7": { id: "e-7", groupId: "g-3", title: "QA testing", start: "2026-01-19", end: "2026-01-26", lane: 1 }, + "e-8": { id: "e-8", groupId: "g-3", title: "Beta release", start: "2026-01-24", end: "2026-01-28", lane: 2 }, + "e-9": { id: "e-9", groupId: "g-3", title: "Marketing prep", start: "2026-01-08", end: "2026-01-15", lane: 1 }, + "e-11": { id: "e-11", groupId: "g-3", title: "Go-live", start: "2026-01-28", end: null, lane: 3 }, + }, + groupOrder: ["g-1", "g-2", "g-3"], selectedItemId: null, }; @@ -96,39 +78,17 @@ if (event.type === "entry_moved") { var p = event.payload; - var entry = null; - var sourceGroup = null; - - // Find and remove the entry from its current group - for (var i = 0; i < state.timeline.groups.length; i++) { - var g = state.timeline.groups[i]; - for (var j = 0; j < g.items.length; j++) { - if (g.items[j].id === p.entryId) { - entry = g.items.splice(j, 1)[0]; - sourceGroup = g; - break; - } - } - if (entry) break; - } - - if (entry) { - // Update start/end from payload (ISO 8601 → date-only) - entry.start = new Date(p.newStart).toISOString().split("T")[0]; - entry.end = p.newEnd + var item = state.items[p.entryId]; + if (item) { + // Update in place — normalized makes this trivial + item.start = p.newStart + ? new Date(p.newStart).toISOString().split("T")[0] + : item.start; + item.end = p.newEnd ? new Date(p.newEnd).toISOString().split("T")[0] : null; - - // Preserve the lane from the drop target - entry.lane = p.newLane; - - // Add to target group - var targetGroup = state.timeline.groups.find( - function (g) { return g.id === p.newGroupId; } - ); - if (targetGroup) { - targetGroup.items.push(entry); - } + item.groupId = p.newGroupId; + item.lane = p.newLane; // Push updated state back to Flutter if (_updateState) {