Scheduled searches with a weekly review of new and rising repositories

A search can now be saved with a schedule (every N hours, daily, or weekly at
a given day and time). An internal scheduler runs it and keeps a snapshot of
the results on a /data volume (JSON files, no new dependency). The new Watch
tab lists the scheduled searches; the review page of each one shows, for any
run of its history, the repositories never seen before and the ones that
gained the most stars since the previous run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude BotandClaude Opus 5.5 committed 2026-10-01 16:36:50 +02:00
1 parent 8f4048d046
commit 02b51fed77
16 files changed
+1745 -45

No files matched your search

+525 -36
View File
@@ -68,6 +68,41 @@ const I18N = {
syntaxHelp: 'The search field accepts GitHub qualifiers, combined with the filters above the list:',
synUser: 'repositories of a user', synOrg: 'repositories of an organization', synTopics: 'several topics at once',
synPhrase: 'exact phrase', synNot: 'exclude a word', synSize: 'size in KB',
pushed1w: 'Pushed < 1 week', created1w: 'Created < 1 week', created3m: 'Created < 3 months', created6m: 'Created < 6 months',
viewsAria: 'Views', viewSearch: 'Search', viewSaved: 'Watch',
saveBtn: 'Schedule', saveBtnTitle: 'Run this search on a regular basis and review what changes',
savedTitle: 'Scheduled searches', savedNew: 'New from a search',
savedCount: (n) => `${n} ${n === 1 ? 'search' : 'searches'}`,
savedEmptyTitle: 'No scheduled search yet.',
savedEmptyHint: 'Set the filters in Search (for example: created < 1 week, most stars, a topic), then click Schedule.',
newTotal: (n) => `${n} new ${n === 1 ? 'repository' : 'repositories'} in the latest runs`,
schedHours: (n) => `Every ${n} h`, schedDay: (tm) => `Every day at ${tm}`, schedWeek: ({ d, tm }) => `Every ${d} at ${tm}`,
paused: 'paused', nextRun: (s) => `next ${s}`, lastRun: (s) => `last run ${s}`, neverRun: 'never run',
running: 'Running…', statNew: (n) => `${n} new`, statRising: (n) => `${n} rising`, statCount: (n) => `${n} repos`, statErr: 'error',
runNow: 'Run now', runNowTitle: 'Run the search now and keep a new snapshot',
runDone: ({ n, r }) => `Done: ${n} new, ${r} rising`, runFailed: 'Run failed: ',
edit: 'Edit', delete: 'Delete', cancel: 'Cancel', save: 'Save', back: 'Back to the list',
pause: 'Pause', resume: 'Resume',
confirmDelete: (n) => `Delete "${n}" and its history?`, deleted: 'Deleted',
openInSearch: 'Open in search', openInSearchTitle: 'Load these filters in the search view',
runAria: 'Run', revModeAria: 'Show',
modeNew: (n) => `New · ${n}`, modeRising: (n) => `Rising · ${n}`, modeAll: (n) => `All · ${n}`,
runOption: ({ d, n }) => `${d} · ${n} new`, runOptionErr: (d) => `${d} · error`,
revInfo: ({ d, total, q }) => `${d} · ${total} matching on GitHub · ${q}`,
revEmptyNew: 'Nothing new in this run.', revEmptyNewHint: 'Every repository was already in a previous run.',
revEmptyFirst: 'This is the first run.', revEmptyFirstHint: 'It is the baseline: the next runs will show what is new and what is rising. "All" shows this snapshot.',
revEmptyRising: 'No star gains since the previous run.', revEmptyRisingHint: 'Rising compares each repository with the previous snapshot.',
revEmptyAll: 'This run returned no repository.', revEmptyAllHint: '',
revNoRun: 'No run yet.', revNoRunHint: 'The first run is on its way, or click Run now.',
badgeNew: 'new', deltaTitle: 'stars gained since the previous run', rankTitle: 'position in the results',
saveNewTitle: 'Schedule this search', saveEditTitle: 'Edit the scheduled search',
svName: 'Name', svQuery: 'GitHub query', svEvery: 'Frequency', svWeekday: 'Day', svTime: 'Time', svHours: 'Hours',
everyWeek: 'Every week', everyDay: 'Every day', everyHours: 'Every N hours', hoursUnit: 'hours',
svMax: 'Results kept', svEnabled: 'Active', svEnabledHelp: 'Run on schedule',
svHintNew: (tz) => `The search runs once right away to set the baseline, then on schedule (server time zone: ${tz}).`,
svHintEdit: (tz) => `Server time zone: ${tz}. To change the filters, open the search, adjust it and schedule it again.`,
saved: 'Search scheduled', updated2: 'Saved',
err_not_found: 'Not found',
},
fr: {
locale: 'fr-FR',
@@ -134,6 +169,41 @@ const I18N = {
syntaxHelp: 'Le champ de recherche accepte les qualificatifs GitHub, combinés aux filtres au-dessus de la liste :',
synUser: 'dépôts d\'un utilisateur', synOrg: 'dépôts d\'une organisation', synTopics: 'plusieurs topics à la fois',
synPhrase: 'expression exacte', synNot: 'exclure un mot', synSize: 'taille en Ko',
pushed1w: 'Push < 1 semaine', created1w: 'Créé < 1 semaine', created3m: 'Créé < 3 mois', created6m: 'Créé < 6 mois',
viewsAria: 'Vues', viewSearch: 'Recherche', viewSaved: 'Veille',
saveBtn: 'Programmer', saveBtnTitle: 'Lancer cette recherche régulièrement et suivre ce qui change',
savedTitle: 'Recherches programmées', savedNew: 'Nouvelle depuis une recherche',
savedCount: (n) => `${n} recherche${n > 1 ? 's' : ''}`,
savedEmptyTitle: 'Aucune recherche programmée.',
savedEmptyHint: 'Réglez les filtres dans Recherche (par exemple : créé < 1 semaine, plus d\'étoiles, un topic), puis cliquez sur Programmer.',
newTotal: (n) => `${n} nouveau${n > 1 ? 'x' : ''} dépôt${n > 1 ? 's' : ''} dans les derniers passages`,
schedHours: (n) => `Toutes les ${n} h`, schedDay: (tm) => `Chaque jour à ${tm}`, schedWeek: ({ d, tm }) => `Chaque ${d} à ${tm}`,
paused: 'en pause', nextRun: (s) => `prochain passage ${s}`, lastRun: (s) => `dernier passage ${s}`, neverRun: 'jamais exécutée',
running: 'En cours…', statNew: (n) => `${n} nouveau${n > 1 ? 'x' : ''}`, statRising: (n) => `${n} en hausse`, statCount: (n) => `${n} dépôts`, statErr: 'erreur',
runNow: 'Exécuter', runNowTitle: 'Lancer la recherche maintenant et garder un nouvel instantané',
runDone: ({ n, r }) => `Terminé : ${n} nouveau${n > 1 ? 'x' : ''}, ${r} en hausse`, runFailed: 'Échec du passage : ',
edit: 'Modifier', delete: 'Supprimer', cancel: 'Annuler', save: 'Enregistrer', back: 'Retour à la liste',
pause: 'Mettre en pause', resume: 'Réactiver',
confirmDelete: (n) => `Supprimer « ${n} » et son historique ?`, deleted: 'Supprimée',
openInSearch: 'Ouvrir dans la recherche', openInSearchTitle: 'Charger ces filtres dans la vue Recherche',
runAria: 'Passage', revModeAria: 'Afficher',
modeNew: (n) => `Nouveautés · ${n}`, modeRising: (n) => `Progressions · ${n}`, modeAll: (n) => `Tous · ${n}`,
runOption: ({ d, n }) => `${d} · ${n} nouveau${n > 1 ? 'x' : ''}`, runOptionErr: (d) => `${d} · erreur`,
revInfo: ({ d, total, q }) => `${d} · ${total} résultats sur GitHub · ${q}`,
revEmptyNew: 'Rien de nouveau dans ce passage.', revEmptyNewHint: 'Tous les dépôts étaient déjà présents dans un passage précédent.',
revEmptyFirst: 'C\'est le premier passage.', revEmptyFirstHint: 'Il sert de référence : les suivants montreront les nouveautés et les progressions. « Tous » affiche cet instantané.',
revEmptyRising: 'Aucun gain d\'étoiles depuis le passage précédent.', revEmptyRisingHint: 'Progressions compare chaque dépôt avec l\'instantané précédent.',
revEmptyAll: 'Ce passage n\'a renvoyé aucun dépôt.', revEmptyAllHint: '',
revNoRun: 'Pas encore de passage.', revNoRunHint: 'Le premier passage est en cours, ou cliquez sur Exécuter.',
badgeNew: 'nouveau', deltaTitle: 'étoiles gagnées depuis le passage précédent', rankTitle: 'position dans les résultats',
saveNewTitle: 'Programmer cette recherche', saveEditTitle: 'Modifier la recherche programmée',
svName: 'Nom', svQuery: 'Requête GitHub', svEvery: 'Fréquence', svWeekday: 'Jour', svTime: 'Heure', svHours: 'Heures',
everyWeek: 'Chaque semaine', everyDay: 'Chaque jour', everyHours: 'Toutes les N heures', hoursUnit: 'heures',
svMax: 'Résultats gardés', svEnabled: 'Active', svEnabledHelp: 'Exécuter selon la fréquence',
svHintNew: (tz) => `La recherche est lancée une première fois tout de suite pour servir de référence, puis selon la fréquence (fuseau du serveur : ${tz}).`,
svHintEdit: (tz) => `Fuseau du serveur : ${tz}. Pour changer les filtres, ouvrez la recherche, ajustez-la et programmez-la à nouveau.`,
saved: 'Recherche programmée', updated2: 'Enregistré',
err_not_found: 'Introuvable',
},
};
@@ -171,8 +241,8 @@ function fmtAgo(s) {
return '';
}
async function api(url) {
const res = await fetch(url);
async function api(url, opts) {
const res = await fetch(url, opts);
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { /* not JSON */ }
@@ -191,8 +261,8 @@ function apiError(res, text, data) {
function toast(msg) {
const el = $('#toast');
const dlg = $('#settingsDlg');
(dlg.open ? dlg : document.body).append(el);
const dlg = document.querySelector('dialog[open]');
(dlg || document.body).append(el);
el.textContent = msg;
el.hidden = false;
clearTimeout(toast.tm);
@@ -291,7 +361,11 @@ function syncURL(f) {
}
function filtersFromURL() {
const src = location.search ? new URLSearchParams(location.search) : new URLSearchParams(store.get('filters', ''));
return filtersFromParams(location.search || store.get('filters', ''));
}
function filtersFromParams(qs) {
const src = new URLSearchParams(qs);
const f = {};
for (const k of FILTERS) f[k] = src.get(k) || '';
for (const k of TOGGLES) f[k] = src.get(k) === '1';
@@ -352,7 +426,9 @@ const ICON_EXT = '<svg viewBox="0 0 24 24" aria-hidden="true" width="14" height=
function rowHTML(r, i) {
const open = state.open.has(r.fullName);
const badges = (r.archived ? `<span class="badge archived">${esc(t('archived'))}</span>` : '')
const review = r.rank !== undefined; // snapshot of a scheduled search
const badges = (review && r.new ? `<span class="badge new">${esc(t('badgeNew'))}</span>` : '')
+ (r.archived ? `<span class="badge archived">${esc(t('archived'))}</span>` : '')
+ (r.fork ? `<span class="badge">${esc(t('fork'))}</span>` : '');
const topics = r.topics.length
? `<div class="topics">${r.topics.map((x) => `<button type="button" class="topic" data-topic="${esc(x)}" title="${esc(t('filterTopic'))}">${esc(x)}</button>`).join('')}</div>`
@@ -360,7 +436,10 @@ function rowHTML(r, i) {
const lang = r.language
? `<span class="lang" data-act="lang" title="${esc(t('filterLang'))}" style="cursor:pointer"><i class="lang-dot" style="--c:${LANGS[r.language] || 'var(--muted)'}"></i>${esc(r.language)}</span>`
: '<span></span>';
return `<div class="row${open ? ' open' : ''}" tabindex="0" data-i="${i}">
const delta = !review ? ''
: r.starsDelta ? `<span class="delta${r.starsDelta < 0 ? ' down' : ''}" title="${esc(t('deltaTitle'))}">${r.starsDelta > 0 ? '+' : ''}${esc(fmtCompact(r.starsDelta))} ★</span>`
: '<span></span>';
return `<div class="row${open ? ' open' : ''}${review && r.new ? ' is-new' : ''}" tabindex="0" data-i="${i}">
<img class="av" src="${esc(r.avatar)}${r.avatar.includes('?') ? '&' : '?'}s=72" alt="" loading="lazy">
<div class="main">
<div class="name"><a href="${esc(r.url)}" target="_blank" rel="noopener"><span class="owner">${esc(r.owner)}/</span>${esc(r.name)}</a>${badges}</div>
@@ -368,7 +447,7 @@ function rowHTML(r, i) {
${topics}
</div>
<div class="meta">
${lang}
${delta}${lang}
<span class="stars" title="${esc(fmtNum(r.stars))} ${esc(t('fStars'))}">${ICON_STAR}${fmtCompact(r.stars)}</span>
<span title="${esc(fmtNum(r.forks))} ${esc(t('fForks'))}">${ICON_FORK}${fmtCompact(r.forks)}</span>
<span title="${esc(t('fPushed'))} ${esc(fmtDate(r.pushedAt))}">${ICON_CLOCK}${esc(fmtAgo(r.pushedAt))}</span>
@@ -449,6 +528,7 @@ async function loadStatus() {
try {
const s = await api('/api/status');
state.token = s.token;
state.tz = s.tz || '';
if (s.rate && s.rate.limit) state.rate = s.rate;
} catch { state.token = null; }
renderStatus();
@@ -456,38 +536,49 @@ async function loadStatus() {
/* ================= List interactions ================= */
$('#list').addEventListener('click', (ev) => {
const rowEl = ev.target.closest('.row');
if (!rowEl) return;
const r = state.items[Number(rowEl.dataset.i)];
const topic = ev.target.closest('[data-topic]');
if (topic) {
$('#topic').value = topic.dataset.topic;
onFilterChange();
return;
}
if (ev.target.closest('[data-act="lang"]')) {
writeFilters({ ...readFilters(), language: r.language });
onFilterChange();
return;
}
const cp = ev.target.closest('[data-copy]');
if (cp) { copy(cp.dataset.copy); return; }
if (ev.target.closest('a, .details')) return;
toggleRow(rowEl, r);
});
// The search list and the review list share the same rows; items() returns
// the array the data-i indexes refer to.
function listEvents(listEl, items) {
listEl.addEventListener('click', (ev) => {
const rowEl = ev.target.closest('.row');
if (!rowEl) return;
const r = items()[Number(rowEl.dataset.i)];
const topic = ev.target.closest('[data-topic]');
if (topic) {
filterFromList({ topic: topic.dataset.topic });
return;
}
if (ev.target.closest('[data-act="lang"]')) {
filterFromList({ language: r.language });
return;
}
const cp = ev.target.closest('[data-copy]');
if (cp) { copy(cp.dataset.copy); return; }
if (ev.target.closest('a, .details')) return;
toggleRow(listEl, rowEl, r);
});
listEl.addEventListener('keydown', (ev) => {
if (ev.key !== 'Enter' || !ev.target.classList.contains('row')) return;
toggleRow(listEl, ev.target, items()[Number(ev.target.dataset.i)]);
});
}
listEvents($('#list'), () => state.items);
$('#list').addEventListener('keydown', (ev) => {
if (ev.key !== 'Enter' || !ev.target.classList.contains('row')) return;
toggleRow(ev.target, state.items[Number(ev.target.dataset.i)]);
});
// A click on a topic or a language filters the search (from the review too).
function filterFromList(change) {
const base = view === 'search' ? readFilters() : review.search ? filtersFromParams(review.search.params) : {};
writeFilters({ ...base, ...change });
state.searchPending = false;
if (view !== 'search') location.hash = '';
onFilterChange();
}
function toggleRow(rowEl, r) {
function toggleRow(listEl, rowEl, r) {
if (state.open.has(r.fullName)) state.open.delete(r.fullName);
else state.open.add(r.fullName);
const i = Number(rowEl.dataset.i);
rowEl.outerHTML = rowHTML(r, i);
$(`#list .row[data-i="${i}"]`).focus({ preventScroll: true });
listEl.querySelector(`.row[data-i="${i}"]`).focus({ preventScroll: true });
}
/* ================= Filter events ================= */
@@ -516,7 +607,7 @@ $('#resetBtn').addEventListener('click', () => {
$('#moreBtn').addEventListener('click', () => search(true));
document.addEventListener('keydown', (ev) => {
if (ev.key === '/' && !ev.target.closest('input, select, textarea') && !$('#settingsDlg').open) {
if (ev.key === '/' && view === 'search' && !ev.target.closest('input, select, textarea') && !document.querySelector('dialog[open]')) {
ev.preventDefault();
$('#q').focus();
$('#q').select();
@@ -567,6 +658,8 @@ function setLang(next) {
applyLang();
renderList();
renderStatus();
renderSaved();
renderReview();
}
$('#langSwitch').addEventListener('click', (ev) => {
@@ -663,6 +756,400 @@ $('#settingsBtn').addEventListener('click', () => {
$('#closeSettings').addEventListener('click', () => $('#settingsDlg').close());
$('#settingsDlg').addEventListener('click', (ev) => { if (ev.target === ev.currentTarget) ev.currentTarget.close(); });
/* ================= Views ================= */
// # search
// #saved scheduled searches
// #saved/<id> review of one scheduled search (#saved/<id>/<run> for an older run)
let view = 'search';
function route() {
const parts = location.hash.replace(/^#\/?/, '').split('/').filter(Boolean);
if (parts[0] === 'saved' && parts[1]) {
view = 'review';
openReview(decodeURIComponent(parts[1]), parts[2] ? decodeURIComponent(parts[2]) : 'latest');
} else if (parts[0] === 'saved') {
view = 'saved';
loadSaved();
} else {
view = 'search';
if (state.searchPending) { state.searchPending = false; search(); }
}
document.body.dataset.view = view;
$('#viewSearch').hidden = view !== 'search';
$('#viewSaved').hidden = view !== 'saved';
$('#viewReview').hidden = view !== 'review';
for (const a of document.querySelectorAll('#views [data-view]')) {
const cur = a.dataset.view === (view === 'review' ? 'saved' : view);
if (cur) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current');
}
window.scrollTo({ top: 0 });
}
window.addEventListener('hashchange', route);
/* ================= Scheduled searches ================= */
const ICON_PLAY = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4v16l13-8z"/></svg>';
const ICON_EDIT = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h4L19 9l-4-4L4 16zM14 6l4 4"/></svg>';
const ICON_PAUSE = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14M16 5v14"/></svg>';
const ICON_TRASH = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/></svg>';
const ICON_UP = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 15 7-7 7 7"/></svg>';
const ICON_SPIN = '<svg class="spin" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.2-8.6"/></svg>';
const saved = { list: [], loaded: false, running: new Set() };
const pad2 = (n) => String(n).padStart(2, '0');
const dayName = (d) => new Intl.DateTimeFormat(t('locale'), { weekday: 'long', timeZone: 'UTC' }).format(new Date(Date.UTC(2023, 0, 1 + d)));
const fmtDateTime = (s) => new Date(s).toLocaleString(t('locale'), { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
function schedText(sc) {
const tm = pad2(sc.hour) + ':' + pad2(sc.minute);
if (sc.every === 'hours') return t('schedHours', sc.hours);
if (sc.every === 'day') return t('schedDay', tm);
return t('schedWeek', { d: dayName(sc.weekday), tm });
}
// Readable GitHub query of saved parameters (computed by the server on each run).
function savedQuery(x) {
return (x.lastRun && x.lastRun.query) || new URLSearchParams(x.params).toString().replace(/&/g, ' ');
}
async function loadSaved() {
try {
saved.list = await api('/api/saved');
saved.loaded = true;
$('#savedError').hidden = true;
} catch (e) {
$('#savedError').textContent = e.message;
$('#savedError').hidden = false;
}
renderSaved();
}
function renderSaved() {
const n = saved.list.reduce((a, x) => a + (x.enabled && x.lastRun && !x.lastRun.error ? x.lastRun.new : 0), 0);
const pill = $('#newTotal');
pill.hidden = !n;
pill.textContent = n;
pill.title = n ? t('newTotal', n) : '';
if (!saved.loaded) return;
$('#savedCount').textContent = saved.list.length ? t('savedCount', saved.list.length) : '';
$('#savedEmpty').hidden = saved.list.length > 0 || !$('#savedError').hidden;
$('#savedList').innerHTML = saved.list.map((x) => {
const lr = x.lastRun;
let stats = '';
if (saved.running.has(x.id)) stats = `<span class="stat">${ICON_SPIN}${esc(t('running'))}</span>`;
else if (lr && lr.error) stats = `<span class="stat err" title="${esc(lr.error)}">${esc(t('statErr'))}</span>`;
else if (lr) {
stats = (lr.new ? `<span class="stat new">${esc(t('statNew', lr.new))}</span>` : '')
+ (lr.rising ? `<span class="stat up">${ICON_UP}${esc(t('statRising', lr.rising))}</span>` : '')
+ `<span class="stat">${esc(t('statCount', lr.count))}</span>`;
}
const when = [
`<span>${ICON_CLOCK}${esc(schedText(x.schedule))}</span>`,
x.enabled ? (x.nextRunAt ? `<span>${esc(t('nextRun', fmtAgo(x.nextRunAt)))}</span>` : '') : `<span>${esc(t('paused'))}</span>`,
`<span>${esc(x.lastRunAt ? t('lastRun', fmtAgo(x.lastRunAt)) : t('neverRun'))}</span>`,
].join('');
return `<div class="srow${x.enabled ? '' : ' off'}" tabindex="0" data-id="${esc(x.id)}">
<div class="smain">
<div class="sname">${esc(x.name)}</div>
<div class="sq">${esc(savedQuery(x))}</div>
<div class="ssched">${when}</div>
</div>
<div class="sstats">${stats}</div>
<div class="sacts">
<button class="icon-btn" type="button" data-act="run" title="${esc(t('runNow'))}" aria-label="${esc(t('runNow'))}">${ICON_PLAY}</button>
<button class="icon-btn" type="button" data-act="toggle" title="${esc(t(x.enabled ? 'pause' : 'resume'))}" aria-label="${esc(t(x.enabled ? 'pause' : 'resume'))}">${x.enabled ? ICON_PAUSE : ICON_CLOCK}</button>
<button class="icon-btn" type="button" data-act="edit" title="${esc(t('edit'))}" aria-label="${esc(t('edit'))}">${ICON_EDIT}</button>
<button class="icon-btn" type="button" data-act="delete" title="${esc(t('delete'))}" aria-label="${esc(t('delete'))}">${ICON_TRASH}</button>
</div>
</div>`;
}).join('');
}
$('#savedList').addEventListener('click', (ev) => {
const row = ev.target.closest('.srow');
if (!row) return;
const x = saved.list.find((s) => s.id === row.dataset.id);
const act = ev.target.closest('[data-act]');
if (!act) { location.hash = '#saved/' + encodeURIComponent(x.id); return; }
if (act.dataset.act === 'run') runSaved(x.id);
else if (act.dataset.act === 'edit') openSaveDlg(x);
else if (act.dataset.act === 'delete') deleteSaved(x);
else if (act.dataset.act === 'toggle') putSaved(x, { enabled: !x.enabled });
});
$('#savedList').addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' && ev.target.classList.contains('srow')) location.hash = '#saved/' + encodeURIComponent(ev.target.dataset.id);
});
async function runSaved(id) {
saved.running.add(id);
renderSaved();
$('#revRunBtn').disabled = true;
try {
const info = await api('/api/saved/' + encodeURIComponent(id) + '/run', { method: 'POST' });
toast(info.error ? t('runFailed') + info.error : t('runDone', { n: info.new, r: info.rising }));
} catch (e) {
toast(t('runFailed') + e.message);
} finally {
saved.running.delete(id);
$('#revRunBtn').disabled = false;
}
await loadSaved();
if (view === 'review' && review.id === id) openReview(id, 'latest');
}
async function putSaved(x, change) {
const body = { name: x.name, params: x.params, schedule: x.schedule, maxResults: x.maxResults, enabled: x.enabled, ...change };
try {
await api('/api/saved/' + encodeURIComponent(x.id), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
toast(t('updated2'));
} catch (e) {
toast(e.message);
}
await loadSaved();
if (view === 'review') renderReviewHead();
}
async function deleteSaved(x) {
if (!confirm(t('confirmDelete', x.name))) return false;
try {
await fetch('/api/saved/' + encodeURIComponent(x.id), { method: 'DELETE' });
toast(t('deleted'));
} catch (e) {
toast(e.message);
return false;
}
await loadSaved();
return true;
}
/* ---------- Review of one scheduled search ---------- */
const review = { id: '', search: null, runs: [], run: null, mode: '', items: [], req: 0 };
listEvents($('#revList'), () => review.items);
async function openReview(id, runID) {
const req = ++review.req;
if (review.id !== id) { review.mode = ''; review.run = null; review.runs = []; review.search = null; }
review.id = id;
document.body.classList.add('busy');
try {
if (!saved.loaded) await loadSaved();
const [x, runs] = await Promise.all([
api('/api/saved/' + encodeURIComponent(id)),
api('/api/saved/' + encodeURIComponent(id) + '/runs'),
]);
let run = null;
if (runs.length) {
try { run = await api('/api/saved/' + encodeURIComponent(id) + '/runs/' + encodeURIComponent(runID)); } catch { /* pruned run */ }
}
if (req !== review.req) return;
Object.assign(review, { search: x, runs, run });
if (!review.mode || runID === 'latest') {
review.mode = run && run.new ? 'new' : run && run.rising ? 'rising' : 'all';
}
$('#revError').hidden = true;
} catch (e) {
if (req !== review.req) return;
$('#revError').textContent = e.message;
$('#revError').hidden = false;
} finally {
if (req === review.req) document.body.classList.remove('busy');
}
state.open.clear();
renderReview();
}
function renderReviewHead() {
const x = saved.list.find((s) => s.id === review.id) || review.search;
if (!x) return;
review.search = x;
$('#revName').textContent = x.name;
$('#revSched').textContent = schedText(x.schedule) + (x.enabled
? (x.nextRunAt ? ' · ' + t('nextRun', fmtDateTime(x.nextRunAt)) : '')
: ' · ' + t('paused'));
}
function renderReview() {
if (view !== 'review') return;
renderReviewHead();
const run = review.run;
const sel = $('#runSel');
sel.innerHTML = review.runs.map((r) => `<option value="${esc(r.id)}">${esc(r.error ? t('runOptionErr', fmtDateTime(r.at)) : t('runOption', { d: fmtDateTime(r.at), n: r.new }))}</option>`).join('');
sel.hidden = !review.runs.length;
if (run) sel.value = run.id;
const items = run ? run.items : [];
const lists = {
new: items.filter((r) => r.new),
rising: items.filter((r) => r.starsDelta > 0).sort((a, b) => b.starsDelta - a.starsDelta || b.stars - a.stars),
all: items,
};
for (const b of document.querySelectorAll('#revMode [data-mode]')) {
const m = b.dataset.mode;
b.textContent = t('mode' + m[0].toUpperCase() + m.slice(1), fmtNum(lists[m].length));
b.setAttribute('aria-checked', String(m === review.mode));
}
$('#revMode').hidden = !run || !!run.error;
review.items = run && !run.error ? lists[review.mode] || [] : [];
const info = $('#revInfo');
info.textContent = run && !run.error ? t('revInfo', { d: fmtDateTime(run.at), total: fmtNum(run.total), q: run.query }) : '';
if (run && run.error) {
$('#revError').textContent = t('runFailed') + run.error;
$('#revError').hidden = false;
} else if (run) {
$('#revError').hidden = true;
}
$('#revList').classList.add('review');
$('#revList').innerHTML = review.items.map(rowHTML).join('');
const empty = !review.items.length && !(run && run.error);
$('#revEmpty').hidden = !empty;
if (empty) {
const first = review.runs.filter((r) => !r.error).length <= 1;
const key = !run ? 'revNoRun'
: review.mode === 'new' ? (first ? 'revEmptyFirst' : 'revEmptyNew')
: review.mode === 'rising' ? (first ? 'revEmptyFirst' : 'revEmptyRising') : 'revEmptyAll';
$('#revEmptyTitle').textContent = t(key);
$('#revEmptyHint').textContent = t(key + 'Hint');
}
}
$('#revMode').addEventListener('click', (ev) => {
const b = ev.target.closest('[data-mode]');
if (!b) return;
review.mode = b.dataset.mode;
state.open.clear();
renderReview();
});
$('#runSel').addEventListener('change', () => {
const latest = review.runs[0] && review.runs[0].id === $('#runSel').value;
location.hash = '#saved/' + encodeURIComponent(review.id) + (latest ? '' : '/' + encodeURIComponent($('#runSel').value));
});
$('#revRunBtn').addEventListener('click', () => runSaved(review.id));
$('#revEditBtn').addEventListener('click', () => review.search && openSaveDlg(review.search));
$('#revOpenBtn').addEventListener('click', () => {
if (!review.search) return;
writeFilters(filtersFromParams(review.search.params));
state.searchPending = false;
location.hash = '';
onFilterChange();
});
/* ---------- Create / edit dialog ---------- */
const dlgState = { edit: null, params: '', max: 50 };
{
const sel = $('#svWeekday');
for (const d of [1, 2, 3, 4, 5, 6, 0]) sel.add(new Option('', String(d)));
}
function renderSaveDlgLabels() {
for (const o of $('#svWeekday').options) o.textContent = dayName(Number(o.value));
for (const b of document.querySelectorAll('#svMax [data-max]')) b.setAttribute('aria-checked', String(Number(b.dataset.max) === dlgState.max));
const every = $('#svEvery').value;
$('#svWeekday').hidden = every !== 'week';
$('#svTime').hidden = every === 'hours';
$('#svHoursWrap').hidden = every !== 'hours';
}
// A readable default name: the search words, else the topic or the language.
function defaultName(f) {
const bits = [f.q, f.topic && 'topic:' + f.topic, f.language].filter(Boolean);
return bits.join(' · ') || t('savedTitle');
}
function openSaveDlg(x) {
dlgState.edit = x || null;
let sc = { every: 'week', weekday: 1, hour: 8, minute: 0, hours: 24 };
if (x) {
dlgState.params = x.params;
dlgState.max = x.maxResults;
sc = { ...sc, ...x.schedule };
$('#svName').value = x.name;
$('#svQuery').textContent = savedQuery(x);
$('#svEnabled').checked = x.enabled;
} else {
const f = readFilters();
dlgState.params = params(f).toString();
dlgState.max = 50;
$('#svName').value = defaultName(f);
$('#svQuery').textContent = state.query || dlgState.params;
$('#svEnabled').checked = true;
}
$('#svEvery').value = sc.every;
$('#svWeekday').value = String(sc.weekday || 0);
$('#svTime').value = pad2(sc.hour) + ':' + pad2(sc.minute);
$('#svHours').value = sc.hours || 24;
$('#saveTitle').textContent = t(x ? 'saveEditTitle' : 'saveNewTitle');
$('#svHint').textContent = t(x ? 'svHintEdit' : 'svHintNew', state.tz || 'UTC');
$('#svDelete').hidden = !x;
renderSaveDlgLabels();
$('#saveDlg').showModal();
$('#svName').select();
}
$('#svEvery').addEventListener('change', renderSaveDlgLabels);
$('#svMax').addEventListener('click', (ev) => {
const b = ev.target.closest('[data-max]');
if (!b) return;
dlgState.max = Number(b.dataset.max);
renderSaveDlgLabels();
});
$('#closeSave').addEventListener('click', () => $('#saveDlg').close());
$('#svCancel').addEventListener('click', () => $('#saveDlg').close());
$('#saveDlg').addEventListener('click', (ev) => { if (ev.target === ev.currentTarget) ev.currentTarget.close(); });
$('#svDelete').addEventListener('click', async () => {
if (dlgState.edit && await deleteSaved(dlgState.edit)) {
$('#saveDlg').close();
if (view === 'review') location.hash = '#saved';
}
});
$('#saveForm').addEventListener('submit', async (ev) => {
ev.preventDefault();
const [h, m] = ($('#svTime').value || '08:00').split(':').map(Number);
const body = {
name: $('#svName').value.trim(),
params: dlgState.params,
maxResults: dlgState.max,
enabled: $('#svEnabled').checked,
schedule: { every: $('#svEvery').value, weekday: Number($('#svWeekday').value), hour: h || 0, minute: m || 0, hours: Number($('#svHours').value) || 24 },
};
const x = dlgState.edit;
$('#svSubmit').disabled = true;
try {
const res = await api(x ? '/api/saved/' + encodeURIComponent(x.id) : '/api/saved', {
method: x ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
$('#saveDlg').close();
toast(t(x ? 'updated2' : 'saved'));
if (x) {
await loadSaved();
if (view === 'review') renderReview();
} else {
saved.running.add(res.id); // the server runs it once right away
location.hash = '#saved';
setTimeout(async () => { saved.running.delete(res.id); await loadSaved(); }, 4000);
}
} catch (e) {
toast(e.message);
} finally {
$('#svSubmit').disabled = false;
}
});
$('#saveBtn').addEventListener('click', () => openSaveDlg());
// Keep the list fresh (next run countdown, runs made by the scheduler).
setInterval(() => {
if (document.hidden) return;
if (view === 'saved') loadSaved();
}, 30000);
loadSaved();
/* ================= Startup ================= */
applyDensity(store.get('density', 'comfortable'));
@@ -670,5 +1157,7 @@ applyLang();
renderInterface();
writeFilters(filtersFromURL());
loadStatus();
search();
route();
if (view === 'search') search();
else state.searchPending = true;
setInterval(() => { if (!document.hidden) renderStatus(); }, 5000); // reset countdown
+137 -1
View File
@@ -23,7 +23,19 @@
<span>Searchgit</span>
</div>
<form id="searchForm" class="search" role="search">
<nav id="views" class="views" data-i18n-aria="viewsAria">
<a href="#" data-view="search">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<span data-i18n="viewSearch">Search</span>
</a>
<a href="#saved" data-view="saved">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2M9 2h6"/></svg>
<span data-i18n="viewSaved">Watch</span>
<span id="newTotal" class="pill" hidden></span>
</a>
</nav>
<form id="searchForm" class="search view-search" role="search">
<svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<input id="q" type="search" autocomplete="off" spellcheck="false" data-i18n-aria="searchAria" data-i18n-ph="searchPh">
<select id="in" class="chip-select" data-i18n-aria="inAria">
@@ -47,6 +59,7 @@
<div class="progress" aria-hidden="true"></div>
</header>
<div id="viewSearch" class="view">
<section class="filters">
<select id="language" data-i18n-aria="languageAria"><option value="" data-i18n="langAll">All languages</option></select>
<select id="stars" data-i18n-aria="starsAria">
@@ -58,6 +71,7 @@
</select>
<select id="pushed" data-i18n-aria="pushedAria">
<option value="" data-i18n="pushedAll">Any activity</option>
<option value="1w" data-i18n="pushed1w">Pushed &lt; 1 week</option>
<option value="1m" data-i18n="pushed1m">Pushed &lt; 1 month</option>
<option value="6m" data-i18n="pushed6m">Pushed &lt; 6 months</option>
<option value="1y" data-i18n="pushed1y">Pushed &lt; 1 year</option>
@@ -65,7 +79,10 @@
</select>
<select id="created" data-i18n-aria="createdAria">
<option value="" data-i18n="createdAll">Any creation date</option>
<option value="1w" data-i18n="created1w">Created &lt; 1 week</option>
<option value="1m" data-i18n="created1m">Created &lt; 1 month</option>
<option value="3m" data-i18n="created3m">Created &lt; 3 months</option>
<option value="6m" data-i18n="created6m">Created &lt; 6 months</option>
<option value="1y" data-i18n="created1y">Created &lt; 1 year</option>
<option value="5y" data-i18n="created5y">Created &lt; 5 years</option>
</select>
@@ -98,6 +115,10 @@
<option value="updated" data-i18n="sortUpdated">Recently updated</option>
<option value="help-wanted-issues" data-i18n="sortHelp">Help wanted issues</option>
</select>
<button id="saveBtn" class="btn tool" type="button" data-i18n-title="saveBtnTitle">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2M9 2h6"/></svg>
<span class="lbl" data-i18n="saveBtn">Schedule</span>
</button>
<button id="exportBtn" class="btn tool" type="button" data-i18n-title="exportTitle">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12M7 10l5 5 5-5M4 17v3h16v-3"/></svg>
<span class="lbl">CSV</span>
@@ -116,6 +137,70 @@
<p><strong data-i18n="emptyTitle">No matching repositories.</strong></p>
<p class="muted" data-i18n="emptyHint">Loosen a filter or change the search words.</p>
</div>
</div>
<!-- Saved (scheduled) searches -->
<div id="viewSaved" class="view" hidden>
<section class="filters">
<h1 class="view-title" data-i18n="savedTitle">Scheduled searches</h1>
<div class="end">
<span id="savedCount" class="muted small"></span>
<a href="#" class="btn tool">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
<span class="lbl" data-i18n="savedNew">New from a search</span>
</a>
</div>
</section>
<div id="savedError" class="error-banner" hidden></div>
<main id="savedList" class="list"></main>
<div id="savedEmpty" class="empty" hidden>
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2M9 2h6"/></svg>
<p><strong data-i18n="savedEmptyTitle">No scheduled search yet.</strong></p>
<p class="muted" data-i18n="savedEmptyHint"></p>
</div>
</div>
<!-- Review of one saved search -->
<div id="viewReview" class="view" hidden>
<section class="filters review-head">
<a href="#saved" class="icon-btn" data-i18n-title="back" data-i18n-aria="back">
<svg viewBox="0 0 24 24"><path d="m15 18-6-6 6-6"/></svg>
</a>
<div class="review-title">
<h1 id="revName" class="view-title"></h1>
<div id="revSched" class="muted small"></div>
</div>
<div class="end">
<select id="runSel" data-i18n-aria="runAria"></select>
<button id="revRunBtn" class="btn tool" type="button" data-i18n-title="runNowTitle">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4v16l13-8z"/></svg>
<span class="lbl" data-i18n="runNow">Run now</span>
</button>
<button id="revOpenBtn" class="btn tool" type="button" data-i18n-title="openInSearchTitle">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<span class="lbl" data-i18n="openInSearch">Open in search</span>
</button>
<button id="revEditBtn" class="icon-btn" type="button" data-i18n-title="edit" data-i18n-aria="edit">
<svg viewBox="0 0 24 24"><path d="M4 20h4L19 9l-4-4L4 16zM14 6l4 4"/></svg>
</button>
</div>
</section>
<section class="filters review-bar">
<div id="revMode" class="seg" role="radiogroup" data-i18n-aria="revModeAria">
<button type="button" role="radio" data-mode="new"></button>
<button type="button" role="radio" data-mode="rising"></button>
<button type="button" role="radio" data-mode="all"></button>
</div>
<span id="revInfo" class="muted small"></span>
</section>
<div id="revError" class="error-banner" hidden></div>
<main id="revList" class="list"></main>
<div id="revEmpty" class="empty" hidden>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m12 3 2.7 5.6 6.1.8-4.5 4.3 1.1 6.1L12 16.9l-5.4 2.9 1.1-6.1-4.5-4.3 6.1-.8z"/></svg>
<p><strong id="revEmptyTitle"></strong></p>
<p id="revEmptyHint" class="muted"></p>
</div>
</div>
<footer class="status">
<span id="conn" class="conn"></span>
@@ -218,6 +303,57 @@
</div>
</dialog>
<!-- Create / edit a scheduled search -->
<dialog id="saveDlg" class="save-dlg" aria-labelledby="saveTitle">
<form id="saveForm" class="dlg" method="dialog">
<header>
<h2 id="saveTitle"></h2>
<button id="closeSave" class="icon-btn" type="button" data-i18n-aria="close">
<svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg>
</button>
</header>
<div class="save-body">
<label class="lbl" for="svName" data-i18n="svName">Name</label>
<input id="svName" class="text-input" type="text" maxlength="120" required autocomplete="off">
<span class="lbl" data-i18n="svQuery">GitHub query</span>
<code id="svQuery" class="query-box"></code>
<label class="lbl" for="svEvery" data-i18n="svEvery">Frequency</label>
<div class="sched-row">
<select id="svEvery" class="text-input">
<option value="week" data-i18n="everyWeek">Every week</option>
<option value="day" data-i18n="everyDay">Every day</option>
<option value="hours" data-i18n="everyHours">Every N hours</option>
</select>
<select id="svWeekday" class="text-input" data-i18n-aria="svWeekday"></select>
<input id="svTime" class="text-input" type="time" value="08:00" required data-i18n-aria="svTime">
<span id="svHoursWrap" class="hours-wrap">
<input id="svHours" class="text-input" type="number" min="1" max="168" value="24" data-i18n-aria="svHours">
<span class="muted small" data-i18n="hoursUnit">hours</span>
</span>
</div>
<span class="lbl" data-i18n="svMax">Results kept</span>
<div id="svMax" class="seg" role="radiogroup" data-i18n-aria="svMax">
<button type="button" role="radio" data-max="30">30</button>
<button type="button" role="radio" data-max="50">50</button>
<button type="button" role="radio" data-max="100">100</button>
</div>
<span class="lbl" data-i18n="svEnabled">Active</span>
<label class="check"><input id="svEnabled" type="checkbox" checked> <span data-i18n="svEnabledHelp">Run on schedule</span></label>
</div>
<p id="svHint" class="muted small save-hint"></p>
<footer class="dlg-foot">
<button id="svDelete" class="btn danger" type="button" data-i18n="delete" hidden>Delete</button>
<span class="grow"></span>
<button id="svCancel" class="btn" type="button" data-i18n="cancel">Cancel</button>
<button id="svSubmit" class="btn primary" type="submit" data-i18n="save">Save</button>
</footer>
</form>
</dialog>
<div id="toast" class="toast" hidden></div>
<script src="app.js"></script>
+114
View File
@@ -348,8 +348,105 @@ dialog.settings {
.seg button:hover { color: var(--text); }
.seg button[aria-checked="true"] { background: var(--panel); color: var(--accent); box-shadow: 0 1px 3px rgba(0, 0, 0, .12); }
/* ---------- Views (search / scheduled searches) ---------- */
.views { display: inline-flex; gap: 2px; padding: 3px; background: var(--panel-2); border-radius: 10px; flex: none; }
.views a {
display: inline-flex; align-items: center; gap: 6px;
height: 30px; padding: 0 12px; border-radius: 7px;
color: var(--muted); font-size: 13px; font-weight: 600; text-decoration: none; white-space: nowrap;
}
.views a:hover { color: var(--text); }
.views a[aria-current="page"] { background: var(--panel); color: var(--accent); box-shadow: 0 1px 3px rgba(0, 0, 0, .12); }
.views svg { width: 16px; height: 16px; }
.pill {
min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px;
background: var(--accent); color: #fff; font-size: 11px; font-weight: 700;
display: inline-grid; place-items: center; font-variant-numeric: tabular-nums;
}
body:not([data-view="search"]) .view-search { visibility: hidden; }
body:not([data-view="search"]) .gh-query { display: none; }
.view-title { margin: 0; font-size: 16px; font-weight: 650; letter-spacing: -.01em; overflow-wrap: anywhere; }
.review-head { flex-wrap: nowrap; }
.review-title { min-width: 0; }
.review-head .end { flex-wrap: wrap; justify-content: flex-end; }
.review-bar { padding-top: 0; }
#runSel { max-width: 260px; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn.primary:hover { filter: brightness(1.08); background: var(--accent); }
.btn.danger { color: var(--err); }
.btn.danger:hover { background: color-mix(in srgb, var(--err) 10%, transparent); }
/* Saved search rows */
.srow {
display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 6px 18px; align-items: center;
padding: var(--row-pad) 12px var(--row-pad) 16px; border-bottom: 1px solid var(--border); cursor: pointer;
}
.srow:last-child { border-bottom: 0; }
.srow:hover { background: var(--row-hover); }
.srow:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.srow.off { opacity: .6; }
.srow .sname { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; font-weight: 650; }
.srow .sq { margin-top: 2px; font: 12px var(--mono); color: var(--muted); overflow-wrap: anywhere; }
.srow .ssched { margin-top: 4px; font-size: 12.5px; color: var(--muted); display: flex; flex-wrap: wrap; gap: 2px 14px; }
.srow .ssched span { display: inline-flex; align-items: center; gap: 5px; }
.srow .ssched svg { width: 14px; height: 14px; }
.srow .sstats { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
.srow .sacts { display: flex; gap: 2px; }
.srow .sacts .icon-btn { width: 32px; height: 32px; }
.srow .sacts .icon-btn svg { width: 17px; height: 17px; }
.stat {
display: inline-flex; align-items: center; gap: 4px; height: 24px; padding: 0 8px; border-radius: 999px;
font-size: 12px; font-weight: 650; background: var(--panel-2); color: var(--muted); white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.stat.new { background: var(--accent-soft); color: var(--accent); }
.stat.up { background: color-mix(in srgb, var(--ok) 14%, transparent); color: var(--ok); }
.stat.err { background: color-mix(in srgb, var(--err) 12%, transparent); color: var(--err); }
.stat svg { width: 12px; height: 12px; }
.spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Review annotations */
.badge.new { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); background: var(--accent-soft); }
.meta .delta { color: var(--ok); font-weight: 650; }
.meta .delta.down { color: var(--err); }
.row.is-new { box-shadow: inset 3px 0 0 var(--accent); }
.review .meta { grid-template-columns: 4.5rem 7.5rem 4.5rem 4rem 6.5rem; }
/* Save dialog */
dialog.save-dlg { width: min(560px, calc(100vw - 32px)); }
.save-body {
display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 12px 16px; align-items: center;
padding: 18px 22px 4px;
}
.text-input {
height: 34px; padding: 0 10px; border: 1px solid var(--border); border-radius: 8px;
background: var(--panel); font-size: 13px; outline: 0; min-width: 0;
}
.text-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
#svName { width: 100%; }
#svHours { width: 5.5rem; }
.sched-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.hours-wrap { display: inline-flex; align-items: center; gap: 8px; }
.query-box {
display: block; padding: 7px 10px; border-radius: 8px; background: var(--panel-2); border: 1px solid var(--border);
font: 12px/1.5 var(--mono); color: var(--muted); overflow-wrap: anywhere;
}
.check { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; }
.check input { width: 16px; height: 16px; accent-color: var(--accent); }
.save-hint { margin: 10px 22px 0; }
.save-hint:empty { display: none; }
.dlg-foot { display: flex; align-items: center; gap: 8px; padding: 16px 22px 18px; }
.grow { flex: 1; }
/* ---------- Medium screens: the meta columns go under the description ---------- */
@media (max-width: 1100px) {
.srow { grid-template-columns: minmax(0, 1fr) auto; }
.srow .sstats { grid-column: 1; justify-content: flex-start; }
.srow .sacts { grid-row: 1 / span 2; grid-column: 2; }
.row { grid-template-columns: 36px minmax(0, 1fr); grid-template-areas: "av main" "av meta" "av details"; }
.meta { display: flex; flex-wrap: wrap; gap: 4px 16px; padding-top: 6px; }
}
@@ -374,6 +471,23 @@ dialog.settings {
.status { padding: 6px 16px; }
.gh-query { display: none; }
dialog.settings { height: calc(100vh - 24px); width: calc(100vw - 16px); }
.views { order: 2; margin-left: auto; }
.views a { padding: 0 9px; }
.views a span:not(.pill) { display: none; }
.actions { order: 2; margin-left: 0; }
body:not([data-view="search"]) .view-search { display: none; }
.review-head { flex-wrap: wrap; }
.review-title { flex: 1; }
.review-head .end { flex: 1 1 100%; justify-content: flex-start; }
.review-head .end .lbl { display: none; }
#runSel { flex: 1 1 auto; max-width: none; }
.review-bar .seg { flex: 1 1 100%; }
.review-bar .seg button { flex: 1; padding: 0 6px; font-size: 12px; }
.srow { padding-left: 12px; padding-right: 8px; }
.save-body { grid-template-columns: 1fr; gap: 6px; padding: 14px 16px 4px; }
.save-body .lbl { margin-top: 6px; }
.save-hint { margin: 10px 16px 0; }
.dlg-foot { padding: 14px 16px 16px; }
.set-body { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
.set-nav {
display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); padding: 6px;