Building HabitMaker: an open-source habit tracker with Angular 22, NestJS 11, and Web Push reminders
HabitMaker is a small, open-source habit tracker I built to make progress feel gentler: no streak shaming, no perfectionism, just a calm place to notice that you showed up. It is a full-stack application in an Nx monorepo, with an Angular frontend, a NestJS API, a shared UI library, a marketing site, Docker support, and timezone-aware web push reminders.
- Live site: habitmaker.josesaranda.es
- Repository: github.com/josesaranda/HabitMaker
- License: MIT
In this post I walk through the architecture, a few implementation details I am proud of, and how you can run it yourself.
What HabitMaker does
The feature set grew organically, but the core loop is simple:
- Create habits with a name and an emoji.
- See today's habits on a home dashboard and mark them complete.
- Browse a per-habit calendar of completion history.
- Schedule an optional daily reminder per habit, in your own time zone.
- Sign up, log in, recover a password, and manage active device sessions.
- Switch the interface between English and Spanish.
- Admins can manage users, check email/push configuration, and look for new releases.
The project is intentionally self-hostable: clone it, set a few environment variables, and run docker compose up.
Workspace layout
HabitMaker is an Nx monorepo with npm workspaces. Nx keeps builds, tests, and linting consistent across projects.
. ├── apps/ │ ├── habit-maker-app/ # Angular web app (client) │ ├── habit-maker-api/ # NestJS API (server) │ └── habit-maker-landing/ # Astro marketing site ├── packages/ │ └── habit-maker-ui/ # Shared Angular UI component library ├── docker-compose.yml ├── nx.json └── package.json Keeping the UI in its own package (@habit-maker/habit-maker-ui) means the app and any future consumer share the same design tokens, components, and Storybook stories.
Tech stack
| Layer | Technology |
|---|---|
| Frontend | Angular 22, TypeScript 6, Tailwind CSS 4, RxJS 7 |
| Backend | NestJS 11, TypeScript, TypeORM |
| Data | PostgreSQL in production, in-memory sql.js by default for local dev |
| Monorepo | Nx 24, npm workspaces |
| i18n | Transloco (English + Spanish) |
| PWA | Angular service worker, custom preloading strategy |
| Push | Web Push API + VAPID keys |
| UI docs | Storybook 10 |
Frontend: signals, lazy routes, and a PWA that behaves like an app
The Angular app uses standalone components and signal-based state. Dynamic template values are signals or computed signals, read by calling them in templates, which keeps change detection predictable in a zoneless setup.
Routing is lazy by default:
export const appRoutes: Route[] = [ { path: 'home', loadComponent: () => import('./home/home').then((m) => m.Home), canActivate: [authGuard], resolve: { habitsResult: homeHabitsResolver }, }, { path: 'habits/:id', loadComponent: () => import('./habit-detail/habit-detail').then((m) => m.HabitDetail), canActivate: [authGuard], resolve: { habitResult: habitDetailResolver }, }, // ... ]; A custom PwaPreloadStrategy only preloads lazy chunks when the app is actually running as an installed PWA:
@Injectable({ providedIn: 'root' }) export class PwaPreloadStrategy implements PreloadingStrategy { preload(route: Route, load: () => Observable<unknown>): Observable<unknown> { if (isRunningAsStandalonePwa()) { return load().pipe(catchError(() => of(null))); } return of(null); } } export function isRunningAsStandalonePwa(): boolean { if (typeof window === 'undefined') return false; return ( window.matchMedia('(display-mode: standalone)').matches || (window.navigator as Navigator & { standalone?: boolean }).standalone === true ); } That gives installed users instant navigation and lets the service worker cache chunks for offline use, while ordinary browser visits keep lazy loading untouched. View transitions are enabled globally, with a small helper to skip transitions on query-param-only navigations.
Authentication state lives in a signal-backed service. Tokens are stored in localStorage for "remember me" sessions and sessionStorage otherwise, and the JWT is decoded client-side to read the user's email and role.
Backend: NestJS modules and session-aware auth
The API is organized by feature modules (AuthModule, HabitsModule, PushModule, AdminModule, VersionModule). TypeOrmModule.forRoot(getDatabaseConfig()) switches between PostgreSQL and sql.js based on environment variables, so local development needs no database.
Authentication uses JWT plus a server-side session table. On login/signup the API creates a session with a device name parsed from the user agent, an expiry (1 day by default, 30 days when "remember me" is checked), and a last-used timestamp. Users can list, revoke, or revoke-all-other sessions from settings.
export const SHORT_SESSION_DURATION_MS = 24 * 60 * 60 * 1000; export const REMEMBERED_SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; async create(userId, userAgent, remembered) { const now = Date.now(); const duration = remembered ? REMEMBERED_SESSION_DURATION_MS : SHORT_SESSION_DURATION_MS; return this.sessions.save( this.sessions.create({ id: randomUUID(), userId, deviceName: this.getDeviceName(userAgent), createdAt: String(now), lastUsedAt: String(now), expiresAt: String(now + duration), revokedAt: null, }), ); } Passwords are hashed with scrypt, password reset tokens are hashed before storage, and reset links invalidate all existing sessions for that user.
The part I enjoyed most: timezone-aware push reminders
Reminders are the trickiest feature. Each habit can have a daily reminder time and an IANA time zone. The backend runs a dispatcher every 15 seconds:
onModuleInit(): void { if (!this.isConfigured()) return; webpush.setVapidDetails(...); this.interval = setInterval(() => void this.dispatchDueReminders(), 15_000); this.interval.unref(); void this.dispatchDueReminders(); } For each habit with reminders enabled, it converts the current instant to the habit's local time zone using Intl.DateTimeFormat, checks whether the local HH:mm matches the reminder time, and skips habits already completed that day:
private getDueOccurrence(now, timeZone, reminderTime) { // Looking at the previous minute as well makes scheduling resilient to // process startup and timer drift; the unique delivery record deduplicates it. for (const instant of [now, new Date(now.getTime() - 60_000)]) { const local = this.getLocalDateTime(instant, timeZone); if (local.time === reminderTime) return local; } return null; } Idempotency is handled by a PushDeliveryEntity with a unique constraint on (habit, subscription, localDate). claimDelivery inserts a row and treats any insert failure as "already claimed":
private async claimDelivery(habit, subscription, localDate): Promise<boolean> { try { await this.deliveries.insert({ id: randomUUID(), habit, subscription, localDate }); return true; } catch { return false; } } That makes the dispatcher safe across restarts and across multiple API instances. Stale subscriptions (404/410) are deleted, messages are localized per subscription language, and the notification deep-links to the habit detail page.
Shared UI library
packages/habit-maker-ui is a BEM-styled Angular component library with design tokens, Tailwind-based styling, and colocated Storybook stories. Components include button, button-day, card, checkbox, day-selector, emoji-picker, header, icon, input, link, modal, navbar, option, select, and toggle. Every icon goes through a single hm-icon component, so iconography stays consistent and auditable.
Internationalization
The app and landing site support English and Spanish via Transloco. When the user changes language in settings, the push subscription's language is updated too, so notifications arrive in the right language.
Running it yourself
Docker (recommended):
git clone https://github.com/josesaranda/HabitMaker.git cd HabitMaker cp .env.example .env # edit .env: set JWT_SECRET, POSTGRES_PASSWORD, and optionally VAPID_* / SMTP_* / ADMIN_* docker compose up --build -d # open http://localhost:8080 Local CLI (in-memory database, data resets on restart):
npm install npm run start:api # http://localhost:3000/api npm run start:app # http://localhost:4200 Nx tasks:
npm exec nx test HabitMakerApp npm exec nx test HabitMakerUI npm exec nx build HabitMakerApp npx nx format:check --all Lessons learned
- Time zones are the hard part of reminders. Comparing instants in the user's zone and deduplicating by local date kept the feature correct without a job queue.
- PWA preloading should be conditional. Preloading everything in a normal browser tab wastes bandwidth; gating on standalone mode keeps both cases optimal.
- Keep auth tokens out of long-lived storage by default.
sessionStoragefor normal logins andlocalStorageonly for "remember me" reduces exposure. - A shared UI library pays off quickly. BEM tokens + Storybook made new screens consistent and reviewable.
Contributing
HabitMaker is MIT-licensed and built in the open. If you want to try it, report a bug, or add a feature, the repository and open issues are the best starting points. Contributions of any size are welcome.

