@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.8-beta.18",
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"
@@ -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.18' // keep in step with .claude-plugin/plugin.json
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.18",
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
- function renderMd(md, { spaces = [] } = {}) {
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 (spaces.includes(h)) {
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 line of body.split('\n')) {
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
- if (!inList) { out.push('<ul>'); inList = true }
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' + (m ? ' data-repo="' + esc(m[1].trim()) + '"' : '') + '>' + li); continue
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
- if (inList) { out.push('</ul>'); inList = false }
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(line.slice(3).trim()); else out.push('<h2>' + inline(line.slice(3)) + '</h2>') }
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
- else if (line.trim()) out.push('<p>' + inline(line) + '</p>')
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
- if (inList) out.push('</ul>')
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
- html += '<div class="digest-wrap"><nav class="dates digest-dates" aria-label="Past digests">' + (days.length ? days.map((day) => {
2351
- const d = lead(day)
2352
- const on = pick === day
2353
- const label = d.shared ? '' : d.machine
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
- const d = await api('GET', '/v1/digests/' + pick + (pickD.machine ? '?machine=' + encodeURIComponent(pickD.machine) : ''))
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)