Blog
Feature
15 de octubre de 20259 min

Favoritos, asignación y archivos adjuntos en TAMSIV

Hay grandes funcionalidades y hay "pequeñas" adiciones que hacen que el producto sea realmente utilizable. Favoritos, asignación de tareas, archivos adjuntos: cada uno parece trivial en el papel. En la práctica, cada uno me enfrentó a decisiones arquitectónicas que tendrán consecuencias durante años.

Cuando construyes una aplicación solo, cada decisión técnica es una apuesta. No tienes un equipo para debatir, ni un CTO para validar. Tú eliges, asumes, vives con ello. Estas tres funcionalidades me obligaron a tomar decisiones fundamentales: ¿JSONB o tablas relacionales? ¿Filtro simple o combinatorio? ¿URL permanente o firmada? Aquí están los entresijos de esas elecciones.

Puntos clave
  • Un simple favorito (un booleano) requiere una columna de base de datos, RLS, filtro de feed, animación y sincronización en tiempo real para estar listo para producción.
  • La asignación de tareas utiliza una tabla de unión collaborative.task_assignments combinada con un FilterBar de 3 modos para una máxima flexibilidad.
  • Los archivos adjuntos utilizan tablas relacionales en lugar de JSONB, una elección arquitectónica que favorece el rendimiento y la escalabilidad.
  • Las URLs firmadas de Supabase Storage caducan después de 1 hora: una actualización automática por lotes a través de storage_path resuelve el problema de forma transparente.
Pantalla de smartphone mostrando una lista de tareas con una animación de estrella dorada brillante en una tarjeta, tema oscuro con acentos azules

¿Por qué un simple favorito tarda tanto en implementarse?

Marcar una tarea como favorita es un interruptor. Un booleano. is_favorite: true/false. Debería tomar 30 minutos. En la práctica, pasé un día y medio. Aquí te explico por qué.

Primero, la columna en la base de datos. Añadir un booleano a privat.tasks es rápido. Pero también hay que actualizar las RLS (Row Level Security) para que solo el propietario pueda modificar su favorito. Luego, el feed debe reflejar el cambio: las tareas favoritas deben poder filtrarse. Eso significa modificar la RPC get_consolidated_feed para aceptar un parámetro de filtro adicional.

Luego está la animación. Una estrella que aparece sin vida es aburrida. Implementé un rebote con Animated.spring: la estrella crece ligeramente más allá de su tamaño final y luego vuelve. Es sutil, 200 milisegundos en total, pero proporciona una retroalimentación satisfactoria. Los estudios de Nielsen Norman Group sobre microinteracciones muestran que estas animaciones de retroalimentación mejoran significativamente la satisfacción del usuario.

Finalmente, la sincronización en tiempo real. Si marcas una tarea como favorita en tu teléfono, el cambio debe reflejarse inmediatamente en el panel web. Gracias al ContentCacheService y su canal Supabase Realtime, es automático. Pero hubo que asegurarse de que el evento Realtime incluyera el campo is_favorite en el payload.

30 minutos en el papel. Un día y medio en la realidad. Esa es la diferencia entre "implementar" e "implementar correctamente".

¿Cómo funciona el sistema de asignación de tareas?

Vista aérea de un escritorio moderno con varios dispositivos mostrando una interfaz de gestión de tareas colaborativa, avatares de equipo visibles

La asignación es el corazón de la colaboración. Creas una tarea en un grupo y se la confías a un miembro. El modelo de datos se basa en una tabla de unión: collaborative.task_assignments. Una tarea puede asignarse a varias personas. Una persona puede tener varias tareas asignadas.

El verdadero desafío técnico no es la tabla. Es el FilterBar. La interfaz ofrece tres modos de filtrado:

  • Todo: todas las tareas del grupo, independientemente del autor o el asignado.
  • Creadas por mí: solo las tareas que he creado, incluidas las asignadas a otros.
  • Asignadas a mí: solo las tareas que me han asignado, creadas por otros o por mí mismo.

Estos tres modos se combinan con el filtro jerárquico de grupos. Si tienes un grupo "Empresa" con subgrupos "Marketing", "Desarrollo", "Diseño", puedes ver las tareas asignadas a ti en el grupo "Empresa" y todos sus hijos, o solo en "Marketing". Había detallado esta arquitectura jerárquica en el artículo sobre grupos jerárquicos.

La consulta SQL resultante es una unión entre privat.tasks, collaborative.task_assignments y collaborative.groups con una CTE recursiva para la jerarquía. Según la documentación de PostgreSQL sobre CTEs recursivas, este es el enfoque recomendado para estructuras de árbol. El rendimiento sigue siendo excelente gracias a los índices en las claves foráneas.

¿Por qué elegir tablas relacionales en lugar de JSONB para los archivos adjuntos?

Esta es una elección arquitectónica fundamental. Tenía dos enfoques para almacenar los archivos adjuntos (fotos, videos, documentos adjuntos a tareas y notas):

Opción A: JSONB. Simple y rápido. Un campo attachments JSONB directamente en la tabla privat.tasks. Sin uniones, sin tabla adicional. Serializas un array de objetos y listo.

Opción B: Tablas relacionales. Dos tablas dedicadas: privat.task_attachments y privat.memo_attachments. Cada archivo adjunto es un registro con sus propias columnas: storage_path, file_name, file_type, file_size, created_at.

Elegí la opción B. Aquí te explico por qué:

  1. Rendimiento de consulta: buscar "todas las imágenes de más de 5 MB" en un JSONB requiere un jsonb_array_elements seguido de una conversión. Con una tabla relacional, es un simple WHERE file_size > 5000000 AND file_type LIKE 'image/%'.
  2. Eliminación en cascada: cuando eliminas una tarea, el ON DELETE CASCADE en la clave foránea limpia automáticamente los archivos adjuntos. Con JSONB, tienes que gestionar la limpieza manualmente.
  3. RLS individuales: cada archivo adjunto tiene sus propias reglas de acceso. Puedes permitir la lectura de un archivo adjunto a un grupo sin dar acceso a la tarea completa. Imposible con JSONB.
  4. Escalabilidad: añadir metadatos (dimensiones de imagen, duración de video, miniatura) se hace con un simple ALTER TABLE ADD COLUMN. Con JSONB, modificas un esquema implícito sin validación en la base de datos.

Según las recomendaciones de PostgreSQL sobre JSONB, este tipo es ideal para datos semiestructurados cuyo esquema es impredecible. Los archivos adjuntos tienen un esquema perfectamente predecible. La elección era clara.

¿Cuál es la trampa de las URLs firmadas de Supabase Storage?

Visualización abstracta de un esquema de base de datos con nodos interconectados y flujos de datos, tonos holográficos azules y morados

Supabase Storage utiliza URLs firmadas para asegurar el acceso a los archivos. No puedes acceder directamente al archivo a través de una URL pública: debes solicitar una URL temporal, firmada con un token, que caduca después de un tiempo configurable (por defecto, 1 hora).

Esto es excelente para la seguridad. Es una pesadilla para la UX si no lo gestionas correctamente. Imagina: un usuario abre su feed, ve las imágenes de sus tareas. Deja la aplicación abierta durante 2 horas. Se desplaza: las imágenes muestran errores 403. La URL ha caducado. La imagen sigue ahí en el almacenamiento, pero el enlace para acceder a ella ya no es válido.

Mi solución: StorageService.refreshAttachmentUrlsBatch(). Este método toma un array de storage_path (las rutas permanentes en el bucket de Supabase) y regenera las URLs firmadas en lote. El punto clave: nunca almacenamos la URL firmada como fuente de verdad. Almacenamos el storage_path y generamos la URL firmada bajo demanda.

La actualización se activa en tres casos:

  • Al cargar una lista: las URLs se generan en lote para todos los archivos adjuntos visibles.
  • Al hacer pull-to-refresh: el usuario fuerza la actualización.
  • Después de volver a primer plano: si la aplicación estuvo en segundo plano durante más de una hora, las URLs se regeneran.

Este es el tipo de detalle invisible cuando funciona, y catastrófico cuando falla. Había encontrado el mismo tipo de desafío con el caché del feed y la gamificación: los datos existen, pero su visualización depende de un mecanismo de actualización fiable.

¿Cómo se diseñó la animación de la estrella favorita?

La animación de la estrella es un buen ejemplo de microinteracción que marca la diferencia. El principio es simple: cuando tocas la estrella, pasa de vacía a llena con un efecto de rebote.

Técnicamente, es un Animated.spring con un toValue de 1.3 (sobreimpulso del 30%) y luego un retorno a 1.0. El useNativeDriver: true garantiza que la animación se ejecute en el hilo nativo, no en el puente JavaScript. Es la misma filosofía que para el botón de nebulosa IA animado: las animaciones deben ser fluidas incluso en dispositivos de gama baja.

Añadí un ligero efecto háptico en iOS (a través de ReactNativeHapticFeedback) sincronizado con el pico del rebote. En Android, la retroalimentación háptica es menos fiable según los fabricantes, así que opté por un cambio de color instantáneo: la estrella pasa de gris a dorado sin transición de color, solo se anima el tamaño.

La diferencia entre una aplicación amateur y una profesional a menudo se juega en estos detalles. Cada interacción debe dar una retroalimentación. El usuario debe sentir que la aplicación ha entendido su acción antes incluso de que el servidor confirme.

¿Cómo maneja el FilterBar la complejidad combinatoria?

El FilterBar es probablemente el componente más subestimado de TAMSIV. En la superficie, es una fila de botones. Debajo, es un sistema de filtrado combinatorio que gestiona 3 contextos (Privado / Compartido / Todo) multiplicado por 3 modos (Todo / Creadas por mí / Asignadas a mí) multiplicado por N grupos con jerarquía.

El componente HierarchicalGroupPicker muestra el árbol de grupos con un interruptor "incluir subgrupos". Cuando seleccionas un grupo padre con la inclusión activada, la consulta utiliza una CTE recursiva para recuperar todos los IDs hijos. Había establecido esta arquitectura en el artículo sobre grupos jerárquicos.

El principal desafío es el rendimiento. Cada cambio de filtro activa una nueva consulta. Si el usuario toca rápidamente varios filtros, no queremos 5 consultas concurrentes. Implementé un debounce de 300ms: solo el último estado de filtro activa la consulta. El resultado se muestra a través del ContentCacheService que primero verifica la caché L1 en memoria antes de buscar en la base de datos.

¿Cuál es el impacto de estas "pequeñas" funcionalidades en la retención?

Los favoritos, la asignación y los archivos adjuntos no son funcionalidades que hagan que se descargue una aplicación. Nadie busca "aplicación de tareas con animación de estrella" en la Play Store. Pero son funcionalidades que hacen que se mantenga una aplicación.

Según un análisis de AppsFlyer sobre la retención móvil, la tasa promedio de retención D30 para las aplicaciones de productividad es del 4,5%. Las aplicaciones que se destacan son aquellas que reducen la fricción en los flujos de trabajo diarios. Marcar una tarea como favorita con un toque, asignar una tarea a un colega sin salir del contexto, ver la imagen adjunta sin hacer clic en un enlace externo: esto es menos fricción.

Esta es la filosofía que ya seguía en el artículo sobre la búsqueda contextual y el deslizamiento: cada interacción ahorrada es un punto de fricción eliminado. Y en productividad, la fricción es el enemigo número 1 de la adopción.

Preguntas frecuentes

¿Se pueden filtrar las tareas favoritas en la agenda?

Sí. El filtro de favoritos está disponible en el feed y en la agenda. Las tareas marcadas como favoritas aparecen con el icono de estrella en todas las vistas donde se muestran, incluido el calendario con sus filtros avanzados.

¿Cuántas personas pueden ser asignadas a una tarea?

No hay límite técnico. La tabla de unión collaborative.task_assignments permite tantas asignaciones como sea necesario. En la práctica, asignar a más de 5 personas a la misma tarea se vuelve difícil de seguir, pero el sistema lo soporta.

¿Los archivos adjuntos tienen un límite de tamaño?

Sí. El plan Free permite archivos de hasta 5 MB. El plan Pro sube a 25 MB. El plan Team a 50 MB. Estos límites se gestionan en el frontend antes de la subida y en el backend a través de las políticas de Supabase Storage.

¿Qué sucede si una URL firmada caduca durante la visualización?

El StorageService detecta los errores 403 y activa automáticamente una actualización de la URL. El usuario ve un breve marcador de posición de carga y luego la imagen reaparece. El proceso es transparente.

¿Los favoritos se sincronizan entre el móvil y la web?

Sí, en tiempo real. El ContentCacheService utiliza los canales de Supabase Realtime para propagar los cambios de favoritos entre todos los dispositivos conectados a la misma cuenta.