From 64cbb0d2f671228ce3b4509fecb44cbbcc24ebd7 Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 2 Sep 2026 01:33:17 +0000
Subject: [PATCH 1/3] =?UTF-8?q?Pulido=20social:=20identidad=20consistente,?=
=?UTF-8?q?=20estados=20vac=C3=ADos=20accionables=20y=20feedback?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Superficies: amigos (lista, solicitudes, búsqueda, descubrir, rachas,
actividad), liga, perfil propio y público, home del curso, navegación.
UX/UI
- `PersonIdentity`: una sola fila avatar + nombre + contexto para amigos,
solicitudes, búsqueda, descubrimiento y rachas (antes había cuatro
formatos distintos, con @usuario en mono de 10-11px).
- El estado vacío de Amigos ahora lleva a Buscar y a Descubrir; se quita
el banner de la página que repetía ese mismo mensaje.
- Descubrir: estado vacío con acción real (completar perfil académico).
- Rachas: primero lo que pide respuesta, luego las activas; cada racha
dice si HOY ya cuenta o si les falta, y el creador puede cancelar una
propuesta pendiente (la acción ya existía sin superficie).
- Liga: leyenda que traduce las reglas ("los primeros 5 suben a Oro"),
XP propio de la semana y cuándo termina; la clasificación completa
sólo aparece cuando añade filas nuevas.
- Misión con un amigo: se ve cuánto falta de la semana.
- Perfil propio: @usuario visible y enlace al perfil público.
- Perfil académico: se explica por qué Guardar está deshabilitado.
Bugs
- El saludo del inicio usaba la hora del servidor (UTC en producción):
ahora usa la hora de México.
- Los toasts quedaban debajo de la barra de navegación móvil.
- Liga marcaba zona de descenso en Bronce y de ascenso en Diamante,
donde esas transiciones no existen.
- Kudos y búsqueda de usuarios fallaban en silencio.
- Perfil académico dejaba un semestre fantasma al cambiar de carrera.
- El perfil público apretaba nombre y acciones en una sola fila a 360px.
- `discovery_impression` se registraba al cargar Amigos aunque la
pestaña Descubrir nunca se abriera; ahora se registra al mostrarse.
Accesibilidad y táctil
- aria-label en los botones de sólo icono (rechazar, recordar, kudos,
descartar) y texto sr-only en los badges de la navegación.
- Objetivos táctiles de ~44px en descartes, kudos y acciones de fila.
- La pestaña activa de Amigos se trae a la vista en el carril móvil.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01MSwXgordDexfn3uWN3xuDn
---
src/app/app/(global)/amigos/loading.tsx | 19 +++
src/app/app/(global)/amigos/page.tsx | 46 +-----
src/app/app/(global)/liga/page.tsx | 155 +++++++++++++-----
.../app/(global)/perfil/[username]/page.tsx | 56 ++++---
src/app/app/(global)/perfil/page.tsx | 23 ++-
src/app/app/c/[courseSlug]/page.tsx | 8 +-
src/components/layout/mobile-nav.tsx | 14 +-
src/components/layout/sidebar-nav.tsx | 10 +-
src/components/ui/sonner.tsx | 8 +
.../components/academic-profile-editor.tsx | 29 +++-
.../components/academic-prompt-banner.tsx | 13 +-
src/features/discovery/actions.ts | 31 ++++
.../discovery/components/discovery-list.tsx | 71 +++++---
.../friends/components/friends-list.tsx | 51 ++++--
.../friends/components/friends-tabs.tsx | 27 ++-
.../friends/components/incoming-requests.tsx | 43 ++---
.../friends/components/outgoing-requests.tsx | 41 ++---
.../friends/components/person-identity.tsx | 70 ++++++++
.../friends/components/profile-actions.tsx | 23 +--
.../friends/components/user-search.tsx | 42 +++--
src/features/quests/components/quest-card.tsx | 24 ++-
.../social-feed/components/milestone-feed.tsx | 55 +++++--
.../components/start-streak-button.tsx | 4 +-
.../streaks/components/streak-cards.tsx | 120 ++++++++++----
src/features/streaks/queries.ts | 3 +
src/lib/social/time.ts | 5 +
26 files changed, 668 insertions(+), 323 deletions(-)
create mode 100644 src/app/app/(global)/amigos/loading.tsx
create mode 100644 src/features/friends/components/person-identity.tsx
diff --git a/src/app/app/(global)/amigos/loading.tsx b/src/app/app/(global)/amigos/loading.tsx
new file mode 100644
index 0000000..4ca2e9b
--- /dev/null
+++ b/src/app/app/(global)/amigos/loading.tsx
@@ -0,0 +1,19 @@
+import { Skeleton } from "@/components/ui/skeleton";
+
+/** Silueta de Amigos: encabezado, carril de pestañas y filas de gente. */
+export default function AmigosLoading() {
+ return (
+
- Busca a tus compañeros por{" "}
- @usuario{" "}
- o mándales tu link de invitación. Cuando acepten verás su progreso en
- tu inicio.
-
-
- Tu handle es{" "}
-
- @{username}
-
- .
-
-
- );
-}
diff --git a/src/app/app/(global)/liga/page.tsx b/src/app/app/(global)/liga/page.tsx
index 09ac8ff..92fa30c 100644
--- a/src/app/app/(global)/liga/page.tsx
+++ b/src/app/app/(global)/liga/page.tsx
@@ -4,12 +4,14 @@ import { AnimatedNumber } from "@/components/ui/animated-number";
import { Badge } from "@/components/ui/badge";
import { SectionRule } from "@/components/ui/section-rule";
import { FriendAvatar } from "@/features/friends/components/friend-avatar";
-import { getLeagueStanding } from "@/features/league/queries";
+import { getLeagueStanding, type LeagueStanding } from "@/features/league/queries";
import { db } from "@/lib/db";
import { requireConfirmedUsername } from "@/lib/get-session";
import { recordProductEventSafely } from "@/lib/analytics/record";
import { leagueViewPropsSchema } from "@/lib/analytics/social-props";
+import { relativeFromNow } from "@/lib/relative-time";
import { LEAGUE_TIER_LABEL } from "@/lib/social/league-labels";
+import { tierAbove, tierBelow } from "@/lib/social/league";
import { cn } from "@/lib/utils";
export const metadata = {
@@ -29,6 +31,11 @@ export default async function LigaPage() {
});
}
+ const self = standing?.rows.find((r) => r.isSelf) ?? null;
+ // Con el top 3 y "cerca de ti" ya visibles, la lista completa sólo
+ // aporta cuando hay más gente que ésa.
+ const showFullList = standing ? standing.rows.length > 3 : false;
+
return (
@@ -36,9 +43,8 @@ export default async function LigaPage() {
Liga
- Compite en XP de la semana contra tu división. Sube de liga si
- terminas arriba; baja si te quedas atrás — todo se reinicia cada
- lunes.
+ Compites con el XP que ganas esta semana contra tu división. Todo
+ se reinicia el lunes.
@@ -52,22 +58,38 @@ export default async function LigaPage() {
) : (
<>
-
-
-
-
-
-
Liga {LEAGUE_TIER_LABEL[standing.tier]}
-
- {standing.rows.length} {standing.rows.length === 1 ? "alumno" : "alumnos"} en tu división
-
);
}
-function NearbySection({
- standing,
-}: {
- standing: NonNullable>>;
-}) {
+/**
+ * Traduce las reglas de ascenso/descenso a una frase. En Bronce nadie
+ * baja y en Diamante nadie sube: la leyenda tiene que decirlo, porque los
+ * indicadores de las filas tampoco se pintan ahí.
+ */
+function ZoneLegend({ standing }: { standing: LeagueStanding }) {
+ const up = tierAbove(standing.tier);
+ const down = tierBelow(standing.tier);
+ const promotes = standing.promoteCount > 0 && up !== null;
+ const relegates = standing.relegateCount > 0 && down !== null;
+
+ const parts: string[] = [];
+ if (promotes) {
+ parts.push(
+ `Los primeros ${standing.promoteCount} suben a ${LEAGUE_TIER_LABEL[up]}`,
+ );
+ } else if (!up) {
+ parts.push("Diamante es la liga más alta: aquí sólo se defiende el lugar");
+ }
+ if (relegates) {
+ parts.push(`los últimos ${standing.relegateCount} bajan a ${LEAGUE_TIER_LABEL[down]}`);
+ } else if (!down) {
+ parts.push("de Bronce no baja nadie");
+ }
+ if (parts.length === 0) return null;
+
+ return (
+
+ {parts.join(" · ")}.
+
+ );
+}
+
+function NearbySection({ standing }: { standing: LeagueStanding }) {
const selfIdx = standing.rows.findIndex((r) => r.isSelf);
if (selfIdx < 0 || selfIdx < 3) return null; // ya está en el top3 mostrado arriba
@@ -125,12 +178,20 @@ function StandingRow({
row,
standing,
}: {
- row: { userId: string; username: string; name: string; image: string | null; xp: number; rank: number; isSelf: boolean };
- standing: { promoteCount: number; relegateCount: number; rows: { rank: number }[] };
+ row: LeagueStanding["rows"][number];
+ standing: LeagueStanding;
}) {
const n = standing.rows.length;
- const inPromotionZone = row.rank <= standing.promoteCount;
- const inRelegationZone = row.rank > n - standing.relegateCount;
+ // Sólo se marca la zona que de verdad puede pasar: en Diamante no hay
+ // ascenso y en Bronce no hay descenso.
+ const inPromotionZone =
+ tierAbove(standing.tier) !== null &&
+ standing.promoteCount > 0 &&
+ row.rank <= standing.promoteCount;
+ const inRelegationZone =
+ tierBelow(standing.tier) !== null &&
+ standing.relegateCount > 0 &&
+ row.rank > n - standing.relegateCount;
return (
+ meta={`@${request.user.username} · enviada ${relativeFromNow(request.createdAt)}`}
+ />
Cancelar
diff --git a/src/features/friends/components/person-identity.tsx b/src/features/friends/components/person-identity.tsx
new file mode 100644
index 0000000..36abdf4
--- /dev/null
+++ b/src/features/friends/components/person-identity.tsx
@@ -0,0 +1,70 @@
+import Link from "next/link";
+
+import { cn } from "@/lib/utils";
+import { FriendAvatar } from "./friend-avatar";
+
+interface PersonIdentityProps {
+ name: string;
+ username: string;
+ image?: string | null;
+ /**
+ * Línea secundaria bajo el nombre. Si no se pasa, se muestra el
+ * `@usuario` — que es la identidad pública en toda la app.
+ */
+ meta?: React.ReactNode;
+ /** Si se pasa, avatar y nombre son un solo destino táctil. */
+ href?: string;
+ onNavigate?: () => void;
+ className?: string;
+}
+
+/**
+ * Avatar + nombre + línea de contexto: la MISMA fila de identidad en
+ * amigos, búsqueda, solicitudes y descubrimiento, para que una persona se
+ * vea igual en todas las superficies sociales.
+ */
+export function PersonIdentity({
+ name,
+ username,
+ image,
+ meta,
+ href,
+ onNavigate,
+ className,
+}: PersonIdentityProps) {
+ const body = (
+ <>
+
+
+
+ {name}
+
+
+ {meta ?? `@${username}`}
+
+
+ >
+ );
+
+ if (!href) {
+ return (
+
+ {body}
+
+ );
+ }
+
+ return (
+
+ {body}
+
+ );
+}
diff --git a/src/features/friends/components/profile-actions.tsx b/src/features/friends/components/profile-actions.tsx
index 579b2e5..1895fbd 100644
--- a/src/features/friends/components/profile-actions.tsx
+++ b/src/features/friends/components/profile-actions.tsx
@@ -66,7 +66,7 @@ export function ProfileActions({
if (state === "self") {
return (
-
+
Editar mi perfil
);
@@ -75,7 +75,7 @@ export function ProfileActions({
if (state === "none") {
return (
runWith(async () => {
@@ -99,12 +99,12 @@ export function ProfileActions({
if (state === "pending_outgoing") {
return (
- Invita a un amigo desde su perfil a mantener una racha juntos —
- los dos tienen que estudiar el mismo día para que cuente.
+ Entra al perfil de un amigo y proponle una racha: cuenta sólo si
+ los dos estudian el mismo día.
diff --git a/src/features/streaks/queries.ts b/src/features/streaks/queries.ts
index 98b2caa..2cdcf4c 100644
--- a/src/features/streaks/queries.ts
+++ b/src/features/streaks/queries.ts
@@ -12,6 +12,8 @@ export interface FriendStreakCard {
currentStreak: number;
longestStreak: number;
canRemindToday: boolean;
+ /** true si HOY ya calificó para los dos (`lastQualifiedDay` = hoy). */
+ qualifiedToday: boolean;
pendingExpiresAt: Date | null;
}
@@ -53,6 +55,7 @@ export async function getMyFriendStreaks(viewerId: string): Promise
Date: Wed, 2 Sep 2026 01:56:10 +0000
Subject: [PATCH 2/3] =?UTF-8?q?Arregla=20el=20desajuste=20de=20hidrataci?=
=?UTF-8?q?=C3=B3n=20del=20rail=20y=20la=20barra=20superior=20en=20360px?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Encontrados revisando las superficies en el navegador (Chromium, 360/390/
430/768/1280 con datos sembrados en una base local).
- Rail: el `` del trigger de comentarios se creaba en un Server
Component, así que Radix no lo pintaba en el HTML del servidor y React
rehidrataba con un desajuste — regenerando todo el árbol del rail en
cada carga autenticada. Ahora el trigger vive en un componente de
cliente (`FeedbackRailButton`) y la hidratación queda limpia.
- Barra superior a 360px: el bloque de marca se encogía a 4px y el
selector de curso se encimaba sobre el logo, que además desbordaba su
caja. Ahora la marca no se encoge (debajo de sm queda el glifo, que
sigue siendo el enlace a Inicio), el selector de curso tiene un piso de
ancho para no aplastar sus iconos, y la ficha de XP oculta la unidad en
móvil igual que la racha ya ocultaba "días". Todo cabe sin scroll
horizontal desde 360px.
- Amigos: `?tab=amigos` se ignoraba si había solicitudes entrantes.
- Rachas: copy más corto para que el estado no se recorte en móvil.
- Descubrir: la razón de "misma carrera" empieza por el semestre; el
nombre largo de la carrera se comía el dato corto al recortarse.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01MSwXgordDexfn3uWN3xuDn
---
src/app/app/(global)/amigos/page.tsx | 1 +
src/components/layout/sidebar.tsx | 14 ++--------
src/components/layout/topbar-location.tsx | 9 ++++--
src/components/layout/topbar.tsx | 12 ++++----
src/components/shared/logo.tsx | 2 +-
.../courses/components/course-switcher.tsx | 4 ++-
src/features/discovery/queries.ts | 4 ++-
.../components/feedback-rail-button.tsx | 28 +++++++++++++++++++
.../streaks/components/streak-cards.tsx | 4 +--
9 files changed, 55 insertions(+), 23 deletions(-)
create mode 100644 src/features/feedback/components/feedback-rail-button.tsx
diff --git a/src/app/app/(global)/amigos/page.tsx b/src/app/app/(global)/amigos/page.tsx
index 3789e60..7163a32 100644
--- a/src/app/app/(global)/amigos/page.tsx
+++ b/src/app/app/(global)/amigos/page.tsx
@@ -58,6 +58,7 @@ export default async function AmigosPage({
}
const initialTab =
+ params.tab === "amigos" ||
params.tab === "solicitudes" ||
params.tab === "buscar" ||
params.tab === "descubrir" ||
diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx
index f08a991..514068d 100644
--- a/src/components/layout/sidebar.tsx
+++ b/src/components/layout/sidebar.tsx
@@ -1,11 +1,11 @@
import Link from "next/link";
-import { Bug, MessageSquarePlus } from "lucide-react";
+import { Bug } from "lucide-react";
import { Logo } from "@/components/shared/logo";
import { UNOFFICIAL_NOTICE } from "@/lib/branding";
import { SidebarNav } from "@/components/layout/sidebar-nav";
import type { CourseSwitcherItem } from "@/features/courses/components/course-switcher";
-import { FeedbackDialog } from "@/features/feedback/components/feedback-dialog";
+import { FeedbackRailButton } from "@/features/feedback/components/feedback-rail-button";
import type { RoadmapUnit } from "@/features/roadmap/types";
/**
@@ -53,15 +53,7 @@ export function Sidebar({