solar.test.ts15.8 KBView on GitHub import { computeRealTheme, computeSky, type SkyStop } from '@/modules/userSettings/theme/solar';
import SunCalc from 'suncalc';
const SF = { lat: 37.7749, lng: -122.4194 };
const DAY = new Date('2026-08-26T19:00:00Z');
/**
* The palette stop the sky is effectively sitting on, or null if it is genuinely between two.
*
* `computeSky` reports a segment plus a position along it, and the tolerance is what makes
* "is it at dusk" answerable a second either side of the flip — the sun has moved a
* ten-thousandth of a segment by then, which is the same colour to any eye and any screen.
*/
const AT_STOP_TOLERANCE = 0.02;
function nearestStop(sky: { from: SkyStop; to: SkyStop; mix: number }): SkyStop | null {
if (sky.mix <= AT_STOP_TOLERANCE) return sky.from;
if (sky.mix >= 1 - AT_STOP_TOLERANCE) return sky.to;
return null;
}
/**
* The moment the theme next changes over, asked of the driver itself.
*
* The flip is no longer any of SunCalc's named times — it is where the sun crosses the
* elevation the palette stops being readable at — so the tests take it from the same place
* the app does rather than re-deriving it and drifting apart from what actually ships.
*/
const flipAfter = (at: Date) => computeRealTheme(at, SF).nextBoundary;
/** Every minute of one real day in San Francisco, with the theme that day actually resolves to. */
function sweepDay() {
const start = new Date('2026-08-26T07:00:00Z');
const samples: Array<{ at: Date; half: 'light' | 'dark' }> = [];
for (let minute = 0; minute < 24 * 60; minute++) {
const at = new Date(start.getTime() + minute * 60_000);
samples.push({ at, half: computeRealTheme(at, SF).theme });
}
return samples;
}
describe('computeSky', () => {
it('lands on the same colour from either side of the theme flip', () => {
// The sky has to be the same colour a moment before the changeover and a moment after,
// or the flip reads as a jump cut.
const flip = flipAfter(new Date('2026-08-26T20:00:00Z')); // ~13:00 local, so: this evening
const lastLight = computeSky(new Date(flip.getTime() - 1000), SF, 'light');
const firstDark = computeSky(new Date(flip.getTime() + 1000), SF, 'dark');
// Both halves must be on the same segment of the ramp, at the same point along it.
expect(lastLight.from).toBe(firstDark.from);
expect(lastLight.to).toBe(firstDark.to);
expect(lastLight.mix).toBeCloseTo(firstDark.mix, 2);
// Neither derived value may step at the flip. They are not identical because the sun
// really does move over the two seconds between the samples; they are continuous.
expect(lastLight.dim).toBeCloseTo(firstDark.dim, 3);
expect(lastLight.glow).toBeCloseTo(firstDark.glow, 2);
});
it('mirrors that hinge in the morning', () => {
const flip = flipAfter(new Date('2026-08-26T09:00:00Z')); // ~02:00 local, so: this morning
const lastDark = computeSky(new Date(flip.getTime() - 1000), SF, 'dark');
const firstLight = computeSky(new Date(flip.getTime() + 1000), SF, 'light');
expect(lastDark.from).toBe(firstLight.from);
expect(lastDark.to).toBe(firstLight.to);
expect(lastDark.mix).toBeCloseTo(firstLight.mix, 2);
});
it('lands on the same colour at the flip wherever the sun barely clears the horizon', () => {
/*
* The hinge is a fixed elevation, but the ramp's ends follow the day, so a winter this
* far north squeezes the whole ramp into a few degrees and the flip lands somewhere
* quite different along it than it does at temperate latitudes. Wherever that is, the
* two halves still have to meet there.
*/
const NORTH = { Trondheim: { lat: 63.43, lng: 10.39 }, Anchorage: { lat: 61.22, lng: -149.9 } };
for (const [, coords] of Object.entries(NORTH)) {
for (const date of ['2026-12-21', '2026-11-15', '2026-01-25']) {
const midnightLocal = new Date(`${date}T00:00:00Z`).getTime() - (coords.lng / 15) * 3600_000;
const flip = computeRealTheme(new Date(midnightLocal + 12 * 3600_000), coords).nextBoundary;
const lastLight = computeSky(new Date(flip.getTime() - 1000), coords, 'light');
const firstDark = computeSky(new Date(flip.getTime() + 1000), coords, 'dark');
expect(lastLight.from).toBe(firstDark.from);
expect(lastLight.to).toBe(firstDark.to);
expect(lastLight.mix).toBeCloseTo(firstDark.mix, 2);
}
}
});
it('takes a different shoulder on the way down than on the way up', () => {
const times = SunCalc.getTimes(DAY, SF.lat, SF.lng);
const beforeSunset = new Date(times.sunset.getTime() - 20 * 60_000);
const afterSunrise = new Date(times.sunrise.getTime() + 20 * 60_000);
// Same elevation either side of noon, different shoulder — gold going down, coral coming
// up, the distinction the original sunrise/sunset keyframes drew.
expect([computeSky(beforeSunset, SF, 'light').from, computeSky(beforeSunset, SF, 'light').to])
.toContain('evening');
expect([computeSky(afterSunrise, SF, 'light').from, computeSky(afterSunrise, SF, 'light').to])
.toContain('morning');
});
it('rests flat for a theme that is not following the sun', () => {
const midnight = new Date('2026-08-27T08:00:00Z'); // ~01:00 local, sun well below
const noon = new Date('2026-08-26T20:00:00Z'); // ~13:00 local, sun high
// A page that hard-codes light, or a theme the user pinned, gets the flat sky a fixed
// theme has always shown — not a twilight one held indefinitely under the wrong chrome.
expect(computeSky(midnight, SF, 'light')).toMatchObject({ from: 'day', to: 'day', dim: 0 });
expect(computeSky(noon, SF, 'dark')).toMatchObject({ from: 'night', to: 'night', dim: 1 });
});
it('puts peak warmth at the horizon and is dark by civil twilight', () => {
// The complaint this phasing exists to answer: an earlier ramp hung full orange on civil
// twilight, so the backdrop was still blazing sunset once it had gone dark outside.
const times = SunCalc.getTimes(DAY, SF.lat, SF.lng);
const at = (from: Date, mins: number) =>
computeSky(new Date(from.getTime() + mins * 60_000), SF, 'light');
// Soft pink arrives before the deeper red, and the red peaks as the sun touches down.
expect(at(times.sunset, -10).to).toBe('blush');
expect([at(times.sunset, 0).from, at(times.sunset, 0).to]).toContain('dusk');
// By civil twilight it is well past the warmth and most of the way to night.
const atDusk = computeSky(times.dusk, SF, 'dark');
expect(atDusk.to).toBe('dawn');
expect(atDusk.mix).toBeGreaterThan(0.5);
expect(atDusk.dim).toBeGreaterThan(0.65);
});
it('changes over while the sky is still the sunset, and mirrors it at sunrise', () => {
/*
* The complaint: at 19:55 the backdrop was the deepest, reddest stop on the ramp and the
* app was still in light mode, putting near-black ink on it. The flip belongs where the
* sky stops being readable that way, which is a good half-hour before civil twilight.
*/
const times = SunCalc.getTimes(DAY, SF.lat, SF.lng);
const altAt = (at: Date) => SunCalc.getPosition(at, SF.lat, SF.lng).altitude * (180 / Math.PI);
// Within half a degree of the -2° the flip is solved for, not exactly on it: SunCalc's
// time model and its position model disagree by a few tenths, which is the whole reason
// `hingeDeg` asks the position model where the time model put the boundary.
const goesDark = flipAfter(new Date('2026-08-26T20:00:00Z'));
expect(goesDark.getTime()).toBeLessThan(times.dusk.getTime());
expect(altAt(goesDark)).toBeCloseTo(-2, 0);
// And the same moment in reverse: the morning waits for the sky to climb back to it,
// rather than turning light while it is still deep red out.
const goesLight = flipAfter(new Date('2026-08-26T09:00:00Z'));
expect(goesLight.getTime()).toBeGreaterThan(times.dawn.getTime());
expect(altAt(goesLight)).toBeCloseTo(-2, 0);
// Both land on the warm leg of the ramp, not down among the blues.
for (const flip of [goesDark, goesLight]) {
const sky = computeSky(flip, SF, 'dark');
expect(['blush', 'dusk']).toContain(sky.from);
}
});
it('stays inside its ranges and off the wrong stops all day', () => {
// The two halves share the ramp below the blush, so they overlap on `dusk` and `dawn`.
// What must never happen is either reaching past the other's resting colour.
const lightHalfStops: SkyStop[] = ['day', 'evening', 'morning', 'blush', 'dusk', 'dawn'];
const darkHalfStops: SkyStop[] = ['blush', 'dusk', 'dawn', 'night'];
for (const { at, half } of sweepDay()) {
const sky = computeSky(at, SF, half);
expect(sky.mix).toBeGreaterThanOrEqual(0);
expect(sky.mix).toBeLessThanOrEqual(1);
expect(sky.dim).toBeGreaterThanOrEqual(0);
expect(sky.dim).toBeLessThanOrEqual(1);
expect(sky.glow).toBeGreaterThanOrEqual(0);
expect(sky.glow).toBeLessThanOrEqual(1);
const allowed = half === 'light' ? lightHalfStops : darkHalfStops;
expect(allowed).toContain(sky.from);
expect(allowed).toContain(sky.to);
}
});
it('darkens monotonically through the afternoon and lightens through the morning', () => {
const samples = sweepDay();
const noon = SunCalc.getTimes(DAY, SF.lat, SF.lng).solarNoon;
let previousAfternoon = -Infinity;
let previousMorning = Infinity;
for (const { at, half } of samples) {
const { dim } = computeSky(at, SF, half);
if (at > noon) {
expect(dim).toBeGreaterThanOrEqual(previousAfternoon - 1e-9);
previousAfternoon = dim;
} else {
expect(dim).toBeLessThanOrEqual(previousMorning + 1e-9);
previousMorning = dim;
}
}
});
it('peaks the warm bounce light at the horizon, not at noon or midnight', () => {
const times = SunCalc.getTimes(DAY, SF.lat, SF.lng);
const atHorizon = computeSky(times.sunset, SF, 'light').glow;
const atNoon = computeSky(times.solarNoon, SF, 'light').glow;
const atNight = computeSky(times.nadir, SF, 'dark').glow;
expect(atHorizon).toBeGreaterThan(0.8);
expect(atNoon).toBe(0);
expect(atNight).toBe(0);
});
it('re-derives often near the horizon and rarely otherwise', () => {
const times = SunCalc.getTimes(DAY, SF.lat, SF.lng);
expect(computeSky(times.sunset, SF, 'light').nextUpdateMs).toBe(30_000);
expect(computeSky(times.solarNoon, SF, 'light').nextUpdateMs).toBe(5 * 60_000);
expect(computeSky(times.nadir, SF, 'dark').nextUpdateMs).toBe(5 * 60_000);
});
describe('holding still', () => {
/*
* The point of the whole feature: for most of the day and most of the night the backdrop
* must be *identical*, not merely similar. It may only move around sunrise and sunset.
*
* These places and dates are the ones that break naive thresholds — near the solstices at
* high latitude the sun never climbs to a fixed "day" elevation nor sinks to a fixed
* "night" one, and an absolute ramp would drift continuously for twenty-four hours.
*/
const PLACES = {
'San Francisco': { lat: 37.7749, lng: -122.4194 },
London: { lat: 51.5074, lng: -0.1278 },
Stockholm: { lat: 59.3293, lng: 18.0686 },
Singapore: { lat: 1.3521, lng: 103.8198 },
};
const DATES = ['2026-06-21', '2026-09-22', '2026-12-21'];
/** A sky that is resting on an end of the ramp rather than travelling along it. */
const isSettled = (sky: ReturnType<typeof computeSky>) =>
sky.from === sky.to && sky.mix === 0 && (sky.dim === 0 || sky.dim === 1);
function sweep(coords: { lat: number; lng: number }, date: string) {
const start = new Date(`${date}T00:00:00Z`).getTime() - (coords.lng / 15) * 3600_000;
return Array.from({ length: 1440 }, (_, minute) => {
const at = new Date(start + minute * 60_000);
return computeSky(at, coords, computeRealTheme(at, coords).theme);
});
}
it.each(Object.entries(PLACES).flatMap(([p, c]) => DATES.map((d) => [p, d, c] as const)))(
'moves only twice a day in %s on %s',
(_place, date, coords) => {
const day = sweep(coords, date);
// Count runs of movement. Two — one around sunrise, one around sunset — and no more.
let runs = 0;
day.forEach((sky, i) => {
if (!isSettled(sky) && (i === 0 || isSettled(day[i - 1]!))) runs++;
});
expect(runs).toBeLessThanOrEqual(2);
// And every settled minute is byte-identical to the others of its kind, so the
// backdrop is genuinely unchanged rather than creeping by a fraction of a per cent.
for (const kind of ['day', 'night'] as const) {
const settled = day.filter((s) => isSettled(s) && s.from === kind);
for (const sky of settled) {
expect({ ...sky, nextUpdateMs: 0 }).toEqual({ ...settled[0]!, nextUpdateMs: 0 });
}
}
},
);
it('still settles where the sun never reaches a fixed threshold', () => {
// London in June never sinks past -16°, and Stockholm in December never climbs past 7°.
// Both must still come to rest, on night and on day respectively.
const londonJune = sweep(PLACES.London, '2026-06-21');
expect(londonJune.filter((s) => isSettled(s) && s.from === 'night').length).toBeGreaterThan(60);
const stockholmDecember = sweep(PLACES.Stockholm, '2026-12-21');
expect(stockholmDecember.filter((s) => isSettled(s) && s.from === 'day').length)
.toBeGreaterThan(60);
});
});
describe('inside the polar circles', () => {
const TROMSO = { lat: 69.6492, lng: 18.9553 };
it('reads the midnight sun as daylight', () => {
// There is no civil twilight at all that day, so SunCalc has no dawn or dusk to give.
// Falling through the comparisons against those would resolve every hour to dark.
const midnight = new Date('2026-06-21T22:00:00Z'); // ~00:00 local, sun still up
expect(computeRealTheme(midnight, TROMSO).theme).toBe('light');
expect(nearestStop(computeSky(midnight, TROMSO, 'light'))).not.toBe('night');
});
it('always hands back a boundary a timer can use', () => {
// A NaN delay is run by setTimeout as zero, which would spin instead of waiting a day.
for (const date of ['2026-06-21', '2026-12-21', '2026-09-22']) {
const { nextBoundary } = computeRealTheme(new Date(`${date}T12:00:00Z`), TROMSO);
expect(Number.isNaN(nextBoundary.getTime())).toBe(false);
expect(nextBoundary.getTime()).toBeGreaterThan(new Date(`${date}T12:00:00Z`).getTime());
}
});
});
describe('without a location', () => {
// The stand-in sun is tuned so it crosses the flip elevation at the same 06:00/20:00
// `computeRealTheme` falls back to — the two have to agree about when it is night.
const local = (hour: number, minute = 0) => new Date(2026, 7, 26, hour, minute, 0, 0);
it('agrees with the theme fallback about where the hinge is', () => {
expect(computeRealTheme(local(19, 59), null).theme).toBe('light');
expect(computeRealTheme(local(20, 1), null).theme).toBe('dark');
const lastLight = computeSky(local(20), null, 'light');
const firstDark = computeSky(local(20), null, 'dark');
expect(lastLight.from).toBe(firstDark.from);
expect(lastLight.to).toBe(firstDark.to);
expect(lastLight.mix).toBeCloseTo(firstDark.mix, 2);
});
it('still moves through the day', () => {
const noon = computeSky(local(13), null, 'light');
// Inside the ramp: the stand-in sun holds flat daylight until ~19:20, then has forty
// minutes to travel down to the 20:00 flip.
const evening = computeSky(local(19, 45), null, 'light');
const lateNight = computeSky(local(1), null, 'dark');
expect(nearestStop(noon)).toBe('day');
expect(evening.dim).toBeGreaterThan(noon.dim);
expect(nearestStop(lateNight)).toBe('night');
});
});
});