@africanies/angular-web-sdk 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.nx/cache/14876618240301288353/dist/libs/africanies-ui/README.md +68 -0
- package/.nx/cache/14876618240301288353/dist/libs/africanies-ui/assets/brand/africanies-logo-mini.png +0 -0
- package/.nx/cache/14876618240301288353/dist/libs/africanies-ui/assets/brand/africanies-logo.png +0 -0
- package/.nx/cache/14876618240301288353/dist/libs/africanies-ui/assets/brand/africanies-logo.svg +5 -0
- package/.nx/cache/14876618240301288353/dist/libs/africanies-ui/assets/carriers/dhl.svg +7 -0
- package/.nx/cache/14876618240301288353/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs +19375 -0
- package/.nx/cache/14876618240301288353/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs.map +1 -0
- package/.nx/cache/14876618240301288353/dist/libs/africanies-ui/package.json +35 -0
- package/.nx/cache/14876618240301288353/dist/libs/africanies-ui/types/africanies-africanies-ui.d.ts +5490 -0
- package/.nx/cache/17499961385238663835/dist/libs/africanies-ui/README.md +68 -0
- package/.nx/cache/17499961385238663835/dist/libs/africanies-ui/assets/brand/africanies-logo-mini.png +0 -0
- package/.nx/cache/17499961385238663835/dist/libs/africanies-ui/assets/brand/africanies-logo.png +0 -0
- package/.nx/cache/17499961385238663835/dist/libs/africanies-ui/assets/brand/africanies-logo.svg +5 -0
- package/.nx/cache/17499961385238663835/dist/libs/africanies-ui/assets/carriers/dhl.svg +7 -0
- package/.nx/cache/17499961385238663835/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs +19375 -0
- package/.nx/cache/17499961385238663835/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs.map +1 -0
- package/.nx/cache/17499961385238663835/dist/libs/africanies-ui/package.json +35 -0
- package/.nx/cache/17499961385238663835/dist/libs/africanies-ui/types/africanies-africanies-ui.d.ts +5490 -0
- package/.nx/cache/17626862048742598274/dist/libs/africanies-theme/README.md +46 -0
- package/.nx/cache/17626862048742598274/dist/libs/africanies-theme/fesm2022/africanies-africanies-theme.mjs +221 -0
- package/.nx/cache/17626862048742598274/dist/libs/africanies-theme/fesm2022/africanies-africanies-theme.mjs.map +1 -0
- package/.nx/cache/17626862048742598274/dist/libs/africanies-theme/package.json +35 -0
- package/.nx/cache/17626862048742598274/dist/libs/africanies-theme/tailwind-preset.cjs +253 -0
- package/.nx/cache/17626862048742598274/dist/libs/africanies-theme/types/africanies-africanies-theme.d.ts +166 -0
- package/.nx/cache/4082520855419352840/dist/libs/africanies-theme/README.md +46 -0
- package/.nx/cache/4082520855419352840/dist/libs/africanies-theme/fesm2022/africanies-africanies-theme.mjs +221 -0
- package/.nx/cache/4082520855419352840/dist/libs/africanies-theme/fesm2022/africanies-africanies-theme.mjs.map +1 -0
- package/.nx/cache/4082520855419352840/dist/libs/africanies-theme/package.json +35 -0
- package/.nx/cache/4082520855419352840/dist/libs/africanies-theme/tailwind-preset.cjs +253 -0
- package/.nx/cache/4082520855419352840/dist/libs/africanies-theme/types/africanies-africanies-theme.d.ts +166 -0
- package/.nx/cache/9040451482998546630/dist/libs/africanies-core/README.md +85 -0
- package/.nx/cache/9040451482998546630/dist/libs/africanies-core/fesm2022/africanies-africanies-core.mjs +4511 -0
- package/.nx/cache/9040451482998546630/dist/libs/africanies-core/fesm2022/africanies-africanies-core.mjs.map +1 -0
- package/.nx/cache/9040451482998546630/dist/libs/africanies-core/package.json +31 -0
- package/.nx/cache/9040451482998546630/dist/libs/africanies-core/types/africanies-africanies-core.d.ts +2741 -0
- package/.nx/cache/run.json +35 -35
- package/.nx/cache/terminalOutputs/13555699813138069787 +6 -0
- package/.nx/cache/terminalOutputs/14061940909886036014 +173 -0
- package/.nx/cache/terminalOutputs/14876618240301288353 +21 -0
- package/.nx/cache/terminalOutputs/16279119293212733518 +6 -0
- package/.nx/cache/terminalOutputs/17499961385238663835 +21 -0
- package/.nx/cache/terminalOutputs/17626862048742598274 +21 -0
- package/.nx/cache/terminalOutputs/4082520855419352840 +21 -0
- package/.nx/cache/terminalOutputs/7126777490381568487 +32 -0
- package/.nx/cache/terminalOutputs/7978448102466861029 +32 -0
- package/.nx/cache/terminalOutputs/9040451482998546630 +21 -0
- package/.nx/workspace-data/0091ABA9-5C90-580D-953C-8C94E763ADB5-v3.db +0 -0
- package/.nx/workspace-data/0091ABA9-5C90-580D-953C-8C94E763ADB5-v3.db-shm +0 -0
- package/.nx/workspace-data/0091ABA9-5C90-580D-953C-8C94E763ADB5-v3.db-wal +0 -0
- package/.nx/workspace-data/d/daemon.log +3155 -0
- package/.nx/workspace-data/d/server-process.json +2 -2
- package/.nx/workspace-data/file-map.json +3340 -3375
- package/.nx/workspace-data/nx_files.nxt +0 -0
- package/.nx/workspace-data/project-graph.json +2 -2
- package/apps/playground/src/app/app.routes.ts +15 -4
- package/apps/playground/src/app/app.ts +2 -2
- package/apps/playground/src/app/pages/home-page.ts +14 -14
- package/dist/libs/africanies-core/fesm2022/africanies-africanies-core.mjs +8 -43
- package/dist/libs/africanies-core/fesm2022/africanies-africanies-core.mjs.map +1 -1
- package/dist/libs/africanies-core/types/africanies-africanies-core.d.ts +6 -5
- package/dist/libs/africanies-theme/fesm2022/africanies-africanies-theme.mjs +6 -0
- package/dist/libs/africanies-theme/fesm2022/africanies-africanies-theme.mjs.map +1 -1
- package/dist/libs/africanies-theme/tailwind-preset.cjs +15 -7
- package/dist/libs/africanies-theme/types/africanies-africanies-theme.d.ts +10 -1
- package/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs +138 -54
- package/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs.map +1 -1
- package/dist/libs/africanies-ui/types/africanies-africanies-ui.d.ts +32 -25
- package/libs/africanies-core/src/lib/http/api-client.ts +4 -18
- package/libs/africanies-core/src/lib/http/is-retryable-get-error.ts +8 -4
- package/libs/africanies-core/src/lib/query/provide-africanies-query-defaults.ts +5 -6
- package/libs/africanies-theme/src/lib/mode-color.safelist.ts +4 -0
- package/libs/africanies-theme/src/lib/mode-color.service.ts +11 -0
- package/libs/africanies-theme/tailwind-preset.cjs +15 -7
- package/libs/africanies-ui/src/lib/layout/header-weather.spec.ts +111 -6
- package/libs/africanies-ui/src/lib/layout/header-weather.ts +104 -8
- package/libs/africanies-ui/src/lib/table/table.component.ts +67 -44
- package/package.json +1 -1
|
@@ -11,11 +11,9 @@ import {
|
|
|
11
11
|
map,
|
|
12
12
|
Observable,
|
|
13
13
|
of,
|
|
14
|
-
retry,
|
|
15
14
|
tap,
|
|
16
15
|
throwError,
|
|
17
16
|
timeout,
|
|
18
|
-
timer,
|
|
19
17
|
} from 'rxjs';
|
|
20
18
|
|
|
21
19
|
import {
|
|
@@ -30,7 +28,6 @@ import { AFRICANIES_SDK_CONFIG } from '../config/africanies-sdk.config';
|
|
|
30
28
|
import { ShippingModeService } from '../shipping/shipping-mode.service';
|
|
31
29
|
import { formatApiErrorMessage } from './api-error-message';
|
|
32
30
|
import { HttpResponseCache } from './http-cache';
|
|
33
|
-
import { isRetryableGetError } from './is-retryable-get-error';
|
|
34
31
|
import { normalize } from './normalize';
|
|
35
32
|
import {
|
|
36
33
|
resolveApiRequestContext,
|
|
@@ -102,7 +99,8 @@ type ResponseMode = NonNullable<ApiRequestOptions['responseMode']>;
|
|
|
102
99
|
* - HTTP failures rethrown as `Error` whose `.message` is already
|
|
103
100
|
* user-facing ({@link formatApiErrorMessage}) — consumers do not need to
|
|
104
101
|
* parse `HttpErrorResponse` bodies
|
|
105
|
-
* -
|
|
102
|
+
* - **No automatic retry** — fail fast so UIs can show Select / table /
|
|
103
|
+
* error-state Retry instead of a stuck loading spinner
|
|
106
104
|
* - Optional per-GET TTL cache via `cacheTtlMs`
|
|
107
105
|
*
|
|
108
106
|
* @example
|
|
@@ -474,20 +472,8 @@ export class ApiClient {
|
|
|
474
472
|
break;
|
|
475
473
|
}
|
|
476
474
|
|
|
477
|
-
//
|
|
478
|
-
|
|
479
|
-
req$ = req$.pipe(
|
|
480
|
-
retry({
|
|
481
|
-
count: 1,
|
|
482
|
-
delay: (err, retryCount) => {
|
|
483
|
-
if (!isRetryableGetError(err)) {
|
|
484
|
-
return throwError(() => err);
|
|
485
|
-
}
|
|
486
|
-
return timer(2 ** (retryCount - 1) * 1000);
|
|
487
|
-
},
|
|
488
|
-
}),
|
|
489
|
-
);
|
|
490
|
-
}
|
|
475
|
+
// No automatic GET retry — admin shells own Retry via UI
|
|
476
|
+
// (`africanies-select` showRetry, table error→refreshClick, error-state).
|
|
491
477
|
|
|
492
478
|
if (this.config.timeout != null) {
|
|
493
479
|
req$ = req$.pipe(
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { HttpErrorResponse } from '@angular/common/http';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Whether a failed GET
|
|
4
|
+
* Whether a failed GET *would* be a candidate for automatic retry.
|
|
5
|
+
*
|
|
6
|
+
* {@link ApiClient} no longer auto-retries (fail fast for manual UI Retry).
|
|
7
|
+
* Kept for callers/tests that still want to classify transient vs permanent
|
|
8
|
+
* failures.
|
|
9
|
+
*
|
|
10
|
+
* Transient: network blips, timeouts, 408/429, 5xx.
|
|
11
|
+
* Auth and other 4xx fail fast — retrying 401 does not help.
|
|
5
12
|
*
|
|
6
|
-
* Retries transient failures only (network blips, timeouts, 408/429, 5xx).
|
|
7
|
-
* Auth and other 4xx errors fail fast — retrying 401 does not help and causes
|
|
8
|
-
* duplicate requests + toasts.
|
|
9
13
|
* @param err
|
|
10
14
|
*/
|
|
11
15
|
export function isRetryableGetError(err: unknown): boolean {
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* (no caret/tilde): TanStack marks the Angular adapter experimental and
|
|
10
10
|
* breaking changes land without major bumps.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* Query retries default to **0** to match {@link ApiClient} (fail fast;
|
|
13
|
+
* screens use manual Retry). Apps can override per `QueryClient` if needed.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -23,9 +23,9 @@ export interface AfricaniesQueryClientDefaults {
|
|
|
23
23
|
staleTime: number;
|
|
24
24
|
/** Unused-query garbage-collection time (ms). */
|
|
25
25
|
gcTime: number;
|
|
26
|
-
/** Max failed-fetch retries for queries. */
|
|
26
|
+
/** Max failed-fetch retries for queries (default 0 — fail fast). */
|
|
27
27
|
retry: number;
|
|
28
|
-
/**
|
|
28
|
+
/** Delay between retries when {@link AfricaniesQueryClientDefaults.queries.retry} > 0. */
|
|
29
29
|
retryDelay: (attemptIndex: number) => number;
|
|
30
30
|
};
|
|
31
31
|
mutations: {
|
|
@@ -82,8 +82,7 @@ export function createAfricaniesQueryClientDefaults(): AfricaniesQueryClientDefa
|
|
|
82
82
|
queries: {
|
|
83
83
|
staleTime: 60_000,
|
|
84
84
|
gcTime: 5 * 60_000,
|
|
85
|
-
retry:
|
|
86
|
-
// attemptIndex is 0-based in TanStack; match 1s, 2s, 4s style backoff.
|
|
85
|
+
retry: 0,
|
|
87
86
|
retryDelay: (attemptIndex: number) =>
|
|
88
87
|
Math.min(1000 * 2 ** attemptIndex, 30_000),
|
|
89
88
|
},
|
|
@@ -11,10 +11,12 @@
|
|
|
11
11
|
* SFN (export / green):
|
|
12
12
|
* text-export bg-export bg-export-subtle bg-export-light border-export
|
|
13
13
|
* hover:bg-export-light hover:bg-export-subtle dark:bg-export/15 dark:hover:bg-export/15
|
|
14
|
+
* dark:bg-[color-mix(in_srgb,#1cbd5d_15%,#212529)]
|
|
14
15
|
*
|
|
15
16
|
* STN (import / orange):
|
|
16
17
|
* text-import bg-import bg-import-subtle bg-import-light border-import
|
|
17
18
|
* hover:bg-import-light hover:bg-import-subtle dark:bg-import/15 dark:hover:bg-import/15
|
|
19
|
+
* dark:bg-[color-mix(in_srgb,#f08829_15%,#212529)]
|
|
18
20
|
*/
|
|
19
21
|
export const MODE_COLOR_SAFELIST = [
|
|
20
22
|
'text-export',
|
|
@@ -26,6 +28,7 @@ export const MODE_COLOR_SAFELIST = [
|
|
|
26
28
|
'hover:bg-export-subtle',
|
|
27
29
|
'dark:bg-export/15',
|
|
28
30
|
'dark:hover:bg-export/15',
|
|
31
|
+
'dark:bg-[color-mix(in_srgb,#1cbd5d_15%,#212529)]',
|
|
29
32
|
'text-import',
|
|
30
33
|
'bg-import',
|
|
31
34
|
'bg-import-subtle',
|
|
@@ -35,4 +38,5 @@ export const MODE_COLOR_SAFELIST = [
|
|
|
35
38
|
'hover:bg-import-subtle',
|
|
36
39
|
'dark:bg-import/15',
|
|
37
40
|
'dark:hover:bg-import/15',
|
|
41
|
+
'dark:bg-[color-mix(in_srgb,#f08829_15%,#212529)]',
|
|
38
42
|
] as const;
|
|
@@ -29,8 +29,15 @@ export interface ModeColorClasses {
|
|
|
29
29
|
ghostPrimary: string;
|
|
30
30
|
/**
|
|
31
31
|
* Soft highlight for selected / active rows (works in dark mode).
|
|
32
|
+
* Dark uses a translucent tint — fine over solid surfaces, not for
|
|
33
|
+
* sticky overlays (see {@link softSolid}).
|
|
32
34
|
*/
|
|
33
35
|
soft: string;
|
|
36
|
+
/**
|
|
37
|
+
* Opaque soft fill — same tint as {@link soft}, but dark mode mixes into
|
|
38
|
+
* ink so scrolled content cannot show through (sticky table headers/cells).
|
|
39
|
+
*/
|
|
40
|
+
softSolid: string;
|
|
34
41
|
/**
|
|
35
42
|
* Soft hover highlight for menus and lists.
|
|
36
43
|
*/
|
|
@@ -74,6 +81,8 @@ export class ModeColorService {
|
|
|
74
81
|
ghostPrimary:
|
|
75
82
|
'bg-transparent text-export border-transparent hover:bg-export-subtle dark:hover:bg-export/15',
|
|
76
83
|
soft: 'bg-export-subtle dark:bg-export/15',
|
|
84
|
+
softSolid:
|
|
85
|
+
'bg-export-subtle dark:bg-[color-mix(in_srgb,#1cbd5d_15%,#212529)]',
|
|
77
86
|
softHover: 'hover:bg-export-subtle dark:hover:bg-export/15',
|
|
78
87
|
};
|
|
79
88
|
}
|
|
@@ -87,6 +96,8 @@ export class ModeColorService {
|
|
|
87
96
|
ghostPrimary:
|
|
88
97
|
'bg-transparent text-import border-transparent hover:bg-import-subtle dark:hover:bg-import/15',
|
|
89
98
|
soft: 'bg-import-subtle dark:bg-import/15',
|
|
99
|
+
softSolid:
|
|
100
|
+
'bg-import-subtle dark:bg-[color-mix(in_srgb,#f08829_15%,#212529)]',
|
|
90
101
|
softHover: 'hover:bg-import-subtle dark:hover:bg-import/15',
|
|
91
102
|
};
|
|
92
103
|
});
|
|
@@ -69,6 +69,11 @@ function africaniesInteractiveCursorPlugin({ addBase }) {
|
|
|
69
69
|
* (`white` / `ink-950`) and `-webkit-text-fill-color` keeps body text on
|
|
70
70
|
* `ink` / white. `.dark` matches `ThemeService` (`class` on `<html>`).
|
|
71
71
|
*
|
|
72
|
+
* WHY one selector per `addBase` key: joining autofill selectors with commas
|
|
73
|
+
* under a single `.dark ${list}` key only scopes the first selector — the rest
|
|
74
|
+
* leak into light mode. WHY no `color` in the long transition: delaying color
|
|
75
|
+
* freezes text when the user toggles light/dark.
|
|
76
|
+
*
|
|
72
77
|
* @param {import('tailwindcss/types/config').PluginAPI} api
|
|
73
78
|
*/
|
|
74
79
|
function africaniesAutofillPlugin({ addBase }) {
|
|
@@ -81,21 +86,24 @@ function africaniesAutofillPlugin({ addBase }) {
|
|
|
81
86
|
'textarea:-webkit-autofill:hover',
|
|
82
87
|
'textarea:-webkit-autofill:focus',
|
|
83
88
|
'textarea:-webkit-autofill:active',
|
|
84
|
-
]
|
|
89
|
+
];
|
|
85
90
|
|
|
86
|
-
|
|
87
|
-
|
|
91
|
+
/** @type {Record<string, Record<string, string>>} */
|
|
92
|
+
const rules = {};
|
|
93
|
+
for (const selector of autofillSelectors) {
|
|
94
|
+
rules[selector] = {
|
|
88
95
|
WebkitTextFillColor: '#212529',
|
|
89
96
|
caretColor: '#212529',
|
|
90
97
|
boxShadow: '0 0 0 1000px #ffffff inset',
|
|
91
98
|
transition: 'background-color 99999s ease-in-out 0s',
|
|
92
|
-
}
|
|
93
|
-
[`.dark ${
|
|
99
|
+
};
|
|
100
|
+
rules[`.dark ${selector}`] = {
|
|
94
101
|
WebkitTextFillColor: '#ffffff',
|
|
95
102
|
caretColor: '#ffffff',
|
|
96
103
|
boxShadow: '0 0 0 1000px #272729 inset',
|
|
97
|
-
}
|
|
98
|
-
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
addBase(rules);
|
|
99
107
|
}
|
|
100
108
|
|
|
101
109
|
/**
|
|
@@ -24,9 +24,34 @@ describe('mapWmoWeatherCode', () => {
|
|
|
24
24
|
|
|
25
25
|
describe('loadHeaderWeather', () => {
|
|
26
26
|
const originalFetch = globalThis.fetch;
|
|
27
|
+
const originalGeolocation = navigator.geolocation;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
Object.defineProperty(navigator, 'geolocation', {
|
|
31
|
+
configurable: true,
|
|
32
|
+
value: {
|
|
33
|
+
getCurrentPosition: (
|
|
34
|
+
_success: PositionCallback,
|
|
35
|
+
error?: PositionErrorCallback,
|
|
36
|
+
) => {
|
|
37
|
+
error?.({
|
|
38
|
+
code: 1,
|
|
39
|
+
message: 'denied',
|
|
40
|
+
PERMISSION_DENIED: 1,
|
|
41
|
+
POSITION_UNAVAILABLE: 2,
|
|
42
|
+
TIMEOUT: 3,
|
|
43
|
+
} as GeolocationPositionError);
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
});
|
|
27
48
|
|
|
28
49
|
afterEach(() => {
|
|
29
50
|
globalThis.fetch = originalFetch;
|
|
51
|
+
Object.defineProperty(navigator, 'geolocation', {
|
|
52
|
+
configurable: true,
|
|
53
|
+
value: originalGeolocation,
|
|
54
|
+
});
|
|
30
55
|
sessionStorage.clear();
|
|
31
56
|
});
|
|
32
57
|
|
|
@@ -41,22 +66,51 @@ describe('loadHeaderWeather', () => {
|
|
|
41
66
|
).resolves.toBeNull();
|
|
42
67
|
});
|
|
43
68
|
|
|
44
|
-
it('reads Open-Meteo after IP geolocation', async () => {
|
|
69
|
+
it('reads Open-Meteo after IP geolocation and reverse-geocodes the city', async () => {
|
|
45
70
|
const fetchFn = jest.fn(async (url: string) => {
|
|
46
71
|
if (String(url).includes('geojs')) {
|
|
47
|
-
return jsonResponse({
|
|
72
|
+
return jsonResponse({
|
|
73
|
+
latitude: '9.0765',
|
|
74
|
+
longitude: '7.3986',
|
|
75
|
+
city: 'Lagos',
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (String(url).includes('bigdatacloud')) {
|
|
79
|
+
return jsonResponse({ city: 'Abuja', locality: 'Abuja' });
|
|
48
80
|
}
|
|
49
81
|
return jsonResponse({
|
|
50
82
|
current: { weather_code: 61, temperature_2m: 27.4 },
|
|
51
83
|
});
|
|
52
84
|
});
|
|
53
85
|
|
|
54
|
-
await expect(
|
|
86
|
+
await expect(
|
|
87
|
+
loadHeaderWeather(fetchFn as unknown as typeof fetch),
|
|
88
|
+
).resolves.toEqual({
|
|
55
89
|
kind: 'rain',
|
|
56
90
|
temperatureC: 27.4,
|
|
91
|
+
city: 'Abuja',
|
|
92
|
+
});
|
|
93
|
+
expect(fetchFn).toHaveBeenCalledTimes(3);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('falls back to the IP city when reverse geocode is empty', async () => {
|
|
97
|
+
const fetchFn = jest.fn(async (url: string) => {
|
|
98
|
+
if (String(url).includes('geojs')) {
|
|
99
|
+
return jsonResponse({ latitude: 1, longitude: 2, city: 'Lagos' });
|
|
100
|
+
}
|
|
101
|
+
if (String(url).includes('bigdatacloud')) {
|
|
102
|
+
return jsonResponse({});
|
|
103
|
+
}
|
|
104
|
+
return jsonResponse({ current: { weather_code: 0, temperature_2m: 30 } });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await expect(
|
|
108
|
+
loadHeaderWeather(fetchFn as unknown as typeof fetch),
|
|
109
|
+
).resolves.toEqual({
|
|
110
|
+
kind: 'clear',
|
|
111
|
+
temperatureC: 30,
|
|
57
112
|
city: 'Lagos',
|
|
58
113
|
});
|
|
59
|
-
expect(fetchFn).toHaveBeenCalledTimes(2);
|
|
60
114
|
});
|
|
61
115
|
|
|
62
116
|
it('reuses the same-hour session cache', async () => {
|
|
@@ -64,6 +118,9 @@ describe('loadHeaderWeather', () => {
|
|
|
64
118
|
if (String(url).includes('geojs')) {
|
|
65
119
|
return jsonResponse({ latitude: 1, longitude: 2 });
|
|
66
120
|
}
|
|
121
|
+
if (String(url).includes('bigdatacloud')) {
|
|
122
|
+
return jsonResponse({ city: 'Accra' });
|
|
123
|
+
}
|
|
67
124
|
return jsonResponse({ current: { weather_code: 0, temperature_2m: 30 } });
|
|
68
125
|
});
|
|
69
126
|
|
|
@@ -71,7 +128,7 @@ describe('loadHeaderWeather', () => {
|
|
|
71
128
|
const second = await loadHeaderWeather(fetchFn as unknown as typeof fetch);
|
|
72
129
|
|
|
73
130
|
expect(first).toEqual(second);
|
|
74
|
-
expect(fetchFn).toHaveBeenCalledTimes(
|
|
131
|
+
expect(fetchFn).toHaveBeenCalledTimes(3);
|
|
75
132
|
});
|
|
76
133
|
|
|
77
134
|
it('fetches again when the cached hour no longer matches', async () => {
|
|
@@ -79,6 +136,9 @@ describe('loadHeaderWeather', () => {
|
|
|
79
136
|
if (String(url).includes('geojs')) {
|
|
80
137
|
return jsonResponse({ latitude: 1, longitude: 2, city: 'Lagos' });
|
|
81
138
|
}
|
|
139
|
+
if (String(url).includes('bigdatacloud')) {
|
|
140
|
+
return jsonResponse({ city: 'Lagos' });
|
|
141
|
+
}
|
|
82
142
|
return jsonResponse({ current: { weather_code: 0, temperature_2m: 30.2 } });
|
|
83
143
|
});
|
|
84
144
|
|
|
@@ -94,7 +154,52 @@ describe('loadHeaderWeather', () => {
|
|
|
94
154
|
sessionStorage.setItem(cacheKey!, JSON.stringify(stored));
|
|
95
155
|
|
|
96
156
|
await loadHeaderWeather(fetchFn as unknown as typeof fetch);
|
|
97
|
-
expect(fetchFn).toHaveBeenCalledTimes(
|
|
157
|
+
expect(fetchFn).toHaveBeenCalledTimes(6);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('uses browser coordinates when geolocation succeeds', async () => {
|
|
161
|
+
Object.defineProperty(navigator, 'geolocation', {
|
|
162
|
+
configurable: true,
|
|
163
|
+
value: {
|
|
164
|
+
getCurrentPosition: (success: PositionCallback) => {
|
|
165
|
+
success({
|
|
166
|
+
coords: {
|
|
167
|
+
latitude: 9.0765,
|
|
168
|
+
longitude: 7.3986,
|
|
169
|
+
accuracy: 10,
|
|
170
|
+
altitude: null,
|
|
171
|
+
altitudeAccuracy: null,
|
|
172
|
+
heading: null,
|
|
173
|
+
speed: null,
|
|
174
|
+
toJSON: () => ({}),
|
|
175
|
+
},
|
|
176
|
+
timestamp: Date.now(),
|
|
177
|
+
toJSON: () => ({}),
|
|
178
|
+
} as GeolocationPosition);
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const fetchFn = jest.fn(async (url: string) => {
|
|
184
|
+
if (String(url).includes('bigdatacloud')) {
|
|
185
|
+
return jsonResponse({ city: 'Abuja' });
|
|
186
|
+
}
|
|
187
|
+
if (String(url).includes('geojs')) {
|
|
188
|
+
throw new Error('should not call IP geo');
|
|
189
|
+
}
|
|
190
|
+
return jsonResponse({ current: { weather_code: 0, temperature_2m: 31 } });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await expect(
|
|
194
|
+
loadHeaderWeather(fetchFn as unknown as typeof fetch),
|
|
195
|
+
).resolves.toEqual({
|
|
196
|
+
kind: 'clear',
|
|
197
|
+
temperatureC: 31,
|
|
198
|
+
city: 'Abuja',
|
|
199
|
+
});
|
|
200
|
+
expect(
|
|
201
|
+
fetchFn.mock.calls.some((call) => String(call[0]).includes('geojs')),
|
|
202
|
+
).toBe(false);
|
|
98
203
|
});
|
|
99
204
|
});
|
|
100
205
|
|
|
@@ -2,9 +2,15 @@ import type { IconName } from '@africanies/africanies-icons';
|
|
|
2
2
|
|
|
3
3
|
import type { HeaderWeather, HeaderWeatherKind } from './header-greeting.util';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/** Bump when cache shape or city resolution strategy changes. */
|
|
6
|
+
const CACHE_KEY = 'africanies-header-weather-v4';
|
|
6
7
|
const FETCH_MS = 4_000;
|
|
8
|
+
const BROWSER_GEO_MS = 3_500;
|
|
9
|
+
/** IP fallback — city string is often wrong (e.g. NG IPs labelled Lagos). */
|
|
7
10
|
const GEO_URL = 'https://get.geojs.io/v1/ip/geo.json';
|
|
11
|
+
/** Client reverse-geocode so the label matches the forecast coordinates. */
|
|
12
|
+
const REVERSE_GEO_URL =
|
|
13
|
+
'https://api.bigdatacloud.net/data/reverse-geocode-client';
|
|
8
14
|
|
|
9
15
|
const WEATHER_LABELS: Record<HeaderWeatherKind, string> = {
|
|
10
16
|
clear: 'Clear',
|
|
@@ -29,6 +35,12 @@ interface GeoJsPayload {
|
|
|
29
35
|
longitude?: string | number;
|
|
30
36
|
}
|
|
31
37
|
|
|
38
|
+
interface ReverseGeoPayload {
|
|
39
|
+
city?: string;
|
|
40
|
+
locality?: string;
|
|
41
|
+
principalSubdivision?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
32
44
|
interface OpenMeteoPayload {
|
|
33
45
|
current?: {
|
|
34
46
|
weather_code?: number;
|
|
@@ -36,6 +48,11 @@ interface OpenMeteoPayload {
|
|
|
36
48
|
};
|
|
37
49
|
}
|
|
38
50
|
|
|
51
|
+
interface Coordinates {
|
|
52
|
+
latitude: number;
|
|
53
|
+
longitude: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
39
56
|
/**
|
|
40
57
|
* Map Open-Meteo / WMO weather codes to a greeting flavor.
|
|
41
58
|
*
|
|
@@ -95,7 +112,12 @@ export function headerWeatherIcon(kind: HeaderWeatherKind, hour: number): IconNa
|
|
|
95
112
|
}
|
|
96
113
|
|
|
97
114
|
/**
|
|
98
|
-
* City-level forecast via IP geolocation + Open-Meteo (no API key).
|
|
115
|
+
* City-level forecast via browser / IP geolocation + Open-Meteo (no API key).
|
|
116
|
+
*
|
|
117
|
+
* Coordinates prefer the browser Geolocation API (accurate for travellers;
|
|
118
|
+
* may prompt once). Otherwise IP geo is used. The city label is reverse-
|
|
119
|
+
* geocoded from those coordinates — IP `city` strings are often wrong for
|
|
120
|
+
* Nigeria (many networks resolve as Lagos).
|
|
99
121
|
*
|
|
100
122
|
* Fails closed: missing browser APIs, timeouts, and HTTP errors all return
|
|
101
123
|
* `null` so the greeting can stay time-of-day only. Cached per local hour
|
|
@@ -119,16 +141,14 @@ export async function loadHeaderWeather(
|
|
|
119
141
|
}
|
|
120
142
|
|
|
121
143
|
try {
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
const longitude = asNumber(geo?.longitude);
|
|
125
|
-
if (latitude === null || longitude === null) {
|
|
144
|
+
const coords = await resolveCoordinates(run);
|
|
145
|
+
if (!coords) {
|
|
126
146
|
return null;
|
|
127
147
|
}
|
|
128
148
|
|
|
129
149
|
const forecastUrl =
|
|
130
150
|
`https://api.open-meteo.com/v1/forecast` +
|
|
131
|
-
`?latitude=${latitude}&longitude=${longitude}` +
|
|
151
|
+
`?latitude=${coords.latitude}&longitude=${coords.longitude}` +
|
|
132
152
|
`¤t=weather_code,temperature_2m`;
|
|
133
153
|
const forecast = (await fetchJson(run, forecastUrl)) as OpenMeteoPayload | null;
|
|
134
154
|
const code = asNumber(forecast?.current?.weather_code);
|
|
@@ -142,7 +162,9 @@ export async function loadHeaderWeather(
|
|
|
142
162
|
}
|
|
143
163
|
|
|
144
164
|
const temperatureC = asNumber(forecast?.current?.temperature_2m) ?? undefined;
|
|
145
|
-
const city =
|
|
165
|
+
const city =
|
|
166
|
+
(await reverseGeocodeCity(run, coords.latitude, coords.longitude)) ??
|
|
167
|
+
coords.fallbackCity;
|
|
146
168
|
const weather: HeaderWeather = { kind, temperatureC, city };
|
|
147
169
|
writeCache({ hour, kind, temperatureC, city });
|
|
148
170
|
return weather;
|
|
@@ -187,6 +209,80 @@ function writeCache(value: CachedWeather): void {
|
|
|
187
209
|
}
|
|
188
210
|
}
|
|
189
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Prefer device coordinates when available (may prompt once); otherwise fall
|
|
214
|
+
* back to IP geo. IP city labels for Nigeria are often wrong — reverse
|
|
215
|
+
* geocoding from these coordinates still depends on accurate lat/lon.
|
|
216
|
+
*/
|
|
217
|
+
async function resolveCoordinates(
|
|
218
|
+
fetchFn: typeof fetch,
|
|
219
|
+
): Promise<(Coordinates & { fallbackCity?: string }) | null> {
|
|
220
|
+
const browser = await tryBrowserGeolocation();
|
|
221
|
+
if (browser) {
|
|
222
|
+
return browser;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const geo = (await fetchJson(fetchFn, GEO_URL)) as GeoJsPayload | null;
|
|
226
|
+
const latitude = asNumber(geo?.latitude);
|
|
227
|
+
const longitude = asNumber(geo?.longitude);
|
|
228
|
+
if (latitude === null || longitude === null) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
latitude,
|
|
233
|
+
longitude,
|
|
234
|
+
fallbackCity: asCity(geo?.city),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function tryBrowserGeolocation(): Promise<Coordinates | null> {
|
|
239
|
+
if (
|
|
240
|
+
typeof navigator === 'undefined' ||
|
|
241
|
+
typeof navigator.geolocation?.getCurrentPosition !== 'function'
|
|
242
|
+
) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return await new Promise((resolve) => {
|
|
247
|
+
const timer = setTimeout(() => resolve(null), BROWSER_GEO_MS);
|
|
248
|
+
navigator.geolocation.getCurrentPosition(
|
|
249
|
+
(position) => {
|
|
250
|
+
clearTimeout(timer);
|
|
251
|
+
resolve({
|
|
252
|
+
latitude: position.coords.latitude,
|
|
253
|
+
longitude: position.coords.longitude,
|
|
254
|
+
});
|
|
255
|
+
},
|
|
256
|
+
() => {
|
|
257
|
+
clearTimeout(timer);
|
|
258
|
+
resolve(null);
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
enableHighAccuracy: false,
|
|
262
|
+
maximumAge: 600_000,
|
|
263
|
+
timeout: BROWSER_GEO_MS,
|
|
264
|
+
},
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function reverseGeocodeCity(
|
|
270
|
+
fetchFn: typeof fetch,
|
|
271
|
+
latitude: number,
|
|
272
|
+
longitude: number,
|
|
273
|
+
): Promise<string | undefined> {
|
|
274
|
+
const url =
|
|
275
|
+
`${REVERSE_GEO_URL}?latitude=${encodeURIComponent(String(latitude))}` +
|
|
276
|
+
`&longitude=${encodeURIComponent(String(longitude))}` +
|
|
277
|
+
`&localityLanguage=en`;
|
|
278
|
+
const payload = (await fetchJson(fetchFn, url)) as ReverseGeoPayload | null;
|
|
279
|
+
return (
|
|
280
|
+
asCity(payload?.city) ??
|
|
281
|
+
asCity(payload?.locality) ??
|
|
282
|
+
asCity(payload?.principalSubdivision)
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
190
286
|
function asNumber(value: unknown): number | null {
|
|
191
287
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
192
288
|
return value;
|