feat(web): wire calendar page to real API data (#474)
Some checks failed
ci/woodpecker/push/web Pipeline failed

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #474.
This commit is contained in:
2026-02-23 03:51:15 +00:00
committed by jason.woltje
parent 97606713b5
commit f97be2e6a3
2 changed files with 168 additions and 97 deletions

View File

@@ -3,57 +3,161 @@
import { useState, useEffect } from "react";
import type { ReactElement } from "react";
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";
export default function CalendarPage(): ReactElement {
const workspaceId = useWorkspaceId();
const [events, setEvents] = useState<Event[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void loadEvents();
}, []);
if (!workspaceId) {
setIsLoading(false);
return;
}
const wsId = workspaceId;
let cancelled = false;
setError(null);
setIsLoading(true);
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) {
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 (
<main className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Calendar</h1>
<p className="text-gray-600 mt-2">View your schedule at a glance</p>
<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>
{error !== null ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-6 text-center">
<p className="text-amber-800">{error}</p>
<button
onClick={() => void loadEvents()}
className="mt-4 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 transition-colors"
{events.length === 0 ? (
<div
className="rounded-lg p-8 text-center"
style={{
background: "var(--surface)",
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>
) : (
<Calendar events={events} isLoading={isLoading} />
<Calendar events={events} isLoading={false} />
)}
</main>
);

View File

@@ -7,84 +7,51 @@ import type { Event } from "@mosaic/shared";
import { apiGet, type ApiResponse } from "./client";
export interface EventFilters {
startDate?: Date;
endDate?: Date;
workspaceId?: string;
/** Filter events starting from this date (inclusive) */
startFrom?: Date;
/** 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
*
* @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();
if (filters?.startDate) {
params.append("startDate", filters.startDate.toISOString());
if (filters?.startFrom) {
params.append("startFrom", filters.startFrom.toISOString());
}
if (filters?.endDate) {
params.append("endDate", filters.endDate.toISOString());
if (filters?.startTo) {
params.append("startTo", filters.startTo.toISOString());
}
if (filters?.workspaceId) {
params.append("workspaceId", filters.workspaceId);
if (filters?.projectId) {
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 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;
}
/**
* 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"),
},
];