normalize data

This commit is contained in:
2026-03-02 11:05:57 +01:00
parent 22067c4904
commit dbfb29703c
7 changed files with 197 additions and 167 deletions

View File

@@ -5,6 +5,40 @@ import { createServerFn } from "@tanstack/react-start";
import { queryOptions } from "@tanstack/react-query"; import { queryOptions } from "@tanstack/react-query";
import { z } from "zod"; 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<string, NormalizedGroup>;
items: Record<string, NormalizedItem>;
groupOrder: Array<string>;
};
// ---------------------------------------------------------------------------
// Server function — returns the raw nested Drizzle result (efficient query).
// ---------------------------------------------------------------------------
export const getTimeline = createServerFn({ method: "GET" }) export const getTimeline = createServerFn({ method: "GET" })
.inputValidator(z.object({ id: z.string().uuid() })) .inputValidator(z.object({ id: z.string().uuid() }))
.handler(async ({ data }) => { .handler(async ({ data }) => {
@@ -29,8 +63,52 @@ export const getTimeline = createServerFn({ method: "GET" })
return result; return result;
}); });
// ---------------------------------------------------------------------------
// Normalization — flattens the nested DB result into the cache shape.
// ---------------------------------------------------------------------------
type NestedTimeline = Awaited<ReturnType<typeof getTimeline>>;
function normalizeTimeline(nested: NestedTimeline): NormalizedTimeline {
const groups: Record<string, NormalizedGroup> = {};
const items: Record<string, NormalizedItem> = {};
const groupOrder: Array<string> = [];
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) => export const timelineQueryOptions = (timelineId: string) =>
queryOptions({ queryOptions({
queryKey: ["timeline", timelineId], queryKey: ["timeline", timelineId],
queryFn: () => getTimeline({ data: { id: timelineId } }), queryFn: async () => {
const nested = await getTimeline({ data: { id: timelineId } });
return normalizeTimeline(nested);
},
}); });

View File

@@ -1,9 +1,7 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"; 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"; import { updateTimelineItem } from "@/functions/update-timeline-item";
type TimelineData = Awaited<ReturnType<typeof getTimeline>>;
type EntryMovedVars = { type EntryMovedVars = {
entryId: string; entryId: string;
newStart: string; newStart: string;
@@ -31,47 +29,23 @@ export function useEntryMovedMutation(timelineId: string) {
onMutate: async (vars) => { onMutate: async (vars) => {
// Cancel in-flight fetches so they don't overwrite our optimistic update // Cancel in-flight fetches so they don't overwrite our optimistic update
await queryClient.cancelQueries({ queryKey }); await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<TimelineData>(queryKey); const previous = queryClient.getQueryData<NormalizedTimeline>(queryKey);
queryClient.setQueryData<TimelineData>(queryKey, (old) => { queryClient.setQueryData<NormalizedTimeline>(queryKey, (old) => {
if (!old) return 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 { return {
...old, ...old,
groups: groups.map((group) => items: {
group.id === vars.newGroupId ...old.items,
? { [vars.entryId]: {
...group, ...old.items[vars.entryId],
items: [ start: new Date(vars.newStart),
...group.items, end: vars.newEnd ? new Date(vars.newEnd) : null,
{ groupId: vars.newGroupId,
...movedItem!, lane: vars.newLane,
start: new Date(vars.newStart), },
end: vars.newEnd ? new Date(vars.newEnd) : null, },
lane: vars.newLane,
timelineGroupId: vars.newGroupId,
},
],
}
: group
),
}; };
}); });

View File

@@ -4,6 +4,9 @@
* State : React -> Flutter (pushed on every render) * State : React -> Flutter (pushed on every render)
* Events : Flutter -> React (emitted by user interaction in Flutter) * 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 * Keep this file in sync with `packages/z-timeline/lib/state.dart` and
* the `emitEvent()` calls in `packages/z-timeline/lib/main.dart`. * the `emitEvent()` calls in `packages/z-timeline/lib/main.dart`.
*/ */
@@ -14,6 +17,7 @@
export type FlutterTimelineItem = { export type FlutterTimelineItem = {
id: string; id: string;
groupId: string;
title: string; title: string;
description: string | null; description: string | null;
start: string; // ISO-8601 start: string; // ISO-8601
@@ -25,17 +29,13 @@ export type FlutterTimelineGroup = {
id: string; id: string;
title: string; title: string;
sortOrder: number; sortOrder: number;
items: Array<FlutterTimelineItem>;
};
export type FlutterTimelineData = {
id: string;
title: string;
groups: Array<FlutterTimelineGroup>;
}; };
export type FlutterTimelineState = { export type FlutterTimelineState = {
timeline: FlutterTimelineData; timeline: { id: string; title: string };
groups: Record<string, FlutterTimelineGroup>;
items: Record<string, FlutterTimelineItem>;
groupOrder: Array<string>;
selectedItemId: string | null; selectedItemId: string | null;
}; };

View File

@@ -24,23 +24,23 @@ function RouteComponent() {
const flutterState: FlutterTimelineState = useMemo( const flutterState: FlutterTimelineState = useMemo(
() => ({ () => ({
timeline: { timeline: { id: timeline.id, title: timeline.title },
id: timeline.id, groups: timeline.groups,
title: timeline.title, items: Object.fromEntries(
groups: timeline.groups.map((group) => ({ Object.entries(timeline.items).map(([id, item]) => [
id: group.id, id,
title: group.title, {
sortOrder: group.sortOrder,
items: group.items.map((item) => ({
id: item.id, id: item.id,
groupId: item.groupId,
title: item.title, title: item.title,
description: item.description, description: item.description,
start: item.start.toISOString(), start: item.start.toISOString(),
end: item.end?.toISOString() ?? null, end: item.end?.toISOString() ?? null,
lane: item.lane, lane: item.lane,
})), },
})), ])
}, ),
groupOrder: timeline.groupOrder,
selectedItemId, selectedItemId,
}), }),
[timeline, selectedItemId] [timeline, selectedItemId]

View File

@@ -45,8 +45,8 @@ class _MainAppState extends State<MainApp> {
} }
void _applyState(TimelineState state) { void _applyState(TimelineState state) {
final groups = _convertGroups(state.timeline.groups); final groups = _convertGroups(state);
final entries = _convertEntries(state.timeline.groups); final entries = _convertEntries(state);
final domain = _computeDomain(entries); final domain = _computeDomain(entries);
setState(() { setState(() {
@@ -63,32 +63,35 @@ class _MainAppState extends State<MainApp> {
_emitContentHeight(); _emitContentHeight();
} }
List<TimelineGroup> _convertGroups(List<TimelineGroupData> groups) { /// Build an ordered list of [TimelineGroup] using [groupOrder].
return [for (final g in groups) TimelineGroup(id: g.id, title: g.title)]; List<TimelineGroup> _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<TimelineEntry> _convertEntries(List<TimelineGroupData> groups) { /// Build a flat list of [TimelineEntry] from the normalized items map.
final entries = <TimelineEntry>[]; List<TimelineEntry> _convertEntries(TimelineState state) {
for (final group in groups) { return [
for (final item in group.items) { for (final item in state.items.values)
final start = DateTime.parse(item.start); () {
final end = item.end != null final start = DateTime.parse(item.start);
? DateTime.parse(item.end!) final end = item.end != null
: start.add(const Duration(days: 1)); ? DateTime.parse(item.end!)
: start.add(const Duration(days: 1));
entries.add( return TimelineEntry(
TimelineEntry(
id: item.id, id: item.id,
groupId: group.id, groupId: item.groupId,
start: start, start: start,
end: end, end: end,
lane: item.lane, lane: item.lane,
hasEnd: item.end != null, hasEnd: item.end != null,
), );
); }(),
} ];
}
return entries;
} }
({DateTime start, DateTime end}) _computeDomain(List<TimelineEntry> entries) { ({DateTime start, DateTime end}) _computeDomain(List<TimelineEntry> entries) {
@@ -156,16 +159,9 @@ class _MainAppState extends State<MainApp> {
emitEvent('content_height', {'height': totalHeight}); emitEvent('content_height', {'height': totalHeight});
} }
/// O(1) label lookup from the normalized items map.
String _labelForEntry(TimelineEntry entry) { String _labelForEntry(TimelineEntry entry) {
final state = _state; return _state?.items[entry.id]?.title ?? entry.id;
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;
} }
static const _groupColors = [ static const _groupColors = [

View File

@@ -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 { class TimelineState {
final TimelineData timeline; final TimelineData timeline;
final Map<String, TimelineGroupData> groups;
final Map<String, TimelineItemData> items;
final List<String> groupOrder;
final String? selectedItemId; 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<String, dynamic> json) { factory TimelineState.fromJson(Map<String, dynamic> json) {
final rawGroups = json['groups'] as Map<String, dynamic>;
final rawItems = json['items'] as Map<String, dynamic>;
return TimelineState( return TimelineState(
timeline: TimelineData.fromJson(json['timeline'] as Map<String, dynamic>), timeline: TimelineData.fromJson(json['timeline'] as Map<String, dynamic>),
groups: rawGroups.map(
(k, v) =>
MapEntry(k, TimelineGroupData.fromJson(v as Map<String, dynamic>)),
),
items: rawItems.map(
(k, v) =>
MapEntry(k, TimelineItemData.fromJson(v as Map<String, dynamic>)),
),
groupOrder: (json['groupOrder'] as List<dynamic>).cast<String>(),
selectedItemId: json['selectedItemId'] as String?, selectedItemId: json['selectedItemId'] as String?,
); );
} }
@@ -15,17 +43,13 @@ class TimelineState {
class TimelineData { class TimelineData {
final String id; final String id;
final String title; final String title;
final List<TimelineGroupData> groups;
TimelineData({required this.id, required this.title, required this.groups}); TimelineData({required this.id, required this.title});
factory TimelineData.fromJson(Map<String, dynamic> json) { factory TimelineData.fromJson(Map<String, dynamic> json) {
return TimelineData( return TimelineData(
id: json['id'] as String, id: json['id'] as String,
title: json['title'] as String, title: json['title'] as String,
groups: (json['groups'] as List<dynamic>)
.map((g) => TimelineGroupData.fromJson(g as Map<String, dynamic>))
.toList(),
); );
} }
} }
@@ -34,13 +58,11 @@ class TimelineGroupData {
final String id; final String id;
final String title; final String title;
final int sortOrder; final int sortOrder;
final List<TimelineItemData> items;
TimelineGroupData({ TimelineGroupData({
required this.id, required this.id,
required this.title, required this.title,
required this.sortOrder, required this.sortOrder,
required this.items,
}); });
factory TimelineGroupData.fromJson(Map<String, dynamic> json) { factory TimelineGroupData.fromJson(Map<String, dynamic> json) {
@@ -48,15 +70,13 @@ class TimelineGroupData {
id: json['id'] as String, id: json['id'] as String,
title: json['title'] as String, title: json['title'] as String,
sortOrder: json['sortOrder'] as int, sortOrder: json['sortOrder'] as int,
items: (json['items'] as List<dynamic>)
.map((i) => TimelineItemData.fromJson(i as Map<String, dynamic>))
.toList(),
); );
} }
} }
class TimelineItemData { class TimelineItemData {
final String id; final String id;
final String groupId;
final String title; final String title;
final String? description; final String? description;
final String start; final String start;
@@ -65,6 +85,7 @@ class TimelineItemData {
TimelineItemData({ TimelineItemData({
required this.id, required this.id,
required this.groupId,
required this.title, required this.title,
this.description, this.description,
required this.start, required this.start,
@@ -75,6 +96,7 @@ class TimelineItemData {
factory TimelineItemData.fromJson(Map<String, dynamic> json) { factory TimelineItemData.fromJson(Map<String, dynamic> json) {
return TimelineItemData( return TimelineItemData(
id: json['id'] as String, id: json['id'] as String,
groupId: json['groupId'] as String,
title: json['title'] as String, title: json['title'] as String,
description: json['description'] as String?, description: json['description'] as String?,
start: json['start'] as String, start: json['start'] as String,

View File

@@ -42,44 +42,26 @@
if (window.__zendegi__) return; if (window.__zendegi__) return;
var state = { var state = {
timeline: { timeline: { id: "tl-1", title: "My Project" },
id: "tl-1", groups: {
title: "My Project", "g-1": { id: "g-1", title: "Design", sortOrder: 0 },
groups: [ "g-2": { id: "g-2", title: "Engineering", sortOrder: 1 },
{ "g-3": { id: "g-3", title: "Launch", sortOrder: 2 },
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 },
],
},
],
}, },
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, selectedItemId: null,
}; };
@@ -96,39 +78,17 @@
if (event.type === "entry_moved") { if (event.type === "entry_moved") {
var p = event.payload; var p = event.payload;
var entry = null; var item = state.items[p.entryId];
var sourceGroup = null; if (item) {
// Update in place — normalized makes this trivial
// Find and remove the entry from its current group item.start = p.newStart
for (var i = 0; i < state.timeline.groups.length; i++) { ? new Date(p.newStart).toISOString().split("T")[0]
var g = state.timeline.groups[i]; : item.start;
for (var j = 0; j < g.items.length; j++) { item.end = p.newEnd
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
? new Date(p.newEnd).toISOString().split("T")[0] ? new Date(p.newEnd).toISOString().split("T")[0]
: null; : null;
item.groupId = p.newGroupId;
// Preserve the lane from the drop target item.lane = p.newLane;
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);
}
// Push updated state back to Flutter // Push updated state back to Flutter
if (_updateState) { if (_updateState) {