feat(fullstack): implement observability and redesign frontend UI
Build & Push Docker Images / test-backend (push) Failing after 36s
Build & Push Docker Images / test-frontend (push) Successful in 29s
Build & Push Docker Images / build-backend (push) Has been skipped
Build & Push Docker Images / build-frontend (push) Has been skipped
Build & Push Docker Images / deploy (push) Successful in 7s

This commit introduces comprehensive observability for both backend and frontend, alongside a major UI/UX overhaul to align with modern design standards (Material 3 inspired) and improve localization.

Backend changes:
- Integrated OpenTelemetry SDK for distributed tracing.
- Added Pino for structured JSON logging with OTel instrumentation for trace/span correlation.
- Configured OTLP exporters to route traces and logs to Grafana Alloy.
- Updated docker-compose to include necessary environment variables for observability.

Frontend changes:
- Integrated Grafana Faro for Real User Monitoring (RUM), capturing Web Vitals, JS errors, and user interactions.
- Redesigned the entire UI using a new color palette and Material 3 design principles.
- Refactored component architecture (App, Home, Search, Scanner, Profile, Alerts views) for better state management and navigation.
- Improved mobile UX with a redesigned Bottom Navigation bar and Top Bar.
- Localized the interface to Spanish (es).
- Updated assets, icons, and PWA configuration (manifest, icons).
- Refactored CSS to use a centralized design token system (CSS variables).

Observability enables better debugging and performance monitoring across the entire stack.
This commit is contained in:
Antoni Nuñez Romeu
2026-07-01 11:48:27 +02:00
parent 9316f6c54f
commit db0935eb16
46 changed files with 5633 additions and 1867 deletions
+86 -72
View File
@@ -39,13 +39,13 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout }) {
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `Save failed (HTTP ${res.status})`);
throw new Error(err.error || `Error al guardar (HTTP ${res.status})`);
}
const updated = await res.json();
onProfileSaved?.(updated);
setFeedback({ type: 'ok', text: 'Profile saved.' });
setFeedback({ type: 'ok', text: 'Perfil guardado.' });
} catch (err) {
setFeedback({ type: 'err', text: err.message || 'Save failed' });
setFeedback({ type: 'err', text: err.message || 'Error al guardar' });
} finally {
setSaving(false);
}
@@ -58,13 +58,13 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout }) {
const pos = await getUserPosition();
setLatitude(String(pos.lat));
setLongitude(String(pos.lon));
setFeedback({ type: 'ok', text: 'Got your current location — remember to Save.' });
setFeedback({ type: 'ok', text: 'Ubicación obtenida — recuerda Guardar.' });
} catch (err) {
let msg = err && err.message ? err.message : 'Could not get location';
let msg = 'No se pudo obtener la ubicación';
if (err && typeof err.code === 'number') {
if (err.code === 1) msg = 'Location permission denied — allow it in your browser settings.';
else if (err.code === 2) msg = 'Location unavailable — check OS location services.';
else if (err.code === 3) msg = 'Location request timed out — try again.';
if (err.code === 1) msg = 'Permiso de ubicación denegado';
else if (err.code === 2) msg = 'Ubicación no disponible';
else if (err.code === 3) msg = 'La solicitud expiró';
}
setFeedback({ type: 'err', text: msg });
} finally {
@@ -75,7 +75,7 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout }) {
async function handleGeocode() {
const q = address.trim();
if (!q) {
setFeedback({ type: 'err', text: 'Enter an address first.' });
setFeedback({ type: 'err', text: 'Ingresa una dirección primero.' });
return;
}
setGeocoding(true);
@@ -86,61 +86,99 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout }) {
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `Lookup failed (HTTP ${res.status})`);
throw new Error(err.error || `Error en la búsqueda (HTTP ${res.status})`);
}
const data = await res.json();
setLatitude(String(data.lat));
setLongitude(String(data.lon));
setFeedback({
type: 'ok',
text: `Located: ${data.displayName} — remember to Save.`,
text: `Ubicado: ${data.displayName} — recuerda Guardar.`,
});
} catch (err) {
setFeedback({ type: 'err', text: err.message || 'Lookup failed' });
setFeedback({ type: 'err', text: err.message || 'Error en la búsqueda' });
} finally {
setGeocoding(false);
}
}
const hasSavedCoords =
currentUser?.latitude != null && currentUser?.longitude != null;
const username = currentUser?.username || 'Usuario';
return (
<div className="profile-view">
<header className="profile-header">
<div className="profile-avatar" aria-hidden="true">👤</div>
<div className="profile-info">
<h2>{currentUser?.username}</h2>
<p className="profile-role">
{currentUser?.is_admin ? 'Administrator' : 'Member'}
{hasSavedCoords && <span className="profile-pill"> · Location saved</span>}
</p>
<div className="profile-avatar-section">
<div className="profile-avatar-circle">
<svg width="80" height="80" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
</svg>
</div>
</header>
<h2 className="profile-name">Mi Perfil</h2>
</div>
<section className="profile-section">
<h3>Your location</h3>
<p className="profile-section-sub">
Save your address so "Sort by distance" uses it without asking the browser every time.
</p>
<div className="profile-info-cards">
<div className="profile-info-card">
<p className="info-card-label">Nombre</p>
<p className="info-card-value">{username}</p>
</div>
</div>
<div className="profile-menu">
<button className="profile-menu-item" onClick={onShowSaved}>
<div className="menu-item-icon menu-item-icon--primary">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z" />
</svg>
</div>
<span className="menu-item-label">Mis Direcciones</span>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="menu-item-chevron">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
<button className="profile-menu-item">
<div className="menu-item-icon menu-item-icon--error">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M22 3H2C.9 3 0 3.9 0 5v14c0 1.1.9 2 2 2h20c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM8 6c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm6 12H2v-1c0-2 4-3.1 6-3.1s6 1.1 6 3.1v1zm8-5h-5V9h5v4z" />
</svg>
</div>
<span className="menu-item-label">Contactos de Emergencia</span>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="menu-item-chevron">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
<button className="profile-menu-item">
<div className="menu-item-icon menu-item-icon--secondary">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-7 2h2.5v12H13V6zm-2 12H8.5V6H11v12zM4 6h2.5v12H4V6zm16 12h-2.5V6H20v12z" />
</svg>
</div>
<span className="menu-item-label">Configuración de Texto</span>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="menu-item-chevron">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
</div>
<div className="profile-location-section">
<h3>Tu Ubicación</h3>
<p className="profile-section-sub">Guarda tu dirección para ordenar por distancia sin pedir permiso cada vez.</p>
<form onSubmit={handleSave} className="profile-form">
<div className="profile-field">
<label htmlFor="profile-address">Address</label>
<label htmlFor="profile-address">Dirección</label>
<input
id="profile-address"
type="text"
placeholder="123 Main St, Madrid, Spain"
placeholder="Calle Mayor 1, Madrid"
value={address}
onChange={(e) => setAddress(e.target.value)}
autoComplete="street-address"
disabled={saving}
/>
</div>
<div className="profile-field-row">
<div className="profile-field">
<label htmlFor="profile-lat">Latitude</label>
<label htmlFor="profile-lat">Latitud</label>
<input
id="profile-lat"
type="number"
@@ -152,7 +190,7 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout }) {
/>
</div>
<div className="profile-field">
<label htmlFor="profile-lon">Longitude</label>
<label htmlFor="profile-lon">Longitud</label>
<input
id="profile-lon"
type="number"
@@ -164,57 +202,33 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout }) {
/>
</div>
</div>
<div className="profile-helper-actions">
<button
type="button"
className="profile-btn-secondary"
onClick={handleGeocode}
disabled={geocoding || saving}
>
{geocoding ? 'Finding…' : '📍 Find from address'}
<button type="button" className="profile-btn-secondary" onClick={handleGeocode} disabled={geocoding || saving}>
{geocoding ? 'Buscando…' : '📍 Buscar desde dirección'}
</button>
<button
type="button"
className="profile-btn-secondary"
onClick={handleUseLocation}
disabled={locating || saving}
>
{locating ? 'Locating…' : '🛰️ Use my current location'}
<button type="button" className="profile-btn-secondary" onClick={handleUseLocation} disabled={locating || saving}>
{locating ? 'Localizando…' : '🛰️ Usar mi ubicación'}
</button>
</div>
{feedback && (
<p className={`profile-feedback profile-feedback--${feedback.type}`}>
{feedback.text}
</p>
<p className={`profile-feedback profile-feedback--${feedback.type}`}>{feedback.text}</p>
)}
<div className="profile-actions">
<button type="submit" className="profile-btn-primary" disabled={saving}>
{saving ? 'Saving…' : 'Save profile'}
{saving ? 'Guardando…' : 'Guardar'}
</button>
</div>
</form>
</section>
</div>
<section className="profile-section">
<h3>Account</h3>
<button type="button" className="profile-link-row" onClick={onShowSaved}>
<span className="profile-link-row-icon">🔔</span>
<span className="profile-link-row-label">Saved notifications</span>
<span className="profile-link-row-chevron" aria-hidden="true"></span>
</button>
<button
type="button"
className="profile-link-row profile-link-row--danger"
onClick={onLogout}
>
<span className="profile-link-row-icon"></span>
<span className="profile-link-row-label">Logout</span>
<span className="profile-link-row-chevron" aria-hidden="true"></span>
</button>
</section>
<button className="profile-logout-btn" onClick={onLogout}>
<div className="menu-item-icon menu-item-icon--error-outline">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z" />
</svg>
</div>
<span>Cerrar Sesión</span>
</button>
</div>
);
}