openvibers/openvibe.work Commits
Work: alerts through OpenVibe.Watch (every search is an Atom feed; Get alerts opens Watch with it filled in) (#38)
Changes
@@@ -114,6 +114,7 @@ export/deletion routes, pushed with this service's own client-credentials token| `GET /api/v1/ping` | anyone | `{ ok: true, service: "work" }` || `GET /api/v1/jobs` | anyone | Search: `q`, `remote`, `type`, `location`, `limit` (to 100), `cursor` (the `next` of the previous page). Newest first, 30 a page, with facets || `GET /api/v1/jobs/:id` | anyone | One listing: the board's fields, the excerpt, the source, and `url` — the original to apply on |++| `GET /jobs.atom` | anyone | The same search as an Atom feed (`q`, `remote`, `type`, `location`): the newest 25 listings, each linking its page here, cached 5 minutes. The search page links it in its head, and **Get alerts** (on the search page and on each saved search) opens OpenVibe.Watch's feed form with it filled in, so Watch tells you, or runs your agent, when a listing that fits appears || `GET /api/v1/sources` | anyone | The boards, each one's terms as applied, its listing count and its last fetch || `GET /api/v1/saved-searches` | a person | Your saved searches, each with `new_count` — new since you last looked || `POST /api/v1/saved-searches` | a person | `{ q?, remote?, type?, location? }` (at least one). 201 with the saved search |@@@ -262,8 +263,6 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).- **Approval at each step**: the agent help is one-shot today — Work posts a task and hands back its link. An approvalloop (the agent proposing an action and the person approving it) comes with the rest of the agent pillar.--- **Alerts**: being told when a listing that fits appears — that comes through OpenVibe.Watch. Today a saved search is-- a search you do not have to type again, plus a count of what is new.- More boards, and a per-source schedule finer than "the same six hours for everyone".---@@@ -76,6 +76,8 @@ button.sc-btn.sc-primary { border: 0; }@media (max-width: 560px) { .job-item { grid-template-columns: 1fr; } .job-logo { display: none; } }@media (prefers-reduced-motion: reduce) { .job-item { transition: none; } }.job-pager { display: flex; gap: 12px; align-items: center; margin: 18px 0 8px; }++.job-alert, .saved-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin: 10px 0 14px; }++.saved-actions { margin: 6px 0 0; }.job-facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 16px; margin: 16px 0; font-size: .92rem; }.job-facts dt { color: var(--text-secondary, #96a7c2); }.job-facts dd { margin: 0; overflow-wrap: anywhere; }@@@ -155,6 +155,45 @@ ${raw(showcase.cta({ title: 'Prefer the API?', text: 'The same search, the sourc});});++ // ── Alerts: a search as an Atom feed, followed by OpenVibe.Watch ──++ const WATCH_NEW = 'https://openvibe.watch/watches/new';++ const describe = (filters) => [filters.q ? `“${filters.q}”` : '', filters.remote === 'true' ? 'remote' : filters.remote === 'false' ? 'on-site' : '', filters.type ? filters.type : '', filters.location ? `in ${filters.location}` : ''].filter(Boolean).join(' ');++ const feedPath = (filters) => withFilters(filters, { cursor: null }, '/jobs.atom');++ /** Watch's new-watch form, filled in to follow one search's feed; the person reviews and creates it on Watch. */++ function alertHref(filters) {++ const described = describe(filters);++ const q = new URLSearchParams({ template: 'feed', url: `${config.baseUrl}${feedPath(filters)}`, name: `Jobs: ${described || 'every new listing'}`.slice(0, 80) });++ return `${WATCH_NEW}?${q}`;++ }++ const xml = (v) => String(v == null ? '' : v).replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c]))++ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '');++ const isoOf = (v) => { const t = Date.parse(v); return Number.isFinite(t) ? new Date(t).toISOString() : null; };++++ r.get('/jobs.atom', async (req, res) => {++ const filters = store.filtersOf(req.query);++ const p = await store.search(s, filters, { limit: 25 });++ const described = describe(filters);++ const self = `${config.baseUrl}${feedPath(filters)}`;++ const entries = p.rows.map((row) => {++ const l = listing.meta(row);++ const at = isoOf(l.posted_at) || isoOf(row.fetched_at) || new Date().toISOString();++ const where = [l.location, l.remote ? 'Remote' : '', l.job_type].filter(Boolean).join(' · ');++ return `<entry><id>${xml(`${config.baseUrl}/jobs/${row.id}`)}</id><title>${xml(`${l.title} at ${l.company}`)}</title>`++ + `<link rel="alternate" type="text/html" href="${xml(`${config.baseUrl}/jobs/${row.id}`)}"/><updated>${at}</updated>`++ + `<author><name>${xml(sourceOf(row).name)}</name></author>`++ + `<summary>${xml([where, listing.plainExcerpt(row).slice(0, 300)].filter(Boolean).join(' — '))}</summary></entry>`;++ });++ const updated = p.rows.length ? (isoOf(listing.meta(p.rows[0]).posted_at) || isoOf(p.rows[0].fetched_at)) : null;++ res.set('Cache-Control', 'public, max-age=300');++ res.type('application/atom+xml; charset=utf-8').send(`<?xml version="1.0" encoding="utf-8"?>++<feed xmlns="http://www.w3.org/2005/Atom"><id>${xml(self)}</id><title>${xml(`OpenVibe.Work: ${described ? `jobs ${described}` : 'new jobs'}`)}</title>`++ + `<link rel="self" href="${xml(self)}"/><link rel="alternate" type="text/html" href="${xml(`${config.baseUrl}${withFilters(filters, { cursor: null }, '/jobs')}`)}"/>`++ + `<updated>${updated || new Date().toISOString()}</updated><generator>OpenVibe.Work</generator>++${entries.join('\n')}++</feed>++`);++ });++// ── Search ───────────────────────────────────────────────r.get('/jobs', async (req, res) => {const filters = store.filtersOf(req.query);@@@ -162,15 +201,17 @@ ${raw(showcase.cta({ title: 'Prefer the API?', text: 'The same search, the sourc// A cursor we did not write is not a position in the result set; start from the top rather than guess.const p = await store.search(s, filters, { cursor });const nextUrl = p.next ? withFilters(filters, { cursor: store.encodeCursor(p.next) }, '/jobs') : null;-- const described = [filters.q ? `“${filters.q}”` : '', filters.remote === 'true' ? 'remote' : filters.remote === 'false' ? 'on-site' : '', filters.type ? filters.type : '', filters.location ? `in ${filters.location}` : ''].filter(Boolean).join(' ');++ const described = describe(filters);page(req, res, {index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,++ feed: { title: described ? `Jobs ${described}` : 'New jobs', href: feedPath(filters) },title: described ? `Jobs: ${described}` : 'Jobs',description: `Job listings from open boards${described ? ` matching ${described}` : ''}, newest first, each with the source it came from and a link to the original listing.`,crumbs: [{ label: 'Jobs' }],styles: [showcase.STYLESHEET],body: html`<h1>Jobs</h1>${filterForm(filters, { facets: p.facets })}++<p class="job-alert"><a class="sc-btn" href="${alertHref(filters)}">Get alerts for this search</a> <span class="muted small">OpenVibe.Watch follows <a href="${feedPath(filters)}">its feed</a> and tells you when a new listing appears.</span></p>${listing.listingList(p.rows, { sourceOf, empty: 'No listings match this search. Try fewer words, or clear a filter and search again.' })}<nav class="job-pager" aria-label="Pages">${cursor ? html`<a class="sc-btn" href="${withFilters(filters, { cursor: null }, '/jobs')}">First page</a>` : ''}@@@ -270,10 +311,10 @@ ${l.posted_at ? html`<dt>Posted</dt><dd>${time(l.posted_at)}</dd>` : ''}${withCounts.length ? html`<ul class="saved-list">${withCounts.map(({ row, count }) => html`<li class="saved-item"><p class="saved-query"><a href="${store.toSavedWire(row).search_path}">${describeSaved(row)}</a> <span class="badge${count ? ' ok' : ''}">${count ? `${count} new` : 'nothing new'}</span></p><p class="muted small">Saved ${time(row.created_at)} · last looked ${row.last_seen_at ? time(row.last_seen_at) : 'never'}</p>--<form method="post" action="/saved/${row.id}/delete" class="inline"><button class="sc-btn" type="submit">Forget it</button></form>++<div class="saved-actions"><a class="sc-btn" href="${alertHref(store.filtersOf({ q: row.query, remote: row.remote, type: row.type, location: row.location }))}">Get alerts</a> <form method="post" action="/saved/${row.id}/delete" class="inline"><button class="sc-btn" type="submit">Forget it</button></form></div></li>`)}</ul>` : ''}${savedForm(req, initial)}--<p class="muted small">Alerts — being told when a listing that fits appears — come later, through OpenVibe.Watch. For now a saved search is a search you do not have to type again, and a count of what is new.</p>++<p class="muted small">Get alerts opens OpenVibe.Watch with the search's feed filled in: Watch checks it and tells you (or runs your agent) when a listing that fits appears. Every search has its feed at <code>/jobs.atom</code> with the same filters.</p><p class="muted small">In code: <code>GET /api/v1/saved-searches</code>.</p>`,});});@@@ -93,6 +93,7 @@ function renderPage(o) {head: `<meta name="referrer" content="${o.noReferrer ? 'no-referrer' : 'strict-origin-when-cross-origin'}">${appIcon.headTags({ site: 'work' })}<link rel="stylesheet" href="${asset('css/app.css')}">++${o.feed ? `<link rel="alternate" type="application/atom+xml" title="${esc(o.feed.title)}" href="${esc(o.feed.href)}">` : ''}${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}${(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`).join('\n')}${render(scripts)}@@@ -91,6 +91,31 @@ const first = async (t, path) => (await t.get(path)).json();assert.match(html, /href="\/jobs\?[^"]*remote=true"/, 'a facet link is missing');});++ await check('a search is an Atom feed with the same filters, and its page offers alerts through OpenVibe.Watch', async () => {++ const r = await t.get('/jobs.atom?q=devops&remote=true');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type'), /^application\/atom\+xml/);++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300');++ assert.match(r.text, /^<\?xml version="1\.0" encoding="utf-8"\?>\n<feed xmlns="http:\/\/www\.w3\.org\/2005\/Atom">/);++ const entries = [...r.text.matchAll(/<entry>([\s\S]*?)<\/entry>/g)].map((m) => m[1]);++ assert.strictEqual(entries.length, 1, 'only the matching listing');++ assert.match(entries[0], /<title>DevOps Engineer at Skyward Cloud<\/title>/);++ assert.match(entries[0], /<link rel="alternate" type="text\/html" href="[^"]+\/jobs\/[^"]+"\/>/);++ assert.match(entries[0], /<author><name>RemoteOK<\/name><\/author>/);++ assert.match(r.text, /<link rel="self" href="[^"]*\/jobs\.atom\?q=devops&remote=true"\/>/);++ const all = await t.get('/jobs.atom');++ const ids = [...all.text.matchAll(/<updated>([^<]+)<\/updated><author>/g)].map((m) => m[1]);++ assert.ok(ids.length >= 3 && ids.join() === [...ids].sort().reverse().join(), 'every listing, newest first');++ // The page links its feed, and Get alerts opens Watch's feed form with it filled in.++ const page = (await t.get('/jobs?q=devops&remote=true')).text;++ assert.ok(page.includes('<link rel="alternate" type="application/atom+xml" title="Jobs “devops” remote" href="/jobs.atom?q=devops&remote=true">'), 'the feed link in the head');++ const alert = /href="(https:\/\/openvibe\.watch\/watches\/new\?[^"]+)">Get alerts for this search/.exec(page);++ assert.ok(alert, 'the alerts link');++ const u = new URL(alert[1].replace(/&/g, '&'));++ assert.deepStrictEqual([u.searchParams.get('template'), new URL(u.searchParams.get('url')).pathname + new URL(u.searchParams.get('url')).search, u.searchParams.get('name')],++ ['feed', '/jobs.atom?q=devops&remote=true', 'Jobs: “devops” remote']);++ });++await check('one listing: the board\'s fields, the excerpt, the source and "read the full listing on" link', async () => {const job = (await first(t, '/api/v1/jobs?q=Platform Engineer')).jobs[0];const r = await t.get(`/jobs/${job.id}`);@@@ -84,6 +84,12 @@ const { boot, check, done } = require('./helpers/boot');const page = await t.get('/saved', { as: kim });assert.strictEqual(page.status, 200);assert.match(page.text, /1 new/);++ // Each saved search offers alerts: Watch's feed form with that search's feed filled in.++ const alert = /href="(https:\/\/openvibe\.watch\/watches\/new\?[^"]+)">Get alerts</.exec(page.text);++ assert.ok(alert, 'a Get alerts link');++ const u = new URL(alert[1].replace(/&/g, '&'));++ assert.strictEqual(u.searchParams.get('template'), 'feed');++ assert.match(new URL(u.searchParams.get('url')).pathname, /^\/jobs\.atom$/);assert.strictEqual((await t.get('/api/v1/saved-searches', { as: kim })).json().saved_searches.find((x) => x.id === saved.id).new_count, 0, 'opening /saved did not clear the count');assert.strictEqual((await t.get('/saved', { as: kim })).text.includes('nothing new'), true);});
@@ -114,6 +114,7 @@ export/deletion routes, pushed with this service's own client-credentials token
@@ -114,6 +114,7 @@ export/deletion routes, pushed with this service's own client-credentials token
| `GET /api/v1/ping` | anyone | `{ ok: true, service: "work" }` |
| `GET /api/v1/ping` | anyone | `{ ok: true, service: "work" }` |
| `GET /api/v1/jobs` | anyone | Search: `q`, `remote`, `type`, `location`, `limit` (to 100), `cursor` (the `next` of the previous page). Newest first, 30 a page, with facets |
| `GET /api/v1/jobs` | anyone | Search: `q`, `remote`, `type`, `location`, `limit` (to 100), `cursor` (the `next` of the previous page). Newest first, 30 a page, with facets |
| `GET /api/v1/jobs/:id` | anyone | One listing: the board's fields, the excerpt, the source, and `url` — the original to apply on |
| `GET /api/v1/jobs/:id` | anyone | One listing: the board's fields, the excerpt, the source, and `url` — the original to apply on |
+| `GET /jobs.atom` | anyone | The same search as an Atom feed (`q`, `remote`, `type`, `location`): the newest 25 listings, each linking its page here, cached 5 minutes. The search page links it in its head, and **Get alerts** (on the search page and on each saved search) opens OpenVibe.Watch's feed form with it filled in, so Watch tells you, or runs your agent, when a listing that fits appears |
| `GET /api/v1/sources` | anyone | The boards, each one's terms as applied, its listing count and its last fetch |
| `GET /api/v1/sources` | anyone | The boards, each one's terms as applied, its listing count and its last fetch |
| `GET /api/v1/saved-searches` | a person | Your saved searches, each with `new_count` — new since you last looked |
| `GET /api/v1/saved-searches` | a person | Your saved searches, each with `new_count` — new since you last looked |
| `POST /api/v1/saved-searches` | a person | `{ q?, remote?, type?, location? }` (at least one). 201 with the saved search |
| `POST /api/v1/saved-searches` | a person | `{ q?, remote?, type?, location? }` (at least one). 201 with the saved search |
@@ -262,8 +263,6 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
@@ -262,8 +263,6 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
- **Approval at each step**: the agent help is one-shot today — Work posts a task and hands back its link. An approval
- **Approval at each step**: the agent help is one-shot today — Work posts a task and hands back its link. An approval
loop (the agent proposing an action and the person approving it) comes with the rest of the agent pillar.
loop (the agent proposing an action and the person approving it) comes with the rest of the agent pillar.
-- **Alerts**: being told when a listing that fits appears — that comes through OpenVibe.Watch. Today a saved search is
- a search you do not have to type again, plus a count of what is new.
- More boards, and a per-source schedule finer than "the same six hours for everyone".
- More boards, and a per-source schedule finer than "the same six hours for everyone".
---
---
@@ -76,6 +76,8 @@ button.sc-btn.sc-primary { border: 0; }
@@ -76,6 +76,8 @@ button.sc-btn.sc-primary { border: 0; }
@media (max-width: 560px) { .job-item { grid-template-columns: 1fr; } .job-logo { display: none; } }
@media (max-width: 560px) { .job-item { grid-template-columns: 1fr; } .job-logo { display: none; } }
@media (prefers-reduced-motion: reduce) { .job-item { transition: none; } }
@media (prefers-reduced-motion: reduce) { .job-item { transition: none; } }
.job-pager { display: flex; gap: 12px; align-items: center; margin: 18px 0 8px; }
.job-pager { display: flex; gap: 12px; align-items: center; margin: 18px 0 8px; }
+.job-alert, .saved-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin: 10px 0 14px; }
+.saved-actions { margin: 6px 0 0; }
.job-facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 16px; margin: 16px 0; font-size: .92rem; }
.job-facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 16px; margin: 16px 0; font-size: .92rem; }
.job-facts dt { color: var(--text-secondary, #96a7c2); }
.job-facts dt { color: var(--text-secondary, #96a7c2); }
.job-facts dd { margin: 0; overflow-wrap: anywhere; }
.job-facts dd { margin: 0; overflow-wrap: anywhere; }
@@ -155,6 +155,45 @@ ${raw(showcase.cta({ title: 'Prefer the API?', text: 'The same search, the sourc
@@ -155,6 +155,45 @@ ${raw(showcase.cta({ title: 'Prefer the API?', text: 'The same search, the sourc
});
});
});
});
+ // ── Alerts: a search as an Atom feed, followed by OpenVibe.Watch ──
+ const WATCH_NEW = 'https://openvibe.watch/watches/new';
+ const describe = (filters) => [filters.q ? `“${filters.q}”` : '', filters.remote === 'true' ? 'remote' : filters.remote === 'false' ? 'on-site' : '', filters.type ? filters.type : '', filters.location ? `in ${filters.location}` : ''].filter(Boolean).join(' ');
+ const feedPath = (filters) => withFilters(filters, { cursor: null }, '/jobs.atom');
+ /** Watch's new-watch form, filled in to follow one search's feed; the person reviews and creates it on Watch. */
+ function alertHref(filters) {
+ const described = describe(filters);
+ const q = new URLSearchParams({ template: 'feed', url: `${config.baseUrl}${feedPath(filters)}`, name: `Jobs: ${described || 'every new listing'}`.slice(0, 80) });
+ return `${WATCH_NEW}?${q}`;
+ }
+ const xml = (v) => String(v == null ? '' : v).replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c]))
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '');
+ const isoOf = (v) => { const t = Date.parse(v); return Number.isFinite(t) ? new Date(t).toISOString() : null; };
+
+ r.get('/jobs.atom', async (req, res) => {
+ const filters = store.filtersOf(req.query);
+ const p = await store.search(s, filters, { limit: 25 });
+ const described = describe(filters);
+ const self = `${config.baseUrl}${feedPath(filters)}`;
+ const entries = p.rows.map((row) => {
+ const l = listing.meta(row);
+ const at = isoOf(l.posted_at) || isoOf(row.fetched_at) || new Date().toISOString();
+ const where = [l.location, l.remote ? 'Remote' : '', l.job_type].filter(Boolean).join(' · ');
+ return `<entry><id>${xml(`${config.baseUrl}/jobs/${row.id}`)}</id><title>${xml(`${l.title} at ${l.company}`)}</title>`
+ + `<link rel="alternate" type="text/html" href="${xml(`${config.baseUrl}/jobs/${row.id}`)}"/><updated>${at}</updated>`
+ + `<author><name>${xml(sourceOf(row).name)}</name></author>`
+ + `<summary>${xml([where, listing.plainExcerpt(row).slice(0, 300)].filter(Boolean).join(' — '))}</summary></entry>`;
+ });
+ const updated = p.rows.length ? (isoOf(listing.meta(p.rows[0]).posted_at) || isoOf(p.rows[0].fetched_at)) : null;
+ res.set('Cache-Control', 'public, max-age=300');
+ res.type('application/atom+xml; charset=utf-8').send(`<?xml version="1.0" encoding="utf-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom"><id>${xml(self)}</id><title>${xml(`OpenVibe.Work: ${described ? `jobs ${described}` : 'new jobs'}`)}</title>`
+ + `<link rel="self" href="${xml(self)}"/><link rel="alternate" type="text/html" href="${xml(`${config.baseUrl}${withFilters(filters, { cursor: null }, '/jobs')}`)}"/>`
+ + `<updated>${updated || new Date().toISOString()}</updated><generator>OpenVibe.Work</generator>
+${entries.join('\n')}
+</feed>
+`);
+ });
+
// ── Search ───────────────────────────────────────────────
// ── Search ───────────────────────────────────────────────
r.get('/jobs', async (req, res) => {
r.get('/jobs', async (req, res) => {
const filters = store.filtersOf(req.query);
const filters = store.filtersOf(req.query);
@@ -162,15 +201,17 @@ ${raw(showcase.cta({ title: 'Prefer the API?', text: 'The same search, the sourc
@@ -162,15 +201,17 @@ ${raw(showcase.cta({ title: 'Prefer the API?', text: 'The same search, the sourc
// A cursor we did not write is not a position in the result set; start from the top rather than guess.
// A cursor we did not write is not a position in the result set; start from the top rather than guess.
const p = await store.search(s, filters, { cursor });
const p = await store.search(s, filters, { cursor });
const nextUrl = p.next ? withFilters(filters, { cursor: store.encodeCursor(p.next) }, '/jobs') : null;
const nextUrl = p.next ? withFilters(filters, { cursor: store.encodeCursor(p.next) }, '/jobs') : null;
- const described = [filters.q ? `“${filters.q}”` : '', filters.remote === 'true' ? 'remote' : filters.remote === 'false' ? 'on-site' : '', filters.type ? filters.type : '', filters.location ? `in ${filters.location}` : ''].filter(Boolean).join(' ');
+ const described = describe(filters);
page(req, res, {
page(req, res, {
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
+ feed: { title: described ? `Jobs ${described}` : 'New jobs', href: feedPath(filters) },
title: described ? `Jobs: ${described}` : 'Jobs',
title: described ? `Jobs: ${described}` : 'Jobs',
description: `Job listings from open boards${described ? ` matching ${described}` : ''}, newest first, each with the source it came from and a link to the original listing.`,
description: `Job listings from open boards${described ? ` matching ${described}` : ''}, newest first, each with the source it came from and a link to the original listing.`,
crumbs: [{ label: 'Jobs' }],
crumbs: [{ label: 'Jobs' }],
styles: [showcase.STYLESHEET],
styles: [showcase.STYLESHEET],
body: html`<h1>Jobs</h1>
body: html`<h1>Jobs</h1>
${filterForm(filters, { facets: p.facets })}
${filterForm(filters, { facets: p.facets })}
+<p class="job-alert"><a class="sc-btn" href="${alertHref(filters)}">Get alerts for this search</a> <span class="muted small">OpenVibe.Watch follows <a href="${feedPath(filters)}">its feed</a> and tells you when a new listing appears.</span></p>
${listing.listingList(p.rows, { sourceOf, empty: 'No listings match this search. Try fewer words, or clear a filter and search again.' })}
${listing.listingList(p.rows, { sourceOf, empty: 'No listings match this search. Try fewer words, or clear a filter and search again.' })}
<nav class="job-pager" aria-label="Pages">
<nav class="job-pager" aria-label="Pages">
${cursor ? html`<a class="sc-btn" href="${withFilters(filters, { cursor: null }, '/jobs')}">First page</a>` : ''}
${cursor ? html`<a class="sc-btn" href="${withFilters(filters, { cursor: null }, '/jobs')}">First page</a>` : ''}
@@ -270,10 +311,10 @@ ${l.posted_at ? html`<dt>Posted</dt><dd>${time(l.posted_at)}</dd>` : ''}
@@ -270,10 +311,10 @@ ${l.posted_at ? html`<dt>Posted</dt><dd>${time(l.posted_at)}</dd>` : ''}
${withCounts.length ? html`<ul class="saved-list">${withCounts.map(({ row, count }) => html`<li class="saved-item">
${withCounts.length ? html`<ul class="saved-list">${withCounts.map(({ row, count }) => html`<li class="saved-item">
<p class="saved-query"><a href="${store.toSavedWire(row).search_path}">${describeSaved(row)}</a> <span class="badge${count ? ' ok' : ''}">${count ? `${count} new` : 'nothing new'}</span></p>
<p class="saved-query"><a href="${store.toSavedWire(row).search_path}">${describeSaved(row)}</a> <span class="badge${count ? ' ok' : ''}">${count ? `${count} new` : 'nothing new'}</span></p>
<p class="muted small">Saved ${time(row.created_at)} · last looked ${row.last_seen_at ? time(row.last_seen_at) : 'never'}</p>
<p class="muted small">Saved ${time(row.created_at)} · last looked ${row.last_seen_at ? time(row.last_seen_at) : 'never'}</p>
-<form method="post" action="/saved/${row.id}/delete" class="inline"><button class="sc-btn" type="submit">Forget it</button></form>
+<div class="saved-actions"><a class="sc-btn" href="${alertHref(store.filtersOf({ q: row.query, remote: row.remote, type: row.type, location: row.location }))}">Get alerts</a> <form method="post" action="/saved/${row.id}/delete" class="inline"><button class="sc-btn" type="submit">Forget it</button></form></div>
</li>`)}</ul>` : ''}
</li>`)}</ul>` : ''}
${savedForm(req, initial)}
${savedForm(req, initial)}
-<p class="muted small">Alerts — being told when a listing that fits appears — come later, through OpenVibe.Watch. For now a saved search is a search you do not have to type again, and a count of what is new.</p>
+<p class="muted small">Get alerts opens OpenVibe.Watch with the search's feed filled in: Watch checks it and tells you (or runs your agent) when a listing that fits appears. Every search has its feed at <code>/jobs.atom</code> with the same filters.</p>
<p class="muted small">In code: <code>GET /api/v1/saved-searches</code>.</p>`,
<p class="muted small">In code: <code>GET /api/v1/saved-searches</code>.</p>`,
});
});
});
});
@@ -93,6 +93,7 @@ function renderPage(o) {
@@ -93,6 +93,7 @@ function renderPage(o) {
head: `<meta name="referrer" content="${o.noReferrer ? 'no-referrer' : 'strict-origin-when-cross-origin'}">
head: `<meta name="referrer" content="${o.noReferrer ? 'no-referrer' : 'strict-origin-when-cross-origin'}">
${appIcon.headTags({ site: 'work' })}
${appIcon.headTags({ site: 'work' })}
<link rel="stylesheet" href="${asset('css/app.css')}">
<link rel="stylesheet" href="${asset('css/app.css')}">
+${o.feed ? `<link rel="alternate" type="application/atom+xml" title="${esc(o.feed.title)}" href="${esc(o.feed.href)}">` : ''}
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
${(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`).join('\n')}
${(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`).join('\n')}
${render(scripts)}
${render(scripts)}
@@ -91,6 +91,31 @@ const first = async (t, path) => (await t.get(path)).json();
@@ -91,6 +91,31 @@ const first = async (t, path) => (await t.get(path)).json();
assert.match(html, /href="\/jobs\?[^"]*remote=true"/, 'a facet link is missing');
assert.match(html, /href="\/jobs\?[^"]*remote=true"/, 'a facet link is missing');
});
});
+ await check('a search is an Atom feed with the same filters, and its page offers alerts through OpenVibe.Watch', async () => {
+ const r = await t.get('/jobs.atom?q=devops&remote=true');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type'), /^application\/atom\+xml/);
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300');
+ assert.match(r.text, /^<\?xml version="1\.0" encoding="utf-8"\?>\n<feed xmlns="http:\/\/www\.w3\.org\/2005\/Atom">/);
+ const entries = [...r.text.matchAll(/<entry>([\s\S]*?)<\/entry>/g)].map((m) => m[1]);
+ assert.strictEqual(entries.length, 1, 'only the matching listing');
+ assert.match(entries[0], /<title>DevOps Engineer at Skyward Cloud<\/title>/);
+ assert.match(entries[0], /<link rel="alternate" type="text\/html" href="[^"]+\/jobs\/[^"]+"\/>/);
+ assert.match(entries[0], /<author><name>RemoteOK<\/name><\/author>/);
+ assert.match(r.text, /<link rel="self" href="[^"]*\/jobs\.atom\?q=devops&remote=true"\/>/);
+ const all = await t.get('/jobs.atom');
+ const ids = [...all.text.matchAll(/<updated>([^<]+)<\/updated><author>/g)].map((m) => m[1]);
+ assert.ok(ids.length >= 3 && ids.join() === [...ids].sort().reverse().join(), 'every listing, newest first');
+ // The page links its feed, and Get alerts opens Watch's feed form with it filled in.
+ const page = (await t.get('/jobs?q=devops&remote=true')).text;
+ assert.ok(page.includes('<link rel="alternate" type="application/atom+xml" title="Jobs “devops” remote" href="/jobs.atom?q=devops&remote=true">'), 'the feed link in the head');
+ const alert = /href="(https:\/\/openvibe\.watch\/watches\/new\?[^"]+)">Get alerts for this search/.exec(page);
+ assert.ok(alert, 'the alerts link');
+ const u = new URL(alert[1].replace(/&/g, '&'));
+ assert.deepStrictEqual([u.searchParams.get('template'), new URL(u.searchParams.get('url')).pathname + new URL(u.searchParams.get('url')).search, u.searchParams.get('name')],
+ ['feed', '/jobs.atom?q=devops&remote=true', 'Jobs: “devops” remote']);
+ });
+
await check('one listing: the board\'s fields, the excerpt, the source and "read the full listing on" link', async () => {
await check('one listing: the board\'s fields, the excerpt, the source and "read the full listing on" link', async () => {
const job = (await first(t, '/api/v1/jobs?q=Platform Engineer')).jobs[0];
const job = (await first(t, '/api/v1/jobs?q=Platform Engineer')).jobs[0];
const r = await t.get(`/jobs/${job.id}`);
const r = await t.get(`/jobs/${job.id}`);
@@ -84,6 +84,12 @@ const { boot, check, done } = require('./helpers/boot');
@@ -84,6 +84,12 @@ const { boot, check, done } = require('./helpers/boot');
const page = await t.get('/saved', { as: kim });
const page = await t.get('/saved', { as: kim });
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
assert.match(page.text, /1 new/);
assert.match(page.text, /1 new/);
+ // Each saved search offers alerts: Watch's feed form with that search's feed filled in.
+ const alert = /href="(https:\/\/openvibe\.watch\/watches\/new\?[^"]+)">Get alerts</.exec(page.text);
+ assert.ok(alert, 'a Get alerts link');
+ const u = new URL(alert[1].replace(/&/g, '&'));
+ assert.strictEqual(u.searchParams.get('template'), 'feed');
+ assert.match(new URL(u.searchParams.get('url')).pathname, /^\/jobs\.atom$/);
assert.strictEqual((await t.get('/api/v1/saved-searches', { as: kim })).json().saved_searches.find((x) => x.id === saved.id).new_count, 0, 'opening /saved did not clear the count');
assert.strictEqual((await t.get('/api/v1/saved-searches', { as: kim })).json().saved_searches.find((x) => x.id === saved.id).new_count, 0, 'opening /saved did not clear the count');
assert.strictEqual((await t.get('/saved', { as: kim })).text.includes('nothing new'), true);
assert.strictEqual((await t.get('/saved', { as: kim })).text.includes('nothing new'), true);
});
});