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>
  );
}