Feeding mobile and web with the same calendar logic (Looplio diary)
The practical decisions and lessons learned while consistently serving a single business logic to both React Native and web clients in Looplio.
While building Looplio solo, the problem that demanded the most attention was this: serving the same calendar logic to both mobile and web clients. It sounds straightforward — “write an API, let both sides consume it.” In practice, though, two clients can have different expectations, presentation needs, and temporal behaviors around the same data. Failing to spot that difference early creates a double maintenance burden down the road.
In April I learned that lesson a bit late.
One API, different expectations
At the core of Looplio sits a recurrence engine. A user defines a rule like “every Monday” or “first day of the month”; the API expands that rule over a given time range and produces concrete dates.
The web client uses this data in a monthly grid view: all events within the month arrive in one shot and get rendered into a calendar table. Mobile works differently: the user scrolls down and the next events stream in — a kind of paginated time feed.
My first mistake was starting to write separate endpoints for the two clients — /api/events/monthly and /api/events/upcoming, that sort of thing. This feels fast in the short run, but it means maintaining the same logic in two places. When a business rule changes, you have to update it twice.
A single parameterized endpoint
The solution is actually simple: one endpoint, called with different parameters.
GET /api/events?from=2024-04-01&to=2024-04-30
GET /api/events?from=2024-04-07&limit=20
The endpoint uses the same query engine in both cases; only which dates are covered and how many records are returned changes. When a new filtering rule is added, I update one file, not two.
In Laravel, writing a query builder class for this kind of flexible querying works well:
class EventQueryBuilder
{
public function __construct(
private readonly User $user,
private readonly EventRepository $repo
) {}
public function forRange(CarbonInterface $from, CarbonInterface $to): Collection
{
return $this->repo->expandRecurrences($this->user, $from, $to);
}
public function upcoming(CarbonInterface $after, int $limit = 20): Collection
{
return $this->repo->expandRecurrences($this->user, $after, $after->copy()->addMonths(3))
->filter(fn ($e) => $e->date->gt($after))
->take($limit)
->values();
}
}
The controller receives this class and calls the right method based on the parameters. Business logic lives inside the layer; the controller only does coordination.
Resource formatting
The two clients sometimes need different fields from the same data. The web client wants all the metadata; the mobile app prefers a leaner response for performance.
For this I use conditional fields inside the API resource class:
class EventResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'date' => $this->date->toIso8601String(),
'color' => $this->color,
$this->mergeWhen($request->is('*/web/*'), [
'recurrence_rule' => $this->recurrence_rule,
'notes' => $this->notes,
'created_at' => $this->created_at->toIso8601String(),
]),
];
}
}
Instead of checking for a /web/ or /mobile/ segment in the URL, you could also use a request header or a query parameter. The key point is that the resource class handles this decision — clients receive different payloads, but the business logic stays in one place.
The timezone question
The classic trap of calendar apps: when you store UTC in the database, you have to decide in which timezone you’ll return dates to the client.
My decision: the server always returns UTC; clients display in their own timezone. On the React Native side, Intl.DateTimeFormat or date-fns-tz handles that conversion. The web side does the same.
This decision keeps the API free from timezone concerns. If a user changes their timezone, the API response doesn’t change; the client recalculates. Consistent and predictable.
What I learned
Writing separately for two clients feels fast at the start. But after adding a few features, the maintenance burden compounds. When a decision changes, I have to search in two places, test in two places.
If you are writing an API and have more than one client, the most valuable thing you can do is ask this question: “Do these two clients actually want a different business rule, or do they want the same data presented differently?” Most of the time it’s the latter. And then the answer is: a single business logic with a parameterized interface.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.