feat(#41): implement Widget/HUD system

- BaseWidget wrapper with loading/error states
- WidgetRegistry for central widget management
- WidgetGrid with react-grid-layout integration
- TasksWidget, CalendarWidget, QuickCaptureWidget
- useLayouts hooks for layout persistence
- Comprehensive test suite (TDD approach)
This commit is contained in:
Jason Woltje
2026-01-29 17:54:46 -06:00
parent 95833fb4ea
commit 14a1e218a5
14 changed files with 2142 additions and 0 deletions

View File

@@ -0,0 +1,176 @@
/**
* React Query hooks for layout management
*/
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { UserLayout, WidgetPlacement } from "@mosaic/shared";
const LAYOUTS_KEY = ["layouts"];
interface CreateLayoutData {
name: string;
layout: WidgetPlacement[];
isDefault?: boolean;
metadata?: Record<string, unknown>;
}
interface UpdateLayoutData {
id: string;
name?: string;
layout?: WidgetPlacement[];
isDefault?: boolean;
metadata?: Record<string, unknown>;
}
/**
* Fetch all layouts for the current user
*/
export function useLayouts() {
return useQuery<UserLayout[]>({
queryKey: LAYOUTS_KEY,
queryFn: async () => {
const response = await fetch("/api/layouts");
if (!response.ok) {
throw new Error("Failed to fetch layouts");
}
return response.json();
},
});
}
/**
* Fetch a single layout by ID
*/
export function useLayout(id: string) {
return useQuery<UserLayout>({
queryKey: [...LAYOUTS_KEY, id],
queryFn: async () => {
const response = await fetch(`/api/layouts/${id}`);
if (!response.ok) {
throw new Error("Failed to fetch layout");
}
return response.json();
},
enabled: !!id,
});
}
/**
* Fetch the default layout
*/
export function useDefaultLayout() {
return useQuery<UserLayout>({
queryKey: [...LAYOUTS_KEY, "default"],
queryFn: async () => {
const response = await fetch("/api/layouts/default");
if (!response.ok) {
throw new Error("Failed to fetch default layout");
}
return response.json();
},
});
}
/**
* Create a new layout
*/
export function useCreateLayout() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: CreateLayoutData) => {
const response = await fetch("/api/layouts", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error("Failed to create layout");
}
return response.json();
},
onSuccess: () => {
// Invalidate layouts cache to refetch
queryClient.invalidateQueries({ queryKey: LAYOUTS_KEY });
},
});
}
/**
* Update an existing layout
*/
export function useUpdateLayout() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, ...data }: UpdateLayoutData) => {
const response = await fetch(`/api/layouts/${id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error("Failed to update layout");
}
return response.json();
},
onSuccess: (_, variables) => {
// Invalidate affected queries
queryClient.invalidateQueries({ queryKey: LAYOUTS_KEY });
queryClient.invalidateQueries({ queryKey: [...LAYOUTS_KEY, variables.id] });
},
});
}
/**
* Delete a layout
*/
export function useDeleteLayout() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
const response = await fetch(`/api/layouts/${id}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error("Failed to delete layout");
}
return response.json();
},
onSuccess: () => {
// Invalidate layouts cache to refetch
queryClient.invalidateQueries({ queryKey: LAYOUTS_KEY });
},
});
}
/**
* Helper hook to save layout changes with debouncing
*/
export function useSaveLayout(layoutId: string) {
const updateLayout = useUpdateLayout();
const saveLayout = (layout: WidgetPlacement[]) => {
updateLayout.mutate({
id: layoutId,
layout,
});
};
return {
saveLayout,
isSaving: updateLayout.isPending,
error: updateLayout.error,
};
}