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
@@ -1,12 +1,13 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { useState } from "react";
import { OrderType, AllocationType, ProjectStatus } from "@capakraken/shared";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
import type { Project } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { DateInput } from "~/components/ui/DateInput.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { toDateInputValue } from "~/lib/format.js";
const ORDER_TYPE_OPTIONS = [
{ value: "BD", label: "BD" },
@@ -28,14 +29,6 @@ const STATUS_OPTIONS = [
{ value: "CANCELLED", label: "Cancelled" },
] as const;
function formatDateForInput(date: Date | string): string {
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
interface FormState {
shortCode: string;
name: string;
@@ -54,7 +47,7 @@ interface FormState {
}
function getDefaultForm(): FormState {
const today = formatDateForInput(new Date());
const today = toDateInputValue(new Date());
return {
shortCode: "",
name: "",
@@ -81,8 +74,8 @@ function projectToForm(project: Project): FormState {
allocationType: project.allocationType,
winProbability: String(project.winProbability),
budgetEur: String(Math.round(project.budgetCents) / 100),
startDate: formatDateForInput(project.startDate),
endDate: formatDateForInput(project.endDate),
startDate: toDateInputValue(project.startDate),
endDate: toDateInputValue(project.endDate),
status: project.status,
responsiblePerson: project.responsiblePerson ?? "",
color: (project as unknown as { color?: string | null }).color ?? "",
@@ -108,9 +101,6 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
const [serverError, setServerError] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
useFocusTrap(panelRef, true);
const { data: utilizationCategories } = trpc.utilizationCategory.list.useQuery(undefined, { staleTime: 60_000 });
const { data: clientList } = trpc.clientEntity.list.useQuery(undefined, { staleTime: 60_000 });
@@ -139,15 +129,6 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
const isLoading = createMutation.isPending || updateMutation.isPending;
// Close on Escape key
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
function setField<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((prev) => ({ ...prev, [key]: value }));
setErrors((prev) => ({ ...prev, [key]: undefined }));
@@ -246,17 +227,8 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={panelRef}
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-xl mx-4"
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
>
<AnimatedModal open={true} onClose={onClose} maxWidth="max-w-xl" className="mx-4">
<div>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
@@ -609,6 +581,6 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
</div>
</form>
</div>
</div>
</AnimatedModal>
);
}
@@ -11,7 +11,7 @@ import { DateInput } from "~/components/ui/DateInput.js";
import { SkillTagInput } from "~/components/ui/SkillTagInput.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatCents } from "~/lib/format.js";
import { formatCents, toDateInputValue } from "~/lib/format.js";
import { ConfettiBurst } from "~/components/ui/ConfettiBurst.js";
import { SuccessToast } from "~/components/ui/SuccessToast.js";
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
@@ -75,15 +75,8 @@ interface WizardState {
blueprintFieldDefs: BlueprintFieldDefinition[];
}
function formatDateForInput(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function makeDefaultState(): WizardState {
const today = formatDateForInput(new Date());
const today = toDateInputValue(new Date());
return {
blueprintId: null,
shortCode: "",
@@ -1,6 +1,7 @@
"use client";
import { useState, useCallback, useMemo } from "react";
import { toIsoDate } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { DateInput } from "~/components/ui/DateInput.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
@@ -87,11 +88,6 @@ interface ScenarioPlannerProps {
// ── Helpers ──────────────────────────────────────────────────────────────────
function toISODate(d: Date | string): string {
if (typeof d === "string") return d.split("T")[0] ?? d;
return d.toISOString().split("T")[0] ?? "";
}
let nextKey = 1;
function genKey(): string {
return `new-${nextKey++}`;
@@ -100,8 +96,8 @@ function genKey(): string {
// ── Component ────────────────────────────────────────────────────────────────
export function ScenarioPlanner({ projectId, baseline, resources, roles }: ScenarioPlannerProps) {
const projectStart = toISODate(baseline.project.startDate);
const projectEnd = toISODate(baseline.project.endDate);
const projectStart = toIsoDate(baseline.project.startDate);
const projectEnd = toIsoDate(baseline.project.endDate);
// Initialize scenario rows from baseline assignments
const initialRows: ScenarioRow[] = baseline.assignments.map((a) => ({
@@ -109,8 +105,8 @@ export function ScenarioPlanner({ projectId, baseline, resources, roles }: Scena
assignmentId: a.id,
resourceId: a.resourceId ?? "",
roleId: a.roleId ?? "",
startDate: toISODate(a.startDate),
endDate: toISODate(a.endDate),
startDate: toIsoDate(a.startDate),
endDate: toIsoDate(a.endDate),
hoursPerDay: a.hoursPerDay,
}));