fix(idaa): fix country/subdivision/timezone dropdowns — switch to in-memory sort

- Country and state/province fields were showing as plain text inputs because
  liveQuery used orderBy() on non-indexed columns, causing silent Dexie errors
  that left the store as undefined indefinitely.
- Fix: replaced orderBy() with toArray() + in-memory sort across all three
  lookup types (country, country_subdivision, time_zone).
- Sort convention matches Aether backend: sort DESC (higher = first, NULL=0
  last), then name ASC — puts priority entries at the top.
- Added db_lookups.ts (IDB schema for lookup tables) and updated core__countries,
  core__country_subdivisions, core__time_zones to IDB-backed SWR pattern.
- Affected: archive edit, archive content edit, recovery meeting edit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-23 18:44:24 -04:00
parent dafe79b3c6
commit a6f8ff709e
7 changed files with 300 additions and 270 deletions

View File

@@ -1,65 +1,64 @@
import type { key_val } from '$lib/stores/ae_stores';
import { api } from '$lib/api/api';
import { db_lookups, LOOKUP_TTL_MS } from '$lib/ae_core/db_lookups';
import { db_core } from '$lib/ae_core/db_core';
/**
* Country lookup — IDB-backed SWR helper.
*
* Calling this function triggers a background API refresh if IDB is empty or
* older than 24 hours. The function returns immediately without awaiting the
* refresh. Components subscribe to db_lookups.lu_country via liveQuery and
* receive automatic updates when the refresh completes.
*
* Updated 2026-03-23 — replaced localStorage pattern with IDB + 24h TTL
*/
const ae_promises: key_val = {};
// Updated 2024-10-14
export async function load_ae_obj_li__country({
async function _refresh_lu_country_background({
api_cfg,
// account_id,
enabled = 'enabled',
hidden = 'not_hidden',
limit = 275, // There are roughly 249 as of 2026-02
offset = 0,
order_by_li = { sort: 'DESC', english_short_name: 'ASC', alpha_2_code: 'ASC' } as const,
params = {},
try_cache = true,
log_lvl = 0
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
api_cfg: any;
// account_id: string,
enabled?: 'enabled' | 'all' | 'not_enabled' | undefined;
hidden?: 'hidden' | 'all' | 'not_hidden' | undefined;
limit?: number;
offset?: number;
order_by_li?: key_val;
params?: key_val;
try_cache?: boolean;
log_lvl?: number;
}) {
if (log_lvl) {
console.log(`*** load_ae_obj_li__country() ***`);
}
const params_json: key_val = {};
// console.log('params_json:', params_json);
ae_promises.load__country_li = await api
.get_ae_obj_li_for_lu({
api_cfg: api_cfg,
if (log_lvl) console.log('*** _refresh_lu_country_background() ***');
try {
const result = await api.get_ae_obj_li_for_lu({
api_cfg,
for_lu_type: 'country',
enabled: enabled,
hidden: hidden,
limit: limit,
offset: offset,
params: params,
log_lvl: log_lvl
})
.then(function (country_li_get_result) {
if (country_li_get_result) {
// handle_db_save_ae_obj_li__country({obj_type: 'country', obj_li: country_li_get_result});
return country_li_get_result;
} else {
return [];
}
})
.catch(function (error: any) {
console.log('No results returned or failed.', error);
enabled: 'enabled',
hidden: 'not_hidden',
limit: 275,
log_lvl
});
console.log('ae_promises.load__country_li:', ae_promises.load__country_li);
return ae_promises.load__country_li;
if (result?.length) {
await db_lookups.lu_country.clear();
await db_lookups.lu_country.bulkPut(result);
await db_lookups.lu_cache_meta.put({ lu_type: 'country', refreshed_at: Date.now() });
if (log_lvl) console.log(`lu_country: saved ${result.length} records to IDB`);
}
} catch (error) {
console.error('lu_country refresh failed:', error);
}
}
export async function load_ae_obj_li__country({
api_cfg,
log_lvl = 0
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
api_cfg: any;
log_lvl?: number;
}) {
if (log_lvl) console.log('*** load_ae_obj_li__country() ***');
const count = await db_lookups.lu_country.count();
const meta = await db_lookups.lu_cache_meta.get('country');
const is_stale = !meta || Date.now() - meta.refreshed_at > LOOKUP_TTL_MS;
if (count === 0 || is_stale) {
// Fire-and-forget — liveQuery subscribers receive updates when IDB is written
_refresh_lu_country_background({ api_cfg, log_lvl });
} else if (log_lvl) {
console.log(`lu_country: IDB fresh (${count} records), skipping refresh`);
}
}

View File

@@ -1,68 +1,66 @@
import type { key_val } from '$lib/stores/ae_stores';
import { api } from '$lib/api/api';
import { db_lookups, LOOKUP_TTL_MS } from '$lib/ae_core/db_lookups';
import { db_core } from '$lib/ae_core/db_core';
/**
* Country subdivision lookup — IDB-backed SWR helper.
*
* Calling this function triggers a background API refresh if IDB is empty or
* older than 24 hours. Components subscribe to db_lookups.lu_country_subdivision
* via liveQuery and receive automatic updates when the refresh completes.
*
* Updated 2026-03-23 — replaced localStorage pattern with IDB + 24h TTL
*/
const ae_promises: key_val = {};
// Updated 2024-10-14
export async function load_ae_obj_li__country_subdivision({
async function _refresh_lu_country_subdivision_background({
api_cfg,
// account_id,
enabled = 'enabled',
hidden = 'not_hidden',
limit = 3500, // There are roughly 3434 as of 2026-02
offset = 0,
order_by_li = { sort: 'DESC', name: 'ASC', code: 'ASC' } as const,
params = {},
try_cache = true,
log_lvl = 0
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
api_cfg: any;
// account_id: string,
enabled?: 'enabled' | 'all' | 'not_enabled' | undefined;
hidden?: 'hidden' | 'all' | 'not_hidden' | undefined;
limit?: number;
offset?: number;
order_by_li?: key_val;
params?: key_val;
try_cache?: boolean;
log_lvl?: number;
}) {
if (log_lvl) {
console.log(`*** load_ae_obj_li__country_subdivision() ***`);
}
const params_json: key_val = {};
// console.log('params_json:', params_json);
ae_promises.load__country_subdivision_li = await api
.get_ae_obj_li_for_lu({
api_cfg: api_cfg,
if (log_lvl) console.log('*** _refresh_lu_country_subdivision_background() ***');
try {
const result = await api.get_ae_obj_li_for_lu({
api_cfg,
for_lu_type: 'country_subdivision',
enabled: enabled,
hidden: hidden,
limit: limit,
offset: offset,
params: params,
log_lvl: log_lvl
})
.then(function (country_subdivision_li_get_result) {
if (country_subdivision_li_get_result) {
// handle_db_save_ae_obj_li__country_subdivision({obj_type: 'country_subdivision', obj_li: country_subdivision_li_get_result});
return country_subdivision_li_get_result;
} else {
return [];
}
})
.catch(function (error: any) {
console.log('No results returned or failed.', error);
enabled: 'enabled',
hidden: 'not_hidden',
limit: 3500,
log_lvl
});
console.log(
'ae_promises.load__country_subdivision_li:',
ae_promises.load__country_subdivision_li
);
return ae_promises.load__country_subdivision_li;
if (result?.length) {
await db_lookups.lu_country_subdivision.clear();
await db_lookups.lu_country_subdivision.bulkPut(result);
await db_lookups.lu_cache_meta.put({
lu_type: 'country_subdivision',
refreshed_at: Date.now()
});
if (log_lvl)
console.log(`lu_country_subdivision: saved ${result.length} records to IDB`);
}
} catch (error) {
console.error('lu_country_subdivision refresh failed:', error);
}
}
export async function load_ae_obj_li__country_subdivision({
api_cfg,
log_lvl = 0
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
api_cfg: any;
log_lvl?: number;
}) {
if (log_lvl) console.log('*** load_ae_obj_li__country_subdivision() ***');
const count = await db_lookups.lu_country_subdivision.count();
const meta = await db_lookups.lu_cache_meta.get('country_subdivision');
const is_stale = !meta || Date.now() - meta.refreshed_at > LOOKUP_TTL_MS;
if (count === 0 || is_stale) {
_refresh_lu_country_subdivision_background({ api_cfg, log_lvl });
} else if (log_lvl) {
console.log(`lu_country_subdivision: IDB fresh (${count} records), skipping refresh`);
}
}

View File

@@ -1,70 +1,67 @@
import type { key_val } from '$lib/stores/ae_stores';
import { api } from '$lib/api/api';
import { db_lookups, LOOKUP_TTL_MS } from '$lib/ae_core/db_lookups';
import { db_core } from '$lib/ae_core/db_core';
/**
* Time zone lookup — IDB-backed SWR helper.
*
* Fetches priority timezones (only_priority=true, ~72 records). Calling this
* function triggers a background API refresh if IDB is empty or older than
* 24 hours. Components subscribe to db_lookups.lu_time_zone via liveQuery and
* receive automatic updates when the refresh completes.
*
* Updated 2026-03-23 — replaced $ae_loc + localStorage pattern with IDB + 24h TTL
*/
const ae_promises: key_val = {};
// Updated 2026-02-20
export async function load_ae_obj_li__time_zone({
async function _refresh_lu_time_zone_background({
api_cfg,
// account_id,
enabled = 'enabled',
hidden = 'not_hidden',
limit = 1800, // There are roughly 1780 as of 2026-02
offset = 0,
// order_by_li = {'priority': 'DESC', 'group': 'ASC', 'sort': 'DESC', 'name': 'ASC'},
order_by_li = { priority: 'DESC', sort: 'DESC', name: 'ASC' } as const,
params = {},
only_priority = false,
try_cache = true,
log_lvl = 0
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
api_cfg: any;
// account_id: string,
enabled?: 'enabled' | 'all' | 'not_enabled' | undefined;
hidden?: 'hidden' | 'all' | 'not_hidden' | undefined;
limit?: number;
offset?: number;
order_by_li?: key_val;
params?: key_val;
only_priority?: boolean;
try_cache?: boolean;
log_lvl?: number;
}) {
if (log_lvl) {
console.log(`*** load_ae_obj_li__time_zone() *** only_priority=${only_priority}`);
}
const params_json: key_val = {};
// console.log('params_json:', params_json);
ae_promises.load__time_zone_li = await api
.get_ae_obj_li_for_lu({
api_cfg: api_cfg,
if (log_lvl) console.log('*** _refresh_lu_time_zone_background() ***');
try {
const result = await api.get_ae_obj_li_for_lu({
api_cfg,
for_lu_type: 'time_zone',
enabled: enabled,
hidden: hidden,
limit: limit,
offset: offset,
order_by_li: order_by_li,
params: params,
only_priority: only_priority,
log_lvl: log_lvl
})
.then(function (time_zone_li_get_result) {
if (time_zone_li_get_result) {
// handle_db_save_ae_obj_li__time_zone({obj_type: 'time_zone', obj_li: time_zone_li_get_result});
return time_zone_li_get_result;
} else {
return [];
}
})
.catch(function (error: any) {
console.log('No results returned or failed.', error);
enabled: 'enabled',
hidden: 'not_hidden',
only_priority: true, // ~72 priority timezone records
limit: 1800,
log_lvl
});
console.log('ae_promises.load__time_zone_li:', ae_promises.load__time_zone_li);
return ae_promises.load__time_zone_li;
if (result?.length) {
await db_lookups.lu_time_zone.clear();
await db_lookups.lu_time_zone.bulkPut(result);
await db_lookups.lu_cache_meta.put({
lu_type: 'time_zone',
refreshed_at: Date.now()
});
if (log_lvl) console.log(`lu_time_zone: saved ${result.length} records to IDB`);
}
} catch (error) {
console.error('lu_time_zone refresh failed:', error);
}
}
export async function load_ae_obj_li__time_zone({
api_cfg,
log_lvl = 0
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
api_cfg: any;
log_lvl?: number;
}) {
if (log_lvl) console.log('*** load_ae_obj_li__time_zone() ***');
const count = await db_lookups.lu_time_zone.count();
const meta = await db_lookups.lu_cache_meta.get('time_zone');
const is_stale = !meta || Date.now() - meta.refreshed_at > LOOKUP_TTL_MS;
if (count === 0 || is_stale) {
_refresh_lu_time_zone_background({ api_cfg, log_lvl });
} else if (log_lvl) {
console.log(`lu_time_zone: IDB fresh (${count} records), skipping refresh`);
}
}

View File

@@ -0,0 +1,81 @@
import Dexie, { type Table } from 'dexie';
/**
* Lookup DB — IDB-backed cache for V3 Uniform Lookup System reference data.
*
* These tables store the deduplicated, priority-ranked list returned by
* GET /v3/lookup/{lu_type}/list. Data is refreshed automatically on a 24-hour
* TTL via the core__*.ts load helpers; components subscribe via liveQuery.
*
* Updated 2026-03-23
*/
export interface LuCountry {
id: number;
group: string; // dedup key = alpha_2_code (e.g. "US")
alpha_2_code: string;
name: string;
english_short_name?: string;
name_override?: string;
enable?: number;
hide?: number;
priority?: number;
sort?: number;
account_id?: number | null;
[key: string]: unknown; // allow extra fields from API without TS errors
}
export interface LuCountrySubdivision {
id: number;
group: string; // dedup key = code (e.g. "US-NY")
code: string;
name: string;
country_alpha_2_code?: string;
name_override?: string;
enable?: number;
hide?: number;
priority?: number;
sort?: number;
account_id?: number | null;
[key: string]: unknown;
}
export interface LuTimeZone {
id: number;
group: string; // dedup key = name (IANA identifier, e.g. "US/Eastern")
name: string;
name_override?: string; // display label override; prefer this over name when set
enable?: number;
hide?: number;
priority?: number;
sort?: number;
account_id?: number | null;
[key: string]: unknown;
}
export interface LuCacheMeta {
lu_type: 'country' | 'country_subdivision' | 'time_zone';
refreshed_at: number; // Unix timestamp ms — used for 24h TTL check
}
class LookupsDexie extends Dexie {
lu_country!: Table<LuCountry>;
lu_country_subdivision!: Table<LuCountrySubdivision>;
lu_time_zone!: Table<LuTimeZone>;
lu_cache_meta!: Table<LuCacheMeta>;
constructor() {
super('ae_lookups_db');
this.version(1).stores({
lu_country: 'id, alpha_2_code, group',
lu_country_subdivision: 'id, code, country_alpha_2_code, group',
lu_time_zone: 'id, name, group',
lu_cache_meta: 'lu_type'
});
}
}
export const db_lookups = new LookupsDexie();
/** 24-hour TTL in milliseconds */
export const LOOKUP_TTL_MS = 24 * 60 * 60 * 1000;

View File

@@ -7,6 +7,8 @@
import type { key_val } from '$lib/stores/ae_stores';
import { ae_util } from '$lib/ae_utils/ae_utils';
import { core_func } from '$lib/ae_core/ae_core_functions';
import { liveQuery } from 'dexie';
import { db_lookups } from '$lib/ae_core/db_lookups';
import {
ae_snip,
ae_loc,
@@ -106,56 +108,21 @@
);
}
let lu_time_zone_list: any = $state(
localStorage.getItem('lu_time_zone_list')
? JSON.parse(localStorage.getItem('lu_time_zone_list') as string)
: []
// Timezone lookup — reactive IDB query; background refresh handled by liveQuery + TTL
// Sort: sort DESC (higher = first, NULL=0 last), then name ASC — matches Aether backend convention.
const lq__lu_time_zone = liveQuery(() =>
db_lookups.lu_time_zone.toArray().then(arr =>
arr.sort((a, b) => {
const s_diff = Number(b['sort'] ?? 0) - Number(a['sort'] ?? 0);
if (s_diff !== 0) return s_diff;
return (a.name ?? '').localeCompare(b.name ?? '');
})
)
);
onMount(() => {
$ae_loc.lu_time_zone_list = [];
// $ae_loc.lu_time_zone_list = [];
// lu_time_zone_list = [];
if (lu_time_zone_list && lu_time_zone_list.length > 0) {
// console.log('Already have time zone list!', lu_time_zone_list);
} else {
console.log('No time zone list');
let lu_time_zone_li_get_promise = core_func
.load_ae_obj_li__time_zone({
api_cfg: $ae_api,
only_priority: true,
log_lvl: log_lvl
})
.then(function (lu_time_zone_li_get_result) {
/* We need to save the time zone list to localStore */
if (lu_time_zone_li_get_result) {
lu_time_zone_list = lu_time_zone_li_get_result;
localStorage.setItem(
'lu_time_zone_list',
JSON.stringify(lu_time_zone_li_get_result)
);
if (log_lvl) {
console.log(`Time zone list:`, lu_time_zone_list);
}
} else {
console.log(`No time zones returned!`);
// $ae_loc.lu_time_zone_list = [];
}
if (lu_time_zone_li_get_result) {
lu_time_zone_list = lu_time_zone_li_get_result;
console.log(`Time zone list:`, lu_time_zone_list);
console.log(lu_time_zone_list[0]);
console.log(lu_time_zone_list[10]);
} else {
console.log(`No time zones returned!`);
lu_time_zone_list = [];
}
})
.catch(function (error: any) {
console.log('No results returned or failed.', error);
});
}
// Trigger background IDB refresh if stale/empty; liveQuery reacts automatically
core_func.load_ae_obj_li__time_zone({ api_cfg: $ae_api, log_lvl });
});
function prevent_default<T extends Event>(fn: (event: T) => void) {
@@ -873,7 +840,7 @@
<fieldset class="flex_row flex_gap_md flex_justify_around">
<label for="original_timezone"
>Original Timezone
{#if lu_time_zone_list}
{#if ($lq__lu_time_zone ?? []).length}
<select
id="original_timezone"
name="original_timezone"
@@ -886,7 +853,7 @@
title="Select the original timezone"
>
<option value="">-- None --</option>
{#each lu_time_zone_list as lu_timezone (lu_timezone.name)}
{#each ($lq__lu_time_zone ?? []) as lu_timezone (lu_timezone.name)}
<option value={lu_timezone.name}>
{lu_timezone.name}
</option>

View File

@@ -8,6 +8,8 @@
import type { key_val } from '$lib/stores/ae_stores';
import { ae_util } from '$lib/ae_utils/ae_utils';
import { core_func } from '$lib/ae_core/ae_core_functions';
import { liveQuery } from 'dexie';
import { db_lookups } from '$lib/ae_core/db_lookups';
import {
ae_snip,
ae_loc,
@@ -44,56 +46,21 @@
let notes_changed = $state(false);
let disable_submit_btn = true;
let lu_time_zone_list: any = $state(
localStorage.getItem('lu_time_zone_list')
? JSON.parse(localStorage.getItem('lu_time_zone_list') as string)
: []
// Timezone lookup — reactive IDB query; background refresh handled by liveQuery + TTL
// Sort: sort DESC (higher = first, NULL=0 last), then name ASC — matches Aether backend convention.
const lq__lu_time_zone = liveQuery(() =>
db_lookups.lu_time_zone.toArray().then(arr =>
arr.sort((a, b) => {
const s_diff = Number(b['sort'] ?? 0) - Number(a['sort'] ?? 0);
if (s_diff !== 0) return s_diff;
return (a.name ?? '').localeCompare(b.name ?? '');
})
)
);
onMount(() => {
$ae_loc.lu_time_zone_list = [];
// $ae_loc.lu_time_zone_list = [];
// lu_time_zone_list = [];
if (lu_time_zone_list && lu_time_zone_list.length > 0) {
// console.log('Already have time zone list!', lu_time_zone_list);
} else {
console.log('No time zone list');
let lu_time_zone_li_get_promise = core_func
.load_ae_obj_li__time_zone({
api_cfg: $ae_api,
only_priority: true,
log_lvl: log_lvl
})
.then(function (lu_time_zone_li_get_result) {
/* We need to save the time zone list to localStore */
if (lu_time_zone_li_get_result) {
lu_time_zone_list = lu_time_zone_li_get_result;
localStorage.setItem(
'lu_time_zone_list',
JSON.stringify(lu_time_zone_li_get_result)
);
if (log_lvl) {
console.log(`Time zone list:`, lu_time_zone_list);
}
} else {
console.log(`No time zones returned!`);
// $ae_loc.lu_time_zone_list = [];
}
if (lu_time_zone_li_get_result) {
lu_time_zone_list = lu_time_zone_li_get_result;
console.log(`Time zone list:`, lu_time_zone_list);
console.log(lu_time_zone_list[0]);
console.log(lu_time_zone_list[10]);
} else {
console.log(`No time zones returned!`);
lu_time_zone_list = [];
}
})
.catch(function (error: any) {
console.log('No results returned or failed.', error);
});
}
// Trigger background IDB refresh if stale/empty; liveQuery reacts automatically
core_func.load_ae_obj_li__time_zone({ api_cfg: $ae_api, log_lvl });
});
function prevent_default<T extends Event>(fn: (event: T) => void) {
@@ -406,7 +373,7 @@
<fieldset class="flex_row flex_gap_md flex_justify_around">
<label for="original_timezone"
>Original Timezone
{#if lu_time_zone_list}
{#if ($lq__lu_time_zone ?? []).length}
<select
name="original_timezone"
id="original_timezone"
@@ -418,7 +385,7 @@
title="Select the original timezone"
>
<option value="">-- None --</option>
{#each lu_time_zone_list as lu_timezone (lu_timezone.name)}
{#each ($lq__lu_time_zone ?? []) as lu_timezone (lu_timezone.name)}
<option value={lu_timezone.name}>
{lu_timezone.name}
</option>

View File

@@ -190,14 +190,35 @@
// Lookup lists — reactive IDB queries (SWR via db_lookups + liveQuery)
// Data persists in IndexedDB with a 24h TTL; onMount triggers a background
// refresh if IDB is empty or stale. No localStorage or $ae_loc involved.
// Note: orderBy() requires a declared Dexie index. For fields not in the schema index,
// use toArray() + in-memory sort instead to avoid a silent liveQuery error.
// Sort convention matches the Aether backend: sort DESC (higher = first, NULL=0 last), then name ASC.
const lq__lu_country = liveQuery(() =>
db_lookups.lu_country.orderBy('english_short_name').toArray()
db_lookups.lu_country.toArray().then(arr =>
arr.sort((a, b) => {
const s_diff = Number(b['sort'] ?? 0) - Number(a['sort'] ?? 0);
if (s_diff !== 0) return s_diff;
return (a.english_short_name ?? a.name ?? '').localeCompare(b.english_short_name ?? b.name ?? '');
})
)
);
const lq__lu_country_subdivision = liveQuery(() =>
db_lookups.lu_country_subdivision.orderBy('name').toArray()
db_lookups.lu_country_subdivision.toArray().then(arr =>
arr.sort((a, b) => {
const s_diff = Number(b['sort'] ?? 0) - Number(a['sort'] ?? 0);
if (s_diff !== 0) return s_diff;
return (a.name ?? '').localeCompare(b.name ?? '');
})
)
);
const lq__lu_time_zone = liveQuery(() =>
db_lookups.lu_time_zone.orderBy('name').toArray()
db_lookups.lu_time_zone.toArray().then(arr =>
arr.sort((a, b) => {
const s_diff = Number(b['sort'] ?? 0) - Number(a['sort'] ?? 0);
if (s_diff !== 0) return s_diff;
return (a.name ?? '').localeCompare(b.name ?? '');
})
)
);
onMount(() => {