Files
Kaj Kowalski cd05fc8648 fix: resolve Prettier markdown code block parsing errors
- Fix syntax errors in skills markdown files (.github/skills, .opencode/skills)
- Change typescript to tsx for code blocks with JSX
- Replace ellipsis (...) in array examples with valid syntax
- Separate CSS from TypeScript into distinct code blocks
- Convert JavaScript object examples to valid JSON in docs
- Fix enum definitions with proper comma separation
2026-01-20 21:09:29 +01:00

678 lines
24 KiB
TypeScript

"use client";
import {
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Clock,
Eye,
Filter,
Globe,
MessageSquare,
Search,
} from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useId, useReducer } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { formatCategory } from "@/lib/format-enums";
import type { ChatSession } from "@/lib/types";
// State types
interface FilterState {
searchTerm: string;
debouncedSearchTerm: string;
category: string;
language: string;
startDate: string;
endDate: string;
sortKey: string;
sortOrder: "asc" | "desc";
options: { categories: string[]; languages: string[] };
}
interface PaginationState {
currentPage: number;
totalPages: number;
pageSize: number;
}
interface UIState {
loading: boolean;
error: string | null;
filtersExpanded: boolean;
}
interface SessionsState {
sessions: ChatSession[];
filters: FilterState;
pagination: PaginationState;
ui: UIState;
}
// Action types
type SessionsAction =
| { type: "SET_SESSIONS"; payload: ChatSession[] }
| {
type: "SET_FILTER";
field: keyof Omit<FilterState, "options" | "debouncedSearchTerm">;
value: string;
}
| { type: "SET_DEBOUNCED_SEARCH"; value: string }
| {
type: "SET_FILTER_OPTIONS";
payload: { categories: string[]; languages: string[] };
}
| { type: "SET_PAGE"; page: number }
| { type: "SET_TOTAL_PAGES"; total: number }
| { type: "SET_LOADING"; loading: boolean }
| { type: "SET_ERROR"; error: string | null }
| { type: "TOGGLE_FILTERS" };
const initialState: SessionsState = {
sessions: [],
filters: {
searchTerm: "",
debouncedSearchTerm: "",
category: "",
language: "",
startDate: "",
endDate: "",
sortKey: "startTime",
sortOrder: "desc",
options: { categories: [], languages: [] },
},
pagination: {
currentPage: 1,
totalPages: 0,
pageSize: 10,
},
ui: {
loading: true,
error: null,
filtersExpanded: false,
},
};
function sessionsReducer(
state: SessionsState,
action: SessionsAction
): SessionsState {
switch (action.type) {
case "SET_SESSIONS":
return { ...state, sessions: action.payload };
case "SET_FILTER":
return {
...state,
filters: { ...state.filters, [action.field]: action.value },
pagination: { ...state.pagination, currentPage: 1 }, // Reset page on filter change
};
case "SET_DEBOUNCED_SEARCH":
return {
...state,
filters: { ...state.filters, debouncedSearchTerm: action.value },
};
case "SET_FILTER_OPTIONS":
return {
...state,
filters: { ...state.filters, options: action.payload },
};
case "SET_PAGE":
return {
...state,
pagination: { ...state.pagination, currentPage: action.page },
};
case "SET_TOTAL_PAGES":
return {
...state,
pagination: { ...state.pagination, totalPages: action.total },
};
case "SET_LOADING":
return { ...state, ui: { ...state.ui, loading: action.loading } };
case "SET_ERROR":
return { ...state, ui: { ...state.ui, error: action.error } };
case "TOGGLE_FILTERS":
return {
...state,
ui: { ...state.ui, filtersExpanded: !state.ui.filtersExpanded },
};
default:
return state;
}
}
export default function SessionsPage() {
const [state, dispatch] = useReducer(sessionsReducer, initialState);
const { sessions, filters, pagination, ui } = state;
const searchHeadingId = useId();
const filtersHeadingId = useId();
const filterContentId = useId();
const categoryFilterId = useId();
const categoryHelpId = useId();
const languageFilterId = useId();
const languageHelpId = useId();
const sortOrderId = useId();
const sortOrderHelpId = useId();
const resultsHeadingId = useId();
const startDateFilterId = useId();
const startDateHelpId = useId();
const endDateFilterId = useId();
const endDateHelpId = useId();
const sortKeyId = useId();
const sortKeyHelpId = useId();
// Debounce search term
useEffect(() => {
const timerId = setTimeout(() => {
dispatch({ type: "SET_DEBOUNCED_SEARCH", value: filters.searchTerm });
}, 500);
return () => clearTimeout(timerId);
}, [filters.searchTerm]);
const fetchFilterOptions = useCallback(async () => {
try {
const response = await fetch("/api/dashboard/session-filter-options");
if (!response.ok) throw new Error("Failed to fetch filter options");
const data = await response.json();
dispatch({ type: "SET_FILTER_OPTIONS", payload: data });
} catch (err) {
dispatch({
type: "SET_ERROR",
error:
err instanceof Error ? err.message : "Failed to load filter options",
});
}
}, []);
const fetchSessions = useCallback(async () => {
dispatch({ type: "SET_LOADING", loading: true });
dispatch({ type: "SET_ERROR", error: null });
try {
const params = new URLSearchParams();
if (filters.debouncedSearchTerm)
params.append("searchTerm", filters.debouncedSearchTerm);
if (filters.category) params.append("category", filters.category);
if (filters.language) params.append("language", filters.language);
if (filters.startDate) params.append("startDate", filters.startDate);
if (filters.endDate) params.append("endDate", filters.endDate);
if (filters.sortKey) params.append("sortKey", filters.sortKey);
if (filters.sortOrder) params.append("sortOrder", filters.sortOrder);
params.append("page", pagination.currentPage.toString());
params.append("pageSize", pagination.pageSize.toString());
const response = await fetch(
`/api/dashboard/sessions?${params.toString()}`
);
if (!response.ok)
throw new Error(`Failed to fetch sessions: ${response.statusText}`);
const data = await response.json();
dispatch({ type: "SET_SESSIONS", payload: data.sessions || [] });
dispatch({
type: "SET_TOTAL_PAGES",
total: Math.ceil((data.totalSessions || 0) / pagination.pageSize),
});
} catch (err) {
dispatch({
type: "SET_ERROR",
error: err instanceof Error ? err.message : "An unknown error occurred",
});
dispatch({ type: "SET_SESSIONS", payload: [] });
} finally {
dispatch({ type: "SET_LOADING", loading: false });
}
}, [filters, pagination.currentPage, pagination.pageSize]);
useEffect(() => {
fetchSessions();
}, [fetchSessions]);
useEffect(() => {
fetchFilterOptions();
}, [fetchFilterOptions]);
return (
<div className="space-y-6">
{/* Page heading for screen readers */}
<h1 className="sr-only">Sessions Management</h1>
{/* Header */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<MessageSquare className="h-6 w-6" />
<CardTitle>Chat Sessions</CardTitle>
</div>
</CardHeader>
</Card>
{/* Search Input */}
<section aria-labelledby={searchHeadingId}>
<h2 id={searchHeadingId} className="sr-only">
Search Sessions
</h2>
<Card>
<CardContent className="pt-6">
<div className="relative">
<Search
className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"
aria-hidden="true"
/>
<Input
placeholder="Search sessions (ID, category, initial message...)"
value={filters.searchTerm}
onChange={(e) =>
dispatch({
type: "SET_FILTER",
field: "searchTerm",
value: e.target.value,
})
}
className="pl-10"
aria-label="Search sessions by ID, category, or message content"
/>
</div>
</CardContent>
</Card>
</section>
{/* Filter and Sort Controls */}
<section aria-labelledby={filtersHeadingId}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="h-5 w-5" aria-hidden="true" />
<CardTitle id={filtersHeadingId} className="text-lg">
Filters & Sorting
</CardTitle>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => dispatch({ type: "TOGGLE_FILTERS" })}
className="gap-2"
aria-expanded={ui.filtersExpanded}
aria-controls={filterContentId}
>
{ui.filtersExpanded ? (
<>
<ChevronUp className="h-4 w-4" />
Hide
</>
) : (
<>
<ChevronDown className="h-4 w-4" />
Show
</>
)}
</Button>
</div>
</CardHeader>
{ui.filtersExpanded && (
<CardContent id={filterContentId}>
<fieldset>
<legend className="sr-only">
Session Filters and Sorting Options
</legend>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
{/* Category Filter */}
<div className="space-y-2">
<Label htmlFor={categoryFilterId}>Category</Label>
<select
id={categoryFilterId}
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
value={filters.category}
onChange={(e) =>
dispatch({
type: "SET_FILTER",
field: "category",
value: e.target.value,
})
}
aria-describedby={categoryHelpId}
>
<option value="">All Categories</option>
{filters.options.categories.map((cat) => (
<option key={cat} value={cat}>
{formatCategory(cat)}
</option>
))}
</select>
<div id={categoryHelpId} className="sr-only">
Filter sessions by category type
</div>
</div>
{/* Language Filter */}
<div className="space-y-2">
<Label htmlFor={languageFilterId}>Language</Label>
<select
id={languageFilterId}
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
value={filters.language}
onChange={(e) =>
dispatch({
type: "SET_FILTER",
field: "language",
value: e.target.value,
})
}
aria-describedby={languageHelpId}
>
<option value="">All Languages</option>
{filters.options.languages.map((lang) => (
<option key={lang} value={lang}>
{lang.toUpperCase()}
</option>
))}
</select>
<div id={languageHelpId} className="sr-only">
Filter sessions by language
</div>
</div>
{/* Start Date Filter */}
<div className="space-y-2">
<Label htmlFor={startDateFilterId}>Start Date</Label>
<Input
type="date"
id={startDateFilterId}
value={filters.startDate}
onChange={(e) =>
dispatch({
type: "SET_FILTER",
field: "startDate",
value: e.target.value,
})
}
aria-describedby={startDateHelpId}
/>
<div id={startDateHelpId} className="sr-only">
Filter sessions from this date onwards
</div>
</div>
{/* End Date Filter */}
<div className="space-y-2">
<Label htmlFor={endDateFilterId}>End Date</Label>
<Input
type="date"
id={endDateFilterId}
value={filters.endDate}
onChange={(e) =>
dispatch({
type: "SET_FILTER",
field: "endDate",
value: e.target.value,
})
}
aria-describedby={endDateHelpId}
/>
<div id={endDateHelpId} className="sr-only">
Filter sessions up to this date
</div>
</div>
{/* Sort Key */}
<div className="space-y-2">
<Label htmlFor={sortKeyId}>Sort By</Label>
<select
id={sortKeyId}
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
value={filters.sortKey}
onChange={(e) =>
dispatch({
type: "SET_FILTER",
field: "sortKey",
value: e.target.value,
})
}
aria-describedby={sortKeyHelpId}
>
<option value="startTime">Start Time</option>
<option value="category">Category</option>
<option value="language">Language</option>
<option value="sentiment">Sentiment</option>
<option value="messagesSent">Messages Sent</option>
<option value="avgResponseTime">
Avg. Response Time
</option>
</select>
<div id={sortKeyHelpId} className="sr-only">
Choose field to sort sessions by
</div>
</div>
{/* Sort Order */}
<div className="space-y-2">
<Label htmlFor={sortOrderId}>Order</Label>
<select
id={sortOrderId}
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
value={filters.sortOrder}
onChange={(e) =>
dispatch({
type: "SET_FILTER",
field: "sortOrder",
value: e.target.value as "asc" | "desc",
})
}
aria-describedby={sortOrderHelpId}
>
<option value="desc">Descending</option>
<option value="asc">Ascending</option>
</select>
<div id={sortOrderHelpId} className="sr-only">
Choose ascending or descending order
</div>
</div>
</div>
</fieldset>
</CardContent>
)}
</Card>
</section>
{/* Results section */}
<section aria-labelledby={resultsHeadingId}>
<h2 id={resultsHeadingId} className="sr-only">
Session Results
</h2>
{/* Live region for screen reader announcements */}
<output aria-live="polite" className="sr-only">
{ui.loading && "Loading sessions..."}
{ui.error && `Error loading sessions: ${ui.error}`}
{!ui.loading &&
!ui.error &&
sessions.length > 0 &&
`Found ${sessions.length} sessions`}
{!ui.loading &&
!ui.error &&
sessions.length === 0 &&
"No sessions found"}
</output>
{/* Loading State */}
{ui.loading && (
<Card>
<CardContent className="pt-6">
<div
className="text-center py-8 text-muted-foreground"
aria-hidden="true"
>
Loading sessions...
</div>
</CardContent>
</Card>
)}
{/* Error State */}
{ui.error && (
<Card>
<CardContent className="pt-6">
<div
className="text-center py-8 text-destructive"
role="alert"
aria-hidden="true"
>
Error: {ui.error}
</div>
</CardContent>
</Card>
)}
{/* Empty State */}
{!ui.loading && !ui.error && sessions.length === 0 && (
<Card>
<CardContent className="pt-6">
<div className="text-center py-8 text-muted-foreground">
{filters.debouncedSearchTerm
? `No sessions found for "${filters.debouncedSearchTerm}".`
: "No sessions found."}
</div>
</CardContent>
</Card>
)}
{/* Sessions List */}
{!ui.loading && !ui.error && sessions.length > 0 && (
<ul aria-label="Chat sessions" className="grid gap-4">
{sessions.map((session) => (
<li key={session.id}>
<Card className="hover:shadow-md transition-shadow">
<CardContent className="pt-6">
<article aria-labelledby={`session-${session.id}-title`}>
<header className="flex justify-between items-start mb-4">
<div className="space-y-2 flex-1">
<h3
id={`session-${session.id}-title`}
className="sr-only"
>
Session {session.sessionId || session.id} from{" "}
{new Date(session.startTime).toLocaleDateString()}
</h3>
<div className="flex items-center gap-3">
<Badge
variant="outline"
className="font-mono text-xs"
>
ID
</Badge>
<code className="text-sm text-muted-foreground font-mono truncate max-w-24">
{session.sessionId || session.id}
</code>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
<Clock
className="h-3 w-3 mr-1"
aria-hidden="true"
/>
{new Date(session.startTime).toLocaleDateString()}
</Badge>
<span className="text-xs text-muted-foreground">
{new Date(session.startTime).toLocaleTimeString()}
</span>
</div>
</div>
<Link href={`/dashboard/sessions/${session.id}`}>
<Button
variant="outline"
size="sm"
className="gap-2"
aria-label={`View details for session ${session.sessionId || session.id}`}
>
<Eye className="h-4 w-4" aria-hidden="true" />
<span className="hidden sm:inline">
View Details
</span>
</Button>
</Link>
</header>
<div className="flex flex-wrap gap-2 mb-3">
{session.category && (
<Badge variant="secondary" className="gap-1">
<Filter className="h-3 w-3" aria-hidden="true" />
{formatCategory(session.category)}
</Badge>
)}
{session.language && (
<Badge variant="outline" className="gap-1">
<Globe className="h-3 w-3" aria-hidden="true" />
{session.language.toUpperCase()}
</Badge>
)}
</div>
{session.summary ? (
<p className="text-sm text-muted-foreground line-clamp-2">
{session.summary}
</p>
) : session.initialMsg ? (
<p className="text-sm text-muted-foreground line-clamp-2">
{session.initialMsg}
</p>
) : null}
</article>
</CardContent>
</Card>
</li>
))}
</ul>
)}
{/* Pagination */}
{pagination.totalPages > 0 && (
<Card>
<CardContent className="pt-6">
<div className="flex justify-center items-center gap-4">
<Button
variant="outline"
onClick={() =>
dispatch({
type: "SET_PAGE",
page: Math.max(pagination.currentPage - 1, 1),
})
}
disabled={pagination.currentPage === 1}
className="gap-2"
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {pagination.currentPage} of {pagination.totalPages}
</span>
<Button
variant="outline"
onClick={() =>
dispatch({
type: "SET_PAGE",
page: Math.min(
pagination.currentPage + 1,
pagination.totalPages
),
})
}
disabled={pagination.currentPage === pagination.totalPages}
className="gap-2"
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)}
</section>
</div>
);
}