@bobfrankston/rmfmail 1.2.316 → 1.2.318

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.
@@ -13,7 +13,7 @@
13
13
  * All storage goes through the service-side two-way cache (calendar_events
14
14
  * and tasks tables); this file does not use localStorage for data.
15
15
  */
16
- import { getPrimaryAccount, getCalendarEvents, getCalendars, refreshCalendarNow, getTasks, createTask, updateTask, deleteTask, reauthGoogleScopes, getSettings, saveSettings, } from "../lib/api-client.js";
16
+ import { getCalendarEvents, getCalendars, createCalendarEvent, deleteCalendarEvent, refreshCalendarNow, getTasks, createTask, updateTask, deleteTask, reauthGoogleScopes, getSettings, saveSettings, } from "../lib/api-client.js";
17
17
  import { showContextMenu, confirmAt } from "./context-menu.js";
18
18
  const SIDEBAR_PREF = "mailx-calendar-sidebar-on";
19
19
  const SHOW_RECURRING_PREF = "mailx-cal-show-recurring";
@@ -32,11 +32,40 @@ const HOLIDAY_HORIZON_MS = 14 * 86400_000;
32
32
  let viewYear = new Date().getFullYear();
33
33
  let viewMonth = new Date().getMonth();
34
34
  let viewDay = new Date().getDate();
35
+ /** True once the reader steps to another day with ‹ ›; "Today" clears it.
36
+ * While false the view FOLLOWS the calendar date: the app runs for days,
37
+ * and the date above was fixed when this module loaded (Bob 2026-09-10:
38
+ * "it is still showing me yesterday's calendar"). */
39
+ let viewPinned = false;
40
+ function followToday() {
41
+ if (viewPinned)
42
+ return;
43
+ const t = new Date();
44
+ viewYear = t.getFullYear();
45
+ viewMonth = t.getMonth();
46
+ viewDay = t.getDate();
47
+ }
48
+ /** Re-render at the next local midnight, then re-arm. A setInterval would
49
+ * drift; computing the next midnight each time lands on it exactly. */
50
+ function armMidnightRollover() {
51
+ const now = new Date();
52
+ const next = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 5);
53
+ window.setTimeout(() => { void refresh(); armMidnightRollover(); }, next.getTime() - now.getTime());
54
+ }
35
55
  let lastEvents = [];
36
56
  /** Overdue tasks (due before today, not completed) — pinned to the top of
37
57
  * the calendar list. Cached so renderEvents() can be re-run on a calendar
38
58
  * visibility toggle without re-fetching tasks. */
39
59
  let lastOverdueTasks = [];
60
+ /** Calendars a new event can be saved to, personal first. */
61
+ function writableCalendars(list) {
62
+ return list
63
+ .filter(c => c.accessRole === "owner" || c.accessRole === "writer")
64
+ .sort((a, b) => (a.primary ? 0 : 1) - (b.primary ? 0 : 1) || a.name.localeCompare(b.name));
65
+ }
66
+ /** The calendar the last event was saved to — a per-machine convenience,
67
+ * so a run of entries on a shared calendar does not mean re-picking it. */
68
+ const LAST_CALENDAR_PREF = "mailx.calendar.lastCreateCalendar";
40
69
  /** Selected Google calendars, by id. The primary calendar is also keyed
41
70
  * under the literal "primary" so legacy/stale rows (whose `calendar_id`
42
71
  * predates per-calendar tagging) still resolve. */
@@ -144,7 +173,7 @@ async function renderCalendarList() {
144
173
  else {
145
174
  // Personal first, then alphabetical.
146
175
  const sorted = [...list].sort((a, b) => (a.primary ? 0 : 1) - (b.primary ? 0 : 1) || a.name.localeCompare(b.name));
147
- host.innerHTML = sorted.map(c => `<label class="cal-side-cal-row" title="${escapeHtml(c.name)}">
176
+ host.innerHTML = sorted.map(c => `<label class="cal-side-cal-row" title="${escapeHtml(c.name)} — untick to hide its events in this list. Which calendars are listed is set in Google Calendar: the ones selected there appear; use Refresh after changing it.">
148
177
  <input type="checkbox" class="cal-side-cal-check" data-cal-id="${escapeHtml(c.id)}" ${hiddenCalendars.has(c.id) ? "" : "checked"}>
149
178
  ${calIconHtml(c)}
150
179
  <span class="cal-side-cal-name">${escapeHtml(c.name)}</span>
@@ -417,7 +446,9 @@ function renderEvents(events) {
417
446
  </div>`;
418
447
  }
419
448
  else {
420
- const titleAttr = link ? 'title="Click to open in Google Calendar"' : "";
449
+ const titleAttr = link
450
+ ? 'title="Click to open in Google Calendar to edit it. Right-click for more."'
451
+ : 'title="Saved in rmfmail, syncing to Google — it can be opened there once it has synced. Right-click to delete."';
421
452
  html += `<div class="cal-side-event" data-id="${e.id}"${recurAttr} data-link="${escapeHtml(link)}" ${titleAttr}>
422
453
  ${calIconHtml(info)}
423
454
  <span class="cal-side-event-time">${escapeHtml(formatTime(e))}</span>
@@ -469,18 +500,43 @@ function renderEvents(events) {
469
500
  el.addEventListener("contextmenu", (e) => {
470
501
  e.preventDefault();
471
502
  const link = el.dataset.link;
503
+ const uuid = el.dataset.id || "";
504
+ const title = el.querySelector(".cal-side-event-title")?.textContent?.trim() || "this event";
472
505
  const items = [];
473
506
  if (link)
474
507
  items.push({
475
- label: "View in browser",
508
+ label: `Edit "${title}" in Google Calendar`,
476
509
  action: () => openInBrowser(link),
477
510
  });
478
511
  items.push({
479
512
  label: "Open Google Calendar",
480
513
  action: () => openInBrowser("https://calendar.google.com/"),
481
514
  });
482
- if (items.length > 0)
483
- showContextMenu(e.clientX, e.clientY, items, { editTarget: e.target });
515
+ if (uuid) {
516
+ items.push({ label: "", action: () => { }, separator: true });
517
+ items.push({
518
+ label: `Delete "${title}"`,
519
+ tooltip: "Removes it from your calendar in rmfmail now and from Google when it syncs.",
520
+ action: async () => {
521
+ if (!await confirmAt(e.clientX, e.clientY, `Delete "${title}"`))
522
+ return;
523
+ try {
524
+ await deleteCalendarEvent(uuid);
525
+ const st = document.getElementById("status-sync");
526
+ if (st)
527
+ st.textContent = `Deleted event: ${title}`;
528
+ await refresh();
529
+ }
530
+ catch (err) {
531
+ const st = document.getElementById("status-sync");
532
+ if (st)
533
+ st.textContent = `Couldn't delete "${title}": ${err?.message || err}`;
534
+ console.error("[calendar] delete failed:", err);
535
+ }
536
+ },
537
+ });
538
+ }
539
+ showContextMenu(e.clientX, e.clientY, items, { editTarget: e.target });
484
540
  });
485
541
  });
486
542
  }
@@ -595,6 +651,7 @@ async function renderTasks(prefetched) {
595
651
  });
596
652
  }
597
653
  async function refresh() {
654
+ followToday();
598
655
  renderHead();
599
656
  const from = new Date(viewYear, viewMonth, viewDay);
600
657
  // A throw here (IPC reject, daemon not ready) must NOT leave the
@@ -632,12 +689,15 @@ async function refresh() {
632
689
  /** Open the natural-language new-event modal. The user types a free-form
633
690
  * description ("Lunch with John tomorrow at noon at Joe's"); we send it
634
691
  * to aiTransform with action=extractEvent, get back structured fields
635
- * (title/start/end/location/notes), and open Google Calendar's
636
- * create-event URL with those fields pre-filled. The user reviews / edits
637
- * / saves in Google's UI that path keeps mailx out of the
638
- * event-editor business and gets all the niceties (recurrence, guests,
639
- * conferencing) for free. If AI is disabled or extraction fails, the raw
640
- * text becomes the title and Google's own quick-add parser takes over. */
692
+ * (title/start/end/location/notes), and SAVE them — a local row at once,
693
+ * pushed to Google by the store-sync queue exactly as the sidebar's
694
+ * task editor does. Until 2026-09-10 this opened Google Calendar's
695
+ * create page in the browser instead, and the reader had to save it
696
+ * there, in whatever Google account the browser was signed into (Bob:
697
+ * "add an event puts me into Google Calendar. It should just save the
698
+ * event because I can easily edit it later"). If AI is disabled or the
699
+ * parse fails, the dialog says so and offers "Create as text": the raw
700
+ * text becomes the title of an all-day event today, to be edited. */
641
701
  function openNewEventDialog() {
642
702
  const backdrop = document.createElement("div");
643
703
  backdrop.className = "mailx-modal-backdrop";
@@ -649,12 +709,47 @@ function openNewEventDialog() {
649
709
  heading.textContent = "New event";
650
710
  const hint = document.createElement("div");
651
711
  hint.style.cssText = "font-size:0.9em;color:var(--color-text-muted);";
652
- hint.textContent = "Describe the event in plain English — AI extracts the details and opens Google Calendar to confirm.";
712
+ hint.textContent = "Describe the event in plain English — AI extracts the details and saves it to your calendar. Edit it afterwards if it needs adjusting.";
653
713
  const ta = document.createElement("textarea");
654
714
  ta.style.cssText = "width:100%;min-height:120px;font:inherit;padding:8px;border-radius:4px;border:1px solid var(--color-border, #ccc);box-sizing:border-box;resize:vertical;";
655
715
  ta.placeholder = "Lunch with John tomorrow at noon at Joe's Pizza";
656
716
  const status = document.createElement("div");
657
717
  status.style.cssText = "min-height:1.2em;font-size:0.85em;color:var(--color-text-muted);";
718
+ // Which calendar. Offered only when Google says more than one can take
719
+ // an event (Bob 2026-09-10: "I want to start making more use of shared
720
+ // calendars"); with one there is nothing to choose and the row is not
721
+ // shown. The list is the one the sidebar already enumerated; fetched
722
+ // here only if the sidebar has not loaded it yet.
723
+ const calRow = document.createElement("label");
724
+ calRow.style.cssText = "display:flex;align-items:center;gap:8px;font-size:0.9em;";
725
+ calRow.title = "The calendar this event is saved to. Only calendars you can write to are listed; to add or share a calendar, do that in Google Calendar and press Refresh.";
726
+ const calSelect = document.createElement("select");
727
+ calSelect.style.cssText = "flex:1;font:inherit;padding:4px;";
728
+ calRow.append("Calendar", calSelect);
729
+ calRow.hidden = true;
730
+ const fillCalendars = (list) => {
731
+ const writable = writableCalendars(list);
732
+ calSelect.innerHTML = "";
733
+ let last = "";
734
+ try {
735
+ last = localStorage.getItem(LAST_CALENDAR_PREF) || "";
736
+ }
737
+ catch { /* per-viewer convenience only */ }
738
+ for (const c of writable) {
739
+ const opt = document.createElement("option");
740
+ opt.value = c.id;
741
+ opt.textContent = c.primary ? `${c.name} (personal)` : c.name;
742
+ if (c.id === last || (!last && c.primary))
743
+ opt.selected = true;
744
+ calSelect.append(opt);
745
+ }
746
+ calRow.hidden = writable.length < 2;
747
+ };
748
+ if (calendarList.length)
749
+ fillCalendars(calendarList);
750
+ else
751
+ getCalendars().then(list => { calendarList = list; fillCalendars(calendarList); })
752
+ .catch((e) => console.warn("[calendar] calendar list for the picker failed:", e?.message || e));
658
753
  const btnRow = document.createElement("div");
659
754
  btnRow.style.cssText = "display:flex;gap:8px;justify-content:flex-end;";
660
755
  const cancelBtn = document.createElement("button");
@@ -664,7 +759,7 @@ function openNewEventDialog() {
664
759
  createBtn.textContent = "Create";
665
760
  createBtn.style.cssText = "padding:6px 14px;font-weight:500;";
666
761
  btnRow.append(cancelBtn, createBtn);
667
- panel.append(heading, hint, ta, status, btnRow);
762
+ panel.append(heading, hint, ta, calRow, status, btnRow);
668
763
  backdrop.append(panel);
669
764
  document.body.append(backdrop);
670
765
  setTimeout(() => ta.focus(), 0);
@@ -726,131 +821,89 @@ function openNewEventDialog() {
726
821
  return;
727
822
  }
728
823
  }
729
- // Build Google Calendar's create-event URL(s) one per extracted
730
- // event (the text may describe several). Always opens in browser
731
- // for the user to confirm/edit/save. We never write to the local
732
- // store from this path the round-trip back via Google Calendar
733
- // sync keeps state consistent and avoids duplicate events.
734
- const api = window.mailxapi;
824
+ // Save each extracted event (the text may describe several) as a
825
+ // local row; the store-sync queue pushes it to Google and binds the
826
+ // provider id when that lands. Local-first: the row is in the
827
+ // sidebar before Google has heard of it. The saved title is named
828
+ // in the status line, never "saved" alone.
735
829
  const targets = events.length ? events : [null];
736
- // The page opens in the browser's DEFAULT Google account, which need
737
- // not be the one mailx's calendar reads (Bob 2026-09-10: "I just used
738
- // the add event but don't see them on Google calendar"). authuser=
739
- // names the account, so the event lands where the sidebar looks.
740
- let authuser = "";
830
+ const saved = [];
831
+ const calendarId = calSelect.value || "primary";
832
+ const calendarName = calSelect.selectedOptions[0]?.textContent || "";
741
833
  try {
742
- authuser = String((await getPrimaryAccount("calendar"))?.email || "");
743
- }
744
- catch (e) {
745
- console.warn("[calendar] primary account lookup failed, opening without authuser:", e?.message || e);
834
+ localStorage.setItem(LAST_CALENDAR_PREF, calendarId);
746
835
  }
836
+ catch { /* per-viewer convenience only */ }
747
837
  for (const ev of targets) {
748
- const url = buildGoogleEventUrl(ev, text, authuser);
749
- if (api?.openExternal)
750
- api.openExternal(url);
751
- else
752
- window.open(url, "_blank");
838
+ const row = { ...eventToLocalRow(ev, text), calendarId };
839
+ try {
840
+ await createCalendarEvent(row);
841
+ saved.push(row.title);
842
+ }
843
+ catch (e) {
844
+ // Stop at the first failure and leave the dialog open with the
845
+ // reason: a silent partial save is worse than none.
846
+ status.textContent = `Couldn't save "${row.title}": ${e?.message || e}`;
847
+ createBtn.disabled = false;
848
+ cancelBtn.disabled = false;
849
+ return;
850
+ }
753
851
  }
754
- armPostCreateRefresh();
755
852
  close();
853
+ const st = document.getElementById("status-sync");
854
+ const where = calendarName && !calRow.hidden ? ` to ${calendarName}` : "";
855
+ if (st)
856
+ st.textContent = saved.length === 1 ? `Saved event${where}: ${saved[0]}` : `Saved ${saved.length} events${where}: ${saved.join("; ")}`;
857
+ await refresh();
756
858
  });
757
859
  }
758
- /** After handing an event to Google Calendar in the browser, pull it back
759
- * as soon as it can exist: the next time this window regains focus (the
760
- * reader saved in the browser and came back), and on a short schedule in
761
- * case the browser sits on another monitor and focus never moves. Each
762
- * pull goes through refreshCalendarNow, which has its own 20 s floor and
763
- * emits calendarUpdated when rows changed — the sidebar re-renders from
764
- * that event, not from here. Disarms after the window; a second create
765
- * re-arms. (Bob 2026-09-10: "creating an event should trigger an
766
- * immediate update for the calendar view".) */
767
- const POST_CREATE_REFRESH_DELAYS_MS = [45_000, 120_000, 300_000];
768
- let postCreateTimers = [];
769
- let postCreateFocusHandler = null;
770
- function armPostCreateRefresh() {
771
- disarmPostCreateRefresh();
772
- const pull = async (why) => {
773
- try {
774
- const r = await refreshCalendarNow();
775
- if (!r?.ok)
776
- console.log(`[calendar] post-create refresh (${why}) not started: ${r?.reason || "unknown"}`);
777
- }
778
- catch (e) {
779
- console.warn(`[calendar] post-create refresh (${why}) failed:`, e?.message || e);
780
- }
781
- };
782
- postCreateFocusHandler = () => { void pull("window focus"); };
783
- window.addEventListener("focus", postCreateFocusHandler);
784
- postCreateTimers = POST_CREATE_REFRESH_DELAYS_MS.map((ms, i) => window.setTimeout(() => {
785
- void pull(`${ms / 1000} s after create`);
786
- if (i === POST_CREATE_REFRESH_DELAYS_MS.length - 1)
787
- disarmPostCreateRefresh();
788
- }, ms));
789
- }
790
- function disarmPostCreateRefresh() {
791
- for (const t of postCreateTimers)
792
- window.clearTimeout(t);
793
- postCreateTimers = [];
794
- if (postCreateFocusHandler)
795
- window.removeEventListener("focus", postCreateFocusHandler);
796
- postCreateFocusHandler = null;
797
- }
798
- /** Compose Google Calendar's "render?action=TEMPLATE" URL with extracted
799
- * fields. When `event` is null we still produce a useful URL — the raw
800
- * description goes into `text=` (becomes the title) and Google's UI lets
801
- * the user fill in dates manually. Times are emitted in compact form
802
- * without `Z` so Google interprets them in the user's calendar timezone. */
803
- function buildGoogleEventUrl(event, fallbackText, authuser = "") {
804
- const base = "https://calendar.google.com/calendar/render?action=TEMPLATE";
805
- const params = [];
806
- // Which Google account the page opens in — the one mailx's calendar
807
- // reads, not whichever the browser signed in last (2026-09-10).
808
- if (authuser)
809
- params.push(`authuser=${encodeURIComponent(authuser)}`);
810
- const fmt = (iso) => {
811
- // ISO "2026-05-06T12:00:00" → "20260506T120000". Google accepts
812
- // local-naive times in this form.
813
- const m = iso.match(/(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?/);
814
- if (!m)
815
- return "";
816
- const [, y, mo, d, hh, mm, ss] = m;
817
- const date = `${y}${mo}${d}`;
818
- if (!hh)
819
- return date; // all-day form: dates=YYYYMMDD/YYYYMMDD
820
- return `${date}T${hh}${mm}${ss || "00"}`;
821
- };
822
- if (event?.title)
823
- params.push(`text=${encodeURIComponent(event.title)}`);
860
+ /** The extracted event (wall-clock ISO strings in its own zone) as the
861
+ * row createCalendarEvent takes (epoch ms). A null event "Create as
862
+ * text" becomes an all-day event today titled with the raw text. */
863
+ function eventToLocalRow(event, fallbackText) {
864
+ const startOfToday = new Date();
865
+ startOfToday.setHours(0, 0, 0, 0);
866
+ if (!event?.startISO) {
867
+ return { title: (event?.title || fallbackText).slice(0, 200), startMs: startOfToday.getTime(), endMs: startOfToday.getTime() + 86400_000, allDay: true };
868
+ }
869
+ const allDay = !!event.allDay || !event.startISO.includes("T");
870
+ const startMs = allDay ? new Date(event.startISO.slice(0, 10) + "T00:00:00").getTime() : wallClockToMs(event.startISO, event.timeZone);
871
+ let endMs;
872
+ if (event.endISO)
873
+ endMs = allDay ? new Date(event.endISO.slice(0, 10) + "T00:00:00").getTime() : wallClockToMs(event.endISO, event.timeZone);
824
874
  else
825
- params.push(`text=${encodeURIComponent(fallbackText.slice(0, 200))}`);
826
- if (event?.startISO) {
827
- const startFmt = fmt(event.startISO);
828
- // For all-day events Google wants `dates=YYYYMMDD/YYYYMMDD` where
829
- // the end date is exclusive (next day). For timed events it's
830
- // `YYYYMMDDTHHMMSS/YYYYMMDDTHHMMSS`. Default end = +1h timed,
831
- // +1 day all-day.
832
- if (event.allDay) {
833
- const startDate = new Date(event.startISO + (event.startISO.includes("T") ? "" : "T00:00:00"));
834
- const endDate = new Date(startDate.getTime() + 86400_000);
835
- const endFmt = `${endDate.getFullYear()}${String(endDate.getMonth() + 1).padStart(2, "0")}${String(endDate.getDate()).padStart(2, "0")}`;
836
- params.push(`dates=${startFmt}/${endFmt}`);
837
- }
838
- else {
839
- const endFmt = event.endISO
840
- ? fmt(event.endISO)
841
- : fmt(new Date(new Date(event.startISO).getTime() + 3600_000).toISOString().slice(0, 19));
842
- params.push(`dates=${startFmt}/${endFmt}`);
875
+ endMs = startMs + (allDay ? 86400_000 : 3600_000);
876
+ // An all-day end is exclusive; the extractor may hand back the same day
877
+ // for start and end, which would be a zero-length event.
878
+ if (allDay && endMs <= startMs)
879
+ endMs = startMs + 86400_000;
880
+ return { title: event.title || fallbackText.slice(0, 200), startMs, endMs, allDay, location: event.location || undefined, notes: event.notes || undefined };
881
+ }
882
+ /** Epoch ms for a wall-clock ISO string ("2026-09-12T09:00:00") read in
883
+ * `timeZone` an invite that says "9am Kuala Lumpur" stays 9am there.
884
+ * Without a zone the string is read as local time, which is what
885
+ * `new Date(iso)` does for an offset-less string. */
886
+ function wallClockToMs(iso, timeZone) {
887
+ const local = new Date(iso).getTime();
888
+ if (!timeZone)
889
+ return local;
890
+ try {
891
+ // Offset of the zone at that instant: format the local guess back in
892
+ // the zone, and the difference is the correction. Two passes settle a
893
+ // guess that straddles a DST change.
894
+ let ms = local;
895
+ for (let i = 0; i < 2; i++) {
896
+ const parts = new Intl.DateTimeFormat("en-US", { timeZone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" })
897
+ .formatToParts(new Date(ms)).reduce((o, p) => (o[p.type] = p.value, o), {});
898
+ const asIfLocal = new Date(`${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}`).getTime();
899
+ ms += local - asIfLocal;
843
900
  }
901
+ return ms;
902
+ }
903
+ catch (e) {
904
+ console.warn(`[calendar] unknown time zone "${timeZone}", reading the time as local:`, e?.message || e);
905
+ return local;
844
906
  }
845
- if (event?.location)
846
- params.push(`location=${encodeURIComponent(event.location)}`);
847
- if (event?.notes)
848
- params.push(`details=${encodeURIComponent(event.notes)}`);
849
- // Times above are wall-clock in the event's own zone (an invite that says
850
- // "9am Kuala Lumpur" stays 9am); ctz tells Google which zone that is.
851
- if (event?.timeZone)
852
- params.push(`ctz=${encodeURIComponent(event.timeZone)}`);
853
- return `${base}&${params.join("&")}`;
854
907
  }
855
908
  /** Show the sidebar (called from the View menu toggle). Idempotent. */
856
909
  export async function showCalendarSidebar() {
@@ -900,9 +953,24 @@ export function initCalendarSidebar() {
900
953
  el.__wired = true;
901
954
  el.addEventListener("click", fn);
902
955
  };
903
- wireOnce("cal-side-prev", () => { const d = new Date(viewYear, viewMonth, viewDay - 1); viewYear = d.getFullYear(); viewMonth = d.getMonth(); viewDay = d.getDate(); refresh(); });
904
- wireOnce("cal-side-next", () => { const d = new Date(viewYear, viewMonth, viewDay + 1); viewYear = d.getFullYear(); viewMonth = d.getMonth(); viewDay = d.getDate(); refresh(); });
905
- wireOnce("cal-side-today", () => { const t = new Date(); viewYear = t.getFullYear(); viewMonth = t.getMonth(); viewDay = t.getDate(); refresh(); });
956
+ wireOnce("cal-side-prev", () => { viewPinned = true; const d = new Date(viewYear, viewMonth, viewDay - 1); viewYear = d.getFullYear(); viewMonth = d.getMonth(); viewDay = d.getDate(); refresh(); });
957
+ wireOnce("cal-side-next", () => { viewPinned = true; const d = new Date(viewYear, viewMonth, viewDay + 1); viewYear = d.getFullYear(); viewMonth = d.getMonth(); viewDay = d.getDate(); refresh(); });
958
+ wireOnce("cal-side-today", () => { viewPinned = false; followToday(); refresh(); });
959
+ if (!window.__mailxCalRolloverArmed) {
960
+ window.__mailxCalRolloverArmed = true;
961
+ armMidnightRollover();
962
+ // Coming back to mailx from elsewhere — Google Calendar in the
963
+ // browser, most likely — pull what changed there. The daemon's 20 s
964
+ // floor turns a flapping window into at most one call per 20 s.
965
+ window.addEventListener("focus", () => {
966
+ if (!isCalendarSidebarOn())
967
+ return;
968
+ void refreshCalendarNow().then(r => {
969
+ if (!r?.ok && r?.reason && !/refreshed \d+ s ago/.test(r.reason))
970
+ console.log(`[calendar] focus refresh not started: ${r.reason}`);
971
+ }).catch((e) => console.warn("[calendar] focus refresh failed:", e?.message || e));
972
+ });
973
+ }
906
974
  wireOnce("cal-side-new", () => { openNewEventDialog(); });
907
975
  wireOnce("cal-side-new-task", async () => {
908
976
  const title = prompt("Task title:");
@@ -947,7 +1015,17 @@ export function initCalendarSidebar() {
947
1015
  try {
948
1016
  // Also re-enumerate calendars — the user may have selected /
949
1017
  // deselected one in Google since the sidebar opened.
950
- await Promise.all([renderCalendarList(), refresh()]);
1018
+ // refreshCalendarNow is what makes this button honest: refresh()
1019
+ // alone re-reads local rows, and the daemon's 5-minute throttle
1020
+ // turned "Refresh events from Google" into a no-op most of the
1021
+ // time (Bob 2026-09-10: "still showing me yesterday's calendar").
1022
+ // Its emit re-renders the list when the pull changes anything.
1023
+ const [, , pulled] = await Promise.all([renderCalendarList(), refresh(), refreshCalendarNow()]);
1024
+ if (!pulled?.ok) {
1025
+ const st = document.getElementById("status-sync");
1026
+ if (st)
1027
+ st.textContent = `Calendar not pulled from Google: ${pulled?.reason || "unknown"}`;
1028
+ }
951
1029
  }
952
1030
  finally {
953
1031
  setTimeout(() => {