No description
  • TypeScript 93.2%
  • SCSS 5.4%
  • JavaScript 1.2%
  • Dockerfile 0.2%
Find a file
Smekhov Aleksandr 0a5afdbbfb
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
Full refactoring
Reviewed-on: #1
2026-09-06 20:37:43 +00:00
.forgejo Small fixes. 2026-08-26 21:12:46 +03:00
public base settings. 2024-06-17 16:02:47 +03:00
scripts Fix pre-commit. 2026-08-28 01:35:25 +03:00
src Small bug fixes. 2026-09-04 01:48:10 +03:00
tests Small bug fixes. 2026-09-04 01:48:10 +03:00
.dockerignore Huge refactoring. 2026-08-19 19:00:44 +03:00
.env.example Add tests and workflows. 2026-08-17 21:47:48 +03:00
.gitignore delete env from git. Start middleware 2024-06-18 15:24:55 +03:00
.pre-commit-config.yaml Add import ordering. 2026-08-20 22:47:55 +03:00
.prettierignore Add api generation and huge refactoring. 2026-08-20 21:36:21 +03:00
.prettierrc Huge refactoring. 2026-08-19 19:00:44 +03:00
custom-next.d.ts fix svg block 2024-06-18 17:33:58 +03:00
docker-compose.yaml Add auto-deploy action. 2026-08-24 21:43:15 +03:00
Dockerfile Refactoring. 2026-08-24 16:42:34 +03:00
eslint.config.mjs Add import ordering. 2026-08-20 22:47:55 +03:00
next.config.mjs Small bug fixes. 2026-09-04 01:48:10 +03:00
package.json Small fixes. 2026-08-26 21:12:46 +03:00
README.es.md Add README. 2026-08-26 15:08:48 +03:00
README.md Add README. 2026-08-26 15:08:48 +03:00
README.ru.md Add README. 2026-08-26 15:08:48 +03:00
tsconfig.json More refactoring. 2026-08-24 20:34:48 +03:00
types.d.ts Rename types and interfaces. 2026-08-19 19:18:31 +03:00
yarn.lock Add import ordering. 2026-08-20 22:47:55 +03:00

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

  • 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 keepalive request 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/constraint pairs 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 a Permissions-Policy on 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.yaml and the Dockerfile already 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):

  1. the NEXT_LOCALE cookie — a deliberate choice by the user;
  2. the Accept-Language header, weighted by q, when there is no cookie yet;
  3. 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.