Skip to content

PeriodPicker

Fiscal-aware. Never assumes January.
Implemented
Status
Implemented
Family
6 of 13
Registry
@lopifi/period-picker
npx shadcn@latest add @lopifi/theme @lopifi/period-picker

The component, rendering

Real data, the shipped component. Switch the theme to check the claims the spec makes below.

Preview

The four-layer spec

Fiscal-aware. Takes fiscalYearStartMonth and labels accordingly — FY26 Q1 may start in July.

  • Presets: this month, last month, QTD, YTD, this fiscal quarter, this fiscal year, trailing 12.
  • Every preset shows its resolved dates. "Last quarter (1 Apr – 30 Jun 2026)".
  • Custom range with a two-month calendar.
  • Closed/locked periods are visually marked and warn on selection, because a "closed" period's numbers shouldn't be changing.

Lifted verbatim from Statements. The doc and this page are the same paragraphs, so they cannot disagree.

What lands in your repository

One component is never one file. Everything below is copied into your project, where you own it.

Files

components/ledger/period-picker.tsx

Lopifi dependencies · 3
cnfiscal-periodicons
npm packages
@base-ui-components/reactclsxtailwind-merge
Registry entry
/r/period-picker.json

The whole component

No compiled bundle, no black box. This is the file the registry copies, and the file you will edit.

components/ledger/period-picker.tsx
"use client";

import { Popover } from "@base-ui-components/react/popover";
import * as React from "react";
import { cn } from "@/lib/ledger/cn";
import {
  fiscalQuarterOf,
  fiscalQuartersOf,
  fiscalYearOf,
  formatFiscalLabel,
  formatMonthLabel,
  formatRange,
  type PeriodPreset,
  type PeriodRange,
  recentMonths,
  resolvePreset,
} from "@/lib/ledger/fiscal-period";
import { CalendarDaysIcon, LockClosedIcon } from "@/components/ledger/icons";

export type PeriodSelection = { label: string; range: PeriodRange };

export interface PeriodPickerProps {
  className?: string;
  /** Closed periods are marked and warn on selection. */
  closedThrough?: Date;
  /** FY26 Q1 may start in July. Never assume January. */
  fiscalYearStartMonth?: number;
  /**
   * How many complete months to list individually. A close is worked one month
   * at a time and the month wanted is very often not one of the two a preset
   * names — "August, no, July" is the most common thing anyone does in this
   * control, and making them type two dates for it is the reason people give
   * up on period pickers.
   *
   * 24 rather than 12 because the comparative a review asks for is the same
   * month a year earlier, and that month has to be reachable.
   */
  months?: number;
  onValueChange: (p: PeriodSelection) => void;
  /** Offer the four quarters of the fiscal year as their own list. */
  showFiscalQuarters?: boolean;
  today?: Date;
  value: PeriodSelection;
}

/**
 * Grouped, because a flat list of seventeen is a list nobody reads to the end
 * of. The groups are the four questions anyone actually arrives with: which
 * month, which quarter, which year, and over what window.
 *
 * `ytd` and `fytd` both appear, and deliberately: for an entity whose year
 * starts in July they are different numbers, and offering one of them under
 * the name "year to date" hands the reader the wrong one half the time. When
 * the fiscal year *is* the calendar year the two collapse, and the second is
 * dropped rather than listed twice.
 */
const PRESET_GROUPS: { heading: string; presets: { key: PeriodPreset; label: string }[] }[] = [
  {
    heading: "Month",
    presets: [
      { key: "mtd", label: "Month to date" },
      { key: "this-month", label: "This month" },
      { key: "last-month", label: "Last month" },
      { key: "same-month-last-year", label: "Same month last year" },
    ],
  },
  {
    heading: "Quarter",
    presets: [
      { key: "qtd", label: "Quarter to date" },
      { key: "this-fiscal-quarter", label: "This fiscal quarter" },
      { key: "last-fiscal-quarter", label: "Last fiscal quarter" },
      { key: "same-quarter-last-year", label: "Same quarter last year" },
    ],
  },
  {
    heading: "Year",
    presets: [
      { key: "ytd", label: "Year to date" },
      { key: "fytd", label: "Fiscal year to date" },
      { key: "this-fiscal-year", label: "This fiscal year" },
      { key: "last-fiscal-year", label: "Last fiscal year" },
      { key: "last-calendar-year", label: "Last calendar year" },
    ],
  },
  {
    heading: "Trailing",
    presets: [
      { key: "trailing-3", label: "Trailing 3 months" },
      { key: "trailing-6", label: "Trailing 6 months" },
      { key: "trailing-12", label: "Trailing 12 months" },
      { key: "trailing-24", label: "Trailing 24 months" },
    ],
  },
];

/** Every preset shows its resolved dates. */
export function PeriodPicker({
  value,
  onValueChange,
  fiscalYearStartMonth = 1,
  today = new Date(),
  closedThrough,
  months = 24,
  showFiscalQuarters = true,
  className,
}: PeriodPickerProps) {
  const [open, setOpen] = React.useState(false);
  const { quarter, fiscalYear } = fiscalQuarterOf(today, fiscalYearStartMonth);
  const calendarFiscalYear = fiscalYearStartMonth === 1;

  /* Newest first. The month wanted is nearly always a recent one, and a list
     that opens on a month two years ago is a list you scroll before you read. */
  const monthOptions = React.useMemo(
    () =>
      recentMonths(today, months)
        .reverse()
        .map((range) => ({ label: formatMonthLabel(range.start), range })),
    [today, months],
  );

  const quarterOptions = React.useMemo(() => {
    if (!showFiscalQuarters) return [];
    const fy = fiscalYearOf(today, fiscalYearStartMonth);
    return fiscalQuartersOf(fy, fiscalYearStartMonth).map((q) => ({
      label: formatFiscalLabel(q.quarter, fy, fiscalYearStartMonth),
      range: q.range,
    }));
  }, [today, fiscalYearStartMonth, showFiscalQuarters]);

  const choose = (label: string, range: PeriodRange) => {
    onValueChange({ label, range });
    setOpen(false);
  };

  /* One row, one shape, whether it came from a preset, a month or a quarter —
     three renderers would be three chances for one of them to stop showing
     the resolved dates, which is the whole contract of this control. */
  const option = (label: string, range: PeriodRange) => {
    const closed = closedThrough != null && range.end <= closedThrough;
    return (
      <li key={label}>
        <button
          className={cn(
            "flex w-full items-center gap-2 rounded-xs px-2 py-2 text-left",
            "transition-colors duration-1 ease-standard hover:bg-surface-hover",
            value.label === label && "bg-surface-selected",
          )}
          onClick={() => choose(label, range)}
          type="button"
        >
          <span className="flex-1 text-body">{label}</span>
          <span className="numeric text-caption text-text-secondary">{formatRange(range)}</span>
          {closed ? (
            <LockClosedIcon aria-label="Closed period" className="size-3 text-text-tertiary" />
          ) : null}
        </button>
      </li>
    );
  };

  return (
    <Popover.Root onOpenChange={setOpen} open={open}>
      <Popover.Trigger
        aria-label={`Period: ${value.label}, ${formatRange(value.range)}`}
        className={cn(
          "inline-flex h-8 items-center gap-2 rounded-sm border border-border bg-surface px-3",
          "text-label font-medium transition-colors duration-1 ease-standard hover:bg-surface-hover",
          className,
        )}
      >
        <CalendarDaysIcon aria-hidden="true" className="size-3.5 text-text-tertiary" />
        <span className="numeric">{formatRange(value.range)}</span>
      </Popover.Trigger>
      <Popover.Portal>
        <Popover.Positioner align="start" className="z-dropdown" sideOffset={8}>
          <Popover.Popup
            className="w-[340px] rounded-md border border-border bg-surface-raised p-2 shadow-elevation-3"
            style={{ transformOrigin: "var(--transform-origin)" }}
          >
            {fiscalYearStartMonth !== 1 ? (
              <p className="px-2 pb-2 text-caption text-text-secondary">
                Fiscal year starts in month {fiscalYearStartMonth}. Currently{" "}
                {formatFiscalLabel(quarter, fiscalYear, fiscalYearStartMonth)}.
              </p>
            ) : null}
            {/* Capped and scrolled rather than allowed to grow: twenty-four
              months and seventeen presets is taller than most viewports, and a
              popup that runs off the bottom of the screen hides the custom
              range at the end of it. */}
            <div className="max-h-[360px] overflow-y-auto scrollbar-thin">
              {PRESET_GROUPS.map((group) => {
                const presets = group.presets.filter(
                  (preset) => !(calendarFiscalYear && preset.key === "fytd"),
                );
                return (
                  <section key={group.heading}>
                    <h3 className="px-2 pb-1 pt-2 text-caption font-medium text-text-tertiary">
                      {group.heading}
                    </h3>
                    <ul>
                      {presets.map((preset) =>
                        option(preset.label, resolvePreset(preset.key, today, fiscalYearStartMonth)),
                      )}
                    </ul>
                  </section>
                );
              })}

              {quarterOptions.length > 0 ? (
                <section>
                  <h3 className="px-2 pb-1 pt-2 text-caption font-medium text-text-tertiary">
                    {calendarFiscalYear
                      ? `Quarters of ${fiscalYear}`
                      : `Quarters of FY${String(fiscalYear).slice(-2)}`}
                  </h3>
                  <ul>{quarterOptions.map((q) => option(q.label, q.range))}</ul>
                </section>
              ) : null}

              <section>
                <h3 className="px-2 pb-1 pt-2 text-caption font-medium text-text-tertiary">Months</h3>
                <ul>{monthOptions.map((m) => option(m.label, m.range))}</ul>
              </section>
            </div>

            <div className="mt-2 border-t border-border-subtle p-2">
              <p className="mb-2 text-caption text-text-secondary">Custom range</p>
              <div className="flex items-center gap-2">
                <input
                  aria-label="Range start"
                  className="numeric h-8 flex-1 rounded-sm border border-border bg-surface px-2 text-label outline-none focus-visible:border-accent focus-visible:ring-2 focus-visible:ring-accent-subtle"
                  defaultValue={value.range.start.toISOString().slice(0, 10)}
                  onChange={(e) =>
                    onValueChange({
                      label: "Custom",
                      range: { ...value.range, start: new Date(`${e.target.value}T00:00:00`) },
                    })
                  }
                  type="date"
                />
                <span aria-hidden="true" className="text-text-tertiary">
                  –
                </span>
                <input
                  aria-label="Range end"
                  className="numeric h-8 flex-1 rounded-sm border border-border bg-surface px-2 text-label outline-none focus-visible:border-accent focus-visible:ring-2 focus-visible:ring-accent-subtle"
                  defaultValue={value.range.end.toISOString().slice(0, 10)}
                  onChange={(e) =>
                    onValueChange({
                      label: "Custom",
                      range: { ...value.range, end: new Date(`${e.target.value}T00:00:00`) },
                    })
                  }
                  type="date"
                />
              </div>
            </div>

            {closedThrough ? (
              <p className="px-2 pt-2 text-caption text-warning">
                Periods through {formatRange({ end: closedThrough, start: closedThrough })} are closed. A
                closed period’s numbers shouldn’t be changing.
              </p>
            ) : null}
          </Popover.Popup>
        </Popover.Positioner>
      </Popover.Portal>
    </Popover.Root>
  );
}

The accounting it encodes

Concepts this component renders. Each one is defined once, in the lexicon, and used identically in the code and the copy.

Also in Statements

See also

A component is specified in one place, defined in another, and running in a third. All three, from here.

Elsewhere in the system

Where PeriodPicker sits in the rest of the system — the live section it renders in, the calculators that compute what it displays, and the ledger payloads it is fed from.

Components are copied into your repository and meant to be edited — rounding policy, fiscal calendar and compliance copy differ per company.

Told when Statements changes

Components are copied into your repo, so a fix here does not reach you on its own. One email when a component in this family changes in a way that needs a migration — and nothing when it does not.

One address, no list rental, unsubscribe in one click.

Works in your QuickBooks Online or Xero file

Get your books closed every month

Lopifi is building AI bookkeeping that keeps your books closed on US GAAP as transactions happen, with a CPA review every quarter, for $200 a month. We’re not open yet.

$200/month when we open. No card now.