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 { 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" })
.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<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) =>
queryOptions({
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 type { getTimeline } from "@/functions/get-timeline";
import type { NormalizedTimeline } from "@/functions/get-timeline";
import { updateTimelineItem } from "@/functions/update-timeline-item";
type TimelineData = Awaited<ReturnType<typeof getTimeline>>;
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<TimelineData>(queryKey);
const previous = queryClient.getQueryData<NormalizedTimeline>(queryKey);
queryClient.setQueryData<TimelineData>(queryKey, (old) => {
queryClient.setQueryData<NormalizedTimeline>(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,
},
},
};
});

View File

@@ -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<FlutterTimelineItem>;
};
export type FlutterTimelineData = {
id: string;
title: string;
groups: Array<FlutterTimelineGroup>;
};
export type FlutterTimelineState = {
timeline: FlutterTimelineData;
timeline: { id: string; title: string };
groups: Record<string, FlutterTimelineGroup>;
items: Record<string, FlutterTimelineItem>;
groupOrder: Array<string>;
selectedItemId: string | null;
};

View File

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