diff --git a/.prettierignore b/.prettierignore index 3fc32ed..499bf27 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,4 +2,4 @@ pnpm-lock.yaml **/.claude/** **/public/flutter/** -**/z-timeline/** +**/z-flutter/** diff --git a/apps/web/src/components/item-form-drawer.tsx b/apps/web/src/components/item-form-drawer.tsx index a5fbfd0..64d6cbc 100644 --- a/apps/web/src/components/item-form-drawer.tsx +++ b/apps/web/src/components/item-form-drawer.tsx @@ -1,10 +1,11 @@ -import { useState } from "react"; +import { useCallback, useEffect } from "react"; import { useForm } from "@tanstack/react-form"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Button } from "./ui/button"; import { FieldControl, FieldError, FieldLabel, FieldRoot } from "./ui/field"; +import { inputClasses } from "./ui/input"; import { DrawerClose, DrawerContent, @@ -12,63 +13,101 @@ import { DrawerPortal, DrawerRoot, DrawerTitle, - DrawerTrigger, DrawerViewport, } from "./ui/drawer"; +import type { NormalizedGroup, NormalizedItem } from "@/functions/get-timeline"; +import { updateTimelineItem } from "@/functions/update-timeline-item"; import { createTimelineItem } from "@/functions/create-timeline-item"; -export default function ItemFormDrawer({ - timelineGroupId, - timelineId, -}: { - timelineGroupId: string; +type ItemFormDrawerProps = { + open: boolean; + onOpenChange: (open: boolean) => void; timelineId: string; -}) { - const [open, setOpen] = useState(false); + groups: Record; + groupOrder: Array; + editItem?: NormalizedItem | null; +}; + +export default function ItemFormDrawer({ + open, + onOpenChange, + timelineId, + groups, + groupOrder, + editItem, +}: ItemFormDrawerProps) { + const isEdit = !!editItem; const queryClient = useQueryClient(); + const getDefaultValues = useCallback( + () => ({ + title: editItem?.title ?? "", + description: editItem?.description ?? "", + start: editItem + ? editItem.start.toISOString().split("T")[0] + : new Date().toISOString().split("T")[0], + end: editItem?.end ? editItem.end.toISOString().split("T")[0] : "", + groupId: (editItem?.groupId ?? groupOrder[0]) || "", + }), + [editItem, groupOrder] + ); + const form = useForm({ - defaultValues: { - title: "", - description: "", - start: new Date().toISOString().split("T")[0], - end: "", - }, + defaultValues: getDefaultValues(), onSubmit: async ({ value }) => { try { - await createTimelineItem({ - data: { - title: value.title, - description: value.description, - start: value.start, - end: value.end || undefined, - timelineGroupId, - }, - }); + if (isEdit) { + await updateTimelineItem({ + data: { + id: editItem.id, + title: value.title, + description: value.description, + start: value.start, + end: value.end || null, + timelineGroupId: value.groupId, + lane: editItem.lane, + }, + }); + toast.success("Item updated"); + } else { + await createTimelineItem({ + data: { + title: value.title, + description: value.description, + start: value.start, + end: value.end || null, + timelineGroupId: value.groupId, + }, + }); + toast.success("Item created"); + } await queryClient.invalidateQueries({ queryKey: ["timeline", timelineId], }); - setOpen(false); + onOpenChange(false); form.reset(); - toast.success("Item created"); } catch (error) { toast.error( - error instanceof Error ? error.message : "Failed to create item" + error instanceof Error + ? error.message + : `Failed to ${isEdit ? "update" : "create"} item` ); } }, }); + useEffect(() => { + if (!open) return; + form.reset(getDefaultValues()); + }, [open, getDefaultValues, form]); + return ( - - }> - Add Item - +
- New Item + {isEdit ? "Edit Item" : "New Item"} }> Close @@ -122,6 +161,26 @@ export default function ItemFormDrawer({ )} + + {(field) => ( + + Group + + + )} + + {(field) => ( @@ -168,7 +227,13 @@ export default function ItemFormDrawer({ type="submit" disabled={!state.canSubmit || state.isSubmitting} > - {state.isSubmitting ? "Creating..." : "Create Item"} + {state.isSubmitting + ? isEdit + ? "Saving..." + : "Creating..." + : isEdit + ? "Save Changes" + : "Create Item"} )} diff --git a/apps/web/src/components/ui/drawer.tsx b/apps/web/src/components/ui/drawer.tsx index 370144f..3be6fd1 100644 --- a/apps/web/src/components/ui/drawer.tsx +++ b/apps/web/src/components/ui/drawer.tsx @@ -26,7 +26,14 @@ const { viewport, popup, title, description, close, content } = drawerStyles(); type DrawerRootProps = React.ComponentProps; function DrawerRoot(props: DrawerRootProps) { - return ; + return ( + + ); } type DrawerTriggerProps = React.ComponentProps; diff --git a/apps/web/src/functions/create-timeline-item.ts b/apps/web/src/functions/create-timeline-item.ts index 067a666..293b160 100644 --- a/apps/web/src/functions/create-timeline-item.ts +++ b/apps/web/src/functions/create-timeline-item.ts @@ -15,8 +15,8 @@ export const createTimelineItem = createServerFn({ method: "POST" }) start: z.string().transform((s) => new Date(s)), end: z .string() - .optional() - .transform((s) => (s ? new Date(s) : undefined)), + .nullable() + .transform((s) => (s ? new Date(s) : null)), timelineGroupId: z.string().uuid(), }) ) diff --git a/apps/web/src/functions/update-timeline-item.ts b/apps/web/src/functions/update-timeline-item.ts index 31341c3..1a90eba 100644 --- a/apps/web/src/functions/update-timeline-item.ts +++ b/apps/web/src/functions/update-timeline-item.ts @@ -17,6 +17,8 @@ export const updateTimelineItem = createServerFn({ method: "POST" }) .transform((s) => (s ? new Date(s) : null)), timelineGroupId: z.string().uuid(), lane: z.number().int().min(1), + title: z.string().min(1).optional(), + description: z.string().optional(), }) ) .handler(async ({ data }) => { @@ -27,6 +29,10 @@ export const updateTimelineItem = createServerFn({ method: "POST" }) end: data.end, timelineGroupId: data.timelineGroupId, lane: data.lane, + ...(data.title !== undefined && { title: data.title }), + ...(data.description !== undefined && { + description: data.description, + }), }) .where(eq(timelineItem.id, data.id)) .returning(); diff --git a/apps/web/src/lib/assign-lane.ts b/apps/web/src/lib/assign-lane.ts index c1a9bdd..f385aa4 100644 --- a/apps/web/src/lib/assign-lane.ts +++ b/apps/web/src/lib/assign-lane.ts @@ -6,7 +6,7 @@ * lanes on item creation. */ export function assignLane( - existingItems: { start: Date; end: Date | null; lane: number }[], + existingItems: Array<{ start: Date; end: Date | null; lane: number }>, newStart: Date, newEnd: Date | null ): number { diff --git a/apps/web/src/routes/timeline.$timelineId.tsx b/apps/web/src/routes/timeline.$timelineId.tsx index 5d4989e..9d022d9 100644 --- a/apps/web/src/routes/timeline.$timelineId.tsx +++ b/apps/web/src/routes/timeline.$timelineId.tsx @@ -7,6 +7,7 @@ import { timelineQueryOptions } from "@/functions/get-timeline"; import { FlutterView } from "@/components/flutter-view"; import { Button } from "@/components/ui/button"; import UserMenu from "@/components/user-menu"; +import ItemFormDrawer from "@/components/item-form-drawer"; import { useEntryMovedMutation } from "@/hooks/use-entry-moved-mutation"; import { useEntryResizedMutation } from "@/hooks/use-entry-resized-mutation"; import { useTheme } from "@/lib/theme"; @@ -24,11 +25,16 @@ function RouteComponent() { const { timelineId } = Route.useParams(); const { data: timeline } = useSuspenseQuery(timelineQueryOptions(timelineId)); const [selectedItemId, setSelectedItemId] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); const [flutterHeight, setFlutterHeight] = useState(); const entryMoved = useEntryMovedMutation(timelineId); const entryResized = useEntryResizedMutation(timelineId); const { theme, toggleTheme } = useTheme(); + const editItem = selectedItemId + ? (timeline.items[selectedItemId] ?? null) + : null; + const flutterState: FlutterTimelineState = useMemo( () => ({ timeline: { id: timeline.id, title: timeline.title }, @@ -62,9 +68,11 @@ function RouteComponent() { break; case "item_selected": setSelectedItemId(event.payload.itemId); + setDrawerOpen(true); break; case "item_deselected": setSelectedItemId(null); + setDrawerOpen(false); break; case "entry_moved": entryMoved.mutate(event.payload); @@ -77,6 +85,18 @@ function RouteComponent() { [entryMoved, entryResized] ); + const handleDrawerOpenChange = useCallback((open: boolean) => { + setDrawerOpen(open); + if (!open) { + setSelectedItemId(null); + } + }, []); + + const handleAddItem = useCallback(() => { + setSelectedItemId(null); + setDrawerOpen(true); + }, []); + return (
@@ -91,7 +111,7 @@ function RouteComponent() {

{timeline.title}

- @@ -120,6 +140,15 @@ function RouteComponent() { className="overflow-hidden" height={flutterHeight} /> + +
); } diff --git a/packages/db/src/scripts/fix-lanes.ts b/packages/db/src/scripts/fix-lanes.ts index b632ac3..12fcb81 100644 --- a/packages/db/src/scripts/fix-lanes.ts +++ b/packages/db/src/scripts/fix-lanes.ts @@ -24,7 +24,7 @@ interface ItemForLane { } function assignLane( - existing: ItemForLane[], + existing: Array, newStart: Date, newEnd: Date | null ): number { @@ -57,7 +57,7 @@ async function main() { if (items.length === 0) continue; - const assigned: ItemForLane[] = []; + const assigned: Array = []; let updated = 0; for (const item of items) { diff --git a/packages/z-flutter/lib/main.dart b/packages/z-flutter/lib/main.dart index 0912160..7647ec9 100644 --- a/packages/z-flutter/lib/main.dart +++ b/packages/z-flutter/lib/main.dart @@ -125,6 +125,14 @@ class _MainAppState extends State { return (start: earliest.subtract(padding), end: latest.add(padding)); } + void _onEntrySelected(TimelineEntry entry) { + emitEvent('item_selected', {'itemId': entry.id}); + } + + void _onBackgroundTap() { + emitEvent('item_deselected'); + } + void _onEntryMoved( TimelineEntry entry, DateTime newStart, @@ -346,6 +354,7 @@ class _MainAppState extends State { const ZTimelineTieredHeader(), Expanded( child: ZTimelineInteractor( + onBackgroundTap: _onBackgroundTap, child: ZTimelineView( groups: _groups, entries: _entries, @@ -355,6 +364,8 @@ class _MainAppState extends State { enableDrag: true, onEntryMoved: _onEntryMoved, onEntryResized: _onEntryResized, + onEntrySelected: _onEntrySelected, + selectedEntryId: _state?.selectedItemId, ), ), ), diff --git a/packages/z-flutter/packages/z_timeline/lib/src/widgets/event_pill.dart b/packages/z-flutter/packages/z_timeline/lib/src/widgets/event_pill.dart index aa70293..a56fd02 100644 --- a/packages/z-flutter/packages/z_timeline/lib/src/widgets/event_pill.dart +++ b/packages/z-flutter/packages/z_timeline/lib/src/widgets/event_pill.dart @@ -5,10 +5,16 @@ import '../constants.dart'; /// A standalone event pill widget with entry color, rounded corners, /// and auto text color based on brightness. class EventPill extends StatelessWidget { - const EventPill({required this.color, required this.label, super.key}); + const EventPill({ + required this.color, + required this.label, + this.isSelected = false, + super.key, + }); final Color color; final String label; + final bool isSelected; @override Widget build(BuildContext context) { @@ -17,6 +23,8 @@ class EventPill extends StatelessWidget { ? Colors.white : Colors.black87; + final primaryColor = Theme.of(context).colorScheme.primary; + return Container( padding: ZTimelineConstants.pillPadding, decoration: BoxDecoration( @@ -24,6 +32,9 @@ class EventPill extends StatelessWidget { borderRadius: BorderRadius.circular( ZTimelineConstants.pillBorderRadius, ), + border: isSelected + ? Border.all(color: primaryColor, width: 2) + : null, ), alignment: Alignment.centerLeft, child: Text( diff --git a/packages/z-flutter/packages/z_timeline/lib/src/widgets/event_point.dart b/packages/z-flutter/packages/z_timeline/lib/src/widgets/event_point.dart index b38119b..a889f68 100644 --- a/packages/z-flutter/packages/z_timeline/lib/src/widgets/event_point.dart +++ b/packages/z-flutter/packages/z_timeline/lib/src/widgets/event_point.dart @@ -10,11 +10,13 @@ class EventPoint extends StatelessWidget { required this.color, required this.label, this.maxTextWidth, + this.isSelected = false, super.key, }); final Color color; final String label; + final bool isSelected; /// Maximum width for the label text. When provided, text truncates with /// ellipsis. When null the text sizes naturally. @@ -49,6 +51,12 @@ class EventPoint extends StatelessWidget { decoration: BoxDecoration( color: color, shape: BoxShape.circle, + border: isSelected + ? Border.all( + color: Theme.of(context).colorScheme.primary, + width: 2, + ) + : null, ), ), if (showText) ...[ diff --git a/packages/z-flutter/packages/z_timeline/lib/src/widgets/interactive_event_pill.dart b/packages/z-flutter/packages/z_timeline/lib/src/widgets/interactive_event_pill.dart index 98cdd9c..d8917ef 100644 --- a/packages/z-flutter/packages/z_timeline/lib/src/widgets/interactive_event_pill.dart +++ b/packages/z-flutter/packages/z_timeline/lib/src/widgets/interactive_event_pill.dart @@ -31,6 +31,8 @@ class InteractiveEventPill extends StatefulWidget { this.allEntries = const [], this.onEntryResized, this.onEntryMoved, + this.onEntrySelected, + this.selectedEntryId, this.groupRegistry, this.nextEntryStartX, super.key, @@ -46,6 +48,8 @@ class InteractiveEventPill extends StatefulWidget { final List allEntries; final OnEntryResized? onEntryResized; final OnEntryMoved? onEntryMoved; + final OnEntrySelected? onEntrySelected; + final String? selectedEntryId; final TimelineGroupRegistry? groupRegistry; /// Normalized startX of the next entry in the same lane, used to compute @@ -285,6 +289,7 @@ class _InteractiveEventPillState extends State { final isPoint = !widget.entry.entry.hasEnd; final color = widget.colorBuilder(widget.entry.entry); final label = widget.labelBuilder(widget.entry.entry); + final isSelected = widget.entry.entry.id == widget.selectedEntryId; final top = LayoutCoordinateService.laneToY( lane: widget.entry.entry.lane, @@ -312,6 +317,7 @@ class _InteractiveEventPillState extends State { color: color, label: label, maxTextWidth: maxTextWidth > 0 ? maxTextWidth : 0, + isSelected: isSelected, ); if (!widget.enableDrag) { @@ -355,7 +361,7 @@ class _InteractiveEventPillState extends State { } // Range event (hasEnd == true) - final pill = EventPill(color: color, label: label); + final pill = EventPill(color: color, label: label, isSelected: isSelected); final width = _pillWidth; if (!widget.enableDrag) { @@ -434,6 +440,7 @@ class _InteractiveEventPillState extends State { onExit: (_) => _onHoverExit(), child: GestureDetector( behavior: HitTestBehavior.opaque, + onTap: () => widget.onEntrySelected?.call(widget.entry.entry), onPanDown: _onPanDown, onPanStart: _onPanStart, onPanUpdate: _onPanUpdate, @@ -453,6 +460,7 @@ class _InteractiveEventPillState extends State { onExit: (_) => _onHoverExit(), child: GestureDetector( behavior: HitTestBehavior.opaque, + onTap: () => widget.onEntrySelected?.call(widget.entry.entry), onPanDown: _onPanDown, onPanStart: _onPanStart, onPanUpdate: _onPanUpdate, diff --git a/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_interactor.dart b/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_interactor.dart index 46cb831..759604f 100644 --- a/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_interactor.dart +++ b/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_interactor.dart @@ -27,6 +27,7 @@ class ZTimelineInteractor extends StatefulWidget { const ZTimelineInteractor({ required this.child, this.autofocus = true, + this.onBackgroundTap, super.key, }); @@ -36,6 +37,9 @@ class ZTimelineInteractor extends StatefulWidget { /// Whether to automatically focus this widget for keyboard input. final bool autofocus; + /// Called when the user taps empty space (not on a pill/entry). + final VoidCallback? onBackgroundTap; + @override State createState() => _ZTimelineInteractorState(); } @@ -231,6 +235,7 @@ class _ZTimelineInteractorState extends State { ); }, child: GestureDetector( + onTap: widget.onBackgroundTap, onScaleStart: _handleScaleStart, onScaleUpdate: _handleScaleUpdate, onScaleEnd: _handleScaleEnd, diff --git a/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_view.dart b/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_view.dart index b2807c6..25dbe07 100644 --- a/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_view.dart +++ b/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_view.dart @@ -33,6 +33,9 @@ typedef OnEntryResized = int newLane, ); +/// Callback signature for when an entry is tapped / selected. +typedef OnEntrySelected = void Function(TimelineEntry entry); + /// Base timeline view: renders groups with between-group headers and /// lane rows containing event pills. class ZTimelineView extends StatelessWidget { @@ -47,7 +50,9 @@ class ZTimelineView extends StatelessWidget { this.groupHeaderHeight = ZTimelineConstants.groupHeaderHeight, this.onEntryMoved, this.onEntryResized, + this.onEntrySelected, this.enableDrag = true, + this.selectedEntryId, }); final List groups; @@ -67,9 +72,15 @@ class ZTimelineView extends StatelessWidget { /// Callback invoked when an entry is resized via edge drag. final OnEntryResized? onEntryResized; + /// Callback invoked when an entry is tapped / selected. + final OnEntrySelected? onEntrySelected; + /// Whether drag-and-drop is enabled. final bool enableDrag; + /// ID of the currently selected entry, if any. + final String? selectedEntryId; + @override Widget build(BuildContext context) { return AnimatedBuilder( @@ -112,6 +123,8 @@ class ZTimelineView extends StatelessWidget { enableDrag: enableDrag, onEntryResized: onEntryResized, onEntryMoved: onEntryMoved, + onEntrySelected: onEntrySelected, + selectedEntryId: selectedEntryId, ), ], ); @@ -171,6 +184,8 @@ class _GroupLanes extends StatefulWidget { required this.enableDrag, this.onEntryResized, this.onEntryMoved, + this.onEntrySelected, + this.selectedEntryId, }); final TimelineGroup group; @@ -185,6 +200,8 @@ class _GroupLanes extends StatefulWidget { final bool enableDrag; final OnEntryResized? onEntryResized; final OnEntryMoved? onEntryMoved; + final OnEntrySelected? onEntrySelected; + final String? selectedEntryId; @override State<_GroupLanes> createState() => _GroupLanesState(); @@ -330,6 +347,8 @@ class _GroupLanesState extends State<_GroupLanes> { allEntries: widget.allEntries, onEntryResized: widget.onEntryResized, onEntryMoved: widget.onEntryMoved, + onEntrySelected: widget.onEntrySelected, + selectedEntryId: widget.selectedEntryId, groupRegistry: scope?.groupRegistry, nextEntryStartX: _findNextEntryStartX( widget.entries, diff --git a/packages/z-flutter/web/index.html b/packages/z-flutter/web/index.html index 041a495..e168a8b 100644 --- a/packages/z-flutter/web/index.html +++ b/packages/z-flutter/web/index.html @@ -269,9 +269,11 @@ } case "item_selected": currentState.selectedItemId = event.payload.itemId; + pushState(currentState); break; case "item_deselected": currentState.selectedItemId = null; + pushState(currentState); break; } }