SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%

Server-safe URL/indicator-option helpers; frames values rounded to 6 significant digits

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 12 days ago (Sep 12, 2026) parent b17ad32

5 changed files +61 −45

modified apps/web/src/components/controls/indicator-select.tsx +2 −16
@@ -4,23 +4,9 @@ import { useEffect, useId, useMemo, useRef, useState } from 'react';
4 4 import { t } from '@/i18n';
5 5 import { cn } from '@/lib/cn';
6 6 import { topicById } from '@/lib/topics';
7 −import type { IndicatorSummary } from '@/lib/types';
8 7
9 −export interface IndicatorOption {
10 − slug: string;
11 − name: string;
12 − short_name?: string | null;
13 − topic?: string | null;
14 − unit?: string | null;
15 − featured?: boolean | null;
16 − first_year?: number | null;
17 − last_year?: number | null;
18 − n_countries?: number | null;
19 −}
20 −
21 −export function toIndicatorOption(i: IndicatorSummary): IndicatorOption {
22 − return { slug: i.slug, name: i.name ?? i.slug, short_name: i.short_name, topic: i.topic, unit: i.unit, featured: i.featured, first_year: i.first_year, last_year: i.last_year, n_countries: i.n_countries };
23 −}
8 +import { toIndicatorOption, type IndicatorOption } from '@/lib/indicator-options';
9 +export { toIndicatorOption, type IndicatorOption };
24 10
25 11 /**
26 12 * Indicator picker for the analytical views: a button showing the current indicator, opening a searchable
added apps/web/src/lib/indicator-options.ts +18 −0
@@ -0,0 +1,18 @@
1 +/** Server-safe indicator option shape for pickers (no 'use client'); re-exported by components/controls/indicator-select.tsx. */
2 +import type { IndicatorSummary } from './types';
3 +
4 +export interface IndicatorOption {
5 + slug: string;
6 + name: string;
7 + short_name?: string | null;
8 + topic?: string | null;
9 + unit?: string | null;
10 + featured?: boolean | null;
11 + first_year?: number | null;
12 + last_year?: number | null;
13 + n_countries?: number | null;
14 +}
15 +
16 +export function toIndicatorOption(i: IndicatorSummary): IndicatorOption {
17 + return { slug: i.slug, name: i.name ?? i.slug, short_name: i.short_name, topic: i.topic, unit: i.unit, featured: i.featured, first_year: i.first_year, last_year: i.last_year, n_countries: i.n_countries };
18 +}
added apps/web/src/lib/url-params.ts +37 −0
@@ -0,0 +1,37 @@
1 +/**
2 + * Server-safe URL parameter helpers (no 'use client' directive) shared by server components (searchParams
3 + * parsing) and the client `useUrlState` hook in ./url-state.ts, which re-exports them.
4 + */
5 +export type UrlPatch = Record<string, string | number | boolean | null | undefined>;
6 +
7 +export function applyPatch(current: URLSearchParams, patch: UrlPatch): URLSearchParams {
8 + const p = new URLSearchParams(current.toString());
9 + for (const [k, v] of Object.entries(patch)) {
10 + if (v === undefined || v === null || v === '' || v === false) p.delete(k);
11 + else p.set(k, v === true ? '1' : String(v));
12 + }
13 + return p;
14 +}
15 +
16 +/** Parse "a,b,c" → ["a","b","c"] limited to slug-safe tokens. */
17 +export function parseList(v: string | null | undefined, max = 8): string[] {
18 + if (!v) return [];
19 + const out: string[] = [];
20 + for (const part of v.split(',')) {
21 + const s = part.trim().toLowerCase();
22 + if (s && /^[a-z0-9][a-z0-9-]*$/.test(s) && !out.includes(s)) out.push(s);
23 + if (out.length >= max) break;
24 + }
25 + return out;
26 +}
27 +
28 +export function parseYear(v: string | null | undefined): number | null {
29 + if (!v) return null;
30 + const n = Number(v);
31 + return Number.isInteger(n) && n >= 1750 && n <= 2100 ? n : null;
32 +}
33 +
34 +/** First string value of a Next `searchParams` entry. */
35 +export function firstParam(v: string | string[] | undefined): string | null {
36 + return Array.isArray(v) ? (v[0] ?? null) : (v ?? null);
37 +}
modified apps/web/src/lib/url-state.ts +2 −28
@@ -10,16 +10,8 @@ import { useCallback, useMemo, useRef } from 'react';
10 10 * const { get, set, url } = useUrlState();
11 11 * set({ year: 1990, indicator: 'gdp-per-capita' }) // null/undefined/'' remove the key
12 12 */
13 −export type UrlPatch = Record<string, string | number | boolean | null | undefined>;
14 −
15 −export function applyPatch(current: URLSearchParams, patch: UrlPatch): URLSearchParams {
16 − const p = new URLSearchParams(current.toString());
17 − for (const [k, v] of Object.entries(patch)) {
18 − if (v === undefined || v === null || v === '' || v === false) p.delete(k);
19 − else p.set(k, v === true ? '1' : String(v));
20 − }
21 − return p;
22 −}
13 +import { applyPatch, type UrlPatch } from './url-params';
14 +export { applyPatch, parseList, parseYear, type UrlPatch } from './url-params';
23 15
24 16 export function useUrlState(defaults: Record<string, string> = {}) {
25 17 const router = useRouter();
@@ -64,21 +56,3 @@ export function useUrlState(defaults: Record<string, string> = {}) {
64 56 const url = useMemo(() => `${pathname}${params.toString() ? `?${params.toString()}` : ''}`, [pathname, params]);
65 57 return { get, getNum, set, params, url } as const;
66 58 }
67 −
68 −/** Parse "a,b,c" → ["a","b","c"] limited to slug-safe tokens. */
69 −export function parseList(v: string | null | undefined, max = 8): string[] {
70 − if (!v) return [];
71 − const out: string[] = [];
72 − for (const part of v.split(',')) {
73 − const s = part.trim().toLowerCase();
74 − if (s && /^[a-z0-9][a-z0-9-]*$/.test(s) && !out.includes(s)) out.push(s);
75 − if (out.length >= max) break;
76 − }
77 − return out;
78 −}
79 −
80 −export function parseYear(v: string | null | undefined): number | null {
81 − if (!v) return null;
82 − const n = Number(v);
83 − return Number.isInteger(n) && n >= 1750 && n <= 2100 ? n : null;
84 −}
modified src/countryatlas/api/routers/indicators.py +2 −1
@@ -480,7 +480,8 @@ def get_indicator_frames(slug: str, from_: int | None = Query(None, alias="from"
480 480 rows = snap.query_rows(f"SELECT o.country_id, o.year, o.value FROM observations o JOIN countries c ON c.id = o.country_id WHERE {' AND '.join(where)}", params)
481 481 by_year: dict[int, dict[str, float]] = {}
482 482 for cid, yr, v in rows:
483 − by_year.setdefault(int(yr), {})[cid] = float(v)
483 + # 6 significant digits: halves the payload of a 66-frame × 215-country response with no visible loss
484 + by_year.setdefault(int(yr), {})[cid] = float(f"{float(v):.6g}")
484 485 years = sorted(y for y, d in by_year.items() if len(d) >= 20)
485 486 if step > 1:
486 487 years = [y for y in years if (y - years[0]) % step == 0] if years else []
487 488