@africanies/angular-web-sdk 0.1.3 → 0.1.4

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.
Files changed (42) hide show
  1. package/.nx/cache/17499961385238663835/dist/libs/africanies-ui/README.md +68 -0
  2. package/.nx/cache/17499961385238663835/dist/libs/africanies-ui/assets/brand/africanies-logo-mini.png +0 -0
  3. package/.nx/cache/17499961385238663835/dist/libs/africanies-ui/assets/brand/africanies-logo.png +0 -0
  4. package/.nx/cache/17499961385238663835/dist/libs/africanies-ui/assets/brand/africanies-logo.svg +5 -0
  5. package/.nx/cache/17499961385238663835/dist/libs/africanies-ui/assets/carriers/dhl.svg +7 -0
  6. package/.nx/cache/17499961385238663835/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs +19375 -0
  7. package/.nx/cache/17499961385238663835/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs.map +1 -0
  8. package/.nx/cache/17499961385238663835/dist/libs/africanies-ui/package.json +35 -0
  9. package/.nx/cache/17499961385238663835/dist/libs/africanies-ui/types/africanies-africanies-ui.d.ts +5490 -0
  10. package/.nx/cache/4082520855419352840/dist/libs/africanies-theme/README.md +46 -0
  11. package/.nx/cache/4082520855419352840/dist/libs/africanies-theme/fesm2022/africanies-africanies-theme.mjs +221 -0
  12. package/.nx/cache/4082520855419352840/dist/libs/africanies-theme/fesm2022/africanies-africanies-theme.mjs.map +1 -0
  13. package/.nx/cache/4082520855419352840/dist/libs/africanies-theme/package.json +35 -0
  14. package/.nx/cache/4082520855419352840/dist/libs/africanies-theme/tailwind-preset.cjs +253 -0
  15. package/.nx/cache/4082520855419352840/dist/libs/africanies-theme/types/africanies-africanies-theme.d.ts +166 -0
  16. package/.nx/cache/run.json +36 -36
  17. package/.nx/cache/terminalOutputs/16279119293212733518 +6 -0
  18. package/.nx/cache/terminalOutputs/17499961385238663835 +21 -0
  19. package/.nx/cache/terminalOutputs/4082520855419352840 +21 -0
  20. package/.nx/cache/terminalOutputs/7126777490381568487 +32 -0
  21. package/.nx/workspace-data/0091ABA9-5C90-580D-953C-8C94E763ADB5-v3.db +0 -0
  22. package/.nx/workspace-data/0091ABA9-5C90-580D-953C-8C94E763ADB5-v3.db-shm +0 -0
  23. package/.nx/workspace-data/0091ABA9-5C90-580D-953C-8C94E763ADB5-v3.db-wal +0 -0
  24. package/.nx/workspace-data/d/daemon.log +1637 -0
  25. package/.nx/workspace-data/d/server-process.json +2 -2
  26. package/.nx/workspace-data/file-map.json +1157 -1192
  27. package/.nx/workspace-data/nx_files.nxt +0 -0
  28. package/.nx/workspace-data/project-graph.json +2 -2
  29. package/dist/libs/africanies-theme/fesm2022/africanies-africanies-theme.mjs +6 -0
  30. package/dist/libs/africanies-theme/fesm2022/africanies-africanies-theme.mjs.map +1 -1
  31. package/dist/libs/africanies-theme/tailwind-preset.cjs +15 -7
  32. package/dist/libs/africanies-theme/types/africanies-africanies-theme.d.ts +10 -1
  33. package/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs +138 -54
  34. package/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs.map +1 -1
  35. package/dist/libs/africanies-ui/types/africanies-africanies-ui.d.ts +21 -14
  36. package/libs/africanies-theme/src/lib/mode-color.safelist.ts +4 -0
  37. package/libs/africanies-theme/src/lib/mode-color.service.ts +11 -0
  38. package/libs/africanies-theme/tailwind-preset.cjs +15 -7
  39. package/libs/africanies-ui/src/lib/layout/header-weather.spec.ts +111 -6
  40. package/libs/africanies-ui/src/lib/layout/header-weather.ts +104 -8
  41. package/libs/africanies-ui/src/lib/table/table.component.ts +67 -44
  42. package/package.json +1 -1
@@ -84,7 +84,7 @@ declare class ButtonComponent {
84
84
  */
85
85
  readonly loading: _angular_core.InputSignal<boolean>;
86
86
  protected readonly inactive: _angular_core.Signal<boolean>;
87
- protected readonly spinnerSize: _angular_core.Signal<14 | 16 | 18>;
87
+ protected readonly spinnerSize: _angular_core.Signal<14 | 18 | 16>;
88
88
  /**
89
89
  * Composed Tailwind classes from theme tokens — kept as string literals so
90
90
  * the scanner retains utilities in the published UI bundle.
@@ -4853,9 +4853,11 @@ interface TableSortChange {
4853
4853
  * **Sticky columns:** a column with `key: 'actions'` stays pinned to the
4854
4854
  * right while the grid scrolls horizontally. The expand trigger column pins
4855
4855
  * to the left when rows are expandable. Override with
4856
- * {@link TableColumn.sticky}.
4856
+ * {@link TableColumn.sticky}. Sticky columns use a slightly thicker bottom
4857
+ * border (and opaque fill) so dividers stay visible while scrolling.
4857
4858
  */
4858
4859
  declare class TableComponent<T = unknown> {
4860
+ protected readonly modeColor: ModeColorService;
4859
4861
  /**
4860
4862
  * When false, hides row expansion even if {@link RowDetailDefDirective}
4861
4863
  * templates are projected. Defaults to true.
@@ -5005,6 +5007,17 @@ declare class TableComponent<T = unknown> {
5005
5007
  protected cellContext(row: T): {
5006
5008
  $implicit: T;
5007
5009
  };
5010
+ /**
5011
+ * Header row chrome. Soft fill lives on each `th` ({@link headerSoftClass})
5012
+ * so sticky columns do not double-tint and paint over neighbor labels.
5013
+ * @returns Header row class list.
5014
+ */
5015
+ protected headerRowClass(): string;
5016
+ /**
5017
+ * Opaque mode soft fill for header cells (sticky-safe in dark mode).
5018
+ * @returns Soft solid background utilities.
5019
+ */
5020
+ protected headerSoftClass(): string;
5008
5021
  /**
5009
5022
  * Body row classes — row marker for hover styles, plus hide cell borders on
5010
5023
  * the last / expanded row.
@@ -5028,8 +5041,8 @@ declare class TableComponent<T = unknown> {
5028
5041
  protected expandHeaderClass(): string;
5029
5042
  /**
5030
5043
  * Body classes for the expand/collapse trigger column (pinned left).
5031
- * Omits `border-b` like other sticky cells so the opaque fill does not
5032
- * double-paint the row divider.
5044
+ * Uses a slightly thicker bottom border than scrolling cells so the
5045
+ * sticky fill still reads a clear divider (no top-shadow fallback).
5033
5046
  * @param index - Index within {@link rows}.
5034
5047
  * @returns Body cell class list.
5035
5048
  */
@@ -5042,9 +5055,8 @@ declare class TableComponent<T = unknown> {
5042
5055
  protected headerCellClass(col: TableColumn<T>): string;
5043
5056
  /**
5044
5057
  * Body cell classes, including sticky pin when set.
5045
- * Sticky cells omit `border-b`: their opaque fill sits in a higher stacking
5046
- * context, so a bottom border plus the next row's top shadow paint on the
5047
- * same pixel and read brighter (especially in dark mode).
5058
+ * Sticky expand/actions columns use a 1.5px bottom border; scrolling cells
5059
+ * stay at 1px. Expanded rows drop the bottom border (see {@link bodyRowClass}).
5048
5060
  * @param col - Column definition.
5049
5061
  * @param index - Index within {@link rows}.
5050
5062
  * @returns Body cell class list.
@@ -5054,22 +5066,17 @@ declare class TableComponent<T = unknown> {
5054
5066
  * Sticky pin utilities for a known edge (expand column or data column).
5055
5067
  * @param edge - Left or right pin.
5056
5068
  * @param surface - Header vs body fill tokens.
5057
- * @param index - Body row index; unused for the header.
5058
- * @param afterExpanded - Previous row is expanded — skip the top shadow
5059
- * divider so the sticky column stays flush under the detail panel.
5060
5069
  * @returns Sticky utilities.
5061
5070
  */
5062
- protected stickyPinClass(edge: 'left' | 'right', surface: 'head' | 'body', index?: number, afterExpanded?: boolean): string;
5071
+ protected stickyPinClass(edge: 'left' | 'right', surface: 'head' | 'body'): string;
5063
5072
  /**
5064
5073
  * Sticky pin plus an opaque fill matching the table surface, so scrolled
5065
5074
  * cells do not show through.
5066
5075
  * @param col - Column definition.
5067
5076
  * @param surface - Header vs body fill tokens.
5068
- * @param index - Body row index; unused for the header.
5069
- * @param afterExpanded - Previous row is expanded — skip the top shadow.
5070
5077
  * @returns Sticky utilities, or empty when the column scrolls.
5071
5078
  */
5072
- protected stickySurfaceClass(col: TableColumn<T>, surface: 'head' | 'body', index?: number, afterExpanded?: boolean): string;
5079
+ protected stickySurfaceClass(col: TableColumn<T>, surface: 'head' | 'body'): string;
5073
5080
  /**
5074
5081
  * Stable id for a row — used for expand/collapse state.
5075
5082
  *
@@ -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
- ].join(', ');
89
+ ];
85
90
 
86
- addBase({
87
- [autofillSelectors]: {
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 ${autofillSelectors}`]: {
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({ latitude: '6.45', longitude: '3.39', city: 'Lagos' });
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(loadHeaderWeather(fetchFn as unknown as typeof fetch)).resolves.toEqual({
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(2);
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(4);
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
- const CACHE_KEY = 'africanies-header-weather-v3';
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 geo = (await fetchJson(run, GEO_URL)) as GeoJsPayload | null;
123
- const latitude = asNumber(geo?.latitude);
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
  `&current=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 = asCity(geo?.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;