● Zion Boggan
repos/Docket/app/spending/page.tsx
zionboggan.com ↗
323 lines · tsx
History for this file →
1
import type { Metadata } from "next";
2
import Link from "next/link";
3
import { Suspense } from "react";
4
import { getFiscal, getApportionments, type Apportionment } from "@/lib/feed";
5
import { Card, PageHeader, SectionHeader } from "@/components/ui";
6
import { DataTable, type Column, type Row } from "@/components/data-table";
7
import { SourceLink } from "@/components/source-link";
8
import { formatUsd, formatDate } from "@/lib/format";
9
import { CiteThis } from "@/components/cite-this";
10
import { SpendingAwardsBrowser } from "@/components/spending-awards-browser";
11
 
12
export const metadata: Metadata = {
13
  title: "Spending",
14
  description:
15
    "Federal awards, OMB apportionments, and fiscal totals from USAspending.gov, .max.gov, and Treasury. Every figure sourced.",
16
};
17
 
18
export const revalidate = 300;
19
 
20
 
21
 
22
function dedash(s: string): string {
23
  return s ? s.replace(/--/g, "-") : s;
24
}
25
 
26
function apportionmentColor(status: string): string {
27
  const s = (status || "").toLowerCase();
28
  if (s.startsWith("withhold")) return "var(--dk-nay)";
29
  if (s.startsWith("delay")) return "var(--dk-hold)";
30
  return "var(--dk-yea)";
31
}
32
 
33
function AwardsSkeleton() {
34
  return (
35
    <div className="mt-8 space-y-4">
36
      <div className="h-14 w-full animate-pulse rounded-sm bg-graphite" />
37
      <div className="mt-8 mb-4 h-4 w-48 animate-pulse rounded-sm bg-graphite" />
38
      <div className="space-y-1">
39
        {Array.from({ length: 10 }).map((_, i) => (
40
          <div key={i} className="h-12 w-full animate-pulse rounded-sm bg-graphite" />
41
        ))}
42
      </div>
43
    </div>
44
  );
45
}
46
 
47
export default function SpendingPage() {
48
  const fiscal = getFiscal();
49
  const apportionments = getApportionments();
50
 
51
  const withheld = apportionments.filter((a) =>
52
    (a.status || "").toLowerCase().startsWith("withhold")
53
  );
54
  const delayed = apportionments.filter((a) =>
55
    (a.status || "").toLowerCase().startsWith("delay")
56
  );
57
  const released = apportionments.filter((a) =>
58
    (a.status || "").toLowerCase().startsWith("release")
59
  );
60
  const withholdTotal = withheld.reduce(
61
    (n, a) => n + (a.appropriated ?? 0) - (a.apportioned ?? 0),
62
    0
63
  );
64
 
65
  return (
66
    <div
67
      className="mx-auto w-full max-w-[860px] px-6 sm:px-8"
68
      style={{
69
        paddingTop: "clamp(32px, 6vw, 56px)",
70
        paddingBottom: "clamp(48px, 8vw, 80px)",
71
      }}
72
    >
73
      {}
74
      <nav aria-label="Breadcrumb" className="mb-10">
75
        <ol className="flex flex-wrap items-center gap-2" style={{ marginBottom: "12px" }}>
76
          <li>
77
            <Link
78
              href="/"
79
              className="focus-ring label no-underline hover:underline"
80
              style={{ color: "var(--dk-signal)", fontSize: "12px", letterSpacing: "0.12em" }}
81
            >
82
              Home
83
            </Link>
84
          </li>
85
          <li aria-hidden="true" className="label" style={{ color: "var(--dk-ash-2)", fontSize: "12px" }}>/</li>
86
          <li
87
            className="label"
88
            style={{ color: "var(--dk-ash)", fontSize: "12px", letterSpacing: "0.12em" }}
89
            aria-current="page"
90
          >
91
            Spending
92
          </li>
93
        </ol>
94
        <Link
95
          href="/"
96
          className="focus-ring tap no-underline hover:underline"
97
          style={{
98
            display: "inline-flex",
99
            alignItems: "center",
100
            gap: "8px",
101
            fontFamily: "var(--font-mono), ui-monospace, monospace",
102
            fontSize: "13px",
103
            letterSpacing: "0.08em",
104
            color: "var(--dk-signal)",
105
          }}
106
        >
107
          ← Back to home
108
        </Link>
109
      </nav>
110
 
111
      <PageHeader
112
        eyebrow="Spending register"
113
        title="Dollars, by official record"
114
        lead="Awards from USAspending.gov; debt, outlays, and cash from the Daily Treasury Statement. Figures only - no characterization."
115
      />
116
 
117
      {}
118
      <nav
119
        aria-label="Spending sections"
120
        style={{ marginBottom: "clamp(24px, 4vw, 40px)" }}
121
      >
122
        <p
123
          className="label"
124
          style={{ color: "var(--dk-ash-2)", fontSize: "12px", letterSpacing: "0.12em", marginBottom: "10px" }}
125
        >
126
          Jump to
127
        </p>
128
        <div className="flex flex-wrap gap-2">
129
          {[
130
            { href: "#awards", label: "Awards" },
131
            { href: "#apportionment", label: "Apportionment tracker" },
132
            { href: "#fiscal", label: "Fiscal totals" },
133
          ].map(({ href, label }) => (
134
            <a
135
              key={href}
136
              href={href}
137
              className="focus-ring label inline-flex min-h-[44px] items-center rounded-[3px] border border-line bg-surface px-4 text-ash transition-colors hover:bg-graphite hover:text-paper no-underline"
138
              style={{ fontSize: "12px", letterSpacing: "0.10em" }}
139
            >
140
              {label}
141
            </a>
142
          ))}
143
        </div>
144
      </nav>
145
 
146
      {}
147
      <section id="awards">
148
      <Suspense fallback={<AwardsSkeleton />}>
149
        <SpendingAwardsBrowser />
150
      </Suspense>
151
      </section>
152
 
153
      {}
154
      <section id="apportionment" style={{ marginTop: "clamp(40px, 7vw, 64px)" }}>
155
        <SectionHeader
156
          eyebrow="Apportionments"
157
          title="Impound tracker"
158
          description="Appropriations vs. amounts apportioned for spending, per agency and account. A withheld or delayed status is recorded as fact, not characterized."
159
        />
160
 
161
        {}
162
        <div className="mb-6 grid gap-4 sm:grid-cols-3">
163
          <ImpoundCard label="Released" count={released.length} color="var(--dk-yea)" />
164
          <ImpoundCard label="Delayed" count={delayed.length} color="var(--dk-hold)" />
165
          <ImpoundCard
166
            label="Withheld"
167
            count={withheld.length}
168
            color="var(--dk-nay)"
169
            sub={withholdTotal > 0 ? `${formatUsd(withholdTotal, { compact: true })} gap` : undefined}
170
          />
171
        </div>
172
 
173
        {apportionments.length === 0 ? (
174
          <Card className="p-5">
175
            <p className="font-mono text-[13px] text-ash">
176
              Apportionment data from OMB (.max.gov) is not yet in the record.
177
              When the feed is active, each row shows appropriated vs. apportioned
178
              and a released, delayed, or withheld status.
179
            </p>
180
          </Card>
181
        ) : (
182
          <ApportionmentsTable apportionments={apportionments} color={apportionmentColor} />
183
        )}
184
        <p className="label mt-4 text-ash-2">
185
          Source: apportionment-public.max.gov (OMB). Appropriated and apportioned are recorded figures; the gap is shown where one exists.
186
        </p>
187
      </section>
188
 
189
      {}
190
      <section id="fiscal" style={{ marginTop: "clamp(40px, 7vw, 64px)" }}>
191
        <SectionHeader eyebrow="Treasury" title="Fiscal totals" />
192
        {fiscal.length > 0 ? (
193
          <div className="grid gap-4 sm:grid-cols-3">
194
            {fiscal.map((m) => (
195
              <Card key={m.id} className="p-5">
196
                <p className="label mb-2">{m.metric}</p>
197
                <p className="display text-2xl text-paper">{m.valueText}</p>
198
                <p className="label mt-2 text-ash-2">{formatDate(m.date)}</p>
199
                <div className="mt-3">
200
                  <SourceLink url={m.sourceUrl} />
201
                </div>
202
              </Card>
203
            ))}
204
          </div>
205
        ) : (
206
          <Card className="p-5">
207
            <p className="label mb-2">Fiscal totals</p>
208
            <p className="font-mono text-[13px] text-ash">
209
              Debt, outlays, and cash figures from Treasury are not yet in the
210
              record. The Daily Treasury Statement feed will populate this panel.
211
            </p>
212
            <div className="mt-3">
213
              <SourceLink url="https://fiscaldata.treasury.gov/datasets/daily-treasury-statement/operating-cash-balance" />
214
            </div>
215
          </Card>
216
        )}
217
      </section>
218
 
219
      {}
220
      <div
221
        style={{
222
          marginTop: "clamp(40px, 7vw, 72px)",
223
          borderTop: "1px solid var(--dk-line)",
224
          paddingTop: "clamp(24px, 4vw, 40px)",
225
        }}
226
      >
227
        <p className="label mb-4" style={{ color: "var(--dk-ash)" }}>Cite this page</p>
228
        <CiteThis
229
          kind="page"
230
          title="Spending - Federal awards and apportionments"
231
          permalink="https://usdocket.org/spending"
232
          sourceUrl="https://www.usaspending.gov/"
233
        />
234
      </div>
235
    </div>
236
  );
237
}
238
 
239
function ApportionmentsTable({
240
  apportionments,
241
  color,
242
}: {
243
  apportionments: Apportionment[];
244
  color: (s: string) => string;
245
}) {
246
  const columns: Column[] = [
247
    { key: "agency", header: "Agency", cardTitle: true },
248
    { key: "account", header: "Account", width: "7.5rem" },
249
    { key: "appropriated", header: "Appropriated", align: "right", width: "8rem", hideOnCard: true },
250
    { key: "apportioned", header: "Apportioned", align: "right", width: "8rem", hideOnCard: true },
251
    { key: "status", header: "Status", width: "6.5rem" },
252
    { key: "source", header: "Source", align: "right", width: "11rem" },
253
  ];
254
  const rows: Row[] = apportionments.map((a) => {
255
    const c = color(a.status);
256
    return {
257
      key: a.id,
258
      cells: {
259
        agency: <span className="text-paper">{dedash(a.agency)}</span>,
260
        account: <span className="text-ash">{a.account}</span>,
261
        appropriated: (
262
          <span className="tabular-nums text-paper">
263
            {a.appropriated == null ? "-" : formatUsd(a.appropriated)}
264
          </span>
265
        ),
266
        apportioned: (
267
          <span className="tabular-nums text-paper">
268
            {a.apportioned == null ? "-" : formatUsd(a.apportioned)}
269
          </span>
270
        ),
271
        status: (
272
          <span className="inline-flex items-center gap-1.5 uppercase" style={{ color: c }}>
273
            <span
274
              aria-hidden="true"
275
              className="inline-block h-1.5 w-1.5 rounded-full"
276
              style={{ backgroundColor: c }}
277
            />
278
            {a.status}
279
          </span>
280
        ),
281
        source: <SourceLink url={a.sourceUrl} label="OMB" />,
282
      },
283
    };
284
  });
285
  return (
286
    <DataTable
287
      caption="OMB apportionments: agency, account, appropriated, apportioned, status, source."
288
      cardLabel="OMB apportionments"
289
      columns={columns}
290
      rows={rows}
291
    />
292
  );
293
}
294
 
295
function ImpoundCard({
296
  label,
297
  count,
298
  color,
299
  sub,
300
}: {
301
  label: string;
302
  count: number;
303
  color: string;
304
  sub?: string;
305
}) {
306
  return (
307
    <Card className="p-5">
308
      <div className="label mb-2 flex items-center gap-1.5" style={{ color }}>
309
        <span
310
          aria-hidden="true"
311
          className="inline-block h-1.5 w-1.5 rounded-full"
312
          style={{ backgroundColor: color }}
313
        />
314
        {label}
315
      </div>
316
      <div className="flex items-baseline gap-2">
317
        <span className="display text-3xl text-paper">{count}</span>
318
        <span className="label text-ash-2">account{count === 1 ? "" : "s"}</span>
319
      </div>
320
      {sub ? <p className="label mt-2 text-ash">{sub}</p> : null}
321
    </Card>
322
  );
323
}