diff --git a/frontend/.claude/settings.local.json b/frontend/.claude/settings.local.json new file mode 100644 index 0000000..d0f83a5 --- /dev/null +++ b/frontend/.claude/settings.local.json @@ -0,0 +1,5 @@ +{ + "permissions": { + "allow": ["Bash(npm ls *)", "Bash(npx tsc *)"] + } +} diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index c85fb67..9aaa7a1 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -1,16 +1,6 @@ -import { dirname } from "path"; -import { fileURLToPath } from "url"; -import { FlatCompat } from "@eslint/eslintrc"; +import nextCoreWebVitals from "eslint-config-next/core-web-vitals"; +import nextTypescript from "eslint-config-next/typescript"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const compat = new FlatCompat({ - baseDirectory: __dirname, -}); - -const eslintConfig = [ - ...compat.extends("next/core-web-vitals", "next/typescript"), -]; +const eslintConfig = [...nextCoreWebVitals, ...nextTypescript]; export default eslintConfig; diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/frontend/package.json b/frontend/package.json index f07d174..46042c4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,7 @@ "dev": "next dev --turbopack", "build": "next build", "start": "next start", - "lint": "next lint", + "lint": "eslint .", "format": "npx prettier . --write" }, "dependencies": { diff --git a/frontend/src/actions/jobs.fetch.ts b/frontend/src/actions/jobs.fetch.ts index 7ac1f23..2413881 100644 --- a/frontend/src/actions/jobs.fetch.ts +++ b/frontend/src/actions/jobs.fetch.ts @@ -136,18 +136,32 @@ async function withDbConnection( } } +const SPONSOR_TIERS: Record = { + // Platinum (rank 0) — highest priority + "Jane Street": 0, + Atlassian: 0, + // Gold (rank 1) + "Citadel Securities": 1, + Canva: 1, + "Lyra Technologies": 1, + Susquehanna: 1, + IMC: 1, + Vivcourt: 1, + // Silver (rank 2) + "January Capital": 2, + Optiver: 2, +}; + /** * Fetches paginated and filtered job listings from MongoDB. */ export async function getJobs( filters: Partial, minSponsors: number = -1, - prioritySponsors: Array = ["IMC", "Atlassian"], ): Promise<{ jobs: Job[]; total: number }> { const page = filters.page || 1; const normalizedFilters = normalizeFiltersForKey(filters); - const priorityStr = prioritySponsors.sort().join(","); - const cacheKey = `jobs:${JSON.stringify(normalizedFilters)}:${page}:${minSponsors}:${priorityStr}`; + const cacheKey = `jobs:${JSON.stringify(normalizedFilters)}:${page}:${minSponsors}`; // Check cache first const cached = jobCache.get(cacheKey); @@ -156,10 +170,7 @@ export async function getJobs( return cached as { jobs: Job[]; total: number }; } - logger.info( - { filters, minSponsors, prioritySponsors }, - "Fetching jobs with filters", - ); + logger.info({ filters, minSponsors }, "Fetching jobs with filters"); return await withDbConnection(async (client) => { const collection = client.db("default").collection("active_jobs"); @@ -196,11 +207,16 @@ export async function getJobs( ]) .toArray(); + // Sort by tier (platinum=0 first, gold=1, silver=2, unknown=3), + // shuffling randomly within each tier. sponsoredJobs = sponsoredJobs - .filter((job) => { - const isPriority = prioritySponsors.includes(job.company.name); - return isPriority ? Math.random() < 0.65 : Math.random() >= 0.35; - }) + .map((job) => ({ + job, + rank: SPONSOR_TIERS[job.company?.name as string] ?? 3, + rand: Math.random(), + })) + .sort((a, b) => a.rank - b.rank || a.rand - b.rand) + .map(({ job }) => job) .slice(0, minSponsors) .map((job) => ({ ...job, highlight: true })); diff --git a/frontend/src/components/filters/dropdown-filter.tsx b/frontend/src/components/filters/dropdown-filter.tsx index da28946..0421474 100644 --- a/frontend/src/components/filters/dropdown-filter.tsx +++ b/frontend/src/components/filters/dropdown-filter.tsx @@ -1,5 +1,4 @@ // frontend/src/components/jobs/filters/dropdown-filter.tsx -import { useEffect, useState } from "react"; import { Checkbox, Combobox, @@ -30,22 +29,13 @@ export default function DropdownFilter({ }); const { filters, updateFilters } = useFilterContext(); - const [localSelected, setLocalSelected] = useState( - (filters.filters[filterKey] as string[]) || [], - ); - - // Sync when filters change - useEffect(() => { - setLocalSelected((filters.filters[filterKey] as string[]) || []); - }, [filters.filters, filterKey]); + const selected = (filters.filters[filterKey] as string[]) || []; - // Updates locally selected value & filters const handleValueSelect = (value: string) => { - const newValues = localSelected.includes(value) - ? localSelected.filter((item) => item !== value) - : [...localSelected, value]; + const newValues = selected.includes(value) + ? selected.filter((item) => item !== value) + : [...selected, value]; - setLocalSelected(newValues); updateFilters({ filters: { ...filters.filters, @@ -56,9 +46,9 @@ export default function DropdownFilter({ }; const getDisplayText = () => { - if (localSelected.length === 0) return label; - if (localSelected.length === 1) return formatCapString(localSelected[0]); - return `${localSelected.length} ${getPluralLabel(label)}`; + if (selected.length === 0) return label; + if (selected.length === 1) return formatCapString(selected[0]); + return `${selected.length} ${getPluralLabel(label)}`; }; return ( @@ -77,7 +67,7 @@ export default function DropdownFilter({ onClick={() => combobox.toggleDropdown()} className={`min-w-32`} > - 0 ? "light" : "dimmed"}> + 0 ? "light" : "dimmed"}> {getDisplayText()} @@ -89,11 +79,11 @@ export default function DropdownFilter({ {}} aria-hidden tabIndex={-1} diff --git a/frontend/src/components/jobs/job-pagination.tsx b/frontend/src/components/jobs/job-pagination.tsx index 8950489..bb7aa9e 100644 --- a/frontend/src/components/jobs/job-pagination.tsx +++ b/frontend/src/components/jobs/job-pagination.tsx @@ -1,7 +1,6 @@ // frontend/src/components/jobs/pagination.tsx "use client"; -import { useEffect, useState } from "react"; import { Pagination } from "@mantine/core"; import { useFilterContext } from "@/context/filter/filter-context"; @@ -10,18 +9,11 @@ interface JobPaginationProps { } export default function JobPagination({ pageSize = 20 }: JobPaginationProps) { - const [isReady, setIsReady] = useState(false); const { filters, updateFilters, totalJobs, isLoading } = useFilterContext(); - useEffect(() => { - if (totalJobs !== undefined) { - setIsReady(true); - } - }, [totalJobs]); - const totalPages = Math.ceil(totalJobs / pageSize); - if (!isReady || totalPages <= 1 || isLoading) return null; + if (totalJobs === undefined || totalPages <= 1 || isLoading) return null; const handlePageChange = (page: number) => { const scrollContainer = document.querySelector("#job-list-container"); @@ -48,7 +40,7 @@ export default function JobPagination({ pageSize = 20 }: JobPaginationProps) { size="md" gap={12} boundaries={1} - siblings={0} + siblings={1} radius="lg" color="accent" getItemProps={(page) => ({ diff --git a/frontend/src/components/search/search-bar.tsx b/frontend/src/components/search/search-bar.tsx index a69a07f..18def8c 100644 --- a/frontend/src/components/search/search-bar.tsx +++ b/frontend/src/components/search/search-bar.tsx @@ -4,15 +4,18 @@ import { Input } from "@mantine/core"; import { IconSearch } from "@tabler/icons-react"; import { useFilterContext } from "@/context/filter/filter-context"; import { useDebouncedCallback } from "@mantine/hooks"; -import { useEffect, useState } from "react"; +import { useState } from "react"; export default function SearchBar() { const { filters, updateFilters } = useFilterContext(); - const [searchValue, setSearchValue] = useState(filters.filters.search || ""); + const externalSearch = filters.filters.search || ""; + const [searchValue, setSearchValue] = useState(externalSearch); + const [prevExternalSearch, setPrevExternalSearch] = useState(externalSearch); - useEffect(() => { - setSearchValue(filters.filters.search || ""); - }, [filters.filters.search]); + if (prevExternalSearch !== externalSearch) { + setPrevExternalSearch(externalSearch); + setSearchValue(externalSearch); + } const handleSearch = useDebouncedCallback((value: string) => { updateFilters({ diff --git a/frontend/src/context/filter/filter-provider.tsx b/frontend/src/context/filter/filter-provider.tsx index e51cdc7..7d65154 100644 --- a/frontend/src/context/filter/filter-provider.tsx +++ b/frontend/src/context/filter/filter-provider.tsx @@ -1,7 +1,7 @@ // frontend/src/context/jobs/jobs-provider.tsx "use client"; -import { ReactNode, useEffect, useState } from "react"; +import { ReactNode, useState } from "react"; import { FilterContext } from "./filter-context"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { CreateQueryString } from "@/lib/utils"; @@ -74,6 +74,15 @@ export function FilterProvider({ children }: { children: ReactNode }) { const [isLoading, setIsLoading] = useState(false); const [totalJobs, setTotalJobs] = useState(0); + // Wrapper for SelectedJob to validate attributes first + const setSelectedJob = (job: Job | null) => { + // Remove duplicates from working_rights + if (job?.working_rights && job.working_rights.length > 0) { + job.working_rights = [...new Set(job.working_rights)]; + } + setSelectedJobInternal(job); + }; + const updateFilters = (newFilters: Partial) => { setIsLoading(true); setFilters((curr) => ({ ...curr, ...newFilters })); @@ -82,28 +91,18 @@ export function FilterProvider({ children }: { children: ReactNode }) { router.push(`/jobs?${params}`); }; - useEffect(() => { + const navKey = `${pathname}|${searchParams.toString()}`; + const [prevNavKey, setPrevNavKey] = useState(navKey); + if (prevNavKey !== navKey) { + setPrevNavKey(navKey); if (pathname === "/jobs") { setIsLoading(false); setSelectedJob(null); } - }, [pathname, searchParams]); - - useEffect(() => { - // clear filters on return to homepage if (pathname === "/") { setFilters(emptyFilterState); } - }, [pathname]); - - // Wrapper for SelectedJob to validate attributes first - const setSelectedJob = (job: Job | null) => { - // Remove duplicates from working_rights - if (job?.working_rights && job.working_rights.length > 0) { - job.working_rights = [...new Set(job.working_rights)]; - } - setSelectedJobInternal(job); - }; + } const clearFilters = () => { setIsLoading(true); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index b575f7d..19c51c8 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,11 +1,7 @@ { "compilerOptions": { "target": "ES2017", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -23,9 +19,7 @@ } ], "paths": { - "@/*": [ - "./src/*" - ] + "@/*": ["./src/*"] } }, "include": [ @@ -35,7 +29,5 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": [ - "node_modules" - ] + "exclude": ["node_modules"] }