@lovek4d/kickflip 0.0.0-stage → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +513 -0
- package/LICENSE +57 -0
- package/README.md +64 -2
- package/SKILL.md +142 -0
- package/bin/doctor.mjs +4 -0
- package/bin/install.mjs +3 -0
- package/bin/kickflip.mjs +180 -0
- package/bin/statusline.mjs +5 -0
- package/bin/wait-events.mjs +3 -0
- package/board/arch-review.mjs +5 -0
- package/board/bin/agent.mjs +2 -0
- package/board/bin/board.mjs +379 -0
- package/board/bin/builder-stop-hook.mjs +6 -0
- package/board/build-script.mjs +1 -0
- package/board/domain.mjs +5 -0
- package/board/on-host.mjs +1 -0
- package/board/public/app-arch.js +75 -0
- package/board/public/app-asks.js +188 -0
- package/board/public/app-banner.js +48 -0
- package/board/public/app-cards.js +393 -0
- package/board/public/app-core.js +130 -0
- package/board/public/app-header.js +126 -0
- package/board/public/app-lanes.js +142 -0
- package/board/public/app-needs.js +31 -0
- package/board/public/app-snapshot.js +131 -0
- package/board/public/app-threads.js +167 -0
- package/board/public/app.html +86 -0
- package/board/public/app.js +438 -0
- package/board/public/attach.js +84 -0
- package/board/public/dialogs.js +358 -0
- package/board/public/hub-kit.js +168 -0
- package/board/public/hub.css +170 -0
- package/board/public/kickflip.png +0 -0
- package/board/public/linear-key.js +141 -0
- package/board/public/prompts.js +64 -0
- package/board/public/prototype-kit.js +193 -0
- package/board/public/prototype-switcher.js +226 -0
- package/board/public/prototype.html +55 -0
- package/board/public/router.js +226 -0
- package/board/public/rules-editor.js +127 -0
- package/board/public/shell.js +298 -0
- package/board/public/sidebar.css +219 -0
- package/board/public/sidebar.js +359 -0
- package/board/public/style.css +665 -0
- package/board/public/toasts.js +181 -0
- package/board/public/ui-cards.js +218 -0
- package/board/public/ui-enums.js +10 -0
- package/board/public/ui-header.js +93 -0
- package/board/public/ui-hub.js +112 -0
- package/board/public/ui-links.js +150 -0
- package/board/public/ui-markdown.js +80 -0
- package/board/public/ui-order.js +18 -0
- package/board/public/ui-page-header.js +65 -0
- package/board/public/ui-prompts.js +39 -0
- package/board/public/ui-questions.js +151 -0
- package/board/public/ui-sidebar.js +215 -0
- package/board/public/ui-snapshot.js +62 -0
- package/board/public/ui-threads.js +90 -0
- package/board/public/ui.js +247 -0
- package/board/public/view-frame.js +93 -0
- package/board/public/view-hub-settings.js +282 -0
- package/board/public/view-hub.js +150 -0
- package/board/public/view-notes.js +32 -0
- package/board/public/view-polling.js +87 -0
- package/board/public/view-questions.js +411 -0
- package/board/public/view-settings.js +383 -0
- package/board/review.mjs +1 -0
- package/board/server.mjs +17 -0
- package/board/store.mjs +15 -0
- package/board/tickets.mjs +3 -0
- package/board/worktree.mjs +15 -0
- package/docs/hub-session.md +11 -0
- package/hooks-plugin/.claude-plugin/plugin.json +5 -0
- package/hooks-plugin/hooks/codex-share-auto.mjs +23 -0
- package/hooks-plugin/hooks/hooks.json +54 -0
- package/hooks-plugin/hooks/usage-gate.mjs +18 -0
- package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
- package/hub/awake.mjs +1 -0
- package/hub/board-repo.mjs +1 -0
- package/hub/boards.mjs +4 -0
- package/hub/close-board.mjs +1 -0
- package/hub/duration.mjs +1 -0
- package/hub/files.mjs +1 -0
- package/hub/github.mjs +4 -0
- package/hub/home.mjs +1 -0
- package/hub/hub.mjs +19 -0
- package/hub/icon-180.png +0 -0
- package/hub/icon-192.png +0 -0
- package/hub/icon-512.png +0 -0
- package/hub/icons.mjs +1 -0
- package/hub/linear.mjs +2 -0
- package/hub/live.mjs +2 -0
- package/hub/locks.mjs +1 -0
- package/hub/new-board.mjs +2 -0
- package/hub/permission-hook.mjs +3 -0
- package/hub/pr-report.mjs +1 -0
- package/hub/presence.mjs +1 -0
- package/hub/prompts.mjs +2 -0
- package/hub/push.mjs +2 -0
- package/hub/readiness.mjs +2 -0
- package/hub/repos.mjs +3 -0
- package/hub/restart-board.mjs +1 -0
- package/hub/routes.mjs +1 -0
- package/hub/rules.mjs +1 -0
- package/hub/schedule.mjs +1 -0
- package/hub/sessions.mjs +2 -0
- package/hub/shell.mjs +2 -0
- package/hub/slack.mjs +2 -0
- package/hub/stream.mjs +19 -0
- package/hub/sw.js +144 -0
- package/hub/trust.mjs +2 -0
- package/hub/try.mjs +4 -0
- package/hub/upgrade.mjs +1 -0
- package/hub/usage.mjs +3 -0
- package/hub/views.mjs +1 -0
- package/package.json +26 -4
- package/shared/codex.mjs +1 -0
- package/shared/colors.css +15 -0
- package/shared/fake-gh.mjs +54 -0
- package/shared/repos.mjs +1 -0
- package/shared/service.mjs +33 -0
- package/shared/skills.mjs +2 -0
- package/shared/usage-gate.mjs +1 -0
- package/starter/cross-site.mjs +1 -0
- package/starter/public/claude.js +68 -0
- package/starter/public/index.html +49 -0
- package/starter/server.mjs +4 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// The hub page, /_hub/, a view of the app shell (shell.js): a banner while pushes are held or snoozed, the Usage card with Claude model and Codex share, Needs you for the pending
|
|
2
|
+
// prompts of sessions that own no board, Health, Integrations (readiness.mjs words), each site's health and board links,
|
|
3
|
+
// polling status, then hub settings summary rows (ADR 0075). A board's row can carry Restart (hub/restart-board.mjs). Health comes from GET /_hub/api/hub.
|
|
4
|
+
// Its data refreshes every 30 seconds while it shows (hub-kit.js live).
|
|
5
|
+
(function () {
|
|
6
|
+
// The last data, which a return to the view paints at once.
|
|
7
|
+
let last = null;
|
|
8
|
+
const { esc } = HubUI;
|
|
9
|
+
|
|
10
|
+
function rememberedHtml(sessions = []) {
|
|
11
|
+
return sessions.map((s) => `<div class="remembered"><p>Always allow · session <code>${esc(s.session)}</code></p><ul>${s.rules.map((rule) => `<li><code>${esc(rule)}</code></li>`).join('')}</ul></div>`).join('');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const RESTARTING = '<span class="st wait">Restarting</span> · stopping its session, then starting it again';
|
|
15
|
+
|
|
16
|
+
// A board with Restart (hub/restart-board.mjs restartable) keeps its link and chevron around the button, since a
|
|
17
|
+
// button can't sit inside a link.
|
|
18
|
+
function restartRow(s, sub, busy) {
|
|
19
|
+
const href = esc(s.href);
|
|
20
|
+
return `<div class="row site-row"><a class="site-link" href="${href}">${s.icon}<span class="text"><span class="title">${esc(s.title)}</span><span class="sub wrap">${sub}</span></span></a>`
|
|
21
|
+
+ `<button type="button" class="btn quiet restart-btn" data-restart="${esc(s.route)}" data-title="${esc(s.title)}" aria-label="Restart ${esc(s.title)}"${busy ? ' disabled aria-busy="true"' : ''}>${busy ? '<span class="try-spin" aria-hidden="true"></span>Restarting…' : 'Restart'}</button>`
|
|
22
|
+
+ `<a class="chev" href="${href}" tabindex="-1" aria-hidden="true">›</a></div>`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// restarting: the routes whose Restart is out.
|
|
26
|
+
function sitesHtml(groups, restarting) {
|
|
27
|
+
const { row, CHEVRON } = HubUI;
|
|
28
|
+
const site = (s) => {
|
|
29
|
+
const busy = restarting.has(s.route);
|
|
30
|
+
const lines = s.lines.length ? `<span class="links">${s.lines.map((l) => `<span><b>${esc(l.link)}</b> ${esc(l.detail)}</span>`).join('')}</span>` : '';
|
|
31
|
+
const sub = busy ? RESTARTING : `<span class="st ${esc(s.tone)}">${esc(s.word)}</span>${lines}`;
|
|
32
|
+
return (s.restart ? restartRow(s, sub, busy) : row({ icon: s.icon, title: esc(s.title), sub, wrap: true, trail: CHEVRON, href: s.href })) + rememberedHtml(s.remembered);
|
|
33
|
+
};
|
|
34
|
+
return groups.map((g) => `<h2 class="label">${esc(g.label)}</h2>\n<div class="group">${g.sites.map(site).join('')}</div>\n`).join('');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function integrationsHtml(integrations) {
|
|
38
|
+
const { row, tile } = HubUI;
|
|
39
|
+
const fixHtml = (fix) => fix.command ? `<code>${esc(fix.command)}</code>` : fix.settings
|
|
40
|
+
? `${esc(fix.text)} <button type="button" class="link" data-settings-open="${esc(fix.settings)}">${esc(fix.label)}</button>.` : esc(fix.text);
|
|
41
|
+
const rows = integrations.map((it) => row({ icon: tile(it.id, it.ready ? 'var(--color-quiet)' : 'var(--color-notice)'), title: esc(it.name), wrap: true,
|
|
42
|
+
sub: it.ready ? `<span class="st ok">Ready</span> · ${esc(it.detail)}`
|
|
43
|
+
: `<span class="st wait">Not ready</span> · ${esc(it.missing)}<span class="links"><span class="rd-fix">${fixHtml(it.fix)}</span><span class="rd-waits">${esc(it.waits)}</span></span>` })).join('');
|
|
44
|
+
return `<h2 class="label" id="integrations">Integrations</h2><div class="group">${rows}</div>
|
|
45
|
+
<p class="note">A feature that needs an integration stays off until it’s ready, and says why.</p>
|
|
46
|
+
`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A pending prompt from a session that owns no board waits here (prompts.js).
|
|
50
|
+
function promptsHtml(list, prompts) {
|
|
51
|
+
const shown = list.filter(prompts.shows);
|
|
52
|
+
return shown.length ? `<section class="waiting"><h2>Needs you · ${shown.length}</h2><ul>${shown.map((p) => BoardUI.promptItem(p, prompts.state(p))).join('')}</ul></section>` : '';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function html(d, { flash, prompts, restarting }) {
|
|
56
|
+
const { row, tile, nowRow } = HubUI;
|
|
57
|
+
const usage = HubUI.usageCard(d.usage, d.codex, d.pause, d.claude);
|
|
58
|
+
return `${HubUI.flash(flash)}${HubUI.pauseBanner(d.pause)}${HubUI.holdBanner(d.hold)}${usage}${promptsHtml(d.prompts || [], prompts)}<h2 class="label"${usage ? '' : ' style="margin-top:4px"'}>Health</h2>
|
|
59
|
+
<div class="group">${row({ icon: tile('power', { ok: '#22c55e', wait: '#f59e0b' }[d.hub.tone] || '#ef4444'), title: 'Hub', sub: `<button type="button" class="st ${esc(d.hub.tone)}" data-jump="integrations">${esc(d.hub.word)}</button> ·${esc(d.hub.detail)}${d.awake ? `<span class="links"><span><span class="awake-on">Keeping awake</span> ${esc(d.awake)}</span></span>` : ''}`, wrap: true })}
|
|
60
|
+
${d.empty ? row({ title: 'No sites yet', sub: 'In a Claude session, run <code>/kickflip <route></code>.' }) : ''}</div>
|
|
61
|
+
${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembered?.length ? `<h2 class="label">Sessions without a board</h2><div class="group">${rememberedHtml(d.remembered)}</div>` : ''}<p class="note">The hub runs every site and restarts one that stops. A board's links are what the hub owes it: GitHub polling, the fetch of its repo, events (its session and a watcher run, or the hub can wake it), daily wakes and reviewer links. One that just broke reads settling first.</p>
|
|
62
|
+
<h2 class="label">Polling status</h2>
|
|
63
|
+
<div class="group">${nowRow(d.polling, tile('clock', d.polling.count ? 'var(--color-notice)' : 'var(--color-quiet)'))}</div>`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const footer = (d) => `<footer>${esc(d.sites)}. Each belongs to one Claude session, which adds it with <code>/kickflip <route></code>. This page refreshes every 30 seconds.<br><span class="host">${esc(d.host)}</span></footer>`;
|
|
67
|
+
|
|
68
|
+
// iOS shows an app badge only in a Home Screen app with notifications allowed; elsewhere this does nothing.
|
|
69
|
+
function badge(n) {
|
|
70
|
+
if (!('setAppBadge' in navigator)) return;
|
|
71
|
+
try { (n ? navigator.setAppBadge(n) : navigator.clearAppBadge()).catch(() => {}); } catch { /* not allowed here */ }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
(globalThis.AppViews ||= {}).hub = {
|
|
75
|
+
className: 'hub-view view-hub',
|
|
76
|
+
title: () => 'Hub',
|
|
77
|
+
markup: () => `${BoardUI.header({ lead: 'logo', title: 'Hub', time: 'now', logoSrc: '/_hub/kickflip.png', settingsHref: null })}
|
|
78
|
+
<main class="has-top"><div class="hub-status"></div><section class="hub-settings" aria-labelledby="hubSettingsLabel"><h2 class="label" id="hubSettingsLabel">Hub settings</h2>${HubSettings.markup()}</section><div class="hub-foot"></div></main>`,
|
|
79
|
+
|
|
80
|
+
create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
|
|
81
|
+
const main = B.$('.hub-status');
|
|
82
|
+
// A change in the Notifications modal can start or end the hold the banner shows.
|
|
83
|
+
const settings = HubSettings.create(B.$('.hub-settings'), siteRoot, { ...ctx, sidebarChanged: () => { ctx.sidebarChanged?.(); load(); } });
|
|
84
|
+
settings.mount();
|
|
85
|
+
B.atUnmount(() => settings.unmount());
|
|
86
|
+
B.on(B.$('#refresh'), 'click', () => settings.B.refreshSettings?.());
|
|
87
|
+
let flash = null;
|
|
88
|
+
const restarting = new Set();
|
|
89
|
+
B.openSettings = (...args) => settings.B.openSettings(...args);
|
|
90
|
+
async function restart(route, title) {
|
|
91
|
+
if (restarting.has(route) || !(await BoardDialogs.confirm(`This stops ${title}’s session mid-task, along with any implementer it started, then restarts its server and resumes the session. Its cards and threads stay. For a stuck board. Restart ${title}?`))) return;
|
|
92
|
+
restarting.add(route);
|
|
93
|
+
flash = null;
|
|
94
|
+
paint();
|
|
95
|
+
let out = null;
|
|
96
|
+
try {
|
|
97
|
+
const r = await fetch('/_hub/restart-board', { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ route }) });
|
|
98
|
+
out = await r.json().catch(() => null);
|
|
99
|
+
if (!r.ok || !out) throw new Error();
|
|
100
|
+
if (B.alive()) B.toast(`${out.title} is back up, and its session is listening.`);
|
|
101
|
+
} catch { flash = { text: out?.error || `Could not restart ${title}. Is the host awake?`, error: true }; }
|
|
102
|
+
restarting.delete(route);
|
|
103
|
+
if (B.alive()) { paint(); load(); }
|
|
104
|
+
}
|
|
105
|
+
B.on(main, 'click', (e) => {
|
|
106
|
+
const opener = e.target.closest?.('[data-settings-open]');
|
|
107
|
+
if (opener) B.openSettings({ hash: `#${opener.dataset.settingsOpen}` }, opener);
|
|
108
|
+
// The Hub's status word jumps to what's behind it: Integrations, then each board's links below them.
|
|
109
|
+
const jump = e.target.closest?.('[data-jump]');
|
|
110
|
+
if (jump) B.$(`#${jump.dataset.jump}`).scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
111
|
+
const restarter = e.target.closest?.('[data-restart]');
|
|
112
|
+
if (restarter) restart(restarter.dataset.restart, restarter.dataset.title);
|
|
113
|
+
});
|
|
114
|
+
const foot = B.$('.hub-foot');
|
|
115
|
+
const paint = () => {
|
|
116
|
+
if (!last) return;
|
|
117
|
+
main.innerHTML = html(last, { flash, prompts, restarting });
|
|
118
|
+
foot.innerHTML = footer(last);
|
|
119
|
+
};
|
|
120
|
+
const prompts = PromptAnswers.wire(B, {
|
|
121
|
+
list: () => last?.prompts || [],
|
|
122
|
+
paint,
|
|
123
|
+
notify(text) { flash = { text, error: true }; paint(); },
|
|
124
|
+
after: () => load(),
|
|
125
|
+
});
|
|
126
|
+
const forms = HubKit.forms(B, {
|
|
127
|
+
accepts: (action, form) => action === '/_hub/push/flush' || ['/_hub/settings/codex', '/_hub/settings/claude-model', '/_hub/settings/pause-at', '/_hub/settings/keep-going'].includes(action) || Boolean(form?.closest?.('.hold-banner')),
|
|
128
|
+
answer(out, action) {
|
|
129
|
+
if (['/_hub/settings/codex', '/_hub/settings/claude-model', '/_hub/settings/pause-at', '/_hub/settings/keep-going'].includes(action)) {
|
|
130
|
+
last = out; flash = null; paint();
|
|
131
|
+
if (action.endsWith('/keep-going')) B.toast(out.pause.state === 'going' ? `Keep going: work runs past the buffer until ${BoardUI.usageClock(out.pause.resets_at)}.` : 'Stopped: new work waits again.');
|
|
132
|
+
} else if (action === '/_hub/push/flush') { flash = { text: out.message, error: !out.ok }; load(); } else { flash = null; load(); settings.B.refreshSettings?.(); }
|
|
133
|
+
ctx.sidebarChanged?.();
|
|
134
|
+
},
|
|
135
|
+
fail(message) { flash = { text: message, error: true }; paint(); },
|
|
136
|
+
});
|
|
137
|
+
const load = HubKit.live(B, {
|
|
138
|
+
url: () => '/_hub/api/hub',
|
|
139
|
+
paint(d) {
|
|
140
|
+
if (!d) { if (!last) main.innerHTML = '<p class="note">Offline. Is the host awake?</p>'; return; }
|
|
141
|
+
last = d;
|
|
142
|
+
paint();
|
|
143
|
+
badge(Number(d.total) || 0);
|
|
144
|
+
},
|
|
145
|
+
busy: () => forms.busy() || Boolean(root.querySelector('[data-dirty]')) || Boolean(root.querySelector('dialog[open]')),
|
|
146
|
+
});
|
|
147
|
+
paint();
|
|
148
|
+
}),
|
|
149
|
+
};
|
|
150
|
+
})();
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// An item's notes, /<route>/notes.html?item=<wave>/<id>, a view of the app shell (shell.js)
|
|
2
|
+
// (docs/adr/0053-notes-replace-the-full-plan-and-cards-start-on-their-own.md). Back goes to the item's card.
|
|
3
|
+
// ?wave=<slug> shows a wave's goal, order and note instead, and back goes to the board.
|
|
4
|
+
(globalThis.AppViews ||= {}).notes = {
|
|
5
|
+
className: 'view-notes',
|
|
6
|
+
title: () => 'Notes',
|
|
7
|
+
markup: ({ item, wave }) => `${BoardUI.header({ lead: 'back', title: 'Notes', titleId: 'ref', backHref: wave ? './' : `./#item=${encodeURIComponent(item || '')}`, controls: false })}
|
|
8
|
+
<main>
|
|
9
|
+
<article class="plan" id="plan"><p class="muted">Loading the notes…</p></article>
|
|
10
|
+
</main>`,
|
|
11
|
+
|
|
12
|
+
create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, async (B) => {
|
|
13
|
+
const { $, site } = B;
|
|
14
|
+
const { esc, markdown, linkContext, fetchJSON, restartNotice } = BoardUI;
|
|
15
|
+
const key = ctx.view.item || '';
|
|
16
|
+
const wave = ctx.view.wave;
|
|
17
|
+
const el = $('#plan');
|
|
18
|
+
const get = (url, opts = {}) => fetchJSON(url, { ...opts, sleep: B.sleep });
|
|
19
|
+
try {
|
|
20
|
+
const board = get(site.url('api/board')).then((r) => r.body).catch(() => ({}));
|
|
21
|
+
const query = wave ? `wave=${encodeURIComponent(wave)}` : `item=${encodeURIComponent(key)}`;
|
|
22
|
+
const r = await get(site.url(`api/notes?${query}`), { onWait: () => { el.innerHTML = restartNotice(); } });
|
|
23
|
+
if (!B.alive()) return;
|
|
24
|
+
if (!r.ok) throw new Error(r.body.error || `notes ${r.status}`);
|
|
25
|
+
$('#ref').textContent = `${wave ? r.body.title : r.body.ref} notes`;
|
|
26
|
+
document.title = $('#ref').textContent;
|
|
27
|
+
el.innerHTML = markdown(r.body.markdown, linkContext((await board).items || [], key, { base: './' }));
|
|
28
|
+
} catch (e) {
|
|
29
|
+
el.innerHTML = `<p class="muted">Could not load these notes: ${esc(e.message)}</p>`;
|
|
30
|
+
}
|
|
31
|
+
}),
|
|
32
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// HubPolling, the editor inside Hub’s Polling modal (ADR 0075): the day’s windows, overnight hold
|
|
2
|
+
// and daily wakes, in hub/views.mjs pollingView's words from GET /_hub/api/polling. ?w= names the window shown,
|
|
3
|
+
// which a tap on the day bar changes in place. Each change posts to /_hub/settings/polling and saves at once.
|
|
4
|
+
(function () {
|
|
5
|
+
// The last data, which a return to the view paints at once.
|
|
6
|
+
let last = null;
|
|
7
|
+
const { esc } = HubUI;
|
|
8
|
+
const ACTION = '/_hub/settings/polling';
|
|
9
|
+
// Each change posts op and w, the shown window's start.
|
|
10
|
+
const form = (op, w, inner, attrs = '') => `<form method="post" action="${ACTION}"${attrs}>${op ? `<input type="hidden" name="op" value="${op}">` : ''}<input type="hidden" name="w" value="${esc(w)}">${inner}</form>`;
|
|
11
|
+
|
|
12
|
+
function dayBar(day, sel) {
|
|
13
|
+
const segs = day.segments.map((g) => `<a class="seg${g.w === sel ? ' sel' : ''}${g.held ? ' held' : ''}" href="${esc(AppRouter.urlOf({ kind: 'hub', hash: '#polling', w: g.w }))}" data-w="${esc(g.w)}" style="left:${g.left};width:${g.width};--k:${g.shade}"`
|
|
14
|
+
+ ` aria-label="${esc(g.aria)}"${g.w === sel ? ' aria-current="true"' : ''}>${g.label ? `<span>${esc(g.label)}</span>` : ''}</a>`).join('');
|
|
15
|
+
const pins = day.pins.map((p) => `<span class="pin" style="left:${p.left}" title="${esc(p.title)}" aria-hidden="true">▼</span>`).join('');
|
|
16
|
+
const ticks = ['12am', '6am', '12pm', '6pm', '12am'].map((t, i) => `<span style="left:${i * 25}%">${t}</span>`).join('');
|
|
17
|
+
const legend = ['<span><i style="background:var(--muted)"></i>darker checks more often</span>',
|
|
18
|
+
day.held && '<span><i style="background:repeating-linear-gradient(135deg,var(--muted) 0 3px,var(--chip) 3px 5px)"></i>pushes held</span>',
|
|
19
|
+
day.wakes && '<span style="color:var(--color-notice)">▼ wake</span>',
|
|
20
|
+
'<span><i style="background:var(--text);width:2px"></i>now</span>'].filter(Boolean).join('');
|
|
21
|
+
return `<div class="day">${pins}<span class="now" style="left:${day.now}" aria-hidden="true"></span><div class="bar">${segs}</div><div class="ticks" aria-hidden="true">${ticks}</div></div><div class="legend">${legend}</div>`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function html(d, sel, flash) {
|
|
25
|
+
const { row, tile, nowRow } = HubUI;
|
|
26
|
+
const w = d.windows.find((x) => x.from === sel);
|
|
27
|
+
const chips = w.every.map((c) => `<button class="btn${c.on ? '' : ' quiet'}" name="value" value="${c.minutes}"${c.on ? ' aria-pressed="true"' : ''}>${esc(c.label)}</button>`).join('');
|
|
28
|
+
const wakeChips = d.wakes.map((t) => `<button class="btn quiet" name="value" value="${esc(t.value)}" aria-label="Remove the ${esc(t.label)} wake">${esc(t.label)} ✕</button>`).join('');
|
|
29
|
+
return `${HubUI.flash(flash)}<h2 class="label" style="margin-top:4px">Now</h2>
|
|
30
|
+
<div class="group">${nowRow(d.now)}</div>
|
|
31
|
+
<h2 class="label" id="day">Your day</h2>
|
|
32
|
+
<div class="group"><div class="block">${dayBar(d.day, w.from)}</div></div>
|
|
33
|
+
<h2 class="label" id="window">${esc(w.range)}</h2>
|
|
34
|
+
<div class="group">
|
|
35
|
+
${form('from', w.from, `<label class="body"><span class="text title">Starts</span><input type="time" name="value" value="${esc(w.from)}" required data-autosave></label>`, ' class="row field"')}
|
|
36
|
+
${row({ title: 'Ends', trail: `<span class="val">${esc(w.ends)}</span>` })}
|
|
37
|
+
${form('every', w.from, `<span class="title">Checks every</span><span class="chips">${chips}</span><span class="sub" style="margin-top:6px">${esc(w.checks)}</span>`, ' class="block"')}
|
|
38
|
+
${form('', w.from, `<button class="btn quiet" name="op" value="split"${w.split ? '' : ' disabled'}>Split in two</button><button class="btn quiet danger" name="op" value="remove"${w.remove ? '' : ' disabled'}>Remove</button>`, ' class="block split"')}
|
|
39
|
+
</div>
|
|
40
|
+
<h2 class="label" id="quiet">Quiet times</h2>
|
|
41
|
+
<div class="group">${form('hold', w.from, `${tile('moon', '#6366f1')}<label class="body"><span class="text"><span class="title">Hold pushes overnight</span></span><input type="checkbox" name="value" value="on" class="toggle" data-submit${d.hold.on ? ' checked' : ''}></label>`, ' class="row field"')}</div>
|
|
42
|
+
<h2 class="label" id="wakes">Daily wakes</h2>
|
|
43
|
+
<div class="group"><div class="block"><div class="chips" style="margin-top:0">${wakeChips ? form('wake-remove', w.from, wakeChips) : ''}${form('wake-add', w.from, '<input type="time" name="value" required aria-label="New wake time"><button class="btn quiet">+ Add</button>', ' class="field"')}</div></div></div>`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
globalThis.HubPolling = {
|
|
47
|
+
create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
|
|
48
|
+
const main = root;
|
|
49
|
+
let sel = ctx.view?.w || null;
|
|
50
|
+
let flash = null;
|
|
51
|
+
const shown = () => (last.windows.some((x) => x.from === sel) ? sel : last.select);
|
|
52
|
+
const paint = () => { if (last) main.innerHTML = html(last, shown(), flash); };
|
|
53
|
+
// The shown window lives in the URL, so a reload or a shared link shows it too.
|
|
54
|
+
const select = (w) => {
|
|
55
|
+
sel = w;
|
|
56
|
+
history.replaceState(history.state, '', AppRouter.urlOf({ kind: 'hub', hash: '#polling', w }));
|
|
57
|
+
paint();
|
|
58
|
+
};
|
|
59
|
+
B.selectWindow = select;
|
|
60
|
+
B.on(root, 'click', (e) => {
|
|
61
|
+
const seg = e.target.closest?.('a.seg[data-w]');
|
|
62
|
+
if (!seg || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
63
|
+
e.preventDefault();
|
|
64
|
+
select(seg.dataset.w);
|
|
65
|
+
});
|
|
66
|
+
const forms = HubKit.forms(B, {
|
|
67
|
+
answer(out, action) {
|
|
68
|
+
if (action === ACTION) { last = out; flash = null; select(out.select); }
|
|
69
|
+
else { flash = { text: out.message, error: !out.ok }; load(); }
|
|
70
|
+
ctx.changed?.();
|
|
71
|
+
ctx.sidebarChanged?.();
|
|
72
|
+
},
|
|
73
|
+
fail(message) { flash = { text: message, error: true }; paint(); },
|
|
74
|
+
});
|
|
75
|
+
const load = HubKit.live(B, {
|
|
76
|
+
url: () => `/_hub/api/polling${sel ? `?w=${encodeURIComponent(sel)}` : ''}`,
|
|
77
|
+
paint(d) {
|
|
78
|
+
if (!d) { if (!last) main.innerHTML = '<p class="note">Offline. Is the host awake?</p>'; return; }
|
|
79
|
+
last = d;
|
|
80
|
+
paint();
|
|
81
|
+
},
|
|
82
|
+
busy: () => forms.busy() || Boolean(root.querySelector('input:focus')),
|
|
83
|
+
});
|
|
84
|
+
paint();
|
|
85
|
+
}),
|
|
86
|
+
};
|
|
87
|
+
})();
|