Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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