normalize data
This commit is contained in:
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user