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
4 changes: 0 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,6 @@ GITHUB_REPO_NAME=BetterShift
# RATE_LIMIT_UPLOAD_AVATAR_REQUESTS=5 # Per user
# RATE_LIMIT_UPLOAD_AVATAR_WINDOW=300 # 5 minutes

# Real-Time Features
# RATE_LIMIT_SSE_CONNECTIONS=10 # Per user
# RATE_LIMIT_SSE_WINDOW=10 # 10 seconds

# Calendar Operations
# RATE_LIMIT_CALENDAR_CREATE_REQUESTS=10 # Per user
# RATE_LIMIT_CALENDAR_CREATE_WINDOW=3600 # 1 hour
Expand Down
142 changes: 110 additions & 32 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- **Database**: SQLite (via better-sqlite3) + Drizzle ORM 0.44
- **Auth**: Better Auth 1.4 (enabled by default) - email/password + OAuth (Google/GitHub/Discord) + custom OIDC
- **i18n**: next-intl 4.5 - Supported locales: `en`, `de`, `it`
- **Data Fetching**: @tanstack/react-query 5 - Polling, caching, optimistic updates
- **Key Libraries**: date-fns, ical.js, jsPDF, @dnd-kit, recharts, sonner (toasts)

## Architecture Patterns
Expand All @@ -37,7 +38,6 @@
```bash
npm run db:generate # Generate SQL from schema changes
npm run db:migrate # Apply migrations to database
npm run db:studio # Open Drizzle Studio GUI
```

### 3. Authentication & Permissions
Expand Down Expand Up @@ -181,27 +181,94 @@ ALLOW_GUEST_ACCESS=false
- `useAuthFeatures()` - Client: Convenience hook for auth-related flags
- Feature flags in [`lib/auth/feature-flags.ts`](lib/auth/feature-flags.ts) are SERVER-ONLY now

### 5. Real-Time Updates (SSE)
### 5. Real-Time Updates (React Query Polling)

**Event stream**: [`app/api/events/stream/route.ts`](app/api/events/stream/route.ts) - Server-Sent Events endpoint
**Architecture**: All data fetching uses `@tanstack/react-query` with automatic polling for live updates across all pages and components.

**Event emitter**: [`lib/event-emitter.ts`](lib/event-emitter.ts) - Singleton pattern with HMR support
**Configuration**: [`lib/query-client.ts`](lib/query-client.ts) - Global query client settings

**Emit events after mutations**:
```typescript
// Centralized refetch interval constant
export const REFETCH_INTERVAL = 5000; // 5s polling for live updates

// Default settings
{
staleTime: 3000, // Data fresh for 3s
gcTime: 300000, // Cache for 5min
refetchInterval: REFETCH_INTERVAL,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
}
```

**Using custom polling intervals**: Import and use `REFETCH_INTERVAL` from [`lib/query-client.ts`](lib/query-client.ts) in all queries that need polling:

**Query Keys**: [`lib/query-keys.ts`](lib/query-keys.ts) - Consistent key management

```typescript
import { queryKeys } from "@/lib/query-keys";

// Examples
queryKeys.shifts.byCalendar(calendarId); // ['shifts', calendarId]
queryKeys.admin.users({ filters, sort }); // ['admin', 'users', { filters, sort }]
```

**Data fetching with useQuery**:

```typescript
import { eventEmitter } from "@/lib/event-emitter";

// After creating/updating/deleting
eventEmitter.emit("calendar-change", {
type: "shift" | "preset" | "note" | "calendar" | "sync-log",
action: "create" | "update" | "delete" | "reorder",
calendarId: string,
data: unknown,
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
import { REFETCH_INTERVAL } from "@/lib/query-client";

const { data: shifts = [], isLoading } = useQuery({
queryKey: queryKeys.shifts.byCalendar(calendarId!),
queryFn: () => fetchShiftsApi(calendarId!),
enabled: !!calendarId,
refetchInterval: REFETCH_INTERVAL, // Use centralized constant
});
```

**Client connection**: Use `useSSEConnection` hook in [`hooks/useSSEConnection.ts`](hooks/useSSEConnection.ts) - automatically reconnects, handles visibility changes, and triggers data refreshes.
**Mutations with optimistic updates**:

```typescript
import { useMutation, useQueryClient } from "@tanstack/react-query";

const queryClient = useQueryClient();

const createMutation = useMutation({
mutationFn: createShiftApi,
onMutate: async (newShift) => {
await queryClient.cancelQueries({
queryKey: queryKeys.shifts.byCalendar(calendarId!),
});
const previous = queryClient.getQueryData(
queryKeys.shifts.byCalendar(calendarId!)
);
queryClient.setQueryData(
queryKeys.shifts.byCalendar(calendarId!),
(old: Shift[] = []) => [...old, optimisticShift]
);
return { previous };
},
onError: (err, variables, context) => {
queryClient.setQueryData(
queryKeys.shifts.byCalendar(calendarId!),
context?.previous
);
toast.error(t("common.createError", { item: t("shift.shift_one") }));
},
onSuccess: () => {
toast.success(t("common.created", { item: t("shift.shift_one") }));
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.shifts.byCalendar(calendarId!),
});
},
});
```

**Cache invalidation**: After mutations, use `queryClient.invalidateQueries()` to refetch related data.

### 6. Component Architecture

Expand All @@ -220,7 +287,7 @@ eventEmitter.emit("calendar-change", {

- Data fetching: `useShifts`, `usePresets`, `useNotes`, `useCalendars` (in `/hooks`)
- Actions: `useShiftActions`, `useNoteActions` (handle CRUD + optimistic updates)
- Forms: `useShiftForm`, `usePresetManagement` (form state + validation)
- Forms: `useShiftForm` (form state + validation)

### 7. Internationalization

Expand Down Expand Up @@ -303,7 +370,6 @@ import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getSessionUser } from "@/lib/auth/sessions";
import { checkPermission } from "@/lib/auth/permissions";
import { eventEmitter } from "@/lib/event-emitter";

export async function POST(request: NextRequest) {
const user = await getSessionUser(request.headers);
Expand All @@ -324,27 +390,35 @@ export async function POST(request: NextRequest) {
.values({ ...data, calendarId })
.returning();

// Emit SSE event
eventEmitter.emit("calendar-change", {
type: "shift",
action: "create",
calendarId,
data: result,
});

// No event emission needed - React Query polling handles sync
return NextResponse.json(result, { status: 201 });
}
```

### Optimistic Updates

See [`hooks/useShifts.ts`](hooks/useShifts.ts) `createShift` function:
All mutations use React Query's optimistic update pattern. See [`hooks/useShifts.ts`](hooks/useShifts.ts):

1. **onMutate**: Cancel queries, save previous state, apply optimistic update
2. **onError**: Rollback to previous state, show error toast
3. **onSuccess**: Show success toast
4. **onSettled**: Invalidate queries to refetch real data

1. Generate temp ID: `temp-${Date.now()}`
2. Add optimistic item to state immediately
3. Make API call
4. On error: Remove temp item, show toast
5. On success: Replace temp item with real data from API
```typescript
const mutation = useMutation({
mutationFn: createShiftApi,
onMutate: async (newData) => {
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData(queryKey);
queryClient.setQueryData(queryKey, (old) => [...old, optimisticItem]);
return { previous };
},
onError: (err, vars, context) => {
queryClient.setQueryData(queryKey, context?.previous);
},
onSettled: () => queryClient.invalidateQueries({ queryKey }),
});
```

### Date Handling

Expand All @@ -371,14 +445,18 @@ See [`hooks/useShifts.ts`](hooks/useShifts.ts) `createShift` function:
- [`hooks/usePublicConfig.ts`](hooks/usePublicConfig.ts) - Client-side config access hook
- [`hooks/useAuthFeatures.ts`](hooks/useAuthFeatures.ts) - Auth-specific feature flags hook
- [`components/calendar-grid.tsx`](components/calendar-grid.tsx) - Core calendar rendering with shift display
- [`hooks/useSSEConnection.ts`](hooks/useSSEConnection.ts) - Real-time sync connection management
- [`lib/query-client.ts`](lib/query-client.ts) - React Query client configuration
- [`lib/query-keys.ts`](lib/query-keys.ts) - Query key factory for consistent cache management
- [`components/query-provider.tsx`](components/query-provider.tsx) - React Query provider
- [`MIGRATION_PLAN.md`](MIGRATION_PLAN.md) - Detailed auth migration plan with phases and todos

## Important Notes

- **No NEXT*PUBLIC***: Use `getPublicConfig()` on server, `usePublicConfig()` hook on client - never `process.env.NEXT_PUBLIC_*`
- **Better Auth first**: Always check [Better Auth docs](https://www.better-auth.com/docs) before implementing auth features - use built-in methods, don't reinvent
- **SSE is required**: All mutations must emit events to keep clients in sync
- **React Query for data**: All data fetching uses `useQuery`/`useMutation` with polling - no manual fetch/refetch logic
- **Query keys from factory**: Always use `queryKeys` from `lib/query-keys.ts` for cache consistency
- **Optimistic updates**: All mutations should implement optimistic updates for instant UI feedback
- **Permission checks everywhere**: Check permissions in both API routes (server) and UI (client) for security + UX
- **Strict TypeScript**: No `any` types, use Drizzle-inferred types from schema
- **Translations required**: All user-facing strings must use `t()` function
Expand Down
95 changes: 23 additions & 72 deletions app/admin/calendars/page.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,8 @@
"use client";

import { useState, useEffect } from "react";
import { useState } from "react";
import { useTranslations } from "next-intl";
import {
Search,
Filter,
AlertCircle,
Send,
Trash2,
X,
RefreshCw,
} from "lucide-react";
import { Search, Filter, AlertCircle, Send, Trash2, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Expand All @@ -37,15 +29,9 @@ import { useAdminLevel } from "@/hooks/useAdminAccess";

export default function AdminCalendarsPage() {
const t = useTranslations();
const { fetchCalendars, deleteCalendar, bulkDeleteCalendars, isLoading } =
useAdminCalendars();
const adminLevel = useAdminLevel();
const isSuperAdmin = adminLevel === "superadmin";

// State
const [calendars, setCalendars] = useState<AdminCalendar[]>([]);
const [orphanedCount, setOrphanedCount] = useState(0);

// Filters & Sort
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<
Expand All @@ -54,6 +40,26 @@ export default function AdminCalendarsPage() {
const sortField = "createdAt" as const;
const sortDirection = "desc" as const;

// Build filters and sort
const filters: CalendarFilters = {
search: searchQuery || undefined,
status: statusFilter,
};

const sort: CalendarSort = {
field: sortField,
direction: sortDirection,
};

// Use hook with filters
const {
calendars,
orphanedCount,
isLoading,
deleteCalendar,
bulkDeleteCalendars,
} = useAdminCalendars(filters, sort);

// Selection
const [selectedIds, setSelectedIds] = useState<string[]>([]);

Expand All @@ -72,50 +78,6 @@ export default function AdminCalendarsPage() {
AdminCalendar[]
>([]);

// Load calendars
const loadCalendars = async () => {
const filters: CalendarFilters = {
search: searchQuery || undefined,
status: statusFilter,
};

const sort: CalendarSort = {
field: sortField,
direction: sortDirection,
};

const result = await fetchCalendars(filters, sort);

if (result) {
setCalendars(result.calendars);
setOrphanedCount(result.orphanedCount);
}
};

// Initial load
useEffect(() => {
const filters: CalendarFilters = {
search: searchQuery || undefined,
status: statusFilter,
};

const sort: CalendarSort = {
field: sortField,
direction: sortDirection,
};

const loadData = async () => {
const result = await fetchCalendars(filters, sort);

if (result) {
setCalendars(result.calendars);
setOrphanedCount(result.orphanedCount);
}
};

loadData();
}, [searchQuery, statusFilter, sortField, sortDirection, fetchCalendars]);

// Selection handlers
const handleToggleSelect = (calendarId: string) => {
setSelectedIds((prev) =>
Expand Down Expand Up @@ -177,7 +139,6 @@ export default function AdminCalendarsPage() {
const success = await bulkDeleteCalendars(selectedIds);
if (success) {
setSelectedIds([]);
await loadCalendars();
}
};

Expand All @@ -187,18 +148,16 @@ export default function AdminCalendarsPage() {
if (success) {
setShowDeleteDialog(false);
setSelectedCalendar(null);
await loadCalendars();
}
};

const handleSuccess = async () => {
const handleSuccess = () => {
setShowEditSheet(false);
setShowTransferSheet(false);
setShowBulkTransferSheet(false);
setSelectedCalendar(null);
setCalendarsForBulkTransfer([]);
setSelectedIds([]);
await loadCalendars();
};

const handleEditFromDetails = () => {
Expand Down Expand Up @@ -239,14 +198,6 @@ export default function AdminCalendarsPage() {
{t("admin.calendars.description")}
</p>
</div>
<Button
variant="outline"
size="icon"
onClick={() => loadCalendars()}
disabled={isLoading}
>
<RefreshCw className={isLoading ? "animate-spin" : ""} />
</Button>
</div>

{/* Orphaned Calendars Warning Banner */}
Expand Down
Loading