Case study

flexiday.

The shared calendar for team time off. Request in seconds, approve in a click, and always know who is in and who is away.

A spreadsheet that kept breaking.

Every small team I have worked in tracked time off in a shared sheet. It answered nobody's real question.

The question is always the same: who is off next week? A spreadsheet answers it badly. Rows go stale, formulas break when someone inserts a column, approvals happen in chat and never make it back into the sheet, and the one person who understands the file is the one on holiday.

The tools that solve this properly are built for HR departments. They want an org chart, a payroll integration and a per-seat contract before a five-person team can book a Friday off.

flexiday sits in the gap. It is a calm, shared calendar for teams of a few people to a few hundred: request in seconds, approve in a click, see the month at a glance. Balances, public holidays and calendar feeds are handled for you, and nothing about it needs an HR department.

What it does.

Everything hangs off one calendar. The rest exists so that calendar is always right.

The month view in Group mode. Nine leave types, each with its own colour, filterable per type.

Shared team calendar

One colour-coded view of who is off and when. Switch between your own bookings and the whole group, filter by leave type, and click any day to start a request from there.

Nine kinds of leave

Vacation, home office, sick days, sick leave, paid and unpaid leave, study leave, bank holidays and a catch-all. Only the first three draw down a quota; the others are there so the calendar tells the truth.

One-click approvals

Requests land in the approver's queue with the dates, the type and a note. Approve or decline in a tap. The requester gets an email and an in-app notification either way, with the reason if it was declined.

Every state change is recorded as an event, so a request's history is never a mystery.

The requests queue: type, dates, state, and approve or decline without leaving the row.

Who is away, at a glance

The dashboard answers the daily question before anyone asks it: who is out today, who is out next, and what is waiting on me. Balances sit beside it, so a manager approving a request already knows whether the days are there.

A manager's dashboard: the approval queue, who is out today, upcoming leave and the month, on one screen.

Groups, approvers and coverage

A team, an office or a project is a group. Each group has its own manager, approvers, working days and holiday country, so a colleague in Prague and one in Vienna each see the right red days.

Coverage is the question the group view answers: before approving, an approver sees who else in the group is already away across those dates, so a thin week is visible rather than discovered.

Invites are single-use links bound to an email address. Managers can control a member's calendar when someone forgets to book their own sick day.

A group's members and who can view, administer and approve. Quotas, invites and settings live in the other tabs.

Balances that add up

Yearly allowances, carry-over and a paid-plan sick-day benefit are tracked per person and per group. A scheduled job rolls quotas into the new year. The report exports to Excel for whoever still needs a spreadsheet.

Lives in your own calendar

A private ICS feed puts team absences into Google Calendar, Outlook or Apple Calendar next to your meetings. No plugin, no OAuth prompt; subscribe once and it stays current.

Requests in seconds, on any screen

Pick a type, pick a range, add a note, done. Multi-day requests stay grouped so the approver sees one item, not five. On paid plans a request can carry an attachment, such as a doctor's note, uploaded straight to S3 and processed by a Lambda.

Private by default

Members see their group. Approvers see what they approve. Sensitive leave types are shown discreetly to everyone except the people who need to know. Sign-in supports Google, Microsoft and two-factor authentication.

The new-request form on a phone.

Localised public holidays

Each group picks the country whose holidays it follows and the working days it counts. Holidays are resolved per country and year, so nobody maintains a list of red days by hand, and a range that crosses one does not quietly spend a day of somebody's allowance.

English and Czech

The interface, the emails and the public site all ship in both languages, and the choice follows the person rather than the team. Dates, week starts and holiday names follow it too, which matters more than the wording does.

Three repositories, one product.

The web app, the API and the email templates version and deploy on their own schedules.

flexiday architectureThe browser loads the static web app from S3 through CloudFront and talks JSON to an Express 5 API on App Runner, which reads and writes PostgreSQL on RDS and sends mail through SES using templates that the emails repository publishes at build time. Attachments upload straight to S3 with presigned requests and a Lambda processes them. Terraform manages all of the AWS resources.AWS eu-central-1, managed with TerraformBrowservisitor or team memberWeb appNext.js export on S3 + CloudFrontAPIExpress 5 on App RunnerEmails reporeact-email, synced at buildS3 attachmentspresigned uploadPostgreSQLRDS, Drizzle ORMSEStransactional emailLambdaimage post-processingloads appJSONuploadSQLsend by templateS3 eventtemplates
Solid lines are runtime traffic. Dashed lines happen at build or deploy time. The web app never runs a server: every dynamic thing goes through the API.

The web app is a static export. Next.js builds it to plain files that sit on S3 behind CloudFront. There is no server runtime, no route handlers and no middleware. Everything dynamic is a JSON call to the API, which keeps hosting cheap and the attack surface small.

The API is a plain Express 5 service on App Runner with strict one-way layering: routes call controllers, controllers call services, services call the database through Drizzle. Permission checks live in the service layer, so a route cannot forget them. Cron jobs inside the same process roll quotas over at new year and sweep expired attachments.

Emails are compiled, not rendered. The emails repository renders react-email components to HTML at build time and publishes them to SES as native templates. The API sends by template name with a handful of variables. A broken template fails the emails build, never a user's request.

Infrastructure is Terraform: App Runner, RDS, the VPC, S3, Secrets Manager, IAM and Route 53. Applying is a deliberate manual step, never something CI does on a green build.

The repositories

The workspace repository exists because three independent repos still need to be developed together. It holds the agent skills I use with Claude Code, a CLI that brings up the whole stack and seeds a realistic team, an MCP server exposing the same operations to agents, and the docs that only make sense across all three. The product repos are plain sibling clones inside it, on purpose: submodules pin a commit, and pinning is the opposite of what independently released repos want.

Tech list, by repository.

Web app

  • Next.js 16
  • React 19
  • TypeScript
  • Tailwind v4
  • shadcn/ui
  • TanStack Query
  • better-auth
  • Zod
  • Recharts
  • Paddle
  • S3 + CloudFront
  • Vitest
  • Sentry

Backend

  • Node 24
  • Express 5
  • Drizzle ORM
  • PostgreSQL
  • better-auth
  • Google OAuth
  • Microsoft OAuth
  • AWS SDK (SES, S3)
  • App Runner
  • RDS
  • Lambda
  • Terraform
  • croner
  • date-holidays
  • ExcelJS
  • sharp
  • Vitest
  • Sentry

Emails

  • react-email
  • React 19
  • AWS SES templates
  • tsx

Workspace

  • Node scripts
  • MCP server
  • Claude agent skills
  • GitHub Actions
  • CodeQL
  • actionlint
  • shellcheck
  • ESLint
  • Prettier

Where it stands.

Live, in production, and free for early adopters.

flexiday runs at flexi-day.com on the infrastructure described above. Billing is built on Paddle as merchant of record, with Free, Pro and Enterprise plans that differ in how many groups and people they hold. Early adopters use it free while the product settles.

I run it as a sole trader from Brno, Czech Republic. It started as the vacation spreadsheet I kept breaking, and it is now the tool I wanted.

What's next

  • Direct Google Calendar syncWriting approved leave straight into people's calendars, instead of the ICS feed they subscribe to today.
  • A mobile appA native app for the two things people do on a phone: book a day off, and approve somebody else's.
The public site at flexi-day.com, which is also where the product's own copy comes from.

What I learned.

  1. 01

    A static frontend is a feature

    Removing the server from the web app forced every piece of logic to live in one place, the API. It made the frontend trivially cheap to host and impossible to leak secrets from, and it made local development honest: if it works against localhost:8080, it works in production.

  2. 02

    Guards belong in the service layer

    Early on, permission checks lived in route handlers and drifted. Moving them next to the business logic meant a new endpoint could not skip them, and tests could exercise them without HTTP.

  3. 03

    Email is a build artefact

    Rendering templates at build time and syncing them to SES turned email from a runtime risk into a verified asset. Placeholder tokens are checked before anything is published, so a template cannot ship with a variable that never gets filled.

  4. 04

    Seed the whole story, not a row

    A dev command that creates a team, quotas and requests across pending, approved and rejected states made every screen testable in seconds. The screenshots on this page came out of that seed.

  5. 05

    Agents need a shared workspace

    Working across three repos with coding agents only became smooth once the skills, the dev CLI and the domain glossary lived in one place. The workspace repo is small, and it is where most of the leverage came from.