Skip to content
DocsSupportPricing
Roadmap (has updates coming soon)XFigma3.1K
Overview
  • Introduction
  • Get Started
  • License Setup
  • Styling
  • Registry
  • MCP Server
  • Agent Skills
  • Changelog
MCP Server
  • Claude
  • CodexCodexCodex
  • Cursor
  • v0
  • Lovable
  • Replit
  • OpenCode
  • VS Code
  • Zed
Components
  • Alert
  • Autocomplete
  • Badge
  • Data GridAutosizing, fill and resize hardening, React Compiler ready
  • Date Selector
  • Event CalendarNew event calendar component with five views
  • File Upload
  • FiltersSizing and interaction refinements
  • Frame
  • GanttNew gantt component with day to year scales
  • Icon Stack
  • KanbanonValueCommit callback and accessibility improvements
  • Number Field
  • Phone Input
  • Rating
  • Scrollspy
  • SortableonValueCommit callback and accessibility improvements
  • StepperRender prop composition and styling refinements
  • Timeline
  • Tree

Application

  • App Shell
  • Auth
  • Card
  • Chart
  • Dashboard
  • Dialog
  • Empty State
  • Event CalendarNew event calendar block added
  • Form
  • GanttNew gantt block added
  • Kanban Board
  • List
  • Navbar
  • Onboarding
  • Profile
  • Schedule
  • Settings
  • Sheet
  • Stats
  • Timeline
  • Wizard

Solutions

  • Agents
  • AI Ops
  • Analytics
  • Billing
  • Bookings
  • CRM
  • Inventory
  • Users

eCommerce

  • Category Card
  • Checkout
  • Comparison
  • Coupon
  • Filter Sidebar
  • Product Card
  • Product Detail
  • Product Grid
  • Receipt
  • Review
  • Shopping Cart
  • Wishlist

Data Grid

  • Base
  • Columns
  • Drag & Drop
  • EditingNew editable Data Grid block added
  • Expansion
  • Filtering
  • Grouping
  • Virtualization

Marketing

  • Blog
  • Contact
  • CTA
  • FAQ

Resources

  • Components
  • Blocks
  • Docs
  • Support
  • Pricing
  • Roadmap(has updates coming soon)
  • AffiliateSoon

Legal

  • Privacy Policy
  • Terms & Conditions
  • License
  • Refunds
  • Cookies

© 2026ReUI · All rights reserved

3.1K

Shadcn Gantt

PreviousNext

Custom Shadcn Gantt for React and Tailwind CSS. A headless-first gantt with split tree and timeline panes, day to year scales, zoom, drag and resize scheduling, progress, summary rollups, and an external CRUD contract.

Base UIRadix UI

The gantt package ships a subscribable headless engine (useGanttState) and a composable view layer on top of it: a resizable tree pane for the resource hierarchy, a horizontal timeline with day, week, month, quarter, and year scales, zoom, infinite scrolling, drag and resize scheduling with live validation, per-bar progress fills, and duration-weighted summary rollups on parent rows. The engine never mutates your data on its own; every timing change flows through one proposal funnel (onEventUpdate, canDropEvent) so external CRUD stays in your hands.

The composition contract is <Gantt><GanttNav /><GanttView /></Gantt>. The root provides the calendar instance and the view configuration through context; the nav family, the view, and GanttBar all read from it, so any piece can be replaced with your own markup driven by the same hooks.

Installation

pnpm dlx shadcn@latest add @reui/gantt

Usage

Shadcn Gantt Free Components

Browse 5 production-ready Shadcn Gantt components for dashboards, forms, and product UI. These examples use Base UI primitives from @base-ui/react and stay fully compatible with Shadcn Create so radius, color, and typography match your configured theme.

Browse all 5 Shadcn Gantt components for copy-ready layouts, dashboards, and forms built with Tailwind CSS in the ReUI library.

Shadcn Gantt Pro Blocks

This primitive also powers ready-made ReUI Pro blocks: complete gantt sections assembled on top of the exact component documented here, with realistic data, polished layout details, and production interaction patterns you can copy straight into your app.

Preview all 2 Shadcn Gantt Pro blocks in the ReUI blocks gallery.

FrameIcon Stack

On This Page

InstallationUsageExamplesAnnual Product RoadmapTeam Capacity ScheduleProject Status ReportAPI ReferenceGanttGanttNavGanttNavTodayGanttNavPrevGanttNavNextGanttTitleGanttScaleSwitcherGanttDatePickerGanttToolbarGanttViewGanttBarGanttEventGanttResourceGanttOccurrenceGanttSegmentGanttRecurrenceRuleGanttProposedUpdateGanttUpdateResultGanttSlotInfoGanttSlotDraftGanttSelectionGanttResourceReorderGanttRangeInfoGanttStateGanttDragStateGanttDataAdapterGanttRenderEventPropsGanttColumnContextGanttDragIndicatorPropsGanttScheduleHintPropsGanttSummaryPropsGanttApiHooksHelpersRecurrenceConfigState optionsCallbacks and validatorsView configurationGanttInteractionsGanttOffDaysConfigGanttTreePanelConfigGanttColumnGanttMetricsGanttActivationConfigGanttClassNamesInternationalization
import { Gantt } from "@/components/reui/gantt/gantt"
import { GanttNav, GanttToolbar } from "@/components/reui/gantt/gantt-nav"
import type {
  GanttEvent,
  GanttResource,
} from "@/components/reui/gantt/gantt-types"
import { GanttView } from "@/components/reui/gantt/gantt-view"
const resources: GanttResource[] = [
  {
    id: "design",
    title: "Design",
    children: [
      { id: "wireframes", title: "Wireframes" },
      { id: "visual-design", title: "Visual design" },
    ],
  },
]
 
const events: GanttEvent[] = [
  {
    id: "1",
    title: "Wireframes",
    start: new Date("2026-07-06"),
    end: new Date("2026-07-10"),
    allDay: true,
    resourceId: "wireframes",
    progress: 60,
  },
]
 
return (
  <Gantt
    defaultEvents={events}
    resources={resources}
    defaultScale="month"
    className="h-[480px]"
  >
    <GanttNav />
    <GanttView />
  </Gantt>
)

Give the root an explicit height (className="h-[480px]" or a flex parent): the root is a min-height-zero flex column and the view fills whatever it gets. The root also owns the type scale: every gantt label inherits its text size (text-xs by default), so className="text-sm" scales the whole component up in one place. Events work controlled (events + onEventsChange) or uncontrolled (defaultEvents); the same pairs exist for scale, date, selection, and interactions. Every drag, resize, and API timing change is proposed through onEventUpdate before it commits, canDropEvent validates live during the gesture, and a false return reverts the bar with no cleanup on your side, which makes persisting to a backend a matter of handling one callback.

The demo at the top of this page is the full composition: its settings menu drives every view configuration and interaction flag as controlled props from consumer state, and the unscheduled Launch tasks show the slot-selection contract - hover an empty row and click the hint tile (or drag a range) to schedule it through onSelectSlot. For the full set of variations, browse the Gantt components.

Examples

Annual Product Roadmap

A long-horizon plan on the quarter scale. Workstreams are swimlane groups whose multi-month initiatives roll up into automatic summary bars, so leadership reads the whole year at a glance and navigates a quarter at a time. The toolbar button schedules the next backlog initiative onto its empty row through the addEvent API.

Team Capacity Schedule

A people-centric weekly view. Each row is a teammate rendered with an avatar label via renderResourceLabel, their bars are the week's assignments, and weekends are shaded with offDays so real working-day capacity is obvious. The toolbar drops a fresh assignment onto the next teammate, so one person can hold several bars at once.

Project Status Report

A report on the month scale. Owner and Status columns sit beside each phase in the tree panel via the columns prop and every bar carries a progress fill. Drag, resize, and slot-select are disabled so the plan can't be shifted by dragging, while the toolbar appends new tasks as their own rows with controlled resources.

API Reference

Gantt

The root provider and container. It creates (or adopts) the calendar instance, provides it through context, and renders a div shell with an aria-live announcer. Besides the props below, it accepts every state option (see State options), every callback (see Callbacks and validators), and every view configuration key (see View configuration) as flat props.

PropTypeDefaultDescription
calendarGanttInstance<TData>-Adopt a hoisted useGanttState instance; option props are then ignored.
apiRefRefObject<GanttApi<TData> | null>-Imperative escape hatch usable from outside the tree.
renderuseRender.RenderProp-Base UI render prop to replace the root element.
classNamestring-Additional CSS classes for the root container.
childrenReactNode-The gantt composition (GanttNav, GanttToolbar, GanttView).

GanttNav

The composed navigation bar: Today, scale switcher, prev/next, and the period title with a trailing spacer. Pass children to use it as a pure layout shell instead. The title follows the viewport center while scrolling so the header always names what you are looking at.

PropTypeDefaultDescription
childrenReactNode-Custom nav content; replaces the default composition.
renderuseRender.RenderProp-Base UI render prop to replace the element.
classNamestring-Additional CSS classes.

GanttNavToday

Button that navigates to today. Renders the today i18n label by default and marks itself with data-active while the anchor period contains now.

PropTypeDefaultDescription
childrenReactNodei18n todayCustom button content.
tooltipReactNode | nullthe current dateHover/focus-visible tooltip; null disables it. Never re-triggers from a pointer click.
renderuseRender.RenderProp-Base UI render prop to replace the button.
classNamestring-Additional CSS classes.

GanttNavPrev

Icon button that steps the anchor date one period back at the current scale.

PropTypeDefaultDescription
childrenReactNodechevron iconCustom button content.
tooltipReactNode | nulli18n previousHover/focus-visible tooltip; null disables.
renderuseRender.RenderProp-Base UI render prop to replace the button.
classNamestring-Additional CSS classes.

GanttNavNext

Icon button that steps the anchor date one period forward at the current scale.

PropTypeDefaultDescription
childrenReactNodechevron iconCustom button content.
tooltipReactNode | nulli18n nextHover/focus-visible tooltip; null disables.
renderuseRender.RenderProp-Base UI render prop to replace the button.
classNamestring-Additional CSS classes.

GanttTitle

The current period title, formatted by i18n.functions.formatTitle and announced politely on change.

PropTypeDefaultDescription
format(ctx: { title: string }) => ReactNode-Wraps or replaces the formatted title text.
renderuseRender.RenderProp-Base UI render prop to replace the element.
classNamestring-Additional CSS classes.

GanttScaleSwitcher

Dropdown that switches between the Day, Week, Month, Quarter, and Year scales. Tooltips on this overlay-opener are hover-only so nothing flashes when focus returns after the menu closes.

PropTypeDefaultDescription
scalesGanttScale[]all fiveThe offered scales, in menu order.
childrenReactNodecurrent scale labelCustom trigger content.
tooltipReactNode | nulli18n selectViewHover-only tooltip; null disables.
renderuseRender.RenderProp-Base UI render prop to replace the trigger.
classNamestring-Additional CSS classes.

GanttDatePicker

Compact go-to-date picker (shadcn Calendar in a popover). Not part of the default GanttNav composition; add it to a custom nav when needed. It has no tooltip by design because it opens an overlay.

PropTypeDefaultDescription
classNamestring-Additional CSS classes.

GanttToolbar

Free slot for consumer toolbar buttons; a pure layout shell that also picks up classNames.toolbar from the view configuration.

PropTypeDefaultDescription
childrenReactNode-Toolbar content.
renderuseRender.RenderProp-Base UI render prop to replace the element.
classNamestring-Additional CSS classes.

GanttView

The gantt body: split resizable tree and timeline panes with synced scrolling, the grouped two-row header, lanes, bars, summary rollups, off-screen chips, the zoom control, and all pointer interactions. Display behavior comes from the view configuration on the root.

PropTypeDefaultDescription
intervalnumberinterval configDay-scale unit interval in minutes; clamped between 15 and 240.
renderuseRender.RenderProp-Base UI render prop to replace the element.
classNamestring-Additional CSS classes.

GanttBar

The one interactive bar element, rendered by the view for every visible segment. The wrapper owns positioning hooks, a11y, selection, drag and resize listeners, the range tooltip, the optional right-click menu, and data attributes (data-selected, data-dragging, data-progress, data-completed, data-past, data-recurring); content comes from children, the root renderEvent override, or the built-in default. Exported for fully custom view compositions.

PropTypeDefaultDescription
segmentGanttSegment<TData>-Required. The timeline segment this bar renders.
childrenReactNode-Replaces the default bar content; the interactive wrapper stays gantt-owned.
labelOutsideboolean-The title renders beside the bar (view-owned), so the default inner content is suppressed.
rowTitlestring-The owning row's title for the aria-label; omitting falls back to a memoized tree lookup.
renderuseRender.RenderProp-Base UI render prop to replace the button element.
classNamestring-Additional CSS classes.

GanttEvent

One schedulable bar. TData is a fully generic consumer payload.

PropertyTypeDefaultDescription
idstring-Required. Stable event id.
titlestring-Required. Bar title.
startDate-Required. Plain instant; consumers parse ISO strings themselves.
endDate-Required. Exclusive; must be greater than or equal to start.
allDayboolean-Whether the event is date-based rather than timed.
recurrenceGanttRecurrenceRule | string-Structured rule or a raw "RRULE:..." line.
recurringEventIdstring-This event is an edited single occurrence of that series.
originalStartDate-Which occurrence it replaces (RECURRENCE-ID semantics).
colorstring-Token or CSS color; flows to the --gantt-event-color CSS variable.
readOnlyboolean-Excluded from drag and resize regardless of interactions state.
draggableboolean-Per-event override; default comes from interactions.drag.
resizableboolean-Per-event override; default comes from interactions.resize.
prioritynumber-Packing prominence; feeds getEventPriority ordering.
progressnumber-Completion 0-100; renders as a subtle fill inside the bar.
zIndexnumber-Explicit stacking override; wins over the computed z.
resourceIdstring-Resource row this bar belongs to.
dataTData-Consumer payload, fully generic.

GanttResource

A row of the gantt tree (task, person, equipment). Nesting via children renders as collapsible groups.

PropertyTypeDefaultDescription
idstring-Required. Stable resource id.
titlestring-Required. Row title.
colorstring-Token or CSS color used for subtle row accents.
childrenGanttResource[]-Child rows; presence makes this row a group.

GanttOccurrence

One expanded instance of an event within the visible range (recurring events expand to many).

PropertyTypeDescription
keystringStable per instance: `${event.id}::${startISO}`.
eventIdstringThe source event id.
eventGanttEvent<TData>The source event.
startDateOccurrence start.
endDateOccurrence end (exclusive).
allDaybooleanWhether the occurrence is date-based.
isRecurringbooleanWhether it came from a recurrence expansion.
recurrenceIndexnumberIndex within the series, when recurring.

GanttSegment

The slice of an occurrence rendered inside one timeline range, with lane packing metadata. Passed to GanttBar and every render override.

PropertyTypeDescription
occurrenceGanttOccurrence<TData>The occurrence this segment slices.
dayDateRange-start reference instant of the segment's slice.
isStartbooleanWhether the segment contains the occurrence start.
isEndbooleanWhether the segment contains the occurrence end.
continuesBeforebooleanThe occurrence continues before this segment.
continuesAfterbooleanThe occurrence continues after this segment.
startMinnumberMinutes from the visible range start, clamped to the range.
endMinnumberMinutes from the visible range start, clamped to the range.
columnnumberLane assigned by overlap packing.
columnCountnumberTotal lanes in the overlap cluster.
columnSpannumberLanes this segment may widen into.

GanttRecurrenceRule

Structured RFC 5545 subset. The built-in expander supports freq daily/weekly/monthly/yearly, interval, count, until, and weekly byWeekday without ordinals; byMonthDay, byMonth, and byWeekday outside weekly parse but throw a GanttRecurrenceError on expansion instead of silently mis-expanding. Plug the getOccurrences option for a full engine.

PropertyTypeDefaultDescription
freq"daily" | "weekly" | "monthly" | "yearly"-Required. Recurrence frequency.
intervalnumber1Period multiplier.
countnumber-Total occurrence cap.
untilDate-Inclusive series end instant.
byWeekdayArray<GanttWeekday | { day: GanttWeekday; ordinal: number }>-Weekday filter (weekly only, no ordinals).
byMonthDaynumber[]-Parsed but not expanded (throws on expansion).
byMonthnumber[]-Parsed but not expanded (throws on expansion).
weekStartGanttWeekday-WKST; parses and round-trips.
exDatesDate[]-Excluded instants; matching occurrences are removed (they still consume their count slot).
rDatesDate[]-Extra instants added to the series, each with the event's own duration.

GanttWeekday is "MO" \| "TU" \| "WE" \| "TH" \| "FR" \| "SA" \| "SU".


GanttProposedUpdate

The proposal handed to onEventUpdate and canDropEvent for every timing change.

PropertyTypeDescription
eventGanttEvent<TData>The event being updated.
occurrenceGanttOccurrence<TData> | nullThe gestured occurrence; null when source is "api".
startDateProposed start.
endDateProposed end.
allDaybooleanProposed all-day flag.
resourceIdstringThe bar's own resource (moves stay in-row); set on create/api.
source"drag" | "resize-start" | "resize-end" | "keyboard" | "api"What produced the proposal.

GanttUpdateResult

Return type of onEventUpdate: false rejects and reverts; void or true accepts; { start?: Date; end?: Date; allDay?: boolean } accepts with an adjustment.


GanttSlotInfo

Payload of onSlotClick. A click is a point, not a range; end is reserved for future gestures.

PropertyTypeDescription
dateDateThe clicked instant.
endDateReserved for future gestures.
allDaybooleanWhether the click landed on a date-based surface.
resourceIdstringPresent when the click happened inside a resource row.

GanttSlotDraft

The in-progress drag-create rectangle only, cleared on commit or cancel; the committed slot selection lives in GanttSelection.slot. Payload of onSelectSlot and canSelectSlot.

PropertyTypeDescription
startDateDraft start.
endDateDraft end.
allDaybooleanWhether the draft is date-based.
resourceIdstringPresent when the slot was selected inside a resource row.

GanttSelection

The committed selection state.

PropertyTypeDescription
eventKeysstring[]Selected occurrence keys.
slot{ start: Date; end: Date; allDay: boolean } | nullCommitted slot selection (drafts live in GanttSlotDraft).

GanttResourceReorder

Proposal emitted when a timeline resource row is drag-reordered. Payload of onResourceReorder, canReorderResource, and onResourceReorderReject.

PropertyTypeDescription
resourceIdstringThe dragged resource id.
parentIdstring | nullNew parent id, or null for the root level.
indexnumberInsertion index among the new parent's children.
resourcesGanttResource[]The full resource tree with the move applied (convenience).

GanttRangeInfo

Payload of onRangeChange; fires once on mount and whenever the rendered range changes. Fetch remote data for range.

PropertyTypeDescription
rangeGanttDateRangeFull rendered axis range.
activeRangeGanttDateRangeThe logical period (the month/week itself).
scaleGanttScaleCurrent scale.
dateDateAnchor date.
timeZonestringDisplay time zone.

GanttDateRange is { start: Date; end: Date } with an inclusive start and exclusive end. GanttScale is "day" \| "week" \| "month" \| "quarter" \| "year".


GanttState

The full engine snapshot, readable via instance.getState() or a useGanttSelector selector.

PropertyTypeDescription
scaleGanttScaleHorizontal axis scale.
dateDateAnchor date.
visibleRangeGanttDateRangeFull rendered axis range - fetch remote data for this.
activeRangeGanttDateRangeThe logical period (the month/week itself).
eventsGanttEvent<TData>[]Current events.
selectionGanttSelectionCommitted selection.
interactionsGanttInteractionsEffective interaction switches.
loadingbooleanMirrors the loading option.
dragGanttDragState<TData> | nullIn-flight drag/resize gesture, or null.
slotDraftGanttSlotDraft | nullIn-flight drag-create rectangle, or null.
viewportCenterDate | nullInstant at the center of the scrolled viewport; the nav title follows it.

GanttDragState

The in-flight gesture stored in state.drag.

PropertyTypeDescription
kind"move" | "resize-start" | "resize-end"Gesture kind.
occurrenceGanttOccurrence<TData>The gestured occurrence.
proposedStartDateCurrent snapped proposal start.
proposedEndDateCurrent snapped proposal end.
proposedAllDaybooleanCurrent proposal all-day flag.
proposedResourceIdstringThe bar's own resource; moves are x-axis only and never cross rows.
validbooleanLast canDropEvent verdict; drives data-drop-invalid styling.

GanttDataAdapter

External-data contract: getEvents(range, signal?) => Promise<GanttEvent<TData>[]>. The type ships for adapter recipes (Google events.list and MS Graph calendarView map to GanttEvent in about 15 lines); OAuth, tokens, and sync loops are application backend territory.


GanttRenderEventProps

Payload of renderEvent and renderEventMenu.

PropertyTypeDescription
occurrenceGanttOccurrence<TData>The bar's occurrence.
segmentGanttSegment<TData>The rendered segment.
isDraggingbooleanWhether a gesture owns this bar.
isSelectedbooleanWhether the bar is selected.

GanttColumnContext

Row context handed to tree-panel column renderers, renderResourceLabel, renderResourceMenu, and the resource click callbacks.

PropertyTypeDescription
resourceGanttResourceThe row's resource.
depthnumberNesting depth (root = 0).
isGroupbooleanWhether the row has children.
collapsedbooleanWhether the group is currently collapsed.

GanttDragIndicatorProps

Live gesture snapshot handed to renderDragPreview and renderResizeIndicator; content re-renders per snap step while the gantt writes the wrapper position imperatively.

PropertyTypeDescription
occurrenceGanttOccurrence<TData>The gestured occurrence.
kind"move" | "resize-start" | "resize-end"Gesture kind.
startDateProposed (snapped) start of the current step.
endDateProposed (snapped) end of the current step.
validbooleanLast canDropEvent verdict.

GanttScheduleHintProps

Slot handed to a custom renderScheduleHint renderer.

PropertyTypeDescription
startDateSnapped hint start.
endDateSnapped hint end.
resourceGanttResourceThe hovered row's resource.

GanttSummaryProps

Parent rollup handed to a custom renderSummary renderer.

PropertyTypeDescription
resourceGanttResourceThe group row's resource.
startDateEnvelope start of the descendant bars.
endDateEnvelope end of the descendant bars.
progressnumber | nullDuration-weighted progress, or null to hide.

GanttApi

The imperative surface, available as instance.api from useGantt/useGanttState or through the root apiRef.

MethodSignatureDescription
next() => voidStep one period forward (clamped to rangeBounds).
prev() => voidStep one period back (clamped to rangeBounds).
today() => voidJump to today.
goTo(date: Date) => voidJump to a date.
setScale(scale: GanttScale) => voidSwitch the axis scale.
getEvents() => GanttEvent<TData>[]Current events.
getEvent(id: string) => GanttEvent<TData> | undefinedFind one event by id.
setEvents(events: GanttEvent<TData>[]) => voidReplace all events.
addEvent(event: GanttEvent<TData>) => voidAppend an event.
updateEvent(id: string, patch: Partial<GanttEvent<TData>>) => voidPatch an event; timing changes route through onEventUpdate with source "api".
removeEvent(id: string) => voidRemove an event.
getOccurrences(range?: GanttDateRange) => GanttOccurrence<TData>[]Expanded, sorted occurrences; defaults to the visible range.
findOverlapping(candidate: { start: Date; end: Date; excludeEventId?: string }) => GanttOccurrence<TData>[]Occurrences overlapping a candidate range.
select(selection: Partial<GanttSelection>) => voidMerge into the selection.
selectEvent(key: string, opts?: { additive?: boolean }) => voidSelect one occurrence key; additive toggles.
clearSelection() => voidClear the selection.
setInteractions(patch: Partial<GanttInteractions>) => voidPatch the interaction switches.
getVisibleRange() => GanttDateRangeFull rendered axis range.
getActiveRange() => GanttDateRangeThe logical period range.
toZoned(date: Date) => DateTZDate in the gantt's display time zone.

Hooks

Most hooks must run under a <Gantt> ancestor. The exceptions: useGanttState creates the instance itself, useGanttSelector accepts an explicit instance, useGanttSettingsVersion takes the instance as an argument, and useGanttViewConfig falls back to the default view configuration outside the tree.

HookSignatureDescription
useGanttState(options?: UseGanttStateOptions<TData>) => GanttInstance<TData>Headless root hook: the full engine without any markup. Pass the instance to <Gantt calendar={...}> or drive fully custom UI.
useGantt() => GanttInstance<TData>The stable calendar instance; throws outside <Gantt>.
useGanttSelector(selector: (state: GanttState<TData>) => T, options?: { calendar?, isEqual? }) => TFine-grained subscription with equality memoization (Object.is default).
useGanttScale() => { scale, setScale }Current scale and setter.
useGanttNavigation() => { date, title, visibleRange, activeRange, next, prev, today, goTo, isToday }Navigation state and actions; title follows the viewport center.
useGanttSelection() => { selection, select, selectEvent, clearSelection }Selection state and actions.
useGanttInteractions() => { interactions, setInteractions }Interaction switches and setter.
useGanttOccurrences(range?: GanttDateRange) => GanttOccurrence<TData>[]Expanded, sorted occurrences; defaults to the visible range.
useGanttSettings() => GanttSettings<TData>Resolved settings including merged i18n; re-renders only when settings change.
useGanttSettingsVersion(instance: GanttInstance<TData>) => numberSubscribes to settings changes only (version counter, not state).
useGanttViewConfig() => GanttViewConfig<TData>Root-level display props and render overrides, for view components.
useGanttBarContext() => GanttBarContextValue<TData>The bar's subject (occurrence, segment, isDragging, isSelected); throws outside <GanttBar>.
useGanttGestures() => { beginMove, beginResize, beginCreate, canDrag, canResize }Per-bar and per-row pointer gesture wiring, for custom view compositions.

Helpers

Pure, React-free helpers exported from gantt-lib.tsx, the gesture utilities from gantt-dnd.tsx, and mergeGanttI18n from gantt-i18n.tsx. gantt-lib.tsx also exports the advanced types GanttIndex, BuildIndexOptions, ViewRangeOptions, ViewDateRanges, and WeekStartsOn used in these signatures, and gantt.tsx exports the GanttContext and GanttViewConfigContext context objects for advanced composition.

FunctionSignatureDescription
flattenResources(resources: GanttResource[], depth?: number) => Array<{ resource: GanttResource; depth: number }>Depth-first flatten of the resource tree (parents included).
reorderResources(resources, resourceId, parentId, index) => GanttResource[] | nullPure tree move; returns a new tree, or null for impossible moves.
resolveOffDay(day: Date, timeZone: string, config: boolean | GanttOffDaysConfig | undefined) => booleanResolves whether a day is an off day in the display zone.
getGanttDateRange(scale, date, opts: { timeZone, weekStartsOn }) => { visibleRange, activeRange }Axis range for the anchor date at the given scale.
stepGanttDate(scale, date, direction: 1 | -1, opts: { timeZone }) => DateThe anchor date stepped one period.
buildEventIndex(events, visibleRange, opts: { timeZone, eventOrder?, getOccurrences? }) => { occurrences }Expands and sorts all occurrences for a range.
defaultEventOrder(a: GanttOccurrence, b: GanttOccurrence) => numberStart ascending, longer first, then key.
packTimedSegments(segments: GanttSegment[]) => voidOverlap packing for one row's timed segments; mutates column/columnCount/columnSpan.
eventsOverlap(a: { start, end }, b: { start, end }) => booleanHalf-open range overlap test.
rangesIntersect(a: GanttDateRange, b: GanttDateRange) => booleanHalf-open range intersection test.
spansMultipleDays(occ: { start, end }) => booleanTrue past 24h (an event ending exactly at the next midnight is single-day).
getDayKey(date: Date, timeZone: string) => stringStable per-day key in the display time zone.
getDayTotalMinutes(dayStart: Date, timeZone: string) => numberDay length in minutes; 1380/1500 on DST transition days.
getRangeKey(range: GanttDateRange) => stringCheap cache key for a range.
snapMinutes(minutes: number, snap: number) => numberRounds minutes to the snap grid.
toZoned(date: Date, timeZone: string) => TZDateThe instant re-expressed in the display time zone.
zonedStartOfDay(date: Date, timeZone: string) => TZDateZoned midnight of the day containing the instant.
mergeGanttI18n(overrides?: GanttI18nOverrides) => GanttI18nConfigDeep-partial i18n merge; replaces individual keys, never sections.
wasRecentDrag() => booleanTrue within 250ms of a gesture end, so click handlers can ignore the click that ends a drag.
markGestureEnd() => voidMark a non-dnd gesture (e.g. a timeline pan) so the click it ends is ignored.

Exported constants: GANTT_SCALES (the five scales in menu order), GANTT_COLORS (ten named Tailwind palette presets for bar colors), GANTT_ACTIVATION (the default activation thresholds), MIN_PACK_SLOT (30, packing-effective minimum minutes), MAX_OCCURRENCES (1000, recurrence expansion cap per event), DEFAULT_GANTT_I18N (the default i18n config), and DEFAULT_VIEW_CONFIG (the default view configuration).


Recurrence

Exported from gantt-recurrence.tsx. The built-in expander covers the RFC 5545 subset described under GanttRecurrenceRule; unsupported parts throw GanttRecurrenceError instead of silently mis-expanding, and expansion caps at MAX_OCCURRENCES (1000) per event.

FunctionSignatureDescription
parseRRuleString(input: string, timeZone?: string) => GanttRecurrenceRuleParses a raw RRULE line (with or without the RRULE: prefix); floating UNTIL resolves in the display zone.
formatRRuleString(rule: GanttRecurrenceRule) => stringSerializes the structured subset back to an RRULE line (without prefix).
expandRecurrence(event: GanttEvent<TData>, range: GanttDateRange, ctx: { timeZone: string }) => GanttOccurrence<TData>[]Expands one event into its occurrences intersecting the range; DST-safe wall-time iteration.
GanttRecurrenceErrorclass extends ErrorThrown for unsupported or invalid rules; catch it to fall back or surface a message.

Config

State options

State and configuration options accepted by useGanttState and, as flat props, by <Gantt>. Every controlled prop has an uncontrolled default* twin.

PropTypeDefaultDescription
eventsGanttEvent<TData>[]-Controlled events; pairs with onEventsChange.
defaultEventsGanttEvent<TData>[][]Initial events (uncontrolled).
scaleGanttScale-Controlled scale; pairs with onScaleChange.
defaultScaleGanttScale"day"Initial scale (uncontrolled).
dateDate-Controlled anchor date; pairs with onDateChange.
defaultDateDatenew Date()Initial anchor date (uncontrolled).
selectionGanttSelection-Controlled selection; pairs with onSelectionChange.
defaultSelectionGanttSelectionemptyInitial selection (uncontrolled).
interactionsPartial<GanttInteractions>-Controlled interaction switches; pairs with onInteractionsChange.
defaultInteractionsPartial<GanttInteractions>all trueInitial interaction switches (uncontrolled).
loadingbooleanfalseLoading flag mirrored into state.
timeZonestringsystem zoneIANA display time zone.
localeLocale-date-fns locale for all formatting.
weekStartsOn0 | 1 | 2 | 3 | 4 | 5 | 6locale's, else 0First day of the week; defaults from locale when one is set.
slotDurationnumber30Minimum created-slot length in minutes for a bare create click.
snapDurationnumber15Minute snapping on the day scale; other scales snap to whole zoned days.
i18nGanttI18nOverrides-Deep-partial label/format/function overrides (see ).
rangeBounds{ min?: Date; max?: Date }-Hard travel bounds for navigation and infinite scrolling; either side may be omitted.
activationGanttActivationConfig-Pointer-activation threshold overrides for drag/resize/create.
maxRangeWindownumber12Infinite-scroll growth cap in whole periods per side; past it the anchor slides instead.
resourcesGanttResource[][]Resource rows of the gantt tree.
getEventPriority(event: GanttEvent<TData>) => numberevent.priority ?? 0Packing prominence per event.
eventOrder(a: GanttOccurrence<TData>, b: GanttOccurrence<TData>) => numberpriority-awareOccurrence sort; the default orders higher getEventPriority first, then start/duration/key.
getOccurrences(event, range, ctx: { timeZone: string }) => Array<{ start: Date; end: Date }> | null-Escape hatch for exotic recurrence: return the expanded occurrences yourself.

Callbacks and validators

All callbacks live beside the state options on useGanttState and <Gantt>.

PropTypeDescription
onEventClick(occurrence, e: React.MouseEvent) => voidBar click (the click that ends a drag is ignored).
onEventDoubleClick(occurrence, e: React.MouseEvent) => voidBar double click.
onEventUpdate(update: GanttProposedUpdate<TData>) => GanttUpdateResultCommit gate for every timing change; return false to reject, an object to adjust.
canDropEvent(update: GanttProposedUpdate<TData>) => booleanLive validity predicate while dragging or resizing; drives the destructive indicator.
onSlotClick(slot: GanttSlotInfo, e: React.MouseEvent) => voidClick on empty schedulable track (also the schedule-hint activation).
onSelectSlot(slot: GanttSlotDraft) => voidDrag-create commit.
canSelectSlot(slot: GanttSlotDraft) => booleanLive validity predicate for the drag-create rectangle.
onCreateTask(ctx: { parentId: string | null; index: number }) => voidFires when the "add task" hint is activated; create a new tree row.
canCreateTask(ctx: { parentId: string | null }) => booleanGates the "add task" hint; the shipped view offers root-level creation only (parentId = null).
onResourceClick(ctx: GanttColumnContext, e: React.MouseEvent) => voidClick on a tree row's surface (chevron/checkbox/grip clicks excluded).
onResourceDoubleClick(ctx: GanttColumnContext, e: React.MouseEvent) => voidDouble click on a tree row's surface.
onRangeChange(info: GanttRangeInfo) => voidRendered range changed (fires once on mount); fetch remote data here.
onScaleChange(scale: GanttScale) => voidScale changed.
onDateChange(date: Date) => voidAnchor date changed (navigation or an infinite-scroll anchor slide).
onSelectionChange(selection: GanttSelection) => voidSelection changed.
onInteractionsChange(interactions: GanttInteractions) => voidInteraction switches changed.
onEventsChange(events: GanttEvent<TData>[]) => voidEvents changed (accepted updates, api mutations).
onResourceReorder(proposal: GanttResourceReorder) => void | falseCommit gate for tree-row drag reorder; adopt proposal.resources into your resources state, return false to reject.
canReorderResource(proposal: GanttResourceReorder) => booleanLive validity predicate while a resource row is being dragged.
onResourceReorderReject(proposal: GanttResourceReorder) => voidFires when a reorder gesture is released on a rejected position; explain the rejection (a toast).

View configuration

Display props and render overrides. These live on <Gantt> as flat props (and GanttView accepts interval directly), never in the headless options; view components read them via useGanttViewConfig.

PropTypeDefaultDescription
nowIndicatorbooleantrueRed now-line on the axis.
intervalnumber60Day-scale unit interval in minutes; axis units and gridlines follow it.
scrollbars"custom" | "native""custom"Scroll implementation for the gantt body: shadcn ScrollArea or browser scrollbars.
displayScheduleHintbooleanfalsePlacement hint over empty timeline track: a validated, snapped tile that opens the schedule flow.
dragCreatebooleanfalseEmpty-track presses start a drag-create gesture committing through onSelectSlot; off means the panel drags-to-pan.
displayCreateTaskHintbooleanfalse"Add task" affordance at the foot of the tree; shown only when canCreateTask allows it.
zoomControlbooleantrueFloating zoom in/out control over the track.
navButtonVariant"ghost" | "outline" | "secondary" | "default""ghost"Nav button variant; all nav buttons follow it.
navButtonSize"sm" | "default""sm"Nav button size; icon buttons use the icon twin.
offDaysboolean | GanttOffDaysConfigtrueOff-day marking on day/week/month scales; true = weekends with a muted background, an object customizes it.
columnsGanttColumn[]-Extra tree-panel columns after the built-in name column; the panel scrolls horizontally, the name column stays pinned.
columnsMenuReactNode-Consumer slot pinned at the end of the tree-panel header, the intended home for a columns dropdown.
treePanelGanttTreePanelConfig-Tree-panel width, splitter bounds, and resizability.
timelineLines"vertical" | "both" | "none""vertical"Timeline gridlines: unit boundaries only, plus row borders, or bare.
barLabel"inside" | "outside" | "auto""inside"Bar title placement; "auto" moves it outside only when the bar is too short.
offscreenIndicatorsbooleantrueEdge chips that scroll to bars outside the visible timeline.
infiniteScrollbooleantrueExtend the timeline into the past/future while scrolling near an edge (the anchor period stays the nav title).
zoomRange{ min?: number; max?: number; step?: number }0.5 - 3, step 0.25Zoom bounds and button step for the floating control.
metricsGanttMetrics-Layout metric overrides (row/lane/unit geometry, fades, thresholds).
stickyNavbooleanfalseSticky nav bar.
rowCheckboxesbooleantrueLeaf-row selection checkboxes in the tree panel; uncontrolled unless selectedRows is passed.
selectedRowsstring[]-Controlled selected row ids; pairs with onSelectedRowsChange.
onSelectedRowsChange(ids: string[]) => void-Selected rows changed.
collapsedGroupsstring[]-Controlled collapsed group ids; pairs with onCollapsedGroupsChange.
defaultCollapsedGroupsstring[]-Initial collapsed group ids (uncontrolled).
onCollapsedGroupsChange(ids: string[]) => void-Collapsed groups changed.
zoomnumber-Controlled zoom multiplier; pairs with onZoomChange.
defaultZoomnumber1Initial zoom multiplier (uncontrolled).
onZoomChange(zoom: number) => void-Zoom changed.
parentSchedulingbooleanfalseAllow drag-create and slot clicks on rows that have children; off means parents aggregate their subtree.
summaryBarsbooleantrueRollup strips on parent rows without bars of their own: descendant envelope with duration-weighted progress.
classNamesGanttClassNames-Class overrides for nav, toolbar, view, and event.
renderEvent(props: GanttRenderEventProps<TData>) => ReactNode-Replaces the default bar content; the interactive wrapper stays gantt-owned.
renderEventMenu(props: GanttRenderEventProps<TData>) => ReactNode-Right-click menu for a bar: return shadcn ContextMenu items; omit for no menu.
renderResourceLabel(props: GanttColumnContext) => ReactNode-Tree-node label; return any rich content (icons, badges). Default is the plain title.
renderResourceMenu(ctx: GanttColumnContext) => ReactNode-Right-click menu for a tree row (same contract as renderEventMenu).
renderNoResources() => ReactNode-Rendered in the timeline body when there are no resources.
renderDragPreview(props: GanttDragIndicatorProps<TData>) => ReactNode-Replaces the smooth cursor-following move clone; the gantt owns the wrapper and positions it per pointermove.
renderResizeIndicator(props: GanttDragIndicatorProps<TData>) => ReactNode-Replaces the resize edge line and status chip; same positioning contract as renderDragPreview.
renderScheduleHint(props: GanttScheduleHintProps) => ReactNode-Replaces the schedule-hint tile and bubble inside the snapped, validated, pointer-transparent wrapper.
renderSummary(props: GanttSummaryProps) => ReactNode-Replaces the parent rollup strip (the positioned wrapper stays gantt-owned).
getSummaryProgress(ctx: { resource: GanttResource; events: GanttEvent<TData>[] }) => number | null-Replaces the rollup math: return 0-100 (or null to hide). Default: duration-weighted mean progress.

GanttInteractions

Global interaction switches; per-event readOnly, draggable, and resizable override them.

PropertyTypeDefaultDescription
dragbooleantrueHorizontal move within the bar's own row; never across rows.
resizebooleantrueEdge resize on bar segments.
selectSlotbooleantrueSlot selection (drag-create and slot clicks).

GanttOffDaysConfig

Off-day (non-working day) marking. true uses the defaults: weekends with a muted background. Marked cells carry data-off for CSS-selector customization.

PropertyTypeDefaultDescription
weekendDaysnumber[][0, 6]Weekday numbers treated as off (0 = Sunday).
datesDate[]-Additional explicit off dates (compared by day in the display zone).
isOffDay(day: Date) => boolean-Full custom predicate; runs in addition to weekendDays/dates.
classNamestring"bg-muted/40"Marker classes.

GanttTreePanelConfig

Left tree-panel sizing and splitter behavior.

PropertyTypeDefaultDescription
widthnumber288Initial panel width in px.
minWidthnumber180Splitter lower bound in px.
maxWidthnumber640Splitter upper bound in px.
resizablebooleantrueDrag/keyboard splitter between the panels.
nameColumnWidthnumber208Width of the sticky name column in px.
onWidthChange(width: number) => void-Fires after any user resize (drag release, keyboard, double-click reset).

GanttColumn

One extra tree-panel column after the built-in name column.

PropertyTypeDefaultDescription
idstring-Required. Stable id; doubles as the default header label.
titleReactNode-Header label.
widthnumber96Fixed column width in px.
align"start" | "center" | "end""start"Cell content alignment.
render(ctx: GanttColumnContext) => ReactNode-Cell content per row; omit or return null for an empty cell.
classNamestring-Extra classes on every cell of this column (header included).

GanttMetrics

Layout metrics (rem unless noted); every knob falls back to its default.

PropertyTypeDefaultDescription
laneHeightnumber1.75Bar lane height.
rowPaddingnumber0.5Vertical padding around a row's lane block.
minRowHeightnumber2.5Minimum row height.
autoLabelMinnumber7barLabel "auto" flips the title outside below this bar width.
unitWidthsPartial<Record<GanttScale, number>>-Unit width at zoom 1 per scale (day scale = width per interval unit; defaults: day 5 at a 60-minute interval, week 10, month 4, quarter 8, year 10).
minTimelineWidthnumber200Minimum timeline pane width in px.
infiniteScrollEdgenumber160Scroll distance (px) from an edge that grows the range.

GanttActivationConfig

Pointer-activation thresholds; unset keys keep the dnd-kit parity defaults.

PropertyTypeDefaultDescription
moveDistancePxnumber5Mouse travel (px) before a bar move starts.
createDistancePxnumber4Mouse travel (px) before a drag-create starts.
touchDelayMsnumber250Touch long-press delay in ms.
touchTolerancePxnumber5Touch movement tolerance (px) during the long-press.

GanttClassNames

Class overrides for the composed parts.

PropertyTypeDefaultDescription
navstring-Classes for GanttNav.
toolbarstring-Classes for GanttToolbar.
viewstring-Classes for the gantt body (tree + track).
eventstring-Classes for every bar.

Internationalization

The i18n option takes a GanttI18nOverrides object: a deep-partial of GanttI18nConfig where a partial override replaces individual keys, never whole sections (merged by mergeGanttI18n, defaults in DEFAULT_GANTT_I18N).

labels keys and defaults:

PropertyTypeDefault
todaystring"Today"
previousstring"Previous"
nextstring"Next"
addEventstring"Add event"
addTaskstring"Add task"
allDaystring"All day"
loadingstring"Loading events"
eventstring"event"
events(count: number) => string"1 event" / `${count} events`
week(weekNumber: number) => string`W${weekNumber}`
resourcesstring"Resources"
goToDatestring"Go to date"
scheduleHintstring"Click to add a schedule"
reorderstring"Reorder"
selectViewstring"Select view"
zoomInstring"Zoom in"
zoomOutstring"Zoom out"
resizePanelstring"Resize panel"
jumpToBar(title: string) => string`Scroll to "${title}"`
progress(percent: number) => string`${percent}% complete`
durationDays(days: number) => string"1 day" / `${days} days`
continuesstring"continues"
scalesRecord<GanttScale, string>Day, Week, Month, Quarter, Year

formats are date-fns format strings, applied with the gantt locale:

PropertyDefaultDescription
monthTitle"MMMM yyyy"Month-scale title.
dayTitle"EEEE, MMMM d, yyyy"Day-scale title and Today tooltip.
timeGutter"h a"Day-scale axis unit labels.
eventTime"h:mm a"Timed event time labels.

functions are the composed formatters. The defaults are re-bound to the merged labels/formats on every merge, so overriding a format string (for example formats.eventTime) reaches the default renderers without also replacing the function:

PropertySignatureDescription
formatTitle(scale, ctx: { date, activeRange, visibleRange, locale? }) => stringThe nav title per scale (week gets a smart range label).
formatEventTime(start: Date, end: Date, allDay: boolean, locale?: Locale) => stringBar time/range labels; all-day bars show their date range.
formatDayRange(range: GanttDateRange, locale?: Locale) => stringCompact day-range label.
formatEventAriaLabel(parts: { title, timeLabel, rowTitle?, progressLabel?, continues: boolean }) => stringComposes the bar screen-reader label; appends labels.continues when clipped.
"use client"

import { useMemo, useRef, useState } from "react"
import {
  Gantt,
  type GanttApi,
} from "@/components/reui/gantt/gantt"
import type { GanttI18nOverrides } from "@/components/reui/gantt/gantt-i18n"
import {
  GanttNav,
  GanttToolbar,
} from "@/components/reui/gantt/gantt-nav"
import type {
  GanttEvent,
  GanttInteractions,
  GanttResource,
  GanttSlotDraft,
} from "@/components/reui/gantt/gantt-types"
import { GanttView } from "@/components/reui/gantt/gantt-view"
import { addDays, startOfDay, startOfWeek, type Locale } from "date-fns"
import { ar, de, es, fr, ja } from "date-fns/locale"

import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Label } from "@/components/ui/label"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"
import {
  RadioGroup,
  RadioGroupItem,
} from "@/components/ui/radio-group"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import {
  Tabs,
  TabsContent,
  TabsList,
  TabsTrigger,
} from "@/components/ui/tabs"
import { RotateCcwIcon, SettingsIcon } from 'lucide-react'

/**
 * White-label task tree, tall enough that the panes scroll vertically. The
 * Launch group and "Release prep" ship UNSCHEDULED: hover their empty rows
 * and click the hint tile (or drag a range) to schedule them.
 */
const RESOURCES: GanttResource[] = [
  {
    id: "planning",
    title: "Planning",
    children: [
      { id: "brief", title: "Project brief" },
      { id: "scope", title: "Scope review" },
    ],
  },
  {
    id: "design",
    title: "Design",
    children: [
      { id: "wireframes", title: "Wireframes" },
      { id: "visual-design", title: "Visual design" },
    ],
  },
  {
    id: "build",
    title: "Build",
    children: [
      { id: "frontend", title: "Frontend" },
      { id: "backend", title: "Backend" },
      { id: "qa", title: "QA pass" },
      { id: "release-prep", title: "Release prep" },
    ],
  },
  {
    id: "launch",
    title: "Launch",
    children: [
      { id: "docs", title: "Docs" },
      { id: "marketing-site", title: "Marketing site" },
      { id: "announcement", title: "Announcement" },
    ],
  },
]

/** Leaf id -> title, for naming bars scheduled from empty rows. */
const RESOURCE_TITLES = new Map(
  RESOURCES.flatMap((group) => group.children ?? []).map((leaf) => [
    leaf.id,
    leaf.title,
  ])
)

/** Small white-label fixture built around the current week. */
function buildBars(anchor: Date): GanttEvent[] {
  const week = startOfWeek(startOfDay(anchor), { weekStartsOn: 0 })
  const day = (dayOffset: number) => addDays(week, dayOffset)
  const bar = (
    resourceId: string,
    title: string,
    startOffset: number,
    days: number,
    color: string,
    progress?: number
  ): GanttEvent => ({
    id: `bar-${resourceId}`,
    title,
    start: day(startOffset),
    end: day(startOffset + days),
    allDay: true,
    color,
    resourceId,
    progress,
  })

  return [
    bar("brief", "Project brief", -9, 3, "var(--color-blue-500)", 100),
    bar("scope", "Scope review", -6, 2, "var(--color-sky-500)", 100),
    bar("wireframes", "Wireframes", -4, 4, "var(--color-violet-500)", 80),
    bar("visual-design", "Visual design", 0, 5, "var(--color-purple-500)", 35),
    bar("frontend", "Frontend", 3, 7, "var(--color-emerald-500)", 10),
    bar("backend", "Backend", 5, 6, "var(--color-teal-500)"),
    bar("qa", "QA pass", 12, 4, "var(--color-amber-500)"),
  ]
}

/**
 * i18n presets - each language ships a date-fns `locale` (localizes the axis
 * headers, the nav title, and bar date labels; it also drives the default
 * week start, so a German timeline starts Monday) plus an `i18n` override map
 * for the static strings the locale can't reach (Today, the scale names, the
 * schedule hint). Arabic also flips the chart to right-to-left. English is the
 * built-in default, so it leaves both undefined.
 */
interface DemoLocale {
  id: string
  /** Native language name, shown in the picker. */
  label: string
  locale: Locale | undefined
  dir: "ltr" | "rtl"
  i18n: GanttI18nOverrides | undefined
}

const LOCALES: DemoLocale[] = [
  {
    id: "en",
    label: "English",
    locale: undefined,
    dir: "ltr",
    i18n: undefined,
  },
  {
    id: "de",
    label: "Deutsch",
    locale: de,
    dir: "ltr",
    i18n: {
      labels: {
        today: "Heute",
        scheduleHint: "Zum Planen klicken",
        reorder: "Neu anordnen",
        scales: {
          day: "Tag",
          week: "Woche",
          month: "Monat",
          quarter: "Quartal",
          year: "Jahr",
        },
      },
    },
  },
  {
    id: "fr",
    label: "Français",
    locale: fr,
    dir: "ltr",
    i18n: {
      labels: {
        today: "Aujourd'hui",
        scheduleHint: "Cliquer pour planifier",
        reorder: "Réorganiser",
        scales: {
          day: "Jour",
          week: "Semaine",
          month: "Mois",
          quarter: "Trimestre",
          year: "Année",
        },
      },
    },
  },
  {
    id: "es",
    label: "Español",
    locale: es,
    dir: "ltr",
    i18n: {
      labels: {
        today: "Hoy",
        scheduleHint: "Clic para programar",
        reorder: "Reordenar",
        scales: {
          day: "Día",
          week: "Semana",
          month: "Mes",
          quarter: "Trimestre",
          year: "Año",
        },
      },
    },
  },
  {
    id: "ja",
    label: "日本語",
    locale: ja,
    dir: "ltr",
    i18n: {
      labels: {
        today: "今日",
        scheduleHint: "クリックして予定を追加",
        reorder: "並べ替え",
        scales: {
          day: "日",
          week: "週",
          month: "月",
          quarter: "四半期",
          year: "年",
        },
      },
    },
  },
  {
    id: "ar",
    label: "العربية",
    locale: ar,
    dir: "rtl",
    i18n: {
      labels: {
        today: "اليوم",
        scheduleHint: "انقر لإضافة جدول",
        reorder: "إعادة ترتيب",
        scales: {
          day: "يوم",
          week: "أسبوع",
          month: "شهر",
          quarter: "ربع سنوي",
          year: "سنة",
        },
      },
    },
  },
]

/** Display time zones - all timeline math and rendering happen in the chosen
 *  zone, so switching it re-anchors every bar to that zone's calendar days. */
const TIME_ZONES: Array<{ id: string; label: string; value?: string }> = [
  { id: "local", label: "Browser" },
  { id: "ny", label: "New York", value: "America/New_York" },
  { id: "london", label: "London", value: "Europe/London" },
  { id: "tokyo", label: "Tokyo", value: "Asia/Tokyo" },
  { id: "kolkata", label: "Kolkata", value: "Asia/Kolkata" },
]

/** Every toggle's default; the Reset button returns the demo here. */
const SETTINGS_DEFAULTS = {
  rowCheckboxes: true,
  summaryBars: true,
  zoomControl: true,
  offscreenIndicators: true,
  infiniteScroll: true,
  nowIndicator: true,
  offDays: false,
  dragCreate: true,
  displayScheduleHint: true,
  barLabel: "inside" as "inside" | "outside" | "auto",
  timelineLines: "vertical" as "vertical" | "both" | "none",
  interactions: { drag: true, resize: true, selectSlot: true },
  localeId: "en",
  timeZoneId: "local",
}

/** One labeled switch row inside a settings tab. */
function SettingSwitch({
  id,
  label,
  checked,
  onCheckedChange,
}: {
  id: string
  label: string
  checked: boolean
  onCheckedChange: (value: boolean) => void
}) {
  return (
    <div className="flex items-center justify-between gap-4 py-1">
      <Label htmlFor={id} className="font-normal">
        {label}
      </Label>
      <Switch id={id} checked={checked} onCheckedChange={onCheckedChange} />
    </div>
  )
}

/** One labeled radio row inside a settings tab. */
function SettingRadio({
  id,
  value,
  label,
}: {
  id: string
  value: string
  label: string
}) {
  return (
    <div className="flex items-center gap-2 py-0.5">
      <RadioGroupItem value={value} id={id} />
      <Label htmlFor={id} className="font-normal">
        {label}
      </Label>
    </div>
  )
}

/** One labeled select row - the language and time-zone pickers. */
function SettingSelect({
  id,
  label,
  value,
  options,
  onValueChange,
}: {
  id: string
  label: string
  value: string
  options: Array<{ value: string; label: string }>
  onValueChange: (value: string) => void
}) {
  return (
    <div className="flex items-center justify-between gap-4 py-1">
      <Label htmlFor={id} className="font-normal">
        {label}
      </Label>
      <Select value={value} onValueChange={onValueChange}>
        <SelectTrigger id={id} size="sm" className="w-36">
          {/* Base UI's Value renders the raw value string by default; the
              selected option's label reads better here. */}
          <SelectValue>
            {options.find((option) => option.value === value)?.label}
          </SelectValue>
        </SelectTrigger>
        <SelectContent>
          {options.map((option) => (
            <SelectItem key={option.value} value={option.value}>
              {option.label}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
    </div>
  )
}

interface SettingsMenuProps {
  rowCheckboxes: boolean
  onRowCheckboxesChange: (value: boolean) => void
  summaryBars: boolean
  onSummaryBarsChange: (value: boolean) => void
  zoomControl: boolean
  onZoomControlChange: (value: boolean) => void
  offscreenIndicators: boolean
  onOffscreenIndicatorsChange: (value: boolean) => void
  infiniteScroll: boolean
  onInfiniteScrollChange: (value: boolean) => void
  nowIndicator: boolean
  onNowIndicatorChange: (value: boolean) => void
  offDays: boolean
  onOffDaysChange: (value: boolean) => void
  dragCreate: boolean
  onDragCreateChange: (value: boolean) => void
  displayScheduleHint: boolean
  onDisplayScheduleHintChange: (value: boolean) => void
  barLabel: "inside" | "outside" | "auto"
  onBarLabelChange: (value: "inside" | "outside" | "auto") => void
  timelineLines: "vertical" | "both" | "none"
  onTimelineLinesChange: (value: "vertical" | "both" | "none") => void
  interactions: GanttInteractions
  onInteractionsChange: (value: GanttInteractions) => void
  localeId: string
  onLocaleChange: (value: string) => void
  timeZoneId: string
  onTimeZoneChange: (value: string) => void
  onReset: () => void
}

function SettingsMenu({
  rowCheckboxes,
  onRowCheckboxesChange,
  summaryBars,
  onSummaryBarsChange,
  zoomControl,
  onZoomControlChange,
  offscreenIndicators,
  onOffscreenIndicatorsChange,
  infiniteScroll,
  onInfiniteScrollChange,
  nowIndicator,
  onNowIndicatorChange,
  offDays,
  onOffDaysChange,
  dragCreate,
  onDragCreateChange,
  displayScheduleHint,
  onDisplayScheduleHintChange,
  barLabel,
  onBarLabelChange,
  timelineLines,
  onTimelineLinesChange,
  interactions,
  onInteractionsChange,
  localeId,
  onLocaleChange,
  timeZoneId,
  onTimeZoneChange,
  onReset,
}: SettingsMenuProps) {
  return (
    <Popover>
      <PopoverTrigger
        render={
          <Button variant="outline" size="sm">
            <SettingsIcon  className="size-4" aria-hidden="true" />
            Settings
          </Button>
        }
      />
      <PopoverContent align="end" className="w-80 p-0">
        {/* Tabs keep every group one screen tall - no menu scrolling */}
        <Tabs defaultValue="display">
          <div className="border-b p-2">
            <TabsList className="grid w-full grid-cols-4">
              <TabsTrigger value="display">Display</TabsTrigger>
              <TabsTrigger value="behavior">Behavior</TabsTrigger>
              <TabsTrigger value="style">Style</TabsTrigger>
              <TabsTrigger value="region">Region</TabsTrigger>
            </TabsList>
          </div>
          <TabsContent value="display" className="space-y-0.5 p-3">
            <SettingSwitch
              id="gantt1-row-checkboxes"
              label="Row checkboxes"
              checked={rowCheckboxes}
              onCheckedChange={onRowCheckboxesChange}
            />
            <SettingSwitch
              id="gantt1-summary-bars"
              label="Summary bars"
              checked={summaryBars}
              onCheckedChange={onSummaryBarsChange}
            />
            <SettingSwitch
              id="gantt1-zoom-control"
              label="Zoom control"
              checked={zoomControl}
              onCheckedChange={onZoomControlChange}
            />
            <SettingSwitch
              id="gantt1-offscreen-chips"
              label="Off-screen chips"
              checked={offscreenIndicators}
              onCheckedChange={onOffscreenIndicatorsChange}
            />
            <SettingSwitch
              id="gantt1-infinite-scroll"
              label="Infinite scroll"
              checked={infiniteScroll}
              onCheckedChange={onInfiniteScrollChange}
            />
            <SettingSwitch
              id="gantt1-now-indicator"
              label="Now indicator"
              checked={nowIndicator}
              onCheckedChange={onNowIndicatorChange}
            />
            <SettingSwitch
              id="gantt1-off-days"
              label="Mark off days"
              checked={offDays}
              onCheckedChange={onOffDaysChange}
            />
          </TabsContent>
          <TabsContent value="behavior" className="space-y-0.5 p-3">
            <SettingSwitch
              id="gantt1-drag"
              label="Drag to move"
              checked={interactions.drag}
              onCheckedChange={(checked) =>
                onInteractionsChange({ ...interactions, drag: checked })
              }
            />
            <SettingSwitch
              id="gantt1-resize"
              label="Resize"
              checked={interactions.resize}
              onCheckedChange={(checked) =>
                onInteractionsChange({ ...interactions, resize: checked })
              }
            />
            <SettingSwitch
              id="gantt1-select-slot"
              label="Select slot"
              checked={interactions.selectSlot}
              onCheckedChange={(checked) =>
                onInteractionsChange({ ...interactions, selectSlot: checked })
              }
            />
            <SettingSwitch
              id="gantt1-drag-create"
              label="Drag to create"
              checked={dragCreate}
              onCheckedChange={onDragCreateChange}
            />
            <SettingSwitch
              id="gantt1-schedule-hint"
              label="Schedule hint"
              checked={displayScheduleHint}
              onCheckedChange={onDisplayScheduleHintChange}
            />
          </TabsContent>
          <TabsContent value="style" className="space-y-4 p-3">
            <div className="space-y-1.5">
              <div className="text-muted-foreground text-xs font-medium">
                Bar label
              </div>
              <RadioGroup
                value={barLabel}
                onValueChange={(value) =>
                  onBarLabelChange(value as "inside" | "outside" | "auto")
                }
              >
                <SettingRadio
                  id="gantt1-label-inside"
                  value="inside"
                  label="Inside"
                />
                <SettingRadio
                  id="gantt1-label-outside"
                  value="outside"
                  label="Outside"
                />
                <SettingRadio
                  id="gantt1-label-auto"
                  value="auto"
                  label="Auto"
                />
              </RadioGroup>
            </div>
            <div className="space-y-1.5">
              <div className="text-muted-foreground text-xs font-medium">
                Grid lines
              </div>
              <RadioGroup
                value={timelineLines}
                onValueChange={(value) =>
                  onTimelineLinesChange(value as "vertical" | "both" | "none")
                }
              >
                <SettingRadio
                  id="gantt1-lines-vertical"
                  value="vertical"
                  label="Vertical"
                />
                <SettingRadio
                  id="gantt1-lines-both"
                  value="both"
                  label="Both"
                />
                <SettingRadio
                  id="gantt1-lines-none"
                  value="none"
                  label="None"
                />
              </RadioGroup>
            </div>
          </TabsContent>
          <TabsContent value="region" className="space-y-2 p-3">
            <SettingSelect
              id="gantt1-language"
              label="Language"
              value={localeId}
              options={LOCALES.map((entry) => ({
                value: entry.id,
                label: entry.label,
              }))}
              onValueChange={onLocaleChange}
            />
            <SettingSelect
              id="gantt1-timezone"
              label="Time zone"
              value={timeZoneId}
              options={TIME_ZONES.map((entry) => ({
                value: entry.id,
                label: entry.label,
              }))}
              onValueChange={onTimeZoneChange}
            />
            <p className="text-muted-foreground text-xs leading-relaxed">
              Language switches the date-fns locale, the scale names, and the
              week start. Time zone re-anchors every bar. Arabic also flips the
              chart to right-to-left.
            </p>
          </TabsContent>
        </Tabs>
        <div className="border-t p-2">
          <Button
            variant="outline"
            size="sm"
            className="w-full"
            onClick={onReset}
          >
            <RotateCcwIcon  className="size-3.5" aria-hidden="true" />
            Reset to defaults
          </Button>
        </div>
      </PopoverContent>
    </Popover>
  )
}

export function Pattern() {
  const bars = useMemo(() => buildBars(new Date()), [])
  const apiRef = useRef<GanttApi | null>(null)
  const [rowCheckboxes, setRowCheckboxes] = useState(
    SETTINGS_DEFAULTS.rowCheckboxes
  )
  const [summaryBars, setSummaryBars] = useState(SETTINGS_DEFAULTS.summaryBars)
  const [zoomControl, setZoomControl] = useState(SETTINGS_DEFAULTS.zoomControl)
  const [offscreenIndicators, setOffscreenIndicators] = useState(
    SETTINGS_DEFAULTS.offscreenIndicators
  )
  const [infiniteScroll, setInfiniteScroll] = useState(
    SETTINGS_DEFAULTS.infiniteScroll
  )
  const [nowIndicator, setNowIndicator] = useState(
    SETTINGS_DEFAULTS.nowIndicator
  )
  const [offDays, setOffDays] = useState(SETTINGS_DEFAULTS.offDays)
  const [dragCreate, setDragCreate] = useState(SETTINGS_DEFAULTS.dragCreate)
  const [displayScheduleHint, setDisplayScheduleHint] = useState(
    SETTINGS_DEFAULTS.displayScheduleHint
  )
  const [barLabel, setBarLabel] = useState<"inside" | "outside" | "auto">(
    SETTINGS_DEFAULTS.barLabel
  )
  const [timelineLines, setTimelineLines] = useState<
    "vertical" | "both" | "none"
  >(SETTINGS_DEFAULTS.timelineLines)
  const [interactions, setInteractions] = useState<GanttInteractions>(
    SETTINGS_DEFAULTS.interactions
  )
  const [localeId, setLocaleId] = useState(SETTINGS_DEFAULTS.localeId)
  const [timeZoneId, setTimeZoneId] = useState(SETTINGS_DEFAULTS.timeZoneId)

  const activeLocale =
    LOCALES.find((entry) => entry.id === localeId) ?? LOCALES[0]
  const activeTimeZone =
    TIME_ZONES.find((entry) => entry.id === timeZoneId) ?? TIME_ZONES[0]

  const resetSettings = () => {
    setRowCheckboxes(SETTINGS_DEFAULTS.rowCheckboxes)
    setSummaryBars(SETTINGS_DEFAULTS.summaryBars)
    setZoomControl(SETTINGS_DEFAULTS.zoomControl)
    setOffscreenIndicators(SETTINGS_DEFAULTS.offscreenIndicators)
    setInfiniteScroll(SETTINGS_DEFAULTS.infiniteScroll)
    setNowIndicator(SETTINGS_DEFAULTS.nowIndicator)
    setOffDays(SETTINGS_DEFAULTS.offDays)
    setDragCreate(SETTINGS_DEFAULTS.dragCreate)
    setDisplayScheduleHint(SETTINGS_DEFAULTS.displayScheduleHint)
    setBarLabel(SETTINGS_DEFAULTS.barLabel)
    setTimelineLines(SETTINGS_DEFAULTS.timelineLines)
    setInteractions(SETTINGS_DEFAULTS.interactions)
    setLocaleId(SETTINGS_DEFAULTS.localeId)
    setTimeZoneId(SETTINGS_DEFAULTS.timeZoneId)
  }

  // Unscheduled rows accept ONE schedule: the hint tile (or a drag-create
  // range) proposes a slot, and the handler turns it into a real bar.
  const canSelectSlot = (slot: GanttSlotDraft) =>
    !!slot.resourceId &&
    !(apiRef.current?.getEvents() ?? []).some(
      (event) => event.resourceId === slot.resourceId
    )
  const handleSelectSlot = (slot: GanttSlotDraft) => {
    const api = apiRef.current
    if (!api || !slot.resourceId) return
    api.addEvent({
      id: `scheduled-${slot.resourceId}`,
      title: RESOURCE_TITLES.get(slot.resourceId) ?? "New schedule",
      start: slot.start,
      end: slot.end,
      allDay: true,
      color: "var(--color-indigo-500)",
      resourceId: slot.resourceId,
    })
  }

  return (
    <div className="w-full p-4" dir={activeLocale.dir}>
      <Card className="w-full py-0">
        <CardContent className="p-0">
          <Gantt
            defaultEvents={bars}
            resources={RESOURCES}
            defaultScale="month"
            apiRef={apiRef}
            locale={activeLocale.locale}
            i18n={activeLocale.i18n}
            timeZone={activeTimeZone.value}
            treePanel={{ width: 200 }}
            rowCheckboxes={rowCheckboxes}
            summaryBars={summaryBars}
            zoomControl={zoomControl}
            offscreenIndicators={offscreenIndicators}
            infiniteScroll={infiniteScroll}
            nowIndicator={nowIndicator}
            offDays={offDays}
            dragCreate={dragCreate}
            displayScheduleHint={displayScheduleHint}
            barLabel={barLabel}
            timelineLines={timelineLines}
            interactions={interactions}
            onInteractionsChange={setInteractions}
            canSelectSlot={canSelectSlot}
            onSelectSlot={handleSelectSlot}
            className="h-[520px] w-full"
          >
            {/* one bordered header row, same look as the plain GanttNav:
                the row owns the border and end padding so the toolbar never
                sits glued to the edge */}
            <div className="flex flex-wrap items-center gap-2 border-b pe-3">
              <GanttNav className="min-w-0 flex-1 border-b-0" />
              <GanttToolbar>
                <SettingsMenu
                  rowCheckboxes={rowCheckboxes}
                  onRowCheckboxesChange={setRowCheckboxes}
                  summaryBars={summaryBars}
                  onSummaryBarsChange={setSummaryBars}
                  zoomControl={zoomControl}
                  onZoomControlChange={setZoomControl}
                  offscreenIndicators={offscreenIndicators}
                  onOffscreenIndicatorsChange={setOffscreenIndicators}
                  infiniteScroll={infiniteScroll}
                  onInfiniteScrollChange={setInfiniteScroll}
                  nowIndicator={nowIndicator}
                  onNowIndicatorChange={setNowIndicator}
                  offDays={offDays}
                  onOffDaysChange={setOffDays}
                  dragCreate={dragCreate}
                  onDragCreateChange={setDragCreate}
                  displayScheduleHint={displayScheduleHint}
                  onDisplayScheduleHintChange={setDisplayScheduleHint}
                  barLabel={barLabel}
                  onBarLabelChange={setBarLabel}
                  timelineLines={timelineLines}
                  onTimelineLinesChange={setTimelineLines}
                  interactions={interactions}
                  onInteractionsChange={setInteractions}
                  localeId={localeId}
                  onLocaleChange={setLocaleId}
                  timeZoneId={timeZoneId}
                  onTimeZoneChange={setTimeZoneId}
                  onReset={resetSettings}
                />
              </GanttToolbar>
            </div>
            <GanttView />
          </Gantt>
        </CardContent>
      </Card>
    </div>
  )
}
"use client"

import { useMemo, useRef, useState } from "react"
import {
  Gantt,
  type GanttApi,
} from "@/components/reui/gantt/gantt"
import {
  GanttNav,
  GanttToolbar,
} from "@/components/reui/gantt/gantt-nav"
import type {
  GanttEvent,
  GanttResource,
} from "@/components/reui/gantt/gantt-types"
import { GanttView } from "@/components/reui/gantt/gantt-view"
import { addDays, startOfDay, startOfWeek } from "date-fns"

import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { PlusIcon } from 'lucide-react'

/**
 * Yearly roadmap: each workstream is a swimlane group and its multi-month
 * initiatives are the bars. The Backlog group ships with empty rows - the
 * toolbar button schedules the next one onto the timeline.
 */
const RESOURCES: GanttResource[] = [
  {
    id: "platform",
    title: "Platform",
    children: [
      { id: "auth-revamp", title: "Auth Revamp" },
      { id: "api-v2", title: "API v2" },
    ],
  },
  {
    id: "growth",
    title: "Growth",
    children: [
      { id: "onboarding", title: "Onboarding Flow" },
      { id: "referrals", title: "Referral Program" },
    ],
  },
  {
    id: "design-system",
    title: "Design System",
    children: [
      { id: "tokens", title: "Design Tokens" },
      { id: "components", title: "Component Library" },
    ],
  },
  {
    id: "backlog",
    title: "Backlog",
    children: [
      { id: "search", title: "Search Revamp" },
      { id: "billing", title: "Billing v2" },
      { id: "mobile", title: "Mobile App" },
    ],
  },
]

/** Backlog initiatives, scheduled one per click in this order. */
const BACKLOG: Array<{ id: string; title: string; color: string }> = [
  { id: "search", title: "Search Revamp", color: "var(--color-rose-500)" },
  { id: "billing", title: "Billing v2", color: "var(--color-amber-500)" },
  { id: "mobile", title: "Mobile App", color: "var(--color-cyan-500)" },
]

/** Roadmap fixture - initiatives span months so the quarter axis has something
 *  to show, and offsets straddle today so both past and future are visible. */
function buildBars(anchor: Date): GanttEvent[] {
  const week = startOfWeek(startOfDay(anchor), { weekStartsOn: 0 })
  const day = (dayOffset: number) => addDays(week, dayOffset)
  const bar = (
    resourceId: string,
    title: string,
    startOffset: number,
    days: number,
    color: string,
    progress?: number
  ): GanttEvent => ({
    id: `bar-${resourceId}`,
    title,
    start: day(startOffset),
    end: day(startOffset + days),
    allDay: true,
    color,
    resourceId,
    progress,
  })

  return [
    bar("auth-revamp", "Auth Revamp", -30, 60, "var(--color-blue-500)", 100),
    bar("api-v2", "API v2", 20, 90, "var(--color-sky-500)", 30),
    bar(
      "onboarding",
      "Onboarding Flow",
      -20,
      60,
      "var(--color-emerald-500)",
      80
    ),
    bar("referrals", "Referral Program", 50, 90, "var(--color-teal-500)", 0),
    bar("tokens", "Design Tokens", -60, 70, "var(--color-violet-500)", 100),
    bar(
      "components",
      "Component Library",
      0,
      120,
      "var(--color-purple-500)",
      45
    ),
  ]
}

export function Pattern() {
  const bars = useMemo(() => buildBars(new Date()), [])
  const apiRef = useRef<GanttApi | null>(null)
  // How many backlog initiatives have been scheduled so far.
  const [scheduled, setScheduled] = useState(0)

  // Schedule the next unscheduled backlog initiative onto its (empty) row.
  // The "next" one is derived from the live events, not a captured counter,
  // so it stays correct even if the button is clicked in quick succession.
  const addInitiative = () => {
    const api = apiRef.current
    if (!api) return
    const scheduledIds = new Set(api.getEvents().map((event) => event.id))
    const index = BACKLOG.findIndex(
      (item) => !scheduledIds.has(`bar-${item.id}`)
    )
    if (index === -1) return
    const item = BACKLOG[index]
    const week = startOfWeek(startOfDay(new Date()), { weekStartsOn: 0 })
    // Land the scheduled bars in the near-future part of the current quarter so
    // each one is visible the moment it drops onto its row.
    const start = addDays(week, 12 + index * 20)
    api.addEvent({
      id: `bar-${item.id}`,
      title: item.title,
      start,
      end: addDays(start, 24),
      allDay: true,
      color: item.color,
      resourceId: item.id,
    })
    setScheduled((count) => count + 1)
  }

  return (
    <div className="w-full p-4">
      <Card className="w-full py-0">
        <CardContent className="p-0">
          <Gantt
            defaultEvents={bars}
            resources={RESOURCES}
            defaultScale="quarter"
            apiRef={apiRef}
            treePanel={{ width: 200 }}
            className="h-[480px] w-full"
          >
            <div className="flex flex-wrap items-center gap-2 border-b pe-3">
              <GanttNav className="min-w-0 flex-1 border-b-0" />
              <GanttToolbar>
                <Button
                  variant="outline"
                  size="sm"
                  onClick={addInitiative}
                  disabled={scheduled >= BACKLOG.length}
                >
                  <PlusIcon  className="size-4" aria-hidden="true" />
                  Add to roadmap
                </Button>
              </GanttToolbar>
            </div>
            {/* Parent workstream rows carry no bars - summaryBars rolls up the
                child initiatives into one envelope on the group row. */}
            <GanttView />
          </Gantt>
        </CardContent>
      </Card>
    </div>
  )
}
"use client"

import { useMemo, useRef } from "react"
import {
  Gantt,
  type GanttApi,
} from "@/components/reui/gantt/gantt"
import {
  GanttNav,
  GanttToolbar,
} from "@/components/reui/gantt/gantt-nav"
import type {
  GanttEvent,
  GanttResource,
} from "@/components/reui/gantt/gantt-types"
import { GanttView } from "@/components/reui/gantt/gantt-view"
import { addDays, startOfDay, startOfWeek } from "date-fns"

import {
  Avatar,
  AvatarFallback,
  AvatarImage,
} from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { PlusIcon } from 'lucide-react'

/**
 * People-centric capacity board: rows are teammates grouped by squad, and
 * each teammate's bars are the week's assignments. The toolbar drops a fresh
 * assignment onto the next teammate, so a person can hold several at once.
 */
const RESOURCES: GanttResource[] = [
  {
    id: "product-squad",
    title: "Product Squad",
    children: [
      { id: "ada", title: "Ada Lovelace" },
      { id: "alan", title: "Alan Turing" },
    ],
  },
  {
    id: "design-squad",
    title: "Design Squad",
    children: [
      { id: "grace", title: "Grace Hopper" },
      { id: "linus", title: "Linus Torvalds" },
    ],
  },
]

/** Avatar photo + initials keyed by person id - GanttResource carries no
 *  custom fields, so per-row presentation data lives in a lookup of your own.
 *  The initials show while the image loads or if it fails. */
const resourceMeta: Record<string, { initials: string; avatar: string }> = {
  ada: {
    initials: "AL",
    avatar: "https://randomuser.me/api/portraits/women/44.jpg",
  },
  alan: {
    initials: "AT",
    avatar: "https://randomuser.me/api/portraits/men/32.jpg",
  },
  grace: {
    initials: "GH",
    avatar: "https://randomuser.me/api/portraits/women/68.jpg",
  },
  linus: {
    initials: "LT",
    avatar: "https://randomuser.me/api/portraits/men/54.jpg",
  },
}

/** Teammates the new assignments cycle through, and a small pool to name and
 *  color them from. */
const PEOPLE = ["ada", "alan", "grace", "linus"]
const TASK_POOL = [
  { title: "Bug triage", color: "var(--color-amber-500)" },
  { title: "Code review", color: "var(--color-rose-500)" },
  { title: "Spec draft", color: "var(--color-teal-500)" },
  { title: "Pairing", color: "var(--color-indigo-500)" },
]

/** This-week assignments per teammate - day-precise bars for a capacity read
 *  (progress omitted; occupancy, not percent-done, is the point here). */
function buildBars(anchor: Date): GanttEvent[] {
  const week = startOfWeek(startOfDay(anchor), { weekStartsOn: 0 })
  const day = (dayOffset: number) => addDays(week, dayOffset)
  const bar = (
    resourceId: string,
    title: string,
    startOffset: number,
    days: number,
    color: string
  ): GanttEvent => ({
    id: `bar-${resourceId}`,
    title,
    start: day(startOffset),
    end: day(startOffset + days),
    allDay: true,
    color,
    resourceId,
  })

  return [
    bar("ada", "Checkout API", -1, 3, "var(--color-blue-500)"),
    bar("alan", "Search Indexing", 0, 3, "var(--color-sky-500)"),
    bar("grace", "Dashboard Redesign", 1, 3, "var(--color-violet-500)"),
    bar("linus", "Infra Migration", -2, 3, "var(--color-purple-500)"),
  ]
}

export function Pattern() {
  const bars = useMemo(() => buildBars(new Date()), [])
  const apiRef = useRef<GanttApi | null>(null)
  // A ref (not state) counts the adds, so it advances synchronously and stays
  // correct even when the button is clicked several times in a row.
  const addedRef = useRef(0)

  // Add a 2-day assignment to the next teammate in rotation. Successive adds
  // to the same person overlap and stack into extra lanes on that row.
  const addAssignment = () => {
    const api = apiRef.current
    if (!api) return
    const n = addedRef.current++
    const person = PEOPLE[n % PEOPLE.length]
    const task = TASK_POOL[n % TASK_POOL.length]
    const week = startOfWeek(startOfDay(new Date()), { weekStartsOn: 0 })
    const start = addDays(week, (n % 5) + 1)
    api.addEvent({
      id: `bar-extra-${n}`,
      title: task.title,
      start,
      end: addDays(start, 2),
      allDay: true,
      color: task.color,
      resourceId: person,
    })
  }

  return (
    <div className="w-full p-4">
      <Card className="w-full py-0">
        <CardContent className="p-0">
          <Gantt
            defaultEvents={bars}
            resources={RESOURCES}
            defaultScale="week"
            apiRef={apiRef}
            // Weekends shaded so booked working-day capacity is obvious.
            offDays={true}
            treePanel={{ width: 220 }}
            // People rows get an avatar label; group (squad) rows return
            // undefined to keep the default plain-title label.
            renderResourceLabel={({ resource, isGroup }) => {
              if (isGroup) return undefined
              const person = resourceMeta[resource.id]
              return (
                <span className="flex min-w-0 items-center gap-2">
                  <Avatar className="size-5">
                    <AvatarImage src={person?.avatar} alt={resource.title} />
                    <AvatarFallback className="text-[10px]">
                      {person?.initials ?? resource.title.charAt(0)}
                    </AvatarFallback>
                  </Avatar>
                  <span className="truncate">{resource.title}</span>
                </span>
              )
            }}
            className="h-[440px] w-full"
          >
            <div className="flex flex-wrap items-center gap-2 border-b pe-3">
              <GanttNav className="min-w-0 flex-1 border-b-0" />
              <GanttToolbar>
                <Button variant="outline" size="sm" onClick={addAssignment}>
                  <PlusIcon  className="size-4" aria-hidden="true" />
                  Add assignment
                </Button>
              </GanttToolbar>
            </div>
            <GanttView />
          </Gantt>
        </CardContent>
      </Card>
    </div>
  )
}
"use client"

import { useMemo, useRef, useState } from "react"
import { Badge, type BadgeProps } from "@/components/reui/badge"
import {
  Gantt,
  type GanttApi,
  type GanttColumn,
} from "@/components/reui/gantt/gantt"
import {
  GanttNav,
  GanttToolbar,
} from "@/components/reui/gantt/gantt-nav"
import type {
  GanttEvent,
  GanttResource,
} from "@/components/reui/gantt/gantt-types"
import { GanttView } from "@/components/reui/gantt/gantt-view"
import { addDays, startOfDay, startOfWeek } from "date-fns"

import {
  Avatar,
  AvatarFallback,
  AvatarImage,
} from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { PlusIcon } from 'lucide-react'

type TaskMeta = { owner: string; status: string }

/** Owner headshots keyed by name. Missing entries (e.g. "Unassigned") fall
 *  back to initials, and the initials also show while the photo loads. */
const OWNER_AVATARS: Record<string, string> = {
  "Ada Lovelace": "https://randomuser.me/api/portraits/women/44.jpg",
  "Grace Hopper": "https://randomuser.me/api/portraits/women/68.jpg",
  "Alan Turing": "https://randomuser.me/api/portraits/men/32.jpg",
  "Linus Torvalds": "https://randomuser.me/api/portraits/men/54.jpg",
  "Katherine Johnson": "https://randomuser.me/api/portraits/women/90.jpg",
  "Margaret Hamilton": "https://randomuser.me/api/portraits/women/12.jpg",
}

/** First + last initial from an owner name, used as the avatar fallback -
 *  "Ada Lovelace" reads "AL", "Unassigned" reads "UN". */
function ownerInitials(name: string) {
  const parts = name.trim().split(/\s+/).filter(Boolean)
  if (parts.length === 0) return "?"
  if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase()
  return (parts[0]![0]! + parts[parts.length - 1]![0]!).toUpperCase()
}

/** Status label -> ReUI badge light variant (green done, amber in progress,
 *  neutral not started). */
const STATUS_VARIANT: Record<string, BadgeProps["variant"]> = {
  Done: "success-light",
  "In progress": "warning-light",
  "Not started": "secondary",
}

/**
 * Status report: the left tree panel doubles as a task table with Owner and
 * Status columns beside each phase, every bar carries a progress fill, and the
 * timeline is drag-locked. New tasks are appended as rows from the toolbar.
 */
const INITIAL_RESOURCES: GanttResource[] = [
  {
    id: "planning",
    title: "Planning",
    children: [
      { id: "requirements", title: "Requirements" },
      { id: "design-phase", title: "Design" },
    ],
  },
  {
    id: "build",
    title: "Build",
    children: [
      { id: "frontend", title: "Frontend" },
      { id: "backend", title: "Backend" },
    ],
  },
  {
    id: "launch",
    title: "Launch",
    children: [
      { id: "qa", title: "QA & Testing" },
      { id: "rollout", title: "Rollout" },
    ],
  },
]

/** Owner + status per task, keyed by resource.id - GanttResource has no room
 *  for custom fields, so the extra column data lives in a lookup of your own. */
const INITIAL_META: Record<string, TaskMeta> = {
  requirements: { owner: "Ada Lovelace", status: "Done" },
  "design-phase": { owner: "Grace Hopper", status: "Done" },
  frontend: { owner: "Alan Turing", status: "In progress" },
  backend: { owner: "Linus Torvalds", status: "In progress" },
  qa: { owner: "Katherine Johnson", status: "Not started" },
  rollout: { owner: "Margaret Hamilton", status: "Not started" },
}

/** Status-report fixture - progress descends from finished planning to
 *  not-started launch, and the phases straddle today so the now-line falls
 *  mid-plan. Kept inside a month so every phase reads at a glance. */
function buildBars(anchor: Date): GanttEvent[] {
  const week = startOfWeek(startOfDay(anchor), { weekStartsOn: 0 })
  const day = (dayOffset: number) => addDays(week, dayOffset)
  const bar = (
    resourceId: string,
    title: string,
    startOffset: number,
    days: number,
    color: string,
    progress?: number
  ): GanttEvent => ({
    id: `bar-${resourceId}`,
    title,
    start: day(startOffset),
    end: day(startOffset + days),
    allDay: true,
    color,
    resourceId,
    progress,
  })

  return [
    bar("requirements", "Requirements", -10, 8, "var(--color-blue-500)", 100),
    bar("design-phase", "Design", -8, 8, "var(--color-sky-500)", 100),
    bar("frontend", "Frontend", -2, 10, "var(--color-violet-500)", 60),
    bar("backend", "Backend", 0, 10, "var(--color-purple-500)", 45),
    bar("qa", "QA & Testing", 8, 8, "var(--color-amber-500)", 0),
    bar("rollout", "Rollout", 14, 5, "var(--color-emerald-500)", 0),
  ]
}

export function Pattern() {
  const bars = useMemo(() => buildBars(new Date()), [])
  const apiRef = useRef<GanttApi | null>(null)
  const [resources, setResources] = useState<GanttResource[]>(INITIAL_RESOURCES)
  const [meta, setMeta] = useState<Record<string, TaskMeta>>(INITIAL_META)
  // A ref (not state) numbers each appended task, so ids stay unique even when
  // the button is clicked several times before a re-render.
  const addedRef = useRef(0)

  // Extra tree-panel columns after the pinned name column. Built from meta so
  // appended rows pick up their Owner and Status; group rows return null.
  const columns = useMemo<GanttColumn[]>(
    () => [
      {
        id: "owner",
        title: "Owner",
        width: 130,
        align: "start",
        render: ({ resource, isGroup }) => {
          if (isGroup) return null
          const owner = meta[resource.id]?.owner
          if (!owner) return null
          return (
            <span className="flex min-w-0 items-center gap-2">
              <Avatar className="size-5 shrink-0">
                <AvatarImage src={OWNER_AVATARS[owner]} alt={owner} />
                <AvatarFallback className="text-[10px]">
                  {ownerInitials(owner)}
                </AvatarFallback>
              </Avatar>
              <span className="truncate">{owner}</span>
            </span>
          )
        },
      },
      {
        id: "status",
        title: "Status",
        width: 100,
        align: "start",
        render: ({ resource, isGroup }) => {
          if (isGroup) return null
          const status = meta[resource.id]?.status
          return status ? (
            <Badge variant={STATUS_VARIANT[status] ?? "secondary"} size="sm">
              {status}
            </Badge>
          ) : null
        },
      },
    ],
    [meta]
  )

  // Append a new task as its own row under the Launch phase, register its
  // Owner/Status, and drop a not-started bar on its row.
  const addTask = () => {
    const api = apiRef.current
    if (!api) return
    const n = ++addedRef.current
    const id = `task-${n}`
    setResources((prev) =>
      prev.map((group) =>
        group.id === "launch"
          ? {
              ...group,
              children: [
                ...(group.children ?? []),
                { id, title: `New task ${n}` },
              ],
            }
          : group
      )
    )
    setMeta((prev) => ({
      ...prev,
      [id]: { owner: "Unassigned", status: "Not started" },
    }))
    const week = startOfWeek(startOfDay(new Date()), { weekStartsOn: 0 })
    const start = addDays(week, (n % 6) - 2)
    api.addEvent({
      id: `bar-${id}`,
      title: `New task ${n}`,
      start,
      end: addDays(start, 5),
      allDay: true,
      color: "var(--color-slate-400)",
      resourceId: id,
      progress: 0,
    })
  }

  return (
    <div className="w-full p-4">
      <Card className="w-full py-0">
        <CardContent className="p-0">
          <Gantt
            defaultEvents={bars}
            resources={resources}
            defaultScale="month"
            apiRef={apiRef}
            // Read-only timeline: drag, resize and slot-select are off so the
            // plan can't be shifted by dragging; rows are added via the toolbar.
            defaultInteractions={{
              drag: false,
              resize: false,
              selectSlot: false,
            }}
            columns={columns}
            // Wider tree with a tighter name column so the Owner avatar,
            // owner name and Status all fit alongside the task names.
            treePanel={{ width: 400, nameColumnWidth: 150 }}
            className="h-[500px] w-full"
          >
            <div className="flex flex-wrap items-center gap-2 border-b pe-3">
              <GanttNav className="min-w-0 flex-1 border-b-0" />
              <GanttToolbar>
                <Button variant="outline" size="sm" onClick={addTask}>
                  <PlusIcon  className="size-4" aria-hidden="true" />
                  Add task
                </Button>
              </GanttToolbar>
            </div>
            <GanttView />
          </Gantt>
        </CardContent>
      </Card>
    </div>
  )
}
Internationalization