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
3 changes: 2 additions & 1 deletion .claude/rules/push-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ The push pipeline lives in `packages/shared/src/push/`. It is invoked by `apps/w

## Cron Entry (apps/web/app/api/cron/push/route.ts)

- `export const dynamic = 'force-dynamic'` + `export const maxDuration = 300`
- `export const maxDuration = 300` (Vercel function timeout)
- No `dynamic = 'force-dynamic'` — incompatible with `cacheComponents: true`; route handlers default to dynamic.
- Auth: `Authorization: Bearer ${CRON_SECRET}` verified with `timingSafeEqual` (see `isValidCronSecret`)
- 10-minute overlap guard: skips if another run completed in the last 10 minutes
- Calls `buildPushJobs(supabase, channelRegistry, dispatchLimit)` and `recordPushRun`
Expand Down
12 changes: 11 additions & 1 deletion .claude/rules/web-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ paths:
- **x-user-profile header**: `proxy.ts` queries user profile once -> sets header -> `layout.tsx` reads it -> passes to `<Nav>`. Value is `encodeURIComponent(JSON.stringify({...}))` for ASCII-safe CJK names. Both `nav.tsx` and `admin/layout.tsx` consume this header.
- **revalidatePath caution**: Do NOT call from Server Actions that return data the caller displays (e.g. link tokens) — it wipes `useState` immediately.

## Cache Components (Next.js 16)

- **`cacheComponents: true`** enabled in `next.config.ts`. Every runtime data access (`cookies()`, `headers()`, `await params`, `await searchParams`, `supabase.auth.getUser()`) MUST sit inside a `<Suspense>` boundary or it fails build.
- **Page pattern**: default export is a sync shell that wraps the async body in `<Suspense>`; body lives in a sibling `PageBody` async function. Applies to every page that accesses auth or searchParams.
- **Layouts**: extract dynamic portions (`headers()` reads, auth checks) into Suspense-wrapped child components; keep static chrome (sidebar, nav links) as the synchronous outer shell.
- **`use cache` helpers**: shared/static data fetches (`getProblemBySlug`, `getListBySlug`, `getFilteredProblems`, `getFilteredLists`, sitemap data) use the `'use cache'` directive + `cacheLife('hours')` + `cacheTag(...)`. Cannot read `cookies()`/`headers()` inside these.
- **`cacheTag` vocabulary**: `'problems'` (any problem data), `'problem:<slug>'` (single problem), `'lists'` (any list data), `'list:<slug>'` / `'list:<id>:problems'` (scoped). Use these tags in `runAdminAction({ tags: [...] })` to invalidate on mutation.
- **`await connection()`**: add at the top of any Server Component that reads `new Date()` / `Date.now()` for query construction — tells Next.js the component is request-scoped (otherwise it errors during prerender).
- **No `force-dynamic` / `revalidate` exports**: incompatible with `cacheComponents`. Route handlers default to dynamic; pages become Partial Prerender automatically.

## UI Patterns

- **Sticky bottom bar**: `ProblemActions` uses `IntersectionObserver` on sentinel div; when header action bar scrolls out, `fixed bottom-0` bar slides in via CSS `transition-all duration-200`. iOS safe area: `@utility pb-safe` in globals.css + `generateViewport({ viewportFit: 'cover' })`.
Expand All @@ -32,7 +42,7 @@ paths:

**Routes**:
- `app/page.tsx` — Landing page
- `app/(public)/` — /problems, /problems/[slug], /lists, /lists/[slug] (ISR `revalidate = 3600`)
- `app/(public)/` — /problems, /problems/[slug], /lists, /lists/[slug] (Partial Prerender; shared data cached via `'use cache'` + `cacheTag`)
- `app/robots.ts` — Dynamic robots.txt
- `app/(auth)/` — /dashboard, /settings, /settings/learning, /settings/account, /settings/notifications, /onboarding, /garden
- `app/(admin)/admin/` — Health dashboard, problems, content, lists, users, push monitor, channels
Expand Down
10 changes: 5 additions & 5 deletions apps/web/__tests__/login-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe('LoginPage', () => {
it('redirects to /dashboard when user already authenticated', async () => {
setupUser({ id: 'u1' })

const { default: LoginPage } = await import('../app/login/page')
const { LoginPageBody: LoginPage } = await import('../app/login/page')

await expect(
LoginPage({ searchParams: Promise.resolve({}) })
Expand All @@ -52,7 +52,7 @@ describe('LoginPage', () => {
it('does not redirect when user is null (unauthenticated)', async () => {
setupUser(null)

const { default: LoginPage } = await import('../app/login/page')
const { LoginPageBody: LoginPage } = await import('../app/login/page')
// Should not throw (no redirect)
const result = await LoginPage({ searchParams: Promise.resolve({}) })
expect(result).toBeDefined()
Expand All @@ -61,7 +61,7 @@ describe('LoginPage', () => {
it('passes error search param to LoginForm component', async () => {
setupUser(null)

const { default: LoginPage } = await import('../app/login/page')
const { LoginPageBody: LoginPage } = await import('../app/login/page')
const result = await LoginPage({
searchParams: Promise.resolve({ error: 'auth_failed' }),
}) as unknown as { props: { error?: string } }
Expand All @@ -72,7 +72,7 @@ describe('LoginPage', () => {
it('passes redirect search param to LoginForm component', async () => {
setupUser(null)

const { default: LoginPage } = await import('../app/login/page')
const { LoginPageBody: LoginPage } = await import('../app/login/page')
const result = await LoginPage({
searchParams: Promise.resolve({ redirect: '/settings' }),
}) as unknown as { props: { redirectTo?: string } }
Expand All @@ -83,7 +83,7 @@ describe('LoginPage', () => {
it('handles missing search params gracefully', async () => {
setupUser(null)

const { default: LoginPage } = await import('../app/login/page')
const { LoginPageBody: LoginPage } = await import('../app/login/page')
const result = await LoginPage({
searchParams: Promise.resolve({}),
}) as unknown as { props: { error?: string; redirectTo?: string } }
Expand Down
17 changes: 16 additions & 1 deletion apps/web/app/(admin)/admin/channels/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { createServiceClient } from '@/lib/supabase/server'
import { connection } from 'next/server'
import { Suspense } from 'react'
import { ChannelActions } from './channel-actions'
import { PAGE_SIZE } from '@/lib/utils/filter-url'
import { FilterChips, SortableHeader, Pagination } from '@/components/data-table'
Expand All @@ -15,11 +17,24 @@ interface SearchParams {
page?: string
}

export default async function AdminChannelsPage({
export default function AdminChannelsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
return (
<Suspense fallback={null}>
<AdminChannelsPageBody searchParams={searchParams} />
</Suspense>
)
}

async function AdminChannelsPageBody({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
await connection()
const params = await searchParams
const statusFilter = params.status ?? 'all'
const typeFilter = params.type ?? 'all'
Expand Down
15 changes: 14 additions & 1 deletion apps/web/app/(admin)/admin/content/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from 'zod'
import { Suspense } from 'react'
import { createServiceClient } from '@/lib/supabase/server'
import { flagForRegeneration, unflagRegeneration } from '@/lib/actions/admin'
import { PAGE_SIZE } from '@/lib/utils/filter-url'
Expand All @@ -17,7 +18,19 @@ const FILTER_OPTIONS = [
{ value: 'low_score', label: 'Low Score (<3★)' },
]

export default async function AdminContentPage({
export default function AdminContentPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
return (
<Suspense fallback={null}>
<AdminContentPageBody searchParams={searchParams} />
</Suspense>
)
}

async function AdminContentPageBody({
searchParams,
}: {
searchParams: Promise<SearchParams>
Expand Down
44 changes: 29 additions & 15 deletions apps/web/app/(admin)/admin/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { verifyAdminAccess } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { headers } from 'next/headers'
import { Suspense } from 'react'
import Link from 'next/link'
import type { Metadata } from 'next'
import {
Expand Down Expand Up @@ -36,24 +37,13 @@ const NAV_GROUPS = [
},
]

export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const result = await verifyAdminAccess()
if (!result.authorized) redirect(result.redirectTo)
const user = result.user

// Header is display-only (name/avatar in sidebar) — safe to use for UI
const encoded = (await headers()).get('x-user-profile')
const profile = encoded
? JSON.parse(decodeURIComponent(encoded)) as { display_name: string | null }
: null

export default function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen">
<aside className="w-56 shrink-0 border-r bg-muted/30 flex flex-col">
<div className="px-4 py-5 border-b">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Admin</p>
<p className="text-sm font-medium mt-0.5 truncate">{profile?.display_name ?? user.email}</p>
</div>
<Suspense fallback={<AdminHeaderFallback />}>
<AdminHeader />
</Suspense>
<nav className="flex-1 py-3 px-2">
{NAV_GROUPS.map((group, gi) => (
<div key={group.label} className={gi > 0 ? 'mt-4' : ''}>
Expand Down Expand Up @@ -85,3 +75,27 @@ export default async function AdminLayout({ children }: { children: React.ReactN
</div>
)
}

async function AdminHeader() {
const result = await verifyAdminAccess()
if (!result.authorized) redirect(result.redirectTo)
const encoded = (await headers()).get('x-user-profile')
const profile = encoded
? JSON.parse(decodeURIComponent(encoded)) as { display_name: string | null }
: null
return (
<div className="px-4 py-5 border-b">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Admin</p>
<p className="text-sm font-medium mt-0.5 truncate">{profile?.display_name ?? result.user.email}</p>
</div>
)
}

function AdminHeaderFallback() {
return (
<div className="px-4 py-5 border-b">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Admin</p>
<div className="h-4 w-28 rounded bg-muted/50 mt-1.5" />
</div>
)
}
11 changes: 10 additions & 1 deletion apps/web/app/(admin)/admin/lists/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import { createServiceClient } from '@/lib/supabase/server'
import { Suspense } from 'react'

export default async function AdminListsPage() {
export default function AdminListsPage() {
return (
<Suspense fallback={null}>
<AdminListsPageBody />
</Suspense>
)
}

async function AdminListsPageBody() {
const supabase = createServiceClient()

const { data: lists } = await supabase
Expand Down
13 changes: 12 additions & 1 deletion apps/web/app/(admin)/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
import { createServiceClient } from '@/lib/supabase/server'
import { connection } from 'next/server'
import Link from 'next/link'
import { Suspense } from 'react'

export default async function AdminDashboardPage() {
export default function AdminDashboardPage() {
return (
<Suspense fallback={null}>
<AdminDashboardPageBody />
</Suspense>
)
}

async function AdminDashboardPageBody() {
await connection()
const supabase = createServiceClient()

const [
Expand Down
15 changes: 14 additions & 1 deletion apps/web/app/(admin)/admin/problems/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createServiceClient } from '@/lib/supabase/server'
import { Suspense } from 'react'
import { DeleteProblemButton } from './delete-button'
import { PAGE_SIZE } from '@/lib/utils/filter-url'
import { sanitizeSearch } from '@/lib/utils/sanitize-search'
Expand All @@ -10,7 +11,19 @@ interface SearchParams {
page?: string
}

export default async function AdminProblemsPage({
export default function AdminProblemsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
return (
<Suspense fallback={null}>
<AdminProblemsPageBody searchParams={searchParams} />
</Suspense>
)
}

async function AdminProblemsPageBody({
searchParams,
}: {
searchParams: Promise<SearchParams>
Expand Down
13 changes: 12 additions & 1 deletion apps/web/app/(admin)/admin/push/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { createServiceClient } from '@/lib/supabase/server'
import { connection } from 'next/server'
import { Suspense } from 'react'
import { ForceNotifyButton } from './force-notify-button'

function getLastNUtcDates(n: number): string[] {
Expand All @@ -16,7 +18,16 @@ function toUtcDate(utcTimestamp: string): string {
return new Date(utcTimestamp).toISOString().slice(0, 10)
}

export default async function AdminPushPage() {
export default function AdminPushPage() {
return (
<Suspense fallback={null}>
<AdminPushPageBody />
</Suspense>
)
}

async function AdminPushPageBody() {
await connection()
const supabase = createServiceClient()
// eslint-disable-next-line react-hooks/purity -- async Server Component renders once per request
const now = Date.now()
Expand Down
15 changes: 14 additions & 1 deletion apps/web/app/(admin)/admin/users/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createServiceClient } from '@/lib/supabase/server'
import { Suspense } from 'react'
import { LineToggle } from './line-toggle'
import { DeleteButton } from './delete-button'
import { PAGE_SIZE } from '@/lib/utils/filter-url'
Expand All @@ -25,7 +26,19 @@ interface UserWithChannels {
notification_channels: Array<{ channel_type: string; is_verified: boolean }>
}

export default async function AdminUsersPage({
export default function AdminUsersPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
return (
<Suspense fallback={null}>
<AdminUsersPageBody searchParams={searchParams} />
</Suspense>
)
}

async function AdminUsersPageBody({
searchParams,
}: {
searchParams: Promise<SearchParams>
Expand Down
13 changes: 12 additions & 1 deletion apps/web/app/(auth)/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { getActiveListProgress } from '@/lib/repositories/list.repository'
import { getRecentHistory, getStreakHistory } from '@/lib/repositories/history.repository'
import { calculateStreak } from '@/lib/services/streak.service'
import { redirect } from 'next/navigation'
import { connection } from 'next/server'
import Link from 'next/link'
import { Suspense } from 'react'
import { CircleCheck } from 'lucide-react'
import type { Metadata } from 'next'
import { UnsolvedQueue } from './unsolved-queue'
Expand Down Expand Up @@ -42,7 +44,16 @@ function ProgressRing({ pct }: { pct: number }) {
)
}

export default async function DashboardPage() {
export default function DashboardPage() {
return (
<Suspense fallback={null}>
<DashboardPageBody />
</Suspense>
)
}

async function DashboardPageBody() {
await connection()
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
Expand Down
11 changes: 10 additions & 1 deletion apps/web/app/(auth)/garden/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,23 @@ import { getTopicProficiency, getGardenSummary } from '@/lib/repositories/garden
import { getUserBadges } from '@/lib/repositories/badge.repository'
import { redirect } from 'next/navigation'
import Link from 'next/link'
import { Suspense } from 'react'
import { CoffeeTree } from './coffee-tree'
import { GardenTracker } from './garden-tracker'
import { BadgeShowcase } from './badge-showcase'
import type { Metadata } from 'next'

export const metadata: Metadata = { title: '咖啡莊園 — CaffeCode' }

export default async function GardenPage() {
export default function GardenPage() {
return (
<Suspense fallback={null}>
<GardenPageBody />
</Suspense>
)
}

async function GardenPageBody() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
Expand Down
Loading
Loading