Combine IPify, IPapi, Open-Meteo, Nager.Date, and Frankfurter APIs to build a dashboard that reveals everything derivable from a single IP address, from weather to holidays to exchange rates.
Every device connected to the internet has an IP address. Most people think of it as a technical identifier, like a phone number for computers. But an IP address is far more revealing than a phone number. From a single IP address, without any additional information, it is possible to determine your approximate geographic location (often within a few kilometers), your internet service provider, whether you are using a VPN, your local time zone, and by extension your local weather, upcoming public holidays, and the exchange rate of your currency. That is a lot of information from a single number.
In this tutorial, we are going to build World Clock+, a dashboard that starts with nothing but your IP address and progressively builds a comprehensive picture of your local context. It begins by detecting your IP using IPify, then geolocates it using IPapi to determine your city, country, and timezone. From there, it fans out to Open-Meteo for weather data based on your coordinates, Nager.Date for upcoming public holidays in your country, and Frankfurter for exchange rates of your local currency. The result is a personalized dashboard that knows where you are, what the weather is like, what holidays are coming up, and how your currency is performing, all without you entering a single character of input.
This project is a demonstration of how much contextual information can be derived from minimal input through API chaining. It is also a privacy education tool: by showing users exactly what their IP address reveals, it raises awareness about the information that is passively collected every time they visit a website. The dashboard is simultaneously useful and unsettling, which is exactly the reaction it should provoke.
IPify is the simplest API in our stack. It does exactly one thing: return the IP address of the client making the request. It detects whether the client has an IPv4 or IPv6 address and returns it in plain text or JSON format. The API is free, unlimited, and requires no authentication.
// IPify API
// GET https://api.ipify.org?format=json
// Response
{
"ip": "203.0.113.42"
}
// IPv6 variant
// GET https://api64.ipify.org?format=json
// Response
{
"ip": "2001:0db8:85a3:0000:0000:8a2e:0370:7334"
}
You might wonder why we need an API to tell us our own IP address. The answer is that the client-side JavaScript running in a browser does not have direct access to the machine's public IP. The browser knows the local network address, but the public IP is assigned by the ISP's network infrastructure and is only visible from outside the network. IPify solves this by reflecting the IP address that their server sees when our client makes a request.
IPapi is a geolocation service that takes an IP address and returns detailed geographic and network information. The free tier allows up to 1,000 requests per day with no API key and includes city, region, country, coordinates, timezone, currency, languages, and ISP information.
// IPapi API
// GET https://ipapi.co/{ip}/json/
// Example: GET https://ipapi.co/203.0.113.42/json/
// Response
{
"ip": "203.0.113.42",
"city": "London",
"region": "England",
"region_code": "ENG",
"country_code": "GB",
"country_name": "United Kingdom",
"continent_code": "EU",
"in_eu": false,
"postal": "EC1A",
"latitude": 51.5074,
"longitude": -0.1278,
"timezone": "Europe/London",
"utc_offset": "+0100",
"country_calling_code": "+44",
"currency": "GBP",
"currency_name": "Pound Sterling",
"languages": "en-GB,cy,gd",
"asn": "AS2856",
"org": "BT Public Internet Service"
}
The response is remarkably rich. The latitude and longitude fields give us coordinates for the weather API. The country_code maps to the holidays API. The currency code feeds into the exchange rates API. The timezone lets us display accurate local time. And the org field reveals the ISP, which can indicate whether the user is on a residential, business, or VPN connection. Each field opens a door to the next API in our chain.
Open-Meteo is an open-source weather API that provides current conditions and forecasts without requiring an API key. It takes latitude and longitude coordinates and returns temperature, humidity, wind speed, precipitation, weather codes, and much more. The data comes from national weather services and is available globally.
// Open-Meteo API
// GET https://api.open-meteo.com/v1/forecast?latitude=51.5074&longitude=-0.1278
// ¤t=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code
// &daily=temperature_2m_max,temperature_2m_min,weather_code,sunrise,sunset
// &timezone=auto
// Response (truncated)
{
"current": {
"time": "2026-05-18T14:00",
"temperature_2m": 18.3,
"relative_humidity_2m": 62,
"wind_speed_10m": 12.5,
"weather_code": 2
},
"daily": {
"time": ["2026-05-18", "2026-05-19", "2026-05-20"],
"temperature_2m_max": [19.8, 21.2, 17.5],
"temperature_2m_min": [11.3, 12.8, 10.1],
"weather_code": [2, 1, 61],
"sunrise": ["2026-05-18T05:12", "2026-05-19T05:11", "2026-05-20T05:10"],
"sunset": ["2026-05-18T20:51", "2026-05-19T20:52", "2026-05-20T20:53"]
},
"current_units": {
"temperature_2m": "°C",
"wind_speed_10m": "km/h"
}
}
The weather_code field uses the WMO (World Meteorological Organization) standard codes. Code 0 is clear sky, codes 1-3 are partly cloudy, codes 61-67 are rain of varying intensity, codes 71-77 are snow, and codes 95-99 are thunderstorms. We will map these codes to human-readable descriptions and weather emoji for the dashboard.
Nager.Date provides public holiday data for over 100 countries. Given a country code and year, it returns all public holidays with their dates, names, and types. This is exactly the kind of data that is difficult to maintain manually (holidays change year to year, countries add and remove holidays, and many holidays have moveable dates based on lunar calendars).
// Nager.Date API
// GET https://date.nager.at/api/v3/publicholidays/2026/GB
// Response (truncated)
[
{
"date": "2026-01-01",
"localName": "New Year's Day",
"name": "New Year's Day",
"countryCode": "GB",
"fixed": true,
"global": true,
"types": ["Public"]
},
{
"date": "2026-04-03",
"localName": "Good Friday",
"name": "Good Friday",
"countryCode": "GB",
"fixed": false,
"global": true,
"types": ["Public"]
}
]
Frankfurter is a free, open-source API for currency exchange rates, powered by data from the European Central Bank. It supports over 30 currencies and provides both current and historical rates. Given a base currency, it returns exchange rates against all supported currencies.
// Frankfurter API
// GET https://api.frankfurter.app/latest?from=GBP
// Response
{
"amount": 1.0,
"base": "GBP",
"date": "2026-05-18",
"rates": {
"AUD": 1.9234,
"CAD": 1.7012,
"EUR": 1.1623,
"JPY": 186.42,
"USD": 1.2756
}
}
// Historical rates
// GET https://api.frankfurter.app/2026-01-01..2026-05-18?from=GBP&to=USD,EUR
What makes World Clock+ compelling is the chain of API calls where each call's output feeds into the next call's input. IPify gives us the IP. IPapi takes the IP and gives us coordinates, country, currency, and timezone. Open-Meteo takes the coordinates and gives us weather. Nager.Date takes the country and gives us holidays. Frankfurter takes the currency and gives us exchange rates. Five APIs, chained through their outputs, producing a comprehensive dashboard from zero user input.
// The API chain
//
// Step 1: IPify -> IP address
// |
// Step 2: IPapi -> { lat, lng, country, currency, timezone, city }
// | | |
// Step 3 (parallel): v v v
// Open-Meteo(lat,lng) Nager(country) Frankfurter(currency)
// -> weather -> holidays -> exchange rates
// |
// Step 4: Render Dashboard
Our API chain has a specific dependency structure. Steps 1 and 2 must be sequential (we need the IP before we can geolocate it). But Step 3, the three downstream APIs, can run in parallel because they depend only on Step 2's output, not on each other. This sequential-then-parallel pattern is extremely common in real applications and is important to implement correctly for performance.
async function buildDashboard() {
updateState({ loading: true, error: null, phase: 'Detecting IP...' });
try {
// Step 1: Get IP (sequential)
const ipData = await fetchIP();
updateState({ phase: 'Geolocating...' });
// Step 2: Geolocate IP (sequential - depends on Step 1)
const geoData = await geolocateIP(ipData.ip);
updateState({ phase: 'Loading local data...' });
// Step 3: Fan out to downstream APIs (parallel - all depend on Step 2)
const [weatherData, holidayData, currencyData] = await Promise.allSettled([
fetchWeather(geoData.latitude, geoData.longitude, geoData.timezone),
fetchHolidays(geoData.country_code),
fetchExchangeRates(geoData.currency)
]);
// Assemble dashboard
const dashboard = {
ip: ipData,
geo: geoData,
weather: weatherData.status === 'fulfilled' ? weatherData.value : null,
holidays: holidayData.status === 'fulfilled' ? holidayData.value : null,
currency: currencyData.status === 'fulfilled' ? currencyData.value : null,
errors: collectErrors([weatherData, holidayData, currencyData]),
timestamp: new Date().toISOString()
};
updateState({ loading: false, dashboard });
} catch (err) {
updateState({
loading: false,
error: `Failed to build dashboard: ${err.message}`
});
}
}
function collectErrors(results) {
return results
.filter(r => r.status === 'rejected')
.map(r => r.reason.message);
}
Rather than waiting for all five APIs to respond before showing anything, we render each section as its data becomes available. The IP and geolocation data appear first (within 200-400ms), then the weather, holidays, and exchange rates fill in as they resolve (within 500-1000ms). This progressive display keeps the user engaged and makes the dashboard feel fast even though the total data loading time might be over a second.
async function fetchIP() {
const response = await fetch('https://api.ipify.org?format=json');
if (!response.ok) throw new Error('Failed to detect IP address');
return response.json();
}
// Also detect IPv6 if available
async function fetchIPv6() {
try {
const response = await fetch('https://api64.ipify.org?format=json');
if (!response.ok) return null;
return response.json();
} catch {
return null; // IPv6 not available
}
}
async function geolocateIP(ip) {
const response = await fetch(`https://ipapi.co/${ip}/json/`);
if (!response.ok) {
if (response.status === 429) {
throw new Error('Geolocation rate limit reached. Please try again later.');
}
throw new Error(`Geolocation failed with status ${response.status}`);
}
const data = await response.json();
// Check for error in response body
if (data.error) {
throw new Error(`Geolocation error: ${data.reason || 'Unknown'}`);
}
return {
ip: data.ip,
city: data.city,
region: data.region,
country_code: data.country_code,
country_name: data.country_name,
continent: data.continent_code,
latitude: data.latitude,
longitude: data.longitude,
timezone: data.timezone,
utc_offset: data.utc_offset,
currency: data.currency,
currency_name: data.currency_name,
languages: data.languages,
isp: data.org,
asn: data.asn,
postal: data.postal,
calling_code: data.country_calling_code,
is_eu: data.in_eu
};
}
async function fetchWeather(lat, lng, timezone) {
const params = new URLSearchParams({
latitude: lat,
longitude: lng,
current: [
'temperature_2m', 'relative_humidity_2m', 'apparent_temperature',
'weather_code', 'wind_speed_10m', 'wind_direction_10m'
].join(','),
daily: [
'temperature_2m_max', 'temperature_2m_min', 'weather_code',
'sunrise', 'sunset', 'precipitation_sum'
].join(','),
timezone: timezone || 'auto',
forecast_days: 7
});
const response = await fetch(
`https://api.open-meteo.com/v1/forecast?${params}`
);
if (!response.ok) throw new Error('Weather API failed');
const data = await response.json();
return {
current: {
temperature: data.current.temperature_2m,
feelsLike: data.current.apparent_temperature,
humidity: data.current.relative_humidity_2m,
windSpeed: data.current.wind_speed_10m,
windDirection: data.current.wind_direction_10m,
weatherCode: data.current.weather_code,
weatherDescription: getWeatherDescription(data.current.weather_code),
weatherEmoji: getWeatherEmoji(data.current.weather_code)
},
daily: data.daily.time.map((date, i) => ({
date: date,
maxTemp: data.daily.temperature_2m_max[i],
minTemp: data.daily.temperature_2m_min[i],
weatherCode: data.daily.weather_code[i],
weatherEmoji: getWeatherEmoji(data.daily.weather_code[i]),
sunrise: data.daily.sunrise[i],
sunset: data.daily.sunset[i],
precipitation: data.daily.precipitation_sum[i]
})),
units: data.current_units
};
}
function getWeatherDescription(code) {
const descriptions = {
0: 'Clear sky',
1: 'Mainly clear', 2: 'Partly cloudy', 3: 'Overcast',
45: 'Fog', 48: 'Depositing rime fog',
51: 'Light drizzle', 53: 'Moderate drizzle', 55: 'Dense drizzle',
56: 'Light freezing drizzle', 57: 'Dense freezing drizzle',
61: 'Slight rain', 63: 'Moderate rain', 65: 'Heavy rain',
66: 'Light freezing rain', 67: 'Heavy freezing rain',
71: 'Slight snow', 73: 'Moderate snow', 75: 'Heavy snow',
77: 'Snow grains',
80: 'Slight rain showers', 81: 'Moderate rain showers', 82: 'Violent rain showers',
85: 'Slight snow showers', 86: 'Heavy snow showers',
95: 'Thunderstorm', 96: 'Thunderstorm with slight hail',
99: 'Thunderstorm with heavy hail'
};
return descriptions[code] || 'Unknown';
}
function getWeatherEmoji(code) {
if (code === 0) return '☀️';
if (code <= 3) return '⛅';
if (code <= 48) return '🌫️';
if (code <= 57) return '🌦️';
if (code <= 67) return '🌧️';
if (code <= 77) return '🌨️';
if (code <= 82) return '🌧️';
if (code <= 86) return '🌨️';
if (code >= 95) return '⛈️';
return '🌤️';
}
async function fetchHolidays(countryCode) {
const currentYear = new Date().getFullYear();
const response = await fetch(
`https://date.nager.at/api/v3/publicholidays/${currentYear}/${countryCode}`
);
if (!response.ok) {
if (response.status === 404) {
return { holidays: [], supported: false };
}
throw new Error('Holiday API failed');
}
const holidays = await response.json();
const today = new Date().toISOString().split('T')[0];
// Split into past and upcoming
const upcoming = holidays.filter(h => h.date >= today);
const past = holidays.filter(h => h.date < today);
// Calculate days until next holiday
const nextHoliday = upcoming[0];
let daysUntilNext = null;
if (nextHoliday) {
const nextDate = new Date(nextHoliday.date);
const todayDate = new Date(today);
daysUntilNext = Math.ceil((nextDate - todayDate) / (1000 * 60 * 60 * 24));
}
return {
supported: true,
total: holidays.length,
upcoming: upcoming,
past: past,
nextHoliday: nextHoliday,
daysUntilNext: daysUntilNext,
remainingThisYear: upcoming.length
};
}
async function fetchExchangeRates(baseCurrency) {
if (!baseCurrency) {
throw new Error('No currency detected');
}
// Get current rates
const currentRes = await fetch(
`https://api.frankfurter.app/latest?from=${baseCurrency}`
);
if (!currentRes.ok) {
if (currentRes.status === 404) {
return { supported: false, base: baseCurrency };
}
throw new Error('Currency API failed');
}
const currentData = await currentRes.json();
// Get rates from 30 days ago for trend calculation
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
.toISOString().split('T')[0];
let historicalData = null;
try {
const historicalRes = await fetch(
`https://api.frankfurter.app/${thirtyDaysAgo}?from=${baseCurrency}`
);
if (historicalRes.ok) {
historicalData = await historicalRes.json();
}
} catch {
// Historical data is optional
}
// Calculate trends for major currencies
const majorCurrencies = ['USD', 'EUR', 'GBP', 'JPY', 'CAD', 'AUD', 'CHF'];
const rates = majorCurrencies
.filter(c => c !== baseCurrency && currentData.rates[c])
.map(currency => {
const current = currentData.rates[currency];
const historical = historicalData ? historicalData.rates[currency] : null;
const change = historical ? ((current - historical) / historical * 100) : null;
return {
currency: currency,
rate: current,
change30d: change ? change.toFixed(2) : null,
trending: change > 0 ? 'up' : change < 0 ? 'down' : 'stable'
};
});
return {
supported: true,
base: baseCurrency,
date: currentData.date,
rates: rates,
allRates: currentData.rates
};
}
The dashboard includes a live clock that updates every second, displayed in the user's detected timezone. This reinforces the "we know where you are" theme while being genuinely useful.
function startLiveClock(timezone) {
const clockElement = document.getElementById('liveClock');
const dateElement = document.getElementById('liveDate');
function updateClock() {
const now = new Date();
const options = {
timeZone: timezone,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
};
const dateOptions = {
timeZone: timezone,
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
};
clockElement.textContent = now.toLocaleTimeString('en-GB', options);
dateElement.textContent = now.toLocaleDateString('en-GB', dateOptions);
}
updateClock();
setInterval(updateClock, 1000);
}
function renderTimezoneInfo(geoData) {
const now = new Date();
const utcOffset = geoData.utc_offset;
const isDST = isDaylightSavingTime(now, geoData.timezone);
return `
<div class="timezone-card">
<div id="liveClock" class="live-clock"></div>
<div id="liveDate" class="live-date"></div>
<div class="timezone-details">
<span>Timezone: ${geoData.timezone}</span>
<span>UTC Offset: ${utcOffset}</span>
<span>DST Active: ${isDST ? 'Yes' : 'No'}</span>
</div>
</div>
`;
}
function isDaylightSavingTime(date, timezone) {
const jan = new Date(date.getFullYear(), 0, 1);
const jul = new Date(date.getFullYear(), 6, 1);
const janOffset = getTimezoneOffset(jan, timezone);
const julOffset = getTimezoneOffset(jul, timezone);
const currentOffset = getTimezoneOffset(date, timezone);
const standardOffset = Math.max(janOffset, julOffset);
return currentOffset < standardOffset;
}
function getTimezoneOffset(date, timezone) {
const utc = new Date(date.toLocaleString('en-US', { timeZone: 'UTC' }));
const local = new Date(date.toLocaleString('en-US', { timeZone: timezone }));
return (utc - local) / (60 * 1000);
}
function renderDashboard(dashboard) {
const { ip, geo, weather, holidays, currency } = dashboard;
// Location card
renderLocationCard(ip, geo);
// Live clock
renderTimezoneInfo(geo);
startLiveClock(geo.timezone);
// Weather card (if available)
if (weather) {
renderWeatherCard(weather, geo.city);
} else {
renderUnavailable('weather', 'Weather data unavailable');
}
// Holidays card
if (holidays && holidays.supported) {
renderHolidaysCard(holidays, geo.country_name);
} else {
renderUnavailable('holidays', `Holiday data not available for ${geo.country_name}`);
}
// Currency card
if (currency && currency.supported) {
renderCurrencyCard(currency, geo.currency_name);
} else {
renderUnavailable('currency', `Exchange rates not available for ${geo.currency}`);
}
// Privacy awareness section
renderPrivacyCard(geo);
}
function renderLocationCard(ip, geo) {
document.getElementById('locationCard').innerHTML = `
<h3>Your Location</h3>
<div class="location-main">
<span class="city-name">${geo.city}, ${geo.region}</span>
<span class="country-name">${geo.country_name}</span>
</div>
<div class="location-details">
<div class="detail">
<span class="label">IP Address</span>
<span class="value">${ip.ip}</span>
</div>
<div class="detail">
<span class="label">Coordinates</span>
<span class="value">${geo.latitude.toFixed(4)}, ${geo.longitude.toFixed(4)}</span>
</div>
<div class="detail">
<span class="label">ISP</span>
<span class="value">${geo.isp}</span>
</div>
<div class="detail">
<span class="label">Postal Code</span>
<span class="value">${geo.postal || 'Unknown'}</span>
</div>
<div class="detail">
<span class="label">Currency</span>
<span class="value">${geo.currency} (${geo.currency_name})</span>
</div>
<div class="detail">
<span class="label">Languages</span>
<span class="value">${geo.languages}</span>
</div>
<div class="detail">
<span class="label">EU Member</span>
<span class="value">${geo.is_eu ? 'Yes' : 'No'}</span>
</div>
</div>
`;
}
function renderPrivacyCard(geo) {
document.getElementById('privacyCard').innerHTML = `
<h3>What Your IP Reveals</h3>
<p class="privacy-intro">
From your IP address alone, the following information was derived
without any input from you:
</p>
<ul class="privacy-list">
<li>Your approximate location: ${geo.city}, ${geo.country_name}</li>
<li>Your internet provider: ${geo.isp}</li>
<li>Your timezone: ${geo.timezone}</li>
<li>Your likely currency: ${geo.currency_name}</li>
<li>Your local weather conditions</li>
<li>Your country's public holidays</li>
<li>Whether you are in the EU (GDPR implications)</li>
<li>Your approximate postal code: ${geo.postal || 'Unavailable'}</li>
</ul>
<p class="privacy-note">
Every website you visit has access to this information. Consider using
a VPN if this level of exposure concerns you.
</p>
`;
}
Using the sunrise and sunset times from Open-Meteo, we can build a visual sun position tracker that shows where the sun is in its arc across the sky at the current moment. This ambient information display transforms the dashboard from a data table into a living representation of the user's environment. The calculation uses the solar noon (midpoint between sunrise and sunset) as the apex of a semicircular arc.
function calculateSunPosition(sunrise, sunset) {
const now = new Date();
const sunriseTime = new Date(sunrise);
const sunsetTime = new Date(sunset);
const totalDaylight = sunsetTime - sunriseTime;
const elapsed = now - sunriseTime;
const progress = Math.max(0, Math.min(1, elapsed / totalDaylight));
const isDay = now >= sunriseTime && now <= sunsetTime;
// Calculate angle along semicircular arc (0 = sunrise/east, PI = sunset/west)
const angle = progress * Math.PI;
// Sun height peaks at solar noon (PI/2)
const height = Math.sin(angle); // 0 at horizon, 1 at zenith
const horizontal = Math.cos(angle); // -1 (east) to 1 (west)
// Time until sunrise or sunset
let nextEvent, timeUntilNext;
if (!isDay && now < sunriseTime) {
nextEvent = 'sunrise';
timeUntilNext = sunriseTime - now;
} else if (isDay) {
nextEvent = 'sunset';
timeUntilNext = sunsetTime - now;
} else {
nextEvent = 'sunrise (tomorrow)';
timeUntilNext = null; // Would need tomorrow's sunrise data
}
const daylightHours = totalDaylight / (1000 * 60 * 60);
return {
isDay,
progress: (progress * 100).toFixed(1),
height: height.toFixed(3),
horizontal: horizontal.toFixed(3),
angle: (angle * 180 / Math.PI).toFixed(1),
nextEvent,
timeUntilNext: timeUntilNext ? formatDuration(timeUntilNext) : null,
daylightHours: daylightHours.toFixed(1),
sunrise: sunriseTime.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }),
sunset: sunsetTime.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
};
}
function formatDuration(ms) {
const hours = Math.floor(ms / 3600000);
const minutes = Math.floor((ms % 3600000) / 60000);
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
function renderSunTracker(sunPosition) {
const arcWidth = 300;
const arcHeight = 150;
const centerX = arcWidth / 2;
const sunX = centerX - (parseFloat(sunPosition.horizontal) * centerX);
const sunY = arcHeight - (parseFloat(sunPosition.height) * (arcHeight - 20));
return `
<div class="sun-tracker">
<svg viewBox="0 0 ${arcWidth} ${arcHeight + 30}" width="100%">
<!-- Horizon line -->
<line x1="0" y1="${arcHeight}" x2="${arcWidth}" y2="${arcHeight}"
stroke="#333" stroke-width="1" stroke-dasharray="4,4" />
<!-- Arc path -->
<path d="M 10 ${arcHeight} A ${centerX - 10} ${arcHeight - 20} 0 0 1 ${arcWidth - 10} ${arcHeight}"
fill="none" stroke="${sunPosition.isDay ? '#fed33033' : '#33333333'}"
stroke-width="1" />
<!-- Sun -->
${sunPosition.isDay ? `
<circle cx="${sunX}" cy="${sunY}" r="12"
fill="#fed330" opacity="0.9" />
<circle cx="${sunX}" cy="${sunY}" r="18"
fill="#fed330" opacity="0.2" />
` : `
<circle cx="${centerX}" cy="${arcHeight + 15}" r="8"
fill="#778ca3" opacity="0.5" />
`}
<!-- Labels -->
<text x="10" y="${arcHeight + 20}" fill="#888" font-size="10"
font-family="Inter, sans-serif">${sunPosition.sunrise}</text>
<text x="${arcWidth - 50}" y="${arcHeight + 20}" fill="#888" font-size="10"
font-family="Inter, sans-serif">${sunPosition.sunset}</text>
</svg>
<div class="sun-info">
<span>${sunPosition.isDay ? 'Day' : 'Night'}</span>
<span>Daylight: ${sunPosition.daylightHours}h</span>
${sunPosition.timeUntilNext ?
`<span>${sunPosition.nextEvent} in ${sunPosition.timeUntilNext}</span>` : ''}
</div>
</div>
`;
}
Since we already have exchange rate data from Frankfurter, we can embed a live currency converter directly in the dashboard. The user's local currency is pre-selected as the base, and they can convert to any supported currency. The converter includes the 30-day trend so users can see whether rates are moving in their favor.
function renderCurrencyConverter(currencyData) {
if (!currencyData || !currencyData.supported) return '';
return `
<div class="currency-converter">
<h3>Currency Converter</h3>
<div class="converter-input">
<input type="number" id="convertAmount" value="100"
oninput="updateConversion()" />
<span class="base-currency">${currencyData.base}</span>
</div>
<div class="conversion-results" id="conversionResults">
${currencyData.rates.map(rate => `
<div class="conversion-row">
<span class="target-currency">${rate.currency}</span>
<span class="converted-amount" data-rate="${rate.rate}">
${(100 * rate.rate).toFixed(2)}
</span>
${rate.change30d ? `
<span class="trend ${rate.trending}">
${rate.trending === 'up' ? '▲' : '▼'}
${Math.abs(parseFloat(rate.change30d))}%
</span>
` : ''}
</div>
`).join('')}
</div>
<p class="rate-date">Rates as of ${currencyData.date}</p>
</div>
`;
}
function updateConversion() {
const amount = parseFloat(document.getElementById('convertAmount').value) || 0;
document.querySelectorAll('.converted-amount').forEach(el => {
const rate = parseFloat(el.dataset.rate);
el.textContent = (amount * rate).toFixed(2);
});
}
For remote workers, one of the most practical extensions is a meeting time optimizer. The user adds cities where their team members are located, and the tool shows the overlapping business hours. Using the timezone data from IPapi and the holidays from Nager.Date, we can also warn if a proposed meeting time falls on a public holiday for any team member.
function findOverlappingBusinessHours(timezones) {
// Business hours: 9 AM to 6 PM in each timezone
const BUSINESS_START = 9;
const BUSINESS_END = 18;
const now = new Date();
const results = [];
// Check each hour of the day
for (let utcHour = 0; utcHour < 24; utcHour++) {
const localTimes = timezones.map(tz => {
const date = new Date(now);
date.setUTCHours(utcHour, 0, 0, 0);
const localTime = new Date(
date.toLocaleString('en-US', { timeZone: tz.timezone })
);
return {
timezone: tz.timezone,
city: tz.city,
localHour: localTime.getHours(),
isBusinessHour: localTime.getHours() >= BUSINESS_START &&
localTime.getHours() < BUSINESS_END
};
});
const allInBusinessHours = localTimes.every(lt => lt.isBusinessHour);
results.push({
utcHour,
localTimes,
allInBusinessHours,
inBusinessCount: localTimes.filter(lt => lt.isBusinessHour).length
});
}
// Find best overlapping windows
const perfectOverlap = results.filter(r => r.allInBusinessHours);
const bestPartial = results
.sort((a, b) => b.inBusinessCount - a.inBusinessCount)
.slice(0, 5);
return {
perfectOverlapHours: perfectOverlap.length,
perfectSlots: perfectOverlap,
bestPartialSlots: bestPartial,
allHours: results
};
}
function renderMeetingOptimizer(overlap, timezones) {
const now = new Date();
return `
<div class="meeting-optimizer">
<h3>Meeting Time Optimizer</h3>
<p>${overlap.perfectOverlapHours > 0
? `${overlap.perfectOverlapHours} hours of perfect overlap found.`
: 'No perfect overlap. Here are the best options:'}</p>
<div class="overlap-grid">
${overlap.allHours.map(hour => {
const width = (hour.inBusinessCount / timezones.length * 100);
return `
<div class="hour-row ${hour.allInBusinessHours ? 'perfect' : ''}">
<span class="utc-time">${String(hour.utcHour).padStart(2, '0')}:00 UTC</span>
<div class="overlap-bar" style="width: ${width}%;
background: ${hour.allInBusinessHours ? '#26de81' :
hour.inBusinessCount > timezones.length / 2 ? '#fed330' : '#ff6b6b44'}">
</div>
<div class="local-times">
${hour.localTimes.map(lt => `
<span class="${lt.isBusinessHour ? 'business' : 'off-hours'}">
${lt.city}: ${String(lt.localHour).padStart(2, '0')}:00
</span>
`).join(' | ')}
</div>
</div>
`;
}).join('')}
</div>
</div>
`;
}
Since the dashboard's purpose is partly educational (showing what IP addresses reveal), we can formalize this into a privacy score. The score quantifies how much information is derivable from the user's IP, factoring in geolocation accuracy, ISP identification, timezone inference, and more. A lower score means better privacy.
function calculatePrivacyScore(geoData) {
let exposurePoints = 0;
const findings = [];
// Country-level location
if (geoData.country_name) {
exposurePoints += 10;
findings.push({
severity: 'low',
item: `Country identified: ${geoData.country_name}`,
mitigation: 'VPN with exit in a different country'
});
}
// City-level location
if (geoData.city) {
exposurePoints += 25;
findings.push({
severity: 'medium',
item: `City identified: ${geoData.city}`,
mitigation: 'VPN or proxy server'
});
}
// Postal code
if (geoData.postal) {
exposurePoints += 15;
findings.push({
severity: 'medium',
item: `Postal code detected: ${geoData.postal}`,
mitigation: 'VPN masks postal code inference'
});
}
// ISP identification
if (geoData.isp) {
exposurePoints += 15;
findings.push({
severity: 'medium',
item: `ISP identified: ${geoData.isp}`,
mitigation: 'VPN replaces ISP identity with VPN provider'
});
}
// Timezone
if (geoData.timezone) {
exposurePoints += 10;
findings.push({
severity: 'low',
item: `Timezone: ${geoData.timezone}`,
mitigation: 'Browser timezone can be spoofed separately'
});
}
// Precise coordinates
if (geoData.latitude && geoData.longitude) {
exposurePoints += 20;
findings.push({
severity: 'high',
item: `Coordinates: ${geoData.latitude.toFixed(2)}, ${geoData.longitude.toFixed(2)}`,
mitigation: 'Accuracy is +-5-50km for IP geolocation'
});
}
// EU status (legal implications)
if (geoData.is_eu !== undefined) {
exposurePoints += 5;
findings.push({
severity: 'info',
item: `EU status: ${geoData.is_eu ? 'Yes' : 'No'}`,
mitigation: 'Determines which privacy laws apply to you'
});
}
// Score: 0 (fully exposed) to 100 (fully private)
const maxPoints = 100;
const privacyScore = Math.max(0, 100 - exposurePoints);
return {
score: privacyScore,
grade: privacyScore >= 80 ? 'A' : privacyScore >= 60 ? 'B' :
privacyScore >= 40 ? 'C' : privacyScore >= 20 ? 'D' : 'F',
exposurePoints,
findings,
recommendation: privacyScore < 40
? 'Consider using a VPN to significantly reduce your IP exposure.'
: privacyScore < 70
? 'Moderate exposure. A VPN would improve your privacy.'
: 'Good privacy posture. Your IP reveals limited information.'
};
}
Allow users to add multiple cities and see them displayed side by side with their current times, weather summaries, and upcoming holidays. This comparison view is invaluable for anyone who works with international teams, has family abroad, or is planning travel.
const savedCities = [
// User's detected city is always first
];
async function addCityByName(cityName) {
// Use Open-Meteo's geocoding API to find coordinates
const response = await fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(cityName)}&count=1`
);
if (!response.ok) throw new Error('Geocoding failed');
const data = await response.json();
if (!data.results || data.results.length === 0) {
throw new Error(`City "${cityName}" not found`);
}
const city = data.results[0];
// Fetch weather for this city
const weather = await fetchWeather(
city.latitude, city.longitude, city.timezone
);
// Fetch holidays for this country
let holidays = null;
try {
holidays = await fetchHolidays(city.country_code);
} catch {}
const cityData = {
name: city.name,
country: city.country,
countryCode: city.country_code,
latitude: city.latitude,
longitude: city.longitude,
timezone: city.timezone,
weather: weather,
holidays: holidays,
addedAt: new Date().toISOString()
};
savedCities.push(cityData);
saveCitiesToStorage();
renderCityComparison();
return cityData;
}
function renderCityComparison() {
const container = document.getElementById('cityComparison');
if (savedCities.length < 2) {
container.innerHTML = '<p>Add cities to compare time zones and weather.</p>';
return;
}
container.innerHTML = `
<div class="city-grid">
${savedCities.map(city => {
const now = new Date();
const localTime = now.toLocaleTimeString('en-GB', {
timeZone: city.timezone,
hour: '2-digit', minute: '2-digit'
});
const localDate = now.toLocaleDateString('en-GB', {
timeZone: city.timezone,
weekday: 'short', month: 'short', day: 'numeric'
});
const nextHoliday = city.holidays?.nextHoliday;
return `
<div class="city-card">
<h4>${city.name}, ${city.country}</h4>
<div class="city-time">${localTime}</div>
<div class="city-date">${localDate}</div>
${city.weather ? `
<div class="city-weather">
${city.weather.current.weatherEmoji}
${city.weather.current.temperature}°C
</div>
` : ''}
${nextHoliday ? `
<div class="city-holiday">
Next: ${nextHoliday.name} (${nextHoliday.date})
</div>
` : ''}
</div>
`;
}).join('')}
</div>
`;
}
function saveCitiesToStorage() {
try {
localStorage.setItem('worldclock_cities',
JSON.stringify(savedCities.map(c => ({
name: c.name, country: c.country,
countryCode: c.countryCode,
latitude: c.latitude, longitude: c.longitude,
timezone: c.timezone
})))
);
} catch {}
}
If the user is behind a VPN, all the derived data will be based on the VPN server's location, not the user's actual location. This means a user in Tokyo using a VPN with a London exit node will see London weather, UK holidays, and GBP exchange rates. This is technically correct (the data is accurate for the IP address), but it might confuse users. We add a note: "Your IP resolves to [location]. If you are using a VPN, this may not reflect your actual location."
The free tier of IPapi allows 1,000 requests per day. For a personal tool, this is plenty. But if you deploy this publicly, you will hit the limit quickly. The solution is to cache the geolocation result. IP addresses do not change location, so a 24-hour cache is perfectly safe. Store the result in localStorage keyed by IP address.
async function geolocateWithCache(ip) {
const cacheKey = `geo_${ip}`;
const cached = getCachedData(cacheKey, 24 * 60 * 60 * 1000);
if (cached) return cached;
const data = await geolocateIP(ip);
setCachedData(cacheKey, data);
return data;
}
Nager.Date supports about 100 countries, which covers most of the world but not all. Some countries in Africa, Central Asia, and the Pacific Islands are not supported. When we encounter an unsupported country, we show the holidays section with a message explaining the limitation rather than an error. This graceful degradation ensures the rest of the dashboard remains useful.
Frankfurter uses ECB data, which covers about 30 major currencies. Countries with currencies not in the ECB dataset (like many African and Asian currencies) will not have exchange rate data. Similar to holidays, we handle this gracefully with a fallback message.
There is an inherent tension in building a tool that demonstrates privacy risks: the tool itself must exercise those privacy risks to function. World Clock+ geolocates the user's IP address, which is exactly the behavior it warns about. We address this by being transparent about what the tool does, providing clear explanations of each data point, and not storing or transmitting any data beyond what is needed for the dashboard. The educational value justifies the temporary processing, but the irony is worth acknowledging.
Time zones are notoriously tricky. Some countries have half-hour offsets (India is UTC+5:30, Nepal is UTC+5:45). Some countries span multiple time zones. Some countries observe daylight saving time and others do not, and the rules for when DST starts and ends vary by country. Our live clock uses the Intl.DateTimeFormat API with the detected timezone string, which handles all these edge cases correctly. Never try to calculate time zones manually; always use the platform's built-in timezone support.
The patterns in World Clock+ are used by thousands of production applications to personalize the user experience. E-commerce sites show prices in local currency. News sites show relevant local weather. Event platforms adjust times to the user's timezone. The API chain we built is a simplified version of what these applications do at scale.
Security teams can use World Clock+ as a demonstration tool for privacy awareness training. Showing employees how much information is derivable from their IP address, especially when they are browsing without a VPN, makes the abstract concept of "digital fingerprinting" tangible and personal.
For distributed teams, a variant of World Clock+ that shows multiple team members' local times, weather, and upcoming holidays helps with meeting scheduling and cultural awareness. Knowing that a colleague has a public holiday next Tuesday prevents accidentally scheduling a deadline on that day.
By allowing users to input an arbitrary IP or location instead of using their own, the dashboard becomes a travel planning tool. Enter a destination city and instantly see the current weather, upcoming holidays, currency exchange rates, and timezone difference. This is far more convenient than visiting four separate websites.
The is_eu field from IPapi is directly relevant to GDPR compliance. Applications can use this to determine whether to show cookie consent banners, which data processing rules apply, and what level of privacy protection is legally required. World Clock+ demonstrates how this detection works in practice.
The most impactful optimization is caching. IP geolocation data is stable (cache for 24 hours). Weather data changes slowly (cache for 30 minutes). Holidays are fixed for the year (cache until December 31). Exchange rates update once per business day (cache for 12 hours). By caching aggressively, subsequent visits load instantly with zero API calls.
If you have a server component, the server can read the client's IP from the request headers (X-Forwarded-For or the connection's remote address) and pass it to the client, eliminating the need for the IPify call entirely. This saves one round trip at the start of the chain.
If IPapi fails or returns inaccurate data (common with mobile connections and CDNs), you can ask the user for permission to use the browser's Geolocation API, which uses GPS and WiFi triangulation and is far more accurate. This is a graceful fallback that improves data quality at the cost of requiring user permission.
async function getLocationFallback() {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error('Geolocation not supported'));
return;
}
navigator.geolocation.getCurrentPosition(
position => resolve({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy,
source: 'browser'
}),
error => reject(new Error(`Geolocation failed: ${error.message}`)),
{ timeout: 10000, enableHighAccuracy: false }
);
});
}
Weather is the most time-sensitive data on the dashboard. Set up a 30-minute interval that silently refreshes the weather data in the background. Use the Page Visibility API to only poll when the tab is active, saving bandwidth and API calls when the user is not looking.
Let users add multiple locations and compare them side by side. Show the time difference, weather comparison, currency conversion, and holiday overlap. This is invaluable for distributed teams, frequent travelers, and anyone with friends and family in different countries.
Maintain a history of IP lookups (with user permission) and show how the user's IP has changed over time. This can reveal interesting patterns, like when the ISP assigns a new IP, whether the user's IP is stable or dynamic, and whether they have traveled recently (if the geolocation changes).
Automatically switch the dashboard between dark and light themes based on the local sunrise and sunset times (available from Open-Meteo). After sunset, the dashboard switches to dark mode. After sunrise, it switches to light mode. This time-aware design adapts to the user's environment without any manual toggle.
Add sunrise, sunset, moon phase, and daylight hours to the dashboard. Open-Meteo provides sunrise and sunset times, and moon phase can be calculated algorithmically. This transforms World Clock+ from a practical tool into an ambient information display that keeps users connected to the natural rhythms of their location.
Integrate an air quality API (such as Open-Meteo's air quality endpoint) to show the current AQI for the user's location. This adds a health dimension to the dashboard and is increasingly relevant in cities affected by wildfire smoke, industrial pollution, or high pollen counts.
World Clock+ is at its most powerful when it runs continuously as an ambient information display. Rather than something you visit once, it becomes a living dashboard that sits on a second monitor, a wall-mounted tablet, or a dedicated browser tab. To support this use case, we need to implement continuous data refreshing, graceful transitions when data changes, and power-efficient updates that do not drain laptop batteries or spin up CPU fans.
Different data types have different staleness thresholds. The clock needs to update every second. The weather can be refreshed every thirty minutes. Holidays only need to be checked once per day. Exchange rates update once per business day. Rather than polling everything on a single interval, we use an adaptive scheduler that refreshes each data type at its own cadence.
class AdaptiveScheduler {
constructor() {
this.tasks = new Map();
this.running = false;
}
register(name, fetchFn, renderFn, intervalMs, options = {}) {
this.tasks.set(name, {
name,
fetchFn,
renderFn,
intervalMs,
lastRun: 0,
lastData: null,
errorCount: 0,
maxErrors: options.maxErrors || 5,
backoffMultiplier: options.backoffMultiplier || 2,
onlyWhenVisible: options.onlyWhenVisible !== false
});
}
start() {
this.running = true;
this.tick();
}
stop() {
this.running = false;
}
async tick() {
if (!this.running) return;
const now = Date.now();
for (const [name, task] of this.tasks) {
// Skip if tab is hidden and task requires visibility
if (task.onlyWhenVisible && document.hidden) continue;
// Calculate effective interval (with backoff for errors)
const effectiveInterval = task.intervalMs *
Math.pow(task.backoffMultiplier, Math.min(task.errorCount, 5));
if (now - task.lastRun >= effectiveInterval) {
try {
const data = await task.fetchFn();
task.lastData = data;
task.errorCount = 0;
task.lastRun = now;
task.renderFn(data);
} catch (err) {
task.errorCount++;
console.warn(`${name} failed (attempt ${task.errorCount}):`, err.message);
if (task.errorCount >= task.maxErrors) {
console.error(`${name} disabled after ${task.maxErrors} failures`);
this.tasks.delete(name);
}
}
}
}
// Use requestAnimationFrame for efficient scheduling
requestAnimationFrame(() => {
setTimeout(() => this.tick(), 1000);
});
}
}
// Usage
const scheduler = new AdaptiveScheduler();
scheduler.register(
'weather',
() => fetchWeather(state.geo.latitude, state.geo.longitude, state.geo.timezone),
(data) => renderWeatherCard(data, state.geo.city),
30 * 60 * 1000 // 30 minutes
);
scheduler.register(
'intensity',
() => fetchCarbonIntensity(),
(data) => updateState({ carbonIntensity: data }),
15 * 60 * 1000 // 15 minutes
);
scheduler.register(
'currency',
() => fetchExchangeRates(state.geo.currency),
(data) => renderCurrencyCard(data, state.geo.currency_name),
12 * 60 * 60 * 1000 // 12 hours
);
scheduler.register(
'holidays',
() => fetchHolidays(state.geo.country_code),
(data) => renderHolidaysCard(data, state.geo.country_name),
24 * 60 * 60 * 1000 // 24 hours
);
scheduler.start();
When data refreshes, values should not jump abruptly. We use CSS transitions and a simple animation system to smoothly morph numbers from old values to new ones. This makes the dashboard feel alive and polished rather than flickery and mechanical.
function animateNumber(element, fromValue, toValue, duration = 800) {
const startTime = performance.now();
const diff = toValue - fromValue;
function step(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
// Ease-out cubic for natural deceleration
const eased = 1 - Math.pow(1 - progress, 3);
const currentValue = fromValue + diff * eased;
element.textContent = formatNumber(currentValue);
if (progress < 1) {
requestAnimationFrame(step);
}
}
requestAnimationFrame(step);
}
function formatNumber(value) {
if (Math.abs(value) >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
if (Math.abs(value) >= 1000) return `${(value / 1000).toFixed(1)}K`;
if (Number.isInteger(value)) return value.toLocaleString();
return value.toFixed(2);
}
function updateWeatherSmooth(oldData, newData) {
// Animate temperature change
if (oldData && newData && oldData.current.temperature !== newData.current.temperature) {
const tempEl = document.getElementById('currentTemp');
if (tempEl) {
animateNumber(tempEl, oldData.current.temperature, newData.current.temperature);
}
}
// Fade transition for weather description
const descEl = document.getElementById('weatherDesc');
if (descEl && oldData?.current.weatherDescription !== newData?.current.weatherDescription) {
descEl.style.opacity = '0';
setTimeout(() => {
descEl.textContent = `${newData.current.weatherEmoji} ${newData.current.weatherDescription}`;
descEl.style.opacity = '1';
}, 300);
}
}
The ambient display can include a "photo frame" mode that rotates through curated background images based on the current weather and time of day. Sunny afternoons show bright landscape photos. Rainy evenings show cozy indoor scenes. Night shows starry skies. Combined with the semi-transparent data overlay, this creates a beautiful ambient display.
function getAmbientBackground(weather, sunPosition) {
// Map weather + time to Unsplash search terms
let searchTerm = 'nature';
if (!sunPosition.isDay) {
searchTerm = weather?.current?.weatherCode >= 95
? 'night lightning storm'
: weather?.current?.weatherCode >= 61
? 'rain night city'
: 'starry night sky';
} else {
const code = weather?.current?.weatherCode || 0;
if (code === 0) searchTerm = 'sunny landscape';
else if (code <= 3) searchTerm = 'cloudy sky landscape';
else if (code <= 48) searchTerm = 'foggy morning';
else if (code <= 67) searchTerm = 'rain drops window';
else if (code <= 77) searchTerm = 'snow winter landscape';
else if (code >= 95) searchTerm = 'thunderstorm dramatic sky';
}
// Note: In production, you would use Unsplash API with an API key
// For demo, we use CSS gradient backgrounds instead
return getGradientForCondition(weather?.current?.weatherCode, sunPosition.isDay);
}
function getGradientForCondition(weatherCode, isDay) {
if (!isDay) {
return 'linear-gradient(135deg, #0a0a1a 0%, #1a1a3e 50%, #0d0d2b 100%)';
}
if (!weatherCode || weatherCode === 0) {
return 'linear-gradient(135deg, #1a3a5c 0%, #2a6090 50%, #1a4a6c 100%)';
}
if (weatherCode <= 3) {
return 'linear-gradient(135deg, #2a3a4a 0%, #4a5a6a 50%, #3a4a5a 100%)';
}
if (weatherCode <= 48) {
return 'linear-gradient(135deg, #3a3a4a 0%, #5a5a6a 50%, #4a4a5a 100%)';
}
if (weatherCode <= 67) {
return 'linear-gradient(135deg, #1a2a3a 0%, #2a3a4a 50%, #1a2a3a 100%)';
}
if (weatherCode <= 77) {
return 'linear-gradient(135deg, #3a4a5a 0%, #6a7a8a 50%, #5a6a7a 100%)';
}
return 'linear-gradient(135deg, #1a1a2a 0%, #3a3a5a 50%, #2a2a4a 100%)';
}
function applyAmbientMode(dashboard) {
const sunPos = calculateSunPosition(
dashboard.weather?.daily[0]?.sunrise,
dashboard.weather?.daily[0]?.sunset
);
const gradient = getAmbientBackground(dashboard.weather, sunPos);
document.body.style.background = gradient;
document.body.style.transition = 'background 3s ease';
// Reduce opacity of less important elements
document.querySelectorAll('.secondary-info').forEach(el => {
el.style.opacity = '0.6';
});
// Increase size of clock and temperature
const clock = document.getElementById('liveClock');
if (clock) {
clock.style.fontSize = '4rem';
clock.style.fontWeight = '200';
}
}
Users should be able to share their dashboard snapshot as an image or a link. We implement two export methods: a screenshot-to-clipboard function using the html2canvas approach, and a shareable URL that encodes the location parameters so others can see the same dashboard for a different city.
function generateShareableUrl(geoData) {
const params = new URLSearchParams({
lat: geoData.latitude.toFixed(4),
lng: geoData.longitude.toFixed(4),
city: geoData.city,
country: geoData.country_code,
tz: geoData.timezone,
currency: geoData.currency
});
return `${window.location.origin}${window.location.pathname}?${params}`;
}
function loadFromShareableUrl() {
const params = new URLSearchParams(window.location.search);
if (params.has('lat') && params.has('lng')) {
return {
latitude: parseFloat(params.get('lat')),
longitude: parseFloat(params.get('lng')),
city: params.get('city') || 'Unknown',
country_code: params.get('country') || 'US',
timezone: params.get('tz') || 'UTC',
currency: params.get('currency') || 'USD',
isSharedView: true
};
}
return null;
}
// Check for shared URL on page load
const sharedLocation = loadFromShareableUrl();
if (sharedLocation) {
// Skip IP detection, use shared parameters directly
buildDashboardFromParams(sharedLocation);
} else {
// Normal flow: detect IP and build dashboard
buildDashboard();
}
async function buildDashboardFromParams(params) {
updateState({ loading: true, phase: 'Loading shared location...' });
const [weatherData, holidayData, currencyData] = await Promise.allSettled([
fetchWeather(params.latitude, params.longitude, params.timezone),
fetchHolidays(params.country_code),
fetchExchangeRates(params.currency)
]);
const dashboard = {
ip: { ip: '(shared view)' },
geo: {
...params,
country_name: params.country_code,
isp: '(shared view)',
is_eu: false
},
weather: weatherData.status === 'fulfilled' ? weatherData.value : null,
holidays: holidayData.status === 'fulfilled' ? holidayData.value : null,
currency: currencyData.status === 'fulfilled' ? currencyData.value : null,
isSharedView: true,
timestamp: new Date().toISOString()
};
updateState({ loading: false, dashboard });
}
An ambient dashboard that refreshes data every few minutes is only useful when you have network connectivity. But what happens when the Wi-Fi drops, the subway goes underground, or you are working from a coffee shop with unreliable connectivity? World Clock+ should degrade gracefully, showing cached data with clear freshness indicators rather than presenting a broken blank screen. To achieve this, we need to add service worker caching, an offline indicator, and a data freshness display layer.
A service worker intercepts network requests and can serve cached responses when the network is unavailable. For World Clock+, our caching strategy is straightforward: cache the application shell (HTML, CSS, fonts) aggressively, and cache API responses with stale-while-revalidate semantics.
// service-worker.js for World Clock+
const CACHE_NAME = 'worldclock-v1';
const SHELL_ASSETS = [
'/',
'/index.html',
'/styles.css'
];
// API response cache with different TTLs
const API_CACHE = 'worldclock-api-v1';
const API_TTL = {
'ipapi.co': 24 * 60 * 60 * 1000, // 24 hours
'api.open-meteo.com': 30 * 60 * 1000, // 30 minutes
'date.nager.at': 24 * 60 * 60 * 1000, // 24 hours
'api.frankfurter.app': 60 * 60 * 1000 // 1 hour
};
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(SHELL_ASSETS))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(
keys.filter(k => k !== CACHE_NAME && k !== API_CACHE)
.map(k => caches.delete(k))
)
).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
// Determine if this is an API request
const apiDomain = Object.keys(API_TTL)
.find(domain => url.hostname.includes(domain));
if (apiDomain) {
// Stale-while-revalidate for API calls
event.respondWith(
caches.open(API_CACHE).then(async cache => {
const cached = await cache.match(event.request);
const fetchPromise = fetch(event.request).then(response => {
if (response.ok) {
const clone = response.clone();
// Store with timestamp header
const headers = new Headers(clone.headers);
headers.set('X-Cache-Time', Date.now().toString());
const body = clone.body;
cache.put(event.request, new Response(body, {
status: clone.status,
statusText: clone.statusText,
headers
}));
}
return response;
}).catch(() => cached);
if (cached) {
const cacheTime = parseInt(
cached.headers.get('X-Cache-Time') || '0'
);
const ttl = API_TTL[apiDomain];
const isStale = Date.now() - cacheTime > ttl;
if (!isStale) return cached;
// Return stale data but trigger revalidation
fetchPromise; // fire and forget
return cached;
}
return fetchPromise;
})
);
} else {
// Cache-first for shell assets
event.respondWith(
caches.match(event.request)
.then(cached => cached || fetch(event.request))
);
}
});
The key insight here is that different API domains get different time-to-live values. IP geolocation data rarely changes, so we cache it for twenty-four hours. Weather data updates frequently, so we only cache it for thirty minutes. Exchange rates change once per business day, so a one-hour cache is reasonable. Holiday data is static for the entire year, making it an ideal candidate for long-term caching. This tiered TTL approach ensures that the dashboard always shows reasonably fresh data while minimizing unnecessary network requests.
When the dashboard is running on cached data, users should know. We build a connectivity indicator that shows the current network status and the age of each data source. This transparency is especially important given the project's privacy-awareness theme: if we are teaching users to be aware of what information systems know about them, we should also be transparent about the freshness of the information we display.
class ConnectivityMonitor {
constructor(dashboard) {
this.dashboard = dashboard;
this.online = navigator.onLine;
this.dataFreshness = new Map();
window.addEventListener('online', () => this.handleOnline());
window.addEventListener('offline', () => this.handleOffline());
}
handleOnline() {
this.online = true;
this.updateIndicator();
// Trigger immediate refresh of stale data
this.dashboard.refreshStaleData();
}
handleOffline() {
this.online = false;
this.updateIndicator();
}
recordFetch(source, timestamp = Date.now()) {
this.dataFreshness.set(source, timestamp);
this.updateIndicator();
}
getAgeString(source) {
const timestamp = this.dataFreshness.get(source);
if (!timestamp) return 'No data';
const ageMs = Date.now() - timestamp;
if (ageMs < 60000) return 'Just now';
if (ageMs < 3600000) return Math.floor(ageMs / 60000) + 'm ago';
if (ageMs < 86400000) return Math.floor(ageMs / 3600000) + 'h ago';
return Math.floor(ageMs / 86400000) + 'd ago';
}
updateIndicator() {
const indicator = document.getElementById('connectivity-indicator');
if (!indicator) return;
const statusDot = this.online ? '🟢' : '🔴';
const statusText = this.online ? 'Live' : 'Offline (cached data)';
const sources = ['geolocation', 'weather', 'holidays', 'currency'];
const freshnessHTML = sources.map(s =>
'<span class="freshness-badge">'
+ s + ': ' + this.getAgeString(s)
+ '</span>'
).join(' ');
indicator.innerHTML = statusDot + ' ' + statusText
+ '<div class="freshness-row">' + freshnessHTML + '</div>';
}
}
This connectivity monitor integrates directly with the adaptive scheduler we built in the previous section. When the browser fires the online event, the monitor tells the scheduler to immediately refresh any data that has gone stale during the offline period. When the browser goes offline, the scheduler stops making network requests (they would fail anyway) and the indicator switches to show the age of each cached data source. The result is a dashboard that never breaks, never shows a loading spinner that will never resolve, and always tells the user exactly what they are looking at.
With the service worker in place, we are most of the way to a full Progressive Web App. Adding a web manifest turns World Clock+ into something that can be installed on the home screen of a phone, pinned to the taskbar on a laptop, or launched as a standalone window without browser chrome.
// manifest.json
{
"name": "World Clock+ — IP-Aware Dashboard",
"short_name": "World Clock+",
"description": "A location-aware dashboard powered by your IP",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0f",
"theme_color": "#6c5ce7",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
Register the service worker and link the manifest in the HTML head section. From that point forward, browsers that support the Web App Manifest specification (which includes all modern browsers on both desktop and mobile) will offer an "Install" prompt. Once installed, the dashboard opens as a standalone application with its own entry in the OS task switcher. Combined with the adaptive scheduler and offline support, this transforms World Clock+ from a web page into a genuine ambient information appliance.
The PWA approach is particularly fitting for World Clock+ because the dashboard is designed to run continuously. Desktop shortcuts, home screen icons, and standalone windows all reduce the friction of keeping the dashboard visible. A user who has to open Chrome, navigate to a URL, and find the right tab will eventually stop using the dashboard. A user who clicks an icon on their dock and sees their location-aware dashboard appear instantly will keep coming back. Reducing activation energy is the key to ambient utility.
One subtle challenge for an always-on dashboard is detecting when the user's time zone changes. This happens more often than you might think: laptop users crossing time zones on trains or planes, VPN users switching server locations, and daylight saving time transitions twice per year in most regions. The Intl API in JavaScript reports the current system time zone, but it does not fire an event when it changes.
class TimeZoneWatcher {
constructor(onTimeZoneChange) {
this.currentZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
this.callback = onTimeZoneChange;
this.pollInterval = setInterval(() => this.check(), 30000);
}
check() {
const newZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (newZone !== this.currentZone) {
const oldZone = this.currentZone;
this.currentZone = newZone;
this.callback(oldZone, newZone);
}
}
destroy() {
clearInterval(this.pollInterval);
}
}
// Usage
const watcher = new TimeZoneWatcher((oldZone, newZone) => {
console.log('Time zone changed from', oldZone, 'to', newZone);
// Invalidate all cached data — the user's location has changed
localStorage.removeItem('worldclock_ip_cache');
localStorage.removeItem('worldclock_geo_cache');
localStorage.removeItem('worldclock_weather_cache');
// Trigger a full dashboard refresh
initDashboard();
});
We poll the time zone every thirty seconds, which is frequent enough to catch any transition within a reasonable time while being cheap enough to run indefinitely. When a change is detected, we invalidate the entire cache (since all location-dependent data is now wrong) and trigger a full dashboard refresh. This ensures that a user who crosses a time zone boundary sees their dashboard update automatically, showing the new local time, weather, holidays, and currency for their updated location. The thirty-second polling interval is a practical choice because time zone changes are infrequent events and catching them within half a minute is more than adequate for an ambient display. More frequent polling would waste CPU cycles for negligible benefit.
This time zone watcher also handles the daylight saving time edge case. When clocks spring forward or fall back, the IANA time zone name typically stays the same (for example, "America/New_York" covers both EST and EDT), but the UTC offset changes. Our watcher does not detect this directly, but the dashboard's clock component, which updates every second, will naturally reflect the offset change because it uses the browser's built-in Intl.DateTimeFormat, which is always aware of the current DST status. The watcher is specifically for cases where the IANA zone name itself changes, indicating a genuine location change rather than a seasonal time adjustment.
World Clock+ is as much a privacy education tool as it is a practical dashboard. The fact that five API calls, starting from nothing but an IP address, can produce a comprehensive profile of a user's location, environment, and economic context is both impressive and concerning.
Every website you visit knows your IP address. Most websites log it. Many pass it to third-party analytics services, advertising networks, and data brokers. Each of these entities can perform the same geolocation and enrichment that our dashboard demonstrates. The difference is that they do it silently, in the background, without showing you the results. World Clock+ makes the invisible visible.
This is not an argument against using the internet or even against IP geolocation. Many legitimate uses depend on it: content delivery optimization, fraud prevention, legal compliance, and user experience personalization. The argument is for transparency. Users should know what information they are passively revealing, and they should have meaningful choices about how that information is used.
VPNs are one mitigation. By routing traffic through a server in a different location, VPNs make IP-based geolocation inaccurate. But VPNs are not a panacea. Browser fingerprinting, cookies, logged-in accounts, and other tracking mechanisms can identify and locate users even behind a VPN. True privacy requires a layered approach: VPN plus privacy-focused browser plus careful account management plus awareness of what information you are revealing at each step.
By building World Clock+, you have gained a concrete understanding of what IP-based profiling looks like. This knowledge is the first step toward making informed decisions about your own privacy and toward building applications that respect your users' privacy. Show users what you know about them. Give them control over what you collect. And design your systems to minimize data collection by default.
World Clock+ demonstrates the power and the peril of API chaining. From a single IP address, we built a comprehensive dashboard that shows location, time, weather, holidays, and exchange rates, all without the user lifting a finger. The technical patterns, such as sequential-then-parallel API orchestration, progressive rendering, aggressive caching, and graceful degradation, are directly applicable to any application that aggregates data from multiple sources.
The five APIs we used, IPify, IPapi, Open-Meteo, Nager.Date, and Frankfurter, are all free, well-documented, and reliable. They represent the best of the public API ecosystem: focused services that do one thing well and can be composed into something greater. This composability is the fundamental promise of API-driven development, and World Clock+ is a vivid demonstration of how that promise plays out in practice.
But the project is also a meditation on privacy. The dashboard's ability to profile a user from their IP address alone is a microcosm of the broader surveillance infrastructure that underpins the modern internet. By making this profiling visible and explicit, World Clock+ gives users the knowledge they need to make informed choices about their digital lives. That combination of practical utility and educational value is what makes it more than just a technical exercise. It is a tool for understanding the world you live in, both the geographic world outside your window and the digital world inside your browser.
Build the dashboard. See what your IP reveals. Then decide what you want to do about it.