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
4 changes: 3 additions & 1 deletion src/server/routes/pages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -998,8 +998,9 @@ export function pageRoutes(): Hono<AppEnv> {
job_slug: string;
status: string;
created_at: string;
submitted_at: string | null;
}>(
`select j.title as job_title, j.slug as job_slug, a.status, a.created_at
`select j.title as job_title, j.slug as job_slug, a.status, a.created_at, a.submitted_at
from applications a join jobs j on j.id = a.job_id
where a.user_id = $1 order by a.created_at desc limit 50`,
[viewer.id],
Expand Down Expand Up @@ -1029,6 +1030,7 @@ export function pageRoutes(): Hono<AppEnv> {
jobSlug: row.job_slug,
status: row.status,
createdAt: row.created_at,
submittedAt: row.submitted_at,
}))}
updates={updates}
following={following}
Expand Down
20 changes: 16 additions & 4 deletions src/views/me.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Your side of the board: employers you can post under, jobs you have posted,
* applications you have sent, and your resumes.
* applications you have prepared or sent, and your resumes.
*/

import type { FC, PropsWithChildren } from 'hono/jsx';
Expand All @@ -20,7 +20,13 @@ export const MePage: FC<
orgs: Organisation[];
jobs: Job[];
resumes: Resume[];
applications: { jobTitle: string; jobSlug: string; status: string; createdAt: string }[];
applications: {
jobTitle: string;
jobSlug: string;
status: string;
createdAt: string;
submittedAt: string | null;
}[];
/** Your own updates, and who you follow. */
updates: Update[];
following: Following[];
Expand Down Expand Up @@ -186,7 +192,7 @@ export const MePage: FC<
</section>

<section class="stack">
<h2>Applications you have sent</h2>
<h2>Your applications</h2>
{applications.length === 0 ? (
<Empty>None yet.</Empty>
) : (
Expand All @@ -198,7 +204,13 @@ export const MePage: FC<
<h3 class="card-title">{application.jobTitle}</h3>
<Badge variant="outline">{application.status}</Badge>
</div>
<p class="small muted">Sent {ago(application.createdAt)}</p>
{application.submittedAt === null ? (
<p class="small muted">
Draft prepared {ago(application.createdAt)}. Not sent to the employer.
</p>
) : (
<p class="small muted">Sent {ago(application.submittedAt)}</p>
)}
</a>
</li>
))}
Expand Down
48 changes: 48 additions & 0 deletions test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1082,6 +1082,54 @@ describe('the API', { skip: reason === '' ? false : `no database: ${reason}` },
assert.equal(response.status, 404);
});

test('the candidate page distinguishes a draft from its later submission', async () => {
if (pool === null) return;
const { auth, slug } = await pipeline('Candidate history');
const { createSession, ensureUser } = await import('../dist/core/auth.js');
const stamp = `${Date.now()}${Math.random().toString(36).slice(2, 7)}`;
const email = `history+${stamp}@example.com`;
const candidate = await ensureUser(pool as never, email, 'Candidate');
const token = await createSession(pool as never, candidate.id, { label: 'web' });
const candidateAuth = { authorization: `Bearer ${token}` };
const browser = { accept: 'text/html', cookie: `aj_session=${token}` };

const response = await post(
`/api/v1/jobs/${slug}/apply`,
{ name: 'Candidate', email, cover: 'Please hold this.', submit: false },
candidateAuth,
);
assert.equal(response.status, 201);
const draft = (await response.json()) as { applicationId: string };
await pool.query(
`update applications set created_at = now() - interval '2 days' where id = $1`,
[draft.applicationId],
);

const held = await (await get('/me', browser)).text();
assert.match(held, /<h2>Your applications<\/h2>/);
assert.match(held, /Draft prepared 2 days ago/);
assert.match(held, /Not sent to the employer/);
assert.doesNotMatch(held, /Sent 2 days ago/);
const inbox = (await (await get(`/api/v1/jobs/${slug}/applications`, auth)).json()) as {
items: { id: string }[];
};
assert.ok(!inbox.items.some((entry) => entry.id === draft.applicationId));

const sent = await post(
`/api/v1/applications/${draft.applicationId}/submit`,
{},
candidateAuth,
);
assert.equal(sent.status, 200);
await pool.query(
`update applications set submitted_at = now() - interval '2 hours' where id = $1`,
[draft.applicationId],
);
const submitted = await (await get('/me', browser)).text();
assert.match(submitted, /Sent 2 hours ago/);
assert.doesNotMatch(submitted, /Sent 2 days ago|Not sent to the employer/);
});

test('a draft nobody sent cannot be decided on', async () => {
// An employer cannot see a draft, so an employer cannot reject one out
// from under the candidate who has not released it yet.
Expand Down
62 changes: 61 additions & 1 deletion test/views.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { ResumeEditor } from '../dist/views/me.js';
import { MePage, ResumeEditor } from '../dist/views/me.js';
import { PostJobPage } from '../dist/views/post.js';
import { DocsPage } from '../dist/views/docs.js';

Expand All @@ -26,6 +26,66 @@ const resume = {
updatedAt: '',
};

const renderApplications = (applications: Parameters<typeof MePage>[0]['applications']) =>
String(
MePage({
viewer: {
id: 'candidate',
email: 'candidate@example.test',
name: 'Candidate',
isAdmin: false,
viaToken: false,
},
orgs: [],
jobs: [],
resumes: [],
applications,
updates: [],
following: [],
candidateSlug: null,
updateMax: 500,
}),
);

test('an unsent application is visibly a private draft on the candidate page', (t) => {
t.mock.method(Date, 'now', () => Date.parse('2026-09-16T12:00:00Z'));
const html = renderApplications([
{
jobTitle: 'Draft role',
jobSlug: 'draft-role',
status: 'draft',
createdAt: '2026-09-14T12:00:00Z',
submittedAt: null,
},
]);

assert.match(html, /<h2>Your applications<\/h2>/);
const card = /<a[^>]+href="\/jobs\/draft-role"[\s\S]*?<\/a>/.exec(html)?.[0];
assert.ok(card, 'the candidate must still be able to see their draft');
assert.match(card, /Draft prepared 2 days ago/);
assert.match(card, /Not sent to the employer/);
assert.doesNotMatch(card, />Sent /);
});

test('a sent application shows its submission time, even when drafted earlier', (t) => {
t.mock.method(Date, 'now', () => Date.parse('2026-09-16T12:00:00Z'));
const html = renderApplications([
{
jobTitle: 'Sent role',
jobSlug: 'sent-role',
status: 'reviewing',
createdAt: '2026-09-14T12:00:00Z',
submittedAt: '2026-09-16T10:00:00Z',
},
]);

const card = /<a[^>]+href="\/jobs\/sent-role"[\s\S]*?<\/a>/.exec(html)?.[0];
assert.ok(card);
assert.match(card, />reviewing</);
assert.match(card, />Sent 2 hours ago</);
assert.doesNotMatch(card, /Sent 2 days ago|Not sent to the employer/);
});

test('the resume preview is below the editor, not squeezed into the sidebar', () => {
// The sidebar is 20rem. A resume rendered into 20rem is a column of two-word
// lines, which is what it was, and is unreadable. A document needs the width
Expand Down
Loading