Suspensão temporária do agendamento — Plano de implementação
TLDR: flag
scheduling_suspensionque exibe oTopBannercom o aviso de suspensão e zera a disponibilidade em todos os agendadores dotrgclub-web.
Spec:
.project/docs/specs/20260930114644_scheduling_suspension.mdBranch: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,expectde@jest/globals). - Baseline:
npx jestdos módulos afetados verde;tscjá tem erros pré-existentes só emsrc/services/media/index.test.ts. - Parte do
trgclub-apifica 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
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 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"
Task 3: TopBanner sem link e nova chave de persistência
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
// 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> ```
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
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: flagscheduling_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 notrgclub-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-apiem produção.