normalize data
This commit is contained in:
@@ -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);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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: [
|
|
||||||
...group.items,
|
|
||||||
{
|
|
||||||
...movedItem!,
|
|
||||||
start: new Date(vars.newStart),
|
start: new Date(vars.newStart),
|
||||||
end: vars.newEnd ? new Date(vars.newEnd) : null,
|
end: vars.newEnd ? new Date(vars.newEnd) : null,
|
||||||
|
groupId: vars.newGroupId,
|
||||||
lane: vars.newLane,
|
lane: vars.newLane,
|
||||||
timelineGroupId: vars.newGroupId,
|
|
||||||
},
|
},
|
||||||
],
|
},
|
||||||
}
|
|
||||||
: group
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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 start = DateTime.parse(item.start);
|
||||||
final end = item.end != null
|
final end = item.end != null
|
||||||
? DateTime.parse(item.end!)
|
? DateTime.parse(item.end!)
|
||||||
: start.add(const Duration(days: 1));
|
: 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 = [
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
items: {
|
||||||
id: "g-2",
|
"e-1": { id: "e-1", groupId: "g-1", title: "Brand identity", start: "2026-01-02", end: "2026-01-08", lane: 1 },
|
||||||
title: "Engineering",
|
"e-2": { id: "e-2", groupId: "g-1", title: "UI mockups", start: "2026-01-06", end: "2026-01-14", lane: 2 },
|
||||||
sortOrder: 1,
|
"e-3": { id: "e-3", groupId: "g-1", title: "Design review", start: "2026-01-20", end: "2026-01-22", lane: 1 },
|
||||||
items: [
|
"e-10": { id: "e-10", groupId: "g-1", title: "Kickoff meeting", start: "2026-01-01", end: null, lane: 3 },
|
||||||
{ id: "e-4", title: "API scaffolding", start: "2026-01-05", end: "2026-01-12", lane: 1 },
|
"e-4": { id: "e-4", groupId: "g-2", 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 },
|
"e-5": { id: "e-5", groupId: "g-2", 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 },
|
"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 },
|
||||||
id: "g-3",
|
"e-11": { id: "e-11", groupId: "g-3", title: "Go-live", start: "2026-01-28", end: null, lane: 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 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
|
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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user