feat(web): wire calendar page to real API data #474
@@ -3,57 +3,161 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { Calendar } from "@/components/calendar/Calendar";
|
import { Calendar } from "@/components/calendar/Calendar";
|
||||||
import { mockEvents } from "@/lib/api/events";
|
import { fetchEvents } from "@/lib/api/events";
|
||||||
|
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||||
|
import { useWorkspaceId } from "@/lib/hooks";
|
||||||
import type { Event } from "@mosaic/shared";
|
import type { Event } from "@mosaic/shared";
|
||||||
|
|
||||||
export default function CalendarPage(): ReactElement {
|
export default function CalendarPage(): ReactElement {
|
||||||
|
const workspaceId = useWorkspaceId();
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadEvents();
|
if (!workspaceId) {
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function loadEvents(): Promise<void> {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// TODO: Replace with real API call when backend is ready
|
|
||||||
// const data = await fetchEvents();
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
||||||
setEvents(mockEvents);
|
|
||||||
} catch (err) {
|
|
||||||
setError(
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: "We had trouble loading your calendar. Please try again when you're ready."
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wsId = workspaceId;
|
||||||
|
let cancelled = false;
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
async function loadEvents(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const data = await fetchEvents(wsId);
|
||||||
|
if (!cancelled) {
|
||||||
|
setEvents(data);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error("[Calendar] Failed to fetch events:", err);
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "We had trouble loading your calendar. Please try again when you're ready."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadEvents();
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [workspaceId]);
|
||||||
|
|
||||||
|
function handleRetry(): void {
|
||||||
|
if (!workspaceId) return;
|
||||||
|
|
||||||
|
const wsId = workspaceId;
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
fetchEvents(wsId)
|
||||||
|
.then((data) => {
|
||||||
|
setEvents(data);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error("[Calendar] Retry failed:", err);
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "We had trouble loading your calendar. Please try again when you're ready."
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
|
||||||
|
Calendar
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: "var(--text-muted)" }} className="mt-2">
|
||||||
|
View your schedule at a glance
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<MosaicSpinner label="Loading calendar..." />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error !== null) {
|
||||||
|
return (
|
||||||
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
|
||||||
|
Calendar
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: "var(--text-muted)" }} className="mt-2">
|
||||||
|
View your schedule at a glance
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="rounded-lg p-6 text-center"
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: "var(--danger)" }}>{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={handleRetry}
|
||||||
|
className="mt-4 rounded-md px-4 py-2 text-sm font-medium transition-colors"
|
||||||
|
style={{
|
||||||
|
background: "var(--accent)",
|
||||||
|
color: "var(--surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="container mx-auto px-4 py-8">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Calendar</h1>
|
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
|
||||||
<p className="text-gray-600 mt-2">View your schedule at a glance</p>
|
Calendar
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: "var(--text-muted)" }} className="mt-2">
|
||||||
|
View your schedule at a glance
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error !== null ? (
|
{events.length === 0 ? (
|
||||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-6 text-center">
|
<div
|
||||||
<p className="text-amber-800">{error}</p>
|
className="rounded-lg p-8 text-center"
|
||||||
<button
|
style={{
|
||||||
onClick={() => void loadEvents()}
|
background: "var(--surface)",
|
||||||
className="mt-4 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 transition-colors"
|
border: "1px solid var(--border)",
|
||||||
>
|
}}
|
||||||
Try again
|
>
|
||||||
</button>
|
<p className="text-lg" style={{ color: "var(--text-muted)" }}>
|
||||||
|
No events scheduled
|
||||||
|
</p>
|
||||||
|
<p className="text-sm mt-2" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Your calendar is clear
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Calendar events={events} isLoading={isLoading} />
|
<Calendar events={events} isLoading={false} />
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,84 +7,51 @@ import type { Event } from "@mosaic/shared";
|
|||||||
import { apiGet, type ApiResponse } from "./client";
|
import { apiGet, type ApiResponse } from "./client";
|
||||||
|
|
||||||
export interface EventFilters {
|
export interface EventFilters {
|
||||||
startDate?: Date;
|
/** Filter events starting from this date (inclusive) */
|
||||||
endDate?: Date;
|
startFrom?: Date;
|
||||||
workspaceId?: string;
|
/** Filter events starting up to this date (inclusive) */
|
||||||
|
startTo?: Date;
|
||||||
|
/** Filter by project ID */
|
||||||
|
projectId?: string;
|
||||||
|
/** Filter by all-day events */
|
||||||
|
allDay?: boolean;
|
||||||
|
/** Page number (1-based) */
|
||||||
|
page?: number;
|
||||||
|
/** Items per page (max 100) */
|
||||||
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch events with optional filters
|
* Fetch events with optional filters
|
||||||
|
*
|
||||||
|
* @param workspaceId - Workspace ID sent via X-Workspace-Id header
|
||||||
|
* @param filters - Optional query parameter filters
|
||||||
*/
|
*/
|
||||||
export async function fetchEvents(filters?: EventFilters): Promise<Event[]> {
|
export async function fetchEvents(workspaceId?: string, filters?: EventFilters): Promise<Event[]> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
if (filters?.startDate) {
|
if (filters?.startFrom) {
|
||||||
params.append("startDate", filters.startDate.toISOString());
|
params.append("startFrom", filters.startFrom.toISOString());
|
||||||
}
|
}
|
||||||
if (filters?.endDate) {
|
if (filters?.startTo) {
|
||||||
params.append("endDate", filters.endDate.toISOString());
|
params.append("startTo", filters.startTo.toISOString());
|
||||||
}
|
}
|
||||||
if (filters?.workspaceId) {
|
if (filters?.projectId) {
|
||||||
params.append("workspaceId", filters.workspaceId);
|
params.append("projectId", filters.projectId);
|
||||||
|
}
|
||||||
|
if (filters?.allDay !== undefined) {
|
||||||
|
params.append("allDay", String(filters.allDay));
|
||||||
|
}
|
||||||
|
if (filters?.page !== undefined) {
|
||||||
|
params.append("page", String(filters.page));
|
||||||
|
}
|
||||||
|
if (filters?.limit !== undefined) {
|
||||||
|
params.append("limit", String(filters.limit));
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryString = params.toString();
|
const queryString = params.toString();
|
||||||
const endpoint = queryString ? `/api/events?${queryString}` : "/api/events";
|
const endpoint = queryString ? `/api/events?${queryString}` : "/api/events";
|
||||||
|
|
||||||
const response = await apiGet<ApiResponse<Event[]>>(endpoint);
|
const response = await apiGet<ApiResponse<Event[]>>(endpoint, workspaceId);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Mock events for development (until backend endpoints are ready)
|
|
||||||
*/
|
|
||||||
export const mockEvents: Event[] = [
|
|
||||||
{
|
|
||||||
id: "event-1",
|
|
||||||
title: "Team standup",
|
|
||||||
description: "Daily sync meeting",
|
|
||||||
startTime: new Date("2026-01-29T10:00:00"),
|
|
||||||
endTime: new Date("2026-01-29T10:30:00"),
|
|
||||||
allDay: false,
|
|
||||||
location: "Zoom",
|
|
||||||
recurrence: null,
|
|
||||||
creatorId: "user-1",
|
|
||||||
workspaceId: "workspace-1",
|
|
||||||
projectId: null,
|
|
||||||
metadata: {},
|
|
||||||
createdAt: new Date("2026-01-28"),
|
|
||||||
updatedAt: new Date("2026-01-28"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "event-2",
|
|
||||||
title: "Project review",
|
|
||||||
description: "Quarterly project review session",
|
|
||||||
startTime: new Date("2026-01-30T14:00:00"),
|
|
||||||
endTime: new Date("2026-01-30T15:30:00"),
|
|
||||||
allDay: false,
|
|
||||||
location: "Conference Room A",
|
|
||||||
recurrence: null,
|
|
||||||
creatorId: "user-1",
|
|
||||||
workspaceId: "workspace-1",
|
|
||||||
projectId: null,
|
|
||||||
metadata: {},
|
|
||||||
createdAt: new Date("2026-01-28"),
|
|
||||||
updatedAt: new Date("2026-01-28"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "event-3",
|
|
||||||
title: "Focus time",
|
|
||||||
description: "Dedicated time for deep work",
|
|
||||||
startTime: new Date("2026-01-31T09:00:00"),
|
|
||||||
endTime: new Date("2026-01-31T12:00:00"),
|
|
||||||
allDay: false,
|
|
||||||
location: null,
|
|
||||||
recurrence: null,
|
|
||||||
creatorId: "user-1",
|
|
||||||
workspaceId: "workspace-1",
|
|
||||||
projectId: null,
|
|
||||||
metadata: {},
|
|
||||||
createdAt: new Date("2026-01-28"),
|
|
||||||
updatedAt: new Date("2026-01-28"),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|||||||
Reference in New Issue
Block a user