Lima, Peru

Senior full stack developer in Lima, Peru. React, Node.js and PostgreSQL, from the domain model to production.

Clear boundaries, small changes, proven in production.

Section 1 of 7: What I do

Let's talk
  1. SaaS platforms

    Products with organizations, scheduling and billing, from the schema to the screen.

    Multi-tenant

  2. Typed APIs

    Contract-first REST in hexagonal layers, with domain events through an outbox.

    Fastify · OpenAPI

  3. Product interfaces

    Calendars, billing screens and editors with optimistic updates.

    React 19 · TanStack Query

  4. AI in the product

    LLM features where the work happens: bilingual documents and a booking assistant.

    OpenAI · WhatsApp

Section 2 of 7: By the numbers

See experience
years since the first role
7
companies
4
projects with public code
2
technologies in the stack
40
74 months since 2019, across 4 companies.Each bar is a month; its height, months into the role.

Section 3 of 7: Stack

Layer

The toolchain

Every tool here appears in my own commits from the last two years.

Typed end to end

Strict TypeScript from the schema to the screen.

Tested as shipped

Vitest and Playwright against the production build.

Section 4 of 7: Method

See this repository
  1. Dependency runs one way; the domain knows nothing of the framework.

    Explicit boundaries

  2. No abstraction for a future that has not arrived.

    The smallest change

  3. Done means the gates are green and it was seen in production.

    Verify before claiming

  4. What the code cannot say goes into a dated record.

    Decisions in writing

Section 5 of 7: Code

brunogalvan.dev / figures.ts
import { roles, type Month } from './experience';
import { projects } from './projects';
import { stack } from './stack';

// Every figure the site states is computed here from the record, at build
// time, so a number can never contradict the roles and projects under it.
const toMonths = (month: Month) => {
  const [year, index] = month.split('-').map(Number);
  return (year ?? 0) * 12 + (index ?? 1) - 1;
};
const monthOf = (date: Date): Month =>
  `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}` as Month;

// How long a role lasted, in whole months, counting its first and last month;
// an open role runs to the current month.
export function roleMonths(
  role: { start: Month; end: Month | null },
  today: Date,
) {
  const end = role.end ? toMonths(role.end) : toMonths(monthOf(today));
  return end - toMonths(role.start) + 1;
}

export function figures(today: Date) {
  const now = toMonths(monthOf(today));
  const first = Math.min(...roles.map((role) => toMonths(role.start)));
  return {
    yearsSinceFirstRole: Math.floor((now - first) / 12),
    firstYear: Math.floor(first / 12),
    companies: new Set(roles.map((role) => role.company)).size,
    projects: projects.length,
    publicProjects: projects.filter((project) => project.repository).length,
    technologies: Object.values(stack).flat().length,
  };
}

// One entry per month from the first role to today: the role held that month
// and how many months into it, or null for a month between roles.
export function career(today: Date) {
  const now = toMonths(monthOf(today));
  const first = Math.min(...roles.map((role) => toMonths(role.start)));
  return Array.from({ length: now - first + 1 }, (_, offset) => {
    const month = first + offset;
    const role = roles.find(
      (candidate) =>
        toMonths(candidate.start) <= month &&
        month <= (candidate.end ? toMonths(candidate.end) : now),
    );
    return {
      year: Math.floor(month / 12),
      month: month % 12,
      role: role?.id ?? null,
      tenure: role ? month - toMonths(role.start) + 1 : 0,
    };
  });
}
brunogalvan.dev / headline.astro
---
// The section statement: two short sentences between decorative slashes. The
// slashes are generated content with an empty text alternative, so they are
// neither read aloud nor measured as text. The name lives on the heading and
// the visual copy is hidden, so the per-character reveal never reaches
// assistive technology and the phrase reads whole.
interface Props {
  lines: readonly string[];
  id?: string;
  level?: 'h1' | 'h2' | 'h3';
  size?: 'headline' | 'display';
}
const { lines, id, level: Tag = 'h2', size = 'headline' } = Astro.props;
---

<Tag
  class:list={['headline', `text-${size}`]}
  id={id}
  aria-label={lines.join(' ')}
>
  <span aria-hidden="true" data-reveal>
    {lines.map((line, index) => (
      <>
        {index > 0 && <br />}
        {index < lines.length - 1 ? `${line} ` : line}
      </>
    ))}
  </span>
</Tag>

<style>
  .headline {
    margin: 0;
    text-wrap: balance;
  }
  .headline::before,
  .headline::after {
    color: var(--decor);
    font-family: var(--font-pixel);
  }
  .headline::before {
    content: '/\00a0' / '';
  }
  .headline::after {
    content: '\00a0/' / '';
  }
</style>
brunogalvan.dev / foundation.spec.ts · L91–110
  for (const colorScheme of ['light', 'dark'] as const) {
    test(`${locale}: accessible ${colorScheme} page without overflow`, async ({
      page,
    }) => {
      await page.emulateMedia({ colorScheme, reducedMotion: 'reduce' });
      await page.goto(`/${locale}/`);
      await expect(
        page.getByRole('button', { name: messages(locale).theme.darkMode }),
      ).toHaveAttribute('aria-pressed', String(colorScheme === 'dark'));
      const results = await new AxeBuilder({ page })
        .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
        .analyze();
      expect(results.violations).toEqual([]);
      expect(
        await page.evaluate(
          () => document.documentElement.scrollWidth <= window.innerWidth,
        ),
      ).toBe(true);
    });
  }

More on GitHub
Language

Section 6 of 7: Projects

All repositories
  • VitalPro

    • Next.js
    • Fastify
    • Better Auth
    • Prisma
    • PostgreSQL
    • Nx
    • Fly.io

    My own multi-tenant SaaS for service businesses: an Nx monorepo with Next.js and Fastify.

    Own product
  • brunogalvan.dev

    • Astro
    • TypeScript
    • Tailwind CSS
    • GSAP
    • Playwright

    This site: static Astro in two languages, a CSP built from hashes, and axe in both themes.

    Project
  • Placer Sano

    • Astro
    • CSS
    • Netlify

    A restaurant site in Astro: menu, reservations and team.

    Project

Section 7 of 7: Experience

Full record
  1. — Present

    ClinicSay

    Senior Full Stack Developer

    Healthcare SaaS: billing, scheduling and documents across React 19 and a hexagonal Fastify API.

  2. Métrica Andina

    Senior Analyst Programmer

    Microservices, a Salesforce REST integration and WhatsApp automation for education clients.

  3. Surtidores S.A.C.

    PHP Programmer

    Led the new web ERP in Laravel; kept the previous one, on PHP 5, running.

  4. Platanitos

    Web Programmer · SAP Analyst

    Web applications for retail, electronic invoicing, and reports joining SAP with SQL.

Contact

Send an email