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.
Written 2 years ago — it reflects the conditions of that time.
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.
Experiments on this topic
Swipenor: if the client computes the score, what does the server trust?
A swipe-based true/false trivia game; the real subject is not the game but making a client-computed score verifiable through a signature.
What it does today
The swipe-based true/false quiz runs end to end in its core loop as a mobile app; the score is produced on the client but verified server-side with a server_nonce and an HMAC signature. swipenor.com is the app's landing page, not the game itself.
Garajdaş: model-specific community without the forum sprawl
A Reddit-like community platform where every vehicle make and model has its own club, holding ICE and EV under one roof; web and mobile run in parallel.
What it does today
A community platform where every car make and model has its own club; identity, content, notifications and moderation all run on a single-schema API, with the web and mobile clients sharing that one schema. Anyone looking for a community specific to their car's model can join today.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.