/* ── Live clock + weather ── */
/**
* Returns the ISO 8601 week number for a given date.
* @param {Date} d - The date to evaluate.
* @returns {number} ISO week number (1–53).
*/
function getISOWeek(d) {
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
date.setUTCDate(date.getUTCDate() + 4 - (date.getUTCDay() || 7));
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
return Math.ceil(((date - yearStart) / 86400000 + 1) / 7);
}
/**
* Returns the total number of ISO weeks in a given year (52 or 53).
* Uses the fact that Dec 28 is always in the last ISO week.
* @param {number} year - The full 4-digit year.
* @returns {number} 52 or 53.
*/
function totalISOWeeks(year) {
return getISOWeek(new Date(year, 11, 28)); // Dec 28 is always in last ISO week
}
let _lastTickDate = dk(new Date());
tickClock();
setInterval(tickClock, 10000);
/**
* Updates the live clock display (date, time, ISO week), the time-block
* "now" line, and block notifications. Also detects midnight rollover:
* carries unfinished plan tasks to the new day and re-renders the UI.
*/
function tickClock() {
const now = new Date();
const weekday = now.toLocaleDateString('en', { weekday: 'long' });
const month = now.toLocaleDateString('en', { month: 'long' });
const day = now.getDate();
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
document.getElementById('liveDate').textContent = `${weekday}, ${month} ${day}`;
document.getElementById('liveTime').textContent = `${hh}:${mm}`;
const w = getISOWeek(now);
const total = totalISOWeeks(now.getFullYear());
document.getElementById('liveWeek').textContent = `Week ${w}/${total}`;
positionNowLine();
checkBlockNotifications();
updateHeaderTracking();
// Detect midnight rollover — carry tasks and re-render
const todayKey = dk(now);
if (todayKey !== _lastTickDate) {
_lastTickDate = todayKey;
autoCarryTasks();
patchCarriedTasks();
viewDate = new Date();
renderSodBtn();
renderEodBtn();
checkPomoWeeklyClear();
render();
}
}
/**
* No-op: the header tracked-total and pace bar were removed in the top-zone
* redesign (ITEM 1). Kept so tickClock() and tickTimer() call sites remain
* unchanged.
*/
function updateHeaderTracking() {
// Tracking display now lives in the Hero Card — no header elements to update.
}
// WEATHER_LAT, WEATHER_LON, WEATHER_NAME, JIRA_BASE are defined in 00-config.js
/**
* Returns HTML for a task text string, converting any leading Jira ticket key
* (e.g. `AITO-1234`) into a clickable link. The remainder of the text is
* HTML-escaped and appended.
* @param {string} text - Raw task text, possibly starting with a Jira key.
* @returns {string} HTML string.
*/
function jiraTicketHtml(text) {
// Anchored on ^…$; [\s:_-]+ and .* overlap on whitespace but cannot catastrophically backtrack.
// eslint-disable-next-line security/detect-unsafe-regex
const m = text.match(/^([A-Z]+-\d+)([\s:_-]+(.*))?$/);
if (!m) return escHtml(text);
const key = m[1];
const rest = (m[3] || '').trim();
const link = `<a class="jira-key-link" href="${JIRA_BASE}/${key}" target="_blank" rel="noopener" onclick="event.stopPropagation()">${escHtml(key)}</a>`;
return rest ? `${link}<span class="jira-key-sep">:</span> ${escHtml(rest)}` : link;
}
/**
* Maps a WMO weather interpretation code to a representative emoji.
* @param {number} code - WMO weather code (0 = clear sky, 95+ = thunderstorm).
* @returns {string} A single weather emoji character.
*/
function weatherEmoji(code) {
if (code === 0) return '☀️';
if (code <= 1) return '🌤️';
if (code <= 2) return '⛅';
if (code <= 3) return '☁️';
if (code <= 48) return '🌫️';
if (code <= 55) return '🌦️';
if (code <= 65) return '🌧️';
if (code <= 75) return '❄️';
if (code <= 82) return '🌧️';
if (code <= 86) return '🌨️';
return '⛈️';
}
// Finnish nameday (nimipäivä) — hardcoded official list (University of Helsinki 2020)
/**
* Calculates moon phase, illumination percentage, and zodiac sign for a date
* using a simplified version of the Meeus algorithm.
* @param {Date} date - The date to evaluate.
* @returns {{emoji: string, phase: string, illum: number, sign: Array.<string>}}
* `emoji` = phase emoji, `phase` = phase name, `illum` = illumination (%),
* `sign` = [symbol, name] of the current zodiac sign.
*/
function getMoonData(date) {
const JD = date.getTime() / 86400000 + 2440587.5;
const D = JD - 2451545.0; // days from J2000
const toR = (x) => (x * Math.PI) / 180;
// Moon's ecliptic longitude (simplified Meeus)
const L = (((218.316 + 13.176396 * D) % 360) + 360) % 360;
const M = toR((((134.963 + 13.064993 * D) % 360) + 360) % 360);
const E = toR((((297.85 + 12.190749 * D) % 360) + 360) % 360);
const lon =
(((L +
6.289 * Math.sin(M) -
1.274 * Math.sin(2 * E - M) +
0.658 * Math.sin(2 * E) -
0.214 * Math.sin(2 * M) -
0.11 * Math.sin(E)) %
360) +
360) %
360;
// Zodiac sign
const SIGNS = [
['♈', 'Aries'],
['♉', 'Taurus'],
['♊', 'Gemini'],
['♋', 'Cancer'],
['♌', 'Leo'],
['♍', 'Virgo'],
['♎', 'Libra'],
['♏', 'Scorpio'],
['♐', 'Sagittarius'],
['♑', 'Capricorn'],
['♒', 'Aquarius'],
['♓', 'Pisces'],
];
const sign = SIGNS[Math.floor(lon / 30)];
// Synodic age → phase emoji + illumination
const synodicPeriod = 29.530588853;
const age = (((JD - 2451550.26) % synodicPeriod) + synodicPeriod) % synodicPeriod;
const illum = Math.round(((1 - Math.cos((2 * Math.PI * age) / synodicPeriod)) / 2) * 100);
const p = age / synodicPeriod;
const PHASES = ['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'];
const PHASE_NAMES = [
'New Moon',
'Waxing Crescent',
'First Quarter',
'Waxing Gibbous',
'Full Moon',
'Waning Gibbous',
'Last Quarter',
'Waning Crescent',
];
const pIdx =
p < 0.025 || p >= 0.975
? 0
: p < 0.25
? 1
: p < 0.275
? 2
: p < 0.5
? 3
: p < 0.525
? 4
: p < 0.75
? 5
: p < 0.775
? 6
: 7;
return { emoji: PHASES[pIdx], phase: PHASE_NAMES[pIdx], illum, sign };
}
/**
* Renders today's moon phase, illumination, and zodiac sign into `#liveMoon`.
* No-ops if the element is absent.
*/
function renderMoon() {
const el = document.getElementById('liveMoon');
if (!el) return;
const { emoji, phase, illum, sign } = getMoonData(new Date());
el.textContent = `${emoji} ${illum}% ${phase} — ${sign[1]} ${sign[0]}`;
}
// Finnish flag days
const FLAG_DAYS_FIXED = {
'01-01': "New Year's Day",
'02-05': 'J.L. Runeberg Day',
'02-28': 'Kalevala Day',
'03-19': 'Minna Canth Day',
'04-09': 'Mikael Agricola Day',
'04-27': "National Veterans' Day",
'05-01': 'May Day',
'05-09': 'Europe Day',
'05-12': 'J.V. Snellman Day',
'06-04': 'Flag Day of the Finnish Defence Forces',
'07-06': 'Eino Leino Day',
'10-01': 'Miina Sillanpää Day',
'10-10': 'Aleksis Kivi Day',
'10-24': 'United Nations Day',
'11-06': 'Swedish Heritage Day',
'11-20': "Children's Rights Day",
'12-06': 'Finnish Independence Day',
'12-08': 'Jean Sibelius Day',
};
/**
* Returns an object mapping "MM-DD" strings to Finnish flag-day names for
* the given year. Combines fixed dates with computed moveable feasts.
* @param {number} year - The full 4-digit year.
* @returns {Object<string, string>} Map of "MM-DD" → event name.
*/
function getFlagDays(year) {
const days = { ...FLAG_DAYS_FIXED };
// Mother's Day — 2nd Sunday of May
days[fmtMD(nthWeekday(year, 5, 0, 2))] = "Mother's Day";
// Kaatuneitten muistopäivä — 3rd Sunday of May
days[fmtMD(nthWeekday(year, 5, 0, 3))] = 'Kaatuneitten muistopäivä';
// Midsummer / Finnish Flag Day — Saturday between Jun 20–26
days[fmtMD(nearestWeekday(new Date(year, 5, 20), 6))] = 'Midsummer / Finnish Flag Day';
// Father's Day — 2nd Sunday of November
days[fmtMD(nthWeekday(year, 11, 0, 2))] = "Father's Day";
// Finnish Nature Day — last Saturday of August
days[fmtMD(lastWeekday(year, 8, 6))] = 'Finnish Nature Day';
return days;
}
/**
* Returns the date of the nth occurrence of a weekday in the given month.
* @param {number} year - Full 4-digit year.
* @param {number} month - Month (1 = January … 12 = December).
* @param {number} weekday - Day of week (0 = Sunday … 6 = Saturday).
* @param {number} n - 1-based occurrence index (1 = first, 2 = second, …).
* @returns {Date}
*/
function nthWeekday(year, month, weekday, n) {
// weekday: 0=Sun..6=Sat, month: 1-12, n: 1-based
const d = new Date(year, month - 1, 1);
let count = 0;
while (true) {
if (d.getDay() === weekday) {
count++;
if (count === n) return new Date(d);
}
d.setDate(d.getDate() + 1);
}
}
/**
* Returns the date of the last occurrence of a weekday in the given month.
* @param {number} year - Full 4-digit year.
* @param {number} month - Month (1 = January … 12 = December).
* @param {number} weekday - Day of week (0 = Sunday … 6 = Saturday).
* @returns {Date}
*/
function lastWeekday(year, month, weekday) {
// Last occurrence of weekday (0=Sun..6=Sat) in month (1-12)
const d = new Date(year, month, 0); // last day of month
while (d.getDay() !== weekday) d.setDate(d.getDate() - 1);
return d;
}
/**
* Returns the first date on or after `from` that falls on the given weekday.
* @param {Date} from - Start date (inclusive).
* @param {number} weekday - Day of week (0 = Sunday … 6 = Saturday).
* @returns {Date}
*/
function nearestWeekday(from, weekday) {
const d = new Date(from);
while (d.getDay() !== weekday) d.setDate(d.getDate() + 1);
return d;
}
/**
* Formats a Date as a zero-padded "MM-DD" string used as flag-day map keys.
* @param {Date} d - The date to format.
* @returns {string} e.g. "06-04" for the 4th of June.
*/
function fmtMD(d) {
return String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
}
/**
* No longer actively used — `fetchCalendarEvents` handles flag-day display.
* Kept as a no-op stub so call sites don't need to be removed.
*/
function renderFlagDay() {
// No longer needed — fetchCalendarEvents handles everything
// Kept as stub for compatibility
}
// Token is injected server-side by start-server.ps1 — never put it in client JS.
const NAMEDAY_API_BASE = '/api'; // proxied through local server to avoid CORS
/**
* Fetches today's Finnish and Swedish name days from the Nimipäivärajapinta API
* and renders them in `#liveNameday`. Falls back to an "API unavailable" note
* on error.
*/
function fetchNameday() {
const el = document.getElementById('liveNameday');
if (!el) return;
// Fetch both Finnish and Swedish names, show with explicit language labels
fetch(`${NAMEDAY_API_BASE}/namedays/today`)
.then((r) => {
if (!r.ok) throw new Error(`API error: ${r.status}`);
return r.json();
})
.then((data) => {
if (data.success && data.name_days_by_type) {
const finnishNames = (data.name_days_by_type.suomi || []).map((n) => n.name);
const swedishNames = (data.name_days_by_type.ruotsi || []).map((n) => n.name);
let display = "🎂 Today's name day: ";
const SE_FLAG =
'<svg width="16" height="11" viewBox="0 0 16 11" style="vertical-align:middle;margin:0 2px;border-radius:1px;display:inline-block"><rect width="16" height="11" fill="#006AA7"/><rect x="4" width="2.5" height="11" fill="#FECC02"/><rect y="4" width="16" height="2.5" fill="#FECC02"/></svg>';
if (finnishNames.length > 0 && swedishNames.length > 0) {
display += `${finnishNames.slice(0, 2).map(escHtml).join(', ')}`;
display += ` <span style="font-size:85%;color:var(--text3)">/ ${SE_FLAG}${swedishNames.slice(0, 2).map(escHtml).join(', ')}</span>`;
} else if (finnishNames.length > 0) {
display += `${finnishNames.slice(0, 2).map(escHtml).join(', ')}`;
} else if (swedishNames.length > 0) {
display += `<span style="font-size:85%;color:var(--text3)">${SE_FLAG}${swedishNames.slice(0, 2).map(escHtml).join(', ')}</span>`;
} else {
throw new Error('No name day data');
}
el.innerHTML = display;
} else {
throw new Error('Invalid API response');
}
})
.catch((err) => {
console.warn('[wl] Nameday API failed:', err.message);
el.innerHTML = `<span style="color:#999999;font-size:85%">🎂 (API unavailable)</span>`;
});
}
/**
* Fetches today's Finnish flag days, public holidays, and notable days from the
* Nimipäivärajapinta Typesense API, displaying the first match in `#liveFlagDay`.
* If today has no event, shows the next upcoming one. Falls back to the hardcoded
* {@link FLAG_DAYS_FIXED} list if the API is unreachable.
*/
function fetchCalendarEvents() {
const el = document.getElementById('liveFlagDay');
if (!el) return;
const now = new Date();
const day = now.getDate();
const mon = now.getMonth() + 1;
const year = now.getFullYear();
const FLAG_SVG =
'<svg width="20" height="13" viewBox="0 0 18 11" style="vertical-align:middle;margin-right:5px;border-radius:1px;display:inline-block"><rect width="18" height="11" fill="#fff"/><rect y="4" width="18" height="3" fill="#003580"/><rect x="5" width="3" height="11" fill="#003580"/></svg>';
// Hardcoded fallback — always shows something even if API is unreachable
function showFallback() {
const todayMD = fmtMD(now);
const thisYear = getFlagDays(year);
const nextYear = getFlagDays(year + 1);
if (thisYear[todayMD]) {
el.innerHTML = `${FLAG_SVG}<span style="font-weight:500">${escHtml(thisYear[todayMD])}</span>`;
return;
}
const todayFull = `${year}-${todayMD}`;
const all = [
...Object.entries(thisYear).map(([k, v]) => ({ key: `${year}-${k}`, type: 'flag', name: v })),
...Object.entries(nextYear).map(([k, v]) => ({
key: `${year + 1}-${k}`,
type: 'flag',
name: v,
})),
].sort((a, b) => a.key.localeCompare(b.key));
const next = all.find((d) => d.key > todayFull);
if (next) {
const dateStr = new Date(next.key + 'T12:00:00').toLocaleDateString('en', {
month: 'long',
day: 'numeric',
});
el.innerHTML = `${FLAG_SVG}Upcoming: ${dateStr} — <span style="font-weight:500">${escHtml(next.name)}</span>`;
}
}
const apiPost = (collection, body) =>
fetch(`${NAMEDAY_API_BASE}/typesense/collections/${collection}/documents/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
.then((r) => (r.ok ? r.json() : Promise.reject()))
.catch(() => ({ hits: [] }));
const todayStr = `${year}-${String(mon).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
// Check today first, then upcoming if nothing found
Promise.all([
apiPost('flag_days', { q: '*', filter_by: `day:=${day} && month:=${mon}`, per_page: 1 }),
apiPost('holidays', {
q: '*',
filter_by: `day:=${day} && month:=${mon} && in_short_list:=true`,
per_page: 1,
}),
apiPost('notable_days', { q: '*', filter_by: `day:=${day} && month:=${mon}`, per_page: 1 }),
])
.then(([flagToday, holidayToday, notableToday]) => {
// Show today's event if found
if (flagToday.hits && flagToday.hits.length > 0) {
el.innerHTML = `${FLAG_SVG}<span style="font-weight:500">${escHtml(flagToday.hits[0].document.name_fi)}</span>`;
return;
}
if (holidayToday.hits && holidayToday.hits.length > 0) {
el.innerHTML = `📅 <span style="font-weight:500">${escHtml(holidayToday.hits[0].document.name_fi)}</span>`;
return;
}
if (notableToday.hits && notableToday.hits.length > 0) {
el.innerHTML = `📅 <span style="font-weight:500">${escHtml(notableToday.hits[0].document.name_fi)}</span>`;
return;
}
// Nothing today — find next upcoming event
return Promise.all([
apiPost('flag_days', {
q: '*',
filter_by: `date:>${todayStr}`,
sort_by: 'date:asc',
per_page: 1,
}),
apiPost('holidays', {
q: '*',
filter_by: `date:>${todayStr} && in_short_list:=true`,
sort_by: 'date:asc',
per_page: 1,
}),
apiPost('notable_days', {
q: '*',
filter_by: `date:>${todayStr}`,
sort_by: 'date:asc',
per_page: 1,
}),
]).then(([flagNext, holidayNext, notableNext]) => {
const events = [];
if (flagNext.hits && flagNext.hits.length > 0)
events.push({
date: flagNext.hits[0].document.date,
type: 'flag',
name: flagNext.hits[0].document.name_fi,
});
if (holidayNext.hits && holidayNext.hits.length > 0)
events.push({
date: holidayNext.hits[0].document.date,
type: 'calendar',
name: holidayNext.hits[0].document.name_fi,
});
if (notableNext.hits && notableNext.hits.length > 0)
events.push({
date: notableNext.hits[0].document.date,
type: 'calendar',
name: notableNext.hits[0].document.name_fi,
});
if (events.length > 0) {
const next = events.sort((a, b) => a.date.localeCompare(b.date))[0];
const dateStr = new Date(next.date + 'T12:00:00').toLocaleDateString('en', {
month: 'long',
day: 'numeric',
});
const icon = next.type === 'flag' ? FLAG_SVG : '📅 ';
el.innerHTML = `${icon}Upcoming: ${dateStr} — <span style="font-weight:500">${escHtml(next.name)}</span>`;
} else {
showFallback(); // API returned nothing — use hardcoded list
}
});
})
.catch(() => showFallback());
}
/**
* Fetches current weather, hourly precipitation probability, and sunrise/sunset
* data from the Open-Meteo API for the configured location ({@link WEATHER_LAT},
* {@link WEATHER_LON}) and populates `#liveWeather`, `#liveRain`, and
* `#liveSunrise`. No-ops gracefully on `file:` protocol or network error.
*/
function fetchWeather() {
if (location.protocol === 'file:') {
document.getElementById('liveWeather').textContent =
`${WEATHER_NAME} — open via localhost for weather`;
return;
}
fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${WEATHER_LAT}&longitude=${WEATHER_LON}¤t=temperature_2m,weather_code&hourly=precipitation_probability&daily=sunrise,sunset,daylight_duration&timezone=Europe%2FHelsinki&past_days=1&forecast_days=2`
)
.then((r) => r.json())
.then((d) => {
if (!validWeatherResponse(d)) {
wlLog.warn('fetchWeather: unexpected response shape', d);
throw new Error('fetchWeather: invalid response shape');
}
const temp = Math.round(d.current.temperature_2m);
const emoji = weatherEmoji(d.current.weather_code);
document.getElementById('liveWeather').textContent = `${WEATHER_NAME}, ${temp}°C ${emoji}`;
// Peak rain probability in next 8 hours
const times = d.hourly.time;
const probs = d.hourly.precipitation_probability;
// Find current hour in local time (API times are local)
const _now = new Date();
const _pad = (n) => String(n).padStart(2, '0');
const nowLocalStr = `${_now.getFullYear()}-${_pad(_now.getMonth() + 1)}-${_pad(_now.getDate())}T${_pad(_now.getHours())}`;
const nowIdx = times.findIndex((t) => t.slice(0, 13) === nowLocalStr);
if (nowIdx === -1) return;
const windowStart = nowIdx + 1; // start from next hour — current hour may already be past
const window = probs.slice(windowStart, windowStart + 9);
const peak = Math.max(...window);
const peakOff = window.indexOf(peak);
const peakTime = new Date(times[windowStart + peakOff]);
const hh = String(peakTime.getHours()).padStart(2, '0');
const mm = String(peakTime.getMinutes()).padStart(2, '0');
document.getElementById('liveRain').textContent =
peak > 0 ? `${peak}% chance of rain at ${hh}:${mm}` : 'No rain expected';
// Sunrise / sunset / day length
if (d.daily && d.daily.time && d.daily.sunrise) {
const todayStr = dk(new Date()); // "YYYY-MM-DD"
const todayIdx = d.daily.time.indexOf(todayStr);
const yesterdayIdx = todayIdx > 0 ? todayIdx - 1 : -1;
if (todayIdx !== -1) {
const parse = (str) => {
const t = new Date(str);
return (
String(t.getHours()).padStart(2, '0') + ':' + String(t.getMinutes()).padStart(2, '0')
);
};
const rise = parse(d.daily.sunrise[todayIdx]);
const set_ = parse(d.daily.sunset[todayIdx]);
const durSec = d.daily.daylight_duration[todayIdx];
const h = Math.floor(durSec / 3600);
const m = Math.floor((durSec % 3600) / 60);
let diffHtml = '';
if (yesterdayIdx !== -1) {
const diffMin = Math.round((durSec - d.daily.daylight_duration[yesterdayIdx]) / 60);
if (diffMin > 0)
diffHtml = ` <strong style="color:var(--sig-event)">+${diffMin} min</strong>`;
else if (diffMin < 0)
diffHtml = ` <strong style="color:var(--sig-overtime)">${diffMin} min</strong>`;
}
document.getElementById('liveSunrise').innerHTML =
`🌅 ${rise} | 🌇 ${set_} | ☀️ ${h}h ${m}min${diffHtml}`;
}
}
})
.catch(() => {
document.getElementById('liveWeather').textContent = WEATHER_NAME;
});
}
// Load location from server config before the first weather fetch.
// Falls back to the defaults in 00-config.js when the server is not running.
fetch('/api/config')
.then((r) => (r.ok ? r.json() : null))
.then((cfg) => {
if (!cfg) return;
if (typeof cfg.weatherLat === 'number') WEATHER_LAT = cfg.weatherLat;
if (typeof cfg.weatherLon === 'number') WEATHER_LON = cfg.weatherLon;
if (cfg.weatherName) WEATHER_NAME = cfg.weatherName;
// Mark that the API server responded — read by wlLog.config() in 07-lifecycle.js
// to record which environment the app is running in.
localStorage.setItem('wl_api_ok', '1');
})
.catch(() => {
localStorage.removeItem('wl_api_ok');
})
.finally(() => fetchWeather());
fetchNameday();
fetchCalendarEvents();
renderMoon();
renderFlagDay();
renderDistractionCount();
renderSodBtn();
renderEodBtn();
renderFolderStatus();
loadChimeSetting();
setInterval(fetchWeather, 10 * 60 * 1000); // refresh every 10 min