From 78fbf45bd614e1b3a3da8f4ed64e785fff0a45a7 Mon Sep 17 00:00:00 2001
From: Fabio Manganiello <fabio@manganiello.tech>
Date: Mon, 24 Feb 2025 00:28:59 +0100
Subject: [PATCH] Support for paginated results.

---
 frontend/src/components/Map.vue         | 99 +++++++++++++++++++++----
 frontend/src/components/filter/Form.vue | 61 ++++++++++-----
 frontend/src/mixins/Api.vue             |  7 +-
 frontend/src/mixins/Paginate.vue        | 53 +++++++++++++
 frontend/src/mixins/Points.vue          |  6 ++
 frontend/src/models/GPSPoint.ts         |  2 +
 frontend/src/models/LocationQuery.ts    |  4 +-
 src/models/LocationRequest.ts           |  5 +-
 8 files changed, 200 insertions(+), 37 deletions(-)
 create mode 100644 frontend/src/mixins/Paginate.vue

diff --git a/frontend/src/components/Map.vue b/frontend/src/components/Map.vue
index 4822a7b..851409c 100644
--- a/frontend/src/components/Map.vue
+++ b/frontend/src/components/Map.vue
@@ -9,7 +9,14 @@
 
         <div class="controls">
           <div class="form-container" v-if="showControls">
-            <FilterForm :value="locationQuery" @refresh="locationQuery = $event" />
+            <FilterForm :value="locationQuery"
+                        :disabled="loading"
+                        :has-next-page="hasNextPage"
+                        :has-prev-page="hasPrevPage"
+                        @refresh="locationQuery = $event"
+                        @reset-page="locationQuery.minId = locationQuery.maxId = undefined"
+                        @next-page="fetchNextPage"
+                        @prev-page="fetchPrevPage" />
           </div>
           <FilterButton @input="showControls = !showControls"
                         :value="showControls" />
@@ -35,6 +42,7 @@ import FilterForm from './filter/Form.vue';
 import GPSPoint from '../models/GPSPoint';
 import LocationQuery from '../models/LocationQuery';
 import MapView from '../mixins/MapView.vue';
+import Paginate from '../mixins/Paginate.vue';
 import Points from '../mixins/Points.vue';
 import Routes from '../mixins/Routes.vue';
 import URLQueryHandler from '../mixins/URLQueryHandler.vue';
@@ -45,6 +53,7 @@ export default {
   mixins: [
     Api,
     MapView,
+    Paginate,
     Points,
     Routes,
     URLQueryHandler,
@@ -58,13 +67,14 @@ export default {
 
   data() {
     return {
-      gpsPoints: [] as GPSPoint[],
       loading: false,
       locationQuery: new LocationQuery({}),
       map: null as Nullable<Map>,
+      mappedPoints: [] as Point[],
       mapView: null as Nullable<View>,
       pointsLayer: null as Nullable<VectorLayer>,
       popup: null as Nullable<Overlay>,
+      queryInitialized: false,
       routesLayer: null as Nullable<VectorLayer>,
       selectedPoint: null as Nullable<GPSPoint>,
       showControls: false,
@@ -84,11 +94,29 @@ export default {
       }
     },
 
+    fetchNextPage() {
+      const nextPageQuery = this.nextPageQuery()
+      if (!nextPageQuery) {
+        return
+      }
+
+      this.locationQuery = nextPageQuery
+    },
+
+    fetchPrevPage() {
+      const prevPageQuery = this.prevPageQuery()
+      if (!prevPageQuery) {
+        return
+      }
+
+      this.locationQuery = prevPageQuery
+    },
+
     createMap(gpsPoints: GPSPoint[]): Map {
-      const points = gpsPoints.map((gps: GPSPoint) => new Point([gps.longitude, gps.latitude]))
-      this.pointsLayer = this.createPointsLayer(points)
-      this.routesLayer = this.createRoutesLayer(points)
-      this.mapView = this.createMapView(gpsPoints)
+      this.mappedPoints = this.toMappedPoints(gpsPoints)
+      this.pointsLayer = this.createPointsLayer(this.mappedPoints)
+      this.routesLayer = this.createRoutesLayer(this.mappedPoints)
+      this.mapView = this.mapView || this.createMapView(gpsPoints)
       const map = new Map({
         target: 'map',
         layers: [
@@ -142,13 +170,56 @@ export default {
 
   watch: {
     locationQuery: {
-      async handler() {
-        this.setQuery(this.locationQuery)
-        this.gpsPoints = this.groupPoints(await this.fetch())
-        const points = this.gpsPoints.map((gps: GPSPoint) => new Point([gps.longitude, gps.latitude]))
-        this.refreshMapView(this.mapView, this.gpsPoints)
-        this.refreshPointsLayer(this.pointsLayer, points)
-        this.refreshRoutesLayer(this.routesLayer, points)
+      async handler(newQuery, oldQuery) {
+        const isFirstQuery = !this.queryInitialized
+
+        // If startDate/endDate have changed, reset minId/maxId
+        if (!isFirstQuery &&
+          (newQuery.startDate !== oldQuery.startDate || newQuery.endDate !== oldQuery.endDate)
+        ) {
+          newQuery.minId = undefined
+          newQuery.maxId = undefined
+          this.hasNextPage = true
+          this.hasPrevPage = true
+        }
+
+        // Results with maxId should be retrieved in descending order,
+        // otherwise all results should be retrieved in ascending order
+        newQuery.order = newQuery.maxId ? 'desc' : 'asc'
+        this.setQuery(newQuery)
+        this.queryInitialized = true
+
+        if (!isFirstQuery) {
+          const gpsPoints = await this.fetch()
+
+          // If there are no points, and minId/maxId are set, reset them
+          // and don't update the map (it means that we have reached the
+          // start/end of the current window)
+          if (gpsPoints.length < 2 && (newQuery.minId || newQuery.maxId)) {
+            if (newQuery.minId) {
+              this.hasNextPage = false
+            }
+
+            if (newQuery.maxId) {
+              this.hasPrevPage = false
+            }
+
+            newQuery.minId = oldQuery.minId
+            newQuery.maxId = oldQuery.maxId
+            return
+          }
+
+          this.gpsPoints = gpsPoints
+          this.hasNextPage = gpsPoints.length > 1
+          this.hasPrevPage = gpsPoints.length > 1
+        }
+
+        this.mappedPoints = this.toMappedPoints(this.gpsPoints)
+        if (this.mapView) {
+          this.refreshMapView(this.mapView, this.gpsPoints)
+          this.refreshPointsLayer(this.pointsLayer, this.mappedPoints)
+          this.refreshRoutesLayer(this.routesLayer, this.mappedPoints)
+        }
       },
       deep: true,
     },
@@ -156,7 +227,7 @@ export default {
 
   async mounted() {
     this.initQuery()
-    this.gpsPoints = this.groupPoints(await this.fetch())
+    this.gpsPoints = await this.fetch()
     this.map = this.createMap(this.gpsPoints)
   },
 }
diff --git a/frontend/src/components/filter/Form.vue b/frontend/src/components/filter/Form.vue
index d8989ab..105cbe1 100644
--- a/frontend/src/components/filter/Form.vue
+++ b/frontend/src/components/filter/Form.vue
@@ -11,30 +11,31 @@
                @input="newFilter.startDate = startPlusHours($event.target.value, 0)"
                @change="newFilter.startDate = startPlusHours($event.target.value, 0)"
                :value="toLocalString(newFilter.startDate)"
+               :disabled="disabled"
                :max="maxDate" />
 
         <div class="footer">
           <button type="button"
                   @click="newFilter.startDate = startPlusDays(newFilter.startDate, -7)"
-                  :disabled="!newFilter.startDate">-1w</button>
+                  :disabled="disabled || !newFilter.startDate">-1w</button>
           <button type="button"
                   @click="newFilter.startDate = startPlusDays(newFilter.startDate, -1)"
-                  :disabled="!newFilter.startDate">-1d</button>
+                  :disabled="disabled || !newFilter.startDate">-1d</button>
           <button type="button"
                   @click="newFilter.startDate = startPlusHours(newFilter.startDate, -1)"
-                  :disabled="!newFilter.startDate">-1h</button>
+                  :disabled="disabled || !newFilter.startDate">-1h</button>
           <button type="button"
                   @click="newFilter.startDate = startPlusDays(new Date(), 0)"
-                  :disabled="!newFilter.startDate">Now</button>
+                  :disabled="disabled || !newFilter.startDate">Now</button>
           <button type="button"
                   @click="newFilter.startDate = startPlusHours(newFilter.startDate, 1)"
-                  :disabled="!newFilter.startDate">+1h</button>
+                  :disabled="disabled || !newFilter.startDate">+1h</button>
           <button type="button"
                   @click="newFilter.startDate = startPlusDays(newFilter.startDate, 1)"
-                  :disabled="!newFilter.startDate">+1d</button>
+                  :disabled="disabled || !newFilter.startDate">+1d</button>
           <button type="button"
                   @click="newFilter.startDate = startPlusDays(newFilter.startDate, 7)"
-                  :disabled="!newFilter.startDate">+1w</button>
+                  :disabled="disabled || !newFilter.startDate">+1w</button>
         </div>
       </div>
 
@@ -46,40 +47,50 @@
                @input="newFilter.endDate = endPlusHours($event.target.value, 0)"
                @change="newFilter.endDate = endPlusHours($event.target.value, 0)"
                :value="toLocalString(newFilter.endDate)"
+               :disabled="disabled"
                :max="maxDate" />
 
         <div class="footer">
           <button type="button"
                   @click="newFilter.endDate = endPlusDays(newFilter.endDate, -7)"
-                  :disabled="!newFilter.endDate">-1w</button>
+                  :disabled="disabled || !newFilter.endDate">-1w</button>
           <button type="button"
                   @click="newFilter.endDate = endPlusDays(newFilter.endDate, -1)"
-                  :disabled="!newFilter.endDate">-1d</button>
+                  :disabled="disabled || !newFilter.endDate">-1d</button>
           <button type="button"
                   @click="newFilter.endDate = endPlusHours(newFilter.endDate, -1)"
-                  :disabled="!newFilter.endDate">-1h</button>
+                  :disabled="disabled || !newFilter.endDate">-1h</button>
           <button type="button"
                   @click="newFilter.endDate = endPlusDays(new Date(), 0)"
-                  :disabled="!newFilter.endDate">Now</button>
+                  :disabled="disabled || !newFilter.endDate">Now</button>
           <button type="button"
                   @click="newFilter.endDate = endPlusHours(newFilter.endDate, 1)"
-                  :disabled="!newFilter.endDate">+1h</button>
+                  :disabled="disabled || !newFilter.endDate">+1h</button>
           <button type="button"
                   @click="newFilter.endDate = endPlusDays(newFilter.endDate, 1)"
-                  :disabled="!newFilter.endDate">+1d</button>
+                  :disabled="disabled || !newFilter.endDate">+1d</button>
           <button type="button"
                   @click="newFilter.endDate = endPlusDays(newFilter.endDate, 7)"
-                  :disabled="!newFilter.endDate">+1w</button>
+                  :disabled="disabled || !newFilter.endDate">+1w</button>
         </div>
       </div>
     </div>
 
     <div class="pagination-container">
+      <div class="page-button-container">
+        <button type="button"
+                :disabled="disabled"
+                v-if="value.minId || value.maxId"
+                @click.stop="$emit('reset-page')">
+          <font-awesome-icon icon="fas fa-undo" />
+        </button>
+      </div>
+
       <div class="page-button-container">
         <button type="button"
                 @click="$emit('prev-page')"
                 title="Previous Results"
-                :disabled="!hasPrev">
+                :disabled="disabled || !hasPrevPage">
           <font-awesome-icon icon="fas fa-chevron-left" />
         </button>
       </div>
@@ -91,6 +102,7 @@
                @input="newFilter.limit = Number($event.target.value)"
                @change="newFilter.limit = Number($event.target.value)"
                :value="newFilter.limit"
+               :disabled="disabled"
                min="1" />
       </div>
 
@@ -98,14 +110,16 @@
         <button type="button"
                 @click="$emit('next-page')"
                 title="Next Results"
-                :disabled="!hasNext">
+                :disabled="disabled || !hasNextPage">
           <font-awesome-icon icon="fas fa-chevron-right" />
         </button>
       </div>
     </div>
 
     <div class="footer">
-      <button type="submit" :disabled="!changed">Apply</button>
+      <button type="submit" :disabled="disabled || !changed">
+        <font-awesome-icon icon="fas fa-check" />&nbsp;Apply
+      </button>
     </div>
   </form>
 </template>
@@ -117,15 +131,20 @@ export default {
   emit: [
     'next-page',
     'prev-page',
+    'reset-page',
     'refresh',
   ],
   props: {
     value: Object,
-    hasPrev: {
+    disabled: {
+      type: Boolean,
+      default: false,
+    },
+    hasPrevPage: {
       type: Boolean,
       default: true,
     },
-    hasNext: {
+    hasNextPage: {
       type: Boolean,
       default: true,
     },
@@ -333,5 +352,9 @@ export default {
       width: 100%;
     }
   }
+
+  button[type=submit] {
+    min-width: 10em;
+  }
 }
 </style>
diff --git a/frontend/src/mixins/Api.vue b/frontend/src/mixins/Api.vue
index 672d67e..6dd3466 100644
--- a/frontend/src/mixins/Api.vue
+++ b/frontend/src/mixins/Api.vue
@@ -26,8 +26,13 @@ export default {
 
       return (await response.json())
         .map((gps: any) => {
-          return new GPSPoint(gps)
+          return new GPSPoint({
+            ...gps,
+            // Normalize timestamp to Date object
+            timestamp: new Date(gps.timestamp),
+          })
         })
+        .sort((a: GPSPoint, b: GPSPoint) => a.timestamp.getTime() - b.timestamp.getTime())
     },
   },
 }
diff --git a/frontend/src/mixins/Paginate.vue b/frontend/src/mixins/Paginate.vue
new file mode 100644
index 0000000..f1254f7
--- /dev/null
+++ b/frontend/src/mixins/Paginate.vue
@@ -0,0 +1,53 @@
+<script lang="ts">
+import GPSPoint from '../models/GPSPoint';
+import LocationQuery from '../models/LocationQuery';
+
+export default {
+  data() {
+    return {
+      gpsPoints: [] as GPSPoint[],
+      hasNextPage: true,
+      hasPrevPage: true,
+    }
+  },
+
+  computed: {
+    newestPoint(): GPSPoint | undefined {
+      return this.gpsPoints[this.gpsPoints.length - 1] || undefined
+    },
+
+    oldestPoint(): GPSPoint | undefined {
+      return this.gpsPoints[0] || undefined
+    },
+  },
+
+  methods: {
+    prevPageQuery(): LocationQuery | null {
+      if (!this.oldestPoint) {
+        return null
+      }
+
+      return new LocationQuery({
+        ...this.locationQuery,
+        minId: undefined,
+        maxId: this.oldestPoint.id,
+        // Previous page results should be retrieved in descending order
+        order: 'desc',
+      })
+    },
+
+    nextPageQuery(): LocationQuery | null {
+      if (!this.newestPoint) {
+        return null
+      }
+
+      return new LocationQuery({
+        ...this.locationQuery,
+        minId: this.newestPoint.id,
+        maxId: undefined,
+        order: 'asc',
+      })
+    },
+  },
+}
+</script>
diff --git a/frontend/src/mixins/Points.vue b/frontend/src/mixins/Points.vue
index 2bccde4..2879adb 100644
--- a/frontend/src/mixins/Points.vue
+++ b/frontend/src/mixins/Points.vue
@@ -75,6 +75,12 @@ export default {
       source.changed()
     },
 
+    toMappedPoints(gpsPoints: GPSPoint[]): Point[] {
+      return this.groupPoints(gpsPoints).map(
+        (gps: GPSPoint) => new Point([gps.longitude, gps.latitude])
+      )
+    },
+
     getCenterAndZoom(points: GPSPoint[]) {
       if (!points?.length) {
         return {
diff --git a/frontend/src/models/GPSPoint.ts b/frontend/src/models/GPSPoint.ts
index 5b65157..67e28db 100644
--- a/frontend/src/models/GPSPoint.ts
+++ b/frontend/src/models/GPSPoint.ts
@@ -1,4 +1,5 @@
 class GPSPoint {
+  public id: number;
   public latitude: number;
   public longitude: number;
   public altitude: number;
@@ -9,6 +10,7 @@ class GPSPoint {
   public timestamp: Date;
 
   constructor(public data: any) {
+    this.id = data.id;
     this.latitude = data.latitude;
     this.longitude = data.longitude;
     this.altitude = data.altitude;
diff --git a/frontend/src/models/LocationQuery.ts b/frontend/src/models/LocationQuery.ts
index 4a320e9..484098c 100644
--- a/frontend/src/models/LocationQuery.ts
+++ b/frontend/src/models/LocationQuery.ts
@@ -1,6 +1,6 @@
 class LocationQuery {
   public limit: number = 250;
-  public offset: number = 0;
+  public offset: number | null = null;
   public startDate: Date | null = null;
   public endDate: Date | null = null;
   public minId: number | null = null;
@@ -8,6 +8,7 @@ class LocationQuery {
   public country: string | null = null;
   public locality: string | null = null;
   public postalCode: string | null = null;
+  public order: string = 'asc';
 
   constructor(public data: any) {
     this.limit = data.limit || this.limit;
@@ -19,6 +20,7 @@ class LocationQuery {
     this.country = data.country || this.country;
     this.locality = data.locality || this.locality;
     this.postalCode = data.postalCode || this.postalCode;
+    this.order = data.order || this.order;
 
     if (!(this.startDate && this.endDate)) {
       // Default to the past 24 hours
diff --git a/src/models/LocationRequest.ts b/src/models/LocationRequest.ts
index 64d4f73..ece8148 100644
--- a/src/models/LocationRequest.ts
+++ b/src/models/LocationRequest.ts
@@ -25,7 +25,8 @@ class LocationRequest {
     this.country = req.country;
     this.locality = req.locality;
     this.postalCode = req.postalCode;
-    this.orderBy = req.orderBy || 'timestamp';
+    this.orderBy = req.orderBy || this.orderBy;
+    this.order = req.order || this.order;
   }
 
   private initNumber(key: string, req: any): void {
@@ -93,7 +94,7 @@ class LocationRequest {
     }
 
     queryMap.where = where;
-    queryMap.order = [[colMapping[this.orderBy], this.order]];
+    queryMap.order = [[colMapping[this.orderBy], this.order.toUpperCase()]];
     return queryMap;
   }
 }