Designing an aging-urgency system so shared tasks don't silently die

2026-11-04来源 dev.toEnglish

Every shared to-do list I've used has the same failure mode. A task gets added, nobody picks it up, and it quietly sinks under newer items. It never errors and never times out. It just keeps the same grey row it had on day one while it stops being real to everyone except the person who asked for it.

I work on Carry, a shared household task app built with Expo and React Native for couples and small households. Early on we saw that the costly part of a shared list isn't finishing tasks. It's keeping track of what's been waiting. One person ends up scanning the list, remembering that the gutter job has been there since last Tuesday, and deciding whether to bring it up. That's invisible work, and a list that looks the same every day pushes all of it onto a person.

So we made the list carry some of it: open tasks change colour as they age. This post covers how that system is designed and built, plus the tradeoffs we accepted.

The problem, stated as constraints

Before choosing colours, I wrote down what the feature had to do:

  1. Readable at a glance. You shouldn't have to open a task or read a date. Scrolling the feed should be enough.
  2. Describe the task, not the person. No scores, streaks or "you're behind" copy. Age is a property of the item.
  3. Don't turn into an alarm panel. A household list with 30 items can't be 30 red cards. Urgency has to be rare enough to mean something.
  4. Protect the person who asked. The worst case is a partner's request quietly ageing out of view, so that case gets its own cue.
  5. Cost nothing on the backend. No cron jobs, no extra writes, no migrations when we tune the thresholds.

The last constraint shaped the implementation more than anything else.

Design: four bands, three surfaces

An open card has three surfaces that change colour together:

  • a 5pt accent bar down the left edge
  • the icon tile behind the task's icon
  • the age label in the attribution line ("by Sam · 6 days ago")

They map to four age bands:

Band Age (whole days) Label Accent bar Icon tile Label text
Fresh <= 1 "Just added" #A78BFA #F1EDFE #9C8FB4
Warming <= 4 "N days ago" #F5B41C #FEF6E0 #B45309
Warm <= 7 "N days ago" #FB7315 #FFF0E3 #C2410C
Urgent > 7 "N days ago" #F5545C #FDECEC #E1373F

A few deliberate choices:

  • Fresh is violet, not green. Violet is Carry's brand colour, so a new task looks like a normal part of the app. Green means one thing only: done. A completed card gets a solid green tile with a white check, and its age label is replaced by "Done by …".
  • The ramp is slow. Amber and orange say "this has been sitting a while," not "you failed." Red only shows up after a full week.
  • Each band has three tones. The bar is saturated so it reads in peripheral vision. The tile is a pale tint so the icon stays legible. The label is a darker shade of the same hue so it passes contrast on a cream card. If one hex were reused across all three, you'd get either a garish tile or an illegible label.
  • The bar is thin. At 5pt it's enough to scan a column of cards without colour taking over the layout.
flowchart LR A[item.createdAt] --> B[getAgeDays] B --> C{getAgeStyle} C -->|<= 1| F[Fresh · violet] C -->|<= 4| W1[Warming · amber] C -->|<= 7| W2[Warm · orange] C -->|> 7| U[Urgent · red] F & W1 & W2 & U --> R[accent bar · icon tile · age label] B --> P{ageDays >= 7 AND partner's AND open?} P -->|yes| S["Still waiting on this one"]

Implementation

Stack context

Carry uses Expo Router on Hermes. Household state lives in an AppContext that applies changes optimistically and syncs with Supabase (Postgres plus realtime). A task row has a created_at timestamp, which reaches the client as createdAt on a HouseholdItem.

There's no colour or urgency column. Age is derived on the client at render time from createdAt. That decision covers constraint 5:

  • No backend jobs. Nothing has to wake up at midnight and re-tag rows.
  • No write amplification. Ageing doesn't generate realtime events, so the other partner's phone isn't flooded with updates just because time passed.
  • Thresholds are just code. Changing a band boundary ships in an app update (or an OTA update) with no migration.
  • Works with optimistic state. A task created offline already has a local createdAt, so it renders as Fresh immediately. It doesn't wait for a server round-trip to get a colour.

Computing age

This is the real helper from ItemCard.tsx:

function getAgeDays(createdAt: string): number { const created = new Date(createdAt).getTime(); return Math.floor((Date.now() - created) / (1000 * 60 * 60 * 24)); } 

It counts elapsed 24-hour periods, not calendar days. I'll come back to that under tradeoffs.

Mapping age to a style

getAgeStyle is a plain function, not a hook. It returns tokens, not strings. Copy is resolved at render time through i18n, because "Just added" is a fixed phrase and "N days ago" has to pluralise per locale:

function getAgeStyle(days: number): { accentBar: string; tileBg: string; labelColor: string; labelKind: 'justAdded' | 'daysAgo'; } { if (days <= 1) return { accentBar: Colors.ageFreshBar, // #A78BFA tileBg: Colors.ageFreshTile, // #F1EDFE labelColor: Colors.ageFreshLabel, // #9C8FB4 labelKind: 'justAdded', }; if (days <= 4) return { accentBar: Colors.ageWarmingBar, // #F5B41C tileBg: Colors.ageWarmingTile, // #FEF6E0 labelColor: Colors.ageWarmingLabel,// #B45309 labelKind: 'daysAgo', }; if (days <= 7) return { accentBar: Colors.ageWarmBar, // #FB7315 tileBg: Colors.ageWarmTile, // #FFF0E3 labelColor: Colors.ageWarmLabel, // #C2410C labelKind: 'daysAgo', }; return { accentBar: Colors.ageUrgentBar, // #F5545C tileBg: Colors.ageUrgentTile, // #FDECEC labelColor: Colors.ageUrgentLabel, // #E1373F labelKind: 'daysAgo', }; } 

All twelve hex values are named tokens in constants/colors.ts. Components never hard-code them, which made the due-date work below a two-minute change.

Wiring it into the card

The card resolves the style once and lets "done" override it:

const ageDays = getAgeDays(item.createdAt); const ageStyle = getAgeStyle(ageDays); const isDone = item.status === 'done'; const tileBg = isDone ? Colors.success : ageStyle.tileBg; const accentColor = isDone ? Colors.success : ageStyle.accentBar; // ... <View style={[styles.accentBar, { backgroundColor: accentColor }]} /> <View style={[styles.iconTile, { backgroundColor: tileBg }]}> {isDone ? <Ionicons name="checkmark" size={24} color="#fff" /> : <MaterialCommunityIcons name={icon} size={24} color={accentColor} />} </View> 
accentBar: { width: 5, alignSelf: 'stretch' }, 

The icon glyph uses the accent colour while the tile behind it uses the pale tint. That keeps it a two-tone icon in the same hue rather than another competing colour.

Due dates reuse the palette

Some tasks have a due date as well as an age. I didn't want a second colour language, so the due pill reuses the same tokens, counting down to the date instead of up from creation:

function getDueStyle(dueDate: string) { const due = new Date(dueDate); due.setHours(0, 0, 0, 0); const today = new Date(); today.setHours(0, 0, 0, 0); const days = Math.floor((due.getTime() - today.getTime()) / 86_400_000); if (days < 0) return { bg: Colors.ageUrgentTile, color: Colors.ageUrgentLabel }; // overdue if (days <= 3) return { bg: Colors.ageWarmTile, color: Colors.ageWarmLabel }; // soon return { bg: Colors.ageWarmingTile, color: Colors.ageWarmingLabel }; // later } 

Users learn one rule: warmer means more pressing.

"Still waiting on this one"

Colour alone wasn't enough for constraint 4. A red card is ambiguous: did I forget my own thing, or is my partner waiting on me? Those are emotionally different situations, so the second one gets a soft italic line under the title:

const isMyItem = item.createdBy === currentUser?.id; const showPartnerLabel = !isDone && !isMyItem && ageDays >= 7; {showPartnerLabel && ( <Text style={styles.partnerAwarenessLabel}> {t('components.itemCard.stillWaiting')} </Text> )} 

Three conditions, each for a reason:

  • !isMyItem: It never shows on your own tasks. Nudging yourself about your own list is noise, and the colour already covers it.
  • ageDays >= 7: A week is roughly when "they'll get to it" becomes "they forgot," and it's also where the card is about to turn red.
  • !isDone: Once a task is done, nobody is waiting. When the list shows completed items, the line has to go away with the age label.

The style is deliberately quiet: secondary grey and italic, with no icon and no badge. It's meant to read like the card is mentioning something, not raising an alert.

Tradeoffs and things I'd revisit

Elapsed days vs calendar days. getAgeDays divides elapsed milliseconds by 24 hours, while getDueStyle normalises both dates to local midnight. So a task added at 11pm is still "Just added" at 9am the next day, while a due date flips over exactly at midnight. For age, that's what we want: it's forgiving and doesn't jump at midnight. It does mean the two helpers define "a day" differently, and DST shifts can move an age boundary by an hour. I've chosen to leave that inconsistency explicit rather than hide it.

Age doesn't move while the screen is open. Age is computed during render, so a card only changes band when something re-renders it. In practice that's constant: realtime updates, focus changes, pull-to-refresh. A card can still sit at "4 days" past the boundary if you leave the app open overnight. A ticking clock context would fix it at the cost of re-rendering the whole feed. So far that hasn't been worth it.

createdAt is the right clock, mostly. Age starts at creation, not at the last edit. Renaming a task shouldn't reset its urgency, or people would "refresh" tasks by editing them. The downside is that a task deliberately parked for later still turns red. For now, due dates and recurrence cover that case, and a "snooze" that resets the age clock is on the list.

Client-side means it's not queryable. Because there's no column, the server can't query "all urgent tasks" for a digest or push notification without duplicating the band logic. If we build server-side nudges, the thresholds should live in one shared module (or a Postgres function) so the bands stay consistent everywhere.

Colour isn't the only signal. About 1 in 12 men have some form of colour vision deficiency, and amber vs orange is a hard pair. That's why every card also shows the age as text ("6 days ago") and the partner case uses words. The colour speeds up scanning, but the meaning is always in the text too.

Thresholds are guesses we're validating. 1 / 4 / 7 came from how often couples tend to check a shared list and how long people are willing to wait before raising something. They're constants, so tuning them is cheap. The real question is whether the share of red cards in an average feed stays low enough for red to keep its meaning.

Takeaways

If you're building any shared list in React Native, whether chores, tickets or a reading list, the pattern carries over:

  • Derive staleness from a timestamp you already store. Don't persist it.
  • Use a small number of bands with three tones each (bar, tint, text) from named tokens.
  • Reuse the palette for related signals like due dates so people learn one rule.
  • Save explicit copy for the one case where colour is ambiguous and a person is waiting on someone else.

The feature is about forty lines of code. Most of the work was deciding what not to show, so the list could keep track of what's been waiting without turning into another thing people have to manage.

原文:https://www.a8q9.cn/en/article/designing-an-aging-urgency-system-so-shared-tasks-don-t-silently-die-3d3b7e.html

相关文章