Skip to main content
#BuiltWithHERE 10 min read

How I Created a Last-Minute COVID-19 Vaccine Appointment Booking System

How I Created a Last-Minute COVID-19 Vaccine Appointment Booking System

Early May, 2021, I created the website coronaflash.com with the aim to get hassle free COVID-19 vaccination appointments in Berlin (so far). The website which was started as a personal project became quite popular in Germany with more than 10k daily visitors.

Since the beginning of June, the website also acts as a scalable platform to bring together doctors who are offering last minute and short-term appointments for vaccinations. Within the first two weeks, more than 100 vaccination appointments were delivered that way.

This post focuses on the modelling and implementation of the location-based match-making algorithm behind that platform.

The Problem Statement 

A discussion over a glass of wine with my doctor friends made me understand the challenges they are facing during their daily work to get people vaccinated. Doctors had to order vaccines weeks in advance and schedule dozens to hundreds of vaccination appointments. However, often vaccines get delivered with delays or people don’t come to their appointment. Hence, even with perfect planning, they may have vaccines available on short notice, and it turned out to be very hard to cover exactly these short notice appointments: Telephoning the contact lists for those willing to be vaccinated on short notice is like finding a time bomb in a haystack. Occasionally, they had to throw away vaccines.  

Hence, my friends and I had the idea whether it was possible to leverage coronaflash.com to solve this problem. Of course, I could just have announced appointments on coronaflash.com, but I wanted to do better. I wanted to deliver the contact information of a doctor to exactly the number of people they had vaccines for. 

Modeling the Problem 

The problem presented itself as a classical exchange setup where you have demand and supply that you want to match. I modeled both demand and supply and created a matchmaker. The basic idea for matching was to use the distance of people from the doctor’s office. In the scenario where demand is more than the supply of vaccines, people closer to the doctor would get the preference. 

On the supply side, I had the doctors who offered one or more vaccination appointments – called tickets in the model — with constraints on age and gender. I discussed the necessary attributes with my doctor friends and modeled this as a Dart class that leverages the freezed package to create JSON bindings: 

Copied
        
@freezed 
class BatchDetails with _$BatchDetails { 
  factory BatchDetails({ 
    required String vaccine, 
    required int maxDistanceKm, 
    required int ticketCount, 
    required int minAge, 
    required int maxAge, 
    required String? gender, 
    required String? type, 
    required String? region, 
    required int created, 
    required int expires, 
    required String notes, 
  }) = _BatchDetails; 

  factory BatchDetails.fromJson(Map<String, dynamic> json) =html> 
    _$BatchDetailsFromJson(json); 
} 

  

Quickly, I added a German form to coronaflash.com for doctors to submit a batch of available appointments:

You can check out the form here. 

As soon as a doctor submitted a report, the available appointments would be published on coronaflash.com. Moreover, the matchmaker bot would make an announcement to the subscribers of the Telegram Channel t.me/coronaflashAI: 

You must have noticed that the BatchDetails data class does not contain details about the doctor’s office. The reason being, I only wanted to reveal the actual contact information of the doctor once the platforms found a candidate that matches the distance criteria and were able to assign it to one of the available tickets. Hence, I captured the doctor’s contact information in a separate data class: 

Copied
        
@freezed 
class BatchContact with _$BatchContact { 
  factory BatchContact({ 
    required String name, 
    required String address, 
    required String phoneNumber, 
  }) = _BatchContact; 

  factory BatchContact.fromJson(Map<String, dynamic> json) => 
    _$BatchContactFromJson(json); 
} 

  

Finally, I created a third class that ties both BatchDetails and BatchContact together: 

Copied
        
@freezed 
class Batch with _$Batch { 
  factory Batch({ 
    required String id, 
    required String status, 
    required BuiltList<String> tickets, 
    required BatchDetails details, 
    required BatchContact contact, 
  }) = _Batch; 

  factory Batch.fromJson(Map<String, dynamic> json) => 
    _$BatchFromJson(json); 
}  

  

On the demand side, I did not want to handle people’s private data, so I came up with a simple anonymous approach: People get a random ID and can submit their postcode together with this random ID. The whole state of such an application is captured in a single data class: 

Copied
        
@freezed 

class Application with _$Application { 

  factory Application({ 
    required String id, 
    required String batchId, 
    required String postcode, 
    required String status, 
    required String? ticket, 
    required int updated, 
  }) = _Application; 

factory Application.fromJson(Map<String, dynamic> json) => 
    _$ApplicationFromJson(json); 
} 

  

Once available appointments were published, a person could submit their postcode using a simple form: 

Match Maker Leveraging the HERE Platform 

In cases where demand was more than supply, I choose to use the distance between the doctor and a candidate as the main criteria to select between multiple candidates. To compute the distance, I had to convert both the doctor’s address and the candidates’ postcodes into geo-coordinates. This conversion is well known as “geocoding”. 

To hold the geo-coordinates and compute the distance, I created an extended data class: 

Copied
        
@freezed 

class GeoPoint with _$GeoPoint { 
  factory GeoPoint({ 
    required double lat, 
    required double lon, 
  }) = _GeoPoint; 
 
  const GeoPoint._(); 
 
  double distance(GeoPoint to) { 
    final pLat = lat * _radPerDeg; 
    final pLon = lon * _radPerDeg;
    final qLat = to.lat * _radPerDeg; 
    final qLon = to.lon * _radPerDeg; 

    final deltaLat = qLat - pLat; 
    final deltaLon = qLon - pLon; 

    final sinDeltaLat2 = sin(deltaLat / 2); 
    final sinDeltaLon2 = sin(deltaLon / 2); 

    final a = sinDeltaLat2 * sinDeltaLat2 + 
              cos(pLat) * cos(qLat) * sinDeltaLon2 * sinDeltaLon2; 
    return 2 * atan2(sqrt(a), sqrt(1 - a)) * _earthRadius; 
  } 

  static const _radPerDeg = pi / 180; 
  static const _earthRadius = 6378137.0; 
 
  factory GeoPoint.fromJson(Map<String, dynamic> json) =>
    _$GeoPointFromJson(json); 
}

  

For geocoding, the HERE Platform offers a nice REST API. All I had to do is to sign up and get a private API key for coronaflash.com which is required to make calls to the HERE Geocoding and Search API. 

Tailored to my specific use-case, I created a few functions to call the API and convert the result into instances of the above GeoPoint class: 

Copied
        
/// Call `url` and return decoded JSON. 
Future<dynamic> fetchJson(Uri url) async { 
  final response = await http.get(url);
  
  if (response.statusCode != 200) { 
    throw HttpException( 
      '${response.statusCode} ${response.reasonPhrase}', 
    ); 
  } 

  final responseBody = (await http.get(url)).body; 
  return jsonDecode(responseBody); 
} 

const baseUrl = "https://geocode.search.hereapi.com/v1/"; 

/// Geocode given `address`. 
Future<GeoPoint?> geocode( 

  String address, { 
  required String apiKey, 
}) async { 
  var url = Uri.parse('${baseUrl}geocode'); 
  url = url.replace( 
    queryParameters: <String, String>{ 
      'apiKey': apiKey, 
      'q': address, 
    }, 
  ); 

  final dynamic response = await fetchJson(url); 
  final items = response['items'] as List<dynamic>; 

  if (items.length != 1) { 
    return null; 
  } 

  final dynamic position = items[0]['position'];
  
  return GeoPoint( 
    lat: position['lat'] as double, 
    lon: position['lng'] as double, 
  ); 
} 

/// Geocode given `postcode` and `country`. 
Future<GeoPoint?> geocodePostcode( 
  String postcode, 
  String country, { 
  required String apiKey, 
}) async { 
  var url = Uri.parse('${baseUrl}geocode'); 
  url = url.replace( 
    queryParameters: <String, String>{ 
      'apiKey': apiKey, 
      'qq': 'postalCode=$postcode;country=$country', 
    }, 
  ); 

  final dynamic response = await fetchJson(url); 
  final items = response['items'] as List<dynamic>;
  
  if (items.length != 1) { 
    return null; 
  } 

  final dynamic position = items[0]['position']; 

  return GeoPoint( 
    lat: position['lat'] as double, 
    lon: position['lng'] as double, 
  ); 
} 

  

Doing a simple test run, we can see how they work: 

Copied
        
Future<void> main() async { 
  final brandenBurgerGate = await geocode( 
    'Pariser Platz, 10117 Berlin, Deutschland', 
    apiKey: hereApiKey, 
  ); 

  final postcode10245 = await geocodePostcode( 
    '10245', 
    'Germany', 
    apiKey: hereApiKey, 
  ); 

  final distance = brandenBurgerGate!.distance(postcode10245!); 

  print('$brandenBurgerGate'); 
  print('$postcode10245'); 
  print('$distance'); 
}

  

The output of the program is: 

Copied
        
GeoPoint(lat: 52.51636, lon: 13.37821) 
GeoPoint(lat: 52.50215, lon: 13.45777) 
5617.717396204784  

  

Finally, given a Batch and a list of Applications, I created a function that filters out invalid postcodes and selects the closest applications with respect to the location of the doctor’s office: 

Copied
        
Future<List<Application>> match( 
  Batch batch, 
  
  List<Application> applications, { 
  required String apiKey, 
}) async { 
  // geocode doctor's address 
  final doctorLocation = await geocode( 
    batch.contact.address, 
    apiKey: apiKey, 
  ); 

  if (doctorLocation == null) { 
    return []; 
  } 

  // geocode all application postcodes and filter out invalid ones 
  // compute their distance to the doctor 
  final applicationsWithLocation = (await Future.wait( 
    applications.map( 
      (e) async { 
        final location = await geocodePostcode( 
          e.postcode, 
          'Germany', 
          apiKey: apiKey, 
        ); 

        if (location == null) { 
          return null; 
        } 

        final distance = doctorLocation.distance(location); 

        return MapEntry( 
          e, 
          distance, 
        ); 
      }, 
    ), 
  )) 
      .whereNotNull() 
      .toList(); 

  // sort by distance and time 
  applicationsWithLocation.sort( 
    (a, b) { 
      // the closer one is favoured 
      final distanceCompared = a.value.compareTo(b.value); 

      if (distanceCompared != 0) { 
        return distanceCompared; 
      } 

      // if both are at the same distance, the earlier is favoured 
      final timeCompared = a.key.updated.compareTo(b.key.updated); 
      return timeCompared; 
    }, 
  ); 

  // take as many as we have tickets for 
  return applicationsWithLocation // 
      .take(batch.details.ticketCount) 
      .map((e) => e.key) 
      .toList(); 
}

  

You can imagine that there is some more dancing involved to make the whole process work, but I think we covered the essential pieces here. 

After the bot was able to match an application to an offer, the candidate gets a ticket assigned that is stored in Application.ticket. The candidate will be automatically redirected to a webpage that finally reveals the contact information of the doctor: 

The candidate is asked to call the doctor and make an appointment and is offered directions to the doctor’s office as well as a link to print out some necessary paperwork. 

A raw model for hyper-local markets? 

If we step a bit back, we see that the platform I have built with coronaflash.com is actually a raw model for use-cases where one wants to bring together local demand and supply. I don’t see the approach limited to vaccines, but it could work for flea markets, cakes baked in your community, or pet-sitting.  

However, that kind of market requires a powerful platform that offers simple access to building blocks to solve problems in the location space. Even though I am working for HERE Technologies, I have built my website like an external developer would do: Signing up to the HERE Platform, reading the docs, and building a custom client to call the services. 

I am happy with the result and the users of my website love it. 

Daniel Rolf

Daniel Rolf

Have your say

Sign up for our newsletter

Why sign up:

  • Latest offers and discounts
  • Tailored content delivered weekly
  • Exclusive events
  • One click to unsubscribe