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
36 changes: 36 additions & 0 deletions docs/specs/2026-07-31-vercel-deployment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
## Problem Statement

When deploying Fit Level Up to Vercel (especially on Hobby plans), there are several environmental and architectural gotchas. Specifically, missing Upstash Redis variables in `env.ts` will only cause runtime errors instead of failing the build. Furthermore, Vercel kills serverless functions after 60 seconds, which abruptly closes Server-Sent Events (SSE). The native browser `EventSource` attempts to reconnect, but currently, the UI offers no visual indication to the user that their live feed is temporarily down and reconnecting.

## Solution

1. Add `UPSTASH_REDIS_URL` and `UPSTASH_REDIS_TOKEN` to `src/env.ts` using Zod to enforce their presence at build time.
2. Expose an `isReconnecting` state from the `useFriendEvents` hook.
3. Display a subtle, non-intrusive loading spinner next to the "Friends" section/title in the UI when `isReconnecting` is true, ensuring users know the feed is refreshing without blocking their experience.
4. Establish that database seeding is a manual, one-time execution against the production URI locally (no code changes needed).

## User Stories

1. As a developer deploying to Vercel, I want the build to fail fast if I forget my Upstash Redis environment variables, so that I don't discover crashes at runtime.
2. As a user viewing the Friends page, I want a subtle visual indicator when my live activity feed connection drops and is reconnecting, so that I know why new events might be temporarily delayed.
3. As a developer, I want to safely seed my production database without exposing a public admin API route, so that my production environment remains secure.

## Implementation Decisions

- **Environment Validation:** `src/env.ts` will be updated to include `UPSTASH_REDIS_URL` and `UPSTASH_REDIS_TOKEN` in the `server` configuration. This relies on the `@t3-oss/env-nextjs` package already in use.
- **SSE Hook Modification:** `useFriendEvents` will track an `isReconnecting` boolean state. It will be set to `true` inside `es.onerror` and reset to `false` inside `es.onopen` or when a message is successfully received.
- **UI Placement:** The reconnecting spinner will be placed in `src/app/(app)/friends/page.tsx` next to the PageHeader title, using a small `Loader2` from `lucide-react`.
- **Seeding:** Confirmed that running `npm run seed` locally with a modified `.env.local` pointing to the production Atlas URI is the official path forward. No new seeding code is required.

## Testing Decisions

- **Manual Testing of Env Validation:** Temporarily removing `UPSTASH_REDIS_URL` from `.env.local` and running `npm run build` should result in an immediate Zod validation error.
- **Manual Testing of SSE Reconnect UI:** The developer can test this locally by stopping the Next.js server while the browser is open on the Friends page. The subtle spinner should appear as `EventSource` enters the error state. When the server restarts, the spinner should disappear.

## Out of Scope

- Building a dedicated admin dashboard or API route for database seeding.
- Migrating from Server-Sent Events to WebSockets (Pusher/Socket.io). We are keeping SSE.

## Further Notes
- Vercel hobby limits are a known constraint; the subtle UI spinner is a "graceful degradation" pattern that avoids rewriting the real-time infrastructure.
141 changes: 141 additions & 0 deletions docs/superpowers/plans/2026-07-31-vercel-deployment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# Vercel Deployment Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Ensure the application fails fast if Redis vars are missing, and handles SSE dropouts gracefully in the UI.

**Architecture:** We will add Zod schemas to `src/env.ts` for Upstash variables, and add a simple boolean state to `useFriendEvents` that activates a Lucide `Loader2` spinner on the Friends page.

**Tech Stack:** Next.js, Zod, Lucide React, Server-Sent Events

## Global Constraints

- Must run properly on Vercel Hobby plan.
- The UI spinner must be subtle and not block the user.
- Seeding Atlas is an operational step outside this codebase modification.

---

### Task 1: Enforce Upstash Redis Variables

**Files:**
- Modify: `src/env.ts`

- [ ] **Step 1: Add validation to env.ts**

Update `src/env.ts` to include `UPSTASH_REDIS_URL` and `UPSTASH_REDIS_TOKEN` as required strings in the `server` block.

```typescript
UPSTASH_REDIS_URL: z.string().url(),
UPSTASH_REDIS_TOKEN: z.string().min(1),
```

- [ ] **Step 2: Verify type check passes**

Run: `npx tsc --noEmit`
Expected: PASS

- [ ] **Step 3: Commit**
```bash
git add src/env.ts
git commit -m "chore: enforce upstash redis env variables at build time"
```

---

### Task 2: Add Reconnecting State to SSE Hook

**Files:**
- Modify: `src/lib/hooks/useFriendEvents.ts`

**Interfaces:**
- Produces: `isReconnecting: boolean` returned from the hook.

- [ ] **Step 1: Add state to hook**

Update `src/lib/hooks/useFriendEvents.ts` to add an `isReconnecting` state. Set it to `false` initially and inside `es.onopen` or on the first message, and set it to `true` inside `es.onerror`.

```typescript
const [activeEvent, setActiveEvent] = useState<SSEEvent | null>(null);
const [isReconnecting, setIsReconnecting] = useState(false);

useEffect(() => {
const es = new EventSource("/api/friends/events");

es.onopen = () => {
setIsReconnecting(false);
};

es.onmessage = (e) => {
setIsReconnecting(false);
// ... existing switch statement ...
};

es.onerror = () => {
console.warn("[SSE] Connection error, reconnecting...");
setIsReconnecting(true);
};

return () => es.close();
}, [queryClient]);

const clearEvent = () => setActiveEvent(null);

return { activeEvent, clearEvent, isReconnecting };
```

- [ ] **Step 2: Commit**

```bash
git add src/lib/hooks/useFriendEvents.ts
git commit -m "feat: expose isReconnecting state for SSE connection drops"
```

---

### Task 3: Show Reconnecting UI on Friends Page

**Files:**
- Modify: `src/app/(app)/friends/page.tsx`

**Interfaces:**
- Consumes: `isReconnecting` from `useFriendEvents`.

- [ ] **Step 1: Import and hook into UI**

Import `useFriendEvents` and use it in `FriendsPage`. Add a small indicator next to the `PageHeader`.

```tsx
// Imports:
import { useFriendEvents } from "@/lib/hooks/useFriendEvents";
import { Loader2 } from "lucide-react";

// Inside component:
export default function FriendsPage() {
const { isReconnecting } = useFriendEvents();
// ... existing code ...

return (
<div className="space-y-8 pb-12 animate-in fade-in duration-500">
<div className="flex items-center justify-between">
<PageHeader title="Friends" subtitle="Compete and train with your squad." />
{isReconnecting && (
<div className="flex items-center gap-2 text-muted-foreground bg-secondary/50 px-3 py-1.5 rounded-full text-xs font-medium">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span>Reconnecting feed...</span>
</div>
)}
</div>
```

- [ ] **Step 2: Run linter/tsc**

Run: `npx tsc --noEmit`
Expected: PASS

- [ ] **Step 3: Commit**

```bash
git add src/app/\(app\)/friends/page.tsx
git commit -m "feat: show non-intrusive spinner when SSE reconnects"
```
12 changes: 11 additions & 1 deletion src/app/(app)/friends/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { FriendRequestCard } from "@/components/friends/FriendRequestCard";
import { AddFriendSection } from "@/components/friends/AddFriendSection";
import { FriendProfileModal } from "@/components/friends/FriendProfileModal";
import type { FriendProfile } from "@/lib/types";
import { useFriendEvents } from "@/lib/hooks/useFriendEvents";
import {
getFriends,
getFriendRequests,
Expand All @@ -23,6 +24,7 @@ export default function FriendsPage() {
const [activeTab, setActiveTab] = useState<Tab>("friends");
const [selectedFriend, setSelectedFriend] = useState<FriendProfile | null>(null);
const queryClient = useQueryClient();
const { isReconnecting } = useFriendEvents();

// Queries
const { data: friends = [], isLoading: loadingFriends } = useQuery({
Expand Down Expand Up @@ -66,7 +68,15 @@ export default function FriendsPage() {

return (
<div className="space-y-8 pb-12 animate-in fade-in duration-500">
<PageHeader title="Friends" subtitle="Compete and train with your squad." />
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<PageHeader title="Friends" subtitle="Compete and train with your squad." />
{isReconnecting && (
<div className="flex items-center gap-2 text-muted-foreground bg-secondary/50 px-3 py-1.5 rounded-full text-xs font-medium self-start sm:self-auto">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span>Reconnecting feed...</span>
</div>
)}
</div>

{/* Tabs */}
<div className="flex p-1 bg-card border border-border rounded-xl w-fit relative z-10 shadow-sm overflow-x-auto max-w-full no-scrollbar">
Expand Down
2 changes: 2 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export const env = createEnv({
MONGODB_FRIENDSHIPS_COLLECTION: z.string().min(1).default("friendships"),
MONGODB_WORKOUT_TEMPLATES_COLLECTION: z.string().min(1).default("workout_templates"),

UPSTASH_REDIS_REST_URL: z.string().url(),
UPSTASH_REDIS_REST_TOKEN: z.string().min(1),
BETTER_AUTH_SECRET: z.string().min(10),
},
client: {
Expand Down
9 changes: 8 additions & 1 deletion src/lib/hooks/useFriendEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,17 @@ import type { SSEEvent } from "../sse/sse-types";
export function useFriendEvents() {
const queryClient = useQueryClient();
const [activeEvent, setActiveEvent] = useState<SSEEvent | null>(null);
const [isReconnecting, setIsReconnecting] = useState(false);

useEffect(() => {
const es = new EventSource("/api/friends/events");

es.onopen = () => {
setIsReconnecting(false);
};

es.onmessage = (e) => {
setIsReconnecting(false);
const event: SSEEvent = JSON.parse(e.data);

switch (event.type) {
Expand All @@ -33,12 +39,13 @@ export function useFriendEvents() {

es.onerror = () => {
console.warn("[SSE] Connection error, reconnecting...");
setIsReconnecting(true);
};

return () => es.close();
}, [queryClient]);

const clearEvent = () => setActiveEvent(null);

return { activeEvent, clearEvent };
return { activeEvent, clearEvent, isReconnecting };
}
2 changes: 2 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export default defineConfig({
environment: 'node',
env: {
BETTER_AUTH_SECRET: 'test-secret-for-vitest',
UPSTASH_REDIS_REST_URL: 'https://test-redis-url.upstash.io',
UPSTASH_REDIS_REST_TOKEN: 'test-token',
},
alias: {
'@': path.resolve(__dirname, './src'),
Expand Down
Loading