From 0e7a498c384c72c4866fcde888586bd0d618e813 Mon Sep 17 00:00:00 2001
From: SagiEv
Date: Wed, 9 Sep 2026 19:27:39 +0300
Subject: [PATCH 1/9] added experience to profile page component
---
backend/controllers/roles.controller.js | 20 +++
.../migrations/003_add_experience_tables.sql | 30 ++++
backend/repositories/profile.repository.js | 27 ++-
backend/routes/roles.routes.js | 8 +
backend/schema.sql | 18 ++
backend/server.js | 2 +
backend/services/profile.service.js | 12 +-
.../controllers/roles.controller.test.js | 50 ++++++
.../tests/services/profile.service.test.js | 54 +++++-
frontend/src/components/ExperienceEditor.jsx | 158 ++++++++++++++++++
.../src/hooks/__tests__/useRolesBank.test.jsx | 53 ++++++
frontend/src/hooks/useRolesBank.js | 13 ++
frontend/src/pages/ProfilePage.jsx | 13 ++
.../src/pages/__tests__/ProfilePage.test.jsx | 90 ++++++++++
14 files changed, 545 insertions(+), 3 deletions(-)
create mode 100644 backend/controllers/roles.controller.js
create mode 100644 backend/migrations/003_add_experience_tables.sql
create mode 100644 backend/routes/roles.routes.js
create mode 100644 backend/tests/controllers/roles.controller.test.js
create mode 100644 frontend/src/components/ExperienceEditor.jsx
create mode 100644 frontend/src/hooks/__tests__/useRolesBank.test.jsx
create mode 100644 frontend/src/hooks/useRolesBank.js
create mode 100644 frontend/src/pages/__tests__/ProfilePage.test.jsx
diff --git a/backend/controllers/roles.controller.js b/backend/controllers/roles.controller.js
new file mode 100644
index 0000000..cce4d19
--- /dev/null
+++ b/backend/controllers/roles.controller.js
@@ -0,0 +1,20 @@
+const supabase = require('../supabaseClient');
+
+const getRolesBank = async (req, res) => {
+ try {
+ const { data, error } = await req.supabase
+ .from('roles_dictionary')
+ .select('*')
+ .order('name');
+
+ if (error) throw new Error(error.message);
+
+ res.json(data);
+ } catch (error) {
+ res.status(400).json({ error: error.message });
+ }
+};
+
+module.exports = {
+ getRolesBank
+};
diff --git a/backend/migrations/003_add_experience_tables.sql b/backend/migrations/003_add_experience_tables.sql
new file mode 100644
index 0000000..47c2c56
--- /dev/null
+++ b/backend/migrations/003_add_experience_tables.sql
@@ -0,0 +1,30 @@
+-- Bank of roles and tags
+CREATE TABLE IF NOT EXISTS roles_dictionary (
+ id SERIAL PRIMARY KEY,
+ name TEXT UNIQUE NOT NULL,
+ category TEXT -- e.g., 'role', 'tag'
+);
+
+-- Relational table for tracking user experience
+CREATE TABLE IF NOT EXISTS user_experiences (
+ id SERIAL PRIMARY KEY,
+ user_id UUID NOT NULL, -- Link to user
+ role_id INTEGER REFERENCES roles_dictionary(id) ON DELETE CASCADE,
+ status TEXT CHECK (status IN ('current', 'previous')),
+ years INTEGER, -- Manual override (e.g., 0 for no experience, 1, 2...)
+ start_date DATE, -- Optional: used to dynamically calculate duration
+ end_date DATE -- Optional: marks the end of previous roles
+);
+
+-- Seed initial roles
+INSERT INTO roles_dictionary (name, category) VALUES
+ ('Software Engineer', 'role'),
+ ('Frontend Developer', 'role'),
+ ('Backend Developer', 'role'),
+ ('Fullstack Developer', 'role'),
+ ('DevOps Engineer', 'role'),
+ ('Data Scientist', 'role'),
+ ('Product Manager', 'role'),
+ ('UI', 'tag'),
+ ('UX', 'tag')
+ON CONFLICT (name) DO NOTHING;
diff --git a/backend/repositories/profile.repository.js b/backend/repositories/profile.repository.js
index 988ab75..ead259e 100644
--- a/backend/repositories/profile.repository.js
+++ b/backend/repositories/profile.repository.js
@@ -21,8 +21,33 @@ const createProfile = async (userId, updateData, client) => {
.single();
};
+const findUserExperiences = async (userId, client) => {
+ return await client
+ .from('user_experiences')
+ .select('*, roles_dictionary(*)')
+ .eq('user_id', userId);
+};
+
+const syncUserExperiences = async (userId, experiences, client) => {
+ // Simplified sync: delete all and re-insert
+ await client.from('user_experiences').delete().eq('user_id', userId);
+ if (experiences && experiences.length > 0) {
+ const rows = experiences.map(exp => ({
+ user_id: userId,
+ role_id: exp.role_id,
+ status: exp.status,
+ years: exp.years || null,
+ start_date: exp.start_date || null,
+ end_date: exp.end_date || null
+ }));
+ await client.from('user_experiences').insert(rows);
+ }
+};
+
module.exports = {
findFirstProfile,
updateProfile,
- createProfile
+ createProfile,
+ findUserExperiences,
+ syncUserExperiences
};
\ No newline at end of file
diff --git a/backend/routes/roles.routes.js b/backend/routes/roles.routes.js
new file mode 100644
index 0000000..9de43fc
--- /dev/null
+++ b/backend/routes/roles.routes.js
@@ -0,0 +1,8 @@
+const express = require('express');
+const router = express.Router();
+const rolesController = require('../controllers/roles.controller');
+const { authenticate } = require('../middleware/auth');
+
+router.get('/', authenticate, rolesController.getRolesBank);
+
+module.exports = router;
diff --git a/backend/schema.sql b/backend/schema.sql
index 47e31bf..31ca342 100644
--- a/backend/schema.sql
+++ b/backend/schema.sql
@@ -162,3 +162,21 @@ CREATE TABLE IF NOT EXISTS rss_jobs (
seniority TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
+
+-- Bank of roles and tags
+CREATE TABLE IF NOT EXISTS roles_dictionary (
+ id SERIAL PRIMARY KEY,
+ name TEXT UNIQUE NOT NULL,
+ category TEXT -- e.g., 'role', 'tag'
+);
+
+-- Relational table for tracking user experience
+CREATE TABLE IF NOT EXISTS user_experiences (
+ id SERIAL PRIMARY KEY,
+ user_id UUID NOT NULL, -- Link to user
+ role_id INTEGER REFERENCES roles_dictionary(id) ON DELETE CASCADE,
+ status TEXT CHECK (status IN ('current', 'previous')),
+ years INTEGER, -- Manual override (e.g., 0 for no experience, 1, 2...)
+ start_date DATE, -- Optional: used to dynamically calculate duration
+ end_date DATE -- Optional: marks the end of previous roles
+);
diff --git a/backend/server.js b/backend/server.js
index 2751e2c..65ab5f4 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -30,6 +30,7 @@ const { startMailPolling } = require('./cron/mail-poll-cron');
const notificationsRoutes = require('./routes/notifications.routes');
const rssRoutes = require('./routes/rss.routes');
const { startRssPolling } = require('./cron/rss-poll-cron');
+const rolesRoutes = require('./routes/roles.routes');
// --- Mount Routes ---
app.use('/auth', userRoutes);
@@ -49,6 +50,7 @@ app.use('/api/messages', messagesRoutes);
app.use('/api/events', eventsRoutes);
app.use('/api/notifications', notificationsRoutes);
app.use('/api/rss-feeds', rssRoutes);
+app.use('/api/roles-bank', rolesRoutes);
// Health check
app.get('/api/health', (req, res) => {
diff --git a/backend/services/profile.service.js b/backend/services/profile.service.js
index 9b9273d..9037c01 100644
--- a/backend/services/profile.service.js
+++ b/backend/services/profile.service.js
@@ -7,6 +7,11 @@ const getProfile = async (userId, supabaseClient) => {
let profile = data || {};
+ const { data: experiencesData, error: expError } = await profileRepository.findUserExperiences(userId, supabaseClient);
+ if (expError) throw new Error(expError.message);
+
+ profile.experiences = experiencesData || [];
+
// Transformation logic
if (profile.cv_data) {
profile.cvData = profile.cv_data;
@@ -20,7 +25,7 @@ const getProfile = async (userId, supabaseClient) => {
};
const upsertProfile = async (userId, payload, supabaseClient) => {
- const { id, ...updateData } = payload;
+ const { id, experiences, ...updateData } = payload;
// Mapping frontend keys to database keys
if ('cvData' in updateData) {
@@ -37,6 +42,11 @@ const upsertProfile = async (userId, payload, supabaseClient) => {
: await profileRepository.createProfile(userId, updateData, supabaseClient);
if (error) throw new Error(error.message);
+
+ if (experiences !== undefined) {
+ await profileRepository.syncUserExperiences(userId, experiences, supabaseClient);
+ }
+
return data;
};
diff --git a/backend/tests/controllers/roles.controller.test.js b/backend/tests/controllers/roles.controller.test.js
new file mode 100644
index 0000000..1446193
--- /dev/null
+++ b/backend/tests/controllers/roles.controller.test.js
@@ -0,0 +1,50 @@
+const rolesController = require('../../controllers/roles.controller');
+
+describe('Roles Controller', () => {
+ let mockReq;
+ let mockRes;
+
+ beforeEach(() => {
+ mockRes = {
+ json: jest.fn(),
+ status: jest.fn().mockReturnThis()
+ };
+ });
+
+ it('should return roles bank successfully (Sunny Day)', async () => {
+ mockReq = {
+ supabase: {
+ from: jest.fn().mockReturnValue({
+ select: jest.fn().mockReturnValue({
+ order: jest.fn().mockResolvedValue({
+ data: [{ id: 1, name: 'Software Engineer' }],
+ error: null
+ })
+ })
+ })
+ }
+ };
+
+ await rolesController.getRolesBank(mockReq, mockRes);
+ expect(mockRes.json).toHaveBeenCalledWith([{ id: 1, name: 'Software Engineer' }]);
+ });
+
+ it('should handle db error gracefully (Rainy Day)', async () => {
+ mockReq = {
+ supabase: {
+ from: jest.fn().mockReturnValue({
+ select: jest.fn().mockReturnValue({
+ order: jest.fn().mockResolvedValue({
+ data: null,
+ error: { message: 'Database error' }
+ })
+ })
+ })
+ }
+ };
+
+ await rolesController.getRolesBank(mockReq, mockRes);
+ expect(mockRes.status).toHaveBeenCalledWith(400);
+ expect(mockRes.json).toHaveBeenCalledWith({ error: 'Database error' });
+ });
+});
diff --git a/backend/tests/services/profile.service.test.js b/backend/tests/services/profile.service.test.js
index 9c004ba..c51f3e7 100644
--- a/backend/tests/services/profile.service.test.js
+++ b/backend/tests/services/profile.service.test.js
@@ -14,6 +14,10 @@ describe('profile.service', () => {
data: { id: 1, cv_data: { summary: 'My CV' }, website: null },
error: null,
});
+ profileRepository.findUserExperiences.mockResolvedValue({
+ data: [],
+ error: null,
+ });
// Act
const result = await profileService.getProfile('user-123');
@@ -29,6 +33,10 @@ describe('profile.service', () => {
data: { id: 1, website: 'github.com/user' },
error: null,
});
+ profileRepository.findUserExperiences.mockResolvedValue({
+ data: [],
+ error: null,
+ });
// Act
const result = await profileService.getProfile('user-123');
@@ -43,12 +51,16 @@ describe('profile.service', () => {
data: null,
error: { code: 'PGRST116', message: 'Not found' },
});
+ profileRepository.findUserExperiences.mockResolvedValue({
+ data: [],
+ error: null,
+ });
// Act
const result = await profileService.getProfile('user-123');
// Assert
- expect(result).toEqual({});
+ expect(result).toEqual({ experiences: [] });
});
it('should throw on non-PGRST116 error', async () => {
@@ -61,6 +73,24 @@ describe('profile.service', () => {
// Act & Assert
await expect(profileService.getProfile('user-123')).rejects.toThrow('real error');
});
+
+ it('should fetch and attach user experiences', async () => {
+ // Arrange
+ profileRepository.findFirstProfile.mockResolvedValue({
+ data: { id: 1 },
+ error: null,
+ });
+ profileRepository.findUserExperiences.mockResolvedValue({
+ data: [{ id: 1, status: 'current', years: 2 }],
+ error: null,
+ });
+
+ // Act
+ const result = await profileService.getProfile('user-123');
+
+ // Assert
+ expect(result.experiences).toEqual([{ id: 1, status: 'current', years: 2 }]);
+ });
});
describe('upsertProfile', () => {
@@ -69,6 +99,7 @@ describe('profile.service', () => {
profileRepository.updateProfile.mockResolvedValue({
data: { id: 1 }, error: null,
});
+ profileRepository.syncUserExperiences.mockResolvedValue();
// Act
await profileService.upsertProfile('user-123', {
@@ -81,6 +112,27 @@ describe('profile.service', () => {
expect.objectContaining({ cv_data: { summary: 'test' }, website: 'gh.com/me' }),
undefined
);
+ expect(profileRepository.syncUserExperiences).not.toHaveBeenCalled();
+ });
+
+ it('should call syncUserExperiences when experiences array is provided', async () => {
+ // Arrange
+ profileRepository.updateProfile.mockResolvedValue({
+ data: { id: 1 }, error: null,
+ });
+ profileRepository.syncUserExperiences.mockResolvedValue();
+
+ // Act
+ await profileService.upsertProfile('user-123', {
+ id: 1, experiences: [{ role_id: 1, status: 'current' }],
+ });
+
+ // Assert
+ expect(profileRepository.syncUserExperiences).toHaveBeenCalledWith(
+ 'user-123',
+ [{ role_id: 1, status: 'current' }],
+ undefined
+ );
});
it('should call createProfile when no id', async () => {
diff --git a/frontend/src/components/ExperienceEditor.jsx b/frontend/src/components/ExperienceEditor.jsx
new file mode 100644
index 0000000..04e1b88
--- /dev/null
+++ b/frontend/src/components/ExperienceEditor.jsx
@@ -0,0 +1,158 @@
+import React from 'react';
+import { useRolesBank } from '../hooks/useRolesBank';
+
+const ExperienceEditor = ({ experiences = [], onChange, status }) => {
+ const { data: rolesBank = [], isLoading } = useRolesBank();
+ const localExperiences = experiences.filter(exp => exp.status === status);
+
+ const handleAdd = () => {
+ const newExp = {
+ role_id: rolesBank.length > 0 ? rolesBank[0].id : '',
+ status,
+ years: 0,
+ start_date: '',
+ end_date: ''
+ };
+ const updated = [...experiences, newExp];
+ onChange(updated);
+ };
+
+ const handleUpdate = (index, field, value) => {
+ const filtered = experiences.filter(exp => exp.status === status);
+ const itemToUpdate = filtered[index];
+ const globalIndex = experiences.indexOf(itemToUpdate);
+
+ if (globalIndex > -1) {
+ const updated = [...experiences];
+ updated[globalIndex] = { ...updated[globalIndex], [field]: value };
+ onChange(updated);
+ }
+ };
+
+ const handleRemove = (index) => {
+ const filtered = experiences.filter(exp => exp.status === status);
+ const itemToRemove = filtered[index];
+ const globalIndex = experiences.indexOf(itemToRemove);
+
+ if (globalIndex > -1) {
+ const updated = experiences.filter((_, i) => i !== globalIndex);
+ onChange(updated);
+ }
+ };
+
+ const calculateDuration = (start) => {
+ if (!start) return '';
+ const startDate = new Date(start);
+ const now = new Date();
+ if (startDate > now) return 'Start date in future';
+ let months = (now.getFullYear() - startDate.getFullYear()) * 12;
+ months -= startDate.getMonth();
+ months += now.getMonth();
+ if (months <= 0) return 'Just started';
+ const years = Math.floor(months / 12);
+ const remainingMonths = months % 12;
+
+ const yearStr = years > 0 ? `${years} year${years > 1 ? 's' : ''}` : '';
+ const monthStr = remainingMonths > 0 ? `${remainingMonths} month${remainingMonths > 1 ? 's' : ''}` : '';
+
+ if (yearStr && monthStr) return `${yearStr}, ${monthStr}`;
+ if (yearStr) return yearStr;
+ return monthStr;
+ };
+
+ if (isLoading) return Loading roles...
;
+
+ return (
+
+
+ {status === 'current' ? 'Current Experience' : 'Previous Experience'}
+
+
+
+ {localExperiences.length === 0 && (
+
No {status} experience added.
+ )}
+
+ {localExperiences.map((exp, index) => (
+
+
+
+
Role
+
+
+
+
+
+
+ {status === 'current' ? (
+ <>
+
+
Years of Experience (Manual)
+
handleUpdate(index, 'years', e.target.value === '' ? null : parseInt(e.target.value, 10))}
+ placeholder="e.g. 0"
+ />
+
+
+
Start Date (Optional)
+
handleUpdate(index, 'start_date', e.target.value || null)}
+ />
+
+ {exp.start_date && (
+
+ Dynamic duration: {calculateDuration(exp.start_date)}
+
+ )}
+ >
+ ) : (
+ <>
+
+
Start Date
+
handleUpdate(index, 'start_date', e.target.value || null)}
+ />
+
+
+
End Date
+
handleUpdate(index, 'end_date', e.target.value || null)}
+ />
+
+ {exp.start_date && exp.end_date && exp.end_date < exp.start_date && (
+
+ End date cannot be before start date.
+
+ )}
+ >
+ )}
+
+
+ ))}
+
+ );
+};
+
+export default ExperienceEditor;
diff --git a/frontend/src/hooks/__tests__/useRolesBank.test.jsx b/frontend/src/hooks/__tests__/useRolesBank.test.jsx
new file mode 100644
index 0000000..2295e0b
--- /dev/null
+++ b/frontend/src/hooks/__tests__/useRolesBank.test.jsx
@@ -0,0 +1,53 @@
+import { renderHook, waitFor } from '@testing-library/react';
+import { useRolesBank } from '../useRolesBank';
+import apiClient from '../../services/apiClient';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import React from 'react';
+import { vi, describe, beforeEach, it, expect } from 'vitest';
+
+vi.mock('../../services/apiClient', () => ({
+ __esModule: true,
+ default: {
+ get: vi.fn()
+ },
+ getAccessToken: vi.fn(() => 'mock-token')
+}));
+
+describe('useRolesBank', () => {
+ let queryClient;
+
+ beforeEach(() => {
+ queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } }
+ });
+ vi.clearAllMocks();
+ });
+
+ const wrapper = ({ children }) => (
+
+ {children}
+
+ );
+
+ it('fetches roles bank successfully (Sunny Day)', async () => {
+ const mockRoles = [{ id: 1, name: 'Software Engineer' }];
+ apiClient.get.mockResolvedValueOnce({ data: mockRoles });
+
+ const { result } = renderHook(() => useRolesBank(), { wrapper });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(result.current.data).toEqual(mockRoles);
+ expect(apiClient.get).toHaveBeenCalledWith('/api/roles-bank');
+ });
+
+ it('handles API error gracefully (Rainy Day)', async () => {
+ apiClient.get.mockRejectedValueOnce(new Error('Network error'));
+
+ const { result } = renderHook(() => useRolesBank(), { wrapper });
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+
+ expect(result.current.data).toBeUndefined();
+ });
+});
diff --git a/frontend/src/hooks/useRolesBank.js b/frontend/src/hooks/useRolesBank.js
new file mode 100644
index 0000000..4ceb6e3
--- /dev/null
+++ b/frontend/src/hooks/useRolesBank.js
@@ -0,0 +1,13 @@
+import { useQuery } from '@tanstack/react-query';
+import apiClient, { getAccessToken } from '../services/apiClient';
+
+export function useRolesBank() {
+ return useQuery({
+ enabled: !!getAccessToken(),
+ queryKey: ['rolesBank'],
+ queryFn: async () => {
+ const response = await apiClient.get('/api/roles-bank');
+ return response.data || [];
+ }
+ });
+}
diff --git a/frontend/src/pages/ProfilePage.jsx b/frontend/src/pages/ProfilePage.jsx
index fd08b0e..8cb8c3a 100644
--- a/frontend/src/pages/ProfilePage.jsx
+++ b/frontend/src/pages/ProfilePage.jsx
@@ -6,6 +6,7 @@ import ReactQuill from 'react-quill';
import 'react-quill/dist/quill.snow.css';
// import api from '../api';
import PageLoader from '../components/PageLoader';
+import ExperienceEditor from '../components/ExperienceEditor';
const EditableCVField = ({ title, value, onChange }) => {
const confirm = useConfirm();
@@ -202,6 +203,18 @@ const ProfilePage = () => {
+ handleProfileChange('experiences', newExps)}
+ status="current"
+ />
+
+ handleProfileChange('experiences', newExps)}
+ status="previous"
+ />
+
handleProfileChange('cvData.summary', val)} />
handleProfileChange('cvData.technicalSkills', val)} />
handleProfileChange('cvData.education', val)} />
diff --git a/frontend/src/pages/__tests__/ProfilePage.test.jsx b/frontend/src/pages/__tests__/ProfilePage.test.jsx
new file mode 100644
index 0000000..a0fce90
--- /dev/null
+++ b/frontend/src/pages/__tests__/ProfilePage.test.jsx
@@ -0,0 +1,90 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import ProfilePage from '../ProfilePage';
+import { useProfile } from '../../hooks/useProfile';
+import { useRolesBank } from '../../hooks/useRolesBank';
+
+import { vi, describe, beforeEach, it, expect } from 'vitest';
+
+// Mock the hooks
+vi.mock('../../hooks/useProfile');
+vi.mock('../../hooks/useRolesBank');
+vi.mock('../../components/ToastProvider', () => ({
+ useToast: () => ({ addToast: vi.fn() })
+}));
+vi.mock('../../components/ConfirmProvider', () => ({
+ useConfirm: () => vi.fn()
+}));
+
+// Mock React Quill to avoid document undefined errors in tests
+vi.mock('react-quill', () => {
+ const Component = () => ;
+ return { default: Component };
+});
+
+describe('ProfilePage', () => {
+ const mockHandleProfileChange = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useRolesBank.mockReturnValue({
+ data: [{ id: 1, name: 'Software Engineer' }, { id: 2, name: 'UI' }],
+ isLoading: false
+ });
+ });
+
+ it('renders current experience and calculates months dynamically (Sunny Day)', () => {
+ useProfile.mockReturnValue({
+ profile: {
+ experiences: [
+ { status: 'current', role_id: 1, years: null, start_date: '2023-01-01' }
+ ]
+ },
+ loading: false,
+ error: null,
+ handleProfileChange: mockHandleProfileChange
+ });
+
+ render();
+
+ expect(screen.getByText('Current Experience')).toBeInTheDocument();
+ expect(screen.getByDisplayValue('Software Engineer')).toBeInTheDocument();
+
+ // We can't strictly match dynamic text because "months/years" depends on current date,
+ // but we can check if "Dynamic duration:" is present.
+ expect(screen.getByText(/Dynamic duration:/)).toBeInTheDocument();
+ });
+
+ it('allows adding 0 years of experience (Sunny Day Junior Case)', () => {
+ useProfile.mockReturnValue({
+ profile: {
+ experiences: [
+ { status: 'current', role_id: 2, years: 0, start_date: null }
+ ]
+ },
+ loading: false,
+ error: null,
+ handleProfileChange: mockHandleProfileChange
+ });
+
+ render();
+ const inputs = screen.getAllByPlaceholderText('e.g. 0');
+ expect(inputs[0].value).toBe('0');
+ });
+
+ it('displays warning if end date is before start date (Rainy Day)', () => {
+ useProfile.mockReturnValue({
+ profile: {
+ experiences: [
+ { status: 'previous', role_id: 1, start_date: '2023-01-01', end_date: '2022-01-01' }
+ ]
+ },
+ loading: false,
+ error: null,
+ handleProfileChange: mockHandleProfileChange
+ });
+
+ render();
+ expect(screen.getByText('End date cannot be before start date.')).toBeInTheDocument();
+ });
+});
From 0948b38f34100a5912464b5ce9db880535f30063 Mon Sep 17 00:00:00 2001
From: SagiEv
Date: Wed, 9 Sep 2026 22:29:39 +0300
Subject: [PATCH 2/9] Experience Editor & Validation Updates
---
backend/services/profile.service.js | 3 +
.../tests/services/profile.service.test.js | 18 +++++
frontend/src/components/ConfirmProvider.jsx | 72 +++++++++----------
frontend/src/components/ExperienceEditor.jsx | 59 +++++++++++++--
frontend/src/pages/ProfilePage.jsx | 22 +++---
frontend/tests/e2e/profile.spec.js | 26 +++++++
6 files changed, 149 insertions(+), 51 deletions(-)
create mode 100644 frontend/tests/e2e/profile.spec.js
diff --git a/backend/services/profile.service.js b/backend/services/profile.service.js
index 9037c01..514cfe4 100644
--- a/backend/services/profile.service.js
+++ b/backend/services/profile.service.js
@@ -44,6 +44,9 @@ const upsertProfile = async (userId, payload, supabaseClient) => {
if (error) throw new Error(error.message);
if (experiences !== undefined) {
+ if (experiences.filter(exp => exp.status === 'current').length > 1) {
+ throw new Error("Only one current experience is allowed.");
+ }
await profileRepository.syncUserExperiences(userId, experiences, supabaseClient);
}
diff --git a/backend/tests/services/profile.service.test.js b/backend/tests/services/profile.service.test.js
index c51f3e7..38adc7c 100644
--- a/backend/tests/services/profile.service.test.js
+++ b/backend/tests/services/profile.service.test.js
@@ -135,6 +135,24 @@ describe('profile.service', () => {
);
});
+ it('should throw error if multiple current experiences are provided', async () => {
+ // Arrange
+ profileRepository.updateProfile.mockResolvedValue({
+ data: { id: 1 }, error: null,
+ });
+
+ // Act & Assert
+ await expect(profileService.upsertProfile('user-123', {
+ id: 1,
+ experiences: [
+ { role_id: 1, status: 'current' },
+ { role_id: 2, status: 'current' }
+ ]
+ })).rejects.toThrow('Only one current experience is allowed.');
+
+ expect(profileRepository.syncUserExperiences).not.toHaveBeenCalled();
+ });
+
it('should call createProfile when no id', async () => {
// Arrange
profileRepository.createProfile.mockResolvedValue({
diff --git a/frontend/src/components/ConfirmProvider.jsx b/frontend/src/components/ConfirmProvider.jsx
index 52f7da8..8fcd7e7 100644
--- a/frontend/src/components/ConfirmProvider.jsx
+++ b/frontend/src/components/ConfirmProvider.jsx
@@ -11,25 +11,28 @@ export const ConfirmProvider = ({ children }) => {
resolve: null,
});
- const confirm = useCallback((message) => {
+ const confirm = useCallback((message, options = {}) => {
return new Promise((resolve) => {
setConfirmState({
isOpen: true,
message,
resolve,
+ options
});
});
}, []);
- const handleConfirm = useCallback(() => {
- if (confirmState.resolve) confirmState.resolve(true);
- setConfirmState({ isOpen: false, message: '', resolve: null });
+ const handleAction = useCallback((value) => {
+ if (confirmState.resolve) confirmState.resolve(value);
+ setConfirmState({ isOpen: false, message: '', resolve: null, options: {} });
}, [confirmState]);
- const handleCancel = useCallback(() => {
- if (confirmState.resolve) confirmState.resolve(false);
- setConfirmState({ isOpen: false, message: '', resolve: null });
- }, [confirmState]);
+ const defaultButtons = [
+ { text: 'Cancel', value: false, style: 'secondary' },
+ { text: 'Confirm', value: true, style: 'primary' }
+ ];
+
+ const buttons = confirmState.options?.buttons || defaultButtons;
return (
@@ -69,34 +72,31 @@ export const ConfirmProvider = ({ children }) => {
{confirmState.message}
-
-
+ {buttons.map((btn, index) => (
+
+ ))}
diff --git a/frontend/src/components/ExperienceEditor.jsx b/frontend/src/components/ExperienceEditor.jsx
index 04e1b88..3a8f7be 100644
--- a/frontend/src/components/ExperienceEditor.jsx
+++ b/frontend/src/components/ExperienceEditor.jsx
@@ -1,11 +1,60 @@
import React from 'react';
import { useRolesBank } from '../hooks/useRolesBank';
+import { useToast } from './ToastProvider';
+import { useConfirm } from './ConfirmProvider';
const ExperienceEditor = ({ experiences = [], onChange, status }) => {
const { data: rolesBank = [], isLoading } = useRolesBank();
+ const { addToast } = useToast();
+ const confirm = useConfirm();
const localExperiences = experiences.filter(exp => exp.status === status);
- const handleAdd = () => {
+ const handleAdd = async () => {
+ if (localExperiences.length > 0) {
+ const lastExp = localExperiences[localExperiences.length - 1];
+ const isFilled = lastExp.start_date || lastExp.end_date || (lastExp.years !== null && lastExp.years !== undefined && lastExp.years > 0);
+ if (!isFilled) {
+ addToast("Please fill out the existing experience before adding a new one.", "error");
+ return;
+ }
+ }
+
+ if (status === 'current' && localExperiences.length >= 1) {
+ const choice = await confirm(
+ "You already have a current role. What would you like to do?",
+ {
+ buttons: [
+ { text: 'Cancel', value: 'cancel', style: 'secondary' },
+ { text: 'Move to Previous', value: 'move', style: 'secondary' },
+ { text: 'Overwrite', value: 'overwrite', style: 'primary' }
+ ]
+ }
+ );
+
+ if (choice === 'cancel' || !choice) return;
+
+ let updated = [...experiences];
+ if (choice === 'move') {
+ const currentExpIndex = updated.findIndex(e => e.status === 'current');
+ if (currentExpIndex > -1) {
+ updated[currentExpIndex] = { ...updated[currentExpIndex], status: 'previous' };
+ }
+ } else if (choice === 'overwrite') {
+ updated = updated.filter(e => e.status !== 'current');
+ }
+
+ const newExp = {
+ role_id: rolesBank.length > 0 ? rolesBank[0].id : '',
+ status: 'current',
+ years: 0,
+ start_date: '',
+ end_date: ''
+ };
+ updated.push(newExp);
+ onChange(updated);
+ return;
+ }
+
const newExp = {
role_id: rolesBank.length > 0 ? rolesBank[0].id : '',
status,
@@ -63,9 +112,11 @@ const ExperienceEditor = ({ experiences = [], onChange, status }) => {
if (isLoading) return Loading roles...
;
return (
-
-
- {status === 'current' ? 'Current Experience' : 'Previous Experience'}
+
+
+
+ {status === 'current' ? 'Current Experience' : 'Previous Experience'}
+
diff --git a/frontend/src/pages/ProfilePage.jsx b/frontend/src/pages/ProfilePage.jsx
index 8cb8c3a..df3daef 100644
--- a/frontend/src/pages/ProfilePage.jsx
+++ b/frontend/src/pages/ProfilePage.jsx
@@ -201,19 +201,19 @@ const ProfilePage = () => {
Target Roles
handleProfileChange('roles', e.target.value)} />
-
-
handleProfileChange('experiences', newExps)}
- status="current"
- />
+ handleProfileChange('experiences', newExps)}
+ status="current"
+ />
- handleProfileChange('experiences', newExps)}
- status="previous"
- />
+ handleProfileChange('experiences', newExps)}
+ status="previous"
+ />
+
handleProfileChange('cvData.summary', val)} />
handleProfileChange('cvData.technicalSkills', val)} />
diff --git a/frontend/tests/e2e/profile.spec.js b/frontend/tests/e2e/profile.spec.js
new file mode 100644
index 0000000..e4f2b63
--- /dev/null
+++ b/frontend/tests/e2e/profile.spec.js
@@ -0,0 +1,26 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('Profile Page - Experience Editor', () => {
+ test.beforeEach(async ({ page }) => {
+ // Navigate to profile page assuming standard routing and auth is set up
+ await page.goto('/profile');
+ // Wait for the page to load by waiting for the CV & Preferences card
+ await page.waitForSelector('text=CV & Preferences');
+ });
+
+ test('should display toast when trying to add empty experience', async ({ page }) => {
+ // We try to add an experience. First find the "Current Experience" editor
+ const currentExpSection = page.locator('.experience-editor').filter({ hasText: 'Current Experience' });
+ const addRoleBtn = currentExpSection.locator('button', { hasText: '+ Add Role' });
+
+ // Add one role (if list is empty, this adds an empty role)
+ await addRoleBtn.click();
+
+ // Attempt to add another role while the previous one is still empty
+ await addRoleBtn.click();
+
+ // Verify toast appears
+ const toast = page.locator('text=Please fill out the existing experience before adding a new one.');
+ await expect(toast).toBeVisible();
+ });
+});
From 0dfdb3e2508abe542057039f7d210b48f4d63533 Mon Sep 17 00:00:00 2001
From: SagiEv
Date: Wed, 9 Sep 2026 22:42:22 +0300
Subject: [PATCH 3/9] fixed navigation to Profile page fail
---
frontend/tests/e2e/profile.spec.js | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/frontend/tests/e2e/profile.spec.js b/frontend/tests/e2e/profile.spec.js
index e4f2b63..dff7cc5 100644
--- a/frontend/tests/e2e/profile.spec.js
+++ b/frontend/tests/e2e/profile.spec.js
@@ -2,10 +2,14 @@ import { test, expect } from '@playwright/test';
test.describe('Profile Page - Experience Editor', () => {
test.beforeEach(async ({ page }) => {
- // Navigate to profile page assuming standard routing and auth is set up
- await page.goto('/profile');
+ // Navigate to root (Dashboard) which uses the globally authenticated state
+ await page.goto('/');
+
+ // Navigate to profile via UI
+ await page.getByText('Profile', { exact: true }).first().click();
+
// Wait for the page to load by waiting for the CV & Preferences card
- await page.waitForSelector('text=CV & Preferences');
+ await expect(page.getByText('CV & Preferences')).toBeVisible({ timeout: 10000 });
});
test('should display toast when trying to add empty experience', async ({ page }) => {
@@ -21,6 +25,6 @@ test.describe('Profile Page - Experience Editor', () => {
// Verify toast appears
const toast = page.locator('text=Please fill out the existing experience before adding a new one.');
- await expect(toast).toBeVisible();
+ await expect(toast).toBeVisible({ timeout: 5000 });
});
});
From be121d89cc907b5d204b427563ec91489d8bc67b Mon Sep 17 00:00:00 2001
From: SagiEv
Date: Wed, 9 Sep 2026 23:14:33 +0300
Subject: [PATCH 4/9] added cache for roles fetch
---
frontend/src/hooks/useRolesBank.js | 6 +++++-
frontend/tests/e2e/profile.spec.js | 3 +++
2 files changed, 8 insertions(+), 1 deletion(-)
diff --git a/frontend/src/hooks/useRolesBank.js b/frontend/src/hooks/useRolesBank.js
index 4ceb6e3..017fa2e 100644
--- a/frontend/src/hooks/useRolesBank.js
+++ b/frontend/src/hooks/useRolesBank.js
@@ -8,6 +8,10 @@ export function useRolesBank() {
queryFn: async () => {
const response = await apiClient.get('/api/roles-bank');
return response.data || [];
- }
+ },
+ staleTime: Infinity,
+ gcTime: 1000 * 60 * 60 * 24, // 24 hours
+ refetchOnWindowFocus: false,
+ refetchOnMount: false,
});
}
diff --git a/frontend/tests/e2e/profile.spec.js b/frontend/tests/e2e/profile.spec.js
index dff7cc5..bafb5af 100644
--- a/frontend/tests/e2e/profile.spec.js
+++ b/frontend/tests/e2e/profile.spec.js
@@ -5,6 +5,9 @@ test.describe('Profile Page - Experience Editor', () => {
// Navigate to root (Dashboard) which uses the globally authenticated state
await page.goto('/');
+ // Ensure Dashboard is fully loaded before trying to click sidebar items
+ await expect(page.getByText('Dashboard', { exact: true }).first()).toBeVisible({ timeout: 10000 });
+
// Navigate to profile via UI
await page.getByText('Profile', { exact: true }).first().click();
From 79e8511fb1a3a9e1f3da6f8d09a76cd50b6cbb3b Mon Sep 17 00:00:00 2001
From: SagiEv
Date: Wed, 9 Sep 2026 23:38:06 +0300
Subject: [PATCH 5/9] dropdown to see all prev experience
---
frontend/src/components/ExperienceEditor.jsx | 45 +++++++++++++++++---
frontend/tests/e2e/profile.spec.js | 12 +++---
2 files changed, 46 insertions(+), 11 deletions(-)
diff --git a/frontend/src/components/ExperienceEditor.jsx b/frontend/src/components/ExperienceEditor.jsx
index 3a8f7be..ceddb6c 100644
--- a/frontend/src/components/ExperienceEditor.jsx
+++ b/frontend/src/components/ExperienceEditor.jsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useState, useMemo } from 'react';
import { useRolesBank } from '../hooks/useRolesBank';
import { useToast } from './ToastProvider';
import { useConfirm } from './ConfirmProvider';
@@ -8,6 +8,22 @@ const ExperienceEditor = ({ experiences = [], onChange, status }) => {
const { addToast } = useToast();
const confirm = useConfirm();
const localExperiences = experiences.filter(exp => exp.status === status);
+ const [showAll, setShowAll] = useState(false);
+
+ // Calculate the most recent previous experience to display by default
+ const mostRecentIndex = useMemo(() => {
+ if (status !== 'previous' || localExperiences.length === 0) return -1;
+ let latestIdx = 0;
+ let latestDate = 0;
+ localExperiences.forEach((exp, i) => {
+ const dateVal = exp.end_date ? new Date(exp.end_date).getTime() : (exp.start_date ? new Date(exp.start_date).getTime() : 0);
+ if (dateVal > latestDate) {
+ latestDate = dateVal;
+ latestIdx = i;
+ }
+ });
+ return latestIdx;
+ }, [localExperiences, status]);
const handleAdd = async () => {
if (localExperiences.length > 0) {
@@ -124,9 +140,14 @@ const ExperienceEditor = ({ experiences = [], onChange, status }) => {
No {status} experience added.
)}
- {localExperiences.map((exp, index) => (
-
-
+ {localExperiences.map((exp, index) => {
+ if (status === 'previous' && !showAll && localExperiences.length > 1 && index !== mostRecentIndex) {
+ return null;
+ }
+
+ return (
+
+
- ))}
+ );
+ })}
+
+ {status === 'previous' && localExperiences.length > 1 && (
+
+
+
+ )}
);
};
diff --git a/frontend/tests/e2e/profile.spec.js b/frontend/tests/e2e/profile.spec.js
index bafb5af..b28cf91 100644
--- a/frontend/tests/e2e/profile.spec.js
+++ b/frontend/tests/e2e/profile.spec.js
@@ -5,14 +5,14 @@ test.describe('Profile Page - Experience Editor', () => {
// Navigate to root (Dashboard) which uses the globally authenticated state
await page.goto('/');
- // Ensure Dashboard is fully loaded before trying to click sidebar items
- await expect(page.getByText('Dashboard', { exact: true }).first()).toBeVisible({ timeout: 10000 });
+ // Ensure Dashboard is fully loaded by waiting for its specific heading
+ await expect(page.getByRole('heading', { name: 'Welcome back, Job Hunter!' })).toBeVisible({ timeout: 10000 });
- // Navigate to profile via UI
- await page.getByText('Profile', { exact: true }).first().click();
+ // Navigate to profile via UI using robust locator
+ await page.locator('.nav-item').filter({ hasText: /^Profile$/ }).click();
- // Wait for the page to load by waiting for the CV & Preferences card
- await expect(page.getByText('CV & Preferences')).toBeVisible({ timeout: 10000 });
+ // Wait for the Profile page to load by looking for its main heading
+ await expect(page.getByRole('heading', { name: 'My Profile' })).toBeVisible({ timeout: 10000 });
});
test('should display toast when trying to add empty experience', async ({ page }) => {
From d68563348f78c96cd6b30186a59312c1f882ff2e Mon Sep 17 00:00:00 2001
From: SagiEv
Date: Thu, 10 Sep 2026 00:16:23 +0300
Subject: [PATCH 6/9] improved UI/UX & removed legacy experience from portfolio
page
---
backend/controllers/experience.controller.js | 26 +-------------
backend/routes/experience.routes.js | 5 ---
.../controllers/experience.controller.test.js | 17 ---------
frontend/src/components/ExperienceEditor.jsx | 17 +++++++--
frontend/src/hooks/useExperience.js | 36 ++-----------------
frontend/src/pages/ExperiencePage.jsx | 31 +---------------
6 files changed, 20 insertions(+), 112 deletions(-)
diff --git a/backend/controllers/experience.controller.js b/backend/controllers/experience.controller.js
index 656e8b2..7472721 100644
--- a/backend/controllers/experience.controller.js
+++ b/backend/controllers/experience.controller.js
@@ -42,33 +42,9 @@ const deleteProject = async (req, res) => {
}
};
-// Experience Text
-const getExpText = async (req, res) => {
- try {
- const userId = req.user.id;
- const data = await experienceService.getExperienceText(userId, req.supabase);
- res.json(data);
- } catch (error) {
- res.status(400).json({ error: error.message });
- }
-};
-
-const putExpText = async (req, res) => {
- try {
- const userId = req.user.id;
- const { id, text } = req.body;
- const data = await experienceService.saveExperienceText(userId, id, text, req.supabase);
- res.json(data);
- } catch (error) {
- res.status(400).json({ error: error.message });
- }
-};
-
module.exports = {
getProjects,
postProject,
putProject,
- deleteProject,
- getExpText,
- putExpText
+ deleteProject
};
\ No newline at end of file
diff --git a/backend/routes/experience.routes.js b/backend/routes/experience.routes.js
index 496dbde..a484567 100644
--- a/backend/routes/experience.routes.js
+++ b/backend/routes/experience.routes.js
@@ -8,9 +8,4 @@ router.get('/projects', authenticate, experienceController.getProjects);
router.post('/projects', authenticate, experienceController.postProject);
router.put('/projects/:id', authenticate, experienceController.putProject);
router.delete('/projects/:id', authenticate, experienceController.deleteProject);
-
-// Text Routes
-router.get('/text', authenticate, experienceController.getExpText);
-router.put('/text', authenticate, experienceController.putExpText);
-
module.exports = router;
\ No newline at end of file
diff --git a/backend/tests/controllers/experience.controller.test.js b/backend/tests/controllers/experience.controller.test.js
index 5d7a529..4c18b78 100644
--- a/backend/tests/controllers/experience.controller.test.js
+++ b/backend/tests/controllers/experience.controller.test.js
@@ -51,21 +51,4 @@ describe('experience.controller', () => {
});
});
- describe('getExpText', () => {
- it('should return data', async () => {
- const { req, res } = buildReqRes();
- experienceService.getExperienceText.mockResolvedValue({ text: 'exp' });
- await controller.getExpText(req, res);
- expect(res.json).toHaveBeenCalledWith({ text: 'exp' });
- });
- });
-
- describe('putExpText', () => {
- it('should return data', async () => {
- const { req, res } = buildReqRes({ body: { id: 1, text: 'updated' } });
- experienceService.saveExperienceText.mockResolvedValue({ text: 'updated' });
- await controller.putExpText(req, res);
- expect(res.json).toHaveBeenCalledWith({ text: 'updated' });
- });
- });
});
diff --git a/frontend/src/components/ExperienceEditor.jsx b/frontend/src/components/ExperienceEditor.jsx
index ceddb6c..9cbd0d4 100644
--- a/frontend/src/components/ExperienceEditor.jsx
+++ b/frontend/src/components/ExperienceEditor.jsx
@@ -136,8 +136,21 @@ const ExperienceEditor = ({ experiences = [], onChange, status }) => {
- {localExperiences.length === 0 && (
-
No {status} experience added.
+ {localExperiences.length === 0 && status === 'current' && (
+
+
🌱
+
Starting Fresh!
+
+ It looks like you're at the beginning of your journey. Add a role to get started, or keep it this way if you're a junior!
+
+
)}
{localExperiences.map((exp, index) => {
diff --git a/frontend/src/hooks/useExperience.js b/frontend/src/hooks/useExperience.js
index 0c8cf97..7d66fe6 100644
--- a/frontend/src/hooks/useExperience.js
+++ b/frontend/src/hooks/useExperience.js
@@ -4,19 +4,12 @@ import apiClient, { getAccessToken } from '../services/apiClient';
export function useExperience() {
const queryClient = useQueryClient();
- const { data: { projects = [], experienceTextObj = { id: null, text: '' } } = {}, isLoading: loading } = useQuery({
+ const { data: { projects = [] } = {}, isLoading: loading } = useQuery({
enabled: !!getAccessToken(),
queryKey: ['experience'],
queryFn: async () => {
- const [projRes, textRes] = await Promise.all([
- apiClient.get('/api/experience/projects'),
- apiClient.get('/api/experience/text')
- ]);
- let textObj = { id: null, text: '' };
- if (textRes.data && textRes.data.text) {
- textObj = textRes.data;
- }
- return { projects: projRes.data || [], experienceTextObj: textObj };
+ const projRes = await apiClient.get('/api/experience/projects');
+ return { projects: projRes.data || [] };
}
});
@@ -53,15 +46,6 @@ export function useExperience() {
}
});
- const updateTextMutation = useMutation({
- mutationFn: async ({ id, text }) => {
- const res = await apiClient.put('/api/experience/text', { id, text });
- return res.data;
- },
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['experience'] });
- }
- });
const addProject = async (data) => {
return addProjectMutation.mutateAsync(data);
@@ -97,24 +81,10 @@ export function useExperience() {
return deleteProjectMutation.mutateAsync(id);
};
- const setExperienceText = async (newText) => {
- // Optimistic update
- queryClient.setQueryData(['experience'], (old) => {
- if (!old) return old;
- return {
- ...old,
- experienceTextObj: { ...old.experienceTextObj, text: newText }
- };
- });
-
- return updateTextMutation.mutateAsync({ id: experienceTextObj.id, text: newText });
- };
return {
projects,
loading,
- experienceText: experienceTextObj.text,
- setExperienceText,
addProject,
updateProject,
deleteProject
diff --git a/frontend/src/pages/ExperiencePage.jsx b/frontend/src/pages/ExperiencePage.jsx
index d369048..3962e76 100644
--- a/frontend/src/pages/ExperiencePage.jsx
+++ b/frontend/src/pages/ExperiencePage.jsx
@@ -6,13 +6,11 @@ import PageLoader from '../components/PageLoader';
const ExperiencePage = () => {
const confirm = useConfirm();
- const { projects, loading, experienceText, setExperienceText, addProject, updateProject, deleteProject } = useExperience();
+ const { projects, loading, addProject, updateProject, deleteProject } = useExperience();
// Modals/Forms State
const [projectModal, setProjectModal] = useState({ show: false, mode: 'add', id: null });
- const [showExpForm, setShowExpForm] = useState(false);
const [formData, setFormData] = useState({ name: '', tech: '', summary: '', link: '' });
- const [tempExp, setTempExp] = useState(experienceText);
const handleProjectSubmit = (e) => {
e.preventDefault();
@@ -43,37 +41,10 @@ const ExperiencePage = () => {
Portfolio
- {!experienceText && !showExpForm && (
-
- )}
- {/* 1. Experience Section: Conditional Rendering */}
- {showExpForm ? (
-
-
Edit Professional Experience
-
- ) : experienceText ? (
-
-
- Professional Experience
-
-
-
{experienceText}
-
- ) : null}
{/* 2. Projects Grid with Hover Actions */}
From 4bb969350a99a0afa2a699ed0b223af0af9e70a3 Mon Sep 17 00:00:00 2001
From: SagiEv
Date: Thu, 10 Sep 2026 18:01:20 +0300
Subject: [PATCH 7/9] E2E locator fix
---
frontend/tests/e2e/profile.spec.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/frontend/tests/e2e/profile.spec.js b/frontend/tests/e2e/profile.spec.js
index b28cf91..64943b8 100644
--- a/frontend/tests/e2e/profile.spec.js
+++ b/frontend/tests/e2e/profile.spec.js
@@ -11,8 +11,8 @@ test.describe('Profile Page - Experience Editor', () => {
// Navigate to profile via UI using robust locator
await page.locator('.nav-item').filter({ hasText: /^Profile$/ }).click();
- // Wait for the Profile page to load by looking for its main heading
- await expect(page.getByRole('heading', { name: 'My Profile' })).toBeVisible({ timeout: 10000 });
+ // Wait for the Profile page to load by looking for the Personal Info section
+ await expect(page.locator('text=Personal Info').first()).toBeVisible({ timeout: 10000 });
});
test('should display toast when trying to add empty experience', async ({ page }) => {
From 1d142ce6744225bf5c2ab2f503e1ba5b4498b2e4 Mon Sep 17 00:00:00 2001
From: SagiEv
Date: Sat, 12 Sep 2026 14:02:21 +0300
Subject: [PATCH 8/9] added missing migration
---
frontend/tests/e2e/profile.spec.js | 16 +++++---
.../20260910000000_add_experience_tables.sql | 41 +++++++++++++++++++
2 files changed, 52 insertions(+), 5 deletions(-)
create mode 100644 supabase/migrations/20260910000000_add_experience_tables.sql
diff --git a/frontend/tests/e2e/profile.spec.js b/frontend/tests/e2e/profile.spec.js
index 64943b8..825fd5d 100644
--- a/frontend/tests/e2e/profile.spec.js
+++ b/frontend/tests/e2e/profile.spec.js
@@ -8,11 +8,17 @@ test.describe('Profile Page - Experience Editor', () => {
// Ensure Dashboard is fully loaded by waiting for its specific heading
await expect(page.getByRole('heading', { name: 'Welcome back, Job Hunter!' })).toBeVisible({ timeout: 10000 });
- // Navigate to profile via UI using robust locator
- await page.locator('.nav-item').filter({ hasText: /^Profile$/ }).click();
-
- // Wait for the Profile page to load by looking for the Personal Info section
- await expect(page.locator('text=Personal Info').first()).toBeVisible({ timeout: 10000 });
+ // Navigate to profile via sidebar and wait for the profile API to finish
+ const profileResponsePromise = page.waitForResponse(
+ resp => resp.url().includes('/api/profile') && resp.request().method() === 'GET',
+ { timeout: 15000 }
+ );
+ await page.getByText('Profile', { exact: true }).click();
+ await profileResponsePromise;
+
+ // Wait for the Profile page to finish rendering (loader must disappear)
+ await expect(page.locator('.profile-grid')).toBeVisible({ timeout: 10000 });
+ await expect(page.locator('text=Personal Info').first()).toBeVisible({ timeout: 5000 });
});
test('should display toast when trying to add empty experience', async ({ page }) => {
diff --git a/supabase/migrations/20260910000000_add_experience_tables.sql b/supabase/migrations/20260910000000_add_experience_tables.sql
new file mode 100644
index 0000000..5db7973
--- /dev/null
+++ b/supabase/migrations/20260910000000_add_experience_tables.sql
@@ -0,0 +1,41 @@
+-- Bank of roles and tags (shared/global, no RLS needed)
+CREATE TABLE IF NOT EXISTS roles_dictionary (
+ id SERIAL PRIMARY KEY,
+ name TEXT UNIQUE NOT NULL,
+ category TEXT -- e.g., 'role', 'tag'
+);
+
+-- Relational table for tracking user experience
+CREATE TABLE IF NOT EXISTS user_experiences (
+ id SERIAL PRIMARY KEY,
+ user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
+ role_id INTEGER REFERENCES roles_dictionary(id) ON DELETE CASCADE,
+ status TEXT CHECK (status IN ('current', 'previous')),
+ years INTEGER,
+ start_date DATE,
+ end_date DATE
+);
+
+-- Seed initial roles
+INSERT INTO roles_dictionary (name, category) VALUES
+ ('Software Engineer', 'role'),
+ ('Frontend Developer', 'role'),
+ ('Backend Developer', 'role'),
+ ('Fullstack Developer', 'role'),
+ ('DevOps Engineer', 'role'),
+ ('Data Scientist', 'role'),
+ ('Product Manager', 'role'),
+ ('UI', 'tag'),
+ ('UX', 'tag')
+ON CONFLICT (name) DO NOTHING;
+
+-- roles_dictionary is a shared lookup table, allow all authenticated users to read
+ALTER TABLE roles_dictionary ENABLE ROW LEVEL SECURITY;
+CREATE POLICY "Anyone can view roles_dictionary" ON roles_dictionary FOR SELECT USING (true);
+
+-- user_experiences: each user can only access their own
+ALTER TABLE user_experiences ENABLE ROW LEVEL SECURITY;
+CREATE POLICY "Users can view their own experiences" ON user_experiences FOR SELECT USING (auth.uid() = user_id);
+CREATE POLICY "Users can insert their own experiences" ON user_experiences FOR INSERT WITH CHECK (auth.uid() = user_id);
+CREATE POLICY "Users can update their own experiences" ON user_experiences FOR UPDATE USING (auth.uid() = user_id);
+CREATE POLICY "Users can delete their own experiences" ON user_experiences FOR DELETE USING (auth.uid() = user_id);
From a5daf79cb2f007d106b3dad04517ba13742e47d9 Mon Sep 17 00:00:00 2001
From: SagiEv
Date: Sun, 13 Sep 2026 12:46:15 +0300
Subject: [PATCH 9/9] UI improvements
---
frontend/src/components/ExperienceEditor.jsx | 53 +++++++++++++-------
1 file changed, 36 insertions(+), 17 deletions(-)
diff --git a/frontend/src/components/ExperienceEditor.jsx b/frontend/src/components/ExperienceEditor.jsx
index 9cbd0d4..ce4e878 100644
--- a/frontend/src/components/ExperienceEditor.jsx
+++ b/frontend/src/components/ExperienceEditor.jsx
@@ -125,31 +125,46 @@ const ExperienceEditor = ({ experiences = [], onChange, status }) => {
return monthStr;
};
- if (isLoading) return Loading roles...
;
-
return (
-
+
{status === 'current' ? 'Current Experience' : 'Previous Experience'}
{localExperiences.length === 0 && status === 'current' && (
-
-
🌱
-
Starting Fresh!
-
- It looks like you're at the beginning of your journey. Add a role to get started, or keep it this way if you're a junior!
-
+ Junior
+
+ i
+
)}
@@ -169,9 +184,13 @@ const ExperienceEditor = ({ experiences = [], onChange, status }) => {
onChange={(e) => handleUpdate(index, 'role_id', parseInt(e.target.value, 10))}
>
- {rolesBank.map(role => (
-
- ))}
+ {isLoading ? (
+
+ ) : (
+ rolesBank.map(role => (
+
+ ))
+ )}