Favorites, assignment, and attachments in TAMSIV
There are big features, and then there are the "small" additions that make a product truly usable. Favorites, task assignment, attachments: each seems trivial on paper. In practice, each confronted me with architectural choices that will have consequences for years to come.
When you build an app solo, every technical decision is a gamble. You don't have a team to debate with, no CTO to validate. You choose, you own it, you live with it. These three features forced me to make fundamental decisions: JSONB or relational tables? Simple or combinatorial filter? Permanent or signed URL? Here's a look behind the scenes of these choices.
Key points
- A simple favorite (a boolean) requires a DB column, RLS, feed filter, animation, and Realtime synchronization to be production-ready.
- Task assignment uses a
collaborative.task_assignmentslinking table combined with a 3-mode FilterBar for maximum flexibility.- Attachments use relational tables instead of JSONB, an architectural choice that favors performance and scalability.
- Supabase Storage signed URLs expire after 1 hour: an automatic batch refresh via
storage_pathsolves the problem transparently.
Why does a simple favorite take so long to implement?
Favoriting a task is a toggle. A boolean. is_favorite: true/false. It should take 30 minutes. In practice, I spent a day and a half on it. Here's why.
First, the database column. Adding a boolean to privat.tasks is quick. But you also need to update RLS (Row Level Security) so that only the owner can modify their favorite. Then, the feed needs to reflect the change: favorite tasks must be filterable. That means modifying the get_consolidated_feed RPC to accept an additional filter parameter.
Then there's the animation. A lifeless star appearing is boring. I implemented a bounce with Animated.spring: the star slightly grows beyond its final size then returns. It's subtle, 200 milliseconds in total, but it provides satisfying feedback. Studies by Nielsen Norman Group on micro-interactions show that these feedback animations significantly improve user satisfaction.
Finally, Realtime synchronization. If you favorite a task on your phone, the change must immediately reflect on the web dashboard. Thanks to the ContentCacheService and its Supabase Realtime channel, this is automatic. But we had to ensure that the Realtime event included the is_favorite field in the payload.
30 minutes on paper. A day and a half in reality. That's the difference between "implementing" and "implementing correctly."
How does the task assignment system work?
Assignment is the heart of collaboration. You create a task in a group and assign it to a member. The data model relies on a linking table: collaborative.task_assignments. A task can be assigned to multiple people. A person can have multiple tasks assigned to them.
The real technical challenge isn't the table. It's the FilterBar. The interface offers three filtering modes:
- All: all tasks in the group, regardless of author or assignee.
- Created by me: only tasks I created, including those assigned to others.
- Assigned to me: only tasks assigned to me, created by others or by myself.
These three modes combine with the hierarchical group filter. If you have an "Enterprise" group with "Marketing," "Dev," "Design" subgroups, you can see tasks assigned to you in the "Enterprise" group and all its children, or only in "Marketing." I detailed this hierarchical architecture in the article on hierarchical groups.
The resulting SQL query is a join between privat.tasks, collaborative.task_assignments, and collaborative.groups with a recursive CTE for the hierarchy. According to the PostgreSQL documentation on recursive CTEs, this is the recommended approach for tree structures. Performance remains excellent thanks to foreign key indexes.
Why choose relational tables over JSONB for attachments?
This is a fundamental architectural choice. Two approaches were available to me for storing attachments (photos, videos, documents attached to tasks and memos):
Option A: JSONB. Simple and fast. An attachments JSONB field directly in the privat.tasks table. No joins, no extra table. You serialize an array of objects, and it's done.
Option B: Relational tables. Two dedicated tables: privat.task_attachments and privat.memo_attachments. Each attachment is a record with its own columns: storage_path, file_name, file_type, file_size, created_at.
I chose Option B. Here's why:
- Query performance: searching for "all images larger than 5 MB" in a JSONB requires a
jsonb_array_elementsfollowed by a cast. With a relational table, it's a simpleWHERE file_size > 5000000 AND file_type LIKE 'image/%'. - Cascade deletion: when you delete a task, the
ON DELETE CASCADEon the foreign key automatically cleans up attachments. With JSONB, you have to manage cleanup manually. - Individual RLS: each attachment has its own access rules. You can allow a group to read an attachment without giving access to the entire task. Impossible with JSONB.
- Scalability: adding metadata (image dimensions, video duration, thumbnail) is done with a simple
ALTER TABLE ADD COLUMN. With JSONB, you modify an implicit schema without DB-side validation.
According to PostgreSQL recommendations on JSONB, this type is ideal for semi-structured data with an unpredictable schema. Attachments have a perfectly predictable schema. The choice was clear.
What's the catch with Supabase Storage signed URLs?
Supabase Storage uses signed URLs to secure file access. You cannot directly access the file via a public URL: you must request a temporary URL, signed with a token, which expires after a configurable delay (by default, 1 hour).
This is excellent for security. It's a nightmare for UX if you don't manage it correctly. Imagine: a user opens their feed, sees images of their tasks. They leave the app open for 2 hours. They scroll: the images display 403 errors. The URL has expired. The image is still there in storage, but the link to access it is no longer valid.
My solution: StorageService.refreshAttachmentUrlsBatch(). This method takes an array of storage_path (the permanent paths in the Supabase bucket) and regenerates the signed URLs in a batch. The key point: we never store the signed URL as the source of truth. We store the storage_path and generate the signed URL on demand.
The refresh is triggered in three cases:
- When a list loads: URLs are generated in a batch for all visible attachments.
- On pull-to-refresh: the user forces a refresh.
- After returning to the foreground: if the app was in the background for more than an hour, the URLs are regenerated.
This is the kind of detail that's invisible when it works, and catastrophic when it breaks. I encountered the same type of challenge with the feed cache and gamification: the data exists, but its display depends on a reliable refresh mechanism.
How was the favorite star animation designed?
The star animation is a good example of a micro-interaction that makes a difference. The principle is simple: when you tap the star, it goes from empty to full with a bounce effect.
Technically, it's an Animated.spring with a toValue of 1.3 (30% overshoot) then a return to 1.0. The useNativeDriver: true ensures that the animation runs on the native thread, not on the JavaScript bridge. This is the same philosophy as for the AI nebula button: animations must be fluid even on entry-level devices.
I added a slight haptic effect on iOS (via ReactNativeHapticFeedback) synchronized with the peak of the bounce. On Android, haptic feedback is less reliable depending on the manufacturer, so I opted for an instant color change: the star goes from gray to gold without a color transition, only the size is animated.
The difference between an amateur app and a professional app often lies in these details. Every interaction must provide feedback. The user must feel that the app understood their action even before the server confirms.
How does the FilterBar handle combinatorial complexity?
The FilterBar is probably TAMSIV's most underestimated component. On the surface, it's a row of buttons. Underneath, it's a combinatorial filtering system that manages 3 contexts (Private / Shared / All) multiplied by 3 modes (All / Created by me / Assigned to me) multiplied by N groups with hierarchy.
The HierarchicalGroupPicker component displays the group tree with an "include subgroups" toggle. When you select a parent group with inclusion enabled, the query uses a recursive CTE to retrieve all child IDs. I laid out this architecture in the article on hierarchical groups.
The main challenge is performance. Each filter change triggers a new query. If the user taps quickly on several filters, we don't want 5 concurrent queries. I implemented a 300ms debounce: only the last filter state triggers the query. The result is displayed via the ContentCacheService which first checks the in-memory L1 cache before querying the DB.
What is the impact of these "small" features on retention?
Favorites, assignment, and attachments are not features that make someone download an app. Nobody searches for "task app with star animation" on the Play Store. But these are features that make people keep an app.
According to an AppsFlyer analysis of mobile retention, the average 30-day retention rate for productivity apps is 4.5%. Apps that stand out are those that reduce friction in daily workflows. Marking a task as a favorite with a tap, assigning a task to a colleague without leaving the context, seeing an attached image without clicking an external link: that's less friction.
This is the philosophy I was already pursuing in the article on contextual search and swipe: every saved interaction is a friction point eliminated. And in productivity, friction is the number one enemy of adoption.
Frequently Asked Questions
Can favorite tasks be filtered in the agenda?
Yes. The favorite filter is available in the feed and in the agenda. Tasks marked as favorites appear with the star icon in all views where they are displayed, including the calendar with its advanced filters.
How many people can be assigned to a task?
There is no technical limit. The collaborative.task_assignments linking table allows as many assignments as necessary. In practice, assigning more than 5 people to the same task becomes difficult to track, but the system supports it.
Do attachments have a size limit?
Yes. The Free plan allows files up to 5 MB. The Pro plan goes up to 25 MB. The Team plan to 50 MB. These limits are managed on the frontend before upload and on the backend via Supabase Storage policies.
What happens if a signed URL expires during viewing?
The StorageService detects 403 errors and automatically triggers a URL refresh. The user sees a brief loading placeholder, then the image reappears. The process is transparent.
Are favorites synchronized between mobile and web?
Yes, in real-time. The ContentCacheService uses Supabase Realtime channels to propagate favorite changes between all devices connected to the same account.