HomeWidgetRail.tsx8.4 KBView on GitHub 'use client';
import { useMemo, useState } from 'react';
import { Check, SlidersHorizontal } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useQuery } from '@tanstack/react-query';
import { resolveDealsAopId } from '@/modules/aop/utils/deals-aop';
import { useHomeSettingList } from '@/modules/home/hooks/use-home-setting-list';
import { useHomeOverview } from '@/modules/home/hooks/use-home-overview';
import { useHomePipelineFilters } from '@/modules/home/hooks/use-home-pipeline-filters';
import { useTRPC } from '@/providers/query-provider';
import { cn } from '@/lib/utils';
import { DEFAULT_HOME_WIDGETS, LISTED_HOME_WIDGETS, resolveHomeWidgets } from './registry';
/**
* The home screen's second column — a user-configurable stack of widgets beside the chat.
*
* Everything the user wants at a glance used to sit UNDER the composer in one centred column,
* so the day, the agents and any metric competed for the same vertical space and only the
* first one was above the fold. The rail is the fix: the chat column keeps the greeting, the
* composer and the day's tasks; everything glanceable moves here.
*
* ── One query for the whole rail ─────────────────────────────────────────────
*
* The metric widgets — Pipeline and Statistics — are pure projections of a single
* `statistics.getOverview`, fetched once here and passed down; see use-home-overview. (The
* design planned five separate metric tiles and the build collapsed them into these two, so
* "one query for the rail" now buys less than it was drawn to, but it is still the property
* that keeps a tile from fetching its own scope.) The call is issued ONLY when a mounted
* widget declares `needsOverview`, so a rail of nothing but Meetings makes no statistics
* request at all — and one request covers Pipeline and Statistics together when both are on.
*
* ── One page, one scrollbar ──────────────────────────────────────────────────
*
* The rail does NOT scroll. The hero row above it owns the only scrollbar on the screen, so
* the rail and the chat column move together as one page — two independent scrollers would
* let the day's tasks and the day's schedule drift out of step with each other.
*
* ── Hidden below `lg` ────────────────────────────────────────────────────────
*
* A second column needs the width to be a second column. Below `lg` it is not rendered rather
* than wrapped underneath, because a "rail" stacked under the chat is just the old single
* column again with extra card chrome.
*/
export function HomeWidgetRail({ className }: { className?: string }) {
const trpc = useTRPC();
const [pickerOpen, setPickerOpen] = useState(false);
const { value: widgetIds, setValue: setWidgetIds } = useHomeSettingList(
'homeWidgets',
DEFAULT_HOME_WIDGETS,
);
const widgets = useMemo(() => resolveHomeWidgets(widgetIds), [widgetIds]);
const needsOverview = widgets.some((w) => w.needsOverview);
// The Pipeline widget's filters scope the SHARED overview, so they are read here rather
// than inside the tile — one query for the rail is the whole point, and a tile that
// re-fetched with its own scope would quietly make that two.
//
// The AOP resolves the same way the tile resolves it: an unset `aopId` means "never chosen"
// and falls back to the fuzzy-matched deals AOP, NOT to unscoped.
const { filters } = useHomePipelineFilters();
const { data: aopsData } = useQuery(trpc.aop.listAopsForUser.queryOptions({}));
const aopId = useMemo(
() => filters.aopId ?? resolveDealsAopId(aopsData?.aops),
[filters.aopId, aopsData],
);
const { overview, isLoading } = useHomeOverview(needsOverview, {
aopId,
targetUserId: filters.targetUserId,
});
const toggle = (id: string) =>
setWidgetIds(widgetIds.includes(id) ? widgetIds.filter((w) => w !== id) : [...widgetIds, id]);
return (
<aside
data-testid="home-widget-rail"
className={cn(
// No scroller of its own: the hero row is the page's single scrollbar, and a rail
// that scrolled independently would slide out of step with the column beside it.
// Top-aligned with the CHAT INPUT, not with the greeting above it: `14vh` is the
// greeting's own top padding, and `4rem` is the greeting block itself (its 2.5rem
// line box plus its `pb-6`). The rail is the day's context, and the composer is what
// it sits beside — starting it level with "Welcome back" left it floating a heading
// higher than anything it relates to.
'hidden w-[21rem] shrink-0 flex-col gap-3 px-4 pb-10 pt-[calc(14vh+4rem)] lg:flex',
className,
)}
>
{widgets.map((widget) => (
<widget.Render key=[redacted] overview={overview} isLoading={isLoading} />
))}
{/* The rail's ONE editing affordance. Adding and removing are the same act — deciding
what is on the rail — so they live behind the same button rather than being split
between here and a ✕ on every card. */}
<button
type="button"
onClick={() => setPickerOpen(true)}
className="flex shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-xl bg-muted/50 py-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<SlidersHorizontal aria-hidden className="h-3.5 w-3.5 shrink-0" />
Edit widgets
</button>
{pickerOpen && (
<WidgetPickerDialog
open
onOpenChange={setPickerOpen}
activeIds={widgetIds}
onToggle={toggle}
/>
)}
</aside>
);
}
/**
* The "Add widget" grid. Lists every widget the build OFFERS (`LISTED_HOME_WIDGETS`, i.e. not
* the ones flagged `unlisted`), marking the ones already in the rail rather than hiding them —
* the same dialog is then how you take one out, and a picker whose contents change depending
* on what you already have is a picker you have to re-learn each visit.
*
* The one consequence of `unlisted`: a rail that already holds such a widget has no control
* for taking it back off, because this dialog is the only one. That is the trade for keeping
* an unshipped tile running on the rails that already name it.
*/
function WidgetPickerDialog({
open,
onOpenChange,
activeIds,
onToggle,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
activeIds: readonly string[];
onToggle: (id: string) => void;
}) {
const active = useMemo(() => new Set(activeIds), [activeIds]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Edit home screen widgets</DialogTitle>
</DialogHeader>
<div className="grid max-h-[60vh] gap-2 overflow-y-auto sm:grid-cols-2">
{LISTED_HOME_WIDGETS.map((widget) => {
const isActive = active.has(widget.id);
return (
<button
key=[redacted]
type="button"
data-testid="widget-picker-card"
aria-pressed={isActive}
onClick={() => onToggle(widget.id)}
className={cn(
'flex cursor-pointer flex-col rounded-xl border p-3 text-left transition-colors',
isActive
? 'border-primary/50 bg-accent'
: 'border-border bg-raised hover:border-primary/30 hover:bg-accent',
)}
>
<div className="flex min-w-0 items-center gap-2">
<widget.icon aria-hidden className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate text-sm font-medium text-foreground">
{widget.title}
</span>
{isActive && <Check aria-hidden className="ml-auto h-4 w-4 shrink-0 text-primary" />}
</div>
<p className="mt-1 text-xs text-muted-foreground">{widget.description}</p>
</button>
);
})}
</div>
</DialogContent>
</Dialog>
);
}