@leftoff/cli 0.9.8-beta.18 → 0.9.8-beta.19
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/CHANGELOG.md +2 -0
- package/bin/leftoff.js +356 -340
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +92 -10
- package/ui/app.js +164 -19
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.8-beta.
|
|
3
|
+
"version": "0.9.8-beta.19",
|
|
4
4
|
"description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Joel"
|
package/mod/hooks/register.js
CHANGED
|
@@ -28,7 +28,7 @@ const END_TIMEOUT_MS = 1000
|
|
|
28
28
|
const MODEL_TIMEOUT_MS = 20000
|
|
29
29
|
|
|
30
30
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
31
|
-
const MOD_VERSION = '0.9.8-beta.
|
|
31
|
+
const MOD_VERSION = '0.9.8-beta.19' // keep in step with .claude-plugin/plugin.json
|
|
32
32
|
|
|
33
33
|
const st = {
|
|
34
34
|
internal: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.9.8-beta.
|
|
3
|
+
"version": "0.9.8-beta.19",
|
|
4
4
|
"description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/ui/app.css
CHANGED
|
@@ -956,25 +956,15 @@ main img, main video { max-width: 100%; height: auto; }
|
|
|
956
956
|
.md code { overflow-wrap: anywhere; word-break: break-word; }
|
|
957
957
|
.md .where { color: var(--muted); font-family: var(--mono); font-size: 12px; }
|
|
958
958
|
.md li .sub .k { color: var(--faint); font-weight: 600; font-size: 11.5px; text-transform: uppercase; letter-spacing: .4px; margin-right: 2px; }
|
|
959
|
-
.dates a .d-short { display: none; }
|
|
960
959
|
.rchip .rl { max-width: 22ch; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
|
961
960
|
.tlbar > * { min-width: 0; }
|
|
962
961
|
.tl-head { flex-wrap: wrap; row-gap: 4px; min-width: 0; }
|
|
963
962
|
.need-head > * { min-width: 0; }
|
|
964
963
|
.need-head .need-title { max-width: calc(100% - 20px); } /* stays beside its dot when it wraps */
|
|
965
964
|
@media (max-width: 900px) {
|
|
966
|
-
/* past digests: one compact row that scrolls on its own, like Catch me up's */
|
|
967
|
-
.digest-dates { flex-direction: row; gap: 6px; overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; margin: 0 -16px; padding: 0 16px 2px; }
|
|
968
|
-
.digest-dates::-webkit-scrollbar { display: none; }
|
|
969
|
-
.digest-dates a { flex: none; display: flex; flex-direction: column; gap: 1px; padding: 6px 11px; font-family: inherit; font-size: 13px; line-height: 1.3; border: 1px solid var(--line); background: var(--card); }
|
|
970
|
-
.digest-dates a.on { border-color: var(--accent); background: var(--tint-accent); }
|
|
971
|
-
.digest-dates a .d-iso { display: none; }
|
|
972
|
-
.digest-dates a .d-short { display: inline; }
|
|
973
|
-
.digest-dates a .m { margin-left: 0; font-size: 11.5px; }
|
|
974
965
|
.digest-wrap { gap: 12px; margin-top: 12px; }
|
|
975
966
|
}
|
|
976
967
|
@media (max-width: 640px) {
|
|
977
|
-
.digest-dates { margin: 0 -14px; padding: 0 14px 2px; }
|
|
978
968
|
.md { padding: 16px 16px 18px; }
|
|
979
969
|
.md h1 { font-size: 19px; line-height: 1.3; }
|
|
980
970
|
.md h2 { font-size: 15.5px; margin: 18px 0 6px; }
|
|
@@ -2624,3 +2614,95 @@ html.pv-open, html.pv-open body { overflow: hidden; }
|
|
|
2624
2614
|
.live-box.off .live-resume-row .copy-float::after { content: attr(data-tip); position: absolute; bottom: calc(100% - 2px); right: 4px; padding: 4px 8px; border-radius: 7px; background: var(--card2); border: 1px solid var(--line-strong); box-shadow: var(--toast-shadow); color: var(--text2); font-size: 12px; line-height: 1.3; font-weight: 500; white-space: nowrap; opacity: 0; transform: translateY(3px); transition: opacity .12s, transform .12s; pointer-events: none; }
|
|
2625
2615
|
@media (hover: hover) { .live-box.off .live-resume-row .copy-float:hover::after { opacity: 1; transform: none; transition-delay: .35s; } }
|
|
2626
2616
|
.live-box.off .live-resume-row .copy-float:focus-visible::after, .live-box.off .live-resume-row .copy-float.copied::after { opacity: 1; transform: none; transition-delay: 0s; }
|
|
2617
|
+
|
|
2618
|
+
/* 0.9.8: the digest reads as a brief: Yesterday / Today, Start here, a sentence per session, Finished folded */
|
|
2619
|
+
.md p.lede { color: var(--text); font-size: 15px; line-height: 1.5; margin: 8px 0 0; }
|
|
2620
|
+
.md p.lede b { font-weight: 650; }
|
|
2621
|
+
.md p.lede + p.lede { margin-top: 4px; }
|
|
2622
|
+
.md section.start-sec { margin: 16px 0 4px; padding: 10px 14px 4px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg2); }
|
|
2623
|
+
.md section.start-sec h2 { margin: 2px 0 2px; font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
|
|
2624
|
+
.md ol.start { list-style: none; margin: 0; padding: 0; counter-reset: sh; }
|
|
2625
|
+
.md ol.start > li { counter-increment: sh; display: flex; align-items: center; gap: 10px 12px; padding: 9px 0; margin: 0; border-top: 1px solid var(--line); color: var(--text2); }
|
|
2626
|
+
.md ol.start > li:first-child { border-top: 0; }
|
|
2627
|
+
.md ol.start > li[hidden] { display: none; }
|
|
2628
|
+
.md ol.start > li::before { content: counter(sh); flex: none; align-self: flex-start; width: 22px; height: 22px; margin-top: 1px; border-radius: 50%; background: var(--tint-accent); color: var(--accent); font-size: 12px; font-weight: 700; display: grid; place-items: center; }
|
|
2629
|
+
.md .sh-main { flex: 1 1 auto; min-width: 0; display: grid; gap: 2px; }
|
|
2630
|
+
.md .sh-step { color: var(--text); font-weight: 600; line-height: 1.4; }
|
|
2631
|
+
.md .sh-sess { font-size: 12.5px; color: var(--muted); }
|
|
2632
|
+
.md .sh-sess a { color: inherit; text-decoration: underline; text-decoration-color: var(--line); text-underline-offset: 2px; }
|
|
2633
|
+
.md .sh-sess a:hover { color: var(--text); }
|
|
2634
|
+
.md .sh-repo { color: var(--faint); white-space: nowrap; }
|
|
2635
|
+
.md .sh-repo::before { content: '· '; }
|
|
2636
|
+
.md .sh-act { flex: none; display: flex; }
|
|
2637
|
+
.md .sh-act .btn { min-height: 44px; min-width: 64px; padding: 0 14px; justify-content: center; font-size: 13px; }
|
|
2638
|
+
.md .sh-act .go-off .why { display: none; }
|
|
2639
|
+
.md ul:has(> li.bi) { padding-left: 0; }
|
|
2640
|
+
.md li.bi { list-style: none; margin: 12px 0; }
|
|
2641
|
+
.md .bi-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: 2px 10px; }
|
|
2642
|
+
.md .bi-title { color: var(--text); font-weight: 600; text-decoration: none; padding: 2px 0; }
|
|
2643
|
+
.md .bi-title:hover { text-decoration: underline; text-underline-offset: 2px; }
|
|
2644
|
+
.md .bi-tag { color: var(--faint); font-size: 12px; }
|
|
2645
|
+
.md section.start-sec + .digest-hint, .md p.lede + .digest-hint { margin-top: 14px; }
|
|
2646
|
+
.md section.start-sec ~ #digestSpaces { margin-top: 16px; }
|
|
2647
|
+
.md .bi-say { display: block; color: var(--text2); line-height: 1.45; margin-top: 1px; }
|
|
2648
|
+
.md details.fin { margin: 10px 0 4px; }
|
|
2649
|
+
.md details.fin[hidden] { display: none; }
|
|
2650
|
+
.md details.fin > summary { list-style: none; cursor: pointer; min-height: 44px; display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); border-radius: 8px; }
|
|
2651
|
+
.md details.fin > summary::-webkit-details-marker { display: none; }
|
|
2652
|
+
.md details.fin > summary::before { content: ''; width: 7px; height: 7px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(-45deg); transition: transform .15s; margin: 0 2px 0 1px; }
|
|
2653
|
+
.md details.fin[open] > summary::before { transform: rotate(45deg); }
|
|
2654
|
+
.md details.fin > summary:hover { color: var(--text); }
|
|
2655
|
+
.md details.fin > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
2656
|
+
.md details.fin > ul { margin-top: -4px; }
|
|
2657
|
+
@media (prefers-reduced-motion: reduce) { .md details.fin > summary::before, .digest-cal .cal-fold .ic { transition: none; } }
|
|
2658
|
+
@media (max-width: 600px) {
|
|
2659
|
+
.md p.lede { font-size: 15px; }
|
|
2660
|
+
.md section.start-sec { padding: 8px 12px 2px; margin-left: -4px; margin-right: -4px; }
|
|
2661
|
+
.md li.bi { margin: 14px 0; }
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
/* 0.9.8: past digests as a month calendar (left column; a compact month above the digest on phones) */
|
|
2665
|
+
.digest-wrap:has(> .digest-cal) { grid-template-columns: 252px minmax(0, 1fr); }
|
|
2666
|
+
.digest-cal { align-self: start; position: sticky; top: 64px; background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--card-shadow); padding: 6px 8px 8px; }
|
|
2667
|
+
.digest-cal .cal-head { display: flex; align-items: center; justify-content: space-between; gap: 4px; }
|
|
2668
|
+
.digest-cal .cal-month { font-weight: 600; font-size: 14px; color: var(--text); }
|
|
2669
|
+
.digest-cal .cal-nav { flex: none; width: 44px; height: 44px; display: grid; place-items: center; border: 0; padding: 0; background: transparent; color: var(--muted); border-radius: 8px; cursor: pointer; }
|
|
2670
|
+
.digest-cal .cal-nav:hover:not(:disabled) { background: var(--card2); color: var(--text); }
|
|
2671
|
+
.digest-cal .cal-nav:disabled { opacity: .3; cursor: default; }
|
|
2672
|
+
.digest-cal .cal-nav:focus { outline: none; }
|
|
2673
|
+
.digest-cal .cal-nav:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
2674
|
+
.digest-cal .cal-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 2px; }
|
|
2675
|
+
.digest-cal .cal-wd { text-align: center; font-size: 11px; font-weight: 600; color: var(--faint); padding: 2px 0 4px; }
|
|
2676
|
+
.digest-cal .cal-day { position: relative; height: 34px; display: grid; place-items: center; border-radius: 8px; font-size: 13px; font-variant-numeric: tabular-nums; color: var(--faint); }
|
|
2677
|
+
.digest-cal .cal-day.pad { visibility: hidden; }
|
|
2678
|
+
.digest-cal a.cal-day { color: var(--text); font-weight: 600; text-decoration: none; }
|
|
2679
|
+
.digest-cal a.cal-day:hover { background: var(--card2); }
|
|
2680
|
+
.digest-cal a.cal-day:focus { outline: none; }
|
|
2681
|
+
.digest-cal a.cal-day:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
2682
|
+
.digest-cal .cal-day .dot { position: absolute; bottom: 4px; left: 50%; width: 4px; height: 4px; margin-left: -2px; border-radius: 50%; background: var(--accent); }
|
|
2683
|
+
.digest-cal .cal-day.today { box-shadow: inset 0 0 0 1.5px var(--accent); }
|
|
2684
|
+
.digest-cal a.cal-day.on { background: var(--accent); color: var(--on-accent); }
|
|
2685
|
+
.digest-cal a.cal-day.on .dot { background: var(--on-accent); }
|
|
2686
|
+
.digest-cal a.cal-day.on.today { box-shadow: inset 0 0 0 2px var(--accent), inset 0 0 0 3.5px var(--card); }
|
|
2687
|
+
.digest-cal .cal-none { text-align: center; font-size: 12.5px; color: var(--muted); padding: 8px 4px 2px; }
|
|
2688
|
+
@media (pointer: coarse) { .digest-cal .cal-day { height: 44px; } }
|
|
2689
|
+
.digest-cal .cal-fold { display: none; }
|
|
2690
|
+
@media (max-width: 900px) {
|
|
2691
|
+
/* folded to the picked day's week until the month is opened */
|
|
2692
|
+
.digest-cal .cal-head > span.cal-month { display: none; }
|
|
2693
|
+
.digest-cal .cal-fold { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 12px; border: 0; background: transparent; color: var(--text); font: inherit; font-weight: 600; font-size: 15px; border-radius: 8px; cursor: pointer; }
|
|
2694
|
+
.digest-cal .cal-fold .ic { width: 16px; height: 16px; color: var(--muted); transition: transform .15s; }
|
|
2695
|
+
.digest-cal .cal-fold[aria-expanded="true"] .ic { transform: rotate(180deg); }
|
|
2696
|
+
.digest-cal .cal-fold:focus { outline: none; }
|
|
2697
|
+
.digest-cal .cal-fold:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
2698
|
+
.digest-cal .cal-grid:not(.open) > .cal-day:not(.wk) { display: none; }
|
|
2699
|
+
.digest-wrap:has(> .digest-cal) { grid-template-columns: minmax(0, 1fr); }
|
|
2700
|
+
.digest-cal { position: static; width: 100%; max-width: 420px; padding: 2px 6px 6px; box-shadow: none; }
|
|
2701
|
+
.digest-cal .cal-day { height: 44px; font-size: 14px; }
|
|
2702
|
+
.digest-cal .cal-wd { padding: 0 0 2px; }
|
|
2703
|
+
}
|
|
2704
|
+
/* 0.9.8: an Unfinished session: its sentence, with Resume beside it */
|
|
2705
|
+
.md li.bi.unf { display: flex; align-items: center; gap: 12px; }
|
|
2706
|
+
.md li.bi.unf .bi-body { flex: 1 1 auto; min-width: 0; }
|
|
2707
|
+
.md li.bi.unf[hidden] { display: none; }
|
|
2708
|
+
|
package/ui/app.js
CHANGED
|
@@ -2275,29 +2275,67 @@ async function viewTimeline() {
|
|
|
2275
2275
|
$app.innerHTML = html
|
|
2276
2276
|
}
|
|
2277
2277
|
|
|
2278
|
-
|
|
2278
|
+
// 0.9.8: a digest line may end in "<!-- s:<id> -->", its session: the title
|
|
2279
|
+
// links there and Start here gets that session's Go / Resume.
|
|
2280
|
+
const MD_SID = /\s*<!-- s:(\S+?) -->\s*$/
|
|
2281
|
+
function briefAction(id, sessions, viewer) {
|
|
2282
|
+
const open = '<a class="btn sh-open" href="#/s/' + encodeURIComponent(id) + '" aria-label="Open the session">Open</a>'
|
|
2283
|
+
const s = sessions && sessions.get(id)
|
|
2284
|
+
if (!s) return open
|
|
2285
|
+
let b = ''
|
|
2286
|
+
// 0.9.8: closed: Resume opens it in a new tab where it can, else (on the Mac) copies the resume command
|
|
2287
|
+
if (s.live === 'closed' || s.status === 'ended') b = resumeButton(s, viewer || {}) || (s.agent || !s.cwd || (viewer && viewer.local === false) || state.viewerLocal === false ? '' : '<button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '" aria-label="Copy the resume command">' + icon('copy') + 'Resume</button>')
|
|
2288
|
+
else { const g = goButton(s); b = g && !/disabled/.test(g) ? g : '' }
|
|
2289
|
+
return b || open
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
function renderMd(md, { spaces = [], sessions = null, viewer = null } = {}) {
|
|
2279
2293
|
const body = md.replace(/^---\n[\s\S]*?\n---\n/, '')
|
|
2280
2294
|
const inline = (t) => esc(t).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/`([^`]+)`/g, '<code>$1</code>').replace(/(^|[^\w*])_([^_\s][^_]*?)_(?!\w)/g, '$1<em>$2</em>') // not inside snake_case names (clutch_hockey)
|
|
2281
2295
|
const out = []
|
|
2282
|
-
let inList = false, inSec = false
|
|
2296
|
+
let inList = false, inSec = false, inFin = false, seenH2 = false, inUnf = false
|
|
2297
|
+
const closeList = () => { if (inList) { out.push(inList === 'ol' ? '</ol>' : '</ul>'); inList = false } if (inFin) { out.push('</details>'); inFin = false } }
|
|
2298
|
+
const openList = (kind, cls) => { if (inList !== kind) { if (inList) out.push(inList === 'ol' ? '</ol>' : '</ul>'); out.push('<' + kind + (cls ? ' class="' + cls + '"' : '') + '>'); inList = kind } }
|
|
2299
|
+
const href = (id) => '#/s/' + encodeURIComponent(id)
|
|
2283
2300
|
// v0.5.8: each "## <space>" opens a section with its own Copy for standup;
|
|
2284
2301
|
// other "## " headings (Lists, Top of mind) are whole-folder sections.
|
|
2302
|
+
// 0.9.8: "## Start here" is the brief's own section, never hidden by a space.
|
|
2285
2303
|
const openSec = (h) => {
|
|
2286
2304
|
if (inSec) out.push('</section>')
|
|
2287
2305
|
inSec = true
|
|
2288
|
-
if (
|
|
2306
|
+
if (h === 'Start here') out.push('<section class="start-sec"><h2>' + inline(h) + '</h2>')
|
|
2307
|
+
else if (spaces.includes(h)) {
|
|
2289
2308
|
out.push('<section class="space-sec" data-space="' + esc(h) + '"><div class="space-head"><h2>' + esc(h) + '</h2>' +
|
|
2290
2309
|
'<button type="button" class="btn standup-btn' + (h === 'Work' ? ' primary' : '') + '" data-action="standup-copy" data-space="' + esc(h) + '">' + icon('clipboard') + 'Copy for standup</button></div>')
|
|
2291
2310
|
} else out.push('<section class="other-sec"><h2>' + inline(h) + '</h2>')
|
|
2292
2311
|
}
|
|
2293
|
-
for (const
|
|
2312
|
+
for (const raw of body.split('\n')) {
|
|
2313
|
+
const mid = raw.match(MD_SID)
|
|
2314
|
+
const id = mid ? mid[1] : null
|
|
2315
|
+
const line = mid ? raw.slice(0, mid.index) : raw
|
|
2316
|
+
// 0.9.8: Start here: "1. <step> — **Title** (repo)"
|
|
2317
|
+
const st = /^\d+\. /.test(line) && line.match(/^\d+\. (.*?) — \*\*(.+?)\*\*(?: \(([^)]*)\))?$/)
|
|
2318
|
+
if (st) {
|
|
2319
|
+
openList('ol', 'start')
|
|
2320
|
+
const t = id ? '<a href="' + href(id) + '">' + inline(st[2]) + '</a>' : inline(st[2])
|
|
2321
|
+
out.push('<li' + (id ? ' data-sid="' + esc(id) + '"' : '') + '><div class="sh-main"><span class="sh-step">' + inline(st[1]) + '</span><span class="sh-sess">' + t + (st[3] ? ' <span class="sh-repo">' + esc(st[3]) + '</span>' : '') + '</span></div>' +
|
|
2322
|
+
(id ? '<div class="sh-act">' + briefAction(id, sessions, viewer) + '</div>' : '') + '</li>')
|
|
2323
|
+
continue
|
|
2324
|
+
}
|
|
2294
2325
|
if (/^- /.test(line)) {
|
|
2295
|
-
|
|
2326
|
+
openList('ul')
|
|
2296
2327
|
// Digest items read "- **Title** (repo · branch) — state": tag the repo so the view can filter.
|
|
2297
2328
|
const m = line.match(/^- \*\*.*?\*\* \(([^)·]+?)(?: · [^)]*)?\) — /)
|
|
2329
|
+
const attrs = (m ? ' data-repo="' + esc(m[1].trim()) + '"' : '') + (id ? ' data-sid="' + esc(id) + '"' : '')
|
|
2330
|
+
// 0.9.8: "- **Title** (repo · state) — You were …; left off …." with its
|
|
2331
|
+
// session: the title links to it, the sentence under it, the tags quiet.
|
|
2332
|
+
const bi = id && line.match(/^- \*\*(.+?)\*\* \(([^)]*)\) — (.*)$/)
|
|
2333
|
+
// 0.9.8: an Unfinished one is closed, so it has its Resume beside it
|
|
2334
|
+
if (bi && inUnf) { out.push('<li class="bi unf"' + attrs + '><div class="bi-body"><div class="bi-head"><a class="bi-title" href="' + href(id) + '">' + inline(bi[1]) + '</a><span class="bi-tag">' + esc(bi[2]) + '</span></div><span class="bi-say">' + inline(bi[3]) + '</span></div><div class="sh-act">' + briefAction(id, sessions, viewer) + '</div>'); continue }
|
|
2335
|
+
if (bi) { out.push('<li class="bi"' + attrs + '><div class="bi-head"><a class="bi-title" href="' + href(id) + '">' + inline(bi[1]) + '</a><span class="bi-tag">' + esc(bi[2]) + '</span></div><span class="bi-say">' + inline(bi[3]) + '</span>'); continue }
|
|
2298
2336
|
// v0.5.7: the "(repo · branch · machine)" part is set small so the title leads on a phone.
|
|
2299
2337
|
const li = inline(line.slice(2)).replace(/^(<strong>.*?<\/strong>) (\([^)]*\))( — )/, '$1 <span class="where">$2</span>$3')
|
|
2300
|
-
out.push('<li' +
|
|
2338
|
+
out.push('<li' + attrs + '>' + li); continue
|
|
2301
2339
|
}
|
|
2302
2340
|
if (/^\s{2,}\S/.test(line) && inList) {
|
|
2303
2341
|
// "Stopped at: … Next: …" is two lines, each with its label.
|
|
@@ -2306,13 +2344,17 @@ function renderMd(md, { spaces = [] } = {}) {
|
|
|
2306
2344
|
else out.push('<span class="sub">' + inline(t) + '</span>')
|
|
2307
2345
|
continue
|
|
2308
2346
|
}
|
|
2309
|
-
|
|
2347
|
+
closeList()
|
|
2348
|
+
if (/^#{1,3} /.test(line)) inUnf = /^### Unfinished \(\d+\)\s*$/.test(line)
|
|
2310
2349
|
if (/^# /.test(line)) out.push('<h1>' + inline(line.slice(2)) + '</h1>')
|
|
2311
|
-
else if (/^## /.test(line)) { if (spaces.length) openSec(
|
|
2350
|
+
else if (/^## /.test(line)) { seenH2 = true; const h = line.slice(3).trim(); if (spaces.length || h === 'Start here') openSec(h); else { if (inSec) { out.push('</section>'); inSec = false } out.push('<h2>' + inline(h) + '</h2>') } }
|
|
2351
|
+
// 0.9.8: Finished folds away (older digests' "Finished or closed" too)
|
|
2352
|
+
else if (/^### Finished(?: or closed)? \(\d+\)\s*$/.test(line)) { out.push('<details class="fin"><summary>' + inline(line.slice(4).replace(/ or closed/, '')) + '</summary>'); inFin = true }
|
|
2312
2353
|
else if (/^### /.test(line)) out.push('<h3>' + inline(line.slice(4)) + '</h3>')
|
|
2313
|
-
|
|
2354
|
+
// 0.9.8: the Yesterday / Today opener, above the first section
|
|
2355
|
+
else if (line.trim()) out.push(seenH2 ? '<p>' + inline(line) + '</p>' : '<p class="lede">' + inline(line).replace(/^(Yesterday|Today):/, '<b>$1:</b>') + '</p>')
|
|
2314
2356
|
}
|
|
2315
|
-
|
|
2357
|
+
closeList()
|
|
2316
2358
|
if (inSec) out.push('</section>')
|
|
2317
2359
|
return out.join('\n')
|
|
2318
2360
|
}
|
|
@@ -2323,6 +2365,81 @@ function shortDay(iso) {
|
|
|
2323
2365
|
return isNaN(d) ? iso : d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }).replace(',', '')
|
|
2324
2366
|
}
|
|
2325
2367
|
|
|
2368
|
+
// The past-digests calendar (0.9.8): one month at a time, Sunday first. A day
|
|
2369
|
+
// with a digest is a link (the shared one, else a Mac's own from before one
|
|
2370
|
+
// digest a day); the rest are plain numbers. Opens on the picked digest's
|
|
2371
|
+
// month; next stops at this month, prev at the first month with a digest.
|
|
2372
|
+
const monthOf = (iso) => String(iso).slice(0, 7)
|
|
2373
|
+
const localDay = (t = new Date()) => t.getFullYear() + '-' + String(t.getMonth() + 1).padStart(2, '0') + '-' + String(t.getDate()).padStart(2, '0')
|
|
2374
|
+
function shiftMonth(ym, by) {
|
|
2375
|
+
const [y, m] = ym.split('-').map(Number)
|
|
2376
|
+
const d = new Date(y, m - 1 + by, 1)
|
|
2377
|
+
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0')
|
|
2378
|
+
}
|
|
2379
|
+
function digestCalendar() {
|
|
2380
|
+
const { days = [], pick = null } = digestUi.cal || {}
|
|
2381
|
+
const today = localDay()
|
|
2382
|
+
const max = monthOf(today)
|
|
2383
|
+
const min = days.length ? days.map((x) => monthOf(x.day)).sort()[0] : max
|
|
2384
|
+
let ym = digestUi.month || max
|
|
2385
|
+
if (ym > max) ym = max
|
|
2386
|
+
if (ym < min) ym = min
|
|
2387
|
+
digestUi.month = ym
|
|
2388
|
+
const [y, m] = ym.split('-').map(Number)
|
|
2389
|
+
const first = new Date(y, m - 1, 1)
|
|
2390
|
+
const label = first.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
|
2391
|
+
const by = new Map(days.map((x) => [x.day, x.d]))
|
|
2392
|
+
const prev = shiftMonth(ym, -1), next = shiftMonth(ym, 1)
|
|
2393
|
+
const name = (x) => new Date(x + '-01T12:00:00').toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
|
2394
|
+
const navBtn = (to, ok, dir) => '<button type="button" class="cal-nav" data-action="digest-month" data-to="' + to + '"' + (ok ? '' : ' disabled') + ' aria-label="' + (dir < 0 ? 'Previous month, ' : 'Next month, ') + esc(name(to)) + '">' + icon(dir < 0 ? 'chevron-left' : 'chevron-right') + '</button>'
|
|
2395
|
+
// phones fold it to one week (the picked day's) until you open the month
|
|
2396
|
+
const open = !!digestUi.calOpen
|
|
2397
|
+
let h = '<div class="cal-head">' + navBtn(prev, prev >= min, -1) + '<span class="cal-month" aria-live="polite">' + esc(label) + '</span>' +
|
|
2398
|
+
'<button type="button" class="cal-month cal-fold" data-action="digest-cal-fold" aria-expanded="' + open + '" aria-label="' + esc(label) + (open ? ', show one week' : ', show the whole month') + '">' + esc(label) + icon('chevron-down') + '</button>' + navBtn(next, next <= max, 1) + '</div>'
|
|
2399
|
+
h += '<div class="cal-grid' + (open ? ' open' : '') + '">' + ['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((w) => '<span class="cal-wd" aria-hidden="true">' + w + '</span>').join('')
|
|
2400
|
+
const n = new Date(y, m, 0).getDate()
|
|
2401
|
+
const lead0 = first.getDay()
|
|
2402
|
+
const week = (dn) => Math.floor((lead0 + dn - 1) / 7)
|
|
2403
|
+
const inMonth = (x) => x && x.slice(0, 7) === ym
|
|
2404
|
+
const firstHas = days.map((x) => x.day).filter(inMonth).sort()[0]
|
|
2405
|
+
const wk = week(inMonth(pick) ? Number(pick.slice(8)) : inMonth(today) ? Number(today.slice(8)) : firstHas ? Number(firstHas.slice(8)) : 1)
|
|
2406
|
+
for (let i = 0; i < lead0; i++) h += '<span class="cal-day pad' + (wk === 0 ? ' wk' : '') + '" aria-hidden="true"></span>'
|
|
2407
|
+
let count = 0
|
|
2408
|
+
for (let dn = 1; dn <= n; dn++) {
|
|
2409
|
+
const day = ym + '-' + String(dn).padStart(2, '0')
|
|
2410
|
+
const d = by.get(day)
|
|
2411
|
+
const cls = ['cal-day', d ? 'has' : 'none', day === pick ? 'on' : '', day === today ? 'today' : '', week(dn) === wk ? 'wk' : ''].filter(Boolean).join(' ')
|
|
2412
|
+
const long = shortDay(day)
|
|
2413
|
+
if (d) {
|
|
2414
|
+
count++
|
|
2415
|
+
const whose = d.shared ? '' : ', ' + d.machine + '’s own digest'
|
|
2416
|
+
h += '<a class="' + cls + '" href="#/digest/' + day + (d.shared ? '' : '?machine=' + encodeURIComponent(d.machine)) + '" data-day="' + day + '" aria-label="' + esc(long + whose + (day === today ? ', today' : '')) + '"' + (day === pick ? ' aria-current="date"' : '') + '><span class="n">' + dn + '</span><span class="dot" aria-hidden="true"></span></a>'
|
|
2417
|
+
} else h += '<span class="' + cls + '" data-day="' + day + '"><span class="n">' + dn + '</span></span>'
|
|
2418
|
+
}
|
|
2419
|
+
h += '</div>'
|
|
2420
|
+
if (!count) h += '<div class="cal-none">' + (days.length ? 'No digests in ' + esc(first.toLocaleDateString('en-US', { month: 'long' })) + '.' : 'None yet') + '</div>'
|
|
2421
|
+
return h
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
// Month or fold changed: patch the calendar in place, so the arrows (and the
|
|
2425
|
+
// focus on them, with its keyboard ring) stay put.
|
|
2426
|
+
function repaintCalendar() {
|
|
2427
|
+
const nav = document.querySelector('nav.digest-cal')
|
|
2428
|
+
if (!nav) return null
|
|
2429
|
+
const t = document.createElement('div')
|
|
2430
|
+
t.innerHTML = digestCalendar()
|
|
2431
|
+
const now = [...nav.querySelectorAll('.cal-head > *')], fresh = [...t.querySelectorAll('.cal-head > *')]
|
|
2432
|
+
now.forEach((el, i) => {
|
|
2433
|
+
const f = fresh[i]
|
|
2434
|
+
for (const a of ['data-to', 'aria-label', 'aria-expanded']) if (f.hasAttribute(a)) el.setAttribute(a, f.getAttribute(a))
|
|
2435
|
+
el.disabled = !!f.disabled
|
|
2436
|
+
if (!el.classList.contains('cal-nav')) el.innerHTML = f.innerHTML
|
|
2437
|
+
})
|
|
2438
|
+
for (const el of nav.querySelectorAll('.cal-grid, .cal-none')) el.remove()
|
|
2439
|
+
nav.append(...t.querySelectorAll('.cal-grid, .cal-none'))
|
|
2440
|
+
return nav
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2326
2443
|
// Digest (v0.5.8): one digest a day for the whole folder. The strip has one
|
|
2327
2444
|
// pill per day: the shared digest, else (days from before 0.5.8) a Mac's own,
|
|
2328
2445
|
// labelled with that Mac. Space tabs (All / Work / Personal …) sit above the
|
|
@@ -2347,16 +2464,16 @@ async function viewDigest(date, machine) {
|
|
|
2347
2464
|
const who = dg.order && dg.order.length > 1 ? ' Made by ' + esc(dg.order[0]) + ' (first in line), or the next Mac awake ' + esc(dg.graceMin) + ' min later.' : ''
|
|
2348
2465
|
const nextLine = todayLine + (dg.time ? 'Next automatic digest: ' + esc(when(dg.nextAt) || 'at ' + dg.time) + ' · ' + esc(dg.daysLabel || 'Every day') + ', ' + esc(dg.label || dg.time) + '.' + who : 'Automatic digests are off (<a href="#/settings/digest">Settings → Digest</a>).')
|
|
2349
2466
|
if (dg.lastError) html += '<div class="banner warn" role="status"><span class="grow"><b>The last digest failed</b> (' + esc(ago(dg.lastError.at)) + '): ' + esc(dg.lastError.message) + '. Generate now tries again; <code>leftoff doctor</code> shows it too.</span></div>'
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
return '<a href="#/digest/' + day + (d.shared ? '' : '?machine=' + encodeURIComponent(d.machine)) + '" class="' + (on ? 'on' : '') + '" title="' + esc(day + (d.shared ? '' : ' · ' + d.machine + '’s own digest (from before one digest a day)')) + '"><span class="d-iso">' + day + '</span><span class="d-short">' + esc(shortDay(day)) + '</span>' + (label ? ' <span class="m">' + esc(label) + '</span>' : '') + '</a>'
|
|
2355
|
-
}).join('') : '<span class="nocp">None yet</span>') + '</nav>'
|
|
2467
|
+
// 0.9.8: a month calendar, not a long list: days with a digest are links
|
|
2468
|
+
if (digestUi.date !== pick || !digestUi.month) { digestUi.month = monthOf(pick || localDay()); digestUi.calOpen = false }
|
|
2469
|
+
digestUi.cal = { days: days.map((day) => ({ day, d: lead(day) })), pick }
|
|
2470
|
+
html += '<div class="digest-wrap"><nav class="digest-cal" aria-label="Past digests">' + digestCalendar() + '</nav>'
|
|
2356
2471
|
let spaces = []
|
|
2357
2472
|
if (pick) {
|
|
2358
2473
|
try {
|
|
2359
|
-
|
|
2474
|
+
// 0.9.8: the sessions too, for Start here's Go / Resume
|
|
2475
|
+
const [d, sv] = await Promise.all([api('GET', '/v1/digests/' + pick + (pickD.machine ? '?machine=' + encodeURIComponent(pickD.machine) : '')), api('GET', '/v1/sessions?limit=500&archived=1').catch(() => null)])
|
|
2476
|
+
if (sv) { state.sessions = sv.sessions || []; if (sv.viewer) { state.viewerLocal = sv.viewer.local !== false; state.ghostty = sv.viewer.ghostty === true } }
|
|
2360
2477
|
const fm = d.meta || {}
|
|
2361
2478
|
const madeAt = fm.generated_at
|
|
2362
2479
|
spaces = Array.isArray(fm.spaces) ? fm.spaces.map(String) : []
|
|
@@ -2366,10 +2483,14 @@ async function viewDigest(date, machine) {
|
|
|
2366
2483
|
const also = others.length ? '<div class="digest-also">Also for this day: ' + others.map((x) => '<a href="#/digest/' + x.date + (x.shared ? '' : '?machine=' + encodeURIComponent(x.machine)) + '">' + esc(x.shared ? 'the shared digest' : x.machine + '’s') + '</a>').join(' · ') + '</div>' : ''
|
|
2367
2484
|
const unnamed = spv && spaces.some((x) => (spv.machines || []).some((m) => !m.set && m.machine === x)) ? '<div class="digest-hint">Spaces are named after your Macs until you pick one for each: <a href="#/settings/spaces">Settings → Spaces</a> (Work, Personal or your own).</div>' : ''
|
|
2368
2485
|
html += '<article class="md"><div class="digest-meta">' + legacy + (madeAt ? 'Made ' + esc(clock(madeAt)) + whose + ' · ' : '') + nextLine + '</div>' + also + unnamed +
|
|
2369
|
-
'<div id="digestSpaces"></div><div class="repos digest-repos" id="digestRepos"></div>' + renderMd(d.markdown, { spaces }) + '</article>'
|
|
2486
|
+
'<div id="digestSpaces"></div><div class="repos digest-repos" id="digestRepos"></div>' + renderMd(d.markdown, { spaces, sessions: sv ? new Map((sv.sessions || []).map((x) => [x.id, x])) : null, viewer: sv && sv.viewer }) + '</article>'
|
|
2370
2487
|
} catch (e) { html += '<div class="empty">' + esc(e.message) + '</div>' }
|
|
2371
2488
|
} else html += '<div class="empty"><p><strong>No digest yet.</strong></p><p>' + nextLine + '</p><p><button type="button" class="btn primary" data-action="gen-digest">Generate now</button> makes one from your sessions so far.</p></div>'
|
|
2372
2489
|
$app.innerHTML = html + '</div>'
|
|
2490
|
+
// 0.9.8: the brief comes first; the space tabs and repo chips sit under it, over the sessions they filter
|
|
2491
|
+
const art = $app.querySelector('article.md')
|
|
2492
|
+
const after = art && (art.querySelector(':scope > section.start-sec') || [...art.querySelectorAll(':scope > p.lede')].pop())
|
|
2493
|
+
if (after) after.after(...['.digest-hint', '#digestSpaces', '#digestRepos'].map((q) => art.querySelector(q)).filter(Boolean))
|
|
2373
2494
|
const params = (state.route && state.route.params) || new URLSearchParams()
|
|
2374
2495
|
state.digestRepos = readFilter(params, window.localStorage).repos
|
|
2375
2496
|
state.digestSpaces = spaces
|
|
@@ -2391,7 +2512,7 @@ function paintDigestFilter() {
|
|
|
2391
2512
|
const spaces = state.digestSpaces || []
|
|
2392
2513
|
const sp = state.digestSpace
|
|
2393
2514
|
const secs = [...document.querySelectorAll('.md section')]
|
|
2394
|
-
for (const sec of secs) sec.hidden = !!sp && (sec.classList.contains('other-sec') || sec.dataset.space !== sp)
|
|
2515
|
+
for (const sec of secs) sec.hidden = !sec.classList.contains('start-sec') && !!sp && (sec.classList.contains('other-sec') || sec.dataset.space !== sp)
|
|
2395
2516
|
const tabsBox = document.getElementById('digestSpaces')
|
|
2396
2517
|
if (tabsBox) {
|
|
2397
2518
|
const counts = new Map(spaces.map((x) => [x, document.querySelectorAll('.md section[data-space="' + CSS.escape(x) + '"] li[data-repo]').length]))
|
|
@@ -2409,8 +2530,19 @@ function paintDigestFilter() {
|
|
|
2409
2530
|
const any = [...ul.children].some((li) => !li.hidden)
|
|
2410
2531
|
ul.hidden = !any
|
|
2411
2532
|
if (h && (h.tagName === 'H2' || h.tagName === 'H3')) h.hidden = !any
|
|
2533
|
+
const fin = ul.closest('details.fin')
|
|
2534
|
+
if (fin) fin.hidden = !any
|
|
2412
2535
|
}
|
|
2413
2536
|
if (sel.length) for (const sec of secs) if (sec.classList.contains('space-sec') && !sec.hidden && ![...sec.querySelectorAll('li[data-repo]')].some((li) => !li.hidden)) sec.hidden = true
|
|
2537
|
+
// 0.9.8: a Start here step follows its session's line: hidden with it (space or repo)
|
|
2538
|
+
const startSec = document.querySelector('.md section.start-sec')
|
|
2539
|
+
if (startSec) {
|
|
2540
|
+
for (const li of startSec.querySelectorAll('li[data-sid]')) {
|
|
2541
|
+
const twin = [...document.querySelectorAll('.md li[data-repo][data-sid]')].find((x) => x.dataset.sid === li.dataset.sid)
|
|
2542
|
+
li.hidden = !!twin && (twin.hidden || !!(twin.closest('section') && twin.closest('section').hidden))
|
|
2543
|
+
}
|
|
2544
|
+
startSec.hidden = ![...startSec.querySelectorAll('ol.start > li')].some((li) => !li.hidden)
|
|
2545
|
+
}
|
|
2414
2546
|
if (counts.size < 2) { box.innerHTML = ''; return }
|
|
2415
2547
|
const on = (r) => sel.some((tok) => repoMatches({ repoKey: r, repoLabel: r }, tok))
|
|
2416
2548
|
box.innerHTML = '<div class="track"><button type="button" class="rchip all' + (sel.length ? '' : ' on') + '" data-action="digest-repo" data-repo=""><span class="rl">All</span><span class="rc">' + items.length + '</span></button>' +
|
|
@@ -5162,6 +5294,19 @@ document.addEventListener('click', async (ev) => {
|
|
|
5162
5294
|
saveSpace(state.digestSpace)
|
|
5163
5295
|
return paintDigestFilter()
|
|
5164
5296
|
}
|
|
5297
|
+
if (action === 'digest-cal-fold') {
|
|
5298
|
+
digestUi.calOpen = !digestUi.calOpen
|
|
5299
|
+
repaintCalendar()
|
|
5300
|
+
return
|
|
5301
|
+
}
|
|
5302
|
+
if (action === 'digest-month') {
|
|
5303
|
+
const back = btn.dataset.to < (digestUi.month || '')
|
|
5304
|
+
digestUi.month = btn.dataset.to
|
|
5305
|
+
digestUi.calOpen = true // another month: show all of it
|
|
5306
|
+
const nav = repaintCalendar()
|
|
5307
|
+
if (nav && btn.disabled) { const [p, n] = nav.querySelectorAll('.cal-nav'); const b = back ? n : p; if (!b.disabled) b.focus() } // the end of the range: focus stays on the arrows
|
|
5308
|
+
return
|
|
5309
|
+
}
|
|
5165
5310
|
if (action === 'standup-copy') return standupCopy(btn)
|
|
5166
5311
|
if (action === 'standup-close') { const b = btn.closest('.standup-out'); if (b) b.remove(); digestUi.open = null; return }
|
|
5167
5312
|
if (action === 'space-quick' || action === 'space-repo-add' || action === 'space-unset') return spacesAction(action, btn)
|