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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user