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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
});
});
2 changes: 2 additions & 0 deletions apps/api/src/http/schema/queries/applicationQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ builder.queryField('applicationsPage', (t) =>
search: t.arg.string({ required: false }),
cursor: t.arg.string({ required: false }),
limit: t.arg.int({ required: false }),
likelyGhosted: t.arg.boolean({ required: false }),
},
resolve: async (_root, args, ctx) => {
if (!ctx.user)
Expand All @@ -44,6 +45,7 @@ builder.queryField('applicationsPage', (t) =>
search: args.search ?? undefined,
cursor: args.cursor ?? undefined,
limit: args.limit ?? undefined,
likelyGhosted: args.likelyGhosted ?? undefined,
});
},
}),
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/http/schema/types/ApplicationType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,6 @@ JobApplicationRef.implement({
tags: t.exposeStringList('tags'),
createdAt: t.exposeString('createdAt'),
updatedAt: t.exposeString('updatedAt'),
likelyGhosted: t.exposeBoolean('likelyGhosted'),
}),
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { eq, and, or, desc, gte, lte, lt, like, isNull, inArray } from 'drizzle-orm';
import { eq, and, or, desc, gte, lte, lt, like, isNull, isNotNull, inArray } from 'drizzle-orm';
import type { DrizzleDb, DrizzleClient } from '../client.js';
import { jobApplication, applicationTag } from '../schema.js';
import type { Application } from '#src/domain/application/Application.js';
Expand All @@ -13,6 +13,7 @@ import type {
} from '#src/use-cases/ports/IApplicationRepository.js';
import { txStorage, getClient } from '../transactionContext.js';
import { REMINDER_WINDOW_MS } from '#src/constants.js';
import { LIKELY_GHOSTED_AFTER_DAYS } from '#src/use-cases/jobs/applicationStaleness.js';

type AppRow = typeof jobApplication.$inferSelect & { tags: string[] };

Expand Down Expand Up @@ -70,6 +71,17 @@ export class DrizzleApplicationRepository implements IApplicationRepository {
const conditions = [eq(jobApplication.userId, userId)];
if (filters.status) conditions.push(eq(jobApplication.status, filters.status));
if (filters.starred) conditions.push(eq(jobApplication.starred, true));
if (filters.likelyGhosted) {
const cutoff = new Date(Date.now() - LIKELY_GHOSTED_AFTER_DAYS * 24 * 60 * 60 * 1000);
conditions.push(
and(
inArray(jobApplication.status, ['applied', 'interviewing']),
isNotNull(jobApplication.appliedAt),
lte(jobApplication.updatedAt, cutoff),
or(isNull(jobApplication.reminderSentAt), lte(jobApplication.reminderSentAt, cutoff)),
)!,
);
}
if (search) {
conditions.push(
or(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Application } from '#src/domain/application/Application.js';
import type { ApplicationStatus } from '#src/domain/application/ApplicationStatus.js';
import { isLikelyGhosted } from '#src/use-cases/jobs/applicationStaleness.js';

export interface ApplicationDTO {
id: string;
Expand All @@ -18,6 +19,7 @@ export interface ApplicationDTO {
tags: string[];
createdAt: string;
updatedAt: string;
likelyGhosted: boolean;
}

export interface ApplicationConnectionDTO {
Expand Down Expand Up @@ -45,6 +47,7 @@ export class ApplicationMapper {
tags: app.tags,
createdAt: app.createdAt.toISOString(),
updatedAt: app.updatedAt.toISOString(),
likelyGhosted: isLikelyGhosted(app),
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ interface GetApplicationsPageInput {
search?: string;
cursor?: string;
limit?: number;
likelyGhosted?: boolean;
}

interface BulkUpdateInput {
Expand Down
7 changes: 6 additions & 1 deletion apps/api/src/use-cases/jobs/GetApplicationsPageUseCase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ export class GetApplicationsPageUseCase implements IGetApplicationsPageUseCase {

const { items, hasNextPage } = await this.deps.applicationRepository.findPageByUserId(
input.userId,
{ status: input.status, starred: input.starred, search: input.search },
{
status: input.status,
starred: input.starred,
search: input.search,
likelyGhosted: input.likelyGhosted,
},
{ cursor: input.cursor, limit },
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface GetApplicationsPageInput {
status?: ApplicationStatus;
starred?: boolean;
search?: string;
likelyGhosted?: boolean;
cursor?: string;
limit?: number;
}
Expand Down
16 changes: 16 additions & 0 deletions apps/api/src/use-cases/jobs/applicationStaleness.ts
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)
);
}
1 change: 1 addition & 0 deletions apps/api/src/use-cases/ports/IApplicationRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export interface FindApplicationsPageFilters {
status?: ApplicationStatus;
starred?: boolean;
search?: string;
likelyGhosted?: boolean;
}

export interface FindApplicationsPagePagination {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,11 @@ function AppCard({ app, isDragging }: { app: Application; isDragging?: boolean }
<div className="flex items-center justify-between mt-2">
{app.starred && <StarIcon size={11} className="text-yellow-400 fill-yellow-400" />}
<StatusBadge status={app.status as ApplicationStatus} />
{app.likelyGhosted && (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
Likely ghosted
</span>
)}
</div>
</Link>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { ApplicationStatus } from '#/graphql/generated/graphql';
const APPLICATIONS_QUERY = `
query BoardApplications {
applications {
id company role status location appliedAt starred createdAt
id company role status location appliedAt starred createdAt likelyGhosted
}
}
`;
Expand All @@ -19,6 +19,7 @@ export type BoardApplication = {
appliedAt?: string | null;
starred: boolean;
createdAt: string;
likelyGhosted: boolean;
};

// Kept in its own module (no dnd-kit/component imports) so the board route's
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { Route } from '../index';
import { applicationsPageQueryOptions, APPLICATION_STATUSES, type Application } from '../index';

function ApplicationsPage() {
const { status, starred } = Route.useSearch();
const { status, starred, likelyGhosted } = Route.useSearch();
const [searchInput, setSearchInput] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
Expand All @@ -44,7 +44,7 @@ function ApplicationsPage() {
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery(applicationsPageQueryOptions(status, starred, searchTerm));
} = useInfiniteQuery(applicationsPageQueryOptions(status, starred, searchTerm, likelyGhosted));

const apps = useMemo(
() => data?.pages.flatMap((page) => page.applicationsPage.items) ?? [],
Expand Down Expand Up @@ -151,6 +151,13 @@ function ApplicationsPage() {
<StarIcon size={11} className={starred ? 'fill-white' : ''} />
Starred
</Link>
<Link
to="/applications"
search={likelyGhosted ? {} : { likelyGhosted: true }}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${likelyGhosted ? 'bg-amber-500 text-white border-amber-500' : 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700 hover:border-amber-400'}`}
>
Likely ghosted
</Link>
Comment on lines +154 to +160

Copy link
Copy Markdown

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:

#!/bin/bash
set -euo pipefail

echo "== dependency versions =="
if [ -f package.json ]; then jq -r '.dependencies["`@tanstack/react-router`"], .devDependencies["`@tanstack/react-router`"]' package.json; fi
if [ -f pnpm-lock.yaml ]; then rg -n "`@tanstack/react-router`:(.*)" pnpm-lock.yaml | head -20 || true; fi
if [ -f yarn.lock ]; then rg -n "`@tanstack/react-router`@|`@tanstack/react-router`:" yarn.lock | head -20 || true; fi
if [ -f package-lock.json ]; then jq -r '.packages["node_modules/@tanstack/react-router"].version // empty, .dependencies["`@tanstack/react-router`"].version // empty' package-lock.json 2>/dev/null || true; fi

echo
echo "== target file outline/usages =="
fd -a 'ApplicationsPage\.tsx$' . | sed 's#^\./##'
file="$(fd 'ApplicationsPage\.tsx$' apps/web/src/routes | head -1 || true)"
if [ -n "${file:-}" ]; then
  wc -l "$file"
  ast-grep outline "$file" --view compact || true
  sed -n '1,220p' "$file"
fi

echo
echo "== likelyGhosted status starred search usages =="
rg -n "likelyGhosted|status|starred" apps/web/src/routes -g '*.tsx' -g '*.ts' || true

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 search property is the recommended way to modify search parameters while preserving existing ones [1][2]. When you provide a function to the search prop (in the Link component) or the search option (in navigate or useNavigate), 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 the search value, 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 search prop replaces the current search state. If status or starred is active, clicking the link removes those filters when it adds or removes likelyGhosted. Use the functional search updater and keep the previous parameters.

Proposed fix
-          search={likelyGhosted ? {} : { likelyGhosted: true }}
+          search={(prev) => {
+            if (likelyGhosted) {
+              const next = { ...prev };
+              delete next.likelyGhosted;
+              return next;
+            }
+            return { ...prev, likelyGhosted: true };
+          }}
📝 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.

Suggested change
<Link
to="/applications"
search={likelyGhosted ? {} : { likelyGhosted: true }}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${likelyGhosted ? 'bg-amber-500 text-white border-amber-500' : 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700 hover:border-amber-400'}`}
>
Likely ghosted
</Link>
<Link
to="/applications"
search={(prev) => {
if (likelyGhosted) {
const next = { ...prev };
delete next.likelyGhosted;
return next;
}
return { ...prev, likelyGhosted: true };
}}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${likelyGhosted ? 'bg-amber-500 text-white border-amber-500' : 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700 hover:border-amber-400'}`}
>
Likely ghosted
</Link>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/web/src/routes/_authenticated/applications/-components/ApplicationsPage.tsx`
around lines 154 - 160, Update the likelyGhosted Link’s search configuration in
ApplicationsPage so it uses the functional search updater, preserves all
existing query parameters such as status and starred, and only toggles the
likelyGhosted value based on the current state.

</div>

{isLoading ? (
Expand All @@ -170,7 +177,9 @@ function ApplicationsPage() {
? ` matching "${searchTerm}"`
: status
? ` with status "${status}"`
: ''}{' '}
: likelyGhosted
? ' likely ghosted'
: ''}{' '}
yet.
</p>
</div>
Expand Down Expand Up @@ -238,6 +247,11 @@ function ApplicationsPage() {
: new Date(app.createdAt).toLocaleDateString()}
</p>
<StatusBadge status={app.status} />
{app.likelyGhosted && (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
Likely ghosted
</span>
)}
</div>
</Link>
</div>
Expand Down
28 changes: 24 additions & 4 deletions apps/web/src/routes/_authenticated/applications/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Select likelyGhosted in APPLICATIONS_PAGE_QUERY.

Line 76 declares likelyGhosted as required. The items selection does not request this field. GraphQL therefore omits it from list results, and list cards cannot display the likely ghosted state.

Proposed fix
       items {
         id
         company
         role
         status
+        likelyGhosted
         location

Also applies to: 76-76

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/routes/_authenticated/applications/index.tsx` around lines 33 -
41, Update APPLICATIONS_PAGE_QUERY’s applicationsPage items selection to include
the likelyGhosted field, matching the required $likelyGhosted variable and
ensuring each application result exposes its likely ghosted state to list cards.

) {
hasNextPage
nextCursor
Expand Down Expand Up @@ -67,6 +73,7 @@ export type Application = {
followUpAt?: string | null;
tags: string[];
createdAt: string;
likelyGhosted: boolean;
};

export type ApplicationsPageResult = {
Expand All @@ -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) =>
Expand All @@ -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,
});
Loading