Skip to content

Commit e63aea6

Browse files
authored
Improve mobile feed resilience and dashboard nav UX (#39)
* Improve mobile dashboard UX and add feed transport fallback * Add env-gated Convex auth and transport debugging
1 parent aade6eb commit e63aea6

12 files changed

Lines changed: 452 additions & 24 deletions

File tree

.claude/napkin.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33
## Corrections
44
| Date | Source | What Went Wrong | What To Do Instead |
55
|------|--------|----------------|-------------------|
6+
| 2026-02-16 | self | Ran eslint command with unquoted bracketed path (`app/api/challenges/[id]/...`) and zsh globbing failed | Quote any CLI path containing `[]` in this repo, including lint/file-specific commands |
7+
| 2026-02-16 | user | Fade behavior was interpreted as full hide, and reappearing nav felt inactive | Use partial opacity dimming (not `opacity-0`), keep nav interactive, and restore stronger foreground styling when revealed |
8+
| 2026-02-16 | self | Tried to use `python` for a quick text edit and the command wasn't available here | Use `apply_patch` or shell-native tools for small file edits; avoid Python for routine edits in this repo |
9+
| 2026-02-16 | user | After enabling `viewportFit: \"cover\"`, top dashboard feed nav could sit under mobile browser top chrome/notch | When using safe-area viewport mode, add top inset offsets to mobile main content and sticky top elements |
10+
| 2026-02-16 | user | Implemented mobile nav hide-on-scroll too aggressively; user perceived nav as disappearing entirely | Keep mobile bottom nav persistently visible by default; add hide-on-scroll only as an opt-in, tuned enhancement |
11+
| 2026-02-16 | self | Ran `pnpm -F web exec eslint` with `apps/web/...` paths, which failed because the command runs from `apps/web` | Use package-relative paths (e.g., `components/...`, `app/...`) when executing commands inside a filtered workspace |
12+
| 2026-02-16 | self | Ran `rg` with unquoted path containing `[id]` so zsh expanded it and command failed | Always single-quote paths with glob chars (`[]`, `()`) in shell commands |
613
| 2026-02-11 | self | Ran `ls` before reading napkin (again) | Always read `.claude/napkin.md` before any other command |
714
| 2026-02-10 | self | Ran `ls` before reading napkin | Always read `.claude/napkin.md` before any other command |
815
| 2026-02-10 | self | Used backticks in a shell-quoted PR body so the shell tried to execute `turbo` | Use a heredoc or escape backticks when passing PR bodies to shell commands |
@@ -13,20 +20,28 @@
1320
## User Preferences
1421
- Hide navbar on full-screen flow pages (invite, dashboard, admin) via `ConditionalHeader` patterns + remove `page-with-header` class
1522
- Avoid LAN-specific runtime rewrites in product code unless explicitly requested
23+
- For mobile bottom nav visual style, prefer transparent icon treatment over standout filled purple CTA button.
24+
- For production troubleshooting UX, do not add user-facing alerts for transient feed/connection issues; log to Sentry instead.
1625

1726
## Patterns That Work
1827
- Convex queries can join related data inline (e.g., activity types + categories in one query)
1928
- `conditional-header.tsx` DASHBOARD_LAYOUT_PATTERNS array controls navbar visibility per route
2029
- Admin console sidebar approach was scrapped — revisit admin nav design in the future
2130
- Mobile feed performance improves by skipping non-critical per-item work (engagement count scans and media URL generation) on initial query
2231
- For mobile perceived performance, SSR the first feed page from server auth and then let client `usePaginatedQuery` take over for realtime/pagination
32+
- For mobile browser chrome collapse behavior, use document scrolling on mobile and keep internal `overflow-y-auto` scrollers only on desktop breakpoints.
33+
- For x-like translucent mobile bottom nav, use low-alpha supported background (`supports-[backdrop-filter]:bg-zinc-950/15`) plus stronger blur/saturation.
34+
- If user prefers no glass treatment, remove all `backdrop-*` and `supports-[backdrop-filter]:*` classes and use a plain alpha background (`bg-zinc-950/55`).
35+
- Scroll-direction-driven nav fade works with a throttled `requestAnimationFrame` + `opacity` transition, and avoids layout jumps versus translate-based hide.
36+
- For Convex mobile diagnostics, prefer env-gated verbose logs (`NEXT_PUBLIC_CONVEX_DEBUG=1`, `AUTH_LOG_LEVEL=DEBUG`) plus Sentry capture over user-facing banners.
2337

2438
## Patterns That Don't Work
2539
- Deriving env vars inside `convex deploy --cmd` shell strings — escaping hell, fragile, hard to debug. Instead, derive them in `next.config.ts` which runs at build time and can set `process.env` before Next.js compiles.
2640

2741
## Domain Notes
2842
- Scoring configs have types: distance, duration, count, variant
2943
- `page-with-header` CSS class = `pt-16` to offset fixed navbar
44+
- Dashboard layout uses `h-dvh` + `overflow-hidden` shell with an internal `main` scroller (`overflow-y-auto`); mobile browser chrome hide behavior is tied to this choice.
3045
- Seed data lives in `packages/backend/actions/seed.ts`
3146
- Schema changes auto-deploy locally via `pnpm dev`
3247
- Local Convex HTTP routes (`httpAction`, `/api/v1/*`) are served from site origin (`127.0.0.1:3211`), not cloud origin (`127.0.0.1:3210`)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { NextResponse } from "next/server";
2+
import type { Id } from "@repo/backend/_generated/dataModel";
3+
import { api } from "@repo/backend";
4+
5+
import { fetchAuthQuery } from "@/lib/server-auth";
6+
7+
interface FeedRequestBody {
8+
followingOnly?: boolean;
9+
includeEngagementCounts?: boolean;
10+
includeMediaUrls?: boolean;
11+
cursor?: string | null;
12+
numItems?: number;
13+
}
14+
15+
function parseBody(value: unknown): FeedRequestBody {
16+
if (!value || typeof value !== "object") {
17+
return {};
18+
}
19+
return value as FeedRequestBody;
20+
}
21+
22+
export async function POST(
23+
request: Request,
24+
context: { params: Promise<{ id: string }> }
25+
) {
26+
try {
27+
const { id } = await context.params;
28+
const body = parseBody(await request.json().catch(() => ({})));
29+
30+
const followingOnly = Boolean(body.followingOnly);
31+
const includeEngagementCounts = body.includeEngagementCounts ?? true;
32+
const includeMediaUrls = body.includeMediaUrls ?? true;
33+
const cursor = typeof body.cursor === "string" || body.cursor === null
34+
? body.cursor
35+
: null;
36+
const requestedNumItems = Number.isFinite(body.numItems)
37+
? Number(body.numItems)
38+
: 10;
39+
const numItems = Math.min(50, Math.max(1, requestedNumItems));
40+
41+
const result = await fetchAuthQuery(api.queries.activities.getChallengeFeed, {
42+
challengeId: id as Id<"challenges">,
43+
followingOnly,
44+
includeEngagementCounts,
45+
includeMediaUrls,
46+
paginationOpts: {
47+
numItems,
48+
cursor,
49+
},
50+
});
51+
52+
return NextResponse.json(result);
53+
} catch (error) {
54+
console.error("[feed api] failed", error);
55+
return NextResponse.json(
56+
{ error: "Failed to load feed" },
57+
{ status: 500 }
58+
);
59+
}
60+
}

apps/web/app/layout.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export const viewport: Viewport = {
2424
width: "device-width",
2525
initialScale: 1,
2626
maximumScale: 1,
27+
viewportFit: "cover",
2728
};
2829

2930
export const metadata: Metadata = {

apps/web/components/dashboard/activity-feed.tsx

Lines changed: 166 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
'use client';
22

3-
import { useMemo, useState } from 'react';
3+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
44
import { useRouter } from 'next/navigation';
55
import { formatDistanceToNow } from 'date-fns';
6+
import * as Sentry from '@sentry/nextjs';
67
import {
78
Flag,
89
Loader2,
@@ -13,7 +14,7 @@ import {
1314
ThumbsUp,
1415
Zap,
1516
} from 'lucide-react';
16-
import { useMutation, usePaginatedQuery } from 'convex/react';
17+
import { useConvexConnectionState, useMutation, usePaginatedQuery } from 'convex/react';
1718
import { api } from '@repo/backend';
1819
import type { Id } from '@repo/backend/_generated/dataModel';
1920
import { ConvexError } from 'convex/values';
@@ -102,15 +103,28 @@ interface ActivityFeedProps {
102103

103104
type FeedFilter = 'all' | 'following';
104105

106+
interface FeedPageResponse {
107+
page: ActivityFeedItem[];
108+
continueCursor: string;
109+
isDone: boolean;
110+
}
111+
105112
export function ActivityFeed({
106113
challengeId,
107114
initialItems = [],
108115
initialLightweightMode = false,
109116
}: ActivityFeedProps) {
117+
const connectionState = useConvexConnectionState();
110118
const { hasNewActivity, acknowledgeActivity } = useChallengeRealtime();
111119
const { users: mentionUsers } = useMentionableUsers(challengeId);
112120
const [pendingLikes, setPendingLikes] = useState<Record<string, boolean>>({});
113121
const [feedFilter, setFeedFilter] = useState<FeedFilter>('all');
122+
const [useHttpFallback, setUseHttpFallback] = useState(false);
123+
const [httpItems, setHttpItems] = useState<ActivityFeedItem[]>(initialItems);
124+
const [httpCursor, setHttpCursor] = useState<string | null>(null);
125+
const [httpIsDone, setHttpIsDone] = useState(false);
126+
const [httpLoading, setHttpLoading] = useState(false);
127+
const httpRequestIdRef = useRef(0);
114128
const isMobileClient = useMemo(() => {
115129
if (typeof navigator === 'undefined') {
116130
return false;
@@ -137,7 +151,126 @@ export function ActivityFeed({
137151
{ initialNumItems: 10 }
138152
);
139153

154+
const loadHttpPage = useCallback(async (cursor: string | null, append: boolean) => {
155+
const requestId = ++httpRequestIdRef.current;
156+
setHttpLoading(true);
157+
158+
try {
159+
const response = await fetch(`/api/challenges/${challengeId}/feed`, {
160+
method: "POST",
161+
headers: {
162+
"Content-Type": "application/json",
163+
},
164+
credentials: "include",
165+
body: JSON.stringify({
166+
followingOnly: feedFilter === "following",
167+
includeEngagementCounts: !lightweightFeedMode,
168+
includeMediaUrls: !lightweightFeedMode,
169+
cursor,
170+
numItems: 10,
171+
}),
172+
});
173+
174+
if (!response.ok) {
175+
throw new Error(`Feed request failed with status ${response.status}`);
176+
}
177+
178+
const data = (await response.json()) as FeedPageResponse;
179+
if (requestId !== httpRequestIdRef.current) {
180+
return;
181+
}
182+
183+
setHttpItems((prev) => (append ? [...prev, ...data.page] : data.page));
184+
setHttpIsDone(data.isDone);
185+
setHttpCursor(data.isDone ? null : (data.continueCursor ?? null));
186+
} catch (error) {
187+
if (requestId !== httpRequestIdRef.current) {
188+
return;
189+
}
190+
console.error("Failed to load feed over HTTP fallback", error);
191+
Sentry.captureException(error, {
192+
tags: {
193+
area: "activity-feed",
194+
transport: "http-fallback",
195+
feedFilter,
196+
platform: isMobileClient ? "mobile" : "desktop",
197+
},
198+
extra: {
199+
challengeId,
200+
lightweightFeedMode,
201+
},
202+
});
203+
} finally {
204+
if (requestId === httpRequestIdRef.current) {
205+
setHttpLoading(false);
206+
}
207+
}
208+
}, [challengeId, feedFilter, isMobileClient, lightweightFeedMode]);
209+
210+
useEffect(() => {
211+
if (useHttpFallback) {
212+
return;
213+
}
214+
if (!isLoading) {
215+
return;
216+
}
217+
if (connectionState.isWebSocketConnected || connectionState.hasEverConnected) {
218+
return;
219+
}
220+
221+
const timeoutId = window.setTimeout(() => {
222+
Sentry.captureMessage("Convex websocket not ready; enabling HTTP feed fallback", {
223+
level: "warning",
224+
tags: {
225+
area: "activity-feed",
226+
feedFilter,
227+
platform: isMobileClient ? "mobile" : "desktop",
228+
},
229+
extra: {
230+
challengeId,
231+
hasEverConnected: connectionState.hasEverConnected,
232+
isWebSocketConnected: connectionState.isWebSocketConnected,
233+
connectionRetries: connectionState.connectionRetries,
234+
},
235+
});
236+
setUseHttpFallback(true);
237+
}, 6000);
238+
239+
return () => {
240+
window.clearTimeout(timeoutId);
241+
};
242+
}, [
243+
challengeId,
244+
connectionState.connectionRetries,
245+
connectionState.hasEverConnected,
246+
connectionState.isWebSocketConnected,
247+
feedFilter,
248+
isMobileClient,
249+
isLoading,
250+
useHttpFallback,
251+
]);
252+
253+
useEffect(() => {
254+
if (!useHttpFallback) {
255+
return;
256+
}
257+
258+
httpRequestIdRef.current += 1;
259+
setHttpCursor(null);
260+
setHttpIsDone(false);
261+
setHttpItems(feedFilter === "all" ? initialItems : []);
262+
263+
void loadHttpPage(null, false);
264+
}, [feedFilter, initialItems, loadHttpPage, useHttpFallback]);
265+
140266
const handleLoadMore = () => {
267+
if (useHttpFallback) {
268+
if (!httpLoading && !httpIsDone && httpCursor) {
269+
void loadHttpPage(httpCursor, true);
270+
}
271+
return;
272+
}
273+
141274
if (status === "CanLoadMore") {
142275
loadMore(10);
143276
}
@@ -166,15 +299,7 @@ export function ActivityFeed({
166299
}
167300
};
168301

169-
const feedStatus = useMemo(() => {
170-
const hasInitialFeed = feedFilter === 'all' && initialItems.length > 0;
171-
if (isLoading && !hasInitialFeed) {
172-
return 'Loading recent activities...';
173-
}
174-
return null;
175-
}, [feedFilter, initialItems.length, isLoading]);
176-
177-
const displayResults = useMemo(() => {
302+
const liveDisplayResults = useMemo(() => {
178303
if (feedFilter !== 'all') {
179304
return results;
180305
}
@@ -186,10 +311,35 @@ export function ActivityFeed({
186311
return results;
187312
}, [feedFilter, initialItems, results]);
188313

314+
const displayResults = useMemo(() => {
315+
if (!useHttpFallback) {
316+
return liveDisplayResults;
317+
}
318+
319+
if (feedFilter === "all" && httpItems.length === 0) {
320+
return initialItems;
321+
}
322+
323+
return httpItems;
324+
}, [feedFilter, httpItems, initialItems, liveDisplayResults, useHttpFallback]);
325+
326+
const effectiveIsLoading = useHttpFallback ? httpLoading : isLoading;
327+
const canLoadMore = useHttpFallback
328+
? !httpIsDone && !httpLoading && httpCursor !== null
329+
: status === "CanLoadMore";
330+
331+
const feedStatus = useMemo(() => {
332+
const hasInitialFeed = feedFilter === "all" && (displayResults?.length ?? 0) > 0;
333+
if (effectiveIsLoading && !hasInitialFeed) {
334+
return "Loading recent activities...";
335+
}
336+
return null;
337+
}, [displayResults, effectiveIsLoading, feedFilter]);
338+
189339
return (
190340
<div className="space-y-4">
191341
{/* Twitter-like Feed Filter Tabs */}
192-
<div className="sticky top-0 z-10 -mx-4 border-b border-zinc-800 bg-black/80 backdrop-blur">
342+
<div className="sticky top-[env(safe-area-inset-top)] z-10 -mx-4 border-b border-zinc-800 bg-black/80 backdrop-blur">
193343
<div className="flex">
194344
<button
195345
onClick={() => setFeedFilter('all')}
@@ -258,7 +408,7 @@ export function ActivityFeed({
258408
/>
259409
))}
260410

261-
{!isLoading && (displayResults?.length ?? 0) === 0 && (
411+
{!effectiveIsLoading && (displayResults?.length ?? 0) === 0 && (
262412
<Card className="border-dashed text-center">
263413
<CardHeader>
264414
<CardTitle>
@@ -273,10 +423,10 @@ export function ActivityFeed({
273423
</Card>
274424
)}
275425

276-
{status === "CanLoadMore" && (
426+
{canLoadMore && (
277427
<div className="flex justify-center">
278-
<Button variant="outline" onClick={handleLoadMore}>
279-
Load more
428+
<Button variant="outline" onClick={handleLoadMore} disabled={effectiveIsLoading}>
429+
{effectiveIsLoading ? "Loading..." : "Load more"}
280430
</Button>
281431
</div>
282432
)}

apps/web/components/dashboard/dashboard-layout.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export function DashboardLayout({
4040
}, []);
4141

4242
return (
43-
<div className="flex h-dvh overflow-hidden bg-black text-white">
43+
<div className="flex min-h-dvh bg-black text-white lg:h-dvh lg:overflow-hidden">
4444
{/* Left Sidebar - Collapsed (lg) */}
4545
<aside onWheel={forwardScroll} className="hidden w-[72px] flex-shrink-0 flex-col border-r border-zinc-800 lg:flex xl:hidden">
4646
<div className="flex h-full flex-col items-center py-4">
@@ -129,8 +129,11 @@ export function DashboardLayout({
129129
</div>
130130
</aside>
131131

132-
{/* Main Content - Scrollable */}
133-
<main ref={mainRef} className="min-h-0 flex-1 overflow-y-auto overscroll-contain scrollbar-hide pb-20 lg:pb-0">
132+
{/* Main Content */}
133+
<main
134+
ref={mainRef}
135+
className="min-h-0 flex-1 scrollbar-hide pt-[env(safe-area-inset-top)] pb-[calc(5.5rem+env(safe-area-inset-bottom))] lg:overflow-y-auto lg:overscroll-contain lg:pt-0 lg:pb-0"
136+
>
134137
<PaymentRequiredBanner challengeId={challenge.id} />
135138
<AnnouncementBanner challengeId={challenge.id} />
136139
{children}

0 commit comments

Comments
 (0)