-
Notifications
You must be signed in to change notification settings - Fork 1
feat(jef-57): flag likely ghosted applications #294
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import { describe, expect, it } from 'vitest'; | ||
| import { makeApplication } from '#src/__tests__/helpers/mocks.js'; | ||
| import { isLikelyGhosted } from '#src/use-cases/jobs/applicationStaleness.js'; | ||
|
|
||
| const now = new Date('2026-08-20T12:00:00Z'); | ||
|
|
||
| describe('isLikelyGhosted', () => { | ||
| it('flags an old active application with no recent activity', () => { | ||
| const application = makeApplication({ | ||
| status: 'applied', | ||
| appliedAt: new Date('2026-07-01T12:00:00Z'), | ||
| updatedAt: new Date('2026-07-01T12:00:00Z'), | ||
| }); | ||
|
|
||
| expect(isLikelyGhosted(application, now)).toBe(true); | ||
| }); | ||
|
|
||
| it('excludes terminal statuses, recent edits, and recent reminders', () => { | ||
| const base = { | ||
| appliedAt: new Date('2026-07-01T12:00:00Z'), | ||
| updatedAt: new Date('2026-07-01T12:00:00Z'), | ||
| }; | ||
| expect(isLikelyGhosted(makeApplication({ ...base, status: 'rejected' }), now)).toBe(false); | ||
| expect( | ||
| isLikelyGhosted( | ||
| makeApplication({ | ||
| ...base, | ||
| status: 'applied', | ||
| updatedAt: new Date('2026-08-15T12:00:00Z'), | ||
| }), | ||
| now, | ||
| ), | ||
| ).toBe(false); | ||
| expect( | ||
| isLikelyGhosted( | ||
| makeApplication({ | ||
| ...base, | ||
| status: 'interviewing', | ||
| reminderSentAt: new Date('2026-08-15T12:00:00Z'), | ||
| }), | ||
| now, | ||
| ), | ||
| ).toBe(false); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import type { Application } from '#src/domain/application/Application.js'; | ||
|
|
||
| export const LIKELY_GHOSTED_AFTER_DAYS = 14; | ||
| const LIKELY_GHOSTED_AFTER_MS = LIKELY_GHOSTED_AFTER_DAYS * 24 * 60 * 60 * 1000; | ||
|
|
||
| export function isLikelyGhosted(application: Application, now = new Date()): boolean { | ||
| if (!['applied', 'interviewing'].includes(application.status) || !application.appliedAt) { | ||
| return false; | ||
| } | ||
|
|
||
| const cutoff = now.getTime() - LIKELY_GHOSTED_AFTER_MS; | ||
| return ( | ||
| application.updatedAt.getTime() <= cutoff && | ||
| (application.reminderSentAt == null || application.reminderSentAt.getTime() <= cutoff) | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,7 +17,11 @@ const APPLICATION_STATUSES: ApplicationStatus[] = [ | |
| 'withdrawn', | ||
| ]; | ||
|
|
||
| const searchSchema = z.object({ status: z.string().optional(), starred: z.boolean().optional() }); | ||
| const searchSchema = z.object({ | ||
| status: z.string().optional(), | ||
| starred: z.boolean().optional(), | ||
| likelyGhosted: z.boolean().optional(), | ||
| }); | ||
|
|
||
| export const APPLICATIONS_PAGE_QUERY = ` | ||
| query ApplicationsPage( | ||
|
|
@@ -26,13 +30,15 @@ export const APPLICATIONS_PAGE_QUERY = ` | |
| $search: String | ||
| $cursor: String | ||
| $limit: Int | ||
| $likelyGhosted: Boolean | ||
| ) { | ||
| applicationsPage( | ||
| status: $status | ||
| starred: $starred | ||
| search: $search | ||
| cursor: $cursor | ||
| limit: $limit | ||
| likelyGhosted: $likelyGhosted | ||
|
Comment on lines
+33
to
+41
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Select Line 76 declares Proposed fix items {
id
company
role
status
+ likelyGhosted
locationAlso applies to: 76-76 🤖 Prompt for AI Agents |
||
| ) { | ||
| hasNextPage | ||
| nextCursor | ||
|
|
@@ -67,6 +73,7 @@ export type Application = { | |
| followUpAt?: string | null; | ||
| tags: string[]; | ||
| createdAt: string; | ||
| likelyGhosted: boolean; | ||
| }; | ||
|
|
||
| export type ApplicationsPageResult = { | ||
|
|
@@ -83,16 +90,25 @@ export function applicationsPageQueryOptions( | |
| status: string | undefined, | ||
| starred: boolean | undefined, | ||
| searchTerm: string, | ||
| likelyGhosted: boolean | undefined, | ||
| ) { | ||
| return infiniteQueryOptions({ | ||
| queryKey: ['applications', 'page', status ?? null, starred ?? false, searchTerm], | ||
| queryKey: [ | ||
| 'applications', | ||
| 'page', | ||
| status ?? null, | ||
| starred ?? false, | ||
| likelyGhosted ?? false, | ||
| searchTerm, | ||
| ], | ||
| queryFn: ({ pageParam }) => | ||
| gqlClient.request<ApplicationsPageResult>(APPLICATIONS_PAGE_QUERY, { | ||
| status: status ?? null, | ||
| starred: starred ?? null, | ||
| search: searchTerm || null, | ||
| cursor: pageParam, | ||
| limit: PAGE_SIZE, | ||
| ...(likelyGhosted !== undefined ? { likelyGhosted } : {}), | ||
| }), | ||
| initialPageParam: undefined as string | undefined, | ||
| getNextPageParam: (lastPage) => | ||
|
|
@@ -104,10 +120,14 @@ export const Route = createFileRoute('/_authenticated/applications/')({ | |
| validateSearch: searchSchema, | ||
| // searchTerm is local-only component state (always '' on a fresh navigation), | ||
| // so only status/starred — the URL-driven filters — need to be loader deps. | ||
| loaderDeps: ({ search }) => ({ status: search.status, starred: search.starred }), | ||
| loaderDeps: ({ search }) => ({ | ||
| status: search.status, | ||
| starred: search.starred, | ||
| likelyGhosted: search.likelyGhosted, | ||
| }), | ||
| loader: ({ context: { queryClient }, deps }) => | ||
| queryClient.ensureInfiniteQueryData( | ||
| applicationsPageQueryOptions(deps.status, deps.starred, ''), | ||
| applicationsPageQueryOptions(deps.status, deps.starred, '', deps.likelyGhosted), | ||
| ), | ||
| component: ApplicationsPage, | ||
| }); | ||
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.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: mankatcheung/job-finder
Length of output: 26720
🌐 Web query:
TanStack Router navigate search functional updater preserves search params search prop💡 Result:
In TanStack Router, using a functional updater with the
searchproperty is the recommended way to modify search parameters while preserving existing ones [1][2]. When you provide a function to thesearchprop (in theLinkcomponent) or thesearchoption (innavigateoruseNavigate), the router passes the current search parameters as an argument to that function [1][3]. You can then return a new object containing your updates, merging it with the previous parameters [1][2]. Example usage: // Using navigate navigate({ search: (prev) => ({...prev, page: 1 }), }) // Using Link <Link search={(prev) => ({...prev, page: 1 })}> Next Page By using the spread operator ({...prev,... }), you ensure that any existing search parameters—including those inherited from parent routes—are maintained [2]. If you instead provide a plain object as thesearchvalue, it will typically replace or overwrite the existing search parameters [2]. For more complex scenarios, such as needing to remove specific parameters, you can destructure them within the functional updater: navigate({ search: (prev) => { const { unwantedParam,...rest } = prev; return rest; }, }) This functional approach is essential for maintaining application state that is stored in the URL, especially in deeply nested route structures where parent search parameters must be preserved during navigation [2].Citations:
Preserve existing URL filters when toggling
likelyGhosted.This
searchprop replaces the current search state. Ifstatusorstarredis active, clicking the link removes those filters when it adds or removeslikelyGhosted. Use the functionalsearchupdater and keep the previous parameters.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents