Lima, Perú

Desarrollador full stack senior en Lima, Perú. React, Node.js y PostgreSQL, del modelo de dominio a producción.

Límites claros, cambios pequeños, visto en producción.

Sección 1 de 7: Qué hago

Hablemos
  1. Plataformas SaaS

    Productos con organizaciones, agenda y facturación, del esquema a la pantalla.

    Multi-tenant

  2. APIs tipadas

    REST contract-first en capas hexagonales, con eventos de dominio vía outbox.

    Fastify · OpenAPI

  3. Interfaces de producto

    Calendarios, pantallas de facturación y editores con actualizaciones optimistas.

    React 19 · TanStack Query

  4. IA en el producto

    Funciones con LLM donde ocurre el trabajo: documentos bilingües y un asistente de reservas.

    OpenAI · WhatsApp

Sección 2 de 7: En cifras

Ver experiencia
años desde el primer rol
7
empresas
4
proyectos con código público
2
tecnologías en el stack
40
74 meses desde 2019, en 4 empresas.Cada barra es un mes; su altura, los meses en el rol.

Sección 3 de 7: Stack

Capa

Las herramientas

Cada herramienta aparece en mis propios commits de los últimos dos años.

Tipado de punta a punta

TypeScript estricto del esquema a la pantalla.

Probado tal como se publica

Vitest y Playwright sobre el build de producción.

Sección 4 de 7: Método

Ver este repositorio
  1. Dependencia en un solo sentido; el dominio no conoce el framework.

    Límites explícitos

  2. Ninguna abstracción para un futuro que no ha llegado.

    El cambio más pequeño

  3. Hecho es: verificaciones en verde y visto en producción.

    Verificar antes de afirmar

  4. Lo que el código no dice va a un registro fechado.

    Decisiones por escrito

Sección 5 de 7: Código

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);
    });
  }

Más en GitHub
Lenguaje

Sección 6 de 7: Proyectos

Todos los repositorios
  • VitalPro

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

    Mi SaaS multi-tenant para negocios de servicios: monorepo Nx con Next.js y Fastify.

    Producto propio
  • brunogalvan.dev

    • Astro
    • TypeScript
    • Tailwind CSS
    • GSAP
    • Playwright

    Este sitio: Astro estático en dos idiomas, una CSP hecha de hashes y axe en ambos temas.

    Proyecto
  • Placer Sano

    • Astro
    • CSS
    • Netlify

    Sitio de restaurante en Astro: carta, reservas y equipo.

    Proyecto

Sección 7 de 7: Experiencia

Trayectoria completa
  1. — Hoy

    ClinicSay

    Desarrollador Full Stack Senior

    SaaS de salud: facturación, agenda y documentos en React 19 y una API hexagonal en Fastify.

  2. Métrica Andina

    Analista Programador Senior

    Microservicios, una integración REST con Salesforce y automatización de WhatsApp para clientes de educación.

  3. Surtidores S.A.C.

    Programador PHP

    Lideré el nuevo ERP web en Laravel y mantuve en marcha el anterior, en PHP 5.

  4. Platanitos

    Programador Web · Analista SAP

    Aplicaciones web para retail, facturación electrónica y reportes que cruzan SAP con SQL.

Contacto

Enviar un correo