- TypeScript 93.2%
- SCSS 5.4%
- JavaScript 1.2%
- Dockerfile 0.2%
|
All checks were successful
Deploy / pre-commit (push) Successful in 50s
Deploy / lint (push) Successful in 1m47s
Deploy / tests (push) Successful in 54s
Deploy / build (push) Successful in 52s
Deploy / audit (push) Successful in 25s
Deploy / api (push) Successful in 26s
Deploy / deploy (push) Successful in 2m32s
Reviewed-on: #1 |
||
|---|---|---|
| .forgejo | ||
| public | ||
| scripts | ||
| src | ||
| tests | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| .pre-commit-config.yaml | ||
| .prettierignore | ||
| .prettierrc | ||
| custom-next.d.ts | ||
| docker-compose.yaml | ||
| Dockerfile | ||
| eslint.config.mjs | ||
| next.config.mjs | ||
| package.json | ||
| README.es.md | ||
| README.md | ||
| README.ru.md | ||
| tsconfig.json | ||
| types.d.ts | ||
| yarn.lock | ||
SA Planner — Frontend
Language / Язык / Idioma: English | Русский | Español
Web client for the Planner pet project — a day-and-week planner with tasks in a list or on a board, drag-and-drop time blocks and a Pomodoro timer. Built on the Next.js App Router with React 19, TanStack Query and SCSS modules, translated into English, Russian and Spanish. TypeScript types and field limits are generated from the backend's OpenAPI contract, so the two sides cannot drift apart unnoticed: a CI job compares the committed copy of the contract against the backend repository and fails the pull request if they differ.
The API lives in a separate repository: sa_planner-backend.
Table of Contents
- Features
- Tech Stack
- Getting Started
- Environment Variables
- Commands
- Pages
- Authentication
- API Contract
- Internationalization
- Project Structure
- Testing
- CI/CD
- Deployment
- License
Features
- Tasks in two views — a flat list or a kanban board, both grouped into the same seven buckets (overdue, today, tomorrow, this week, next week, later, completed). The chosen view is remembered in
localStorage. - Drag and drop — dropping a task into a column rewrites its due date, or marks it completed; a column that has no free day right now (for instance "this week" on a Saturday) simply refuses the drop instead of inventing a date.
- Editing without a save button — task fields are watched by React Hook Form and written back with a debounce; a brand-new row is created on first keystroke and subsequent edits queue behind that creation rather than racing it.
- Pomodoro timer — counts down against a deadline rather than accumulating ticks, so it survives a throttled background tab. The remaining seconds are pushed to the server every 30 seconds and again on leaving the page (a
keepaliverequest that re-authenticates once if the token expired), so reopening the page resumes the round where it stood. - Time blocking — create, edit, reorder and delete blocks, with a running count of how many of the 24 hours are still left for sleep.
- Settings — profile and Pomodoro settings on one page; changing the email or the password additionally requires the current one.
- Three languages — English, Russian and Spanish, switchable from the sidebar and from the sign-in page.
- Errors the user can read — backend error codes and validation
field/constraintpairs are mapped to translated toasts instead of a generic "something went wrong". - Content Security Policy with a per-request nonce — generated in the proxy layer, plus
X-Frame-Options,nosniff, a referrer policy and aPermissions-Policyon every response. - Types generated from the contract — request/response types and numeric limits come from the backend's
openapi.json; the client validates against exactly the numbers the server enforces. - Closed to indexing — the whole app sits behind authentication and is marked
noindex.
Tech Stack
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router, Turbopack) |
| UI | React 19 |
| Language | TypeScript 6 |
| Server state | TanStack Query 5 |
| HTTP | axios with request/response interceptors |
| Forms | React Hook Form |
| Drag and drop | dnd kit (core, sortable) |
| Dates | @daypicker/react, Day.js |
| Styling | Sass modules |
| i18n | next-intl |
| Notifications | Sonner |
| Type generation | openapi-typescript + a local limits generator |
| Testing | Jest 30, React Testing Library, @testing-library/user-event |
| Lint / format | ESLint 10 (eslint-config-next, perfectionist), Prettier, pre-commit |
| Package manager | Yarn |
| Containerization | Docker (multi-stage node:24-alpine, Next.js standalone output) |
| CI/CD | Forgejo Actions |
Requires Node.js 20.9+.
Getting Started
Prerequisites
- Node.js 20.9 or newer
- Yarn 1.x
- A running backend — the client is useless without it
Installation
git clone https://git.smekhov-alex.com/SmehAlex/sa_planner-frontend
cd sa_planner-frontend
yarn
cp .env.example .env
Run
yarn dev # development server
yarn build # production build
yarn start # serve the production build
By default the app is available at http://localhost:3000 and redirects to /lk.
Environment Variables
See .env.example.
| Variable | Purpose |
|---|---|
NEXT_PUBLIC_BASE_URL |
Base URL of the backend API. Also becomes the only external origin allowed by connect-src in the CSP |
NEXT_PUBLIC_SITE_DOMAIN |
Domain the language cookie is written to |
BASE_URL |
Extra path prefix the app is mounted under (Next.js basePath) |
Note: variables prefixed
NEXT_PUBLIC_are inlined into the bundle at build time. When building the image they must be passed as build arguments, not as container environment variables —docker-compose.yamland theDockerfilealready wire them that way.
Commands
| Command | Description |
|---|---|
yarn dev |
Development server |
yarn build |
Production build (build:ci skips the standalone output for CI) |
yarn start |
Serve the production build |
yarn lint |
ESLint over src and tests (lint:fix to autofix) |
yarn format |
Prettier over the sources |
yarn test |
Jest suite (test:watch, test:cov for coverage) |
yarn generate:api |
Regenerate schema.ts and limits.ts from the committed openapi.json |
yarn check:api |
Regenerate them and fail if the committed files differ |
Pages
| Route | What it is |
|---|---|
/auth |
Sign in and register, with a language switcher |
/lk |
Statistics: total, completed, due today, due this week |
/lk/tasks |
Tasks as a list or a board |
/lk/timer |
Pomodoro timer |
/lk/time-blocking |
Time blocks and the hours left in the day |
/lk/settings |
Profile and Pomodoro settings |
/health |
Liveness probe for the container's HEALTHCHECK |
/ redirects to /lk. There is no locale prefix in the URL: the app is closed behind authentication and marked noindex.
Authentication
The access token is kept in a module variable only — never in localStorage, never in a cookie the page can read. The refresh token lives in an httpOnly cookie set by the backend, which the browser attaches on its own; the client can only observe whether it exists.
How a request finds a token. The axios instance in src/api/interceptors.ts attaches the in-memory access token, and refreshes first if there is none. A 401 triggers one refresh and one retry of the original request — a second failure is surfaced instead of looping. Concurrent requests share a single in-flight refresh, so a page that loads five queries at once does not fire five refreshes.
When the session actually ends. Only a 401 or a 403 on the refresh call counts as a dead session and sends the user to /auth. A dropped connection, a timeout or a 5xx from a proxy does not — those say nothing about whether the cookie is still valid. Signing out clears the in-memory token even if the request itself failed, so a failed network call cannot leave the user apparently signed in.
Route protection. src/proxy.ts runs before every page: a visitor holding the refresh cookie is redirected away from /auth, and one without it is redirected away from /lk. The same layer mints a per-request CSP nonce and sets the policy on both the request (so Next.js can stamp its own scripts) and the response.
Retries. TanStack Query retries a failed query at most twice, and only for responses of 500 and above; a 4xx is not going to change its mind on the second attempt.
API Contract
Four files under src/shared/api/ carry the contract, and only the last is written by hand:
| File | Origin |
|---|---|
openapi.json |
A copy of the backend's contract, committed here |
schema.ts |
Generated by openapi-typescript — every request and response type |
limits.ts |
Generated by scripts/generate-limits.mjs — the minimum/maximum/minLength/maxLength/default values pulled out of the contract |
types.ts |
Hand-written: readable aliases over the generated schema, plus runtime constants for the enums |
Updating after a backend change:
cp ../sa_planner-backend/openapi.json src/shared/api/openapi.json
yarn generate:api
The api.yml workflow downloads openapi.json straight from the backend repository, diffs it against the committed copy, then regenerates the types and fails if the committed output is stale. Neither a forgotten copy nor a forgotten regeneration can reach main.
Because limits.ts is generated, the client's own validation uses the same numbers the server enforces — a maximum only has to change in one place.
Internationalization
The UI is translated into English (the default), Russian and Spanish. Dictionaries live in src/shared/i18n/messages/; en.json defines the key set and the others are required to mirror it — enforced both by types and by a test.
Choosing a language (see resolveLocale.ts):
- the
NEXT_LOCALEcookie — a deliberate choice by the user; - the
Accept-Languageheader, weighted byq, when there is no cookie yet; - English, if nothing matched.
After signing in, the profile's language field becomes the source of truth: if it disagrees with the cookie, the cookie is rewritten and the page re-renders. The switcher in the sidebar saves the choice to the profile; the switcher on the sign-in page only writes the cookie; and the language chosen at registration is sent to the backend along with the form.
Project Structure
sa_planner-frontend/
├── public/ # Favicons and the web manifest
├── scripts/generate-limits.mjs # openapi.json → limits.ts
├── src/
│ ├── proxy.ts # Route guards, CSP nonce
│ ├── api/interceptors.ts # axios instances, token attachment, single-flight refresh
│ ├── app/
│ │ ├── layout.tsx # Fonts, metadata, i18n and query providers, toasts
│ │ ├── providers.tsx # QueryClientProvider, confirmation dialogs, error toasts
│ │ ├── queryClient.ts # Retry policy and error routing
│ │ ├── auth/ # Sign-in and registration page
│ │ ├── lk/ # Dashboard: statistics, tasks, timer, time blocking, settings
│ │ ├── health/route.ts # Liveness endpoint
│ │ └── styles/ # Global styles and the colour palette
│ ├── components/
│ │ ├── dashboard-layout/ # Sidebar, header, profile, sign-out
│ │ ├── list-view/ # Tasks as a list
│ │ ├── kanban-view/ # Tasks as a board
│ │ ├── task-views/ # Shared columns, drag context, add button
│ │ ├── time-blocking/ # Block list and form
│ │ ├── pomodoro-rounds/ # Round indicator
│ │ ├── language-switcher/ view-switcher/
│ │ └── ui/ # Buttons, fields, select, checkbox, badge, loader, confirmation, date/priority/colour pickers
│ ├── services/ # One module per API area, plus the in-memory token store
│ ├── i18n/request.ts # next-intl request configuration
│ └── shared/
│ ├── api/ # Contract copy, generated types and limits, aliases
│ ├── assets/icons/ # SVG imported as components
│ ├── consts/ # Routes, menu, locales, task buckets, colours, validation
│ ├── i18n/ # Dictionaries, locale resolution, language cookie
│ ├── lib/
│ │ ├── date/calendar.ts # Calendar-day helpers over Day.js
│ │ ├── hooks/ # Queries and mutations per area, timer, drag and drop, storage
│ │ └── utils/ # Task grouping, time formatting, error → translation key
│ └── models/ # Form types
├── tests/ # Jest suites mirroring src/
├── .forgejo/ # Forgejo Actions workflows and composite actions
└── Dockerfile / docker-compose.yaml
Testing
Jest with React Testing Library and a jsdom environment, configured in tests/jest.config.cjs through next/jest. Coverage must stay at or above 90% for statements, branches, functions and lines; pages and layouts are excluded from the measurement.
yarn test
yarn test:cov
The suites cover components and pages, hooks (tasks, time blocking, the timer, the profile, language), services and axios interceptors, the proxy layer, locale resolution and dictionary parity, and the utilities.
CI/CD
Forgejo Actions. Reusable steps are factored into composite actions under .forgejo/actions/; every workflow caches node_modules on the yarn.lock hash.
| Workflow | Trigger | What it does |
|---|---|---|
lint.yml |
pull request | tsc --noEmit and ESLint |
tests.yml |
pull request | The whole Jest suite with coverage |
api.yml |
pull request | Fetches openapi.json from the backend repository, diffs it against the committed copy, regenerates the types and fails if they are stale |
build.yml |
pull request | A production build |
audit.yml |
pull request | yarn audit; fails on high or critical findings only |
pre-commit.yml |
pull request | Every pre-commit hook across the whole tree |
deploy.yml |
push to main / manual |
Re-runs all six checks, then deploys: sync, render .env, build the image, restart |
redeploy.yml |
manual | Deploy without any checks — refuses anything but main unless allow_any_branch is set |
Pre-commit hooks
pip install pre-commit
pre-commit install
Runs the standard file hygiene hooks, ESLint with --fix over src and tests, and Prettier. The generated schema.ts and the contract copy are excluded.
Deployment
The production image is a multi-stage Dockerfile on node:24-alpine that builds the Next.js standalone output and runs it as the unprivileged node user, with a HEALTHCHECK hitting /health. NEXT_PUBLIC_* values are baked in at build time as build arguments. docker-compose.yaml publishes the service on 127.0.0.1:3001, capped at one CPU and 512 MB.
License
A private pet project (UNLICENSED). Source: git.smekhov-alex.com/SmehAlex/sa_planner-frontend.