Suspensão temporária do agendamento — Plano de implementação

TLDR: flag scheduling_suspension que exibe o TopBanner com o aviso de suspensão e zera a disponibilidade em todos os agendadores do trgclub-web.

Spec: .project/docs/specs/20260930114644_scheduling_suspension.md Branch: feat/scheduling-suspension

Arquitetura: a flag é lida via useFeature, que passa a buscar flags também para visitantes (/features/anonymous). useScheduler desliga a query de disponibilidade e devolve availableDays vazio quando a flag está ligada, reaproveitando a variante “Sem horários disponíveis” já existente. O Header renderiza o TopBanner dentro de <Feature> e o MobileMenu recupera os offsets responsivos.

Stack: Next.js (pages router), React Query v4, zustand, MUI, Jest + Testing Library.

Restrições globais

  • Código, testes e nomes em inglês; commits de uma linha, até 60 caracteres, sem menção a IA.
  • Seguir o estilo de teste existente (// arrange / // act / // assert, expect de @jest/globals).
  • Baseline: npx jest dos módulos afetados verde; tsc já tem erros pré-existentes só em src/services/media/index.test.ts.
  • Parte do trgclub-api fica fora deste plano (PR separado).

Task 1: Flag e useFeature para visitantes

Files: - Modify: src/services/feature/types.ts - Modify: src/infra/FeatureManager/useFeature.ts - Test: src/infra/FeatureManager/test/useFeature.test.ts

Interfaces: - Produces: Flag.SCHEDULING_SUSPENSION = "scheduling_suspension"; useFeature() busca /features/{pid} logado e /features/anonymous deslogado, queryKey: ["features", pid || "anonymous"].

  • [ ] Step 1: Write the failing test — adicionar em useFeature.test.ts:

```ts import { listFeatures } from “@/services/feature/feature”;

jest.mock(“@/services/feature/feature”); const mockListFeatures = listFeatures as jest.MockedFunction;

describe(“query configuration”, () => { it(“should fetch features for the logged user”, () => { // arrange mockUseSession.mockReturnValue({ data: mockSession } as any); mockUseQuery.mockReturnValue({ data: undefined } as any);

// act
renderHook(() => useFeature());
const options = mockUseQuery.mock.calls[0][0] as any;
options.queryFn();

// assert
expect(options.queryKey).toEqual(["features", "user-123"]);
expect(mockListFeatures).toHaveBeenCalledWith("user-123");   });

it(“should fetch anonymous features when there is no session”, () => { // arrange mockUseSession.mockReturnValue({ data: null } as any); mockUseQuery.mockReturnValue({ data: undefined } as any);

// act
renderHook(() => useFeature());
const options = mockUseQuery.mock.calls[0][0] as any;
options.queryFn();

// assert
expect(options.queryKey).toEqual(["features", "anonymous"]);
expect(options.enabled).not.toBe(false);
expect(mockListFeatures).toHaveBeenCalledWith("anonymous");   }); }); ```
  • [ ] Step 2: Run to verify it fails

bash npx jest src/infra/FeatureManager/test/useFeature.test.ts

Expected: FAIL — queryKey é ["features"] e enabled é false.

  • [ ] Step 3: Write minimal implementation

src/services/feature/types.ts:

ts export enum Flag { DEFAULT_FLAG = "default_flag", SCHEDULING_SUSPENSION = "scheduling_suspension", }

src/infra/FeatureManager/useFeature.ts:

```ts const ANONYMOUS_ACTOR = “anonymous”;

export default function useFeature() { const { data: session } = useSession(); const userSession = session as unknown as TUserSession; const userId = userSession?.user?.pid; const actorId = userId || ANONYMOUS_ACTOR; const threeMinutes = 300000; const retryCount = 3;

const { data: featuresData } = useQuery({ queryKey: ["features", actorId], queryFn: () => listFeatures(actorId), staleTime: threeMinutes, retry: retryCount, });

const feature = useMemo( () => new FeatureFlagManager(featuresData, userId), [featuresData, userId] );

return feature; } ```

  • [ ] Step 4: Run to verify it passes

bash npx jest src/infra/FeatureManager src/providers/FeatureProvider

Expected: PASS

  • [ ] Step 5: Commit

bash git add src/services/feature/types.ts src/infra/FeatureManager git commit -m "feat: fetch feature flags for anonymous visitors"


Task 2: useScheduler sem disponibilidade com a flag ligada

Files: - Modify: src/hooks/useScheduler.ts - Test: src/hooks/__tests__/useScheduler.test.ts (novo)

Interfaces: - Consumes: useFeature, Flag.SCHEDULING_SUSPENSION (Task 1). - Produces: com a flag ligada, availableDays é [], loadingAvailableDays é false e a query de disponibilidade roda com enabled: false.

  • [ ] Step 1: Write the failing test

```ts import { renderHook } from “@testing-library/react”; import { expect } from “@jest/globals”; import { useQuery } from “@tanstack/react-query”; import useScheduler from “../useScheduler”; import { useFeature } from “@/infra/FeatureManager”; import { Flag } from “@/services/feature/types”;

jest.mock(“@tanstack/react-query”); jest.mock(“@/infra/FeatureManager”); jest.mock(“@/providers/ReactQuery/ReactQuery”, () => ({ queryClient: {} })); jest.mock(“next/navigation”, () => ({ useSearchParams: () => ({ get: () => null }), }));

const mockUseQuery = useQuery as jest.MockedFunction; const mockUseFeature = useFeature as jest.MockedFunction;

const profile = { slug: “therapist-slug” } as any; const entries = [{ date: “2026-10-01” }] as any;

const mockFlag = (enabled: boolean) => mockUseFeature.mockReturnValue({ isEnabled: (slug: string) => enabled && slug === Flag.SCHEDULING_SUSPENSION, } as any);

describe(“useScheduler”, () => { beforeEach(() => { jest.clearAllMocks(); mockUseQuery.mockImplementation((_key: any, _fn: any, options: any) => { if (options?.enabled) options.onSuccess(entries); return { isLoading: false } as any; }); });

describe(“when scheduling suspension is enabled”, () => { it(“should not fetch availability and return no available days”, () => { // arrange mockFlag(true);

  // act
  const { result } = renderHook(() =>
    useScheduler({ profile, fetchByDefault: true }),
  );
  const options = mockUseQuery.mock.calls[0][2] as any;

  // assert
  expect(options.enabled).toBe(false);
  expect(result.current.availableDays).toEqual([]);
  expect(result.current.loadingAvailableDays).toBe(false);
});   });

describe(“when scheduling suspension is disabled”, () => { it(“should fetch availability”, () => { // arrange mockFlag(false);

  // act
  renderHook(() => useScheduler({ profile, fetchByDefault: true }));
  const options = mockUseQuery.mock.calls[0][2] as any;

  // assert
  expect(options.enabled).toBe(true);
});   }); }); ```
  • [ ] Step 2: Run to verify it fails

bash npx jest src/hooks/__tests__/useScheduler.test.ts

Expected: FAIL — enabled é true com a flag ligada.

  • [ ] Step 3: Write minimal implementation — em useScheduler.ts:

```ts import { useFeature } from “@/infra/FeatureManager”; import { Flag } from “@/services/feature/types”;

// inside useScheduler, before useQuery const isSchedulingSuspended = useFeature().isEnabled(Flag.SCHEDULING_SUSPENSION);

// useQuery options enabled: shouldFetch && !isSchedulingSuspended,

// replace availableDays / loading
const availableDays = isSchedulingSuspended
? []
Object.values(availableDaysMap).flat();

loadingAvailableDays: isLoading && !isSchedulingSuspended, ```

  • [ ] Step 4: Run to verify it passes

bash npx jest src/hooks

Expected: PASS

  • [ ] Step 5: Commit

bash git add src/hooks/useScheduler.ts src/hooks/__tests__/useScheduler.test.ts git commit -m "feat: hide availability when scheduling is suspended"


Files: - Modify: src/components/TopBanner/index.tsx - Modify: src/stores/topBanner/topBanner.ts - Test: src/components/TopBanner/TopBanner.test.tsx (novo), src/stores/topBanner/topBanner.test.ts

Interfaces: - Produces: TopBanner com label?: string; href?: string; store persistido em top-banner-storage-v2.

  • [ ] Step 1: Write the failing test

TopBanner.test.tsx:

```tsx import { render, screen } from “@testing-library/react”; import { expect } from “@jest/globals”; import { TopBanner } from “.”; import { useTopBanner } from “@/hooks/useTopBanner”;

jest.mock(“@/hooks/useTopBanner”); const mockUseTopBanner = useTopBanner as jest.MockedFunction;

describe(“TopBanner”, () => { beforeEach(() => { mockUseTopBanner.mockReturnValue({ shouldShow: true, dismiss: jest.fn() }); });

it(“should render only the description when there is no link”, () => { // act render();

// assert
expect(screen.getByText("Scheduling suspended")).toBeTruthy();
expect(screen.queryByRole("link")).toBeNull();   });

it(“should render the link when label and href are provided”, () => { // act render();

// assert
expect(screen.getByRole("link").getAttribute("href")).toBe("/novidades");   });

it(“should render nothing when the banner should not show”, () => { // arrange mockUseTopBanner.mockReturnValue({ shouldShow: false, dismiss: jest.fn() });

// act
const { container } = render(<TopBanner description="News" />);

// assert
expect(container.firstChild).toBeNull();   }); }); ```

Em topBanner.test.ts, adicionar:

```ts it(“should persist under the v2 storage key”, () => { // act useTopBannerStore.getState().dismiss();

// assert expect(localStorageMock.getItem(“top-banner-storage-v2”)).toContain(‘“isVisible”:false’); }); ```

  • [ ] Step 2: Run to verify it fails

bash npx jest src/components/TopBanner src/stores/topBanner

Expected: FAIL — erro de tipo/link renderizado sem href e chave top-banner-storage-v2 ausente.

  • [ ] Step 3: Write minimal implementation

TopBanner/index.tsx:

```tsx interface TopBannerProps { description: string; label?: string; href?: string; onClick?: (event: React.MouseEvent) => void; }

// inside S.Text {description} {label && href && ( <> {‘ ‘} <S.Link href={href} onClick={onClick}> {label} </S.Link> </> )} ```

topBanner.ts: name: 'top-banner-storage-v2',

  • [ ] Step 4: Run to verify it passes

bash npx jest src/components/TopBanner src/stores/topBanner

Expected: PASS

  • [ ] Step 5: Commit

bash git add src/components/TopBanner src/stores/topBanner git commit -m "feat: allow top banner without link"


Task 4: Banner no Header e offsets do MobileMenu

Files: - Modify: src/components/Header/Header.tsx - Modify: src/components/MobileMenu/MobileMenu.tsx

Interfaces: - Consumes: Flag.SCHEDULING_SUSPENSION, useFeature (Task 1); TopBanner sem link (Task 3); useTopBanner().shouldShow.

  • [ ] Step 1: Implementation (integração visual; coberta por verificação manual)

Header.tsx:

```tsx import { TopBanner } from “../TopBanner”; import { Feature } from “@/providers/FeatureProvider/FeatureProvider”; import { Flag } from “@/services/feature/types”;

const SCHEDULING_SUSPENSION_MESSAGE = “O agendamento em nosso sistema precisará ser suspenso por 24h.”;

// after {withFlags && } <Feature flag={Flag.SCHEDULING_SUSPENSION}>

<TopBanner description={SCHEDULING_SUSPENSION_MESSAGE} />

</Feature> ```

MobileMenu.tsx (substitui PopoverComponent/MobileMenuWithPopover):

```tsx import { useTopBanner } from “@/hooks/useTopBanner”; import { useFeature } from “@/infra/FeatureManager”; import { Flag } from “@/services/feature/types”;

const MobileMenuWithPopover = (props: any) => { const { shouldShow } = useTopBanner(); const feature = useFeature(); const isBannerVisible = feature.isEnabled(Flag.SCHEDULING_SUSPENSION) && shouldShow;

const PopoverComponent = React.useMemo( () => withPopover(MobileMenu, { sx: (theme: Theme) => ({ width: { md: "249px", xs: "100%", }, borderRadius: "2rem", marginTop: isBannerVisible ? "1.5rem" : "1rem", [theme.breakpoints.down(391)]: { marginTop: isBannerVisible ? "2rem" : "1rem", ...(isBannerVisible && { maxHeight: "calc(100vh - 10rem)" }), }, }), }), [isBannerVisible] );

return <PopoverComponent {…props} />; }; ```

  • [ ] Step 2: Run tests, lint and typecheck

bash npx jest src/components src/hooks src/stores src/infra src/providers npx eslint src/components/Header src/components/MobileMenu src/components/TopBanner src/hooks/useScheduler.ts src/infra/FeatureManager npx tsc --noEmit -p . | grep -v "src/services/media/index.test.ts"

Expected: PASS; nenhum erro novo de tipo.

  • [ ] Step 3: Commit

bash git add src/components/Header/Header.tsx src/components/MobileMenu/MobileMenu.tsx git commit -m "feat: show scheduling suspension banner"


Task 5: Regra de negócio

Files: - Create: .project/docs/rules/scheduling/scheduling_suspension.md - Modify: .project/docs/README.md

  • [ ] Step 1: Criar a regra (via commons:docs) descrevendo: flag scheduling_suspension; ligada → banner com o aviso para logados fora das rotas excluídas; todos os agendadores (perfil público, card, Meus Terapeutas, reagendamento, voucher) exibem “Sem horários disponíveis para estas datas.”; vale para deslogados apenas com a flag “Fully enabled” e o ajuste no trgclub-api; suspensão é só de interface.
  • [ ] Step 2: Registrar no índice .project/docs/README.md.
  • [ ] Step 3: Commit

bash git add .project/docs/rules/scheduling .project/docs/README.md git commit -m "docs: scheduling suspension rule"


Verificação final

  • Checklist “Como verificar” da spec, com a flag ligada e desligada em ambiente local.
  • Deslogados só ficam cobertos depois do PR do trgclub-api em produção.