UI-Data Alignment
The Core Problem
A web application that shows buttons, links, or filters for data that doesn't exist creates a worse experience than one that simply omits them. Three bugs in this project shared the same root cause: the UI assumed uniform data availability when the underlying data was sparse or partitioned.
Trap 1: Dead-End Links to Empty Pages
The occupation profile page displayed a "Compare by State" button for all 1,447 occupations. But 600 occupations — mostly broad groups and minor groups, plus 49 detailed occupations — have no state-level OEWS wage data. Clicking the button led to a page that said "No state-level wage data available."
# Occupations WITH state data by level:
detailed_occupation: 818 # Most detailed occupations have state data
major_group: 22 # A few major groups
broad_occupation: 7 # Very few broad occupations
# Occupations WITHOUT state data by level:
broad_occupation: 452 # Most broad occupations lack state data
minor_group: 98 # All minor groups lack state data
detailed_occupation: 49 # Some detailed occupations too
The fix: hide the link by default and reveal it only after confirming state data exists.
// Hidden by default
html += '<p id="compare-state-link" style="display:none;">' +
'<a href="..." class="btn">Compare by State</a></p>';
// Background check — only show if data exists
fetchWithTimeout("/api/occupations/" + code + "/wages?geo_type=state")
.then(function(r) { return r.ok ? r.json() : null; })
.then(function(stateData) {
if (stateData && stateData.wages && stateData.wages.length > 0) {
document.getElementById("compare-state-link").style.display = "";
}
});
Rule: Don't show navigation to views that will be empty. A dead-end page erodes user trust more than a missing link.
Trap 2: Time as a Column Instead of a Filter
The Ranked Movers page displayed all years' data in one table with a "Year" column. This mixed 2022 and 2023 movers together, making comparison within a single period impossible. Users had to mentally filter by year while scanning the list.
# BEFORE: Mixed years, year as a data column
| Occupation | YoY Change | YoY % | Year |
| Dancers | +5,080 | +132% | 2022 |
| Subway and Streetcar Ops | +5,740 | +63% | 2023 |
| Model Makers, Wood | +380 | +112% | 2022 |
# Users can't tell if 2022 or 2023 movers are "bigger"
# AFTER: Year as a filter, all rows are same year
Year: [2023 ▼]
| Occupation | YoY Change | YoY % |
| Subway and Streetcar Ops | +5,740 | +63% |
| Plasterers and Stucco Mason | +2,770 | +39% |
# Clean single-period comparison
The fix: add a year query parameter to the API endpoint, default to the
latest year, and return available_years in the response so the UI can
populate a dropdown.
# API response now includes:
{
"year": 2023,
"available_years": [2022, 2023],
"gainers": [...],
"losers": [...]
}
Rule: When data has a time dimension, expose it as a filter control rather than a table column. Default to the most recent period so users see current data immediately.
Trap 3: Static Site Shim Gaps
The Compare Occupations page worked perfectly on the live server but always showed
"Failed to load comparison data" on the GitHub Pages static site. The fetch shim
— which intercepts API calls and redirects them to pre-built JSON files —
had no handler for /api/trends/compare/occupations.
// The shim's catch-all tried to load a non-existent file:
// /api/trends/compare/occupations.json → 404
// Fix: assemble the comparison client-side from per-occupation files
if (p === '/api/trends/compare/occupations') {
var codes = sp.get('soc_codes').split(',');
return Promise.all(codes.map(function(c) {
// Fetch each occupation's pre-built trend JSON
return fetch(base + '/api/trends/' + c + '.json')
.then(function(r) { return r.json(); });
})).then(function(arr) {
// Assemble the comparison response client-side
return new Response(JSON.stringify({
metric: metric,
occupations: arr.map(...)
}));
});
}
The same pattern applied to metric-specific trend data: the shim needed to route
?metric=mean_annual_wage to a separate pre-built file instead of
the default employment_count file.
Rule: When a static site can't pre-generate an endpoint, check if the response can be assembled from existing pre-generated data. Client-side composition of pre-built JSON files can replicate server-side joins.
Decision Guide: UI-Data Alignment Patterns
| Scenario | Pattern | Example |
|---|---|---|
| Link to a view that may be empty | Hide-and-reveal after background probe | "Compare by State" link |
| Data spans multiple time periods | Filter control, not table column | Year dropdown on Ranked Movers |
| Dynamic query on static site | Client-side composition from pre-built data | Compare Occupations shim |
| Metric with limited data availability | Default to the mode/metric where data exists | Trend Explorer defaulting to "comparable" |