spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { t, tOpt } from '@/i18n';4import { ApiError, isNotBuilt } from '@/lib/api';5import { apiPlatform } from '@/lib/api-platform';6import { compact, formatDate, grouped } from '@/lib/format';7import { routes } from '@/lib/site';8import { topicById } from '@/lib/topics';9import type { UpdatesResponse } from '@/lib/types-analytics';10import { cn } from '@/lib/cn';11import { EmptyState, NotBuiltState } from '@/components/data/empty-state';12import { FreshnessBadge } from '@/components/data/freshness-badge';13import { Section } from '@/components/data/section';14import { PageHeader } from '@/components/explore/page-header';1516export const revalidate = 600;1718export const metadata: Metadata = {19 title: t('updates.title'),20 description: t('updates.description'),21 alternates: { canonical: routes.updates() },22};2324const STATUS_TONE: Record<string, string> = {25 ok: 'border-up/40 text-up',26 partial: 'border-warn/40 text-warn',27 failed: 'border-down/40 text-down',28 stale: 'border-warn/40 text-warn',29 unknown: 'border-rule text-ink-3',30};3132export default async function UpdatesPage() {33 let data: UpdatesResponse | null = null;34 let missing = false;35 try {36 data = await apiPlatform.updates();37 } catch (e) {38 if (isNotBuilt(e)) return <NotBuiltState />;39 if (e instanceof ApiError && e.status === 404) missing = true;40 else throw e;41 }42 const now = Date.parse(data?.meta.generated_at ?? '') || Date.now();4344 return (45 <>46 <PageHeader title={t('updates.title')} lede={t('updates.sub')} meta={data?.snapshot.built_at ? `${t('site.footer.refreshed', { date: formatDate(data.snapshot.built_at) })} · ${t('site.footer.build', { run: data.snapshot.run_id ?? '' })}` : undefined} />47 {missing || !data ? (48 <EmptyState title={t('updates.unavailable')} />49 ) : (50 <>51 {/* Snapshot ticker */}52 <section aria-label={t('updates.snapshot.title')} className="border-y border-rule">53 <dl className="ticker divide-x divide-rule">54 {[55 [t('updates.snapshot.built'), formatDate(data.snapshot.built_at), null],56 [t('updates.snapshot.observations'), compact(data.snapshot.observations), null],57 [t('updates.snapshot.indicators'), grouped(data.snapshot.indicators), null],58 [t('updates.snapshot.countries'), grouped(data.snapshot.countries), null],59 [t('updates.snapshot.changed'), grouped(data.snapshot.values_changed), t('updates.snapshot.changedHint')],60 ].map(([k, v, hint]) => (61 <div key={k as string} className="min-w-[10rem] px-4 py-4 first:pl-0" title={(hint as string | null) ?? undefined}>62 <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{k}</dt>63 <dd className="pnum mt-1 text-xl font-semibold leading-none text-ink md:text-2xl">{v}</dd>64 </div>65 ))}66 </dl>67 </section>6869 <Section id="sources" title={t('updates.sources.title')} subtitle={t('updates.sources.sub')} className="border-t-0">70 {/* Desktop table */}71 <table className="hidden w-full border-collapse text-sm md:table">72 <thead>73 <tr className="border-b border-rule text-left text-xs text-ink-3">74 <th scope="col" className="py-1.5 pr-3 font-medium">{t('updates.col.source')}</th>75 <th scope="col" className="py-1.5 pr-3 font-medium">{t('updates.col.status')}</th>76 <th scope="col" className="py-1.5 pr-3 font-medium">{t('updates.col.lastImport')}</th>77 <th scope="col" className="py-1.5 pr-3 font-medium">{t('updates.col.vintage')}</th>78 <th scope="col" className="py-1.5 pr-3 text-right font-medium">{t('updates.col.datasets')}</th>79 <th scope="col" className="py-1.5 pr-3 text-right font-medium">{t('updates.col.indicators')}</th>80 <th scope="col" className="py-1.5 pr-3 text-right font-medium">{t('updates.col.observations')}</th>81 <th scope="col" className="py-1.5 pr-3 text-right font-medium">{t('updates.col.latestYear')}</th>82 <th scope="col" className="py-1.5 pr-3 text-right font-medium">{t('updates.col.changed')}</th>83 <th scope="col" className="py-1.5 text-right font-medium">{t('updates.col.countries')}</th>84 </tr>85 </thead>86 <tbody className="divide-y divide-rule">87 {data.sources.map((s) => (88 <tr key={s.source.id}>89 <td className="py-2 pr-3">90 <Link href={routes.source(s.source.id)} className="link-quiet font-medium text-ink">91 {s.source.name ?? s.source.id}92 </Link>93 <span className="block text-2xs text-ink-3">{s.source.organization}</span>94 </td>95 <td className="py-2 pr-3">96 <span className={cn('badge', STATUS_TONE[s.status] ?? STATUS_TONE.unknown)}>{tOpt(`updates.status.${s.status}`, s.status)}</span>97 </td>98 <td className="tnum py-2 pr-3 text-ink-2">99 {formatDate(s.last_success_at ?? s.last_retrieved_at)}100 <span className="ml-1.5 inline-block align-middle">101 <FreshnessBadge retrievedAt={s.last_success_at ?? s.last_retrieved_at} now={now} />102 </span>103 </td>104 <td className="tnum py-2 pr-3 text-ink-2">{formatDate(s.source_updated_at)}</td>105 <td className="tnum py-2 pr-3 text-right text-ink">{grouped(s.n_datasets)}</td>106 <td className="tnum py-2 pr-3 text-right text-ink">{grouped(s.n_indicators)}</td>107 <td className="tnum py-2 pr-3 text-right text-ink">{compact(s.n_observations)}</td>108 <td className="tnum py-2 pr-3 text-right text-ink">{s.latest_year ?? t('common.na')}</td>109 <td className="tnum py-2 pr-3 text-right text-ink">{grouped(s.values_changed)}</td>110 <td className="tnum py-2 text-right text-ink">{grouped(s.countries_affected)}</td>111 </tr>112 ))}113 </tbody>114 </table>115 {/* Phone: one block per source */}116 <ul className="divide-y divide-rule md:hidden">117 {data.sources.map((s) => (118 <li key={s.source.id} className="py-3">119 <div className="flex items-center justify-between gap-2">120 <Link href={routes.source(s.source.id)} className="link-quiet inline-flex min-h-[44px] min-w-0 items-center truncate text-sm font-medium text-ink">121 {s.source.name ?? s.source.id}122 </Link>123 <span className={cn('badge shrink-0', STATUS_TONE[s.status] ?? STATUS_TONE.unknown)}>{tOpt(`updates.status.${s.status}`, s.status)}</span>124 </div>125 <dl className="tnum mt-1.5 grid grid-cols-2 gap-x-4 gap-y-1 text-xs">126 <div className="flex justify-between gap-2">127 <dt className="text-ink-3">{t('updates.col.lastImport')}</dt>128 <dd className="text-ink">{formatDate(s.last_success_at ?? s.last_retrieved_at)}</dd>129 </div>130 <div className="flex justify-between gap-2">131 <dt className="text-ink-3">{t('updates.col.vintage')}</dt>132 <dd className="text-ink">{formatDate(s.source_updated_at)}</dd>133 </div>134 <div className="flex justify-between gap-2">135 <dt className="text-ink-3">{t('updates.col.observations')}</dt>136 <dd className="text-ink">{compact(s.n_observations)}</dd>137 </div>138 <div className="flex justify-between gap-2">139 <dt className="text-ink-3">{t('updates.col.latestYear')}</dt>140 <dd className="text-ink">{s.latest_year ?? t('common.na')}</dd>141 </div>142 <div className="flex justify-between gap-2">143 <dt className="text-ink-3">{t('updates.col.changed')}</dt>144 <dd className="text-ink">{grouped(s.values_changed)}</dd>145 </div>146 <div className="flex justify-between gap-2">147 <dt className="text-ink-3">{t('updates.col.countries')}</dt>148 <dd className="text-ink">{grouped(s.countries_affected)}</dd>149 </div>150 </dl>151 </li>152 ))}153 </ul>154 <p className="mt-3 max-w-prose text-xs text-ink-3">{t('updates.schedule')}</p>155 </Section>156157 <div className="grid gap-x-10 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">158 <Section id="runs" title={t('updates.runs.title')} subtitle={t('updates.runs.sub')}>159 {data.recent_runs.length === 0 ? (160 <p className="text-sm text-ink-3">{t('updates.none')}</p>161 ) : (162 <ol className="divide-y divide-rule border-y border-rule">163 {data.recent_runs.map((r, i) => (164 <li key={`${r.run_id}-${r.connector}-${r.dataset}-${i}`} className="grid gap-x-4 gap-y-0.5 py-2 text-sm sm:grid-cols-[5.5rem_minmax(0,1fr)_5rem_6rem]">165 <span className={cn('badge self-start', STATUS_TONE[r.status ?? 'unknown'] ?? STATUS_TONE.unknown)}>{tOpt(`source.run.${r.status ?? 'unknown'}`, r.status ?? '')}</span>166 <span className="min-w-0">167 <span className="block truncate font-mono text-xs text-ink">168 {r.connector} · {r.dataset}169 </span>170 {r.message ? <span className="block truncate text-xs text-ink-3">{r.message}</span> : null}171 </span>172 <span className="tnum text-xs text-ink-2">173 {r.rows_valid != null ? `${compact(r.rows_valid)} ${t('updates.col.rows').toLowerCase()}` : ''}174 {r.warnings ? ` · ${r.warnings} ⚠` : ''}175 {r.errors ? ` · ${r.errors} ✕` : ''}176 </span>177 <span className="tnum text-xs text-ink-3">{formatDate(r.finished_at ?? r.started_at)}</span>178 </li>179 ))}180 </ol>181 )}182 </Section>183 <Section id="indicators" title={t('updates.indicators.title')} subtitle={t('updates.indicators.sub')}>184 <ul className="divide-y divide-rule">185 {data.indicators_recently_updated.slice(0, 15).map((ind) => (186 <li key={ind.id}>187 <Link href={routes.indicator(ind.slug)} className="group grid min-h-[48px] grid-cols-[1fr_auto] items-center gap-x-4 py-2">188 <span className="min-w-0">189 <span className="block truncate text-sm text-ink group-hover:text-accent">{ind.name ?? ind.slug}</span>190 <span className="block truncate text-xs text-ink-3">191 {topicById(ind.topic ?? '')?.short ?? ind.topic}192 {ind.primary_source_id ? ` · ${ind.primary_source_id}` : ''}193 </span>194 </span>195 <span className="tnum text-right text-xs text-ink-2">196 <span className="block">{formatDate(ind.latest_source_updated_at)}</span>197 {ind.last_year ? <span className="block text-ink-3">→ {ind.last_year}</span> : null}198 </span>199 </Link>200 </li>201 ))}202 </ul>203 </Section>204 </div>205 </>206 )}207 </>208 );209}210