Endurance / Engineering table

Projects

OMSTA

ERP and mobile app for a travel agency

In daily production · mobile app in development

  • Python
  • Django
  • Django REST Framework
  • PostgreSQL
  • Redis
  • Django Q2
  • React Native
  • Expo
  • TypeScript
  • Railway
OMSTA dashboard with the month's bookings, amount collected, commission, balance due and operational alerts, on synthetic data
The agency's month at a glance, with alerts that lead to action.
Hotel booking record with 70% collected, a status ladder and the outstanding balance
A booking shows how much was sold, how much was collected and what's left.
Problem: A booking mixes sales status, payments and balance, and it has to be readable at a glance. Decision: A status ladder, percent collected and balance in the header, with sales and payments kept apart.
Payments tab of a booking with the supplier payment scheduled before check-in
The hotel payment date is worked out from the check-in date.
Problem: Paying the hotel late puts the client's reservation at risk. Decision: The supplier payment date is calculated from check-in and scheduled by due date.
Flight booking form with quick section navigation and a live price summary
Guided entry by section, with the price summarized live.
Problem: Six product types with different data end up as six forms that look nothing alike. Decision: One entry pattern for all of them, with section navigation and a live price summary.
Mobile app home with overdue payments, upcoming check-ins, balance due, amount collected and the month's commission
The agency's month, in your pocket.
App form to record a payment that stays pending review until accounting applies it
Collect away from the office; accounting reviews it afterwards.
Problem: Away from the office, a sales agent needs to collect without opening the web app or bypassing accounting. Decision: Collecting from the phone uses the same service as the web and stays "pending review" until accounting applies it.
Picker with six product types and a saved draft to continue
Six products, and drafts that never get lost.
Problem: Creating a booking on a phone takes a while, and a phone call can interrupt it halfway. Decision: A step-by-step wizard that saves every draft encrypted on the phone; you pick up where you left off.
Posted payment that can't be edited directly and offers receipts, a guided correction or a refund
A posted payment isn't edited: it's corrected or refunded.
Problem: Editing a posted payment would throw bank, entries and balances out of balance. Decision: A posted payment can't be edited; you manage receipts, make a guided correction or issue a refund.
Entry detail that explains its debit, credit and origin in plain language
Each entry explains in plain Spanish what it records and why.
Problem: For the people who sell, accounting is a black box. Decision: Each entry explains in plain language what it records, and the booking shows all of its entries.
Read-only accounting health check, with no closing blockers and nine of nine identities balanced
An internal auditor: accounting identities that have to balance.
Problem: An accounting error found at month-end arrives too late and costs too much. Decision: A read-only accounting health check, and a period close that locks if any identity doesn't balance.

Inspector20 decisions

Client

Django web

Server-rendered HTML, with no SPA and no build step, so critical logic and permissions live only in the backend.

Architecture22 modules · 31 connections

Server-rendered HTML, with no SPA and no build step, so critical logic and permissions live only in the backend.

Django Templates · Bootstrap 5.3 · jQuery 3.7 · Select2 · HTMX 2.0 · Chart.js 4.4

A second client that never calculates balances or statuses: it renders what the server describes.

React Native 0.86 · Expo SDK 57 · Expo Router · TypeScript 6.0 · TanStack Query 5 · react-native-mmkv · Expo SecureStore

Its own Django app with device-bound JWT and an OpenAPI contract that generates the app's TypeScript types.

Django REST Framework 3.16 · Simple JWT 5.5 · drf-spectacular 0.30 · openapi-typescript 7.13

Closed by default: mandatory login, effective roles (role plus modules) and URL-prefix rules enforced in middleware.

Django 5.2 · django-user-agents · django-crum

Eight product types in a domain that talks to accounting only through a neutral contract, guarded by boundary tests.

Django 5.2 · django-filter · django-select2 · django-countries

One recording service for web and mobile; every payment is born "pending review", with an idempotency key, and accounting applies it.

Django 5.2 · Django REST Framework · django-storages

The invoice posts an atomic package of entries so receivables never go into credit and advances never stay unreclassified.

Django 5.2 · openpyxl 3.1

A generic ledger layer that doesn't import accounting and blocks posting into closed periods.

Django 5.2 · PostgreSQL

ReportLab 4.4 · XlsxWriter 3.2 · Chart.js 4.4

An immutable tax snapshot at posting time; it generates the official TXT and the XLSX, and filing with the DGII stays in human hands.

Django 5.2 · openpyxl 3.1

One bank account per currency, with the exchange rate frozen on every payment in pesos or dollars.

Chart.js 4.4 · chartjs-plugin-zoom · openpyxl 3.1

Receipts rendered live; only the NCF invoice is immutable.

WeasyPrint 65 · ReportLab 4.4 · qrcode 8.0 · bleach 6.3

The single source of truth: everything accounting, auditable or durable lives here.

PostgreSQL · psycopg 3.3

Only the Django Q2 queue, cache and sessions; it can be lost without losing any business state.

Redis · redis-py 6.1 · hiredis

Receipts and documents in private storage with signed URLs, because they hold personal and financial data.

django-storages 1.14 · boto3 1.42

Every push to the main branch deploys production and the demo after lint, checks and pytest pass on GitHub Actions.

GitHub Actions · pytest 8.4 · Ruff · Black · Jest

Threaded Gunicorn, staggered worker recycling and a /health/ probe exempt from login and from the HTTPS redirect.

Railway · Railpack · Gunicorn 23 · WhiteNoise 6.12

Internal development builds tested on a real Android phone; the app stores wait for the business's own accounts.

EAS Build · Expo Dev Client · expo-doctor

Django Q2 instead of Celery; jobs are queued on transaction commit and are idempotent.

Django Q2 1.9 · Redis

Django email · SMTP

IP lookups cached for 24 hours (negative ones for one), so geolocation isn't paid for or waited on in every request.

IPinfo API · requests

Queried from the server, to locate hotels without exposing the key in the app.

Google Places API · Google Maps JavaScript API

Scope

Django apps in a modular monolith
19
domain models
150
screens in the mobile app
54

Design decisions

  1. Problem: A booking mixes sales status, payments and balance, and it has to be readable at a glance. Decision: A status ladder, percent collected and balance in the header, with sales and payments kept apart.
  2. Problem: Away from the office, a sales agent needs to collect without opening the web app or bypassing accounting. Decision: Collecting from the phone uses the same service as the web and stays "pending review" until accounting applies it.
  3. Problem: Editing a posted payment would throw bank, entries and balances out of balance. Decision: A posted payment can't be edited; you manage receipts, make a guided correction or issue a refund.
  4. Problem: For the people who sell, accounting is a black box. Decision: Each entry explains in plain language what it records, and the booking shows all of its entries.
  5. Problem: Paying the hotel late puts the client's reservation at risk. Decision: The supplier payment date is calculated from check-in and scheduled by due date.
  6. Problem: An accounting error found at month-end arrives too late and costs too much. Decision: A read-only accounting health check, and a period close that locks if any identity doesn't balance.
  7. Problem: Six product types with different data end up as six forms that look nothing alike. Decision: One entry pattern for all of them, with section navigation and a live price summary.
  8. Problem: Creating a booking on a phone takes a while, and a phone call can interrupt it halfway. Decision: A step-by-step wizard that saves every draft encrypted on the phone; you pick up where you left off.

System

Django web · Client
Server-rendered HTML, with no SPA and no build step, so critical logic and permissions live only in the backend.
Mobile app · Client
A second client that never calculates balances or statuses: it renders what the server describes.
Mobile API · Services
Its own Django app with device-bound JWT and an OpenAPI contract that generates the app's TypeScript types.
Security · Services
Closed by default: mandatory login, effective roles (role plus modules) and URL-prefix rules enforced in middleware.
Bookings and CRM · Services
Eight product types in a domain that talks to accounting only through a neutral contract, guarded by boundary tests.
Payments · Services
One recording service for web and mobile; every payment is born "pending review", with an idempotency key, and accounting applies it.
Accounting · Services
The invoice posts an atomic package of entries so receivables never go into credit and advances never stay unreclassified.
General ledger · Services
A generic ledger layer that doesn't import accounting and blocks posting into closed periods.
Payroll · Services
DGII tax · Services
An immutable tax snapshot at posting time; it generates the official TXT and the XLSX, and filing with the DGII stays in human hands.
Banking and FX · Services
One bank account per currency, with the exchange rate frozen on every payment in pesos or dollars.
Documents · Services
Receipts rendered live; only the NCF invoice is immutable.
PostgreSQL · Data
The single source of truth: everything accounting, auditable or durable lives here.
Redis · Data
Only the Django Q2 queue, cache and sessions; it can be lost without losing any business state.
S3 bucket · Data
Receipts and documents in private storage with signed URLs, because they hold personal and financial data.
GitHub CI · Infrastructure
Every push to the main branch deploys production and the demo after lint, checks and pytest pass on GitHub Actions.
Railway web · Infrastructure
Threaded Gunicorn, staggered worker recycling and a /health/ probe exempt from login and from the HTTPS redirect.
EAS Build · Infrastructure
Internal development builds tested on a real Android phone; the app stores wait for the business's own accounts.
Q2 worker · Infrastructure
Django Q2 instead of Celery; jobs are queued on transaction commit and are idempotent.
SMTP email · Integrations
IPinfo · Integrations
IP lookups cached for 24 hours (negative ones for one), so geolocation isn't paid for or waited on in every request.
Google Places · Integrations
Queried from the server, to locate hotels without exposing the key in the app.

Izak's Photos

Photography site and portfolio

Live on Railway · demo studio

  • React 18
  • Vite 8
  • React Router 7
  • Django 5.2
  • Django REST Framework
  • Railway
Izak's Photos home page with a warm-light portrait and buttons to view the gallery and book
The photo leads, and the two main actions are visible at first glance.
Problem: The site has to show off the work and, at the same time, lead to a booking. Decision: A full-bleed hero with two actions and “Reserve” pinned in the header on every page.
Wedding photo from the featured mosaic with the First Dance label on hover
Each featured photo shows its series and opens that image in the viewer.
Photo mosaic mixing portrait, wedding, editorial and travel across four columns
“All” interleaves the series so the mosaic doesn't cluster by type.
Problem: Forty-two photos in four series turn into a generic grid. Decision: A mosaic that interleaves the series, filters with counts and a full-screen, keyboard-ready viewer.
Travel series with portrait and landscape photos of stars, streets, flowers and landscapes
Portrait and landscape photos sit together because each one reserves its aspect ratio.
Problem: The gallery downloaded 9.3 MB of full JPEGs just to show small thumbnails. Decision: 720 px WebP thumbnails in the grids; the full JPEG only loads in the viewer.
Full-screen viewer with the Red Motion photo, its title and the 02 of 42 counter
Every photo has its own link that opens the viewer directly.
Problem: There was no way to share or link to a specific photo. Decision: The category and the open photo live in the URL; every photo has its own link.
Form with the email field marked in red and the message Enter a valid email address
If the API rejects a value, the exact field is marked and explained.
Problem: A generic error makes you guess what failed and type everything again. Decision: The API returns each field's error and the form marks it in place, without clearing what was typed.
Spanish home page with the headline Retratos en luz tranquila and the language switcher on ES
The whole site exists in English and Spanish, and remembers the choice.
Problem: A studio that works at home and abroad can't speak just one language. Decision: English and Spanish across the site in one click; the choice is remembered and updates the page lang.
Two-column mosaic on mobile with Stillness, Newsprint, Red Motion and Vows labeled
On touch screens each photo shows its location and title.
Problem: On the phone, titles never showed, because they only appeared on hover. Decision: On touch screens, the location and title stay visible over every photo.
Booking confirmation on mobile with the session summary and the saved message
After submitting, the page scrolls down to the confirmation on its own.
Problem: On the phone, the booking confirmation landed off-screen. Decision: After submitting, the page scrolls to the confirmation, which sums up what was sent.

Inspector17 decisions

Architecture17 modules · 19 connections

A full-bleed hero with a pause button and “Reserve” pinned in the header; featured photos open that image in the viewer.

React 18 · React Router 7

The mosaic interleaves the series and each photo reserves its aspect ratio; the category lives in the URL.

useSearchParams · Custom CSS

Keyboard, swipe and focus kept inside the dialog; the open photo is a link, and its thumbnail covers it while the JPEG arrives.

React 18 · useSearchParams

On touch, each photo's location and title are always visible: there's no hover to reveal them.

@media (hover: none) · Custom CSS

Photos are labeled as portfolio work, not as a portrait of anyone.

React 18

Per-field errors returned by the API and, after submitting, a summary the page scrolls to on the phone.

fetch · aria-invalid

A custom Context with t({ en, es }), no translation library; the language is remembered and updates the page lang.

React Context · localStorage

Validates and stores each request; ten per hour per client, and a honeypot field that accepts bots without saving them.

Django REST Framework 3 · DRF throttling

Filters, search and two actions: mark requests as handled or reopen them.

Django Admin

Railway only releases the new version if /api/health/ responds.

Django 5.2

SQLite locally and PostgreSQL with DATABASE_URL, without code changes; a single ten-field model for requests.

SQLite · PostgreSQL · dj-database-url

Shipped in the build: the full JPEG for the viewer and hero, and 720 px WebP thumbnails for the grids.

import.meta.glob · WebP

Stores only the chosen language: the site has no accounts and no tracking cookies.

localStorage

Railpack builds everything, migrates before release and restarts the service if the health check fails.

Railway · Railpack

A single process serves the Vite build, the API and the admin from the same origin: no CORS in production.

Gunicorn 23 · WhiteNoise 6.12

On every push and pull request: pending migrations, the 8 tests and the frontend build.

GitHub Actions

Fraunces and DM Sans: the only external runtime dependency.

Google Fonts

Scope

lighter gallery thumbnails
−74 %
photos in 4 series, each with its own link
42
languages, English and Spanish
2

Design decisions

  1. Problem: The site has to show off the work and, at the same time, lead to a booking. Decision: A full-bleed hero with two actions and “Reserve” pinned in the header on every page.
  2. Problem: Forty-two photos in four series turn into a generic grid. Decision: A mosaic that interleaves the series, filters with counts and a full-screen, keyboard-ready viewer.
  3. Problem: There was no way to share or link to a specific photo. Decision: The category and the open photo live in the URL; every photo has its own link.
  4. Problem: On the phone, titles never showed, because they only appeared on hover. Decision: On touch screens, the location and title stay visible over every photo.
  5. Problem: The gallery downloaded 9.3 MB of full JPEGs just to show small thumbnails. Decision: 720 px WebP thumbnails in the grids; the full JPEG only loads in the viewer.
  6. Problem: On the phone, the booking confirmation landed off-screen. Decision: After submitting, the page scrolls to the confirmation, which sums up what was sent.
  7. Problem: A generic error makes you guess what failed and type everything again. Decision: The API returns each field's error and the form marks it in place, without clearing what was typed.
  8. Problem: A studio that works at home and abroad can't speak just one language. Decision: English and Spanish across the site in one click; the choice is remembered and updates the page lang.

System

Home page · Client
A full-bleed hero with a pause button and “Reserve” pinned in the header; featured photos open that image in the viewer.
Gallery · Client
The mosaic interleaves the series and each photo reserves its aspect ratio; the category lives in the URL.
Viewer · Client
Keyboard, swipe and focus kept inside the dialog; the open photo is a link, and its thumbnail covers it while the JPEG arrives.
On the phone · Client
On touch, each photo's location and title are always visible: there's no hover to reveal them.
About · Client
Photos are labeled as portfolio work, not as a portrait of anyone.
Bookings · Client
Per-field errors returned by the API and, after submitting, a summary the page scrolls to on the phone.
Language · Client
A custom Context with t({ en, es }), no translation library; the language is remembered and updates the page lang.
Booking API · Services
Validates and stores each request; ten per hour per client, and a honeypot field that accepts bots without saving them.
Admin · Services
Filters, search and two actions: mark requests as handled or reopen them.
Health check · Services
Railway only releases the new version if /api/health/ responds.
Database · Data
SQLite locally and PostgreSQL with DATABASE_URL, without code changes; a single ten-field model for requests.
Photos · Data
Shipped in the build: the full JPEG for the viewer and hero, and 720 px WebP thumbnails for the grids.
localStorage · Data
Stores only the chosen language: the site has no accounts and no tracking cookies.
Railway · Infrastructure
Railpack builds everything, migrates before release and restarts the service if the health check fails.
Server · Infrastructure
A single process serves the Vite build, the API and the admin from the same origin: no CORS in production.
GitHub CI · Infrastructure
On every push and pull request: pending migrations, the 8 tests and the frontend build.
Google Fonts · Integrations
Fraunces and DM Sans: the only external runtime dependency.

Wikiverse

Full-stack web encyclopedia

In production on Railway · own domain

  • React 19
  • TypeScript
  • Django REST Framework
  • PostgreSQL
  • Redis
  • Railway
Entropy article with a side table of contents, a thermodynamics infobox and a red link
Every article combines a table of contents, an infobox, citations and red links to what's missing.
Problem: A link to an article that doesn't exist yet usually breaks or gets hidden. Decision: It shows in red, opens the editor with the title ready and turns blue once someone writes it.
Black hole article in the dark theme with the M87 image and its license credit
A complete dark theme; every Wikimedia image shows its author and license.
Preview card for the Light article over the Black hole article
Hovering over a link shows a summary, without leaving what you're reading.
Problem: Opening every link to see what it's about interrupts reading. Decision: On hover, a card shows the linked article's short description and excerpt.
Wikiverse search box with suggestions for mer — Mercury the planet, the element and the disambiguation page
Search suggests titles as you type and tolerates typos.
Problem: A search with a typo finds nothing, and the reader leaves. Decision: Suggestions as you type and trigram similarity as a second attempt; “entrpy” finds Entropy.
Search results for energy with highlighted snippets, a category filter and sorting
Full-text search in PostgreSQL, with ranking and highlighted matches.
Problem: A search index that the code has to remember to update eventually falls out of date. Decision: A PostgreSQL trigger recomputes the weighted vector of title, summary and body on every change.
Side-by-side comparison of two Marie Curie revisions with the Legacy section added
Any pair of revisions can be compared side by side, by line and by word.
Problem: Text that anyone can edit loses trust if you can't see who changed what. Decision: Every save is an immutable revision with author, summary and delta; any two can be diffed word by word.
Entropy article editor with the fields on the left and the preview on the right
The editor previews with the same renderer as the published article.
Problem: A preview with a different renderer ends up showing something other than what gets published. Decision: The editor previews with the same Markdown pipeline as the article.
Coral reef article on a phone with the lead image and the infobox
Articles read comfortably on the phone, with no side columns.
Problem: Contents, article and tools in three columns don't fit on a phone. Decision: Below 960 px the contents move into the header and the infobox stacks; nothing overflows at 390 px.
Coral reef revision comparison adapted to a phone screen
The version comparison collapses to one column on the phone.
Problem: Diffing a long article word by word in a single pass took tens of seconds. Decision: The diff compares lines first and only then words, inside the lines that changed.

Inspector18 decisions

Architecture18 modules · 17 connections

A featured article, trivia and anniversaries drawn from editable blocks; the main page is cached for a set time.

React 19 · TanStack Query 5

Below 960 px the contents move into the header and the infobox stacks; no screen overflows at 390 px.

Tailwind CSS 4.3

Markdown is rendered on the client with a custom plugin for wikilinks, footnotes and red links, with no raw HTML.

react-markdown 9 · remark-gfm 4 · Custom remark plugin

The query, filter and sort live in the URL: a search can be linked and the back button works.

React Router 7 · TanStack Query 5

Any two revisions can be compared side by side or in one column, with each change's byte delta.

React 19

Edits and talk messages in a single feed, with every filter in the URL and an RSS feed.

React Router 7

Loaded only when used, previews with the same renderer as the article and saves drafts without restoring them on its own.

React.lazy · localStorage

36 routes documented with OpenAPI; 30-minute JWT with a refresh token that rotates and is revoked on sign-out.

Django REST Framework 3.17 · SimpleJWT 5.5 · drf-spectacular 0.30

One immutable revision per save, with parent, author and delta; a save without changes creates none.

Django 5.2

websearch_to_tsquery with ranking, trigram when there are no results and snippets escaped before highlighting.

PostgreSQL full-text · pg_trgm

nginx sends only link-preview bots to Django, which answers with Open Graph and JSON-LD; people get the SPA.

Django 5.2 · Open Graph

Stats, main page, suggestions, diffs and rate limits: losing Redis costs performance, not data.

Redis · django-redis 5.4

63 articles in 21 modules validated against 31 rules; seeding twice produces exactly the same history.

Custom seed command

A trigger maintains the search vector with A, B and C weights; Postgres-only features are chosen at call time, not import time.

PostgreSQL 18 on Railway · tsvector + GIN

Frontend, backend, PostgreSQL and Redis as four services; it deploys on every push to main.

Railway · Docker

Serves the SPA, proxies /api and emits the same CSP as Django; index.html uncached and hashed assets cached forever.

nginx 1.27

608 tests, plus complete migrations, a warning-free OpenAPI schema and the corpus validated before merging.

GitHub Actions · pytest · Vitest · Playwright

Images only from Wikimedia Commons, with author and license; the allowlist is enforced on the server too.

Wikimedia Commons

Scope

tests in CI, from the backend to Playwright
608
articles written, with 428 references
63
API routes documented with OpenAPI
36

Design decisions

  1. Problem: Text that anyone can edit loses trust if you can't see who changed what. Decision: Every save is an immutable revision with author, summary and delta; any two can be diffed word by word.
  2. Problem: Diffing a long article word by word in a single pass took tens of seconds. Decision: The diff compares lines first and only then words, inside the lines that changed.
  3. Problem: A search index that the code has to remember to update eventually falls out of date. Decision: A PostgreSQL trigger recomputes the weighted vector of title, summary and body on every change.
  4. Problem: A search with a typo finds nothing, and the reader leaves. Decision: Suggestions as you type and trigram similarity as a second attempt; “entrpy” finds Entropy.
  5. Problem: Opening every link to see what it's about interrupts reading. Decision: On hover, a card shows the linked article's short description and excerpt.
  6. Problem: A link to an article that doesn't exist yet usually breaks or gets hidden. Decision: It shows in red, opens the editor with the title ready and turns blue once someone writes it.
  7. Problem: A preview with a different renderer ends up showing something other than what gets published. Decision: The editor previews with the same Markdown pipeline as the article.
  8. Problem: Contents, article and tools in three columns don't fit on a phone. Decision: Below 960 px the contents move into the header and the infobox stacks; nothing overflows at 390 px.

System

Main page · Client
A featured article, trivia and anniversaries drawn from editable blocks; the main page is cached for a set time.
On the phone · Client
Below 960 px the contents move into the header and the infobox stacks; no screen overflows at 390 px.
Article · Client
Markdown is rendered on the client with a custom plugin for wikilinks, footnotes and red links, with no raw HTML.
Search · Client
The query, filter and sort live in the URL: a search can be linked and the back button works.
History · Client
Any two revisions can be compared side by side or in one column, with each change's byte delta.
Changes · Client
Edits and talk messages in a single feed, with every filter in the URL and an RSS feed.
Editor · Client
Loaded only when used, previews with the same renderer as the article and saves drafts without restoring them on its own.
REST API · Services
36 routes documented with OpenAPI; 30-minute JWT with a refresh token that rotates and is revoked on sign-out.
Revisions · Services
One immutable revision per save, with parent, author and delta; a save without changes creates none.
Search engine · Services
websearch_to_tsquery with ranking, trigram when there are no results and snippets escaped before highlighting.
Bot view · Services
nginx sends only link-preview bots to Django, which answers with Open Graph and JSON-LD; people get the SPA.
Redis · Data
Stats, main page, suggestions, diffs and rate limits: losing Redis costs performance, not data.
Corpus · Data
63 articles in 21 modules validated against 31 rules; seeding twice produces exactly the same history.
PostgreSQL · Data
A trigger maintains the search vector with A, B and C weights; Postgres-only features are chosen at call time, not import time.
Railway · Infrastructure
Frontend, backend, PostgreSQL and Redis as four services; it deploys on every push to main.
nginx · Infrastructure
Serves the SPA, proxies /api and emits the same CSP as Django; index.html uncached and hashed assets cached forever.
GitHub CI · Infrastructure
608 tests, plus complete migrations, a warning-free OpenAPI schema and the corpus validated before merging.
Commons · Integrations
Images only from Wikimedia Commons, with author and license; the allowlist is enforced on the server too.

Network 3.0

Full-stack social network

Deployed on Railway · public demo

  • React
  • TypeScript
  • Vite
  • Tailwind CSS
  • TanStack Query
  • Django
  • Django REST Framework
  • PostgreSQL
  • Redis
  • Docker
Network feed with the composer, the For you and Following tabs, posts, who-to-follow suggestions and trending hashtags
Post, talk and discover people in a single three-column view.
Problem: What you see on a social network depends on who you follow, and who to follow doesn't find itself. Decision: Posting, talking and discovering people share one three-column view, without leaving the feed.
Post detail with its image, its actions and the comment thread
The detail view brings the post and its whole conversation together.
Comment thread with one comment highlighted by a ring after arriving from a notification
The notification takes you to the exact comment and highlights it.
Problem: A comment notification that only opens the post makes you hunt for the comment in the thread. Decision: The link goes to the exact comment; the page scrolls to it and highlights it with a ring.
Feed with three skeleton cards while posts load
While it loads, the skeleton takes the card's exact place.
Problem: A centered spinner doesn't say what's coming, and the page jumps when the content arrives. Decision: Skeletons with the exact geometry of the real card; the space is already sized and nothing moves.
Notification center with mentions, quotes, likes, comments, reposts and replies, each with its own icon and color
Each kind of notification with its own icon, color and sentence.
Problem: Eight kinds of activity in a single list read as an undifferentiated wall. Decision: Each kind has its icon, color and sentence; unread ones are marked and every row leads to its source.
Delete account dialog that lists what gets removed and asks for the password
Deleting the account lists what you'll lose and asks for your password.
Problem: "Are you sure?" says nothing; people confirm without knowing what they'll lose. Decision: The dialog names what gets deleted and, when there's no going back, asks for the password.
Empty Following feed on a new account, with an explanation and a link to find people
The empty state explains what goes here and links to the action that fills it.
Problem: A screen that only says "nothing here" looks like a half-finished feature. Decision: Each empty state explains what goes there and offers the action that fills it, like finding people.
Settings in dark mode with the Dark option selected
Light, dark or system, applied before the first paint.
Problem: A dark mode that flashes white on load, or ignores the system setting, feels broken. Decision: Light, dark or system; the theme is applied before React mounts, with no flash.
Feed on a phone with the composer, the tabs and the bottom navigation bar
One column and a bottom bar with four destinations.
Problem: A phone has no room for three columns or six navigation destinations. Decision: One column and a four-destination bottom bar; bookmarks and settings move into the account menu.

Inspector27 decisions

Architecture27 modules · 36 connections

Three columns: post, talk and discover people without leaving a feed that loads more on its own when you reach the end.

React 19 · IntersectionObserver

One level of replies: enough to hold a conversation without threads that get lost in depth.

React 19 · React Router 7

Posts, Media and Likes tabs; followers and following are modals with their own URL.

React 19 · React Router 7

Each verb with its icon, color and sentence, and every row leads to its exact source.

React 19 · lucide-react

One box for people, text and hashtags, with a / shortcut; typing a # jumps to the posts.

React Router 7 · 350 ms debounce

Signing up signs you in, and the sign-in screen offers one-click demo accounts.

Zustand 5 · React 19

One column and a four-destination bottom bar; bookmarks and settings go to the account menu.

Tailwind CSS 4 · Installable manifest

An optimistic like shows up in the feed, the detail and the profile at once, written in a single place.

TanStack Query 5 · Zustand 5

One shared refresh promise: ten requests with an expired token trigger one refresh, not ten.

Axios 1.20

Versioned under /api/v1/: 28 routes and 37 operations, with the OpenAPI schema generated from the code itself.

Django REST Framework 3.17 · django-filter · django-cors-headers

Rotated refresh tokens with a blocklist and 10 attempts per minute per IP; changing the password signs out every other session.

SimpleJWT 5.5 · Token blocklist

A cursor on date and id: posting something new doesn't repeat the next page; counters are subqueries, not JOINs.

DRF CursorPagination · Subquery and Exists

Repost and quote are the same model; a conditional unique constraint stops you from reposting the same thing twice.

Django 6.0 · UniqueConstraint

Every upload is validated, rotated by EXIF, stripped of metadata and rewritten as WebP.

Pillow 12.3

Following yourself is forbidden by the database, not just the view.

Django 6.0 · CheckConstraint

Its own app with eight verbs; undoing a like, a follow or a repost deletes its notification.

Django 6.0

Extracted on save; trends count the last seven days and are cached for five minutes.

Regular expressions · Django cache

Eight models with indexes written for the queries that actually exist, like the unread notifications one.

PostgreSQL 17 · psycopg 3.3 · dj-database-url

Trends and suggestions only: if it goes down, the app slows down, it doesn't break.

Redis 8 · django-redis 7.0

Local disk by default; an S3-compatible bucket just by setting the variables.

django-storages 1.14

Beyond lint, types and tests, it validates migrations, check --deploy and the build of both images.

GitHub Actions · Ruff · ESLint · Vitest

Two multi-stage images that honor $PORT: the same one runs locally and on Railway.

Docker · Docker Compose

Both services, configured as code; the health check only fails if the database goes down.

Railway · railway.json

Serves the app, applies the CSP and security headers, and returns index.html for client-side routes.

nginx · Content-Security-Policy

WSGI: there's nothing real-time that would justify ASGI.

Gunicorn 26 · WhiteNoise 6.12

The schema comes from the code: the documentation can't go stale.

drf-spectacular 0.29 · Swagger UI · ReDoc

Built in but off unless SENTRY_DSN is set; it doesn't force a dependency on an external service.

Sentry SDK 2.70

Scope

automated tests, backend and frontend
132
API operations documented in OpenAPI
37
notification types, each with its target
8

Design decisions

  1. Problem: What you see on a social network depends on who you follow, and who to follow doesn't find itself. Decision: Posting, talking and discovering people share one three-column view, without leaving the feed.
  2. Problem: A phone has no room for three columns or six navigation destinations. Decision: One column and a four-destination bottom bar; bookmarks and settings move into the account menu.
  3. Problem: A centered spinner doesn't say what's coming, and the page jumps when the content arrives. Decision: Skeletons with the exact geometry of the real card; the space is already sized and nothing moves.
  4. Problem: A screen that only says "nothing here" looks like a half-finished feature. Decision: Each empty state explains what goes there and offers the action that fills it, like finding people.
  5. Problem: Eight kinds of activity in a single list read as an undifferentiated wall. Decision: Each kind has its icon, color and sentence; unread ones are marked and every row leads to its source.
  6. Problem: A comment notification that only opens the post makes you hunt for the comment in the thread. Decision: The link goes to the exact comment; the page scrolls to it and highlights it with a ring.
  7. Problem: "Are you sure?" says nothing; people confirm without knowing what they'll lose. Decision: The dialog names what gets deleted and, when there's no going back, asks for the password.
  8. Problem: A dark mode that flashes white on load, or ignores the system setting, feels broken. Decision: Light, dark or system; the theme is applied before React mounts, with no flash.

System

Feed · Client
Three columns: post, talk and discover people without leaving a feed that loads more on its own when you reach the end.
Post · Client
One level of replies: enough to hold a conversation without threads that get lost in depth.
Profiles · Client
Posts, Media and Likes tabs; followers and following are modals with their own URL.
Notification center · Client
Each verb with its icon, color and sentence, and every row leads to its exact source.
Search · Client
One box for people, text and hashtags, with a / shortcut; typing a # jumps to the posts.
Account · Client
Signing up signs you in, and the sign-in screen offers one-click demo accounts.
On the phone · Client
One column and a four-destination bottom bar; bookmarks and settings go to the account menu.
State and cache · Client
An optimistic like shows up in the feed, the detail and the profile at once, written in a single place.
HTTP client · Client
One shared refresh promise: ten requests with an expired token trigger one refresh, not ten.
REST API v1 · Services
Versioned under /api/v1/: 28 routes and 37 operations, with the OpenAPI schema generated from the code itself.
JWT auth · Services
Rotated refresh tokens with a blocklist and 10 attempts per minute per IP; changing the password signs out every other session.
Cursor feed · Services
A cursor on date and id: posting something new doesn't repeat the next page; counters are subqueries, not JOINs.
Content · Services
Repost and quote are the same model; a conditional unique constraint stops you from reposting the same thing twice.
Images · Services
Every upload is validated, rotated by EXIF, stripped of metadata and rewritten as WebP.
Social graph · Services
Following yourself is forbidden by the database, not just the view.
Notifications · Services
Its own app with eight verbs; undoing a like, a follow or a repost deletes its notification.
Hashtags · Services
Extracted on save; trends count the last seven days and are cached for five minutes.
PostgreSQL · Data
Eight models with indexes written for the queries that actually exist, like the unread notifications one.
Redis · Data
Trends and suggestions only: if it goes down, the app slows down, it doesn't break.
Files · Data
Local disk by default; an S3-compatible bucket just by setting the variables.
GitHub CI · Infrastructure
Beyond lint, types and tests, it validates migrations, check --deploy and the build of both images.
Docker · Infrastructure
Two multi-stage images that honor $PORT: the same one runs locally and on Railway.
Railway · Infrastructure
Both services, configured as code; the health check only fails if the database goes down.
nginx · Infrastructure
Serves the app, applies the CSP and security headers, and returns index.html for client-side routes.
Gunicorn · Infrastructure
WSGI: there's nothing real-time that would justify ASGI.
OpenAPI · Integrations
The schema comes from the code: the documentation can't go stale.
Sentry · Integrations
Built in but off unless SENTRY_DSN is set; it doesn't force a dependency on an external service.

Delicaté 4.0

E-commerce and brand site

Live on its own domain · commercial launch in validation

  • React 19
  • Vite 8
  • Django 5.2
  • Django REST Framework
  • PostgreSQL
  • Docker
  • Railway
Delicaté home page with the headline “Cuidado hecho con amor” (Care made with love) and handmade soaps with coconut, orange and oats
The home page sells the brand before the product, with a single main action.
Problem: The price of a handmade soap is carried by the brand, not by a product list. Decision: The home page presents the brand before the store, with a single main action.
Our story section with a photo of soaps being cut by hand and the brand's text
The brand story backs up the price of something handmade.
Cacao & Almendras product view with price, stock, benefit, skin type and ingredients
The product view covers benefit, skin, ingredients and stock without leaving the catalog.
Problem: Before buying, customers ask about benefits, skin type and ingredients. Decision: The product view answers in a native dialog, without leaving the catalog.
Catalog filtered by Gifts with Corazón de Lavanda and Flor de Ámbar marked as sold out
An out-of-stock product stays visible, but can't be added.
Problem: If a sold-out soap disappears, whoever was looking for it can't tell if it'll be back. Decision: It stays visible as “Sold out” with buying disabled, on the card and in the product view.
Cart with Avena Calma, Corazón de Lavanda and Café Despierto and an estimated total of RD$1,575
The cart builds the order and makes clear the site doesn't charge.
Problem: A checkout that can't take payment is a broken promise. Decision: The cart builds the order, makes clear it doesn't charge and closes it on WhatsApp.
Cart with a price and availability update notice and Avena Calma capped at 12 units
When you come back, the cart updates to today's prices and stock.
Problem: A cart saved days ago can send old prices or quantities that are out of stock. Decision: When you come back, the cart updates against the real catalog and says what changed.
Catalog unavailable message with a Try again button
If the API fails, it offers a retry, never a made-up catalog.
Problem: If the API fails, showing made-up products misleads the buyer. Decision: Production shows an error with a retry; the demo catalog only exists in development.
Django admin with ten products and editable price, stock, featured and active fields
Price, stock and visibility change right from the list.
Problem: The business had to change its catalog without touching code or rebuilding. Decision: Price, stock, featured and visibility are edited right in the admin list.
Cart on a phone with three products and the green Finish on WhatsApp button
The order goes out over WhatsApp from the same phone.
Arcilla & Avena card on mobile with the Botanicals filter active and the Add button visible
On touch screens, “Add” is always visible and filters scroll sideways.
Problem: On a touch screen there's no hover to reveal the buy button. Decision: On touch, “Add” is always visible and the filters scroll in a single row.

Inspector18 decisions

Architecture18 modules · 19 connections

Brand before store: a single main action leads to the collection.

React 19 · Custom CSS

Read in full from the API, following every page, and filtered in the browser; filters declare their state with aria-pressed.

fetch with AbortController · React 19

A native dialog answers benefit, skin, ingredients and stock without leaving the catalog.

Native <dialog> · React 19

No account. On return it's checked against the catalog: it takes the current price, caps each quantity at the stock and says what changed.

useReducer · Custom useCart hook

Built for the phone: on touch, “Add” is always visible and the cart takes the whole screen.

@media (hover: none) · Custom CSS

The form stores nothing on the server: it turns the question into a WhatsApp message.

React 19

Public and read-only: a paginated, filterable list and a product view by slug. The catalog is only written from the admin.

Django REST Framework 3.17 · Pages of up to 100

Price, stock, featured and visibility are edited in the list itself; photos are validated (JPG, PNG or WebP, up to 5 MB).

Django Admin · Pillow 12.3

CSP and the other headers on every response, one-year HSTS and rate limits that count the real IP behind the proxy.

Custom middleware · DRF throttling

The cart lives in the browser; the server never stores orders or buyer data.

localStorage

Four models, down from eight in 2024: products, messages, subscriptions and the team user.

PostgreSQL · psycopg 3.3 · dj-database-url 3.1

Uploaded photos live on a persistent volume; Django won't start if its path is relative.

Railway volume · 7-day cache

On every push: Django check, pending migrations, the 18 tests, lint and build.

GitHub Actions

A multi-stage image: Node builds the store and Python serves it, with DEBUG off by default.

node:24-alpine · python:3.13-slim

Migrates before release and only sends traffic to the new version if the health check, which queries the database, returns 200.

Railway · railway.json

A single process serves the store, API, admin and photos from the same origin: no CORS.

Gunicorn 23 · WhiteNoise 6.12

The order goes out as a pre-written wa.me message, on the channel where the business already confirms, collects and delivers.

wa.me link

When the link is shared, a 1200×630 image and the canonical URL come from the build configuration.

Open Graph · Twitter Card

Scope

automated tests, run in CI on every push
18
accounts needed to buy
0
of JavaScript in production, gzipped
68 KB

Design decisions

  1. Problem: The price of a handmade soap is carried by the brand, not by a product list. Decision: The home page presents the brand before the store, with a single main action.
  2. Problem: Before buying, customers ask about benefits, skin type and ingredients. Decision: The product view answers in a native dialog, without leaving the catalog.
  3. Problem: A checkout that can't take payment is a broken promise. Decision: The cart builds the order, makes clear it doesn't charge and closes it on WhatsApp.
  4. Problem: A cart saved days ago can send old prices or quantities that are out of stock. Decision: When you come back, the cart updates against the real catalog and says what changed.
  5. Problem: On a touch screen there's no hover to reveal the buy button. Decision: On touch, “Add” is always visible and the filters scroll in a single row.
  6. Problem: If a sold-out soap disappears, whoever was looking for it can't tell if it'll be back. Decision: It stays visible as “Sold out” with buying disabled, on the card and in the product view.
  7. Problem: If the API fails, showing made-up products misleads the buyer. Decision: Production shows an error with a retry; the demo catalog only exists in development.
  8. Problem: The business had to change its catalog without touching code or rebuilding. Decision: Price, stock, featured and visibility are edited right in the admin list.

System

Home page · Client
Brand before store: a single main action leads to the collection.
Catalog · Client
Read in full from the API, following every page, and filtered in the browser; filters declare their state with aria-pressed.
Product view · Client
A native dialog answers benefit, skin, ingredients and stock without leaving the catalog.
Cart · Client
No account. On return it's checked against the catalog: it takes the current price, caps each quantity at the stock and says what changed.
On the phone · Client
Built for the phone: on touch, “Add” is always visible and the cart takes the whole screen.
Contact · Client
The form stores nothing on the server: it turns the question into a WhatsApp message.
Catalog API · Services
Public and read-only: a paginated, filterable list and a product view by slug. The catalog is only written from the admin.
Admin · Services
Price, stock, featured and visibility are edited in the list itself; photos are validated (JPG, PNG or WebP, up to 5 MB).
Security · Services
CSP and the other headers on every response, one-year HSTS and rate limits that count the real IP behind the proxy.
localStorage · Data
The cart lives in the browser; the server never stores orders or buyer data.
PostgreSQL · Data
Four models, down from eight in 2024: products, messages, subscriptions and the team user.
Photos · Data
Uploaded photos live on a persistent volume; Django won't start if its path is relative.
GitHub CI · Infrastructure
On every push: Django check, pending migrations, the 18 tests, lint and build.
Docker · Infrastructure
A multi-stage image: Node builds the store and Python serves it, with DEBUG off by default.
Railway · Infrastructure
Migrates before release and only sends traffic to the new version if the health check, which queries the database, returns 200.
Server · Infrastructure
A single process serves the store, API, admin and photos from the same origin: no CORS.
WhatsApp · Integrations
The order goes out as a pre-written wa.me message, on the channel where the business already confirms, collects and delivers.
Link preview · Integrations
When the link is shared, a 1200×630 image and the canonical URL come from the build configuration.

Each mission explains the problem it solved, how it was built and where it actually stands today.

I've designed and built business systems, web applications and digital products. This is the work that best shows how I bring strategy, engineering and design together, with states and evidence you can verify.

Projects in production
5
For real clients
2
Full case studies
5