Favoris, assignation et pièces jointes dans TAMSIV
Il y a les grandes features et il y a les "petits" ajouts qui rendent le produit vraiment utilisable. Les favoris, l'assignation de tâches, les pièces jointes : chacun semble trivial sur le papier. En pratique, chacun m'a confronté à des choix d'architecture qui auront des conséquences pendant des années.
Quand tu construis une app en solo, chaque décision technique est un pari. Tu n'as pas d'équipe pour débattre, pas de CTO pour valider. Tu choisis, tu assumes, tu vis avec. Ces trois features m'ont forcé à trancher sur des questions fondamentales : JSONB ou tables relationnelles ? Filtre simple ou combinatoire ? URL permanente ou signée ? Voici les coulisses de ces choix.
Points clés
- Un simple favori (un booléen) nécessite colonne DB, RLS, filtre feed, animation et synchronisation Realtime pour être production-ready.
- L'assignation de tâches utilise une table de liaison
collaborative.task_assignmentscombinée à un FilterBar à 3 modes pour une flexibilité maximale.- Les pièces jointes utilisent des tables relationnelles au lieu de JSONB, un choix architectural qui favorise la performance et l'évolutivité.
- Les URLs signées Supabase Storage expirent après 1 heure : un batch refresh automatique via
storage_pathrésout le problème de manière transparente.
Pourquoi un simple favori prend-il autant de temps à implémenter ?
Mettre une tâche en favori, c'est un toggle. Un booléen. is_favorite: true/false. Ça devrait prendre 30 minutes. En pratique, j'y ai passé une journée et demie. Voici pourquoi.
D'abord, la colonne en base de données. Ajouter un booléen à privat.tasks, c'est rapide. Mais il faut aussi mettre à jour les RLS (Row Level Security) pour que seul le propriétaire puisse modifier son favori. Ensuite, il faut que le feed reflète le changement : les tâches favorites doivent pouvoir être filtrées. Ça veut dire modifier la RPC get_consolidated_feed pour accepter un paramètre de filtre supplémentaire.
Puis il y a l'animation. Une étoile qui apparaît sans vie, c'est ennuyeux. J'ai implémenté un bounce avec Animated.spring : l'étoile grossit légèrement au-delà de sa taille finale puis revient. C'est subtil, 200 millisecondes au total, mais ça donne un feedback satisfaisant. Les études de Nielsen Norman Group sur les micro-interactions montrent que ces animations de feedback améliorent significativement la satisfaction utilisateur.
Enfin, la synchronisation Realtime. Si tu mets une tâche en favori sur ton téléphone, le changement doit se refléter immédiatement sur le dashboard web. Grâce au ContentCacheService et son canal Supabase Realtime, c'est automatique. Mais il a fallu s'assurer que l'événement Realtime inclut bien le champ is_favorite dans le payload.
30 minutes sur le papier. Une journée et demie en réalité. C'est la différence entre "implémenter" et "implémenter correctement".
Comment fonctionne le système d'assignation de tâches ?
L'assignation, c'est le coeur de la collaboration. Tu crées une tâche dans un groupe et tu la confies à un membre. Le modèle de données repose sur une table de liaison : collaborative.task_assignments. Une tâche peut être assignée à plusieurs personnes. Une personne peut avoir plusieurs tâches assignées.
Le vrai défi technique, ce n'est pas la table. C'est le FilterBar. L'interface propose trois modes de filtrage :
- Tout : toutes les tâches du groupe, quel que soit l'auteur ou l'assigné.
- Créées par moi : uniquement les tâches que j'ai créées, y compris celles assignées à d'autres.
- Assignées à moi : uniquement les tâches qu'on m'a assignées, créées par d'autres ou par moi-même.
Ces trois modes se combinent avec le filtre hiérarchique de groupes. Si tu as un groupe "Entreprise" avec des sous-groupes "Marketing", "Dev", "Design", tu peux voir les tâches assignées à toi dans le groupe "Entreprise" et tous ses enfants, ou seulement dans "Marketing". J'avais détaillé cette architecture hiérarchique dans l'article sur les groupes hiérarchiques.
La requête SQL résultante est une jointure entre privat.tasks, collaborative.task_assignments et collaborative.groups avec une CTE récursive pour la hiérarchie. Selon la documentation PostgreSQL sur les CTEs récursives, c'est l'approche recommandée pour les structures arborescentes. La performance reste excellente grâce aux index sur les clés étrangères.
Pourquoi choisir les tables relationnelles plutôt que JSONB pour les pièces jointes ?
C'est un choix architectural fondamental. Deux approches s'offraient à moi pour stocker les pièces jointes (photos, vidéos, documents attachés aux tâches et mémos) :
Option A : JSONB. Simple et rapide. Un champ attachments JSONB directement dans la table privat.tasks. Pas de jointure, pas de table supplémentaire. Tu sérialises un tableau d'objets et c'est réglé.
Option B : Tables relationnelles. Deux tables dédiées : privat.task_attachments et privat.memo_attachments. Chaque pièce jointe est un enregistrement avec ses propres colonnes : storage_path, file_name, file_type, file_size, created_at.
J'ai choisi l'option B. Voici pourquoi :
- Performance de requêtage : chercher "toutes les images de plus de 5 Mo" dans un JSONB nécessite un
jsonb_array_elementssuivi d'un cast. Avec une table relationnelle, c'est un simpleWHERE file_size > 5000000 AND file_type LIKE 'image/%'. - Suppression en cascade : quand tu supprimes une tâche, le
ON DELETE CASCADEsur la clé étrangère nettoie automatiquement les pièces jointes. Avec JSONB, tu dois gérer le cleanup manuellement. - RLS individuelles : chaque pièce jointe a ses propres règles d'accès. Tu peux autoriser la lecture d'une pièce jointe à un groupe sans donner accès à la tâche entière. Impossible avec JSONB.
- Évolutivité : ajouter des métadonnées (dimensions d'image, durée de vidéo, miniature) se fait avec un simple
ALTER TABLE ADD COLUMN. Avec JSONB, tu modifies un schéma implicite sans validation côté DB.
D'après les recommandations PostgreSQL sur JSONB, ce type est idéal pour les données semi-structurées dont le schéma est imprévisible. Les pièces jointes ont un schéma parfaitement prévisible. Le choix était clair.
Quel est le piège des URLs signées Supabase Storage ?
Supabase Storage utilise des URLs signées pour sécuriser l'accès aux fichiers. Tu ne peux pas accéder directement au fichier via une URL publique : tu dois demander une URL temporaire, signée avec un token, qui expire après un délai configurable (par défaut, 1 heure).
C'est excellent pour la sécurité. C'est un cauchemar pour l'UX si tu ne le gères pas correctement. Imagine : un utilisateur ouvre son feed, voit les images de ses tâches. Il laisse l'app ouverte pendant 2 heures. Il scrolle : les images affichent des erreurs 403. L'URL a expiré. L'image est toujours là dans le storage, mais le lien pour y accéder n'est plus valide.
Ma solution : StorageService.refreshAttachmentUrlsBatch(). Cette méthode prend un tableau de storage_path (les chemins permanents dans le bucket Supabase) et régénère les URLs signées en batch. Le point clé : on ne stocke jamais l'URL signée comme source de vérité. On stocke le storage_path et on génère l'URL signée à la demande.
Le refresh se déclenche dans trois cas :
- Au chargement d'une liste : les URLs sont générées en batch pour toutes les pièces jointes visibles.
- Au pull-to-refresh : l'utilisateur force le rafraîchissement.
- Après un retour en premier plan : si l'app était en arrière-plan pendant plus d'une heure, les URLs sont régénérées.
C'est le genre de détail invisible quand ça marche, et catastrophique quand ça casse. J'avais rencontré le même type de défi avec le cache du feed et la gamification : la donnée existe, mais son affichage dépend d'un mécanisme de rafraîchissement fiable.
Comment l'animation de l'étoile favori a-t-elle été conçue ?
L'animation de l'étoile est un bon exemple de micro-interaction qui fait la différence. Le principe est simple : quand tu tapes sur l'étoile, elle passe de vide à pleine avec un effet de rebond.
Techniquement, c'est un Animated.spring avec un toValue de 1.3 (overshoot de 30%) puis un retour à 1.0. Le useNativeDriver: true garantit que l'animation tourne sur le thread natif, pas sur le bridge JavaScript. C'est la même philosophie que pour le bouton nébuleuse IA : les animations doivent être fluides même sur des appareils d'entrée de gamme.
J'ai ajouté un effet haptique léger sur iOS (via ReactNativeHapticFeedback) synchronisé avec le pic du bounce. Sur Android, le retour haptique est moins fiable selon les fabricants, donc j'ai opté pour un changement de couleur instantané : l'étoile passe du gris au doré sans transition de couleur, seule la taille est animée.
La différence entre une app amateur et une app professionnelle se joue souvent dans ces détails. Chaque interaction doit donner un feedback. L'utilisateur doit sentir que l'app a compris son action avant même que le serveur confirme.
Comment le FilterBar gère-t-il la complexité combinatoire ?
Le FilterBar est probablement le composant le plus sous-estimé de TAMSIV. En surface, c'est une rangée de boutons. En dessous, c'est un système de filtrage combinatoire qui gère 3 contextes (Privé / Partagé / Tout) multiplié par 3 modes (Tout / Créées par moi / Assignées à moi) multiplié par N groupes avec hiérarchie.
Le composant HierarchicalGroupPicker affiche l'arborescence des groupes avec un toggle "inclure les sous-groupes". Quand tu sélectionnes un groupe parent avec l'inclusion activée, la requête utilise une CTE récursive pour récupérer tous les IDs enfants. J'avais posé cette architecture dans l'article sur les groupes hiérarchiques.
L'enjeu principal est la performance. Chaque changement de filtre déclenche une nouvelle requête. Si l'utilisateur tape rapidement sur plusieurs filtres, on ne veut pas 5 requêtes concurrentes. J'ai implémenté un debounce de 300ms : seul le dernier état de filtre déclenche la requête. Le résultat s'affiche via le ContentCacheService qui vérifie d'abord le cache L1 en mémoire avant d'aller chercher en DB.
Quel est l'impact de ces "petites" features sur la rétention ?
Les favoris, l'assignation et les pièces jointes ne sont pas des features qui font télécharger une app. Personne ne cherche "app de tâches avec animation d'étoile" sur le Play Store. Mais ce sont des features qui font garder une app.
Selon une analyse de AppsFlyer sur la rétention mobile, le taux moyen de rétention J30 pour les apps de productivité est de 4,5%. Les apps qui se démarquent sont celles qui réduisent la friction dans les workflows quotidiens. Marquer une tâche comme favorite en un tap, assigner une tâche à un collègue sans quitter le contexte, voir l'image attachée sans cliquer sur un lien externe : c'est de la friction en moins.
C'est la philosophie que je poursuivais déjà dans l'article sur la recherche contextuelle et le swipe : chaque interaction économisée est un point de friction éliminé. Et en productivité, la friction est l'ennemi numéro 1 de l'adoption.
Questions fréquentes
Peut-on filtrer les tâches favorites dans l'agenda ?
Oui. Le filtre favori est disponible dans le feed et dans l'agenda. Les tâches marquées comme favorites apparaissent avec l'icône étoile dans toutes les vues où elles sont affichées, y compris le calendrier avec ses filtres avancés.
Combien de personnes peuvent être assignées à une tâche ?
Il n'y a pas de limite technique. La table de liaison collaborative.task_assignments permet autant d'assignations que nécessaire. En pratique, assigner plus de 5 personnes à une même tâche devient difficile à suivre, mais le système le supporte.
Les pièces jointes ont-elles une limite de taille ?
Oui. Le plan Free autorise des fichiers jusqu'à 5 Mo. Le plan Pro monte à 25 Mo. Le plan Team à 50 Mo. Ces limites sont gérées côté frontend avant l'upload et côté backend via les politiques Supabase Storage.
Que se passe-t-il si une URL signée expire pendant le visionnage ?
Le StorageService détecte les erreurs 403 et déclenche automatiquement un refresh de l'URL. L'utilisateur voit un bref placeholder de chargement puis l'image réapparaît. Le processus est transparent.
Les favoris sont-ils synchronisés entre mobile et web ?
Oui, en temps réel. Le ContentCacheService utilise les canaux Supabase Realtime pour propager les changements de favoris entre tous les appareils connectés au même compte.