feat(timeline): add pulse animation for in-flight drag mutations

Allocation bars that have active optimistic overrides (post-drag,
awaiting server confirmation) now pulse subtly via animate-pulse.
The pending set is derived from the existing optimisticAllocations
map keys, requiring no additional state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 13:28:46 +02:00
parent 7a5e98e2e9
commit 1df208dbcc
386 changed files with 657 additions and 81650 deletions
@@ -30,6 +30,7 @@ type FinalizeAllocationReleaseEffectsParams = {
extractAllocationFragment: (input: MutationInput) => Promise<{ extractedAllocationId: string }>;
updateAllocation: (input: MutationInput) => void;
clearPendingOptimisticAllocation: (allocationId: string) => void;
onError?: (message: string) => void;
resolveRelease?: (
alloc: AllocationDragReleaseLike,
options: { clickThresholdPx: number; wasShift: boolean },
@@ -52,6 +53,7 @@ export async function finalizeAllocationReleaseEffects({
extractAllocationFragment,
updateAllocation,
clearPendingOptimisticAllocation,
onError,
resolveRelease = resolveAllocationRelease,
previewOps = { preserve: preserveLivePreview, clear: clearLivePreview },
}: FinalizeAllocationReleaseEffectsParams): Promise<void> {
@@ -98,7 +100,10 @@ export async function finalizeAllocationReleaseEffects({
? { ...pendingSnapshotRef.current, mutationAllocationId }
: null;
updateAllocation({ allocationId: mutationAllocationId, startDate: currentStartDate, endDate: currentEndDate });
} catch {
} catch (err) {
console.error("[timeline] finalizeAllocationReleaseEffects error:", err);
const message = err instanceof Error ? err.message : "Zuweisung konnte nicht verschoben werden.";
onError?.(message);
clearPendingOptimisticAllocation(activeAllocationId);
}
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { MILLISECONDS_PER_DAY } from "@capakraken/shared";
import { pixelsToDays } from "~/components/timeline/dragMath.js";
type RangeStateLike = {
@@ -46,7 +47,7 @@ export function updateRangeSelectionDraft<TState extends RangeStateLike>(
currentDate.setDate(currentDate.getDate() + daysDelta);
const prevDelta = state.currentDate
? Math.round((state.currentDate.getTime() - state.startDate.getTime()) / 86400000)
? Math.round((state.currentDate.getTime() - state.startDate.getTime()) / MILLISECONDS_PER_DAY)
: 0;
if (daysDelta === prevDelta) return null;
+13 -50
View File
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback } from "react";
import { useLocalStorage } from "./useLocalStorage.js";
export type HeatmapColorScheme = "green-red" | "blue-orange" | "purple-yellow" | "mono";
@@ -44,66 +45,28 @@ export function readAppPreferences(): AppPreferences {
}
}
function saveAppPreferences(prefs: AppPreferences) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
// Broadcast to all hook instances in the same tab
window.dispatchEvent(new CustomEvent<AppPreferences>(CHANGE_EVENT, { detail: prefs }));
}
export function useAppPreferences() {
const [prefs, setPrefs] = useState<AppPreferences>(DEFAULT);
useEffect(() => {
// Sync from storage on mount
setPrefs(readAppPreferences());
// Keep in sync when any hook instance saves a change
function handleChange(e: Event) {
setPrefs((e as CustomEvent<AppPreferences>).detail);
}
window.addEventListener(CHANGE_EVENT, handleChange);
return () => window.removeEventListener(CHANGE_EVENT, handleChange);
}, []);
const [prefs, setPrefs] = useLocalStorage<AppPreferences>(STORAGE_KEY, DEFAULT, CHANGE_EVENT);
const setHideCompletedProjects = useCallback((value: boolean) => {
setPrefs((prev) => {
const next = { ...prev, hideCompletedProjects: value };
saveAppPreferences(next);
return next;
});
}, []);
setPrefs((prev) => ({ ...prev, hideCompletedProjects: value }));
}, [setPrefs]);
const setTimelineDisplayMode = useCallback((value: AppPreferences["timelineDisplayMode"]) => {
setPrefs((prev) => {
const next = { ...prev, timelineDisplayMode: value };
saveAppPreferences(next);
return next;
});
}, []);
setPrefs((prev) => ({ ...prev, timelineDisplayMode: value }));
}, [setPrefs]);
const setHeatmapColorScheme = useCallback((value: HeatmapColorScheme) => {
setPrefs((prev) => {
const next = { ...prev, heatmapColorScheme: value };
saveAppPreferences(next);
return next;
});
}, []);
setPrefs((prev) => ({ ...prev, heatmapColorScheme: value }));
}, [setPrefs]);
const setShowDemandProjects = useCallback((value: boolean) => {
setPrefs((prev) => {
const next = { ...prev, showDemandProjects: value };
saveAppPreferences(next);
return next;
});
}, []);
setPrefs((prev) => ({ ...prev, showDemandProjects: value }));
}, [setPrefs]);
const setBlinkOverbookedDays = useCallback((value: boolean) => {
setPrefs((prev) => {
const next = { ...prev, blinkOverbookedDays: value };
saveAppPreferences(next);
return next;
});
}, []);
setPrefs((prev) => ({ ...prev, blinkOverbookedDays: value }));
}, [setPrefs]);
return { prefs, setHideCompletedProjects, setTimelineDisplayMode, setHeatmapColorScheme, setShowDemandProjects, setBlinkOverbookedDays };
}
+10 -32
View File
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect } from "react";
import { useLocalStorage } from "./useLocalStorage.js";
export type ThemeMode = "light" | "dark";
export type AccentColor = "sky" | "indigo" | "violet" | "emerald" | "rose" | "amber";
@@ -13,17 +14,6 @@ export interface ThemePreferences {
const STORAGE_KEY = "capakraken_theme";
const DEFAULT: ThemePreferences = { mode: "light", accent: "sky" };
function readStorage(): ThemePreferences {
if (typeof window === "undefined") return DEFAULT;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULT;
return { ...DEFAULT, ...(JSON.parse(raw) as Partial<ThemePreferences>) };
} catch {
return DEFAULT;
}
}
function applyTheme(prefs: ThemePreferences) {
const html = document.documentElement;
if (prefs.mode === "dark") html.classList.add("dark");
@@ -32,32 +22,20 @@ function applyTheme(prefs: ThemePreferences) {
}
export function useTheme() {
const [prefs, setPrefs] = useState<ThemePreferences>(DEFAULT);
const [prefs, setPrefs] = useLocalStorage<ThemePreferences>(STORAGE_KEY, DEFAULT);
// Read from storage on mount
// Apply theme to DOM whenever prefs change
useEffect(() => {
const stored = readStorage();
setPrefs(stored);
applyTheme(stored);
}, []);
applyTheme(prefs);
}, [prefs]);
const setMode = useCallback((mode: ThemeMode) => {
setPrefs((prev) => {
const next = { ...prev, mode };
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
applyTheme(next);
return next;
});
}, []);
setPrefs((prev) => ({ ...prev, mode }));
}, [setPrefs]);
const setAccent = useCallback((accent: AccentColor) => {
setPrefs((prev) => {
const next = { ...prev, accent };
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
applyTheme(next);
return next;
});
}, []);
setPrefs((prev) => ({ ...prev, accent }));
}, [setPrefs]);
return { prefs, setMode, setAccent };
}
+15 -3
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
import { useCallback, useDeferredValue, useEffect, useRef, useState, type MutableRefObject } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js";
import { pixelsToDays } from "~/components/timeline/dragMath.js";
@@ -233,6 +233,7 @@ export function useTimelineDrag({
onAllocationMoved,
onShiftClickAlloc,
onMultiDragComplete,
onMutationError,
}: {
cellWidthRef: MutableRefObject<number>;
onShiftApplied?: (projectId: string) => void;
@@ -241,6 +242,7 @@ export function useTimelineDrag({
onAllocationMoved?: (snapshot: AllocationMovedSnapshot) => void;
onShiftClickAlloc?: (allocationId: string) => void;
onMultiDragComplete?: (daysDelta: number, mode: AllocDragMode, selectedIds?: string[]) => void;
onMutationError?: (message: string) => void;
}) {
const [dragState, setDragState] = useState<DragState>(INITIAL_DRAG_STATE);
const [allocDragState, setAllocDragState] = useState<AllocDragState>(INITIAL_ALLOC_DRAG);
@@ -278,6 +280,9 @@ export function useTimelineDrag({
const onMultiDragCompleteRef = useRef(onMultiDragComplete);
onMultiDragCompleteRef.current = onMultiDragComplete;
const onMutationErrorRef = useRef(onMutationError);
onMutationErrorRef.current = onMutationError;
const utils = trpc.useUtils();
const invalidateTimeline = useInvalidateTimeline();
@@ -387,6 +392,9 @@ export function useTimelineDrag({
setMultiSelectState(nextMultiSelectState);
}, []);
// Defer daysDelta to avoid firing the preview query every pixel during drag
const deferredDaysDelta = useDeferredValue(dragState.daysDelta);
// Project-shift preview
const { data: previewData, isFetching: isPreviewLoading } = trpc.timeline.previewShift.useQuery(
{
@@ -398,7 +406,7 @@ export function useTimelineDrag({
enabled:
dragState.isDragging &&
dragState.projectId !== null &&
dragState.daysDelta !== 0 &&
deferredDaysDelta !== 0 &&
dragState.currentStartDate !== null,
staleTime: 0,
},
@@ -447,7 +455,10 @@ export function useTimelineDrag({
pendingSnapshotRef.current = null;
}
},
onError: () => {
onError: (error) => {
console.error("[timeline] updateAllocationInline failed:", error);
const message = (error as { message?: string }).message ?? "Zuweisung konnte nicht verschoben werden.";
onMutationErrorRef.current?.(message);
clearPendingOptimisticAllocation();
},
});
@@ -653,6 +664,7 @@ export function useTimelineDrag({
extractAllocationFragment: extractAllocFragmentMutation.mutateAsync,
updateAllocation: updateAllocMutation.mutate,
clearPendingOptimisticAllocation,
onError: (msg) => onMutationErrorRef.current?.(msg),
});
allocDragRef.current = INITIAL_ALLOC_DRAG;