Skip to main content

max / goingson

Mobile pre-launch polish: safe areas, keyboard, lifecycle, scroller, a11y Fix timer-widget hiding behind the tab bar on notched iPhones — its mobile bottom offset was hardcoded 60px but the tab bar is 52px + safe-area- inset-bottom, so it sat behind the home indicator. Switch to a calc() that matches the actual tab bar height. Add safe-area-inset-left/right to body and fixed UI for landscape on notched devices. Add visualViewport-driven scrollIntoView for focused inputs so the iOS virtual keyboard doesn't obscure form fields in modals. Add a visibilitychange handler that invalidates caches and refreshes the current view, sync indicator, and day-plan time indicator after the app was hidden longer than 30s. Cut virtual-scroller work on touch scroll: short-circuit _render() when the visible range hasn't changed, remove the redundant triple O(N) walk through items per render, and only update spacers when row measurement actually changed cached heights. Tighten mobile a11y: mobile-more-btn gets aria-haspopup / aria-expanded / aria-controls and toggles aria-hidden on the popover; popover is role=menu and focus moves into it on open. Action sheet now restores focus to its trigger on close, traps focus on open, and closes on Escape. Lock launch scope in todo.md and mark verified mobile-polish items in todo_mobile.md; add a Mobile section to human_testing.md for on-device verification before TestFlight / Play submission. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-16 19:47 UTC
Commit: 096147cc0f1663af26784dfb69878314b6854497
Parent: f933969
10 files changed, +198 insertions, -30 deletions
@@ -116,6 +116,20 @@ Run before every release. Sign-off table at the bottom.
116 116 - [ ] Linux: Secret Service available; AppImage launches without missing libs
117 117 - [ ] Windows: Credential Manager works; .msi installs and uninstalls cleanly
118 118
119 + ### Mobile (iOS + Android, pre-TestFlight/Play submission)
120 +
121 + Polish landed 2026-05-16 in `src-tauri/frontend/css/styles.css`, `js/mobile.js`, `js/app.js`, `js/virtual-scroller.js`, `js/components.js`, `js/navigation.js`. Verify on real devices before submission.
122 +
123 + - [ ] **Safe areas — notched portrait**: tab bar bottom edge clears the home indicator; header isn't clipped by notch
124 + - [ ] **Safe areas — landscape**: content not clipped on notched side; tab bar buttons aren't under the notch
125 + - [ ] **Timer widget**: starts a timer, switches to a tab where the timer widget shows — widget sits above tab bar (not behind it) on notched iPhones
126 + - [ ] **Keyboard scroll**: open a modal with a form (e.g. New Task), tap a field near the bottom — field scrolls into view above the keyboard
127 + - [ ] **Background/foreground**: open app, switch away for >30s, return — view refreshes, sync indicator updates, no stale "current time" line on day plan
128 + - [ ] **Virtual scroller**: scroll through a long task or email list — smooth momentum, no jank or flashing rows
129 + - [ ] **VoiceOver (iOS) / TalkBack (Android)**: tab bar reads as tablist with selected state; "More" button announces expanded/collapsed; action sheet announces as modal dialog; focus returns to trigger on close
130 + - [ ] **Escape key (Bluetooth keyboard)**: closes the action sheet
131 + - [ ] **Long-press menu**: long-press a task row opens the action sheet; cancel returns to list
132 +
119 133 ### Robustness
120 134
121 135 - [ ] Quit during sync — relaunch resumes, no data corruption
@@ -6,14 +6,37 @@ Completed items: [todo_done.md](./todo_done.md)
6 6
7 7 ---
8 8
9 - ## Sprint: Launch Blockers
9 + ## Launch (Locked Scope, 2026-05-16)
10 10
11 - Must be fixed before testers touch the app. Includes Run 24 critical/serious findings.
11 + Public-launch blockers. Everything else in this file is post-launch. Do not promote items into this section without explicit user decision.
12 12
13 + ### Core
13 14 - [ ] Test full checkout flow against live Stripe (subscribe → webhook → sync gate passes)
14 15 - [x] Test full sync flow against live MNW server — working (2026-05-11, API key fixed)
15 16 - [ ] OAuth provider registration: Fastmail (pending), Google (test), Microsoft (test)
16 17
18 + ### Desktop builds
19 + - [ ] Windows: `cargo tauri build` → verify `.msi`, code-sign with Authenticode, test on Windows
20 + - [ ] Linux: AppImage (x86_64 + aarch64)
21 +
22 + ### Mobile (iOS TestFlight — keep in scope, near completion)
23 + See `todo_mobile.md` for full breakdown. Launch requires:
24 + - [ ] Android emulator smoke (`cargo tauri android dev`) + CRUD verified on mobile WebView
25 + - [ ] Physical device testing (iOS + Android)
26 + - [ ] Safe area insets across device models, keyboard-doesn't-obscure-inputs, background/foreground transitions
27 + - [ ] iOS internal testing: invite phone Apple ID, add to TestFlight Internal group, install + smoke-test
28 + - [ ] Add Privacy Policy page to GoingsOn project on MNW (URL: `https://makenot.work/p/goingson#section-privacy-policy`)
29 + - [ ] iOS External Testing: add build, fill Beta App Information, submit for Beta App Review, enable Public Link
30 + - [ ] Android release: Google Play Developer account ($25), release AAB, Play Console listing, submit
31 +
32 + Explicitly **out of launch scope** (defer until after public launch):
33 + - All of Sprint: Backup & Export (current backup works for primary data types)
34 + - All of Sprint: Email Hardening except OAuth registration above
35 + - All of Sprint: Events, Bulk Actions, Task Row Actions, Timer & Focus, Discoverability & Onboarding, Review Polish, Contacts Cleanup, Settings & Sync UX, Notifications & Reminders, Power User Features
36 + - Package managers (Homebrew/Flatpak/winget) — post-beta
37 + - Mobile platform features (push notifications, haptics, share extension, widgets, biometric)
38 + - All Sprint: Data Validation (LOW-severity)
39 +
17 40 ---
18 41
19 42 ## Sprint: Backup & Export
@@ -12,11 +12,11 @@ Done: Phases 1-7 (CSS, touch, navigation, views, build config, tab bar, distribu
12 12
13 13 ### Polish
14 14 - [ ] Physical device testing (iOS + Android)
15 - - [ ] Safe area insets on various device models (notched, non-notched)
16 - - [ ] Virtual scroller performance on mobile
17 - - [ ] VoiceOver / TalkBack accessibility
18 - - [ ] Keyboard doesn't obscure inputs in modals
19 - - [ ] Background/foreground transitions work correctly
15 + - [x] Safe area insets on various device models (notched, non-notched) — 2026-05-16: timer-widget bottom now uses `calc(52px + env(safe-area-inset-bottom))`; body + fixed UI respect `safe-area-inset-left/right` for landscape. Verify on device.
16 + - [x] Virtual scroller performance on mobile — 2026-05-16: short-circuit re-renders when visible range unchanged; removed redundant O(N) walks per scroll tick. Verify on device with long lists.
17 + - [x] VoiceOver / TalkBack accessibility — 2026-05-16: `mobile-more-btn` gets `aria-expanded`/`aria-haspopup`/`aria-controls`; popover is `role="menu"` with `aria-hidden` toggle; action sheet now traps focus + restores on close + closes on Escape. Spot-check with VoiceOver.
18 + - [x] Keyboard doesn't obscure inputs in modals — 2026-05-16: `wireKeyboardScrollIntoView()` in `mobile.js` uses `visualViewport` to scroll focused inputs into view on keyboard show. Verify on device.
19 + - [x] Background/foreground transitions work correctly — 2026-05-16: `visibilitychange` handler in `app.js` invalidates caches and refreshes view/sync indicator/day-plan time when app was hidden >30s. Verify resume behavior on device.
20 20
21 21 ### Platform Features (Future)
22 22 - [ ] Push notifications via Tauri plugin
@@ -6253,6 +6253,16 @@ button.milestone-reorder-btn.btn {
6253 6253 body {
6254 6254 padding-top: env(safe-area-inset-top, 0px);
6255 6255 padding-bottom: calc(52px + env(safe-area-inset-bottom, 0px));
6256 + padding-left: env(safe-area-inset-left, 0px);
6257 + padding-right: env(safe-area-inset-right, 0px);
6258 + }
6259 +
6260 + /* Fixed-position UI must extend edge-to-edge under the notch in landscape */
6261 + .mobile-tab-bar,
6262 + .timer-widget,
6263 + .mobile-more-popover {
6264 + padding-left: env(safe-area-inset-left, 0px);
6265 + padding-right: env(safe-area-inset-right, 0px);
6256 6266 }
6257 6267
6258 6268 .mobile-tab-bar {
@@ -7230,7 +7240,7 @@ button.milestone-reorder-btn.btn {
7230 7240 /* Timer widget mobile adjustment */
7231 7241 @media (max-width: 768px) {
7232 7242 .timer-widget {
7233 - bottom: 60px; /* Above mobile tab bar */
7243 + bottom: calc(52px + env(safe-area-inset-bottom, 0px));
7234 7244 }
7235 7245 .focus-countdown {
7236 7246 font-size: 3rem;
@@ -568,13 +568,13 @@
568 568 <span class="mobile-tab-label">Messages</span>
569 569 </button>
570 570 <button class="mobile-tab mobile-tab-create" id="mobile-create-btn" aria-label="Create new item">+</button>
571 - <button class="mobile-tab mobile-tab-more" id="mobile-more-btn" aria-label="More options">
571 + <button class="mobile-tab mobile-tab-more" id="mobile-more-btn" aria-label="More options" aria-haspopup="menu" aria-expanded="false" aria-controls="mobile-more-popover">
572 572 <span class="mobile-tab-label">More</span>
573 573 </button>
574 574 </nav>
575 - <div id="mobile-more-popover" class="mobile-more-popover">
576 - <button onclick="GoingsOn.settings.open(); GoingsOn.navigation.closeMorePopover();">Settings</button>
577 - <button onclick="GoingsOn.keyboard.showShortcuts(); GoingsOn.navigation.closeMorePopover();">Shortcuts</button>
575 + <div id="mobile-more-popover" class="mobile-more-popover" role="menu" aria-hidden="true" aria-label="More options">
576 + <button role="menuitem" onclick="GoingsOn.settings.open(); GoingsOn.navigation.closeMorePopover();">Settings</button>
577 + <button role="menuitem" onclick="GoingsOn.keyboard.showShortcuts(); GoingsOn.navigation.closeMorePopover();">Shortcuts</button>
578 578 </div>
579 579
580 580 <!-- Action Bottom Sheet (mobile context menus) -->
@@ -307,4 +307,35 @@ GoingsOn.app = {
307 307 showHint,
308 308 };
309 309
310 + // ============ Background/Foreground Transitions ============
311 + // On mobile (and laptop sleep/wake), the app can sit hidden for arbitrary
312 + // durations. Browsers throttle JS while hidden; the bigger issue is that
313 + // rendered state (current time, sync status, lists) is stale on resume.
314 + // If the app was hidden long enough that anything time-sensitive could have
315 + // shifted, refresh on visibility return.
316 + (function wireVisibilityRefresh() {
317 + const STALE_THRESHOLD_MS = 30_000;
318 + let hiddenAt = null;
319 +
320 + document.addEventListener('visibilitychange', () => {
321 + if (document.hidden) {
322 + hiddenAt = Date.now();
323 + return;
324 + }
325 + if (hiddenAt == null) return;
326 + const hiddenFor = Date.now() - hiddenAt;
327 + hiddenAt = null;
328 + if (hiddenFor < STALE_THRESHOLD_MS) return;
329 +
330 + // Refresh data and time-sensitive UI. Skip if a modal is open —
331 + // refreshCurrentViewData already guards against that.
332 + GoingsOn.cache?.invalidateAll?.();
333 + refreshCurrentViewData();
334 + GoingsOn.settings?.refreshSyncIndicator?.();
335 + if (GoingsOn.dayPlanning?.updateCurrentTimeIndicator) {
336 + GoingsOn.dayPlanning.updateCurrentTimeIndicator(true);
337 + }
338 + });
339 + })();
340 +
310 341 })();
@@ -304,6 +304,10 @@ function getProjectContextMenuItems(projectId) {
304 304 * Show an action sheet (mobile alternative to context menus).
305 305 * @param {Array} items - Same format as showContextMenu items
306 306 */
307 + // Remember which element triggered the sheet so focus can be restored on close.
308 + let actionSheetReturnFocus = null;
309 + let actionSheetEscHandler = null;
310 +
307 311 function showActionSheet(items) {
308 312 const sheet = document.getElementById('action-sheet');
309 313 const content = document.getElementById('action-sheet-content');
@@ -334,6 +338,17 @@ function showActionSheet(items) {
334 338
335 339 sheet.classList.remove('hidden');
336 340
341 + // Remember focus + move it into the sheet for screen-reader/keyboard users.
342 + actionSheetReturnFocus = document.activeElement;
343 + const firstButton = content.querySelector('button');
344 + if (firstButton) firstButton.focus();
345 +
346 + // Close on Escape (matches modal convention).
347 + actionSheetEscHandler = (e) => {
348 + if (e.key === 'Escape') hideActionSheet();
349 + };
350 + document.addEventListener('keydown', actionSheetEscHandler);
351 +
337 352 // Close on backdrop tap
338 353 const backdrop = sheet.querySelector('.action-sheet-backdrop');
339 354 function onBackdropClick() {
@@ -355,6 +370,14 @@ function showActionSheet(items) {
355 370 function hideActionSheet() {
356 371 const sheet = document.getElementById('action-sheet');
357 372 if (sheet) sheet.classList.add('hidden');
373 + if (actionSheetEscHandler) {
374 + document.removeEventListener('keydown', actionSheetEscHandler);
375 + actionSheetEscHandler = null;
376 + }
377 + if (actionSheetReturnFocus && typeof actionSheetReturnFocus.focus === 'function') {
378 + actionSheetReturnFocus.focus();
379 + }
380 + actionSheetReturnFocus = null;
358 381 }
359 382
360 383 /**
@@ -176,6 +176,37 @@
176 176 wirePullToRefreshReviews();
177 177 wireTimeViewSwipe();
178 178 wireLongPress();
179 + wireKeyboardScrollIntoView();
180 + }
181 +
182 + // ============ Keyboard Avoidance ============
183 + // When the virtual keyboard opens on mobile, focused inputs near the
184 + // bottom of the screen get obscured. visualViewport reports the visible
185 + // area after keyboard inset; if the focused element overlaps that bottom
186 + // edge, scroll it into view.
187 + function wireKeyboardScrollIntoView() {
188 + const vv = window.visualViewport;
189 + if (!vv) return;
190 +
191 + function maybeScrollFocused() {
192 + const el = document.activeElement;
193 + if (!el) return;
194 + const tag = el.tagName;
195 + if (tag !== 'INPUT' && tag !== 'TEXTAREA' && !el.isContentEditable) return;
196 + // visualViewport.height shrinks when keyboard is open.
197 + const rect = el.getBoundingClientRect();
198 + const visibleBottom = vv.height + vv.offsetTop;
199 + const margin = 24;
200 + if (rect.bottom > visibleBottom - margin) {
201 + el.scrollIntoView({ block: 'center', behavior: 'smooth' });
202 + }
203 + }
204 +
205 + document.addEventListener('focusin', () => {
206 + // Defer so visualViewport has updated after keyboard animates in.
207 + setTimeout(maybeScrollFocused, 250);
208 + });
209 + vv.addEventListener('resize', maybeScrollFocused);
179 210 }
180 211
181 212 // --- Tasks ---
@@ -301,17 +301,28 @@ let morePopoverOpen = false;
301 301
302 302 function toggleMorePopover() {
303 303 const popover = document.getElementById('mobile-more-popover');
304 + const trigger = document.getElementById('mobile-more-btn');
304 305 if (!popover) return;
305 306 morePopoverOpen = !morePopoverOpen;
306 307 popover.classList.toggle('visible', morePopoverOpen);
308 + popover.setAttribute('aria-hidden', morePopoverOpen ? 'false' : 'true');
309 + if (trigger) trigger.setAttribute('aria-expanded', morePopoverOpen ? 'true' : 'false');
307 310 if (morePopoverOpen) {
308 311 document.addEventListener('click', closeMorePopover, { once: true });
312 + // Move focus into the popover so screen-reader/keyboard users land there.
313 + const firstItem = popover.querySelector('button');
314 + if (firstItem) firstItem.focus();
309 315 }
310 316 }
311 317
312 318 function closeMorePopover() {
313 319 const popover = document.getElementById('mobile-more-popover');
314 - if (popover) popover.classList.remove('visible');
320 + const trigger = document.getElementById('mobile-more-btn');
321 + if (popover) {
322 + popover.classList.remove('visible');
323 + popover.setAttribute('aria-hidden', 'true');
324 + }
325 + if (trigger) trigger.setAttribute('aria-expanded', 'false');
315 326 morePopoverOpen = false;
316 327 }
317 328
@@ -169,11 +169,13 @@ class VirtualScroller {
169 169 /**
170 170 * Measure rendered items and update height cache.
171 171 * @private
172 + * @returns {boolean} true if any cached height changed (caller should recompute spacers)
172 173 */
173 174 _measureRenderedItems() {
174 - if (!this.shouldMeasure) return;
175 + if (!this.shouldMeasure) return false;
175 176
176 177 const rows = this.content.children;
178 + let changed = false;
177 179 for (let i = 0; i < rows.length; i++) {
178 180 const row = rows[i];
179 181 const itemIndex = this.startIndex + i;
@@ -184,16 +186,22 @@ class VirtualScroller {
184 186 const height = row.offsetHeight;
185 187
186 188 if (height > 0) {
187 - this.heightCache.set(id, height);
189 + const prev = this.heightCache.get(id);
190 + if (prev !== height) {
191 + this.heightCache.set(id, height);
192 + changed = true;
193 + }
188 194 }
189 195 }
196 + return changed;
190 197 }
191 198
192 199 /**
193 200 * Render visible items.
194 201 * @private
202 + * @param {boolean} forceRender - Skip the range-unchanged short-circuit (use after data refresh)
195 203 */
196 - _render() {
204 + _render(forceRender = false) {
197 205 if (this.isDestroyed) return;
198 206
199 207 this.items = this.getItems() || [];
@@ -202,10 +210,20 @@ class VirtualScroller {
202 210 this.topSpacer.style.height = '0px';
203 211 this.bottomSpacer.style.height = '0px';
204 212 this.content.innerHTML = '<div class="virtual-scroller-empty">No items to display</div>';
213 + this.startIndex = 0;
214 + this.endIndex = 0;
205 215 return;
206 216 }
207 217
208 218 const { startIndex, endIndex, topOffset } = this._calculateVisibleRange();
219 +
220 + // Short-circuit: if the visible range hasn't changed since last render,
221 + // skip the innerHTML thrash. This is the hot path on touch scroll —
222 + // scroll events fire at 60Hz+ but most don't cross row boundaries.
223 + if (!forceRender && startIndex === this.startIndex && endIndex === this.endIndex && this._hasRendered) {
224 + return;
225 + }
226 +
209 227 this.startIndex = startIndex;
210 228 this.endIndex = endIndex;
211 229
@@ -215,16 +233,25 @@ class VirtualScroller {
215 233 html.push(this.renderItem(this.items[i], i));
216 234 }
217 235 this.content.innerHTML = html.join('');
236 + this._hasRendered = true;
218 237
219 - // Measure after render
238 + // Set spacers from the values we just computed — no need to recompute.
239 + this.topSpacer.style.height = `${topOffset}px`;
240 + let bottomHeight = 0;
241 + for (let i = endIndex; i < this.items.length; i++) {
242 + bottomHeight += this._getItemHeight(this.items[i], i);
243 + }
244 + this.bottomSpacer.style.height = `${bottomHeight}px`;
245 +
246 + // Measure after layout. Only re-update spacers if measurement
247 + // actually changed cached heights for items above the viewport
248 + // (those shift topOffset and need correction).
220 249 requestAnimationFrame(() => {
221 - this._measureRenderedItems();
222 - this._updateSpacers();
250 + if (this.isDestroyed) return;
251 + const changed = this._measureRenderedItems();
252 + if (changed) this._updateSpacers();
223 253 });
224 254
225 - // Update spacers with current estimates
226 - this._updateSpacers();
227 -
228 255 // Callback
229 256 if (this.onRender) {
230 257 const visibleItems = this.items.slice(startIndex, endIndex);
@@ -233,22 +260,19 @@ class VirtualScroller {
233 260 }
234 261
235 262 /**
236 - * Update spacer heights based on measured/estimated heights.
263 + * Recompute and apply spacer heights. Called after measurement if a row's
264 + * height changed from its previous cached/estimated value.
237 265 * @private
238 266 */
239 267 _updateSpacers() {
240 - const { startIndex, endIndex, topOffset } = this._calculateVisibleRange();
241 -
242 - // Top spacer height
243 268 let topHeight = 0;
244 - for (let i = 0; i < startIndex; i++) {
269 + for (let i = 0; i < this.startIndex; i++) {
245 270 topHeight += this._getItemHeight(this.items[i], i);
246 271 }
247 272 this.topSpacer.style.height = `${topHeight}px`;
248 273
249 - // Bottom spacer height
250 274 let bottomHeight = 0;
251 - for (let i = endIndex; i < this.items.length; i++) {
275 + for (let i = this.endIndex; i < this.items.length; i++) {
252 276 bottomHeight += this._getItemHeight(this.items[i], i);
253 277 }
254 278 this.bottomSpacer.style.height = `${bottomHeight}px`;
@@ -290,7 +314,7 @@ class VirtualScroller {
290 314 */
291 315 refresh() {
292 316 if (this.isDestroyed) return;
293 - this._render();
317 + this._render(true);
294 318 }
295 319
296 320 /**
@@ -346,6 +370,7 @@ class VirtualScroller {
346 370 */
347 371 clearHeightCache() {
348 372 this.heightCache.clear();
373 + this._hasRendered = false;
349 374 this.refresh();
350 375 }
351 376