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%
11.9 KB · 210 lines tsx
Raw Blame History
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