Bruno GalvánLima, Perú
Sección 1 de 7: Qué hago
Plataformas SaaS
Productos con organizaciones, agenda y facturación, del esquema a la pantalla.
Multi-tenant
APIs tipadas
REST contract-first en capas hexagonales, con eventos de dominio vía outbox.
Fastify · OpenAPI
Interfaces de producto
Calendarios, pantallas de facturación y editores con actualizaciones optimistas.
React 19 · TanStack Query
IA en el producto
Funciones con LLM donde ocurre el trabajo: documentos bilingües y un asistente de reservas.
OpenAI · WhatsApp
Sección 3 de 7: Stack
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
Dependencia en un solo sentido; el dominio no conoce el framework.
Límites explícitos
Ninguna abstracción para un futuro que no ha llegado.
El cambio más pequeño
Hecho es: verificaciones en verde y visto en producción.
Verificar antes de afirmar
Lo que el código no dice va a un registro fechado.
Decisiones por escrito
Sección 5 de 7: Código
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,
};
});
}---
// 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> 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);
});
}Sección 6 de 7: Proyectos
ClinicSay
SaaS de salud. Construyo facturación, agenda y documentos en la app React y la API en Fastify.
Código privadoVitalPro
Mi SaaS multi-tenant para negocios de servicios: monorepo Nx con Next.js y Fastify.
Código privadobrunogalvan.dev
Este sitio: Astro estático en dos idiomas, una CSP hecha de hashes y axe en ambos temas.
Placer Sano
Sitio de restaurante en Astro: carta, reservas y equipo.
Sección 7 de 7: Experiencia
— Hoy
ClinicSay
Desarrollador Full Stack Senior
SaaS de salud: facturación, agenda y documentos en React 19 y una API hexagonal en Fastify.
—
Métrica Andina
Analista Programador Senior
Microservicios, una integración REST con Salesforce y automatización de WhatsApp para clientes de educación.
—
Surtidores S.A.C.
Programador PHP
Lideré el nuevo ERP web en Laravel y mantuve en marcha el anterior, en PHP 5.
—
Platanitos
Programador Web · Analista SAP
Aplicaciones web para retail, facturación electrónica y reportes que cruzan SAP con SQL.