diff --git a/.sisyphus/notepads/club-work-manager/learnings.md b/.sisyphus/notepads/club-work-manager/learnings.md
index 54480fa..1bfffe3 100644
--- a/.sisyphus/notepads/club-work-manager/learnings.md
+++ b/.sisyphus/notepads/club-work-manager/learnings.md
@@ -1561,3 +1561,14 @@ frontend/
- **UI Component Usage**: Leveraged shadcn `Table` for the list and `Card` for details and new task forms, alongside raw inputs for simplified creation without needing heavy forms libraries.
+
+
+## Task 20: Shift Sign-Up UI (2026-03-03)
+
+### Key Learnings
+
+- Card-based UI pattern for shifts: Used shadcn Card component instead of tables for a more visual schedule representation
+- Capacity calculation and Progress component: Calculated percentage and used shadcn Progress bar to visually indicate filled spots
+- Past shift detection and button visibility: Checked if shift startTime is in the past to conditionally show 'Past' badge and hide sign-up buttons
+- Sign-up/cancel mutation patterns: Added mutations using useSignUpShift and useCancelSignUp hooks that invalidate the 'shifts' query on success
+- Tests: Vitest tests need to wrap Suspense inside act when dealing with asynchronous loading in Next.js 15+
diff --git a/.sisyphus/plans/club-work-manager.md b/.sisyphus/plans/club-work-manager.md
index 0de3a52..6add7d2 100644
--- a/.sisyphus/plans/club-work-manager.md
+++ b/.sisyphus/plans/club-work-manager.md
@@ -1854,7 +1854,7 @@ Max Concurrent: 6 (Wave 1)
- Files: `frontend/src/app/(protected)/tasks/**/*.tsx`, `frontend/src/hooks/useTasks.ts`
- Pre-commit: `bun run build && bun run test`
-- [ ] 20. Shift List + Shift Detail + Sign-Up UI
+- [x] 20. Shift List + Shift Detail + Sign-Up UI
**What to do**:
- Create `/frontend/src/app/(protected)/shifts/page.tsx`:
diff --git a/frontend/src/app/(protected)/shifts/[id]/page.tsx b/frontend/src/app/(protected)/shifts/[id]/page.tsx
new file mode 100644
index 0000000..aeff791
--- /dev/null
+++ b/frontend/src/app/(protected)/shifts/[id]/page.tsx
@@ -0,0 +1,98 @@
+'use client';
+
+import { use } from 'react';
+import { useShift, useSignUpShift, useCancelSignUp } from '@/hooks/useShifts';
+import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Progress } from '@/components/ui/progress';
+import { Badge } from '@/components/ui/badge';
+import { useRouter } from 'next/navigation';
+import { useSession } from 'next-auth/react';
+
+export default function ShiftDetailPage({ params }: { params: Promise<{ id: string }> }) {
+ const resolvedParams = use(params);
+ const { data: shift, isLoading } = useShift(resolvedParams.id);
+ const signUpMutation = useSignUpShift();
+ const cancelMutation = useCancelSignUp();
+ const router = useRouter();
+ const { data: session } = useSession();
+
+ if (isLoading) return
Loading shift...
;
+ if (!shift) return Shift not found
;
+
+ const capacityPercentage = (shift.signups.length / shift.capacity) * 100;
+ const isFull = shift.signups.length >= shift.capacity;
+ const isPast = new Date(shift.startTime) < new Date();
+ const isSignedUp = shift.signups.some((s) => s.memberId === session?.user?.id);
+
+ const handleSignUp = async () => {
+ await signUpMutation.mutateAsync(shift.id);
+ };
+
+ const handleCancelSignUp = async () => {
+ await cancelMutation.mutateAsync(shift.id);
+ };
+
+ return (
+
+
+
+
+ {shift.title}
+ {isPast && Past}
+
+
+
+
+
+ Time: {new Date(shift.startTime).toLocaleString()} - {new Date(shift.endTime).toLocaleTimeString()}
+
+ {shift.location && (
+
Location: {shift.location}
+ )}
+ {shift.description && (
+
Description: {shift.description}
+ )}
+
+
+
+
+ Capacity
+ {shift.signups.length}/{shift.capacity} spots filled
+
+
+
+
+
+
Signed Up Members ({shift.signups.length})
+ {shift.signups.length === 0 ? (
+
No sign-ups yet
+ ) : (
+
+ {shift.signups.map((signup) => (
+ - Member ID: {signup.memberId}
+ ))}
+
+ )}
+
+
+
+ {!isPast && !isFull && !isSignedUp && (
+
+ )}
+ {!isPast && isSignedUp && (
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/(protected)/shifts/new/page.tsx b/frontend/src/app/(protected)/shifts/new/page.tsx
new file mode 100644
index 0000000..093f93a
--- /dev/null
+++ b/frontend/src/app/(protected)/shifts/new/page.tsx
@@ -0,0 +1,127 @@
+'use client';
+
+import { useState } from 'react';
+import { useCreateShift } from '@/hooks/useShifts';
+import { useTenant } from '@/contexts/tenant-context';
+import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { useRouter } from 'next/navigation';
+
+export default function NewShiftPage() {
+ const { mutateAsync: createShift, isPending, error } = useCreateShift();
+ const { activeClubId } = useTenant();
+ const router = useRouter();
+
+ const [formData, setFormData] = useState({
+ title: '',
+ description: '',
+ location: '',
+ startTime: '',
+ endTime: '',
+ capacity: 5,
+ });
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!activeClubId) return;
+
+ try {
+ const data = await createShift({
+ ...formData,
+ startTime: new Date(formData.startTime).toISOString(),
+ endTime: new Date(formData.endTime).toISOString(),
+ clubId: activeClubId,
+ });
+
+ router.push(`/shifts/${data.id}`);
+ } catch (err) {
+ console.error('Failed to create shift', err);
+ }
+ };
+
+ return (
+
+
+
+ Create New Shift
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/(protected)/shifts/page.tsx b/frontend/src/app/(protected)/shifts/page.tsx
new file mode 100644
index 0000000..4838a62
--- /dev/null
+++ b/frontend/src/app/(protected)/shifts/page.tsx
@@ -0,0 +1,35 @@
+'use client';
+
+import { useState } from 'react';
+import { useShifts } from '@/hooks/useShifts';
+import { useTenant } from '@/contexts/tenant-context';
+import { Button } from '@/components/ui/button';
+import Link from 'next/link';
+import { ShiftCard } from '@/components/shifts/shift-card';
+
+export default function ShiftsPage() {
+ const [dateRange] = useState<{ startDate?: string; endDate?: string }>({});
+ const { data, isLoading } = useShifts(dateRange);
+ const { userRole } = useTenant();
+
+ if (isLoading) return Loading shifts...
;
+
+ return (
+
+
+
Shifts
+ {(userRole === 'Manager' || userRole === 'Admin') && (
+
+
+
+ )}
+
+
+
+ {data?.items.map((shift) => (
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/components/__tests__/shift-card.test.tsx b/frontend/src/components/__tests__/shift-card.test.tsx
new file mode 100644
index 0000000..0092df6
--- /dev/null
+++ b/frontend/src/components/__tests__/shift-card.test.tsx
@@ -0,0 +1,55 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { ShiftCard } from '../shifts/shift-card';
+
+describe('ShiftCard', () => {
+ it('shows capacity correctly (2/3 spots filled)', () => {
+ render(
+
+ );
+ expect(screen.getByText('2/3 spots filled')).toBeInTheDocument();
+ });
+
+ it('disables sign-up button when full', () => {
+ render(
+
+ );
+ expect(screen.getByText('3/3 spots filled')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Sign Up' })).not.toBeInTheDocument();
+ });
+
+ it('shows "Past" badge and no sign-up button for past shifts', () => {
+ render(
+
+ );
+ expect(screen.getByText('Past')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Sign Up' })).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/__tests__/shift-detail.test.tsx b/frontend/src/components/__tests__/shift-detail.test.tsx
new file mode 100644
index 0000000..9e97e00
--- /dev/null
+++ b/frontend/src/components/__tests__/shift-detail.test.tsx
@@ -0,0 +1,124 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, act } from '@testing-library/react';
+import { Suspense } from 'react';
+import ShiftDetailPage from '../../app/(protected)/shifts/[id]/page';
+
+vi.mock('@/hooks/useShifts', () => ({
+ useShift: vi.fn(),
+ useSignUpShift: vi.fn(),
+ useCancelSignUp: vi.fn(),
+}));
+
+vi.mock('next/navigation', () => ({
+ useRouter: vi.fn(() => ({
+ push: vi.fn(),
+ back: vi.fn(),
+ })),
+}));
+
+vi.mock('@/contexts/tenant-context', () => ({
+ useTenant: vi.fn(() => ({
+ activeClubId: 'club-123',
+ userRole: 'Member',
+ })),
+}));
+
+vi.mock('next-auth/react', () => ({
+ useSession: vi.fn(() => ({
+ data: { user: { id: 'user-123' } },
+ status: 'authenticated',
+ })),
+}));
+
+import { useShift, useSignUpShift, useCancelSignUp } from '@/hooks/useShifts';
+
+describe('ShiftDetailPage', () => {
+ const mockSignUp = vi.fn();
+ const mockCancel = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ (useSignUpShift as any).mockReturnValue({ mutateAsync: mockSignUp, isPending: false });
+ (useCancelSignUp as any).mockReturnValue({ mutateAsync: mockCancel, isPending: false });
+ });
+
+ it('shows "Sign Up" button if capacity available', async () => {
+ (useShift as any).mockReturnValue({
+ data: {
+ id: '1',
+ title: 'Detail Shift',
+ startTime: new Date(Date.now() + 100000).toISOString(),
+ endTime: new Date(Date.now() + 200000).toISOString(),
+ capacity: 3,
+ signups: [{ id: 's1', memberId: 'other-user' }],
+ },
+ isLoading: false,
+ });
+
+ const params = Promise.resolve({ id: '1' });
+ await act(async () => {
+ render(
+ Loading...}>
+
+
+ );
+ });
+
+ expect(screen.getByRole('button', { name: 'Sign Up' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Cancel Sign-up' })).not.toBeInTheDocument();
+ });
+
+ it('shows "Cancel Sign-up" button if user is signed up', async () => {
+ (useShift as any).mockReturnValue({
+ data: {
+ id: '1',
+ title: 'Detail Shift',
+ startTime: new Date(Date.now() + 100000).toISOString(),
+ endTime: new Date(Date.now() + 200000).toISOString(),
+ capacity: 3,
+ signups: [{ id: 's1', memberId: 'user-123' }],
+ },
+ isLoading: false,
+ });
+
+ const params = Promise.resolve({ id: '1' });
+ await act(async () => {
+ render(
+ Loading...}>
+
+
+ );
+ });
+
+ expect(screen.getByRole('button', { name: 'Cancel Sign-up' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Sign Up' })).not.toBeInTheDocument();
+ });
+
+ it('calls sign up mutation on click', async () => {
+ (useShift as any).mockReturnValue({
+ data: {
+ id: '1',
+ title: 'Detail Shift',
+ startTime: new Date(Date.now() + 100000).toISOString(),
+ endTime: new Date(Date.now() + 200000).toISOString(),
+ capacity: 3,
+ signups: [],
+ },
+ isLoading: false,
+ });
+
+ const params = Promise.resolve({ id: '1' });
+ await act(async () => {
+ render(
+ Loading...}>
+
+
+ );
+ });
+
+ const signUpBtn = screen.getByRole('button', { name: 'Sign Up' });
+ fireEvent.click(signUpBtn);
+
+ expect(mockSignUp).toHaveBeenCalledWith('1');
+ });
+});
diff --git a/frontend/src/components/shifts/shift-card.tsx b/frontend/src/components/shifts/shift-card.tsx
new file mode 100644
index 0000000..ffd4677
--- /dev/null
+++ b/frontend/src/components/shifts/shift-card.tsx
@@ -0,0 +1,50 @@
+import Link from 'next/link';
+import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Progress } from '@/components/ui/progress';
+import { Badge } from '@/components/ui/badge';
+import { ShiftListItemDto } from '@/hooks/useShifts';
+
+interface ShiftCardProps {
+ shift: ShiftListItemDto;
+}
+
+export function ShiftCard({ shift }: ShiftCardProps) {
+ const capacityPercentage = (shift.currentSignups / shift.capacity) * 100;
+ const isFull = shift.currentSignups >= shift.capacity;
+ const isPast = new Date(shift.startTime) < new Date();
+
+ return (
+
+
+
+ {shift.title}
+ {isPast && Past}
+
+
+ {new Date(shift.startTime).toLocaleString()} - {new Date(shift.endTime).toLocaleTimeString()}
+
+
+
+
+
+
+ Capacity
+ {shift.currentSignups}/{shift.capacity} spots filled
+
+
+
+
+
+
+
+
+ {!isPast && !isFull && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/ui/progress.tsx b/frontend/src/components/ui/progress.tsx
new file mode 100644
index 0000000..5a0a5a6
--- /dev/null
+++ b/frontend/src/components/ui/progress.tsx
@@ -0,0 +1,31 @@
+"use client"
+
+import * as React from "react"
+import { Progress as ProgressPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Progress({
+ className,
+ value,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export { Progress }
diff --git a/frontend/src/components/ui/textarea.tsx b/frontend/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..e67d8fe
--- /dev/null
+++ b/frontend/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/frontend/src/hooks/useShifts.ts b/frontend/src/hooks/useShifts.ts
new file mode 100644
index 0000000..45eb589
--- /dev/null
+++ b/frontend/src/hooks/useShifts.ts
@@ -0,0 +1,137 @@
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTenant } from '@/contexts/tenant-context';
+import { apiClient } from '@/lib/api';
+
+export interface ShiftListDto {
+ items: ShiftListItemDto[];
+ total: number;
+ page: number;
+ pageSize: number;
+}
+
+export interface ShiftListItemDto {
+ id: string;
+ title: string;
+ startTime: string;
+ endTime: string;
+ capacity: number;
+ currentSignups: number;
+}
+
+export interface ShiftDetailDto {
+ id: string;
+ title: string;
+ description?: string;
+ location?: string;
+ startTime: string;
+ endTime: string;
+ capacity: number;
+ signups: ShiftSignupDto[];
+ clubId: string;
+ createdById: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface ShiftSignupDto {
+ id: string;
+ memberId: string;
+ signedUpAt: string;
+}
+
+export interface CreateShiftRequest {
+ title: string;
+ description?: string;
+ location?: string;
+ startTime: string;
+ endTime: string;
+ capacity: number;
+ clubId: string;
+}
+
+export function useShifts(filters?: { startDate?: string; endDate?: string; page?: number }) {
+ const { activeClubId } = useTenant();
+
+ return useQuery({
+ queryKey: ['shifts', activeClubId, filters],
+ queryFn: async () => {
+ const params = new URLSearchParams();
+ if (filters?.startDate) params.append('startDate', filters.startDate);
+ if (filters?.endDate) params.append('endDate', filters.endDate);
+ if (filters?.page) params.append('page', filters.page.toString());
+
+ const res = await apiClient(`/api/shifts?${params}`);
+ if (!res.ok) throw new Error('Failed to fetch shifts');
+ return res.json();
+ },
+ enabled: !!activeClubId,
+ });
+}
+
+export function useShift(id: string) {
+ const { activeClubId } = useTenant();
+
+ return useQuery({
+ queryKey: ['shifts', activeClubId, id],
+ queryFn: async () => {
+ const res = await apiClient(`/api/shifts/${id}`);
+ if (!res.ok) throw new Error('Failed to fetch shift');
+ return res.json();
+ },
+ enabled: !!activeClubId && !!id,
+ });
+}
+
+export function useCreateShift() {
+ const queryClient = useQueryClient();
+ const { activeClubId } = useTenant();
+
+ return useMutation({
+ mutationFn: async (data: CreateShiftRequest) => {
+ const res = await apiClient('/api/shifts', {
+ method: 'POST',
+ body: JSON.stringify(data),
+ });
+ if (!res.ok) throw new Error('Failed to create shift');
+ return res.json();
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['shifts', activeClubId] });
+ },
+ });
+}
+
+export function useSignUpShift() {
+ const queryClient = useQueryClient();
+ const { activeClubId } = useTenant();
+
+ return useMutation({
+ mutationFn: async (shiftId: string) => {
+ const res = await apiClient(`/api/shifts/${shiftId}/signup`, {
+ method: 'POST',
+ });
+ if (!res.ok) throw new Error('Failed to sign up');
+ return res.json();
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['shifts', activeClubId] });
+ },
+ });
+}
+
+export function useCancelSignUp() {
+ const queryClient = useQueryClient();
+ const { activeClubId } = useTenant();
+
+ return useMutation({
+ mutationFn: async (shiftId: string) => {
+ const res = await apiClient(`/api/shifts/${shiftId}/signup`, {
+ method: 'DELETE',
+ });
+ if (!res.ok) throw new Error('Failed to cancel sign-up');
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['shifts', activeClubId] });
+ },
+ });
+}