Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(calendar): complete reminders, Web Push scheduling, and ICS overlay in all views (#368)

scott ba1b1fac a307cd92

+182 -6
+8
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.35.0] — 2026-04-13 11 + 12 + ### Added 13 + - Calendar: Web Push reminders now sync to the server scheduler so notifications fire even when the browser tab is closed — all events with reminders within the next 30 days are pushed to `/api/push/schedule` on load and after every save (#588, #590) 14 + - Calendar: persistent notifications toggle in the Settings popover — users who dismissed the one-time banner can enable or disable push reminders at any time (#594) 15 + - Calendar: external ICS subscription events now appear in week view and day view (timed blocks and all-day bar), consistent with the existing month and agenda view overlays (#595) 16 + 10 17 ## [0.34.0] — 2026-04-10 11 18 12 19 ### Added 20 + - feat: register Service Worker for guaranteed offline static asset caching (#607) 13 21 - Offline support: the landing page now caches the document list (active + trash) to `localStorage` after every successful fetch, and when the network is unavailable it re-renders from that cache with an "Offline — showing last-known document list" toast so you can still see your docs (and open cached ones) with no connection (#606) 14 22 - Offline support: every editor (docs, sheets, slides, forms, diagrams, calendar) now mounts a small fixed top-right "Offline" badge that appears when `navigator.onLine` flips to false and disappears when you reconnect — provides immediate feedback that changes are local-only until sync resumes (#606) 15 23 - Offline support: the `EncryptedProvider` already loads from the IndexedDB `tools-backups` store as a fallback when the server snapshot is unreachable, so opening a previously-visited document offline hydrates the Yjs doc from the local backup and you can keep editing — edits stay queued in IDB and sync automatically on reconnect (#606)
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.34.0", 3 + "version": "0.35.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+6
src/calendar/index.html
··· 127 127 </select> 128 128 </label> 129 129 <hr class="cal-settings-divider"> 130 + <div class="cal-settings-title">Notifications</div> 131 + <div class="cal-settings-row cal-notif-row" id="cal-notif-row"> 132 + <span id="cal-notif-status-label">Reminders</span> 133 + <button class="cal-notif-toggle-btn" id="cal-notif-toggle-btn" type="button">Enable</button> 134 + </div> 135 + <hr class="cal-settings-divider"> 130 136 <div class="cal-settings-title">Calendar Subscriptions</div> 131 137 <div class="cal-sub-list" id="cal-sub-list"></div> 132 138 <button class="btn-add-subscription" id="btn-add-subscription">+ Add subscription</button>
+140 -5
src/calendar/main.ts
··· 172 172 const subList = document.getElementById('cal-sub-list') as HTMLElement; 173 173 const addSubBtn = document.getElementById('btn-add-subscription') as HTMLButtonElement; 174 174 const subModalBackdrop = document.getElementById('sub-modal-backdrop') as HTMLElement; 175 + const notifToggleBtn = document.getElementById('cal-notif-toggle-btn') as HTMLButtonElement | null; 176 + const notifStatusLabel = document.getElementById('cal-notif-status-label') as HTMLElement | null; 175 177 176 178 // Notification / subscription state 177 179 let notificationsEnabled = false; ··· 608 610 } 609 611 html += '</div>'; 610 612 611 - // All-day event bar 612 - const hasAllDay = days.some(d => d.allDay.length > 0); 613 + // All-day event bar (own + external) 614 + const hasAllDay = days.some(d => { 615 + if (d.allDay.length > 0) return true; 616 + return externalEventsOnDate(d.dateStr).some(e => e.allDay); 617 + }); 613 618 if (hasAllDay) { 614 619 html += '<div class="cal-allday-bar">'; 615 620 html += '<div class="cal-time-gutter cal-allday-gutter">All day</div>'; ··· 619 624 html += `<div class="cal-event-pill" data-event-id="${escapeHtml(evt.id)}" tabindex="0" role="button" style="--pill-color: ${evt.color}">`; 620 625 html += escapeHtml(evt.title || 'Untitled'); 621 626 html += '</div>'; 627 + } 628 + for (const ext of externalEventsOnDate(day.dateStr).filter(e => e.allDay)) { 629 + html += externalEventPillHtml(ext); 622 630 } 623 631 html += '</div>'; 624 632 } ··· 660 668 html += '</div>'; 661 669 } 662 670 671 + // External timed events (read-only, no data-event-id) 672 + for (const ext of externalEventsOnDate(day.dateStr).filter(e => !e.allDay && e.startTime)) { 673 + const startMin = timeToMinutes(ext.startTime); 674 + const endMin = ext.endTime ? timeToMinutes(ext.endTime) : startMin + 60; 675 + const top = (startMin / 60) * HOUR_HEIGHT; 676 + const height = Math.max(((endMin - startMin) / 60) * HOUR_HEIGHT, 20); 677 + html += `<div class="cal-event-block cal-event-external" style="top:${top}px;height:${height}px;left:1px;width:calc(100% - 2px);--pill-color:${ext.color}" title="${escapeHtml(ext.title)}">`; 678 + html += `<div class="cal-event-block-time">${fmtTime(ext.startTime)}\u2013${fmtTime(ext.endTime)}</div>`; 679 + html += `<div class="cal-event-block-title">${escapeHtml(ext.title || 'Untitled')}</div>`; 680 + html += '</div>'; 681 + } 682 + 663 683 // Current time indicator 664 684 if (dayIsToday) { 665 685 const now = new Date(); ··· 698 718 699 719 let html = '<div class="cal-day-grid">'; 700 720 701 - // All-day section 702 - if (allDay.length > 0) { 721 + // All-day section (own + external) 722 + const extAllDay = externalEventsOnDate(dateStr).filter(e => e.allDay); 723 + if (allDay.length > 0 || extAllDay.length > 0) { 703 724 html += '<div class="cal-allday-bar">'; 704 725 html += '<div class="cal-time-gutter cal-day-allday-gutter">All day</div>'; 705 726 html += '<div class="cal-day-allday-cell">'; ··· 708 729 html += escapeHtml(evt.title || 'Untitled'); 709 730 html += '</div>'; 710 731 } 732 + for (const ext of extAllDay) { 733 + html += externalEventPillHtml(ext); 734 + } 711 735 html += '</div>'; 712 736 html += '</div>'; 713 737 } ··· 744 768 html += '</div>'; 745 769 } 746 770 771 + // External timed events (read-only) 772 + for (const ext of externalEventsOnDate(dateStr).filter(e => !e.allDay && e.startTime)) { 773 + const startMin = timeToMinutes(ext.startTime); 774 + const endMin = ext.endTime ? timeToMinutes(ext.endTime) : startMin + 60; 775 + const top = (startMin / 60) * HOUR_HEIGHT; 776 + const height = Math.max(((endMin - startMin) / 60) * HOUR_HEIGHT, 20); 777 + html += `<div class="cal-event-block cal-event-external" style="top:${top}px;height:${height}px;left:1px;width:calc(100% - 2px);--pill-color:${ext.color}" title="${escapeHtml(ext.title)}">`; 778 + html += `<div class="cal-event-block-time">${fmtTime(ext.startTime)}\u2013${fmtTime(ext.endTime)}</div>`; 779 + html += `<div class="cal-event-block-title">${escapeHtml(ext.title || 'Untitled')}</div>`; 780 + html += '</div>'; 781 + } 782 + 747 783 // Current time indicator 748 784 if (isToday(d)) { 749 785 const now = new Date(); ··· 993 1029 }); 994 1030 } 995 1031 1032 + function updateNotifUI(): void { 1033 + if (!notifToggleBtn || !notifStatusLabel) return; 1034 + if (!('Notification' in window)) { 1035 + notifToggleBtn.textContent = 'Not supported'; 1036 + notifToggleBtn.disabled = true; 1037 + notifStatusLabel.textContent = 'Reminders'; 1038 + return; 1039 + } 1040 + if (Notification.permission === 'denied') { 1041 + notifToggleBtn.textContent = 'Blocked'; 1042 + notifToggleBtn.disabled = true; 1043 + notifStatusLabel.textContent = 'Reminders (blocked in browser)'; 1044 + return; 1045 + } 1046 + if (Notification.permission === 'granted' && notificationsEnabled) { 1047 + notifToggleBtn.textContent = 'Disable'; 1048 + notifToggleBtn.disabled = false; 1049 + notifStatusLabel.textContent = 'Reminders (enabled)'; 1050 + } else { 1051 + notifToggleBtn.textContent = 'Enable'; 1052 + notifToggleBtn.disabled = false; 1053 + notifStatusLabel.textContent = 'Reminders'; 1054 + } 1055 + } 1056 + 1057 + notifToggleBtn?.addEventListener('click', async () => { 1058 + if (Notification.permission === 'granted' && notificationsEnabled) { 1059 + // Unsubscribe 1060 + if (pushSubscription) { 1061 + await fetch('/api/push/subscribe', { 1062 + method: 'DELETE', 1063 + headers: { 'Content-Type': 'application/json' }, 1064 + body: JSON.stringify({ endpoint: pushSubscription.endpoint }), 1065 + }).catch(() => {}); 1066 + await pushSubscription.unsubscribe().catch(() => {}); 1067 + pushSubscription = null; 1068 + } 1069 + notificationsEnabled = false; 1070 + updateNotifUI(); 1071 + return; 1072 + } 1073 + // Enable 1074 + const granted = await requestNotificationPermission(); 1075 + if (granted) { 1076 + notificationsEnabled = true; 1077 + await registerPushSubscription(); 1078 + await syncRemindersWithServer(); 1079 + localStorage.removeItem(NOTIF_DISMISSED_KEY); 1080 + } 1081 + updateNotifUI(); 1082 + }); 1083 + 996 1084 function checkInPageReminders(): void { 997 1085 if (!notificationsEnabled && Notification.permission !== 'granted') return; 998 1086 const now = new Date(); ··· 1007 1095 }); 1008 1096 } 1009 1097 } 1098 + } 1099 + 1100 + /** 1101 + * Push all upcoming event reminders to the server scheduler. 1102 + * The server fires Web Push notifications even when the browser tab is closed. 1103 + * Reminders within the next 30 days are synced; in-page setTimeout handles same-day ones. 1104 + */ 1105 + async function syncRemindersWithServer(): Promise<void> { 1106 + if (!pushSubscription) return; 1107 + 1108 + const now = new Date(); 1109 + const horizon = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); 1110 + 1111 + // Collect all future reminder fire times across all events 1112 + const toSchedule: Array<{ fireAt: number; encryptedPayload: string }> = []; 1113 + for (const event of state.events) { 1114 + if (!event.reminders || event.reminders.length === 0) continue; 1115 + for (const reminder of event.reminders) { 1116 + const fireTime = reminderFireTime(event, reminder); 1117 + if (!fireTime) continue; 1118 + if (fireTime <= now || fireTime > horizon) continue; 1119 + toSchedule.push({ 1120 + fireAt: Math.floor(fireTime.getTime() / 1000), 1121 + encryptedPayload: JSON.stringify({ 1122 + title: event.title || 'Calendar Reminder', 1123 + body: reminderLabel(reminder), 1124 + tag: `reminder-${event.id}-${reminder.amount}-${reminder.unit}`, 1125 + url: window.location.href, 1126 + eventId: event.id, 1127 + }), 1128 + }); 1129 + } 1130 + } 1131 + 1132 + try { 1133 + await fetch('/api/push/schedule', { 1134 + method: 'POST', 1135 + headers: { 'Content-Type': 'application/json' }, 1136 + body: JSON.stringify({ reminders: toSchedule }), 1137 + }); 1138 + } catch { /* offline — will retry on next sync */ } 1010 1139 } 1011 1140 1012 1141 // --------------------------------------------------------------------------- ··· 1272 1401 1273 1402 closeModal(); 1274 1403 scheduleAllReminders(event); 1404 + syncRemindersWithServer().catch(() => {}); 1275 1405 } 1276 1406 1277 1407 function deleteEvent(): void { ··· 2141 2271 loadEventsFromYjs(); 2142 2272 renderView(); 2143 2273 maybeShowNotifBanner(); 2274 + if (notificationsEnabled && pushSubscription) { 2275 + syncRemindersWithServer().catch(() => {}); 2276 + } 2144 2277 }); 2145 2278 2146 2279 await loadTitle(); ··· 2158 2291 renderSubList(); 2159 2292 if (Notification.permission === 'granted') { 2160 2293 notificationsEnabled = true; 2161 - registerPushSubscription(); 2294 + await registerPushSubscription(); 2162 2295 for (const evt of state.events) scheduleAllReminders(evt); 2296 + syncRemindersWithServer().catch(() => {}); 2163 2297 } 2298 + updateNotifUI(); 2164 2299 2165 2300 renderView(); 2166 2301 }
+27
src/css/app.css
··· 11023 11023 font-size: 0.8rem; 11024 11024 } 11025 11025 11026 + .cal-notif-row { 11027 + display: flex; 11028 + align-items: center; 11029 + justify-content: space-between; 11030 + padding: var(--space-xs) 0; 11031 + font-size: 0.85rem; 11032 + } 11033 + 11034 + .cal-notif-toggle-btn { 11035 + padding: 2px 10px; 11036 + font-size: 0.8rem; 11037 + border-radius: var(--radius-sm); 11038 + border: 1px solid var(--color-border); 11039 + background: var(--color-surface-raised, var(--color-surface)); 11040 + color: var(--color-text); 11041 + cursor: pointer; 11042 + } 11043 + .cal-notif-toggle-btn:hover:not(:disabled) { 11044 + background: var(--color-teal); 11045 + color: #fff; 11046 + border-color: var(--color-teal); 11047 + } 11048 + .cal-notif-toggle-btn:disabled { 11049 + opacity: 0.5; 11050 + cursor: not-allowed; 11051 + } 11052 + 11026 11053 /* ── Keyboard-focused event pill ─────────────────────────────────────── */ 11027 11054 11028 11055 .cal-event-pill:focus-visible,