Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/posters/labconnect_F25_poster.pdf
Binary file not shown.
Binary file added docs/posters/labonnect_S25_poster (1).pdf
Binary file not shown.
1 change: 0 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ function App() {
<Route path="/logout" element={<LogoutRedirection />} />
<Route path="/opportunities" element={<Opportunities />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/saved" element={<SavedPage />} />
<Route
path="/staff/department/:department"
element={<Department />}
Expand Down
171 changes: 171 additions & 0 deletions src/context/OpportunityContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// src/context/OpportunityContext.tsx
import React, { createContext, useReducer, useCallback } from "react";
import { OpportunityAction } from "../types/opportunityaction";
import { Opportunity } from "../types/opportunity";
import { Filters } from "../types/filters";

const currYr = new Date().getFullYear();

interface OpportunityState {
filters: {
activeFilters: string[];
filterMap: Filters;
};
query: string;
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The query field in state is set but never exposed through the context provider. It should be added to the OpportunityContextType interface and the context provider value to be usable by consuming components.

Copilot uses AI. Check for mistakes.
activeId: string;
opportunities: Opportunity[];
}

const initialState: OpportunityState = {
filters: {
activeFilters: ["2025"],
filterMap: { years: [2025], credits: [], hourlyPay: 0, majors: [] },
Comment on lines +21 to +22
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The initial state hardcodes "2025" and [2025] instead of using currYr (defined on line 7), which means the initial filters won't update when the year changes. Replace "2025" with currYr.toString() and [2025] with [currYr] for consistency with the RESET_FILTERS action.

Suggested change
activeFilters: ["2025"],
filterMap: { years: [2025], credits: [], hourlyPay: 0, majors: [] },
activeFilters: [currYr.toString()],
filterMap: { years: [currYr], credits: [], hourlyPay: 0, majors: [] },

Copilot uses AI. Check for mistakes.
},
query: "",
activeId: "",
opportunities: [],
};

export interface OpportunityContextType {
activeFilters: OpportunityState["filters"]["activeFilters"];
filterMap: OpportunityState["filters"]["filterMap"];
opportunities: OpportunityState["opportunities"];
resetFilters: () => void;
removeFilter: (name: string) => void;
setFilters: (activeFilters: string[], filterMap: Filters) => void;
setQuery: (query: string) => void;
setOpportunities: (opportunities: Opportunity[]) => void;
}

// Stable default values for HMR-safe context
const noop = () => {};

export const OpportunityContext = createContext<OpportunityContextType>({
activeFilters: initialState.filters.activeFilters,
filterMap: initialState.filters.filterMap,
opportunities: initialState.opportunities,
resetFilters: noop,
removeFilter: noop,
setFilters: noop,
setQuery: noop,
setOpportunities: noop,
});

export const OpportunityProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const [opportunityState, dispatch] = useReducer(
(state: OpportunityState, action: OpportunityAction) => {
switch (action.type) {
case "SET_QUERY":
return { ...state, query: action.query };

case "SET_FILTERS":
if (!action.activeFilters || !action.filterMap) return state;
return {
...state,
filters: {
activeFilters: action.activeFilters,
filterMap: action.filterMap,
},
};

case "RESET_FILTERS":
return {
...state,
filters: {
activeFilters: [currYr.toString()],
filterMap: {
years: [currYr],
credits: [],
hourlyPay: 0,
majors: [],
},
},
};

case "REMOVE_FILTER":
if (!action.filter) return state;
const newActiveFilters = state.filters.activeFilters.filter(
(filter) => filter !== action.filter
);
const newFilterMap = {
...state.filters.filterMap,
years: state.filters.filterMap.years.filter(
(year) => year !== parseInt(action.filter)
),
credits: action.filter.includes("Credit")
? []
: state.filters.filterMap.credits,
majors: state.filters.filterMap.majors.filter(
(major) => major !== action.filter
),
hourlyPay: action.filter.includes("$")
? 0
: state.filters.filterMap.hourlyPay,
};
return {
...state,
filters: {
activeFilters: newActiveFilters,
filterMap: newFilterMap,
},
};

case "SET_OPPORTUNITIES":
if (!action.opportunities) return state;
return { ...state, opportunities: action.opportunities };

default:
return state;
}
},
initialState
);

const resetFilters = useCallback(
() => dispatch({ type: "RESET_FILTERS" }),
[]
);

const removeFilter = useCallback(
(name: string) => dispatch({ type: "REMOVE_FILTER", filter: name }),
[]
);

const setFilters = useCallback(
(activeFilters: string[], filterMap: Filters) =>
dispatch({ type: "SET_FILTERS", activeFilters, filterMap }),
[]
);

const setQuery = useCallback(
(query: string) => dispatch({ type: "SET_QUERY", query }),
[]
);

const setOpportunities = useCallback(
(opportunities: Opportunity[]) =>
dispatch({ type: "SET_OPPORTUNITIES", opportunities }),
[]
);

return (
<OpportunityContext.Provider
value={{
activeFilters: opportunityState.filters.activeFilters,
filterMap: opportunityState.filters.filterMap,
opportunities: opportunityState.opportunities,
resetFilters,
removeFilter,
setFilters,
setQuery,
setOpportunities,
}}
>
{children}
</OpportunityContext.Provider>
);
};
6 changes: 6 additions & 0 deletions src/context/useOpportunity.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { useContext } from "react";
import { OpportunityContext } from "./OpportunityContext";

export const useOpportunity = () => {
return useContext(OpportunityContext);
};
153 changes: 78 additions & 75 deletions src/individuals/pages/Saved.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,94 +33,97 @@ export default function SavedPage() {
} catch {
console.log("Error fetching saved");
}
console.log(saved)
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Debug console.log statements should be removed from production code (lines 32 and 36).

Copilot uses AI. Check for mistakes.
}

useEffect(() => {
fetchSaved();
}, []);
Comment on lines 39 to 41
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useEffect has an empty dependency array but should include fetchSaved in its dependencies, or better yet, define fetchSaved inside the useEffect to avoid the stale closure issue. Additionally, the fetchSaved function should be wrapped with useCallback if it remains outside.

Copilot uses AI. Check for mistakes.

return (
<section className="center container-xl">
<h1 className="text-center my-4 text-4xl font-extrabold leading-none tracking-tight text-gray-900 md:text-5xl lg:text-6xl">
Saved Opportunities
</h1>
<div className="p-4">
<div className="overflow-x-auto">
{!saved && "Loading..."}
{saved && (
<table>
<tr>
<th>Name</th>
<th>Description</th>
<th>Recommended Experience</th>
<th>Pay</th>
<th>Credits</th>
<th>Semester</th>
<th>Year</th>
<th>Application Due</th>
<th>Location</th>
<th>Unsave</th>
</tr>
{saved.map((opportunity) => (
<tr key={opportunity.id}>
<td>{opportunity.name}</td>
<td>{opportunity.description}</td>
<td>{opportunity.recommended_experience}</td>
<td>{opportunity.pay}</td>
<td>{opportunity.credits}</td>
<td>{opportunity.semester}</td>
<td>{opportunity.year}</td>
<td style={{
color: (() => {
const today = new Date();
const dueDate = new Date(opportunity.application_due);
const oneWeek = 7 * 24 * 60 * 60 * 1000;
<table className="w-full border-collapse">
<thead>
<tr className="bg-gray-100">
<th className="p-3 text-left border">Name</th>
<th className="p-3 text-left border">Description</th>
<th className="p-3 text-left border">Recommended Experience</th>
<th className="p-3 text-left border">Pay</th>
<th className="p-3 text-left border">Credits</th>
<th className="p-3 text-left border">Semester</th>
<th className="p-3 text-left border">Year</th>
<th className="p-3 text-left border">Application Due</th>
<th className="p-3 text-left border">Location</th>
<th className="p-3 text-left border">Unsave</th>
</tr>
</thead>
<tbody>
{saved.map((opportunity) => (
<tr key={opportunity.id}>
<td className="p-3 border font-medium">{opportunity.name}</td>
<td className="p-3 border">{opportunity.description}</td>
<td className="p-3 border">{opportunity.recommended_experience}</td>
<td className="p-3 border">{opportunity.pay}</td>
<td className="p-3 border">{opportunity.credits}</td>
<td className="p-3 border">{opportunity.semester}</td>
<td className="p-3 border">{opportunity.year}</td>
<td className="p-3 border border-black" style={{
color: (() => {
const today = new Date();
const dueDate = new Date(opportunity.application_due);
const oneWeek = 7 * 24 * 60 * 60 * 1000;

if (dueDate < today) {
return "red";
} else if (dueDate.getTime() - today.getTime() <= oneWeek) {
return "orange";
} else {
return "black";
}
})()
}}>
{new Date(opportunity.application_due).toLocaleDateString("en-US")}
</td>
<td>{opportunity.location}</td>
<td>
<button className="p-2 bg-blue-600 text-white font-semibold rounded-lg shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
onClick={async () => {
try {
const headers: Record<string, string> = {
"Content-Type": "application/json", // Good practice for cross-origin requests
};
if (csrfToken) {
headers["X-CSRF-TOKEN"] = csrfToken; // Include the token only when defined
}
if (dueDate < today) {
return "red";
} else if (dueDate.getTime() - today.getTime() <= oneWeek) {
return "orange";
} else {
return "black";
}
})()
}}>
{new Date(opportunity.application_due).toLocaleDateString("en-US")}
</td>
<td className="p-3 border">{opportunity.location}</td>
<td className="p-3 border">
<button className="p-2 bg-blue-600 text-white font-semibold rounded-lg shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
onClick={async () => {
try {
const headers: Record<string, string> = {
"Content-Type": "application/json", // Good practice for cross-origin requests
};
if (csrfToken) {
headers["X-CSRF-TOKEN"] = csrfToken; // Include the token only when defined
}

const response = await fetch(
`${import.meta.env.VITE_BACKEND_SERVER}/unsaveOpportunity/${opportunity.id}`, {
method: "DELETE",
credentials: "include",
headers,
});
const response = await fetch(
`${import.meta.env.VITE_BACKEND_SERVER}/unsaveOpportunity/${opportunity.id}`, {
method: "DELETE",
credentials: "include",
headers,
});

if (!response.ok) {
throw new Error("Failed to unsave");
}
if (!response.ok) {
throw new Error("Failed to unsave");
}

setSaved(prev => prev ? prev.filter(o => o.id !== opportunity.id) : prev);
} catch {
console.log("Error unsaving opportunity");
}
}}
>
Unsave
</button>
</td>
</tr>
))}
setSaved(prev => prev ? prev.filter(o => o.id !== opportunity.id) : prev);
} catch {
console.log("Error unsaving opportunity");
}
}}
>
Unsave
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
</div>
);
};
Loading