Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ copy holds that copy's secrets.
- **Photos** from an iCloud shared album, straight from a phone, or from a folder.
- **Text sized for the room.** Smaller, Standard or Larger, for the rows and the lists; the clock and the layout stay
as they are.
- **°F or °C** as the household's country uses, or as you choose in Settings.
- **It behaves itself.** Brightness follows sunrise and sunset. After a while it drifts to photos and comes back. After a
power cut, or with the internet down, it shows the last good screen and says from when.

Expand Down
24 changes: 16 additions & 8 deletions dist/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
var calProblem = false, tasksProblem = false;
var page = 0, mode = 'calendar', themeChoice = 'auto';
// Household preferences from the Settings view, stored on the server. These defaults are used until they load.
var PREFS = { rest: 'photos', restAfter: 5, mornings: 0, photoEvery: 60, appearance: 'auto', clock: 'auto', calendarView: 'week', textSize: 'standard' };
var PREFS = { rest: 'photos', restAfter: 5, mornings: 0, photoEvery: 60, appearance: 'auto', clock: 'auto', temperature: 'auto', calendarView: 'week', textSize: 'standard' };
var prefs = withDefaults({});
// What the server sends, over these defaults, so a setting it does not send yet still has its value.
function withDefaults(data) { var out = {}, k; for (k in PREFS) out[k] = PREFS[k]; for (k in data) out[k] = data[k]; return out; }
Expand Down Expand Up @@ -87,6 +87,13 @@
} catch (e) {}
return (clockConvention[country] = uses24);
}
// Fahrenheit or Celsius, as Settings says; on Automatic, as the household's country: Fahrenheit in the US and the few
// places that still use it, Celsius everywhere else. A household whose country is not known yet keeps the Fahrenheit
// it always had. The server always asks Open-Meteo in Fahrenheit, so its cache and the fixtures stay one thing.
var FAHRENHEIT = ['US', 'BS', 'BZ', 'KY', 'LR', 'PW', 'FM', 'MH'];
function celsius() { return prefs.temperature === 'C' || (prefs.temperature !== 'F' && /^[A-Z]{2}$/.test(household.country || '') && FAHRENHEIT.indexOf(household.country) < 0); }
// A temperature from the server, in °F, as a whole number in the unit shown. (+ 0 turns a rounded -0 into 0.)
function deg(f) { return Math.round(celsius() ? (f - 32) * 5 / 9 : f) + 0; }
// "4 PM", "4:30 PM" or "16:00", "16:30": the 12-hour form drops :00 as people say it, the 24-hour form never does.
function clockTime(d) { var h = d.getHours(), m = d.getMinutes(); return hours24() ? pad(h) + ':' + pad(m) : (h % 12 || 12) + (m ? ':' + pad(m) : '') + (h < 12 ? ' AM' : ' PM'); }
// When something was last true, for a time that may be from another day: "5:39 PM" today, then "yesterday 5:39 PM",
Expand Down Expand Up @@ -346,13 +353,13 @@
$('now').hidden = false;
var t = now().getTime(), sun = sunTimes(now());
drawIcon($('now-icon'), weather.code, t < sun.rise || t > sun.set);
$('now-temp').textContent = Math.round(weather.temperature) + '°';
$('now-temp').textContent = deg(weather.temperature) + '°';
$('now-summary').textContent = describe(weather.code);
// Apple's form: H and L first, then the one extra that matters today (rain, or a big feels-like gap). "H:104°" has
// no space inside it to break at, so a label never wraps away from its number.
var feels = Math.round(weather.feelsLike), detail = 'H:' + Math.round(weather.high) + '° L:' + Math.round(weather.low) + '°';
var feels = deg(weather.feelsLike), detail = 'H:' + deg(weather.high) + '° L:' + deg(weather.low) + '°';
if (weather.rain >= 20) detail += ' · ' + weather.rain + '% rain';
else if (Math.abs(feels - Math.round(weather.temperature)) >= 4) detail += ' · Feels like ' + feels + '°';
else if (Math.abs(feels - deg(weather.temperature)) >= (celsius() ? 2 : 4)) detail += ' · Feels like ' + feels + '°';
$('now-detail').textContent = detail + (weather.stale ? ' · not updating' : '');
}
// A day's forecast in one short row: the icon, with the chance of rain under it where weather apps put it (so a wet day
Expand All @@ -364,8 +371,8 @@
drawIcon(svg, fc.code); wx.appendChild(svg);
if (fc.rain >= 30) wx.appendChild(node('small', '', fc.rain + '%'));
row.appendChild(wx);
row.appendChild(node('b', '', Math.round(fc.high) + '°')); row.appendChild(node('span', '', Math.round(fc.low) + '°'));
row.setAttribute('aria-label', describe(fc.code) + ', high ' + Math.round(fc.high) + ', low ' + Math.round(fc.low) + (fc.rain >= 30 ? ', ' + fc.rain + ' percent chance of rain' : ''));
row.appendChild(node('b', '', deg(fc.high) + '°')); row.appendChild(node('span', '', deg(fc.low) + '°'));
row.setAttribute('aria-label', describe(fc.code) + ', high ' + deg(fc.high) + ', low ' + deg(fc.low) + (fc.rain >= 30 ? ', ' + fc.rain + ' percent chance of rain' : ''));
return row;
}
function forecastFor(day) {
Expand Down Expand Up @@ -724,8 +731,8 @@
if (!wx.hidden) {
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'), rain = tomorrowSky.rain >= 30 ? ' · ' + tomorrowSky.rain + '% rain' : '';
svg.setAttribute('class', 'wx-icon'); svg.setAttribute('viewBox', '0 0 64 64'); svg.setAttribute('aria-hidden', 'true'); drawIcon(svg, tomorrowSky.code);
wx.textContent = ''; wx.appendChild(svg); wx.appendChild(node('b', '', Math.round(tomorrowSky.high) + '°')); wx.appendChild(node('span', '', Math.round(tomorrowSky.low) + '°' + rain));
wx.setAttribute('aria-label', describe(tomorrowSky.code) + ', high ' + Math.round(tomorrowSky.high) + ', low ' + Math.round(tomorrowSky.low) + (rain ? ', ' + tomorrowSky.rain + ' percent chance of rain' : ''));
wx.textContent = ''; wx.appendChild(svg); wx.appendChild(node('b', '', deg(tomorrowSky.high) + '°')); wx.appendChild(node('span', '', deg(tomorrowSky.low) + '°' + rain));
wx.setAttribute('aria-label', describe(tomorrowSky.code) + ', high ' + deg(tomorrowSky.high) + ', low ' + deg(tomorrowSky.low) + (rain ? ', ' + tomorrowSky.rain + ' percent chance of rain' : ''));
}
var nodes = coming.map(function (item) {
var el = itemNode(item, false);
Expand Down Expand Up @@ -1161,6 +1168,7 @@
photoEvery: [[30, '30 sec'], [60, '1 min'], [120, '2 min'], [300, '5 min']],
appearance: [['auto', 'Automatic'], ['light', 'Light'], ['dark', 'Dark']],
clock: [['auto', 'Automatic'], ['12', '12-hour'], ['24', '24-hour']],
temperature: [['auto', 'Automatic'], ['F', '°F'], ['C', '°C']],
calendarView: [['week', 'Week'], ['agenda', 'Agenda']],
textSize: [['smaller', 'Smaller'], ['standard', 'Standard'], ['larger', 'Larger']],
screen: [['auto', 'With the sun'], ['bright', 'Bright'], ['dim', 'Dim']]
Expand Down
1 change: 1 addition & 0 deletions dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ <h2 class="eyebrow">Photos</h2>
<h2 class="eyebrow">Appearance</h2>
<div class="setting"><p>Calendar</p><div class="choices" data-setting="calendarView"></div></div>
<div class="setting"><p>Clock</p><div class="choices" data-setting="clock"></div></div>
<div class="setting"><p>Temperature</p><div class="choices" data-setting="temperature"></div></div>
<div class="setting"><p>Text size</p><div class="choices" data-setting="textSize"></div></div>
<div class="setting"><p>Theme</p><div class="choices" data-setting="appearance"></div></div>
<div class="setting"><p>Brightness</p><div class="choices" data-setting="screen"></div></div>
Expand Down
3 changes: 2 additions & 1 deletion settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ const OPTIONS = {
appearance: ['auto', 'light', 'dark'], // auto follows sunrise and sunset where the household lives
screen: ['auto', 'bright', 'dim'], // backlight: with the sun, always bright, or always dim
clock: ['auto', '12', '24'], // auto is the convention of the household's country; 12-hour until that is known
temperature: ['auto', 'F', 'C'], // auto is the household's country: Fahrenheit in the US and the few like it, Celsius elsewhere
calendarView: ['week', 'agenda'], // the days ahead as six columns, or as one list grouped by day
textSize: ['smaller', 'standard', 'larger'] // what is read scales, rows with it; the glance layer and the layout do not
};
const DEFAULTS = {rest: 'photos', restAfter: 5, mornings: 0, photoEvery: 60, appearance: 'auto', screen: 'auto', clock: 'auto', calendarView: 'week', textSize: 'standard'};
const DEFAULTS = {rest: 'photos', restAfter: 5, mornings: 0, photoEvery: 60, appearance: 'auto', screen: 'auto', clock: 'auto', temperature: 'auto', calendarView: 'week', textSize: 'standard'};

function clean(input) {
const out = {};
Expand Down
10 changes: 10 additions & 0 deletions test/settings.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,16 @@ test('Text size is a household setting: Standard unless chosen, one of three, ke
assert.equal(settings.update({textSize: 'larger'}).textSize, 'larger');
});

test('Temperature is a household setting: Automatic unless chosen, °F or °C once chosen, kept across a restart', () => {
const file = tempFile(), settings = createSettings({file});
assert.equal(settings.read().temperature, 'auto');
assert.equal(settings.update({temperature: 'K'}).temperature, 'auto', 'only the three choices are accepted');
assert.equal(settings.update({temperature: 'c'}).temperature, 'auto', 'and exactly as written');
assert.equal(settings.update({temperature: 'C'}).temperature, 'C');
assert.equal(createSettings({file}).read().temperature, 'C', 'survives a restart');
assert.equal(settings.update({temperature: 'F'}).temperature, 'F');
});

// The play page (DESIGN.md → "A play page, for one household"): set only in the household's settings.json, never through
// the API, and dropped unless it is a plain https address.
const {cleanPlayPage} = require('../settings');
Expand Down
3 changes: 2 additions & 1 deletion test/updates-server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,11 @@ test('A household’s data from 0.1.0 reads cleanly in this version', () => {
assert.equal(settings.clock, DEFAULTS.clock, 'the new setting takes its default');
assert.equal(settings.calendarView, 'week', 'and so does 0.1.2’s calendar view: the columns it always had');
assert.equal(settings.textSize, 'standard', 'and 0.1.3’s text size: the size it always had');
assert.equal(settings.temperature, 'auto', 'and the temperature unit: Fahrenheit, as it always was, until a country is known');
assert.equal(settings.rest, 'calendar'); assert.equal(settings.screen, 'dim'); assert.equal(settings.mornings, 8);
// An older version reading what this one writes: it drops what it does not know and keeps the rest.
const written = home.settings.update({clock: '24'});
const olderClean = input => Object.fromEntries(Object.entries(input).filter(([k]) => k !== 'clock' && k !== 'calendarView' && k !== 'textSize'));
const olderClean = input => Object.fromEntries(Object.entries(input).filter(([k]) => k !== 'clock' && k !== 'calendarView' && k !== 'textSize' && k !== 'temperature'));
assert.deepEqual(olderClean(written), {rest: 'calendar', restAfter: 10, mornings: 8, photoEvery: 120, appearance: 'dark', screen: 'dim'});
assert.equal(createSettings({file: path.join(dir, 'settings.json')}).read().clock, '24');
fs.rmSync(data, {recursive: true, force: true});
Expand Down
18 changes: 18 additions & 0 deletions test/wall.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -849,3 +849,21 @@ test('A monogram\'s letter takes the ink that reads on its person\'s colour, and
assert.match(css, /\.forecast small,\.agenda-wx small\{[^}]*color:var\(--rain-ink\)/, 'the rain chance is text in --rain-ink');
assert.match(css, /\.wx-icon \.drop\{stroke:var\(--rain\)\}/, 'the drops keep --rain');
});

test('Temperatures are in °F or °C as the household\'s country uses, or as Settings says', () => {
const house = country => ({'/api/household': {name: '', timezone: 'America/Chicago', place: 'Somewhere', ...(country ? {country} : {})}});
const settings = temperature => ({'/api/settings': {rest: 'calendar', restAfter: 5, mornings: 0, photoEvery: 60, appearance: 'auto', screen: 'auto', clock: 'auto', temperature}});
// The stress fixture is 101°F now, a high of 104°F and a low of 79°F, with an 80% chance of rain.
const shown = w => [w.$('now-temp').textContent, w.$('now-detail').textContent];
// An American household, and one whose country is not known yet, keep Fahrenheit.
for (const country of ['US', '']) assert.deepEqual(shown(load({now: '2026-09-23T10:00:00', overrides: house(country)})), ['101°', 'H:104° L:79° · 80% rain']);
// A British household, on Automatic, is in Celsius, and so is the week's forecast.
let w = load({now: '2026-09-23T10:00:00', overrides: house('GB')});
assert.deepEqual(shown(w), ['38°', 'H:40° L:26° · 80% rain']);
assert.ok(!/1\d\d°/.test(w.document.body.textContent), 'no Fahrenheit left anywhere on the wall');
// Settings overrides the country either way, and offers the three choices.
assert.equal(load({now: '2026-09-23T10:00:00', overrides: {...house('US'), ...settings('C')}}).$('now-temp').textContent, '38°');
assert.equal(load({now: '2026-09-23T10:00:00', overrides: {...house('GB'), ...settings('F')}}).$('now-temp').textContent, '101°');
const choices = w.document.querySelectorAll('[data-setting]').find(el => el.getAttribute('data-setting') === 'temperature');
assert.deepEqual(choices.children.map(b => b.textContent), ['Automatic', '°F', '°C']);
});