Skip to content

Commit 8638362

Browse files
kanekoshoyuclaude
andcommitted
feat: add position tracking and animations to job list
- Track job position changes and score differences across updates - Add visual indicators (arrows) for jobs moving up/down in rankings - Display score changes with color-coded indicators - Implement smooth animations: slide-in for new jobs, bounce for promotions, shake for demotions - Add custom CSS keyframes for slideInLeft, bounceSubtle, and shake animations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 3c3bcf9 commit 8638362

File tree

2 files changed

+130
-6
lines changed

2 files changed

+130
-6
lines changed

src/components/interactive/JobList.tsx

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useState, useEffect } from "react";
3+
import { useState, useEffect, useRef } from "react";
44
import { GlowCard } from "./GlowCard";
55
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
66
import type { Job } from "@/types/models";
@@ -11,11 +11,54 @@ interface JobListProps {
1111
isSearching?: boolean;
1212
}
1313

14+
interface JobWithPosition extends Job {
15+
previousPosition?: number;
16+
currentPosition: number;
17+
isNew?: boolean;
18+
scoreChange?: number;
19+
}
20+
1421
export default function JobList({ jobs, searchComponent, isSearching = false }: JobListProps) {
1522
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
23+
const [jobsWithPosition, setJobsWithPosition] = useState<JobWithPosition[]>([]);
24+
const previousJobsRef = useRef<Map<string, { score: number; position: number }>>(new Map());
25+
26+
// Process jobs to track position changes
27+
useEffect(() => {
28+
if (jobs.length === 0) {
29+
setJobsWithPosition([]);
30+
return;
31+
}
32+
33+
// Sort jobs by Job Fit Score in descending order (highest first)
34+
const sortedJobs = [...jobs].sort((a, b) => b.jobFitScore - a.jobFitScore);
35+
36+
const newJobsWithPosition: JobWithPosition[] = sortedJobs.map((job, index) => {
37+
const previousData = previousJobsRef.current.get(job.id);
38+
const isNew = !previousData;
39+
const scoreChange = previousData ? job.jobFitScore - previousData.score : 0;
40+
41+
return {
42+
...job,
43+
currentPosition: index,
44+
previousPosition: previousData?.position,
45+
isNew,
46+
scoreChange
47+
};
48+
});
49+
50+
setJobsWithPosition(newJobsWithPosition);
51+
52+
// Update the reference for next comparison
53+
const newMap = new Map<string, { score: number; position: number }>();
54+
sortedJobs.forEach((job, index) => {
55+
newMap.set(job.id, { score: job.jobFitScore, position: index });
56+
});
57+
previousJobsRef.current = newMap;
58+
}, [jobs]);
1659

1760
// Sort jobs by Job Fit Score in descending order (highest first)
18-
const sortedJobs = [...jobs].sort((a, b) => b.jobFitScore - a.jobFitScore);
61+
const sortedJobs = jobsWithPosition;
1962

2063
// Auto-select first job when jobs change
2164
useEffect(() => {
@@ -43,20 +86,55 @@ export default function JobList({ jobs, searchComponent, isSearching = false }:
4386
return colors[index % colors.length];
4487
};
4588

46-
const renderJobListItem = (job: Job) => {
89+
const renderJobListItem = (job: JobWithPosition) => {
4790
const scoreColors = getScoreColor(job.jobFitScore);
4891
const isSelected = selectedJob?.id === job.id;
4992

93+
// Calculate position change
94+
const positionChange = job.previousPosition !== undefined
95+
? job.previousPosition - job.currentPosition
96+
: 0;
97+
98+
// Determine animation state
99+
let animationClass = '';
100+
let positionIndicator = null;
101+
102+
if (job.isNew) {
103+
animationClass = 'animate-slide-in-left';
104+
} else if (positionChange > 0) {
105+
// Moved up
106+
animationClass = 'animate-bounce-subtle';
107+
positionIndicator = (
108+
<div className="absolute -top-1 -right-1 flex items-center justify-center w-5 h-5 bg-green-500 text-white text-xs font-bold rounded-full animate-pulse">
109+
110+
</div>
111+
);
112+
} else if (positionChange < 0) {
113+
// Moved down
114+
animationClass = 'animate-shake';
115+
positionIndicator = (
116+
<div className="absolute -top-1 -right-1 flex items-center justify-center w-5 h-5 bg-orange-500 text-white text-xs font-bold rounded-full">
117+
118+
</div>
119+
);
120+
}
121+
50122
return (
51123
<div
52124
key={job.id}
53125
onClick={() => setSelectedJob(job)}
54-
className={`p-3 border rounded-lg cursor-pointer transition-all ${
126+
className={`relative p-3 border rounded-lg cursor-pointer transition-all duration-300 ${animationClass} ${
55127
isSelected
56128
? 'border-primary-500 bg-primary-50'
57129
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
58130
}`}
131+
style={{
132+
transitionProperty: 'all',
133+
transitionDuration: '500ms',
134+
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
135+
}}
59136
>
137+
{positionIndicator}
60138
<div className="flex items-center justify-between">
61139
<div className="flex-1 min-w-0">
62140
<h3 className={`font-medium text-sm truncate ${
@@ -71,10 +149,15 @@ export default function JobList({ jobs, searchComponent, isSearching = false }:
71149
</p>
72150
</div>
73151
<div className="flex items-center gap-2 flex-shrink-0">
74-
<div className={`text-xs font-bold ${scoreColors.text}`}>
152+
<div className={`text-xs font-bold ${scoreColors.text} transition-all duration-300`}>
75153
{job.jobFitScore}
154+
{job.scoreChange !== undefined && job.scoreChange !== 0 && (
155+
<span className={`ml-1 text-[10px] ${job.scoreChange > 0 ? 'text-green-600' : 'text-red-600'}`}>
156+
{job.scoreChange > 0 ? '+' : ''}{job.scoreChange.toFixed(1)}
157+
</span>
158+
)}
76159
</div>
77-
<div className={`w-2 h-6 ${scoreColors.bg} rounded-full`}></div>
160+
<div className={`w-2 h-6 ${scoreColors.bg} rounded-full transition-all duration-300`}></div>
78161
</div>
79162
</div>
80163
</div>

src/styles/global.css

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@
6565
--animate-fade-in: fadeIn 0.5s ease-in-out;
6666
--animate-slide-up: slideUp 0.5s ease-in-out;
6767
--animate-slide-down: slideDown 0.5s ease-in-out;
68+
--animate-slide-in-left: slideInLeft 0.5s ease-out;
69+
--animate-bounce-subtle: bounceSubtle 0.6s ease-in-out;
70+
--animate-shake: shake 0.5s ease-in-out;
6871
--animate-first: moveVertical 30s ease infinite;
6972
--animate-second: moveInCircle 20s reverse infinite;
7073
--animate-third: moveInCircle 40s linear infinite;
@@ -140,6 +143,44 @@
140143
transform: translateY(-50%);
141144
}
142145
}
146+
147+
@keyframes slideInLeft {
148+
0% {
149+
transform: translateX(-100%);
150+
opacity: 0;
151+
}
152+
100% {
153+
transform: translateX(0);
154+
opacity: 1;
155+
}
156+
}
157+
158+
@keyframes bounceSubtle {
159+
0%, 100% {
160+
transform: translateY(0);
161+
}
162+
25% {
163+
transform: translateY(-8px);
164+
}
165+
50% {
166+
transform: translateY(-4px);
167+
}
168+
75% {
169+
transform: translateY(-2px);
170+
}
171+
}
172+
173+
@keyframes shake {
174+
0%, 100% {
175+
transform: translateX(0);
176+
}
177+
10%, 30%, 50%, 70%, 90% {
178+
transform: translateX(-4px);
179+
}
180+
20%, 40%, 60%, 80% {
181+
transform: translateX(4px);
182+
}
183+
}
143184
}
144185

145186
/*

0 commit comments

Comments
 (0)