Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ phone.
iPhone:

- Open the URL in Safari.
- Tap the ... menu.
- Tap Share.
- Tap Add to Home Screen.
- Open MAC Study from the new home-screen icon.
Expand Down
99 changes: 99 additions & 0 deletions docs/ios-pwa-mobile-nav.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# iOS home-screen app: mobile nav and the bottom gap

How the mobile nav fits the screen on an installed iPhone app, and why the
status bar style is `black` rather than `black-translucent`.

## Symptom

On an iPhone running the app from the home screen (standalone PWA), a dark
band about 59pt tall sat under the bottom nav. The nav and the page content
both stopped short of the bottom edge. It showed on a cold launch and had been
there since the PWA was first set up. Safari in a normal tab was not affected.

## Cause

This is an iOS 26 WebKit regression. It hits standalone web apps that use:

- `apple-mobile-web-app-status-bar-style: black-translucent`, and
- `viewport-fit=cover`.

With that combination, iOS makes the page's layout viewport shorter than the
screen by the top safe-area inset, which is the status bar height (about 59pt
on Dynamic Island iPhones, 47pt on notched ones). Every height the page can
read reports the short value: `100dvh`, `100vh`, `window.innerHeight` and
`visualViewport.height`. The page is also clipped at that line, so
`position: fixed; bottom: 0` elements end up above the real bottom edge. Only
the page background colour shows in the strip below.

Measured from a screenshot on our device, the scroll area and the nav both
ended about 59pt above the bottom, matching the top inset exactly.

## What did not work (commit `cfb43e5`, reverted in `f5fe454`)

We read the real screen height (`screen.height`) on iOS standalone, wrote it to
`--app-viewport-height`, and anchored the nav from the top instead of
`bottom: 0`. The nav moved down but was **cut off** at the same line where the
gap used to begin. That is how we confirmed the strip is outside the area the
page is allowed to draw in, so no CSS or JS resizing can reach it. Other
developers hitting the same bug report the same result.

## Fix (commit `e54c53b`)

`src/app/layout.tsx`:

```ts
appleWebApp: {
capable: true,
statusBarStyle: "black",
title: "MAC Study",
},
```

With `black`, iOS places the web view **below** the status bar instead of
under it. The top safe-area inset becomes 0, the layout viewport is no longer
short, and the nav reaches the bottom of the screen.

### Trade-off

The status bar strip is pure black (`#000`) instead of the app background
(`#171717`), so there is a faint colour step at the very top. Content no longer
draws behind the status bar.

### Things that still apply

- `--safe-area-top` resolves to `0` in the installed app now, so headers that
pad by it (for example the chat headers) simply lose that padding. No change
needed.
- `--safe-area-bottom` is still the home indicator inset (about 34pt). The
`@media (display-mode: standalone)` block in `src/app/globals.css` uses it to
lift the nav icons just clear of the indicator. Adjust the `- 1.25rem` in
`--mobile-nav-control-bottom` to change that spacing.

## Testing a change here

iOS reads the status bar style **when the app is added to the home screen**.
After changing it:

1. Deploy.
2. Delete the app from the home screen and add it again. Relaunching alone keeps
the old setting.
3. Cold-launch the app and check:
- The nav sits on the bottom edge, with no band below and nothing cut off.
- Headers aren't hidden under the status bar.
- In a chat, the composer sits at the bottom, rides above the keyboard, and
drops back when the keyboard closes.

## Before switching back to `black-translucent`

Only switch back once Apple fixes the regression, and test the result on a real
iOS 26+ device from the home screen. The simulator and Safari tabs do not show
the bug.

## References

- busssss PR #23, which traces the 47px bottom gap to an iOS 26 WebKit
regression: https://github.com/tpdbf5509/busssss/pull/23
- arc PR #8, which fixes the 59pt black strip by using `black` instead of
`black-translucent`: https://github.com/joejohnston72-dev/arc/pull/8
- 0xchat PR #135, the same gap, tried with `100lvh`:
https://github.com/endziu/0xchat/pull/135
15 changes: 15 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,21 @@ body {
}
}

/* Hide scroll indicators on mobile; scrolling itself still works. */
@media (max-width: 63.999rem) {
html,
body,
* {
scrollbar-width: none;
}

::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
}

@media (min-width: 64rem) {
.mac-desktop-shell {
background: var(--color-background);
Expand Down
5 changes: 4 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ export const metadata: Metadata = {
manifest: "/manifest.webmanifest",
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
// Not "black-translucent": on iOS 26 that shrinks the installed app's
// viewport and leaves a gap under the mobile nav.
// See docs/ios-pwa-mobile-nav.md.
statusBarStyle: "black",
title: "MAC Study",
},
icons: {
Expand Down
33 changes: 30 additions & 3 deletions src/components/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,20 @@ import {
} from "lucide-react";
import type { AppAuthState } from "@/lib/auth/app-auth";
import {
cacheRemoteFriendsSnapshot,
cacheRemoteGroupsSnapshot,
cacheRemoteTimerState,
cacheRemoteUnitState,
dedupeRemoteRequest,
getCachedRemoteFriendsSnapshot,
getCachedRemoteGroupsSnapshot,
getCachedRemoteTimerState,
getStaleRemoteTimerState,
} from "@/lib/client-cache";
import { getMascotSrc, MASCOT_KEYS } from "@/lib/mascots";
import {
fetchRemoteDirectMessageUnreadCount,
fetchRemoteFriendsSnapshot,
fetchRemoteGroupsSnapshot,
fetchRemoteTimerState,
fetchRemoteUnitState,
Expand Down Expand Up @@ -203,6 +207,23 @@ export function AppShell({
}
})();

if (!getCachedRemoteFriendsSnapshot(cacheUserId)) {
void (async () => {
try {
const supabase = createSupabaseBrowserClient();
const snapshot = await dedupeRemoteRequest({
key: "friends",
load: () => fetchRemoteFriendsSnapshot(supabase),
userId: cacheUserId,
});

if (!cancelled && snapshot) cacheRemoteFriendsSnapshot(snapshot);
} catch {
// Preloading is opportunistic; the Friends view can retry on demand.
}
})();
}

if (getCachedRemoteGroupsSnapshot(cacheUserId)) return;

void (async () => {
Expand All @@ -219,7 +240,9 @@ export function AppShell({
// Preloading is opportunistic; the Groups view can retry on demand.
}
})();
}, 350);
// Queued behind the timer request so Home still goes first, but early
// enough that the other tabs are warm by the time the splash leaves.
}, 0);

return () => {
cancelled = true;
Expand Down Expand Up @@ -255,8 +278,12 @@ export function AppShell({
const cacheUserId = currentUserId;
let cancelled = false;

// Fresh cached timer data is enough; don't refetch on every navigation.
if (getCachedRemoteTimerState(cacheUserId)) {
// Any recent timer snapshot is enough to paint Home: the dashboard shows
// it straight away and refetches behind it, so don't hold the splash.
if (
getCachedRemoteTimerState(cacheUserId) ||
getStaleRemoteTimerState(cacheUserId)
) {
revealApp();
return () => window.cancelAnimationFrame(readyFrame);
}
Expand Down
2 changes: 2 additions & 0 deletions src/components/pwa/install-onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import {
Download,
MonitorDown,
MoreHorizontal,
MoreVertical,
Pin,
Share,
Expand Down Expand Up @@ -53,6 +54,7 @@ type LauncherKind = "pc" | "phone";
const phoneGuides: Record<InstallPlatform, InstallStep[]> = {
ios: [
{ title: "Open study.monashcoding.com in Safari" },
{ glyph: MoreHorizontal, title: "Tap the menu" },
{ glyph: Share, title: "Tap Share" },
{ glyph: SquarePlus, title: "Add to Home Screen" },
],
Expand Down
7 changes: 5 additions & 2 deletions src/components/timer/timer-dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
cacheRemoteTimerState,
dedupeRemoteRequest,
getCachedRemoteTimerState,
getStaleRemoteTimerState,
subscribeToRemoteTableChanges,
} from "@/lib/client-cache";
import {
Expand Down Expand Up @@ -229,7 +230,9 @@ export function TimerDashboard({
let cancelled = false;

async function loadInitialState() {
const cachedRemoteState = getCachedRemoteTimerState(userId);
// A stale snapshot paints immediately; the fetch below replaces it.
const cachedRemoteState =
getCachedRemoteTimerState(userId) ?? getStaleRemoteTimerState(userId);

if (cachedRemoteState) {
applyRemoteTimerState(cachedRemoteState);
Expand Down Expand Up @@ -932,7 +935,7 @@ export function TimerDashboard({
/>
) : (
<EmptyStateCta
description="Subjects are what you study, like a unit or a skill. Press play on one to start timing."
description=""
mascot="max-arms-down"
title="Add your first subject"
action={
Expand Down
31 changes: 30 additions & 1 deletion src/lib/client-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
} from "./supabase/app-data/types";
import {
REMOTE_CACHE_MAX_AGE_MS,
REMOTE_STALE_CACHE_MAX_AGE_MS,
cacheRemoteFriendsSnapshot,
cacheRemoteGroupsSnapshot,
cacheRemoteTimerState,
Expand All @@ -17,6 +18,7 @@ import {
getCachedRemoteGroupsSnapshot,
getCachedRemoteTimerState,
getCachedRemoteUnitState,
getStaleRemoteTimerState,
invalidateRemoteCachesForTable,
subscribeToRemoteTableChanges,
} from "./client-cache";
Expand Down Expand Up @@ -72,17 +74,44 @@ afterEach(() => {
});

describe("remote client cache freshness", () => {
it("expires cached data and removes it from persistent storage", () => {
it("stops serving expired data but keeps it stored for the stale window", () => {
cacheRemoteFriendsSnapshot(friendsSnapshot);
expect(getCachedRemoteFriendsSnapshot("viewer")).toEqual(friendsSnapshot);
expect(storage.size).toBe(1);

vi.advanceTimersByTime(REMOTE_CACHE_MAX_AGE_MS + 1);

expect(getCachedRemoteFriendsSnapshot("viewer")).toBeNull();
expect(storage.size).toBe(1);

vi.advanceTimersByTime(REMOTE_STALE_CACHE_MAX_AGE_MS);

expect(getCachedRemoteFriendsSnapshot("viewer")).toBeNull();
expect(storage.size).toBe(0);
});

it("serves stale timer state for painting until the stale window ends", () => {
cacheRemoteTimerState(timerState);
vi.advanceTimersByTime(REMOTE_CACHE_MAX_AGE_MS + 1);

expect(getCachedRemoteTimerState("viewer")).toBeNull();
expect(getStaleRemoteTimerState("viewer")).toEqual(timerState);
expect(getStaleRemoteTimerState("someone-else")).toBeNull();

vi.advanceTimersByTime(REMOTE_STALE_CACHE_MAX_AGE_MS);

expect(getStaleRemoteTimerState("viewer")).toBeNull();
});

it("drops stale timer state once a table change invalidates it", () => {
cacheRemoteTimerState(timerState);
vi.advanceTimersByTime(REMOTE_CACHE_MAX_AGE_MS + 1);

invalidateRemoteCachesForTable("study_sessions");

expect(getStaleRemoteTimerState("viewer")).toBeNull();
});

it("invalidates only the caches affected by a changed table", () => {
cacheRemoteTimerState(timerState);
cacheRemoteFriendsSnapshot(friendsSnapshot);
Expand Down
24 changes: 20 additions & 4 deletions src/lib/client-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import type {
} from "@/lib/supabase/app-data/types";

export const REMOTE_CACHE_MAX_AGE_MS = 2 * 60 * 1000;
// Past the fresh window, data is still good enough to paint on launch while
// a refetch runs, so the app opens straight onto the last-known screen.
export const REMOTE_STALE_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;

const REMOTE_TIMER_CACHE_KEY = "mac-study-remote-timer-cache-v3";
const REMOTE_FRIENDS_CACHE_KEY = "mac-study-remote-friends-cache-v3";
Expand Down Expand Up @@ -46,6 +49,17 @@ export function getCachedRemoteTimerState(userId: string | null) {
return value;
}

/** Last-known timer state up to a day old, for painting before a refetch. */
export function getStaleRemoteTimerState(userId: string | null) {
if (!userId) return null;
timerCache ??= readCache<RemoteTimerState>(REMOTE_TIMER_CACHE_KEY);
if (!timerCache || timerCache.userId !== userId) return null;
if (Date.now() - timerCache.cachedAt > REMOTE_STALE_CACHE_MAX_AGE_MS) {
return null;
}
return timerCache.value;
}

export function cacheRemoteTimerState(state: RemoteTimerState) {
timerCache = createEnvelope(state, state.currentUserId);
writeCache(REMOTE_TIMER_CACHE_KEY, timerCache);
Expand Down Expand Up @@ -215,14 +229,16 @@ function getFreshValue<T>(
): T | null {
if (!envelope) return null;

if (
envelope.userId !== userId ||
Date.now() - envelope.cachedAt > REMOTE_CACHE_MAX_AGE_MS
) {
const age = Date.now() - envelope.cachedAt;

if (envelope.userId !== userId || age > REMOTE_STALE_CACHE_MAX_AGE_MS) {
removeCache(key);
return null;
}

// Expired but within the stale window: keep it stored for stale readers.
if (age > REMOTE_CACHE_MAX_AGE_MS) return null;

return envelope.value;
}

Expand Down
Loading