From e81b67e229518612dd3d04f072ceda980ccf7787 Mon Sep 17 00:00:00 2001 From: Nikola Petrov Date: Sun, 25 Oct 2026 22:32:41 +0100 Subject: [PATCH] Add star --- scrapper/README.md | 1 + scrapper/src/db.ts | 19 ++++++++++++---- scrapper/src/server.ts | 49 ++++++++++++++++++++++++++++++++++++------ 3 files changed, 59 insertions(+), 10 deletions(-) diff --git a/scrapper/README.md b/scrapper/README.md index 72368a7..d9b972e 100644 --- a/scrapper/README.md +++ b/scrapper/README.md @@ -73,6 +73,7 @@ WantedBy=multi-user.target ## UI - **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. - Search filters on title, company and tags. Recent scrape runs (and errors) are listed at the bottom. diff --git a/scrapper/src/db.ts b/scrapper/src/db.ts index d2784b1..e44a0fe 100644 --- a/scrapper/src/db.ts +++ b/scrapper/src/db.ts @@ -25,9 +25,13 @@ db.run(` first_seen_at TEXT NOT NULL, -- when the scraper first found it last_seen_at TEXT NOT NULL, -- last scrape that still listed it 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) ) `); +// 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(` CREATE TABLE IF NOT EXISTS scrape_runs ( id INTEGER PRIMARY KEY, @@ -53,6 +57,7 @@ export interface JobRow { first_seen_at: string; last_seen_at: string; seen_at: string | null; + starred_at: string | null; /** 0 if the job was missing from the source's latest successful scrape. */ 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[] { const where: string[] = []; const params: Record = {}; 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 === "starred") where.push("j.starred_at IS NOT NULL"); if (opts.source) { where.push("j.source = $source"); params.source = opts.source; @@ -142,18 +148,23 @@ export function listJobs(opts: { filter: Filter; source?: string; q?: string }): .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 .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) { 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[]) { const stmt = db.query("UPDATE jobs SET seen_at = ? WHERE id = ? AND seen_at IS NULL"); const now = new Date().toISOString(); diff --git a/scrapper/src/server.ts b/scrapper/src/server.ts index add84f1..cbce81f 100644 --- a/scrapper/src/server.ts +++ b/scrapper/src/server.ts @@ -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 { scrapers } from "./scrapers"; @@ -32,7 +43,7 @@ const sourceName = (id: string) => scrapers.find((s) => s.id === id)?.name ?? id function jobCard(j: JobRow) { const tags = JSON.parse(j.tags) as string[]; return ` -
  • +
  • ${j.seen_at ? "" : `NEW`} @@ -48,12 +59,13 @@ function jobCard(j: JobRow) {
    ${tags.length ? `
    ${tags.map((t) => `${esc(t)}`).join("")}
    ` : ""}
    +
  • `; } 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") : "new") as Filter; const source = url.searchParams.get("source") ?? ""; @@ -74,9 +86,9 @@ function page(url: URL) { Job Board