Skip to content

v2 architecture: TypeScript server, schema migration, client polish#102

Merged
lokeshwardewangan merged 43 commits into
masterfrom
refactor/v2-architecture
May 27, 2026
Merged

v2 architecture: TypeScript server, schema migration, client polish#102
lokeshwardewangan merged 43 commits into
masterfrom
refactor/v2-architecture

Conversation

@lokeshwardewangan

Copy link
Copy Markdown
Owner

Summary

Multi-month v2 cutover bundling the server's TypeScript port, the
MongoDB schema migration to v2, and a client-side UX overhaul.

Server

  • Full TypeScript port with native ESM (type: "module"), build to
    dist/, strict mode, modular shared/ + modules/ layout
  • v2 DB migration — embedded PocketMoneyHistory[],
    LentMoneyHistory[], activeSessions[] arrays on User extracted
    into their own collections; currentPocketMoney retyped String → Number;
    expense day-docs flattened to one doc per line item; sessions hashed
    via sha256. Idempotent migrate-v2-{preflight,users,expenses,verify}
    scripts; already run successfully against prod Atlas
  • OpenAPI 3.0 spec + Swagger UI mounted at /api/docs
  • Vitest + supertest + in-memory Mongo test suite covering
    auth / expense / feed
  • Bug fixes: ObjectId cast in $match aggregations (dashboard was
    silently returning zeros); lent money now sums all unreceived rather
    than month-filtered; legacy expense category remap (Utilities
    Housing & Utilities etc.)
  • Stream avatar uploads to Cloudinary (no on-disk buffer); strictQuery

Client

  • Custom sidebar → shadcn <Sidebar> primitives — drops ~130 LOC
    of hand-rolled width math, gets Cmd+B shortcut, cookie persistence,
    mobile Sheet, proper a11y
  • Theme toggle with View Transitions API clip-path animation
    (magicui-style); ThemeContext is now state-only, body class
    application is route-aware in MainLayout (landing route is
    light-only)
  • react-joyride@sjmc11/tourguidejs — new features/tour/
    module with single registry of target ids, route-aware steps,
    runtime visibility filter that skips missing/hidden targets, dark
    theme overrides
  • react-hot-toast → shadcn sonner at bottom-right
  • IST-aware date formatters replacing raw toLocaleString everywhere;
    reports filters default to current month/year; single source of truth
    for expense categories
  • Clearer sidebar labels + lucide icons

Bundle / deps

  • react-joyride (−16 transitive packages)
  • react-hot-toast removed
  • + @sjmc11/tourguidejs, + sonner, + shadcn sidebar deps
  • Net production bundle: smaller

Migration status

Prod Atlas (/budgetter) is already on v2 schema — verified
2026-05-27, 32/34 users with exact balance match, 2 users with
pre-existing drift (not migration-induced).

Test plan

  • Login on prod after deploy → dashboard loads with real numbers
  • Add expense → appears in expenses collection (not embedded)
  • Add pocket money → lands in pocketmoneys
  • Reports filter by month/year works
  • Mark lent-money as received
  • Theme toggle persists across refresh; landing stays light
  • "Take a Tour" from any user route renders + advances cleanly
  • Vercel server build runs npm run build and starts from
    dist/index.js

…m /me

- add GET /api/expenses/feed with server-side filtering (page, limit,
  month, year, search, category) via aggregation pipeline; powers the
  client useInfiniteQuery on Reports
- switch edit/delete expense to flat /api/expenses/products/:productId
  routes (parent doc is located server-side by actualDate); matches what
  the dialog callers already know
- trim /me payload to user identity + currentSession only; pocket-money
  and lent-money histories ride on their own endpoints via dedicated
  queries
- shared/api: typed endpoints registry + ApiResponse<T> envelope
- shared/lib/queryKeys: factory for every TanStack Query key
- shared/components/form: FormField, PasswordInput, SubmitButton,
  SelectField — replace the duplicated input boilerplate across forms
- shared/components/table/DataTable: generic wrapper over
  @tanstack/react-table; replaces ~60 LOC of header/body markup repeated
  per table
- shared/components/charts/ChartFilterOptions: extracted from
  components/user/common
- shared/hooks: useDialogState, useDebounce, useMediaQuery (with
  useIsMobile)
- shared/contexts: ThemeProvider + SidebarProvider that replace the old
  themeMode and sideNavbar Redux slices
- lib/http: error envelope helpers (getApiErrorMessage,
  getApiFieldErrors) for surfacing server field-level errors into forms
- App + main: drop redux Provider, wire ThemeProvider + SidebarProvider
- types/api/*: sync to new server response shapes; split AuthResType
  (login/register/google) from UserDetailsResType (/me)
- install react-hook-form, @hookform/resolvers, zod; remove react-redux
  and @reduxjs/toolkit
- services/auth + adminAccess: switch every URL to the new REST routes
- delete legacy services/{expenses,lentmoney,reports} now that each
  feature owns its API directly
- features/auth: LoginForm, SignupForm, ForgotPasswordForm,
  ResetPasswordForm, GoogleAuthButton — all on react-hook-form +
  zodResolver with field-level error messages (replaces 100-220 LOC
  Formik components each)
- features/auth/hooks: useLogin / useSignup / useGoogleSignIn share a
  useHandleAuthSuccess that sets the cookie and seeds the /me query
  cache; useLogout clears the cache + cookie + google session
- features/auth/schemas: Zod schemas with shared username / email /
  password primitives; types come from z.infer<typeof schema>
- features/user/hooks: useMe() — single source of truth for the current
  user, replacing the Redux user slice
- delete legacy components/auth/{LoginSection, SignupSection,
  ForgotPasswordSection, ResetPassword, GoogleAuthLogin, AuthFooter}
- schemas/userAuth: trimmed to just the landing-page contact form (Yup
  is otherwise gone)
- delete features/user/user.ts (Redux slice)
- pages/MainLayout: reads useMe() directly; no longer mirrors into Redux
- hooks/useSentryIntegration: identifies the user from useMe()
- routes/routes: point at the new auth form components
… lent / sessions

- features/pocketMoney: api + Zod schema + useAddPocketMoney /
  usePocketMoneyHistory + AddForm + HistoryTable + CurrentBalanceCard
  (renamed from ShowMoney.tsx)
- features/lentMoney: api + Zod schema + useAddLentMoney /
  useLentMoneyHistory / useMarkLentMoneyReceived + AddForm +
  HistoryTable + MarkReceivedDialog (renamed from
  ReceivedLentMoneyDialog); table shows Pending/Received status badges
  thanks to the new isReceived flag (no more $pull-on-receive that
  destroyed history)
- features/sessions: api + useSessions (proper useQuery, not the legacy
  mutation-used-as-read) + SessionsPopover + SessionCard +
  LogOutOtherDevicesButton (matches the new server behaviour: deletes
  other devices only; clearer label) + skeleton
- pages/user/addMoney + addLentMoney: thin composition only
- rename pages/user/addLentMoney/AddLentMoney..tsx -> AddLentMoney.tsx
  (drop the double dot in the filename)
- delete legacy components/user/addMoney, components/user/addLentMoney,
  components/profile/sessions, hooks/auth/useUserLogout — all replaced
  by the new feature hooks
- every mutation invalidates qk.{pocketMoney,lentMoney,sessions}.all
  + qk.me so the running balance refreshes
… reports feed

- features/expenses: api + Zod schemas (addExpenseFormSchema,
  editExpenseFormSchema) + hooks (useTodayExpenses, useExpensesByDate,
  useAllExpenses, useAddExpenses, useEditExpense, useDeleteExpense)
- features/expenses/components: AddExpenseForm + EditExpenseDialog (both
  RHF + Zod), DeleteExpenseDialog (with refund-to-balance checkbox),
  ExpensesTable backed by shared DataTable, TodayExpensesPanel +
  ExpensesByDatePanel (uses useQuery instead of the old
  useMutation-for-a-read pattern)
- every mutation invalidates qk.expenses.all + qk.me — fixes the
  long-standing cache-coherency bug where deleting an expense didn't
  refresh the all-expenses view
- features/reports: useInfiniteQuery against the new
  GET /api/expenses/feed endpoint; ReportsPanel + ReportsFilters +
  ReportsTable with IntersectionObserver-driven auto-load. Page size 10
  — addresses the perf hot-spot where the Reports page used to load
  ~12k-22k rows on first paint
- pages/user/{addExpenses,showExpenses,reports}: thin composition only
- delete schemas/expenses.tsx (Yup version)
- delete legacy components/user/{addExpenses,showExpenses,reports} —
  the components were 200-500 LOC each and full of branching,
  prop-drilling, and Redux dispatches; the replacements are 50-150 LOC
  with hooks owning the data
…it charts

- features/dashboard/api + hooks: useMonthlyReport(month, year) backed
  by a useQuery keyed on the filter pair, so changing month/year auto-
  refetches and jumping back to a prior filter is instant from cache.
  Replaces the legacy useMutation-in-useEffect pattern
- features/dashboard/components: DashboardFilters, SummaryBoxes,
  CategoryDonutChart, ExpensesTimelineChart, CategoryInsightsTable,
  ViewCategoryExpensesDialog, plus the donut/line skeleton loaders
- features/dashboard/hooks/useCategoryStats: properly hook-scoped
  (replaces the legacy getFullExpensesList Rules-of-Hooks violation
  where useSelector was called from inside a table-cell renderer);
  reads from the shared useAllExpenses query so the insights table and
  timeline chart share one network round-trip
- features/dashboard/lib/categoryColors: single source of truth for the
  category gradients (was duplicated inline in the legacy donut chart)
- pages/user/dashboard/Dashboard: slimmed from ~175 LOC of state and
  state-syncing to ~70 LOC of composition
- delete components/user/dashbaord/ (note the typo'd folder name — fixed
  by being deleted)
…o new model

- features/profile: ProfileForm (RHF + Zod, 14 useState → 1 form hook),
  AvatarSection, AdvancedOptions, DeleteAccountDialog; useUpdateProfile
  / useUpdateAvatar / useDeleteAccount invalidate qk.me
- features/layout/header: TopHeader (shell only) + HeaderActions
  (right-hand button cluster) + NotificationsPopover (account-verified
  banner)
- features/layout/sidebar/SideNavbar: renamed from
  components/navbar/SideNavbar; reads sidebar state from the new
  SidebarContext, uses the new useLogout
- pages/user/Profile/ProfilePage: 490 LOC → ~30 LOC composition
- pages/user/UserLayout + pages/admin/AdminLayout: read sidebar/mobile
  state from SidebarContext + useIsMobile; user identity from useMe()
- components/admin/{UserDetails, NewsLetter}: drop references to
  PocketMoneyHistory / LentMoneyHistory (server no longer embeds those
  on the User model); replace useSelector reads with useMe / useTheme
- components/user/PDFExportComponent: pulls user via useMe()
- components/ui/theme-toggle-button: uses the new useTheme() context
- delete pages/user/Profile/DeleteAccountDIalog.tsx (typo'd filename;
  replaced by features/profile/components/DeleteAccountDialog.tsx)
- delete components/header/ + components/navbar/ + components/profile/
  (now empty)
Every consumer migrated in earlier commits — user data lives in the /me
query, theme + sidebar live in contexts, viewport state in a media-query
hook. With nothing reading them anymore, the store and the four legacy
slices can go:

- delete app/store.ts (configureStore root)
- delete features/theme/themeModeSlice.ts
- delete features/sideNavbar/sideNavbarSlice.ts
- delete features/windowWidth/windowWidthSlice.ts

react-redux + @reduxjs/toolkit were already removed from package.json in
the shared-infrastructure commit; this commit removes the last
references on disk.
…der delete

The components/navbar/ folder held two unrelated files: SideNavbar.tsx
(moved to features/layout/sidebar/) and Navbar.tsx (the landing page
header — out of scope for the refactor). The folder-wide delete in the
profile+layout commit took both. HomePage.tsx still imports
@/components/navbar/Navbar, so Vite was throwing a 500.

Restoring Navbar.tsx verbatim from before the delete. No content
changes.
…ors inline

- LoginForm sends username OR email based on '@' in input (server's Zod
  .email() rejected username strings sent in the email field)
- server loginLocal builds $or from populated fields only — closes a
  latent auth-bypass where Mongoose stripped `{ email: undefined }` to `{}`
- FormField/SelectField + all RHF forms now render role=alert error spans
  below inputs and use mode='onTouched'
- DataTable: widen columns prop to `ColumnDef<T, any>[]` so mixed
  accessor types coexist (TanStack's second generic is invariant)
- DatePicker: setInputDate accepts a plain callback, not just a setter
- auth/hooks: wrap useHandleAuthSuccess to match TanStack's onSuccess sig
- PDFExportComponent: DOB→dob and coalesce undefined fields for CellInput
…, env validation

- helmet sets standard security headers (CSP, HSTS, XFO, etc.)
- globalLimiter (120/min) on all routes; authLimiter (10/15min, failures only)
  on /register, /login, /google, /password-reset/{request,confirm}
- express-mongo-sanitize strips $-prefixed keys to block operator injection
- config/env.js validates required env vars via Zod, fails fast with a clear
  list if anything is missing — replaces silent jwt.verify(token, undefined)
- trust proxy reads TRUST_PROXY_HOPS (default 1 = nginx) instead of unsafe true
- index.js no longer references undefined `error` in app.on('error')
Pure mechanical move — no logic changes. Layer-based (controllers/, services/,
routes/, validators/, models/, middleware/, utils/) replaced with vertical
slices under modules/ + shared/.

- modules/{auth,user,expense,pocketMoney,lentMoney,session,report,admin}/ —
  each owns model + controller + service + routes + validator
- shared/{middleware,lib,email,config,db}/ — cross-cutting infrastructure
- routes/index.js → routes.js
- utils/EmailSend.js → shared/email/email.service.js
- utils/getCurrentDate.js → shared/lib/date.js
- validators/common.js → shared/lib/validators.js
- models/expenses.model.js → modules/expense/expense.model.js
- models/activeSession.model.js → modules/session/session.model.js

All 53 renames preserve git history via git mv. 38 files updated to point at
new import paths. Server boots and /healthz responds 200.
Schema:
- User: currentPocketMoney String→Number, dateOfBirth→dob:Date, sparse
  googleId index, dropped redundant username index
- PocketMoney/LentMoney/Expense: amount/price/date now Number/Date
- LentMoney: index {user, isReceived}; PocketMoney: index {user, createdAt}
- ActiveSession: TTL 30d on lastUsedAt

Services:
- adjustBalance uses atomic $inc (race-free under concurrent writes)
- report.service / expense.service use {date: {$gte, $lt}} ranges instead
  of $split + regex string matching
- new helpers: monthRange, dayRange, startOfToday in shared/lib/date.js

Validators:
- dateString → dateInput (accepts Date/dd-mm-yyyy/ISO, emits Date)
- moneyAmount simplified to z.coerce.number().positive()

Migration:
- scripts/migrate-v2-types.js reads raw via collection driver, bulkWrites
  String→Number money and dd-mm-yyyy/yy→Date conversions. Idempotent.
- npm run migrate:v2-types
Replaces the embedded-array Expense model (1 doc per (user, date) with
products[] inside) with one document per expense line. Eliminates the
$unwind in feed queries, makes edit/delete a single-doc op, and lets us
index {user, category, date} directly.

Server:
- expense.model.js: drops products[] subschema; hoists name/price/category/
  label to the top level. New indexes {user, date}, {user, category, date},
  {user, createdAt}.
- expense.service.js: insertMany for bulk; find/findOneAndUpdate/findOneAndDelete
  for single-doc CRUD. getExpensesFeed drops the $unwind pipeline in favour
  of a plain find + countDocuments.
- expense.controller + validator: drops actualDate (server resolves by _id);
  responses now return flat docs.
- report.service: monthly category breakdown via $group on category.

Client:
- types/api/expenses: flat Expense replaces ExpenseEntry/ExpenseProduct
  (kept as alias). All response shapes return Expense[].
- features/expenses/api: drops actualDate/expenseDate from edit/delete.
- ExpensesByDatePanel: data is now Expense[] instead of {products: []}.
- ReportsTable + Panel: row.product.X → row.X (no more nested product).
- Dashboard useCategoryStats + Timeline chart + ViewCategoryExpensesDialog:
  date is ISO 8601 (parsed via new Date) instead of dd-mm-yyyy strings.

Migration:
- scripts/migrate-v2-expense-flatten.js: walks day-docs, $unwinds products[]
  into individual flat docs preserving each product._id, then deletes the
  source day-docs. Idempotent.
- npm run migrate:v2-flatten
…use resets

A6 — ActiveSession.token (plaintext JWT) → tokenHash (sha256). DB leak no
longer = impersonation. session.service hashes on write, look up by hash.
Migration: scripts/migrate-v2-session-hash.js.

C3 — verifyJwtToken stops loading the full User on every request. Just
decodes the JWT and looks up the session by hashed token (session existence
implies user existence via deleteAccount cascade). Drops req.user; sets
req.userId. All controllers updated.

B6 — User.role enum ('user' | 'admin'), requireAdmin middleware mounted on
/api/admin/* so a plain JWT is no longer enough to list all users or
trigger the newsletter blast.

B5 — Password reset is single-use. requestPasswordReset stamps a sha256 of
the issued JWT on User.passwordResetTokenHash. resetPassword rejects when
no pending reset is recorded, then clears the hash on success. Blocks the
prior gap where any caller with a userId could reset the password.

B8 — Multer caps avatar uploads at 5MB and rejects non-image MIME types.

userService.isVerified — small helper to keep auth.controller.checkVerified
working after dropping req.user.

npm run migrate:v2-session-hash
Admin panel will live in a separate SPA that consumes /api/admin/* across
all projects. The user-facing client should not ship code that 99% of
users never load.

Removed:
- pages/admin/, components/admin/, types/api/admin/
- data/AdminSidebarList.ts, services/adminAccess.ts
- admin imports + route block in routes/routes.tsx
- admin entries in endpoints.ts, queryKeys.ts, HeaderName.ts, utility.ts

Bundle: 1713 → 1680 kB (~34 kB shaved). Server admin APIs and
requireAdmin middleware untouched — ready for the separate admin SPA.
…n, DTO mapper

E2  Graceful shutdown: SIGTERM/SIGINT close the HTTP server, await in-flight
    requests, disconnect Mongoose, then exit. 10s force-exit watchdog. Also
    catches unhandledRejection + uncaughtException.

E3  Structured logger via pino (pretty in dev, JSON in prod). Replaces
    console.log/console.error in db/conn, email service, cloudinary, auth
    service, error middleware. Morgan dependency removed (pino-http takes
    over request logging; healthz auto-log suppressed to keep noise down).

E4  Request-id middleware honors inbound x-request-id (gateway/LB) or mints
    one via nanoid, echoes it on the response header, and threads it through
    pino-http's child logger so every log line for a request shares the id.

C4  getMe response built via a whitelist DTO mapper (ME_FIELDS) so adding a
    User field doesn't accidentally leak it to /me. Also fixed a latent bug
    from Phase 5: getMe was still querying ActiveSession by {token} after
    the field was renamed to {tokenHash}, so currentSession was always null.

Deps: +pino, +pino-pretty, +pino-http, +nanoid; -morgan.
Replaces ~100 hard-coded HTTP status numbers across controllers, services,
and middleware with named constants (StatusCodes.OK, .CREATED, .NOT_FOUND,
.UNAUTHORIZED, .FORBIDDEN, .CONFLICT, .BAD_REQUEST, .TOO_MANY_REQUESTS,
.INTERNAL_SERVER_ERROR). Self-documenting; harder to typo a status number.

Touched patterns only:
- new ApiError(NNN, ...)
- new ApiResponse(NNN, ...)
- res.status(NNN)
- statusCode = NNN assignments in error.middleware

Non-status numeric literals (bcrypt rounds, pagination limits, etc.)
left untouched. Server boots clean.
…se/feed

Adds 18 integration tests across three suites:

- tests/auth.test.js (8 tests): register, duplicate-email rejection, login
  by email, login by username, bad-password rejection, /users/me with and
  without token, logout invalidates session.
- tests/expense.test.js (7 tests): addToday deducts balance, edit refunds
  delta down, edit charges delta up, delete refunds when refund=true,
  delete preserves balance when refund=false, 404 on unknown id, today
  list sorted.
- tests/feed.test.js (3 tests): pagination total/hasMore, category filter,
  case-insensitive search.

Infrastructure:
- vitest.config.js: fileParallelism=false so one MongoMemoryServer instance
  is shared across files.
- tests/setup.js: stubs required env synchronously (config/env.js validates
  at import time), boots in-memory mongo, connects mongoose, wipes
  collections between tests.
- tests/helpers.js: api(), register(), loginAndGetToken(), authHeader().

Caught one latent bug while writing tests: buildUserAndSession discarded
the user-supplied username for local register and generated one from
name. Fixed: pass the request username through; only synthesize when
missing (Google flow).

Scripts: npm test, npm run test:watch.
Auto-generated API docs from existing Zod schemas via
@asteasolutions/zod-to-openapi. 32 operations across 25 paths covering
all 8 modules (Auth, User, Sessions, Expenses, Pocket Money, Lent Money,
Reports, Admin).

Endpoints:
- GET  /docs          — Swagger UI
- GET  /openapi.json  — raw spec (consume from the future admin SPA to
                        auto-generate TypeScript types via openapi-typescript)

Infra:
- shared/openapi/init.js — extends Zod with .openapi(), shares the registry
  + bearerAuth security scheme + apiResponse() envelope helper.
- shared/openapi/build.js — side-effect-imports each module's *.openapi.js
  to populate the registry, then generates the document.
- modules/<m>/<m>.openapi.js — one per module, registers paths with
  schemas re-used from the existing *.validator.js files. No duplication.

Tests: 18/18 still passing.
Compose Sidebar/SidebarProvider/SidebarInset/SidebarMenu/etc. instead
of hand-rolled SideNavbar + SidebarContext. Drops ~130 LOC of width
math, outside-click handling, and overlay logic.

- Tune sidebar CSS vars to match existing theme (white/slate-700 light,
  #171829/white dark, emerald active/ring).
- Pin SidebarInset bg to bg_secondary_light/dark so the body bg shows
  through like before (shadcn's --background is too dark for our theme).
- Keep original sizing: 13rem open / 4.25rem icon-only, h-11 menu items,
  text-base, size-5 icons, emerald-gradient active state.
- Replace SidebarTrigger PanelLeft with the previous ri-menu-line button.
- Animate Budgetter logo text fade/slide instead of pop in/out.
- Fix TopHeader h-full → h-16 (was stretching to full viewport inside
  the new flex SidebarInset parent).
- Theme: single source of truth for body.dark in MainLayout (route-aware,
  landing route stays light). ThemeContext only owns state + storage.
- Theme toggle: swap custom hover-expand button for magicui-style View
  Transitions clip-path animation, wired to existing useTheme().
- Sidebar collapsed mode: hide menu/logout labels and collapse logo gap so
  icons sit dead-center in the icon column.
- Sonner: position bottom-right; add toast.promise on logout.
- FormField: vertically center trailing icon (password eye alignment).
- Server: bump authLimiter from 10 to 30 failed attempts / 15 min.
Swap the patchy joyride product tour for @sjmc11/tourguidejs and split
the feature into a proper module under features/tour/:

- tourIds.ts: single registry of target dom ids
- tourSteps.ts: pure data, per route, no JSX
- TourProvider.tsx: module-level TourGuideClient singleton (the lib
  uses global element ids, so a second instance silently breaks
  rendering — singleton survives Strict Mode's double-mount)
- runtime filter drops steps whose target is missing or hidden so
  display:none / `hidden lg:*` elements never produce empty popups

Restore the previously broken/commented steps by adding ids to the
shadcn Sidebar, the logout button, the membership info block, the add
expense / pocket / lent forms, and the pocket money + lent money
tables. Drop the addNewExpense and spinWheel steps from data.

Move the first-login auto-trigger out of TopHeader (where it didn't
belong) into TourProvider; HeaderActions calls useTour().start()
directly so the onStartTour prop chain is gone.

Bundle drops ~60KB after dropping react-joyride and its 16 transitive
deps.
@vercel

vercel Bot commented May 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
api-budgetter Ready Ready Preview, Comment May 27, 2026 7:14pm
budgetter Ready Ready Preview, Comment May 27, 2026 7:14pm
mybudgetter-backend Ready Ready Preview, Comment May 27, 2026 7:14pm

The src/index.js entrypoint no longer exists after the TypeScript
port; the sanity step was failing on the v2 branch. Replace with the
actual quality gates we now have: tsc --noEmit, tsc build, vitest.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant