This commit is contained in:
2026-10-25 22:32:41 +01:00
parent b50f269c41
commit e81b67e229
3 changed files with 59 additions and 10 deletions
+1
View File
@@ -73,6 +73,7 @@ WantedBy=multi-user.target
## UI ## UI
- **New / Seen / All** tabs. Click *Mark seen* to move a job to Seen (opening a job does not mark it). - **New / Seen / All** tabs. Click *Mark seen* to move a job to Seen (opening a job does not mark it).
- Click ☆ to star a job; starred jobs are listed under **Starred** (starring is independent of seen/new).
- **no longer listed** means the job was missing from that source's latest successful scrape. - **no longer listed** means the job was missing from that source's latest successful scrape.
- Search filters on title, company and tags. Recent scrape runs (and errors) are listed at the bottom. - Search filters on title, company and tags. Recent scrape runs (and errors) are listed at the bottom.
+15 -4
View File
@@ -25,9 +25,13 @@ db.run(`
first_seen_at TEXT NOT NULL, -- when the scraper first found it first_seen_at TEXT NOT NULL, -- when the scraper first found it
last_seen_at TEXT NOT NULL, -- last scrape that still listed it last_seen_at TEXT NOT NULL, -- last scrape that still listed it
seen_at TEXT, -- when you marked it as seen (NULL = new) seen_at TEXT, -- when you marked it as seen (NULL = new)
starred_at TEXT, -- when you starred it (NULL = not starred)
UNIQUE (source, external_id) UNIQUE (source, external_id)
) )
`); `);
// No migration system: add columns that older databases are missing.
const jobColumns = (db.query("PRAGMA table_info(jobs)").all() as { name: string }[]).map((c) => c.name);
if (!jobColumns.includes("starred_at")) db.run("ALTER TABLE jobs ADD COLUMN starred_at TEXT");
db.run(` db.run(`
CREATE TABLE IF NOT EXISTS scrape_runs ( CREATE TABLE IF NOT EXISTS scrape_runs (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
@@ -53,6 +57,7 @@ export interface JobRow {
first_seen_at: string; first_seen_at: string;
last_seen_at: string; last_seen_at: string;
seen_at: string | null; seen_at: string | null;
starred_at: string | null;
/** 0 if the job was missing from the source's latest successful scrape. */ /** 0 if the job was missing from the source's latest successful scrape. */
still_listed: number; still_listed: number;
} }
@@ -113,13 +118,14 @@ export function finishRun(id: number, result: { found?: number; newCount?: numbe
); );
} }
export type Filter = "new" | "seen" | "all"; export type Filter = "new" | "seen" | "starred" | "all";
export function listJobs(opts: { filter: Filter; source?: string; q?: string }): JobRow[] { export function listJobs(opts: { filter: Filter; source?: string; q?: string }): JobRow[] {
const where: string[] = []; const where: string[] = [];
const params: Record<string, string> = {}; const params: Record<string, string> = {};
if (opts.filter === "new") where.push("j.seen_at IS NULL"); if (opts.filter === "new") where.push("j.seen_at IS NULL");
if (opts.filter === "seen") where.push("j.seen_at IS NOT NULL"); if (opts.filter === "seen") where.push("j.seen_at IS NOT NULL");
if (opts.filter === "starred") where.push("j.starred_at IS NOT NULL");
if (opts.source) { if (opts.source) {
where.push("j.source = $source"); where.push("j.source = $source");
params.source = opts.source; params.source = opts.source;
@@ -142,18 +148,23 @@ export function listJobs(opts: { filter: Filter; source?: string; q?: string }):
.all(params) as JobRow[]; .all(params) as JobRow[];
} }
export function counts(): { new: number; seen: number; all: number } { export function counts(): { new: number; seen: number; starred: number; all: number } {
return db return db
.query( .query(
`SELECT SUM(seen_at IS NULL) AS new, SUM(seen_at IS NOT NULL) AS seen, COUNT(*) AS "all" FROM jobs`, `SELECT SUM(seen_at IS NULL) AS new, SUM(seen_at IS NOT NULL) AS seen, SUM(starred_at IS NOT NULL) AS starred,
COUNT(*) AS "all" FROM jobs`,
) )
.get() as { new: number; seen: number; all: number }; .get() as { new: number; seen: number; starred: number; all: number };
} }
export function setSeen(id: number, seen: boolean) { export function setSeen(id: number, seen: boolean) {
db.query("UPDATE jobs SET seen_at = ? WHERE id = ?").run(seen ? new Date().toISOString() : null, id); db.query("UPDATE jobs SET seen_at = ? WHERE id = ?").run(seen ? new Date().toISOString() : null, id);
} }
export function setStarred(id: number, starred: boolean) {
db.query("UPDATE jobs SET starred_at = ? WHERE id = ?").run(starred ? new Date().toISOString() : null, id);
}
export function markAllSeen(ids: number[]) { export function markAllSeen(ids: number[]) {
const stmt = db.query("UPDATE jobs SET seen_at = ? WHERE id = ? AND seen_at IS NULL"); const stmt = db.query("UPDATE jobs SET seen_at = ? WHERE id = ? AND seen_at IS NULL");
const now = new Date().toISOString(); const now = new Date().toISOString();
+43 -6
View File
@@ -1,4 +1,15 @@
import { counts, lastRunAt, listJobs, markAllSeen, recentRuns, setSeen, sources, type Filter, type JobRow } from "./db"; import {
counts,
lastRunAt,
listJobs,
markAllSeen,
recentRuns,
setSeen,
setStarred,
sources,
type Filter,
type JobRow,
} from "./db";
import { isScraping, runAllScrapers } from "./scrape"; import { isScraping, runAllScrapers } from "./scrape";
import { scrapers } from "./scrapers"; import { scrapers } from "./scrapers";
@@ -32,7 +43,7 @@ const sourceName = (id: string) => scrapers.find((s) => s.id === id)?.name ?? id
function jobCard(j: JobRow) { function jobCard(j: JobRow) {
const tags = JSON.parse(j.tags) as string[]; const tags = JSON.parse(j.tags) as string[];
return ` return `
<li class="job ${j.seen_at ? "seen" : "new"} ${j.still_listed ? "" : "gone"}" data-id="${j.id}"> <li class="job ${j.seen_at ? "seen" : "new"} ${j.starred_at ? "starred" : ""} ${j.still_listed ? "" : "gone"}" data-id="${j.id}">
<div class="main"> <div class="main">
<div class="title-row"> <div class="title-row">
${j.seen_at ? "" : `<span class="badge new-badge">NEW</span>`} ${j.seen_at ? "" : `<span class="badge new-badge">NEW</span>`}
@@ -48,12 +59,13 @@ function jobCard(j: JobRow) {
</div> </div>
${tags.length ? `<div class="tags">${tags.map((t) => `<span class="tag">${esc(t)}</span>`).join("")}</div>` : ""} ${tags.length ? `<div class="tags">${tags.map((t) => `<span class="tag">${esc(t)}</span>`).join("")}</div>` : ""}
</div> </div>
<button class="star" type="button" title="${j.starred_at ? "Unstar" : "Star"}" aria-pressed="${!!j.starred_at}">${j.starred_at ? "★" : "☆"}</button>
<button class="toggle" type="button">${j.seen_at ? "Mark new" : "Mark seen"}</button> <button class="toggle" type="button">${j.seen_at ? "Mark new" : "Mark seen"}</button>
</li>`; </li>`;
} }
function page(url: URL) { function page(url: URL) {
const filter = (["new", "seen", "all"].includes(url.searchParams.get("filter") ?? "") const filter = (["new", "seen", "starred", "all"].includes(url.searchParams.get("filter") ?? "")
? url.searchParams.get("filter") ? url.searchParams.get("filter")
: "new") as Filter; : "new") as Filter;
const source = url.searchParams.get("source") ?? ""; const source = url.searchParams.get("source") ?? "";
@@ -74,9 +86,9 @@ function page(url: URL) {
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Job Board</title> <title>Job Board</title>
<style> <style>
:root { --bg:#f6f7f9; --card:#fff; --fg:#1c1f24; --muted:#6b7280; --border:#e3e6ea; --accent:#2563eb; --new:#16a34a; --gone:#b45309; --tag:#eef1f5; } :root { --bg:#f6f7f9; --card:#fff; --fg:#1c1f24; --muted:#6b7280; --border:#e3e6ea; --accent:#2563eb; --new:#16a34a; --gone:#b45309; --tag:#eef1f5; --star:#ca8a04; }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { --bg:#111317; --card:#1a1d23; --fg:#e6e8eb; --muted:#9aa1ab; --border:#2a2e36; --accent:#60a5fa; --new:#4ade80; --gone:#fbbf24; --tag:#252a32; } :root { --bg:#111317; --card:#1a1d23; --fg:#e6e8eb; --muted:#9aa1ab; --border:#2a2e36; --accent:#60a5fa; --new:#4ade80; --gone:#fbbf24; --tag:#252a32; --star:#facc15; }
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--fg); font:15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; } body { margin:0; background:var(--bg); color:var(--fg); font:15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
@@ -111,6 +123,8 @@ function page(url: URL) {
.tags { display:flex; flex-wrap:wrap; gap:4px; margin-top:6px; } .tags { display:flex; flex-wrap:wrap; gap:4px; margin-top:6px; }
.tag { background:var(--tag); font-size:12px; padding:1px 8px; border-radius:999px; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .tag { background:var(--tag); font-size:12px; padding:1px 8px; border-radius:999px; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.toggle { flex-shrink:0; } .toggle { flex-shrink:0; }
.star { flex-shrink:0; font-size:18px; line-height:1; padding:4px 8px; color:var(--muted); }
.job.starred .star { color:var(--star); border-color:var(--star); }
.empty { text-align:center; color:var(--muted); padding:48px 0; } .empty { text-align:center; color:var(--muted); padding:48px 0; }
details { margin-top:32px; color:var(--muted); font-size:13px; } details { margin-top:32px; color:var(--muted); font-size:13px; }
table { border-collapse:collapse; width:100%; margin-top:8px; } table { border-collapse:collapse; width:100%; margin-top:8px; }
@@ -133,6 +147,7 @@ function page(url: URL) {
<nav class="tabs"> <nav class="tabs">
<a href="${link("new")}" class="${filter === "new" ? "active" : ""}">New (<span id="c-new">${c.new ?? 0}</span>)</a> <a href="${link("new")}" class="${filter === "new" ? "active" : ""}">New (<span id="c-new">${c.new ?? 0}</span>)</a>
<a href="${link("seen")}" class="${filter === "seen" ? "active" : ""}">Seen (<span id="c-seen">${c.seen ?? 0}</span>)</a> <a href="${link("seen")}" class="${filter === "seen" ? "active" : ""}">Seen (<span id="c-seen">${c.seen ?? 0}</span>)</a>
<a href="${link("starred")}" class="${filter === "starred" ? "active" : ""}">★ Starred (<span id="c-starred">${c.starred ?? 0}</span>)</a>
<a href="${link("all")}" class="${filter === "all" ? "active" : ""}">All (${c.all ?? 0})</a> <a href="${link("all")}" class="${filter === "all" ? "active" : ""}">All (${c.all ?? 0})</a>
</nav> </nav>
<form method="get"> <form method="get">
@@ -146,7 +161,7 @@ function page(url: URL) {
${filter !== "seen" && jobs.some((j) => !j.seen_at) ? `<button id="all-seen" type="button">Mark all shown as seen</button>` : ""} ${filter !== "seen" && jobs.some((j) => !j.seen_at) ? `<button id="all-seen" type="button">Mark all shown as seen</button>` : ""}
</div> </div>
${jobs.length ? `<ul class="jobs">${jobs.map(jobCard).join("")}</ul>` : `<p class="empty">${filter === "new" ? "No new jobs. 🎉" : "No jobs."}</p>`} ${jobs.length ? `<ul class="jobs">${jobs.map(jobCard).join("")}</ul>` : `<p class="empty">${filter === "new" ? "No new jobs. 🎉" : filter === "starred" ? "No starred jobs." : "No jobs."}</p>`}
<details> <details>
<summary>Recent scrape runs</summary> <summary>Recent scrape runs</summary>
@@ -173,8 +188,23 @@ function page(url: URL) {
li.querySelector(".toggle").textContent = seen ? "Mark new" : "Mark seen"; li.querySelector(".toggle").textContent = seen ? "Mark new" : "Mark seen";
if ((filter === "new" && seen) || (filter === "seen" && !seen)) li.remove(); if ((filter === "new" && seen) || (filter === "seen" && !seen)) li.remove();
} }
async function setStarred(li, starred) {
const res = await fetch("/api/jobs/" + li.dataset.id + "/star", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ starred }), keepalive: true,
});
const c = await res.json();
document.getElementById("c-starred").textContent = c.starred ?? 0;
li.classList.toggle("starred", starred);
const btn = li.querySelector(".star");
btn.textContent = starred ? "★" : "☆";
btn.title = starred ? "Unstar" : "Star";
btn.setAttribute("aria-pressed", String(starred));
if (filter === "starred" && !starred) li.remove();
}
document.querySelectorAll(".job").forEach((li) => { document.querySelectorAll(".job").forEach((li) => {
li.querySelector(".toggle").addEventListener("click", () => setSeen(li, !li.classList.contains("seen"))); li.querySelector(".toggle").addEventListener("click", () => setSeen(li, !li.classList.contains("seen")));
li.querySelector(".star").addEventListener("click", () => setStarred(li, !li.classList.contains("starred")));
}); });
document.getElementById("all-seen")?.addEventListener("click", async () => { document.getElementById("all-seen")?.addEventListener("click", async () => {
const ids = [...document.querySelectorAll(".job.new")].map((li) => Number(li.dataset.id)); const ids = [...document.querySelectorAll(".job.new")].map((li) => Number(li.dataset.id));
@@ -209,6 +239,13 @@ Bun.serve({
return Response.json(counts()); return Response.json(counts());
}, },
}, },
"/api/jobs/:id/star": {
POST: async (req) => {
const { starred } = (await req.json()) as { starred: boolean };
setStarred(Number(req.params.id), !!starred);
return Response.json(counts());
},
},
"/api/jobs/:id/seen": { "/api/jobs/:id/seen": {
POST: async (req) => { POST: async (req) => {
const { seen } = (await req.json()) as { seen: boolean }; const { seen } = (await req.json()) as { seen: boolean };