diff --git a/apps/web/src/components/timeline/FloatingActionBar.tsx b/apps/web/src/components/timeline/FloatingActionBar.tsx
index bd04540..6e870f8 100644
--- a/apps/web/src/components/timeline/FloatingActionBar.tsx
+++ b/apps/web/src/components/timeline/FloatingActionBar.tsx
@@ -55,7 +55,9 @@ export function FloatingActionBar({
"disabled:opacity-40 disabled:cursor-not-allowed",
)}
>
- {isDeleting ? "Deleting\u2026" : "Delete"}
+ {isDeleting ? "Deleting\u2026" : (
+ <>Delete (Del)>
+ )}
)}
diff --git a/apps/web/src/components/timeline/KeyboardShortcutOverlay.tsx b/apps/web/src/components/timeline/KeyboardShortcutOverlay.tsx
new file mode 100644
index 0000000..728d75c
--- /dev/null
+++ b/apps/web/src/components/timeline/KeyboardShortcutOverlay.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+const SHORTCUTS: { keys: string; description: string }[] = [
+ { keys: "← / →", description: "Scroll timeline 1 day" },
+ { keys: "Shift + ← / →", description: "Scroll timeline 1 week" },
+ { keys: "Delete / Backspace", description: "Delete selected allocations" },
+ { keys: "Ctrl / Cmd + Z", description: "Undo last action" },
+ { keys: "Ctrl / Cmd + Shift + Z", description: "Redo" },
+ { keys: "Shift + Wheel", description: "Horizontal scroll" },
+ { keys: "ESC", description: "Close popover / clear selection" },
+ { keys: "?", description: "Toggle this overlay" },
+];
+
+interface KeyboardShortcutOverlayProps {
+ onClose: () => void;
+}
+
+export function KeyboardShortcutOverlay({ onClose }: KeyboardShortcutOverlayProps) {
+ return (
+
+
e.stopPropagation()}
+ >
+
+
Keyboard Shortcuts
+
+
+
+ {SHORTCUTS.map((s) => (
+ -
+ {s.description}
+
+ {s.keys}
+
+
+ ))}
+
+
+ Press ? to toggle
+
+
+
+ );
+}
diff --git a/apps/web/src/components/timeline/TimelineView.tsx b/apps/web/src/components/timeline/TimelineView.tsx
index 7c496e3..e6cfe87 100644
--- a/apps/web/src/components/timeline/TimelineView.tsx
+++ b/apps/web/src/components/timeline/TimelineView.tsx
@@ -1,5 +1,6 @@
"use client";
+import { MILLISECONDS_PER_DAY } from "@capakraken/shared";
import { clsx } from "clsx";
import { useSession } from "next-auth/react";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -35,6 +36,9 @@ import { TimelineProjectPanel, type OpenDemandAssignment } from "./TimelineProje
import { ProjectColorLegend } from "./ProjectColorLegend.js";
import { useMultiSelectIntersection } from "~/hooks/useMultiSelectIntersection.js";
import type { TimelineVisualOverrides } from "./allocationVisualState.js";
+import { SuccessToast } from "~/components/ui/SuccessToast.js";
+import { useTimelineKeyboard } from "~/hooks/useTimelineKeyboard.js";
+import { KeyboardShortcutOverlay } from "./KeyboardShortcutOverlay.js";
// ─── Entry point ────────────────────────────────────────────────────────────
// Two-layer mount: the outer shell creates drag state + project context,
@@ -86,6 +90,8 @@ export function TimelineView() {
onSuccess: invalidateTimeline,
});
+ const [dragErrorToast, setDragErrorToast] = useState(null);
+
const {
dragState,
allocDragState,
@@ -156,6 +162,7 @@ export function TimelineView() {
clearMultiSelect();
}
},
+ onMutationError: (message) => setDragErrorToast(message),
});
const [openPanelProjectId, setOpenPanelProjectId] = useState(null);
@@ -164,6 +171,13 @@ export function TimelineView() {
const { contextResourceIds, contextAllocations } = useProjectDragContext(contextProjectId, canManageTimeline);
return (
+ <>
+ setDragErrorToast(null)}
+ />
+ >
);
}
@@ -368,6 +383,20 @@ function TimelineViewContent({
const { CELL_WIDTH, dates, totalCanvasWidth, toLeft, toWidth, gridLines, monthGroups, xToDate } =
useTimelineLayout(viewStart, viewDays, filters.zoom, filters.showWeekends, today);
+
+ const { showShortcuts, setShowShortcuts } = useTimelineKeyboard({
+ scrollContainerRef,
+ cellWidth: CELL_WIDTH,
+ selectedAllocationIds: multiSelectState.selectedAllocationIds,
+ onDeleteSelected: () => {
+ if (multiSelectState.selectedAllocationIds.length === 0) return;
+ const msg = `Delete ${multiSelectState.selectedAllocationIds.length} allocation(s)? This cannot be undone.`;
+ if (window.confirm(msg)) {
+ batchDeleteMutation.mutate({ ids: multiSelectState.selectedAllocationIds });
+ }
+ },
+ });
+
const hasActivePointerOverlay =
dragState.isDragging || allocDragState.isActive || rangeState.isSelecting || multiSelectState.isMultiDragging;
@@ -896,7 +925,7 @@ function TimelineViewContent({
rangeState.startDate <= end
? [rangeState.startDate, end]
: [end, rangeState.startDate];
- const days = Math.round((e.getTime() - s.getTime()) / 86400000) + 1;
+ const days = Math.round((e.getTime() - s.getTime()) / MILLISECONDS_PER_DAY) + 1;
return `${days} day${days !== 1 ? "s" : ""}`;
})()}
@@ -1066,6 +1095,19 @@ function TimelineViewContent({
onClose={() => setResourceHover(null)}
/>
)}
+
+ {/* Keyboard shortcut overlay */}
+ {showShortcuts && setShowShortcuts(false)} />}
+
+ {/* Keyboard shortcut hint button */}
+
);
}
diff --git a/apps/web/src/hooks/useTimelineKeyboard.ts b/apps/web/src/hooks/useTimelineKeyboard.ts
new file mode 100644
index 0000000..d8a99d4
--- /dev/null
+++ b/apps/web/src/hooks/useTimelineKeyboard.ts
@@ -0,0 +1,88 @@
+import React, { useEffect, useRef, useState } from "react";
+import type { RefObject } from "react";
+
+interface UseTimelineKeyboardOptions {
+ scrollContainerRef: RefObject;
+ cellWidth: number;
+ selectedAllocationIds: string[];
+ onDeleteSelected: () => void;
+}
+
+export interface UseTimelineKeyboardResult {
+ showShortcuts: boolean;
+ setShowShortcuts: React.Dispatch>;
+}
+
+function isTypingTarget(el: Element | null): boolean {
+ if (!el) return false;
+ const tag = el.tagName.toLowerCase();
+ if (tag === "input" || tag === "textarea" || tag === "select") return true;
+ if ((el as HTMLElement).isContentEditable) return true;
+ return false;
+}
+
+export function useTimelineKeyboard({
+ scrollContainerRef,
+ cellWidth,
+ selectedAllocationIds,
+ onDeleteSelected,
+}: UseTimelineKeyboardOptions): UseTimelineKeyboardResult {
+ const [showShortcuts, setShowShortcuts] = useState(false);
+
+ // Keep stable refs so the handler closure doesn't need to be re-registered on every render
+ const onDeleteRef = useRef(onDeleteSelected);
+ onDeleteRef.current = onDeleteSelected;
+ const selectedCountRef = useRef(selectedAllocationIds.length);
+ selectedCountRef.current = selectedAllocationIds.length;
+ const cellWidthRef = useRef(cellWidth);
+ cellWidthRef.current = cellWidth;
+
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if (isTypingTarget(document.activeElement)) return;
+
+ const isMac = navigator.platform.toUpperCase().includes("MAC");
+ const modKey = isMac ? e.metaKey : e.ctrlKey;
+ const dayPx = cellWidthRef.current;
+ const el = scrollContainerRef.current;
+
+ switch (e.key) {
+ case "?":
+ e.preventDefault();
+ setShowShortcuts((prev) => !prev);
+ break;
+
+ case "ArrowLeft":
+ if (el) {
+ e.preventDefault();
+ el.scrollLeft -= (e.shiftKey ? 7 : 1) * dayPx;
+ }
+ break;
+
+ case "ArrowRight":
+ if (el) {
+ e.preventDefault();
+ el.scrollLeft += (e.shiftKey ? 7 : 1) * dayPx;
+ }
+ break;
+
+ case "Delete":
+ case "Backspace":
+ if (modKey) break; // let browser handle Cmd+Backspace etc.
+ if (selectedCountRef.current > 0) {
+ e.preventDefault();
+ onDeleteRef.current();
+ }
+ break;
+
+ default:
+ break;
+ }
+ };
+
+ window.addEventListener("keydown", handler);
+ return () => window.removeEventListener("keydown", handler);
+ }, [scrollContainerRef]); // stable — uses refs for everything that changes
+
+ return { showShortcuts, setShowShortcuts };
+}