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










Client
Django web
Server-rendered HTML, with no SPA and no build step, so critical logic and permissions live only in the backend.
- Technologies
- Django Templates · Bootstrap 5.3 · jQuery 3.7 · Select2 · HTMX 2.0 · Chart.js 4.4
- Sends to
- Railway web
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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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









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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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









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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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









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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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










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
- 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.
- Problem: Before buying, customers ask about benefits, skin type and ingredients. Decision: The product view answers in a native dialog, without leaving the catalog.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
