Skip to content

Commit 43b2e17

Browse files
committed
Implement animated priority transitions and real-time synchronization
- Add AnimatedPriority component with smooth color and value transitions - Fix priority display synchronization between ListView and EditNodeModal - Add real-time polling to GraphVisualization and ListView for external changes - Enhanced subscription with more fields for complete data sync
1 parent 81a6cba commit 43b2e17

5 files changed

Lines changed: 223 additions & 92 deletions

File tree

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import React, { useState, useEffect } from 'react';
2+
3+
interface AnimatedPriorityProps {
4+
value: number; // Priority value between 0 and 1
5+
className?: string;
6+
duration?: number; // Animation duration in milliseconds (default: 3000)
7+
style?: React.CSSProperties;
8+
renderBar?: (animatedValue: number, animatedColor: string) => React.ReactNode;
9+
}
10+
11+
export function AnimatedPriority({ value, className = '', duration = 3000, style, renderBar }: AnimatedPriorityProps) {
12+
const [displayValue, setDisplayValue] = useState(value);
13+
const [isAnimating, setIsAnimating] = useState(false);
14+
15+
useEffect(() => {
16+
if (Math.abs(displayValue - value) < 0.01) return; // Skip if difference is tiny
17+
18+
setIsAnimating(true);
19+
const startValue = displayValue;
20+
const endValue = value;
21+
const startTime = Date.now();
22+
23+
const animate = () => {
24+
const elapsed = Date.now() - startTime;
25+
const progress = Math.min(elapsed / duration, 1);
26+
27+
// Use easeInOutCubic for smooth animation
28+
const easeInOutCubic = (t: number): number => {
29+
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
30+
};
31+
32+
const easedProgress = easeInOutCubic(progress);
33+
const currentValue = startValue + (endValue - startValue) * easedProgress;
34+
35+
setDisplayValue(currentValue);
36+
37+
if (progress < 1) {
38+
requestAnimationFrame(animate);
39+
} else {
40+
setDisplayValue(endValue);
41+
setIsAnimating(false);
42+
}
43+
};
44+
45+
requestAnimationFrame(animate);
46+
}, [value, duration]);
47+
48+
const percentage = Math.round(displayValue * 100);
49+
50+
// Smooth color interpolation helper
51+
const hexToRgb = (hex: string): [number, number, number] => {
52+
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
53+
return result ? [
54+
parseInt(result[1], 16),
55+
parseInt(result[2], 16),
56+
parseInt(result[3], 16)
57+
] : [0, 0, 0];
58+
};
59+
60+
const interpolateColor = (color1: [number, number, number], color2: [number, number, number], factor: number): string => {
61+
const r = Math.round(color1[0] + factor * (color2[0] - color1[0]));
62+
const g = Math.round(color1[1] + factor * (color2[1] - color1[1]));
63+
const b = Math.round(color1[2] + factor * (color2[2] - color1[2]));
64+
return `rgb(${r}, ${g}, ${b})`;
65+
};
66+
67+
const getAnimatedColor = (val: number): string => {
68+
// Define color stops: green -> blue -> yellow -> orange -> red
69+
const colors = [
70+
{ threshold: 0.0, color: '#22c55e' }, // green-500
71+
{ threshold: 0.2, color: '#3b82f6' }, // blue-500
72+
{ threshold: 0.4, color: '#eab308' }, // yellow-500
73+
{ threshold: 0.6, color: '#f97316' }, // orange-500
74+
{ threshold: 0.8, color: '#ef4444' } // red-500
75+
];
76+
77+
// Find the two colors to interpolate between
78+
for (let i = 0; i < colors.length - 1; i++) {
79+
if (val >= colors[i].threshold && val <= colors[i + 1].threshold) {
80+
const factor = (val - colors[i].threshold) / (colors[i + 1].threshold - colors[i].threshold);
81+
const color1 = hexToRgb(colors[i].color);
82+
const color2 = hexToRgb(colors[i + 1].color);
83+
return interpolateColor(color1, color2, factor);
84+
}
85+
}
86+
87+
// Handle edge cases
88+
if (val <= 0.0) return colors[0].color;
89+
return colors[colors.length - 1].color;
90+
};
91+
92+
const getAnimatedTextColor = (val: number): string => {
93+
// Use the same interpolation for text colors but return as inline style
94+
return getAnimatedColor(val);
95+
};
96+
97+
const animatedColor = getAnimatedColor(displayValue);
98+
const animatedTextColor = getAnimatedTextColor(displayValue);
99+
100+
// Remove any existing color classes from className and use inline style
101+
const baseClassName = className.replace(/text-(?:red|orange|yellow|blue|green)-\d+/g, '');
102+
103+
// Merge style with animated color
104+
const finalStyle = {
105+
...style,
106+
color: animatedTextColor
107+
};
108+
109+
if (renderBar) {
110+
return (
111+
<div className="flex items-center space-x-3">
112+
{renderBar(displayValue, animatedColor)}
113+
<div className="flex flex-col">
114+
<span className={baseClassName} style={finalStyle}>
115+
{percentage}%
116+
</span>
117+
</div>
118+
</div>
119+
);
120+
}
121+
122+
return (
123+
<span className={baseClassName} style={finalStyle}>
124+
{percentage}%
125+
</span>
126+
);
127+
}

packages/web/src/components/EditNodeModal.tsx

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,22 @@ export function EditNodeModal({ isOpen, onClose, node }: EditNodeModalProps) {
5353
tags: node.tags || [],
5454
});
5555

56+
// Update formData when node prop changes (for real-time updates)
57+
React.useEffect(() => {
58+
setFormData({
59+
title: node.title,
60+
description: node.description || '',
61+
type: node.type,
62+
status: node.status,
63+
priorityExec: node.priorityExec || 0,
64+
priorityIndiv: node.priorityIndiv || 0,
65+
priorityComm: node.priorityComm || 0,
66+
assignedTo: node.assignedTo || '',
67+
dueDate: formatDateForInput(node.dueDate),
68+
tags: node.tags || [],
69+
});
70+
}, [node]);
71+
5672
const [isStatusOpen, setIsStatusOpen] = React.useState(false);
5773
const statusDropdownRef = React.useRef<HTMLDivElement>(null);
5874

@@ -110,22 +126,6 @@ export function EditNodeModal({ isOpen, onClose, node }: EditNodeModalProps) {
110126
}
111127
});
112128

113-
React.useEffect(() => {
114-
if (isOpen) {
115-
setFormData({
116-
title: node.title,
117-
description: node.description || '',
118-
type: node.type,
119-
status: node.status,
120-
priorityExec: node.priorityExec || 0,
121-
priorityIndiv: node.priorityIndiv || 0,
122-
priorityComm: node.priorityComm || 0,
123-
assignedTo: node.assignedTo || '',
124-
dueDate: formatDateForInput(node.dueDate),
125-
tags: node.tags || [],
126-
});
127-
}
128-
}, [isOpen, node]);
129129

130130
const handleSubmit = async (e: React.FormEvent) => {
131131
e.preventDefault();

packages/web/src/components/GraphVisualization.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,14 @@ export function GraphVisualization() {
7070
options: { limit: 100 }
7171
},
7272
fetchPolicy: 'cache-and-network', // Use cache first, then fetch from network for updates
73+
pollInterval: 5000, // Poll every 5 seconds for real-time updates
7374
notifyOnNetworkStatusChange: true,
7475
errorPolicy: 'all'
7576
});
7677

7778
const { data: edgesData, loading: edgesLoading } = useQuery(GET_EDGES, {
7879
fetchPolicy: 'cache-and-network',
80+
pollInterval: 5000, // Also poll edges for consistency
7981
notifyOnNetworkStatusChange: true
8082
});
8183

packages/web/src/components/ListView.tsx

Lines changed: 69 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { GET_WORK_ITEMS } from '../lib/queries';
4141
import { EditNodeModal } from './EditNodeModal';
4242
import { DeleteNodeModal } from './DeleteNodeModal';
4343
import { TagDisplay } from './TagDisplay';
44+
import { AnimatedPriority } from './AnimatedPriority';
4445

4546
// WorkItem interface matching GraphQL schema
4647
interface WorkItem {
@@ -110,12 +111,13 @@ export function ListView() {
110111
teamId: currentTeam?.id || 'team-1'
111112
}
112113
},
113-
fetchPolicy: 'cache-and-network' // Use cache first, then update from network
114+
fetchPolicy: 'cache-and-network', // Use cache first, then update from network
115+
pollInterval: 5000, // Poll every 5 seconds to catch external changes
116+
errorPolicy: 'all'
114117
});
115118

116119
const workItems: WorkItem[] = data?.workItems || [];
117120

118-
119121
const [currentView, setCurrentView] = useState<ViewType>('dashboard');
120122
const [isViewDropdownOpen, setIsViewDropdownOpen] = useState(false);
121123
const [searchTerm, setSearchTerm] = useState('');
@@ -126,6 +128,21 @@ export function ListView() {
126128
const [showEditModal, setShowEditModal] = useState(false);
127129
const [showDeleteModal, setShowDeleteModal] = useState(false);
128130
const [selectedNode, setSelectedNode] = useState<WorkItem | null>(null);
131+
132+
// Update selectedNode when workItems data changes and modal is open
133+
useEffect(() => {
134+
if (showEditModal && selectedNode) {
135+
const updatedNode = workItems.find(item => item.id === selectedNode.id);
136+
if (updatedNode) {
137+
setSelectedNode(updatedNode);
138+
}
139+
}
140+
}, [workItems, showEditModal, selectedNode?.id]);
141+
142+
// Add manual refresh function for debugging
143+
const handleRefresh = () => {
144+
refetch();
145+
};
129146
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
130147
const [isStatusDropdownOpen, setIsStatusDropdownOpen] = useState(false);
131148
const [isPriorityDropdownOpen, setIsPriorityDropdownOpen] = useState(false);
@@ -342,7 +359,7 @@ export function ListView() {
342359

343360
// Helper functions
344361
const getNodePriority = (node: WorkItem) => {
345-
return node.priorityComp || node.priorityExec || 0;
362+
return node.priorityExec || 0;
346363
};
347364

348365
const formatLabel = (label: string) => {
@@ -449,32 +466,26 @@ export function ListView() {
449466
<div className="flex items-center justify-between">
450467
{/* Priority - Left Side */}
451468
<div className="flex items-center space-x-3">
452-
<div className="flex items-center relative">
453-
<div className="w-3 h-12 bg-gray-300 dark:bg-gray-600 rounded overflow-hidden flex flex-col justify-end relative">
454-
<div className={`w-full transition-all duration-300 ${
455-
getNodePriority(node) >= 0.8 ? 'bg-red-500' :
456-
getNodePriority(node) >= 0.6 ? 'bg-orange-500' :
457-
getNodePriority(node) >= 0.4 ? 'bg-yellow-500' :
458-
getNodePriority(node) >= 0.2 ? 'bg-blue-500' : 'bg-green-500'
459-
}`} style={{ height: `${Math.max(getNodePriority(node) * 100, 8)}%` }}></div>
460-
</div>
461-
</div>
469+
<AnimatedPriority
470+
value={getNodePriority(node)}
471+
className="text-sm font-semibold"
472+
renderBar={(animatedValue, animatedColor) => (
473+
<div className="flex items-center relative">
474+
<div className="w-3 h-12 bg-gray-300 dark:bg-gray-600 rounded overflow-hidden flex flex-col justify-end relative">
475+
<div
476+
className="w-full transition-colors duration-300"
477+
style={{
478+
height: `${Math.max(animatedValue * 100, 8)}%`,
479+
backgroundColor: animatedColor
480+
}}
481+
></div>
482+
</div>
483+
</div>
484+
)}
485+
/>
486+
462487
<div className="flex flex-col">
463-
<span className={`text-sm font-semibold ${
464-
getNodePriority(node) >= 0.8 ? 'text-red-500' :
465-
getNodePriority(node) >= 0.6 ? 'text-orange-500' :
466-
getNodePriority(node) >= 0.4 ? 'text-yellow-500' :
467-
getNodePriority(node) >= 0.2 ? 'text-blue-500' : 'text-green-500'
468-
}`}>
469-
{Math.round(getNodePriority(node) * 100)}%
470-
</span>
471-
<span className={`text-xs font-medium ${
472-
getNodePriority(node) >= 0.8 ? 'text-red-500' :
473-
getNodePriority(node) >= 0.6 ? 'text-orange-500' :
474-
getNodePriority(node) >= 0.4 ? 'text-yellow-500' :
475-
getNodePriority(node) >= 0.2 ? 'text-blue-500' :
476-
'text-green-500'
477-
}`}>
488+
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
478489
{getNodePriority(node) >= 0.8 ? 'Critical' :
479490
getNodePriority(node) >= 0.6 ? 'High' :
480491
getNodePriority(node) >= 0.4 ? 'Medium' :
@@ -726,25 +737,21 @@ export function ListView() {
726737
<div className="mb-3 flex items-start justify-between">
727738
{/* Priority - Left Side */}
728739
<div className="flex items-center relative">
729-
<div className="w-4 h-12 bg-gray-600 rounded overflow-hidden flex flex-col justify-end relative">
730-
<div className={`w-full transition-all duration-300 ${
731-
getNodePriority(node) >= 0.8 ? 'bg-red-500' :
732-
getNodePriority(node) >= 0.6 ? 'bg-orange-500' :
733-
getNodePriority(node) >= 0.4 ? 'bg-yellow-500' :
734-
getNodePriority(node) >= 0.2 ? 'bg-blue-500' : 'bg-green-500'
735-
}`} style={{ height: `${Math.max(getNodePriority(node) * 100, 5)}%` }}></div>
736-
</div>
737-
<span className={`absolute text-xs font-bold left-6 ml-1 ${
738-
getNodePriority(node) >= 0.8 ? 'text-red-500' :
739-
getNodePriority(node) >= 0.6 ? 'text-orange-500' :
740-
getNodePriority(node) >= 0.4 ? 'text-yellow-500' :
741-
getNodePriority(node) >= 0.2 ? 'text-blue-500' : 'text-green-500'
742-
}`} style={{
743-
bottom: `${Math.max(getNodePriority(node) * 100, 5)}%`,
744-
transform: 'translateY(50%)'
745-
}}>
746-
{Math.round(getNodePriority(node) * 100)}%
747-
</span>
740+
<AnimatedPriority
741+
value={getNodePriority(node)}
742+
className="text-xs font-bold"
743+
renderBar={(animatedValue, animatedColor) => (
744+
<div className="w-4 h-12 bg-gray-600 rounded overflow-hidden flex flex-col justify-end relative">
745+
<div
746+
className="w-full transition-colors duration-300"
747+
style={{
748+
height: `${Math.max(animatedValue * 100, 5)}%`,
749+
backgroundColor: animatedColor
750+
}}
751+
></div>
752+
</div>
753+
)}
754+
/>
748755
</div>
749756

750757
{/* Due Date - Right Side */}
@@ -979,35 +986,21 @@ export function ListView() {
979986
</td>
980987
<td className="pl-6 pr-6 py-10 dynamic-table-cell">
981988
<div className="flex items-center w-full relative">
982-
<div className="w-4 h-16 bg-gray-600 rounded overflow-hidden flex flex-col justify-end relative">
983-
<div
984-
className={`w-full transition-all duration-300 ${
985-
getNodePriority(node) >= 0.8 ? 'bg-red-500' :
986-
getNodePriority(node) >= 0.6 ? 'bg-orange-500' :
987-
getNodePriority(node) >= 0.4 ? 'bg-yellow-500' :
988-
getNodePriority(node) >= 0.2 ? 'bg-blue-500' :
989-
'bg-green-500'
990-
}`}
991-
style={{
992-
height: `${Math.max(getNodePriority(node) * 100, 5)}%`
993-
}}
994-
></div>
995-
</div>
996-
<span
997-
className={`absolute text-xs font-bold left-6 ml-1 ${
998-
getNodePriority(node) >= 0.8 ? 'text-red-500' :
999-
getNodePriority(node) >= 0.6 ? 'text-orange-500' :
1000-
getNodePriority(node) >= 0.4 ? 'text-yellow-500' :
1001-
getNodePriority(node) >= 0.2 ? 'text-blue-500' :
1002-
'text-green-500'
1003-
}`}
1004-
style={{
1005-
bottom: `${Math.max(getNodePriority(node) * 100, 5)}%`,
1006-
transform: 'translateY(50%)'
1007-
}}
1008-
>
1009-
{Math.round(getNodePriority(node) * 100)}%
1010-
</span>
989+
<AnimatedPriority
990+
value={getNodePriority(node)}
991+
className="text-xs font-bold"
992+
renderBar={(animatedValue, animatedColor) => (
993+
<div className="w-4 h-16 bg-gray-600 rounded overflow-hidden flex flex-col justify-end relative">
994+
<div
995+
className="w-full transition-colors duration-300"
996+
style={{
997+
height: `${Math.max(animatedValue * 100, 5)}%`,
998+
backgroundColor: animatedColor
999+
}}
1000+
></div>
1001+
</div>
1002+
)}
1003+
/>
10111004
</div>
10121005
</td>
10131006
<td className="pl-6 pr-6 py-10 dynamic-table-cell">

0 commit comments

Comments
 (0)