-
Notifications
You must be signed in to change notification settings - Fork 106
Add job-hunting - Mino Use Case #15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
- ## Demo - Live demo: https://job-huntboard.vercel.app - Contributor: Pranav Janakiraman (@pranavjana)
📝 WalkthroughWalkthroughThis pull request introduces a complete new Next.js job-hunting application. It establishes a full-stack project structure with configuration files, API routes for AI-powered job analysis and resume parsing, a comprehensive UI component library built on Radix UI primitives, local storage-based data persistence, integration with external scraping and AI APIs, and a multi-step user workflow. The app enables resume upload, profile creation, automated job board scraping, AI-driven job matching, application tracking via Kanban board, and cover letter generation. Sequence Diagram(s)sequenceDiagram
participant User
participant Dashboard
participant AI API
participant Mino API
participant Storage
User->>Dashboard: Start job search
Dashboard->>AI API: Generate search URLs from profile
AI API-->>Dashboard: Return board-specific URLs
Dashboard->>Mino API: Scrape jobs from multiple boards (parallel)
Mino API-->>Dashboard: Stream job listings per board
Dashboard->>AI API: Batch analyze job matches
AI API-->>Dashboard: Return match scores & explanations
Dashboard->>Storage: Persist jobs & matches
Storage-->>Dashboard: Confirmation
Dashboard-->>User: Display matched jobs with scores
User->>Dashboard: Select job for details
Dashboard->>AI API: Generate cover letter
AI API-->>Dashboard: Return generated letter
Dashboard-->>User: Display job details with cover letter
🚥 Pre-merge checks | ✅ 2✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 11
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
🤖 Fix all issues with AI agents
In `@job-hunting/app/api/ai/cover-letter/route.ts`:
- Around line 36-56: Validate and sanitize the job inputs before building the
prompt passed to generateText: ensure job.requirements is an array (use
Array.isArray(job.requirements) and coerce non-array values into an array or
ignore them) before calling .join, and truncate/limit job.description to a safe
max length (e.g., slice to a fixed character/token limit) to avoid unbounded
prompt size; apply these checks where the prompt string is constructed
(reference the generateText call and the variables job.requirements and
job.description) so the prompt interpolation uses the validated/trimmed values.
In `@job-hunting/components/dashboard.tsx`:
- Around line 474-478: The iframe rendering the live stream (iframe with
src={currentStreamingUrl} in dashboard.tsx) lacks a sandbox attribute and thus
risks XSS/clickjacking; add an appropriate sandbox attribute to the iframe
element (e.g., sandbox with only the necessary permissions) and combine it with
explicit allow attributes (like allow="autoplay; fullscreen") as needed for
streaming functionality, minimizing granted flags (avoid allow-top-navigation
and broad allowances) so the iframe runs with least privilege while preserving
required features.
- Line 78: The AbortController stored in abortControllerRef is not
aborted/cleared on component unmount, risking memory leaks and state updates on
unmounted components; add a useEffect cleanup that on unmount checks
abortControllerRef.current, calls abort() if present, and sets
abortControllerRef.current = null so any in-progress scan (the logic that
creates/uses abortControllerRef) is cancelled and the ref is cleared; reference
abortControllerRef and the scan invocation (the function that assigns
abortControllerRef.current) when implementing the cleanup.
In `@job-hunting/components/jobs/job-detail-dialog.tsx`:
- Around line 60-64: When the job prop changes the component doesn't reset local
state (coverLetter, isGenerating, copied, showCoverLetter), causing stale data
to appear; add a useEffect that depends on job and inside call
setCoverLetter(""), setIsGenerating(false), setCopied(false) and
setShowCoverLetter(false) so the dialog resets whenever the job prop changes
(reference the existing state hooks coverLetter/setCoverLetter,
isGenerating/setIsGenerating, copied/setCopied,
showCoverLetter/setShowCoverLetter).
In `@job-hunting/components/jobs/scraping-grid.tsx`:
- Around line 69-104: The handler passed to setBoardScans currently increments
setCompletedCount for every terminal update and never fills resultsRef, so fix
by: when updating inside setBoardScans (in the function that takes boardName and
update) only call setCompletedCount((c)=>c+1) if the previous status was not a
terminal status and update.status is terminal (check existing.status !==
"complete" && existing.status !== "error" before incrementing); populate
resultsRef.current either incrementally (update resultsRef.current[boardName] =
updated.jobsFound/details when update provides jobsFound) or compute
resultsRef.current = Array.from(newMap.values()) right after setBoardScans
finishes (before calling setIsRunning(false) and onComplete) so
onComplete(resultsRef.current) receives actual results; keep the onStreamingUrl
notification as-is and continue using abortControllerRef.current.signal.
In `@job-hunting/components/onboarding/onboarding-flow.tsx`:
- Around line 31-68: The parsed resume-to-profile mapping is duplicated; remove
the second object literal and call setParsedData(parsedProfile) instead of
reconstructing the object. Ensure parsedProfile uses consistent null coalescing
for phone (use phone ?? undefined) so both setResume and setParsedData get the
same parsedProfile object and semantics; keep parsedProfile, setResume, and
setParsedData as the referenced symbols to locate where to change.
In `@job-hunting/components/onboarding/resume-upload.tsx`:
- Around line 77-110: In processResume, ensure the progressInterval created
before awaiting parseResume is always cleared on failure: move or add
clearInterval(progressInterval) into the catch block (before calling
setState/setError) so the interval stops if parseResume throws; also consider
clearing it in a finally block to guarantee cleanup regardless of success or
error, keeping references to progressInterval, parseResume, clearInterval,
setState, setProgress, and setError to locate the code.
In `@job-hunting/components/ui/form.tsx`:
- Around line 45-66: The guard in useFormField is after fieldContext is
dereferenced, causing a crash if used outside <FormField>; move the null-check
for fieldContext to the top of useFormField (before any access to
fieldContext.name or passing it to useFormState/getFieldState) and throw the
same error if missing so subsequent lines (getFieldState, useFormState, forming
ids) only run when fieldContext is defined.
In `@job-hunting/components/ui/progress.tsx`:
- Around line 8-26: The Progress component destructures value but doesn't pass
it into ProgressPrimitive.Root; update the Progress function to forward the
value prop to ProgressPrimitive.Root (the component defined in this file as
Progress and the Radix element ProgressPrimitive.Root) so Radix can set
aria-valuenow and manage internal state—ensure you add value={value} to the Root
while keeping the existing props and indicator transform logic intact.
In `@job-hunting/lib/ai/client.ts`:
- Around line 129-152: In batchAnalyzeJobs the progress callback and fallback
IDs use the batch start index `i`, causing duplicate IDs and incorrect progress
for items in the same batch; change the code inside the batch.map to compute a
per-job index (e.g., compute `const idx = i + batchIndex` using the map second
arg or track `i + j`) and use that `idx` for onProgress (onProgress?.(idx + 1,
jobs.length)) and for the fallback ID (job.id || `job-${idx}`) so each job gets
a unique index/ID when job.id is missing while leaving analyzeJobMatch and the
error result structure unchanged.
In `@job-hunting/lib/hooks/use-local-storage.ts`:
- Around line 3-75: The bug is that setValue closes over storedValue and misses
rapid functional updates, and storage events with newValue === null (deletions)
are ignored. Fix setValue in useLocalStorage to use the functional updater form:
call setStoredValue(prev => { const valueToStore = value instanceof Function ?
value(prev) : value; return valueToStore; }) and compute valueToStore inside
that updater; remove storedValue from the useCallback dependency list (rely only
on key). When persisting, if valueToStore is null or undefined call
window.localStorage.removeItem(key) and dispatch a StorageEvent with newValue:
null; otherwise setItem(key, JSON.stringify(valueToStore)) and dispatch with the
JSON string. In the storage listener (handleStorageChange) handle e.newValue ===
null by calling setStoredValue(initialValue) (and still parse e.newValue when
present). Ensure window existence checks remain for SSR.
🟡 Minor comments (13)
job-hunting/README.md-7-7 (1)
7-7: Replace the bare URL with a markdown link (MD034).
Line 7 should use a link to satisfy markdownlint.Suggested fix
-**Live Demo:** https://job-huntboard.vercel.app +**Live Demo:** [Job Huntboard](https://job-huntboard.vercel.app)job-hunting/README.md-3-19 (1)
3-19: Remove or rename the duplicate “Demo” heading (MD024).
Line 15 duplicates the earlier “## Demo” section; consolidate to a single heading.Suggested fix (remove the placeholder section)
---- - -## Demo - -*[Demo video/screenshot to be added]* - ----job-hunting/components/ui/card.tsx-32-45 (1)
32-45: Type mismatch between ref type and rendered element.
CardTitledeclares the ref type asHTMLParagraphElementbut renders an<h3>heading element. This could cause TypeScript errors or runtime confusion when consumers try to use ref methods specific to paragraph elements.🔧 Proposed fix
const CardTitle = React.forwardRef< - HTMLParagraphElement, + HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement> >(({ className, ...props }, ref) => (job-hunting/components/ui/kanban-board.tsx-214-219 (1)
214-219: Hardcoded due date display.The due date is hardcoded as
"Jan 15"instead of being formatted fromtask.dueDate. This means all tasks will display the same date regardless of their actual due date.🔧 Proposed fix
{task.dueDate && ( <div className="flex items-center gap-1"> <Calendar className="w-4 h-4" /> - <span className="text-xs font-medium">Jan 15</span> + <span className="text-xs font-medium"> + {new Date(task.dueDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} + </span> </div> )}job-hunting/app/api/scrape/route.ts-9-22 (1)
9-22: ValidatesearchUrlas a real http(s) URL before calling Mino API.
Currently only presence is checked; invalid or non-http(s) schemes should be rejected early to prevent upstream errors and unnecessary API calls.🔧 Proposed fix
if (!searchUrl) { return NextResponse.json( { error: "Search URL is required" }, { status: 400 } ); } + + let parsedUrl: URL; + try { + parsedUrl = new URL(searchUrl); + } catch { + return NextResponse.json( + { error: "Search URL must be a valid http(s) URL" }, + { status: 400 } + ); + } + if (!["http:", "https:"].includes(parsedUrl.protocol)) { + return NextResponse.json( + { error: "Search URL must use http or https" }, + { status: 400 } + ); + }job-hunting/components/jobs/job-card.tsx-211-214 (1)
211-214: Addnoopenerandnoreferrerfor security.When opening external URLs with
window.open, add security attributes to prevent potential tabnabbing attacks and avoid leaking referrer information.🔒 Proposed fix
onClick={(e) => { e.stopPropagation(); - window.open(job.fullUrl, "_blank"); + window.open(job.fullUrl, "_blank", "noopener,noreferrer"); }}job-hunting/components/jobs/job-detail-dialog.tsx-344-364 (1)
344-364: Profile match percentages are derived from matchScore with arbitrary multipliers.The "Skills Match", "Experience Level", and "Title Alignment" percentages are calculated by multiplying
matchScoreby fixed factors (1.1, 0.95, 1.05). These appear to be placeholder values that don't reflect actual analysis, which could mislead users.Consider either:
- Adding a comment clarifying these are approximations
- Deriving these from actual AI analysis data if available
- Removing this section until real data is available
job-hunting/components/jobs/job-detail-dialog.tsx-84-88 (1)
84-88:navigator.clipboard.writeTextcan fail silently.The clipboard API may throw if permissions are denied or the page isn't focused. The current implementation doesn't handle potential errors.
🔧 Proposed fix
const handleCopy = async () => { - await navigator.clipboard.writeText(coverLetter); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + try { + await navigator.clipboard.writeText(coverLetter); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error("Failed to copy:", error); + // Optionally show a toast or fallback + } };job-hunting/components/dashboard.tsx-233-236 (1)
233-236: Swallowed error - no logging or context for debugging.The catch block discards the error details, making it difficult to debug failures. Consider logging the error or including it in the toast message.
🔧 Proposed fix
} catch (error) { - toast.error("Scanning failed"); + const message = error instanceof Error ? error.message : "Unknown error"; + console.error("Scan failed:", error); + toast.error(`Scanning failed: ${message}`); setIsScanning(false); }job-hunting/lib/types.ts-163-165 (1)
163-165:DEFAULT_PROFILEusesnew Date().toISOString()at module load time.These timestamps are evaluated once when the module loads, not when the default is used. All profiles created using this default will share the same
createdAtandupdatedAttimestamp from app startup.🔧 Proposed fix
Use a factory function instead:
-export const DEFAULT_PROFILE: UserProfile = { +export const createDefaultProfile = (): UserProfile => ({ fullName: "", email: "", location: "", currentTitle: "", yearsExperience: 0, skills: [], industries: [], education: "", preferredTitles: [], seniorityLevel: "mid", summary: "", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), -}; +}); +// For backward compatibility, if needed +export const DEFAULT_PROFILE = createDefaultProfile();Then use
createDefaultProfile()where fresh timestamps are needed.job-hunting/components/ui/command.tsx-45-59 (1)
45-59: DialogHeader withsr-onlyis placed outside DialogContent.The
DialogHeaderis rendered as a sibling toDialogContent, but Radix UI's Dialog expectsDialogTitleandDialogDescriptionto be descendants ofDialogContentfor proper ARIA association. This may cause accessibility warnings or broken screen reader announcements.🔧 Proposed fix
Move the header inside DialogContent:
return ( <Dialog {...props}> - <DialogHeader className="sr-only"> - <DialogTitle>{title}</DialogTitle> - <DialogDescription>{description}</DialogDescription> - </DialogHeader> <DialogContent className={cn("overflow-hidden p-0", className)} showCloseButton={showCloseButton} > + <DialogHeader className="sr-only"> + <DialogTitle>{title}</DialogTitle> + <DialogDescription>{description}</DialogDescription> + </DialogHeader> <Command className="..."> {children} </Command> </DialogContent> </Dialog> )job-hunting/components/jobs/job-feed.tsx-199-249 (1)
199-249: Filter pill buttons lack proper accessibility attributes.The filter buttons are implemented as plain
<button>elements without ARIA attributes to indicate their selected/toggle state. This makes it difficult for screen reader users to understand the current filter selection.🔧 Proposed fix
<button + role="tab" + aria-selected={filterTab === "all"} className={cn( "px-3 py-1.5 text-sm font-medium rounded-md transition-colors flex items-center gap-2", filterTab === "all" ? "bg-deep-teal text-white" : "text-muted-foreground hover:text-foreground hover:bg-muted" )} onClick={() => setFilterTab("all")} >Apply similar changes to the other filter buttons and add
role="tablist"to the container div at line 198.job-hunting/lib/mino-client.ts-57-63 (1)
57-63: Incomplete buffer handling - remaining data after stream ends is discarded.After the read loop completes, any remaining content in
buffer(data after the last newline) is never processed. If the stream ends without a trailing newline, the final event could be lost.🔧 Proposed fix
Process remaining buffer after the loop:
while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; for (const line of lines) { // ... process line } } + // Process any remaining data in buffer + if (buffer.trim()) { + const event = parseSSELine(buffer); + if (event) { + events.push(event); + if (isCompleteEvent(event)) { + return { + success: true, + result: event.resultJson, + streamingUrl, + events, + }; + } + } + } return { success: false, error: "Stream ended without completion event",
🧹 Nitpick comments (27)
job-hunting/.gitignore (1)
33-34: Consider refining the.env*pattern to allow template files.The current
.env*pattern will ignore all files starting with.env, including.env.exampleor.env.templatefiles that are commonly committed to repositories as templates for other developers to understand required environment variables.♻️ Proposed refinement to allow .env.example files
# env files (can opt-in for committing if needed) -.env* +.env +.env.local +.env.*.local +!.env.example +!.env.templateThis approach:
- Explicitly ignores common local environment files
- Allows
.env.exampleand.env.templateto be committed as documentation- Maintains security by ignoring actual environment files with secrets
job-hunting/components/ui/sheet.tsx (1)
9-29: Preserve Radix ref support by forwarding refs in wrapper components.
These wrappers currently swallow refs, which breaks common Radix usage (focus management, measurements). RecommendReact.forwardReffor components that accept refs (Trigger/Close/Overlay/Content/Title/Description).♻️ Example pattern (apply similarly to other wrappers)
-function SheetTrigger({ - ...props -}: React.ComponentProps<typeof SheetPrimitive.Trigger>) { - return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} /> -} +const SheetTrigger = React.forwardRef< + React.ElementRef<typeof SheetPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof SheetPrimitive.Trigger> +>(({ ...props }, ref) => ( + <SheetPrimitive.Trigger ref={ref} data-slot="sheet-trigger" {...props} /> +)) +SheetTrigger.displayName = SheetPrimitive.Trigger.displayNamejob-hunting/lib/utils.ts (2)
14-16: Usesubstringinstead of deprecatedsubstr.The
substrmethod is deprecated. Usesubstringorsliceinstead for better compatibility.Suggested fix
export function generateId(): string { - return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; }
68-80: Consider edge case with zero salary values.If
minormaxis explicitly0, the current check!min && !maxwill return "Not specified" since0is falsy. While unlikely for real salary data, consider using explicitundefined/nullchecks if zero is a valid value.Suggested fix if zero is a valid value
export function formatSalary(min?: number, max?: number): string { - if (!min && !max) return "Not specified"; + if (min === undefined && max === undefined) return "Not specified"; + if (min == null && max == null) return "Not specified";job-hunting/components/ui/input.tsx (1)
1-17: Consider forwarding refs for form integrations.
WithoutforwardRef, consumers can’t reliably focus or register the input in form libraries. A small refactor keeps the API stable and improves ergonomics.♻️ Suggested refactor
-function Input({ className, type, ...props }: React.ComponentProps<"input">) { - return ( - <input - type={type} - data-slot="input" - className={cn( - "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", - "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", - "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", - className - )} - {...props} - /> - ) -} +const Input = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<"input">>( + ({ className, type, ...props }, ref) => ( + <input + ref={ref} + type={type} + data-slot="input" + className={cn( + "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", + "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + className + )} + {...props} + /> + ) +) +Input.displayName = "Input"job-hunting/components/jobs/job-tracker-kanban.tsx (2)
11-11: Unused import.
ExternalLinkis imported but never used in this component.🧹 Proposed fix
import { GripVertical, Building2, MapPin, Calendar, - ExternalLink, Wifi, } from "lucide-react";
100-104: Remove duplicate utility function.
getMatchScoreColoris already exported from@/lib/utils(lines 49-53 in that file) and is available for import. The local definition here is identical and should be removed to avoid duplication.♻️ Proposed fix
Update the import at line 14:
-import { cn, formatRelativeDate } from "@/lib/utils"; +import { cn, formatRelativeDate, getMatchScoreColor } from "@/lib/utils";Then remove the local function definition (lines 100-104).
job-hunting/components/ui/kanban-board.tsx (2)
124-142: Add error handling for JSON parsing.
JSON.parsecan throw if the dragged data is malformed or if a non-task element is dropped. Consider wrapping in a try-catch to prevent runtime errors.🛡️ Proposed fix
const handleDrop = (e: React.DragEvent, targetColumnId: string) => { e.preventDefault(); - const data = JSON.parse(e.dataTransfer.getData('text/plain')); - const { task, sourceColumnId } = data; + let data; + try { + data = JSON.parse(e.dataTransfer.getData('text/plain')); + } catch { + return; + } + const { task, sourceColumnId } = data; + if (!task || !sourceColumnId) return; if (sourceColumnId === targetColumnId) return;
171-173: Add accessible label to the button.The "+" button lacks an accessible label, making it unclear for screen reader users what action it performs.
♿ Proposed fix
-<button className="p-1 rounded-full bg-white/30 dark:bg-neutral-800/30 hover:bg-white/50 dark:hover:bg-neutral-700/50 transition-colors"> +<button + aria-label={`Add task to ${column.title}`} + className="p-1 rounded-full bg-white/30 dark:bg-neutral-800/30 hover:bg-white/50 dark:hover:bg-neutral-700/50 transition-colors" +> <Plus className="w-4 h-4 text-neutral-700 dark:text-neutral-300" /> </button>job-hunting/components/ui/button.tsx (2)
1-5: Missing"use client"directive.This component imports from
@radix-ui/react-slotwhich uses React context and client-side features. For consistency with other UI components in this PR (likeselect.tsx,label.tsx,dialog.tsx) and to ensure proper client-side rendering, add the directive.Proposed fix
+"use client" + import * as React from "react" import { Slot } from "@radix-ui/react-slot" import { cva, type VariantProps } from "class-variance-authority"
39-60: Consider setting a defaulttype="button"to prevent accidental form submissions.When a
<button>element is inside a<form>without an explicittype, it defaults totype="submit", which can cause unintended form submissions.Proposed fix
function Button({ className, variant = "default", size = "default", asChild = false, + type = "button", ...props }: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & { asChild?: boolean }) { const Comp = asChild ? Slot : "button" return ( <Comp data-slot="button" data-variant={variant} data-size={size} className={cn(buttonVariants({ variant, size, className }))} + type={asChild ? undefined : type} {...props} /> ) }job-hunting/components/ui/dialog.tsx (1)
9-31:data-slotattributes on non-DOM-rendering primitives have no effect.
DialogPrimitive.Root,DialogPrimitive.Trigger,DialogPrimitive.Portal, andDialogPrimitive.Closedon't render DOM elements themselves—they render their children or manage context. Thedata-slotattributes won't appear in the DOM. This is harmless but inconsistent with the intent.For
DialogTriggerandDialogClose, consider passingdata-slotonly when they render actual DOM (or rely on the child element). ForRootandPortal, the attribute can be removed.job-hunting/app/page.tsx (1)
3-15: DropuseMemoforshowOnboarding; a simple boolean is enough.
This is a cheap derivation, so memoization adds overhead and an extra import.♻️ Proposed refactor
-import { useMemo } from "react"; +import { useMemo } from "react"; @@ - const showOnboarding = useMemo(() => { - return isClient && !profile; - }, [isClient, profile]); + const showOnboarding = isClient && !profile;job-hunting/app/api/ai/parse-resume/route.ts (1)
1-33: DeduplicateextractJSONacross AI routes.
This helper is duplicated here (Lines 21‑33) and injob-hunting/app/api/ai/match-jobs/route.ts(Lines 15‑27), and is also referenced injob-hunting/app/api/ai/generate-urls/route.ts. Centralizing it avoids drift and keeps fixes consistent.♻️ Proposed refactor (this file)
-import { NextRequest, NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; +import { extractJSON } from "@/lib/utils"; @@ -function extractJSON(text: string): string { - // Remove markdown code blocks if present - const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/); - if (codeBlockMatch) { - return codeBlockMatch[1].trim(); - } - // Try to find JSON object directly - const jsonMatch = text.match(/\{[\s\S]*\}/); - if (jsonMatch) { - return jsonMatch[0]; - } - return text; -}// job-hunting/lib/utils.ts export function extractJSON(text: string): string { const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/); if (codeBlockMatch) return codeBlockMatch[1].trim(); const jsonMatch = text.match(/\{[\s\S]*\}/); if (jsonMatch) return jsonMatch[0]; return text; }job-hunting/package.json (1)
9-9: Lint script may need file patterns.The
eslintcommand without arguments relies on ESLint's flat config to determine which files to lint. Ensure your ESLint configuration includes appropriate file patterns, or consider adding explicit paths like"lint": "eslint ."for clarity.job-hunting/app/api/ai/generate-urls/route.ts (2)
6-13: Consider adding URL validation to the schema.The
searchUrlfield accepts any string. Consider usingz.string().url()to ensure the AI returns valid URLs, which would catch malformed URLs early.🔧 Suggested schema enhancement
const searchUrlsSchema = z.object({ urls: z.array( z.object({ boardName: z.string(), - searchUrl: z.string(), + searchUrl: z.string().url(), }) ), });
113-117: Unusederrorvariable in catch block.The caught
erroris not used, which loses diagnostic information. Consider logging the error for debugging purposes while still returning a generic message to the client.🔧 Proposed fix
} catch (error) { + console.error("Failed to generate search URLs:", error); return NextResponse.json( { error: "Failed to generate search URLs" }, { status: 500 } ); }job-hunting/components/jobs/job-card.tsx (1)
41-51: Consider hoisting helper functions outside the component.
getMatchScoreColorandgetMatchScoreBgare pure functions that don't depend on component state or props. Moving them outside the component avoids recreating them on every render.♻️ Suggested refactor
+const getMatchScoreColor = (score: number) => { + if (score >= 80) return "text-match-excellent"; + if (score >= 50) return "text-match-good"; + return "text-match-poor"; +}; + +const getMatchScoreBg = (score: number) => { + if (score >= 80) return "bg-match-excellent/10 border-match-excellent/20"; + if (score >= 50) return "bg-match-good/10 border-match-good/20"; + return "bg-match-poor/10 border-match-poor/20"; +}; + export function JobCard({ job, savedJob, onSave, onUnsave, onClick, index = 0, }: JobCardProps) { const isSaved = !!savedJob; const isNew = job.postedDate ? isNewJob(job.postedDate) : false; - - const getMatchScoreColor = (score: number) => { - if (score >= 80) return "text-match-excellent"; - if (score >= 50) return "text-match-good"; - return "text-match-poor"; - }; - - const getMatchScoreBg = (score: number) => { - if (score >= 80) return "bg-match-excellent/10 border-match-excellent/20"; - if (score >= 50) return "bg-match-good/10 border-match-good/20"; - return "bg-match-poor/10 border-match-poor/20"; - };job-hunting/components/jobs/kanban-board.tsx (3)
140-143: Consider usinguseDroppableinstead ofuseSortablefor columns.Columns are drop targets, not sortable items. Using
useSortableworks because it internally usesuseDroppable, butuseDroppableis more semantically correct and avoids unnecessary sortable overhead.♻️ Suggested change
+import { useDroppable } from "@dnd-kit/core"; -import { - SortableContext, - sortableKeyboardCoordinates, - verticalListSortingStrategy, - useSortable, -} from "@dnd-kit/sortable"; +import { + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, + useSortable, +} from "@dnd-kit/sortable"; // In KanbanColumn: - const { setNodeRef, isOver } = useSortable({ - id: status, - data: { type: "column" }, - }); + const { setNodeRef, isOver } = useDroppable({ + id: status, + data: { type: "column" }, + });
278-296: Remove unusedsavedJobprop from KanbanCardDragPreview.The
savedJobparameter is destructured but never used in the component.♻️ Proposed fix
-function KanbanCardDragPreview({ - job, - savedJob, -}: { - job: Job; - savedJob: SavedJob; -}) { +function KanbanCardDragPreview({ job }: { job: Job }) { return ( <Card className="w-64 shadow-xl rotate-3">Also update the call site at line 124:
- <KanbanCardDragPreview job={activeJob} savedJob={activeSavedJob} /> + <KanbanCardDragPreview job={activeJob} />
71-72: DeduplicategetJobForSavedJobhelper.This helper is defined identically in both
KanbanBoard(line 71) andKanbanColumn(line 145). Consider defining it once outside or passing the lookup result directly.♻️ Option: Pass job lookup from parent
interface KanbanColumnProps { status: ApplicationStatus; savedJobs: SavedJob[]; - jobs: Job[]; + getJobForSavedJob: (savedJob: SavedJob) => Job | undefined; onJobClick: (job: Job) => void; } -function KanbanColumn({ status, savedJobs, jobs, onJobClick }: KanbanColumnProps) { +function KanbanColumn({ status, savedJobs, getJobForSavedJob, onJobClick }: KanbanColumnProps) { const { label, color, bgColor } = APPLICATION_STATUS_CONFIG[status]; - - const getJobForSavedJob = (savedJob: SavedJob) => - jobs.find((j) => j.id === savedJob.jobId);Also applies to: 145-146
job-hunting/components/dashboard.tsx (1)
343-391: DuplicateTabscomponent creates disconnected state.The
Tabscomponent is instantiated twice (lines 346 and 396) with the sameactiveTabstate. While this works, it's unconventional and could cause confusion. The firstTabsonly rendersTabsList, and the second rendersTabsContent. Consider restructuring for clarity.♻️ Suggested restructure
Wrap both the
TabsListandTabsContentelements within a singleTabscomponent to make the relationship clearer:- {/* Tabs at the top */} - <div className="border-b bg-background sticky top-[53px] z-10"> - <div className="max-w-7xl mx-auto px-4"> - <Tabs value={activeTab} onValueChange={setActiveTab}> - <TabsList className="h-10 bg-transparent border-none p-0 gap-0"> - {/* ... triggers ... */} - </TabsList> - </Tabs> - </div> - </div> - - {/* Main content */} - <main className="max-w-7xl mx-auto px-4 py-6"> - <Tabs value={activeTab} onValueChange={setActiveTab}> - {/* ... content ... */} - </Tabs> - </main> + <Tabs value={activeTab} onValueChange={setActiveTab}> + {/* Tabs at the top */} + <div className="border-b bg-background sticky top-[53px] z-10"> + <div className="max-w-7xl mx-auto px-4"> + <TabsList className="h-10 bg-transparent border-none p-0 gap-0"> + {/* ... triggers ... */} + </TabsList> + </div> + </div> + + {/* Main content */} + <main className="max-w-7xl mx-auto px-4 py-6"> + {/* ... content ... */} + </main> + </Tabs>job-hunting/components/jobs/job-feed.tsx (1)
140-144: LocalgetMatchScoreColorduplicates utility function from@/lib/utils.The relevant code snippets show that
getMatchScoreColorexists injob-hunting/lib/utils.ts. This local version has slightly different return values (includes background class), but the duplication could lead to inconsistencies.♻️ Suggested approach
Either import and extend the utility, or rename this local function to clarify its distinct purpose:
- const getMatchScoreColor = (score: number) => { + const getMatchScoreBadgeClass = (score: number) => { if (score >= 80) return "text-match-excellent bg-match-excellent/10"; if (score >= 50) return "text-match-good bg-match-good/10"; return "text-match-poor bg-match-poor/10"; };job-hunting/components/jobs/job-detail-dialog.tsx (1)
102-117: Full-screen modal without focus trap may cause accessibility issues.The modal renders as a full-screen overlay using Framer Motion but doesn't implement focus trapping. Users navigating with keyboard may tab outside the visible modal content.
Consider using a focus trap library or the Dialog primitive from Radix UI (which handles focus management) to ensure keyboard users remain within the modal while it's open.
job-hunting/lib/types.ts (1)
140-148: Consider documenting localStorage key usage for privacy compliance.The storage keys store user profile data including email and phone. For GDPR/CCPA compliance, ensure the application:
- Informs users about local storage usage
- Provides a way to clear this data
- Doesn't sync this to external services without consent
This is informational - the types themselves are fine, but the consuming code should handle PII appropriately.
job-hunting/lib/mino-client.ts (2)
166-179: JSON parsing inside callback could throw and disrupt the flow.While
parseJsonStringhas a try-catch, if the outer code throws during processing, the entire board scrape could fail silently. The nested structure makes error boundaries unclear.Consider moving
parseJsonStringoutside the callback as a module-level helper for better testability and clearer error boundaries.
264-277: Filter at line 277 may discard valid jobs with emptyfullUrl.The filter requires
job.title && job.company && job.fullUrlto be truthy. Jobs without a URL would be silently discarded. Consider logging or handling these cases if the URL might legitimately be missing from some job boards.♻️ Consider fallback handling
.filter((job) => { + if (!job.fullUrl) { + console.warn(`Job "${job.title}" from ${sourceBoard} missing URL, skipping`); + } return job.title && job.company && job.fullUrl; });
| const { text } = await generateText({ | ||
| model, | ||
| system: `You are an expert cover letter writer who creates compelling, personalized letters. | ||
| Write naturally and professionally, avoiding generic phrases. | ||
| Be specific about why this candidate is a great fit for this role. | ||
| Never use placeholder text - use actual company and candidate names.`, | ||
| prompt: `Write a compelling cover letter for this job application: | ||
|
|
||
| JOB DETAILS: | ||
| - Company: ${job.company} | ||
| - Title: ${job.title} | ||
| - Location: ${job.location} | ||
| - Description: ${job.description} | ||
| ${job.requirements ? `- Requirements: ${job.requirements.join(", ")}` : ""} | ||
|
|
||
| CANDIDATE PROFILE: | ||
| - Name: ${profile.fullName} | ||
| - Current title: ${profile.currentTitle} | ||
| - Years of experience: ${profile.yearsExperience} | ||
| - Key skills: ${profile.skills?.slice(0, 8).join(", ") || ""} | ||
| - Summary: ${profile.summary} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
minimax/minimax-m2.1 context length limit OpenRouter
💡 Result:
On OpenRouter, minimax/minimax-m2.1 has a context length limit of 196,608 tokens (i.e., the maximum total tokens the model can handle in a single request/conversation window on OpenRouter). [1][2]
For comparison, MiniMax’s own API docs list 204,800 tokens for MiniMax-M2.1, so OpenRouter’s routed offering appears slightly lower. [3]
Sources: OpenRouter model page / API quickstart [1][2], MiniMax docs [3].
🏁 Script executed:
# Find the full route file to understand context and data types
find . -type f -name "route.ts" | grep -i "cover-letter"Repository: tinyfish-io/TinyFish-cookbook
Length of output: 119
🏁 Script executed:
# Search for where the model is defined
rg -i "minimax|model" --type ts --type tsx -B 3 -A 3 | head -100Repository: tinyfish-io/TinyFish-cookbook
Length of output: 100
🏁 Script executed:
# Check the job and profile type definitions
rg -i "interface|type.*job|type.*profile" --type ts --type tsx -B 2 -A 8 | head -150Repository: tinyfish-io/TinyFish-cookbook
Length of output: 100
🏁 Script executed:
cat -n ./job-hunting/app/api/ai/cover-letter/route.tsRepository: tinyfish-io/TinyFish-cookbook
Length of output: 2855
🏁 Script executed:
# Search for job and profile interface/type definitions
rg "interface.*job|type.*job|interface.*profile|type.*profile" --type ts -B 2 -A 10Repository: tinyfish-io/TinyFish-cookbook
Length of output: 5645
🏁 Script executed:
# Check how job and profile objects are passed in
rg "job\.|profile\." ./job-hunting/app/api/ai/cover-letter/route.ts -B 2 -A 2Repository: tinyfish-io/TinyFish-cookbook
Length of output: 545
Guard prompt inputs: validate job.requirements is an array and cap job description length.
The code assumes job.requirements is an array and calls .join() without type checking—this will throw if the field is a string or other non-array type from the external job API. Additionally, unbounded job descriptions from external sources can inflate token usage and model latency. Validate and bound both inputs before constructing the prompt.
🔧 Proposed fix
- const { text } = await generateText({
+ const description =
+ typeof job.description === "string"
+ ? job.description.slice(0, 1500)
+ : "not provided";
+ const requirements = Array.isArray(job.requirements)
+ ? job.requirements.join(", ")
+ : "";
+
+ const { text } = await generateText({
model,
system: `You are an expert cover letter writer who creates compelling, personalized letters.
Write naturally and professionally, avoiding generic phrases.
Be specific about why this candidate is a great fit for this role.
Never use placeholder text - use actual company and candidate names.`,
prompt: `Write a compelling cover letter for this job application:
JOB DETAILS:
- Company: ${job.company}
- Title: ${job.title}
- Location: ${job.location}
- - Description: ${job.description}
-${job.requirements ? `- Requirements: ${job.requirements.join(", ")}` : ""}
+ - Description: ${description}
+${requirements ? `- Requirements: ${requirements}` : ""}🤖 Prompt for AI Agents
In `@job-hunting/app/api/ai/cover-letter/route.ts` around lines 36 - 56, Validate
and sanitize the job inputs before building the prompt passed to generateText:
ensure job.requirements is an array (use Array.isArray(job.requirements) and
coerce non-array values into an array or ignore them) before calling .join, and
truncate/limit job.description to a safe max length (e.g., slice to a fixed
character/token limit) to avoid unbounded prompt size; apply these checks where
the prompt string is constructed (reference the generateText call and the
variables job.requirements and job.description) so the prompt interpolation uses
the validated/trimmed values.
| const [boardScans, setBoardScans] = useState<Map<string, JobBoardScan>>(new Map()); | ||
| const [selectedBoard, setSelectedBoard] = useState<string | null>(null); | ||
| const [currentStreamingUrl, setCurrentStreamingUrl] = useState<string | null>(null); | ||
| const abortControllerRef = useRef<AbortController | null>(null); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AbortController is not cleaned up on component unmount.
The abortControllerRef is created during scan but never cleaned up if the component unmounts while a scan is in progress. This can lead to memory leaks and attempts to update unmounted component state.
🔧 Proposed fix
Add cleanup in a useEffect:
const abortControllerRef = useRef<AbortController | null>(null);
+ // Cleanup abort controller on unmount
+ useEffect(() => {
+ return () => {
+ abortControllerRef.current?.abort();
+ };
+ }, []);🤖 Prompt for AI Agents
In `@job-hunting/components/dashboard.tsx` at line 78, The AbortController stored
in abortControllerRef is not aborted/cleared on component unmount, risking
memory leaks and state updates on unmounted components; add a useEffect cleanup
that on unmount checks abortControllerRef.current, calls abort() if present, and
sets abortControllerRef.current = null so any in-progress scan (the logic that
creates/uses abortControllerRef) is cancelled and the ref is cleared; reference
abortControllerRef and the scan invocation (the function that assigns
abortControllerRef.current) when implementing the cleanup.
| <iframe | ||
| src={currentStreamingUrl} | ||
| className="flex-1 w-full" | ||
| title="Live browser preview" | ||
| /> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Iframe without sandbox attribute poses a security risk.
Embedding external streaming URLs in an iframe without the sandbox attribute could expose the application to XSS or clickjacking attacks if the streaming service is compromised.
🔧 Proposed fix
<iframe
src={currentStreamingUrl}
className="flex-1 w-full"
title="Live browser preview"
+ sandbox="allow-scripts allow-same-origin"
+ referrerPolicy="no-referrer"
/>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <iframe | |
| src={currentStreamingUrl} | |
| className="flex-1 w-full" | |
| title="Live browser preview" | |
| /> | |
| <iframe | |
| src={currentStreamingUrl} | |
| className="flex-1 w-full" | |
| title="Live browser preview" | |
| sandbox="allow-scripts allow-same-origin" | |
| referrerPolicy="no-referrer" | |
| /> |
🤖 Prompt for AI Agents
In `@job-hunting/components/dashboard.tsx` around lines 474 - 478, The iframe
rendering the live stream (iframe with src={currentStreamingUrl} in
dashboard.tsx) lacks a sandbox attribute and thus risks XSS/clickjacking; add an
appropriate sandbox attribute to the iframe element (e.g., sandbox with only the
necessary permissions) and combine it with explicit allow attributes (like
allow="autoplay; fullscreen") as needed for streaming functionality, minimizing
granted flags (avoid allow-top-navigation and broad allowances) so the iframe
runs with least privilege while preserving required features.
| const [coverLetter, setCoverLetter] = useState(""); | ||
| const [isGenerating, setIsGenerating] = useState(false); | ||
| const [copied, setCopied] = useState(false); | ||
| const [showCoverLetter, setShowCoverLetter] = useState(false); | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
State is not reset when the job prop changes.
When a user opens a different job's detail dialog, the previous cover letter, generation state, and visibility remain. This leads to stale data being displayed for the new job.
🔧 Proposed fix
Add a useEffect to reset state when the job changes:
+ import { useState, useEffect } from "react";
// ... inside component
const [coverLetter, setCoverLetter] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const [copied, setCopied] = useState(false);
const [showCoverLetter, setShowCoverLetter] = useState(false);
+ // Reset state when job changes
+ useEffect(() => {
+ setCoverLetter("");
+ setIsGenerating(false);
+ setCopied(false);
+ setShowCoverLetter(false);
+ }, [job?.id]);🤖 Prompt for AI Agents
In `@job-hunting/components/jobs/job-detail-dialog.tsx` around lines 60 - 64, When
the job prop changes the component doesn't reset local state (coverLetter,
isGenerating, copied, showCoverLetter), causing stale data to appear; add a
useEffect that depends on job and inside call setCoverLetter(""),
setIsGenerating(false), setCopied(false) and setShowCoverLetter(false) so the
dialog resets whenever the job prop changes (reference the existing state hooks
coverLetter/setCoverLetter, isGenerating/setIsGenerating, copied/setCopied,
showCoverLetter/setShowCoverLetter).
| (boardName, update) => { | ||
| setBoardScans((prev) => { | ||
| const newMap = new Map(prev); | ||
| const existing = newMap.get(boardName); | ||
| if (existing) { | ||
| const updated: JobBoardScan = { | ||
| ...existing, | ||
| status: update.status, | ||
| steps: update.step | ||
| ? [...existing.steps, update.step].slice(-5) | ||
| : existing.steps, | ||
| jobsFound: update.jobsFound ?? existing.jobsFound, | ||
| error: update.error, | ||
| streamingUrl: update.streamingUrl ?? existing.streamingUrl, | ||
| }; | ||
| newMap.set(boardName, updated); | ||
|
|
||
| // Notify parent of streaming URL | ||
| if (update.streamingUrl && onStreamingUrl) { | ||
| onStreamingUrl(update.streamingUrl); | ||
| } | ||
|
|
||
| // Track completion | ||
| if (update.status === "complete" || update.status === "error") { | ||
| setCompletedCount((c) => c + 1); | ||
| } | ||
| } | ||
| return newMap; | ||
| }); | ||
| }, | ||
| abortControllerRef.current.signal | ||
| ); | ||
|
|
||
| setIsRunning(false); | ||
| onComplete(resultsRef.current); | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Prevent overcounting and return actual results.
completedCount increments on every terminal update (can exceed total), and resultsRef is never populated, so onComplete receives empty results.
🐛 Suggested fix
(boardName, update) => {
setBoardScans((prev) => {
const newMap = new Map(prev);
const existing = newMap.get(boardName);
if (existing) {
+ const wasTerminal =
+ existing.status === "complete" || existing.status === "error";
const updated: JobBoardScan = {
...existing,
status: update.status,
steps: update.step
? [...existing.steps, update.step].slice(-5)
: existing.steps,
jobsFound: update.jobsFound ?? existing.jobsFound,
error: update.error,
streamingUrl: update.streamingUrl ?? existing.streamingUrl,
};
newMap.set(boardName, updated);
+ if (Array.isArray(update.jobs)) {
+ resultsRef.current.set(boardName, update.jobs);
+ }
+
// Notify parent of streaming URL
if (update.streamingUrl && onStreamingUrl) {
onStreamingUrl(update.streamingUrl);
}
// Track completion
- if (update.status === "complete" || update.status === "error") {
- setCompletedCount((c) => c + 1);
- }
+ const isTerminal =
+ update.status === "complete" || update.status === "error";
+ if (!wasTerminal && isTerminal) {
+ setCompletedCount((c) => c + 1);
+ }
}
return newMap;
});
},📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| (boardName, update) => { | |
| setBoardScans((prev) => { | |
| const newMap = new Map(prev); | |
| const existing = newMap.get(boardName); | |
| if (existing) { | |
| const updated: JobBoardScan = { | |
| ...existing, | |
| status: update.status, | |
| steps: update.step | |
| ? [...existing.steps, update.step].slice(-5) | |
| : existing.steps, | |
| jobsFound: update.jobsFound ?? existing.jobsFound, | |
| error: update.error, | |
| streamingUrl: update.streamingUrl ?? existing.streamingUrl, | |
| }; | |
| newMap.set(boardName, updated); | |
| // Notify parent of streaming URL | |
| if (update.streamingUrl && onStreamingUrl) { | |
| onStreamingUrl(update.streamingUrl); | |
| } | |
| // Track completion | |
| if (update.status === "complete" || update.status === "error") { | |
| setCompletedCount((c) => c + 1); | |
| } | |
| } | |
| return newMap; | |
| }); | |
| }, | |
| abortControllerRef.current.signal | |
| ); | |
| setIsRunning(false); | |
| onComplete(resultsRef.current); | |
| }; | |
| (boardName, update) => { | |
| setBoardScans((prev) => { | |
| const newMap = new Map(prev); | |
| const existing = newMap.get(boardName); | |
| if (existing) { | |
| const wasTerminal = | |
| existing.status === "complete" || existing.status === "error"; | |
| const updated: JobBoardScan = { | |
| ...existing, | |
| status: update.status, | |
| steps: update.step | |
| ? [...existing.steps, update.step].slice(-5) | |
| : existing.steps, | |
| jobsFound: update.jobsFound ?? existing.jobsFound, | |
| error: update.error, | |
| streamingUrl: update.streamingUrl ?? existing.streamingUrl, | |
| }; | |
| newMap.set(boardName, updated); | |
| if (Array.isArray(update.jobs)) { | |
| resultsRef.current.set(boardName, update.jobs); | |
| } | |
| // Notify parent of streaming URL | |
| if (update.streamingUrl && onStreamingUrl) { | |
| onStreamingUrl(update.streamingUrl); | |
| } | |
| // Track completion | |
| const isTerminal = | |
| update.status === "complete" || update.status === "error"; | |
| if (!wasTerminal && isTerminal) { | |
| setCompletedCount((c) => c + 1); | |
| } | |
| } | |
| return newMap; | |
| }); | |
| }, | |
| abortControllerRef.current.signal | |
| ); | |
| setIsRunning(false); | |
| onComplete(resultsRef.current); | |
| }; |
🤖 Prompt for AI Agents
In `@job-hunting/components/jobs/scraping-grid.tsx` around lines 69 - 104, The
handler passed to setBoardScans currently increments setCompletedCount for every
terminal update and never fills resultsRef, so fix by: when updating inside
setBoardScans (in the function that takes boardName and update) only call
setCompletedCount((c)=>c+1) if the previous status was not a terminal status and
update.status is terminal (check existing.status !== "complete" &&
existing.status !== "error" before incrementing); populate resultsRef.current
either incrementally (update resultsRef.current[boardName] =
updated.jobsFound/details when update provides jobsFound) or compute
resultsRef.current = Array.from(newMap.values()) right after setBoardScans
finishes (before calling setIsRunning(false) and onComplete) so
onComplete(resultsRef.current) receives actual results; keep the onStreamingUrl
notification as-is and continue using abortControllerRef.current.signal.
| const processResume = async (text: string, fileName?: string) => { | ||
| setState("parsing"); | ||
| setProgress(40); | ||
| setStatusMessage("Analyzing resume with AI..."); | ||
|
|
||
| try { | ||
| // Simulate progress during AI parsing | ||
| const progressInterval = setInterval(() => { | ||
| setProgress((p) => Math.min(p + 10, 90)); | ||
| }, 1000); | ||
|
|
||
| setProgress(50); | ||
| setStatusMessage("Extracting skills and experience..."); | ||
|
|
||
| const parsedData = await parseResume(text); | ||
|
|
||
| clearInterval(progressInterval); | ||
| setProgress(100); | ||
| setStatusMessage("Resume parsed successfully!"); | ||
| setState("success"); | ||
|
|
||
| // Small delay to show success state | ||
| setTimeout(() => { | ||
| onParsed(parsedData, text, fileName); | ||
| }, 500); | ||
| } catch (err) { | ||
| setState("error"); | ||
| setError( | ||
| err instanceof Error | ||
| ? err.message | ||
| : "Failed to parse resume. Please try again." | ||
| ); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Clear the progress interval on errors.
If parseResume throws, the interval keeps firing, so progress continues after failure and can leak state updates.
🐛 Suggested fix
const processResume = async (text: string, fileName?: string) => {
setState("parsing");
setProgress(40);
setStatusMessage("Analyzing resume with AI...");
+ let progressInterval: ReturnType<typeof setInterval> | undefined;
try {
// Simulate progress during AI parsing
- const progressInterval = setInterval(() => {
+ progressInterval = setInterval(() => {
setProgress((p) => Math.min(p + 10, 90));
}, 1000);
setProgress(50);
setStatusMessage("Extracting skills and experience...");
const parsedData = await parseResume(text);
- clearInterval(progressInterval);
setProgress(100);
setStatusMessage("Resume parsed successfully!");
setState("success");
@@
} catch (err) {
setState("error");
setError(
err instanceof Error
? err.message
: "Failed to parse resume. Please try again."
);
+ } finally {
+ if (progressInterval) clearInterval(progressInterval);
}
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const processResume = async (text: string, fileName?: string) => { | |
| setState("parsing"); | |
| setProgress(40); | |
| setStatusMessage("Analyzing resume with AI..."); | |
| try { | |
| // Simulate progress during AI parsing | |
| const progressInterval = setInterval(() => { | |
| setProgress((p) => Math.min(p + 10, 90)); | |
| }, 1000); | |
| setProgress(50); | |
| setStatusMessage("Extracting skills and experience..."); | |
| const parsedData = await parseResume(text); | |
| clearInterval(progressInterval); | |
| setProgress(100); | |
| setStatusMessage("Resume parsed successfully!"); | |
| setState("success"); | |
| // Small delay to show success state | |
| setTimeout(() => { | |
| onParsed(parsedData, text, fileName); | |
| }, 500); | |
| } catch (err) { | |
| setState("error"); | |
| setError( | |
| err instanceof Error | |
| ? err.message | |
| : "Failed to parse resume. Please try again." | |
| ); | |
| } | |
| }; | |
| const processResume = async (text: string, fileName?: string) => { | |
| setState("parsing"); | |
| setProgress(40); | |
| setStatusMessage("Analyzing resume with AI..."); | |
| let progressInterval: ReturnType<typeof setInterval> | undefined; | |
| try { | |
| // Simulate progress during AI parsing | |
| progressInterval = setInterval(() => { | |
| setProgress((p) => Math.min(p + 10, 90)); | |
| }, 1000); | |
| setProgress(50); | |
| setStatusMessage("Extracting skills and experience..."); | |
| const parsedData = await parseResume(text); | |
| setProgress(100); | |
| setStatusMessage("Resume parsed successfully!"); | |
| setState("success"); | |
| // Small delay to show success state | |
| setTimeout(() => { | |
| onParsed(parsedData, text, fileName); | |
| }, 500); | |
| } catch (err) { | |
| setState("error"); | |
| setError( | |
| err instanceof Error | |
| ? err.message | |
| : "Failed to parse resume. Please try again." | |
| ); | |
| } finally { | |
| if (progressInterval) clearInterval(progressInterval); | |
| } | |
| }; |
🤖 Prompt for AI Agents
In `@job-hunting/components/onboarding/resume-upload.tsx` around lines 77 - 110,
In processResume, ensure the progressInterval created before awaiting
parseResume is always cleared on failure: move or add
clearInterval(progressInterval) into the catch block (before calling
setState/setError) so the interval stops if parseResume throws; also consider
clearing it in a finally block to guarantee cleanup regardless of success or
error, keeping references to progressInterval, parseResume, clearInterval,
setState, setProgress, and setError to locate the code.
| const useFormField = () => { | ||
| const fieldContext = React.useContext(FormFieldContext) | ||
| const itemContext = React.useContext(FormItemContext) | ||
| const { getFieldState } = useFormContext() | ||
| const formState = useFormState({ name: fieldContext.name }) | ||
| const fieldState = getFieldState(fieldContext.name, formState) | ||
|
|
||
| if (!fieldContext) { | ||
| throw new Error("useFormField should be used within <FormField>") | ||
| } | ||
|
|
||
| const { id } = itemContext | ||
|
|
||
| return { | ||
| id, | ||
| name: fieldContext.name, | ||
| formItemId: `${id}-form-item`, | ||
| formDescriptionId: `${id}-form-item-description`, | ||
| formMessageId: `${id}-form-item-message`, | ||
| ...fieldState, | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Guard check is placed after the guarded value is used.
The check if (!fieldContext) at line 52 occurs after fieldContext.name is already accessed on lines 49-50. If useFormField is called outside of a <FormField>, this will throw a runtime error before reaching the helpful error message.
🔧 Proposed fix
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
+
+ if (!fieldContext.name) {
+ throw new Error("useFormField should be used within <FormField>")
+ }
+
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
- if (!fieldContext) {
- throw new Error("useFormField should be used within <FormField>")
- }
-
const { id } = itemContext📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const useFormField = () => { | |
| const fieldContext = React.useContext(FormFieldContext) | |
| const itemContext = React.useContext(FormItemContext) | |
| const { getFieldState } = useFormContext() | |
| const formState = useFormState({ name: fieldContext.name }) | |
| const fieldState = getFieldState(fieldContext.name, formState) | |
| if (!fieldContext) { | |
| throw new Error("useFormField should be used within <FormField>") | |
| } | |
| const { id } = itemContext | |
| return { | |
| id, | |
| name: fieldContext.name, | |
| formItemId: `${id}-form-item`, | |
| formDescriptionId: `${id}-form-item-description`, | |
| formMessageId: `${id}-form-item-message`, | |
| ...fieldState, | |
| } | |
| } | |
| const useFormField = () => { | |
| const fieldContext = React.useContext(FormFieldContext) | |
| const itemContext = React.useContext(FormItemContext) | |
| if (!fieldContext) { | |
| throw new Error("useFormField should be used within <FormField>") | |
| } | |
| const { getFieldState } = useFormContext() | |
| const formState = useFormState({ name: fieldContext.name }) | |
| const fieldState = getFieldState(fieldContext.name, formState) | |
| const { id } = itemContext | |
| return { | |
| id, | |
| name: fieldContext.name, | |
| formItemId: `${id}-form-item`, | |
| formDescriptionId: `${id}-form-item-description`, | |
| formMessageId: `${id}-form-item-message`, | |
| ...fieldState, | |
| } | |
| } |
🤖 Prompt for AI Agents
In `@job-hunting/components/ui/form.tsx` around lines 45 - 66, The guard in
useFormField is after fieldContext is dereferenced, causing a crash if used
outside <FormField>; move the null-check for fieldContext to the top of
useFormField (before any access to fieldContext.name or passing it to
useFormState/getFieldState) and throw the same error if missing so subsequent
lines (getFieldState, useFormState, forming ids) only run when fieldContext is
defined.
| function Progress({ | ||
| className, | ||
| value, | ||
| ...props | ||
| }: React.ComponentProps<typeof ProgressPrimitive.Root>) { | ||
| return ( | ||
| <ProgressPrimitive.Root | ||
| data-slot="progress" | ||
| className={cn( | ||
| "bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", | ||
| className | ||
| )} | ||
| {...props} | ||
| > | ||
| <ProgressPrimitive.Indicator | ||
| data-slot="progress-indicator" | ||
| className="bg-primary h-full w-full flex-1 transition-all" | ||
| style={{ transform: `translateX(-${100 - (value || 0)}%)` }} | ||
| /> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# First, let's find and read the progress.tsx file
find . -name "progress.tsx" -type f | head -20Repository: tinyfish-io/TinyFish-cookbook
Length of output: 113
🏁 Script executed:
#!/bin/bash
# Let's also check the repository structure
ls -la job-hunting/components/ui/ 2>/dev/null || ls -la components/ui/ 2>/dev/null || find . -path "*/components/ui/progress.tsx" -type fRepository: tinyfish-io/TinyFish-cookbook
Length of output: 1584
🏁 Script executed:
#!/bin/bash
# Search for the progress component file
fd "progress.tsx" -type fRepository: tinyfish-io/TinyFish-cookbook
Length of output: 243
🏁 Script executed:
#!/bin/bash
# Read the complete progress.tsx file
cat -n ./job-hunting/components/ui/progress.tsxRepository: tinyfish-io/TinyFish-cookbook
Length of output: 1029
🏁 Script executed:
#!/bin/bash
# Search for how Progress component is used in the codebase
rg "Progress" -A 2 -B 2 --type tsx --type ts | head -100Repository: tinyfish-io/TinyFish-cookbook
Length of output: 100
🏁 Script executed:
#!/bin/bash
# Check if there are any tests or examples using Progress
find . -name "*.tsx" -o -name "*.ts" | xargs grep -l "Progress" | grep -v node_modules | head -10Repository: tinyfish-io/TinyFish-cookbook
Length of output: 382
🏁 Script executed:
#!/bin/bash
# Look for imports of ProgressPrimitive to understand the API
rg "ProgressPrimitive" -A 3 -B 3Repository: tinyfish-io/TinyFish-cookbook
Length of output: 1852
🏁 Script executed:
#!/bin/bash
# Let's check how the Progress component is used
rg "Progress" job-hunting/components/ -A 5 -B 2Repository: tinyfish-io/TinyFish-cookbook
Length of output: 15167
🏁 Script executed:
#!/bin/bash
# Check package.json to understand Radix UI version
cat job-hunting/package.json | grep -A 5 "react-progress" || cat package.json | grep -A 5 "react-progress"Repository: tinyfish-io/TinyFish-cookbook
Length of output: 321
Pass value to the Radix Root to ensure aria-valuenow and state management are set correctly.
value is destructured but not forwarded to ProgressPrimitive.Root, so the Radix component won't set accessibility attributes (aria-valuenow) or maintain its internal state. Although the indicator transform uses value, it must also be passed directly to the Root component.
✅ Minimal fix
<ProgressPrimitive.Root
data-slot="progress"
+ value={value}
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function Progress({ | |
| className, | |
| value, | |
| ...props | |
| }: React.ComponentProps<typeof ProgressPrimitive.Root>) { | |
| return ( | |
| <ProgressPrimitive.Root | |
| data-slot="progress" | |
| className={cn( | |
| "bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", | |
| className | |
| )} | |
| {...props} | |
| > | |
| <ProgressPrimitive.Indicator | |
| data-slot="progress-indicator" | |
| className="bg-primary h-full w-full flex-1 transition-all" | |
| style={{ transform: `translateX(-${100 - (value || 0)}%)` }} | |
| /> | |
| function Progress({ | |
| className, | |
| value, | |
| ...props | |
| }: React.ComponentProps<typeof ProgressPrimitive.Root>) { | |
| return ( | |
| <ProgressPrimitive.Root | |
| data-slot="progress" | |
| value={value} | |
| className={cn( | |
| "bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", | |
| className | |
| )} | |
| {...props} | |
| > | |
| <ProgressPrimitive.Indicator | |
| data-slot="progress-indicator" | |
| className="bg-primary h-full w-full flex-1 transition-all" | |
| style={{ transform: `translateX(-${100 - (value || 0)}%)` }} | |
| /> |
🤖 Prompt for AI Agents
In `@job-hunting/components/ui/progress.tsx` around lines 8 - 26, The Progress
component destructures value but doesn't pass it into ProgressPrimitive.Root;
update the Progress function to forward the value prop to ProgressPrimitive.Root
(the component defined in this file as Progress and the Radix element
ProgressPrimitive.Root) so Radix can set aria-valuenow and manage internal
state—ensure you add value={value} to the Root while keeping the existing props
and indicator transform logic intact.
| // Process in batches of 3 to avoid overwhelming the API | ||
| const batchSize = 3; | ||
| for (let i = 0; i < jobs.length; i += batchSize) { | ||
| const batch = jobs.slice(i, i + batchSize); | ||
|
|
||
| const batchPromises = batch.map(async (job) => { | ||
| try { | ||
| onProgress?.(i + 1, jobs.length); | ||
| const result = await analyzeJobMatch(job, profile); | ||
| return { id: job.id || `job-${i}`, result }; | ||
| } catch (error) { | ||
| return { | ||
| id: job.id || `job-${i}`, | ||
| result: { | ||
| matchScore: 0, | ||
| matchExplanation: "Unable to analyze this job listing.", | ||
| keyStrengths: [], | ||
| potentialConcerns: ["Analysis failed"], | ||
| isReach: false, | ||
| isPerfectFit: false, | ||
| } as JobMatchResult, | ||
| }; | ||
| } | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix duplicate IDs and progress counts in batchAnalyzeJobs.
Line 136 uses i (batch start index) for progress, and Line 138 falls back to job-${i}. Every job in the same batch therefore shares the same ID and progress value, so results get overwritten when job.id is missing.
🐛 Proposed fix
- const batchPromises = batch.map(async (job) => {
+ const batchPromises = batch.map(async (job, batchIndex) => {
+ const current = i + batchIndex + 1;
try {
- onProgress?.(i + 1, jobs.length);
+ onProgress?.(current, jobs.length);
const result = await analyzeJobMatch(job, profile);
- return { id: job.id || `job-${i}`, result };
+ return { id: job.id || `job-${current}`, result };
} catch (error) {
return {
- id: job.id || `job-${i}`,
+ id: job.id || `job-${current}`,
result: {
matchScore: 0,
matchExplanation: "Unable to analyze this job listing.",
keyStrengths: [],
potentialConcerns: ["Analysis failed"],
isReach: false,
isPerfectFit: false,
} as JobMatchResult,
};
}
});🤖 Prompt for AI Agents
In `@job-hunting/lib/ai/client.ts` around lines 129 - 152, In batchAnalyzeJobs the
progress callback and fallback IDs use the batch start index `i`, causing
duplicate IDs and incorrect progress for items in the same batch; change the
code inside the batch.map to compute a per-job index (e.g., compute `const idx =
i + batchIndex` using the map second arg or track `i + j`) and use that `idx`
for onProgress (onProgress?.(idx + 1, jobs.length)) and for the fallback ID
(job.id || `job-${idx}`) so each job gets a unique index/ID when job.id is
missing while leaving analyzeJobMatch and the error result structure unchanged.
| import { useState, useEffect, useCallback, useMemo } from "react"; | ||
|
|
||
| /** | ||
| * Custom hook for reactive localStorage management | ||
| * Syncs state with localStorage and handles SSR | ||
| */ | ||
| export function useLocalStorage<T>( | ||
| key: string, | ||
| initialValue: T | ||
| ): [T, (value: T | ((val: T) => T)) => void, boolean] { | ||
| // Track if we're on the client - derive from window instead of useEffect | ||
| const isClient = useMemo(() => typeof window !== "undefined", []); | ||
|
|
||
| // Initialize state with lazy function to read from localStorage | ||
| const [storedValue, setStoredValue] = useState<T>(() => { | ||
| if (typeof window === "undefined") { | ||
| return initialValue; | ||
| } | ||
| try { | ||
| const item = window.localStorage.getItem(key); | ||
| if (item) { | ||
| return JSON.parse(item); | ||
| } | ||
| } catch (error) { | ||
| // Error loading from localStorage | ||
| } | ||
| return initialValue; | ||
| }); | ||
|
|
||
| // Update localStorage when state changes | ||
| const setValue = useCallback( | ||
| (value: T | ((val: T) => T)) => { | ||
| try { | ||
| // Allow value to be a function for updates based on previous value | ||
| const valueToStore = | ||
| value instanceof Function ? value(storedValue) : value; | ||
|
|
||
| setStoredValue(valueToStore); | ||
|
|
||
| // Only write to localStorage on client | ||
| if (typeof window !== "undefined") { | ||
| window.localStorage.setItem(key, JSON.stringify(valueToStore)); | ||
|
|
||
| // Dispatch custom event for cross-tab sync | ||
| window.dispatchEvent( | ||
| new StorageEvent("storage", { | ||
| key, | ||
| newValue: JSON.stringify(valueToStore), | ||
| }) | ||
| ); | ||
| } | ||
| } catch (error) { | ||
| // Error saving to localStorage | ||
| } | ||
| }, | ||
| [key, storedValue] | ||
| ); | ||
|
|
||
| // Listen for changes from other tabs/windows | ||
| useEffect(() => { | ||
| const handleStorageChange = (e: StorageEvent) => { | ||
| if (e.key === key && e.newValue) { | ||
| try { | ||
| setStoredValue(JSON.parse(e.newValue)); | ||
| } catch { | ||
| // Error parsing storage event | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| window.addEventListener("storage", handleStorageChange); | ||
| return () => window.removeEventListener("storage", handleStorageChange); | ||
| }, [key]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
React useState functional update stale closure best practice setState
💡 Result:
Best practice: avoid stale closures with useState by using the functional updater when the next value depends on the previous one
1) Use the functional updater whenever you derive next state from previous state
setCount(c => c + 1);
setTodos(todos => [...todos, newTodo]);
setForm(f => ({ ...f, name: nextName }));React treats a function passed to the setter as an “updater function”, queues it, and applies queued updaters to the most recent pending state during the next render. This makes updates correct even under batching and rapid/overlapping updates. [1]
2) It’s required when you do multiple updates in one event (otherwise you’ll reuse the same snapshot)
setAge(a => a + 1);
setAge(a => a + 1);
setAge(a => a + 1);If you instead wrote setAge(age + 1) three times, each call would compute from the same age value captured in that render (“state is a snapshot”), so you’d only increment once. [1]
3) It’s strongly recommended for async callbacks (where stale closures commonly happen)
If an update runs later (Promise, setTimeout, subscription, WebSocket, etc.), the callback often “remembers” old state. Prefer:
doSomethingAsync().then(() => setCount(c => c + 1));over:
doSomethingAsync().then(() => setCount(count + 1)); // can be stale(React’s own “queued updates” guide includes an async example where direct reads cause incorrect counters, and the fix is functional updates.) [2]
4) Don’t expect state to change immediately after calling the setter
The setter only updates state for the next render; reading the state variable right after calling it still gives the old value from the current render. [1]
Rule of thumb: if your new state is computed from existing state, default to the functional updater (setX(prev => ...)). Otherwise, passing a value directly is fine. [1]
Sources: React useState reference [1] and “Queueing a Series of State Updates” (batching + updater queue model + async pitfall) [2].
[1] https://react.dev/reference/react/useState
[2] https://react.dev/learn/queueing-a-series-of-state-updates
🏁 Script executed:
cat -n job-hunting/lib/hooks/use-local-storage.ts | head -75Repository: tinyfish-io/TinyFish-cookbook
Length of output: 2800
Fix stale closure in setValue and handle cross-tab item deletions in useLocalStorage.
Line 38 captures storedValue in the callback closure. Rapid functional updates compute from the same stale snapshot—calling setValue(v => v + 1) twice only increments once. Use React's functional updater pattern to always operate on the latest state.
Line 64 ignores e.newValue === null, so clearing an item in another tab won't reset local state. When a key is deleted, dispatch a storage event with newValue: null and reset to the initial value.
Proposed fix
-import { useState, useEffect, useCallback, useMemo } from "react";
+import { useState, useEffect, useCallback, useMemo, useRef } from "react";
@@
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === "undefined") {
return initialValue;
}
try {
const item = window.localStorage.getItem(key);
if (item) {
return JSON.parse(item);
}
} catch (error) {
// Error loading from localStorage
}
return initialValue;
});
+
+ const initialRef = useRef(initialValue);
@@
const setValue = useCallback(
(value: T | ((val: T) => T)) => {
try {
- // Allow value to be a function for updates based on previous value
- const valueToStore =
- value instanceof Function ? value(storedValue) : value;
-
- setStoredValue(valueToStore);
-
- // Only write to localStorage on client
- if (typeof window !== "undefined") {
- window.localStorage.setItem(key, JSON.stringify(valueToStore));
-
- // Dispatch custom event for cross-tab sync
- window.dispatchEvent(
- new StorageEvent("storage", {
- key,
- newValue: JSON.stringify(valueToStore),
- })
- );
- }
+ setStoredValue((prev) => {
+ const valueToStore = value instanceof Function ? value(prev) : value;
+ if (typeof window !== "undefined") {
+ window.localStorage.setItem(key, JSON.stringify(valueToStore));
+ window.dispatchEvent(
+ new StorageEvent("storage", {
+ key,
+ newValue: JSON.stringify(valueToStore),
+ })
+ );
+ }
+ return valueToStore;
+ });
} catch (error) {
// Error saving to localStorage
}
},
- [key, storedValue]
+ [key]
);
@@
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
- if (e.key === key && e.newValue) {
- try {
- setStoredValue(JSON.parse(e.newValue));
- } catch {
- // Error parsing storage event
- }
- }
+ if (e.key !== key) return;
+ if (e.newValue == null) {
+ setStoredValue(initialRef.current);
+ return;
+ }
+ try {
+ setStoredValue(JSON.parse(e.newValue));
+ } catch {
+ // Error parsing storage event
+ }
};🤖 Prompt for AI Agents
In `@job-hunting/lib/hooks/use-local-storage.ts` around lines 3 - 75, The bug is
that setValue closes over storedValue and misses rapid functional updates, and
storage events with newValue === null (deletions) are ignored. Fix setValue in
useLocalStorage to use the functional updater form: call setStoredValue(prev =>
{ const valueToStore = value instanceof Function ? value(prev) : value; return
valueToStore; }) and compute valueToStore inside that updater; remove
storedValue from the useCallback dependency list (rely only on key). When
persisting, if valueToStore is null or undefined call
window.localStorage.removeItem(key) and dispatch a StorageEvent with newValue:
null; otherwise setItem(key, JSON.stringify(valueToStore)) and dispatch with the
JSON string. In the storage listener (handleStorageChange) handle e.newValue ===
null by calling setStoredValue(initialValue) (and still parse e.newValue when
present). Ensure window existence checks remain for SSR.
job-hunting
Live Demo: https://job-huntboard.vercel.app
Overview
Live Demo: https://job-huntboard.vercel.app
Job Hunter is a comprehensive platform that automates the job search process using Mino's browser automation API and OpenRouter's AI models. It handles everything from resume parsing and optimized job board search generation to parallel scraping and intelligent job matching.
Mino API Integration
This use case demonstrates Mino API usage for browser automation.
Tech Stack
Contributor: Pranav Janakiraman (@pranavjana)