Keen to hear some experiences with this. I have been using this feature with capturing field data for wildlife surveys where service is unreliable in remote areas. It’s a great work around where I’ve been able to cache all my lookup table data for forms and save survey data while offline with a simple click to upload from LocalStorage back to the db once back online. Here’s a sample of my code for anyone interested, hope it helps!
- Save Record to LocalStorage from mobile app form:
let inputStr = txtDateTime.value; // "Saturday 21 March 2026 at 6:22 pm"
// Remove day name ("Saturday") since JS can't parse it
let cleanedStr = inputStr.replace(/^[A-Za-z]+ /, ""); // "21 March 2026 at 6:22 pm"
// Replace ' at ' with a space so JS can parse it
cleanedStr = cleanedStr.replace(" at ", " "); // "21 March 2026 6:22 pm"
// Parse with Date
let parsedDate = new Date(cleanedStr);
// Validate
if (isNaN(parsedDate.getTime())) {
throw new Error("Still invalid date: " + cleanedStr);
}
// Convert to ISO string (Postgres-friendly)
const startDateForDB = parsedDate.toISOString();
console.log("Parsed ISO date:", startDateForDB);
const existing = Array.isArray(localStorage.values.cached_Surveys)
? localStorage.values.cached_Surveys
: [];
// ✅ AUTO-INCREMENT ID
const nextId = existing.length > 0
? Math.max(...existing.map(x => x.id || 0)) + 1
: 1;
// 2. New record
const newSurvey = {
// Meta data
id: nextId,
Collector: current_user.fullName,
date: getCurDateTime.data.brisbaneISO,
formatteddate: txtDateTime.value,
createdAt: new Date().toISOString(),
synced: false,
// Captured location data
latitude: numLatitude.value,
longitude: numLongitude.value,
northing: varConvertUTM.value.northing,
easting: varConvertUTM.value.easting,
zone: varConvertUTM.value.zone,
precision: numPrecision.value,
// Species data
commonname: selSpecies.labels[selSpecies.values.indexOf(selSpecies.value)],
speciescode: selSpecies.value,
// Bio data
agecode: selAge.value,
agedescr: selAge.labels[selAge.values.indexOf(selAge.value)],
breedingcode: selBreeding.value,
breedingdescr: selBreeding.labels[selBreeding.values.indexOf(selBreeding.value)],
animalcount: numCount.value,
countcode: selCountCode.value,
countdescr: selCountCode.labels[selCountCode.values.indexOf(selCountCode.value)],
datumcode: selDatum.value,
datumdescr: selDatum.labels[selDatum.values.indexOf(selDatum.value)],
identificationcode: selIdentification.value,
identificationdescr: selIdentification.labels[selIdentification.values.indexOf(selIdentification.value)],
landformcode: selLandform.value,
landformdescr: selLandform.labels[selLandform.values.indexOf(selLandform.value)],
locationdescr: selLocation.labels[selLocation.values.indexOf(selLocation.value)],
locationcode: selLocation.value,
sexcode: selSex.value,
sexdescr: selSex.labels[selSex.values.indexOf(selSex.value)],
vegetationcode: selVegetation.value,
vegetationdescr: selVegetation.labels[selVegetation.values.indexOf(selVegetation.value)],
vettingcode: selVetting.value,
vettingdescr: selVetting.labels[selVetting.values.indexOf(selVetting.value)],
notes: txtNotes.value
};
// 3. Push
existing.push(newSurvey);
// 4. Save (NO stringify)
localStorage.setValue("cached_Surveys", existing);
// 5. Return
return existing;
- Retrieve record for review mapping info while offline:
{{
(localStorage.values.cached_Surveys || []).map(row => ({
longitude: row.longitude,
latitude: row.latitude,
location: row.locationdescr,
locationcode: row.locationcode,
id: row.id,
collector: row.Collector,
formatteddate: row.formatteddate,
date: row.date,
vegetation: row.vegetationdescr,
vegetationcode: row.vegetationcode,
commonname: row.commonname,
speciescode: row.speciescode,
identification: row.identificationdescr,
identificationcode: row.identificationcode,
sex: row.sexcode,
notes: row.notes
}))
}}
- Sync data up to database as inserted records once survey session complete:
if (OnlineStatus.value === true)
{
// Get cached surveys
const surveys = localStorage.values.cached_Surveys || [];
// Only send unsynced ones (important)
const unsynced = surveys.filter(x => !x.synced);
// Map to match SQL columns
return unsynced.map(s => ({
// Prep meta data
date: s.date,
formatteddate: s.formatteddate,
collector: s.Collector,
// Prep Location data
latitude: s.latitude,
longitude: s.longitude,
northing: s.northing,
easting: s.easting,
zone: s.zone,
precision: s.precision,
// Prep Bio data
agecode: s.agecode,
breedingcode: s.breedingcode,
animalcount: s.animalcount,
countcode: s.countcode,
datumcode: s.datumcode,
identificationcode: s.identificationcode,
landformcode: s.landformcode,
locationcode: s.locationcode,
sexcode: s.sexcode,
speciescode: s.speciescode,
vegetationcode: s.vegetationcode,
vettingcode: s.vettingcode,
notes: s.notes,
startdate: s.date,
surveylogid: txtLogId.value
}));
}
PostgreSQL insert:
INSERT INTO "data.surveys" ("date", "collector", "latitude", "longitude", "age_code", "breeding_code", "animal_count", "count_code", "datum_code", "identification_code", "landform_code", "location_code", "sex_code", "species_code", "vegetation_code", "vetting_code", "notes", "start_date","log_id", "northing", "easting", "zone", "detectiondate_formatted","precision")
SELECT *
FROM jsonb_to_recordset(
{{ JSON.stringify(prepSurveysForSync.data) }}
) AS x(
"date" timestamptz,
"collector" text,
"latitude" float,
"longitude" float,
"agecode" text,
"breedingcode" text,
"animalcount" int,
"countcode" text,
"datumcode" text,
"identificationcode" text,
"landformcode" text,
"locationcode" text,
"sexcode" text,
"speciescode" text,
"vegetationcode" text,
"vettingcode" text,
"notes" text,
"startdate" timestamptz,
"surveylogid" text,
"northing" float,
"easting" float,
"zone" int,
"formatteddate" text,
"precision" int
);