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
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ This is a Next.js forecasting application inspired by Philip Tetlock's Good Judg
**Testing Setup:**

- Unit tests use Vitest with Node.js environment
- **Gotcha**: Importing components that transitively import `lib/database.ts` will fail in unit tests (requires `DATABASE_URL`). Extract pure logic (e.g., Zod schemas) into separate files for testability.
- Testcontainers integration available for database testing with real PostgreSQL instances
- Test files: `**/*.{test,spec}.{ts,tsx}`
- Coverage provided by V8 with HTML/JSON/text reports
Expand All @@ -49,13 +50,17 @@ This is a Next.js forecasting application inspired by Philip Tetlock's Good Judg
- **Database**: PostgreSQL with Kysely query builder
- **Connection**: `/lib/database.ts` exports `db` instance
- **Types**: `/types/db_types.ts` contains all database types and table definitions
- **Tables**: users, forecasts, props, competitions, categories, resolutions, feature_flags
- **Tables**: users, forecasts, props, competitions, categories, resolutions, feature_flags, competition_members (roles: `admin`/`forecaster`)
- **Views**: Prefixed with `v_` (e.g., `v_forecasts`, `v_props`) for complex queries with joins

### Server Actions Pattern

This codebase follows a structured server action pattern that returns results instead of throwing errors. **See `/docs/server-actions-best-practices.md` for complete documentation and examples.**

- Server actions return `ServerActionResult<T>` — either `success(data)` or `error(message, code)`
- Use `withRLS(userId, async (trx) => ...)` from `/lib/db-helpers.ts` for queries needing Row Level Security
- Client components consume server actions via the `useServerAction` hook from `/hooks/use-server-action.ts`

### Authentication & Authorization

- JWT-based auth with cookies
Expand Down
48 changes: 0 additions & 48 deletions app/competitions/[competitionId]/members/members-page-content.tsx

This file was deleted.

93 changes: 0 additions & 93 deletions app/competitions/[competitionId]/members/page.tsx

This file was deleted.

83 changes: 69 additions & 14 deletions components/competition-dashboard/competition-dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"use client";

import { useCallback, useMemo } from "react";
import { useCallback, useEffect, useMemo, useState, useTransition } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { UserPlus } from "lucide-react";
import { CompetitionHeader } from "./competition-header";
import { CompetitionTabs, type DashboardTab } from "./competition-tabs";
import { StatCards } from "./stat-cards";
Expand All @@ -10,9 +11,13 @@
import { ForecastablePropsTable } from "@/components/forecastable-props-table";
import { PropsTable } from "@/components/props/props-table";
import Leaderboard from "@/components/scores/leaderboard";
import { MembersTable, InviteMemberDialog } from "@/components/members";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { getCompetitionMembers } from "@/lib/db_actions/competition-members";
import type { CompetitionStats, UpcomingDeadline } from "@/lib/db_actions/competition-stats";
import type { CompetitionScore } from "@/lib/db_actions";
import type { Category, PropWithUserForecast } from "@/types/db_types";
import type { Category, PropWithUserForecast, VCompetitionMember } from "@/types/db_types";

interface CompetitionDashboardProps {
competitionId: number;
Expand Down Expand Up @@ -58,7 +63,7 @@
: "overview";

// Filter props based on tab
const now = new Date();

Check warning on line 66 in components/competition-dashboard/competition-dashboard.tsx

View workflow job for this annotation

GitHub Actions / PR Checks

The 'now' object construction makes the dependencies of useMemo Hook (at line 94) change on every render. To fix this, wrap the initialization of 'now' in its own useMemo() Hook

Check warning on line 66 in components/competition-dashboard/competition-dashboard.tsx

View workflow job for this annotation

GitHub Actions / PR Checks

The 'now' object construction makes the dependencies of useMemo Hook (at line 85) change on every render. To fix this, wrap the initialization of 'now' in its own useMemo() Hook

// Helper to get the effective close date for a prop
// Private competitions use per-prop dates, public use competition-level dates
Expand Down Expand Up @@ -118,8 +123,31 @@
// Calculate forecaster count from scores for public competitions
const forecasterCount = scores.overallScores?.length ?? 0;

// Show members tab only for private competitions where user is admin
const showMembersTab = isPrivate && isAdmin;
// Show members tab for all private competition members
const showMembersTab = isPrivate;

// Members tab state — fetch members when the tab is active
const [members, setMembers] = useState<VCompetitionMember[] | null>(null);
const [membersError, setMembersError] = useState<string | null>(null);
const [isLoadingMembers, startLoadingMembers] = useTransition();
const [showInviteDialog, setShowInviteDialog] = useState(false);
const [membersRefreshKey, setMembersRefreshKey] = useState(0);
const refreshMembers = useCallback(() => {
setMembersRefreshKey((k) => k + 1);
}, []);

useEffect(() => {
if (activeTab !== "members" || !isPrivate) return;
startLoadingMembers(async () => {
setMembersError(null);
const result = await getCompetitionMembers(competitionId);
if (result.success) {
setMembers(result.data);
} else {
setMembersError(result.error);
}
});
}, [activeTab, isPrivate, competitionId, membersRefreshKey]);

return (
<div className="min-h-screen bg-background">
Expand Down Expand Up @@ -222,16 +250,43 @@
</div>
)}
{activeTab === "members" && showMembersTab && (
<div className="text-center py-8">
<p className="text-muted-foreground mb-4">
View and manage competition members
</p>
<button
onClick={() => router.push(`/competitions/${competitionId}/members`)}
className="text-primary hover:underline"
>
Go to Members Page →
</button>
<div className="max-w-3xl mx-auto space-y-6">
{isAdmin && (
<div className="flex items-center justify-between">
<p className="text-muted-foreground">
Manage who has access to this competition.
</p>
<Button onClick={() => setShowInviteDialog(true)}>
<UserPlus className="h-4 w-4 mr-2" />
Add Member
</Button>
</div>
)}
{membersError ? (
<div className="text-center py-12">
<p className="text-sm text-destructive">{membersError}</p>
</div>
) : isLoadingMembers || members === null ? (
<div className="flex items-center justify-center py-12">
<Spinner className="h-6 w-6" />
</div>
) : (
<MembersTable
members={members}
competitionId={competitionId}
currentUserId={currentUserId}
isAdmin={isAdmin}
onMemberChange={refreshMembers}
/>
)}
{isAdmin && (
<InviteMemberDialog
competitionId={competitionId}
isOpen={showInviteDialog}
onClose={() => setShowInviteDialog(false)}
onMemberChange={refreshMembers}
/>
)}
</div>
)}
</div>
Expand Down
78 changes: 48 additions & 30 deletions components/competition-dashboard/competition-header.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
"use client";

import { useState } from "react";
import Link from "next/link";
import { MoreVertical, Plus, Settings, UserPlus, Users } from "lucide-react";
import { useRouter } from "next/navigation";
import { MoreVertical, Plus, Settings } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { CreateEditCompetitionForm } from "@/components/forms/create-edit-competition-form";

interface CompetitionHeaderProps {
competitionId: number;
Expand All @@ -30,6 +38,18 @@ export function CompetitionHeader({
forecasterCount,
onAddProp,
}: CompetitionHeaderProps) {
const [editOpen, setEditOpen] = useState(false);
const router = useRouter();

const competitionForForm = {
id: competitionId,
name: competitionName,
is_private: isPrivate,
forecasts_open_date: null,
forecasts_close_date: null,
end_date: null,
};

return (
<div className="flex items-center justify-between mb-4">
<div>
Expand Down Expand Up @@ -71,39 +91,37 @@ export function CompetitionHeader({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{isPrivate && (
<>
<DropdownMenuItem asChild>
<Link
href={`/competitions/${competitionId}/members`}
className="flex items-center"
>
<UserPlus className="h-4 w-4 mr-2" />
Invite Members
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
href={`/competitions/${competitionId}/members`}
className="flex items-center"
>
<Users className="h-4 w-4 mr-2" />
Manage Members
</Link>
</DropdownMenuItem>
</>
)}
<DropdownMenuItem asChild>
<Link
href={`/admin/competitions`}
className="flex items-center"
>
{isPrivate ? (
<DropdownMenuItem onSelect={() => setEditOpen(true)}>
<Settings className="h-4 w-4 mr-2" />
Competition Settings
</Link>
</DropdownMenuItem>
</DropdownMenuItem>
) : (
<DropdownMenuItem asChild>
<Link
href="/admin/competitions"
className="flex items-center"
>
<Settings className="h-4 w-4 mr-2" />
Competition Settings
</Link>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>

<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent>
<DialogTitle>Edit Competition</DialogTitle>
<CreateEditCompetitionForm
initialCompetition={competitionForForm}
onSubmit={() => {
setEditOpen(false);
router.refresh();
}}
/>
</DialogContent>
</Dialog>
</div>
)}
</div>
Expand Down
Loading
Loading