Skip to content

Commit 160c6f9

Browse files
committed
fix(weather): sky uses the site (Bangkok) clock and strict HH:MM rise/set (codex P2)
- phase, sun position and the hero clock read UTC+7 like lib/format.ts, so a viewer abroad sees the site's sky; rise/set that aren't HH:MM (e.g. the '—' placeholder) give no sun and a plain-clock phase instead of being read as 00:00 - tests: timezone-independent phases/arc (run under America/New_York too), placeholder and out-of-range strings, heat-index thresholds, TMD code mapping
1 parent fb87483 commit 160c6f9

3 files changed

Lines changed: 74 additions & 9 deletions

File tree

‎src/components/WeatherHero.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useEffect, useMemo, useRef, useState } from "react";
22
import type { Weather } from "../lib/api";
3+
import { bkkClock } from "../lib/format";
34
import { condText, feelsLike, skyPhase, skyWx, sunPos, solarInfo, type SkyPhase, type SkyWx } from "../lib/weather";
45

56
/* Live sky for the weather tab: the real time of day (from the location's sunrise/sunset),
@@ -78,7 +79,7 @@ export function WeatherHero({ weather: w, force }: { weather: Weather; force?: {
7879

7980
// sun on its real arc (east→west mapped onto the right of the card so it never sits under the temperature), height from elevation vs today's noon
8081
const sunStyle = sun ? { left: `${(36 + sun.x * 58).toFixed(1)}%`, top: `${(70 - Math.min(1, sun.elev / noon) * 56).toFixed(1)}%` } : undefined;
81-
const clock = now.toLocaleTimeString("th-TH", { hour: "2-digit", minute: "2-digit", hour12: false });
82+
const clock = bkkClock(now.getTime() / 1000); // site clock (Bangkok), whatever the viewer's timezone
8283

8384
return (
8485
<div className={`wxhero p-${phase} x-${wx}`} role="img" aria-label={`${condText(w.cond, night)} ${Math.round(w.temp!)} องศา`}>
@@ -118,7 +119,7 @@ export function WeatherHero({ weather: w, force }: { weather: Weather; force?: {
118119
</div>
119120

120121
{night ? (
121-
<div className="wh-pv"><span>กลางคืน · แผงพักการผลิต</span><span className="dim">รอแดด {w.sun?.rise ? `${w.sun.rise} น.` : "พรุ่งนี้"}</span></div>
122+
<div className="wh-pv"><span>กลางคืน · แผงพักการผลิต</span><span className="dim">รอแดด {/^\d{1,2}:\d{2}$/.test(w.sun?.rise || "") ? `${w.sun!.rise} น.` : "พรุ่งนี้"}</span></div>
122123
) : (
123124
<div className="wh-pv">
124125
<span>แสงแดดวันนี้ · {solar.label}</span><span>{solar.pct}%</span>

‎src/lib/__tests__/sky.test.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, it, expect } from "vitest";
2+
import { skyPhase, sunPos, siteMinutes, feelsLike, skyWx } from "../weather";
3+
4+
// 2026-10-08 12:05 Bangkok = 05:05Z. The Date's local zone must not matter.
5+
const at = (bkkHHMM: string) => { const [h, m] = bkkHHMM.split(":").map(Number); return new Date(Date.UTC(2026, 9, 8, h - 7, m)); };
6+
const SUN = { rise: "06:07", set: "18:03", arc: [0, 30, 60, 30, 0] };
7+
8+
describe("sky — site (Bangkok) clock, never the viewer's", () => {
9+
it("siteMinutes reads UTC+7 regardless of the runtime TZ", () => {
10+
expect(siteMinutes(at("00:00"))).toBe(0);
11+
expect(siteMinutes(at("12:05"))).toBe(12 * 60 + 5);
12+
expect(siteMinutes(at("23:59"))).toBe(23 * 60 + 59);
13+
});
14+
it("phases from sunrise/sunset", () => {
15+
expect(skyPhase(at("03:00"), SUN.rise, SUN.set)).toBe("night");
16+
expect(skyPhase(at("06:20"), SUN.rise, SUN.set)).toBe("dawn");
17+
expect(skyPhase(at("12:00"), SUN.rise, SUN.set)).toBe("day");
18+
expect(skyPhase(at("17:15"), SUN.rise, SUN.set)).toBe("golden");
19+
expect(skyPhase(at("18:10"), SUN.rise, SUN.set)).toBe("dusk");
20+
expect(skyPhase(at("19:00"), SUN.rise, SUN.set)).toBe("night");
21+
});
22+
it("sun on its arc: noon at the top, down outside rise→set", () => {
23+
const noon = sunPos(at("12:05"), SUN)!;
24+
expect(noon.x).toBeCloseTo(0.5, 2); expect(noon.elev).toBeCloseTo(60, 0);
25+
expect(sunPos(at("05:00"), SUN)).toBeNull();
26+
expect(sunPos(at("19:00"), SUN)).toBeNull();
27+
});
28+
});
29+
30+
describe("sky — invalid rise/set never becomes 00:00", () => {
31+
it("placeholder '—' → no sun, clock-default phases", () => {
32+
expect(sunPos(at("12:00"), { rise: "—", set: "18:00", arc: SUN.arc })).toBeNull();
33+
expect(sunPos(at("12:00"), { rise: "06:00", set: "" })).toBeNull();
34+
expect(skyPhase(at("03:00"), "—", "18:00")).toBe("night");
35+
expect(skyPhase(at("12:00"), "—", "—")).toBe("day");
36+
expect(skyPhase(at("12:00"), "25:00", "18:00")).toBe("day");
37+
});
38+
});
39+
40+
describe("feelsLike / skyWx", () => {
41+
it("heat index only when warm and humid", () => {
42+
expect(feelsLike(25, 90)).toBe(25);
43+
expect(feelsLike(32, 30)).toBe(32);
44+
expect(feelsLike(32, 70)).toBeGreaterThan(38);
45+
expect(feelsLike(32, 70)).toBeLessThan(42);
46+
});
47+
it("TMD codes → sky weather", () => {
48+
expect([1, 2, 3, 5, 8, 12].map(skyWx)).toEqual(["clear", "partly", "cloud", "rain", "storm", "clear"]);
49+
});
50+
});

‎src/lib/weather.ts‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,25 @@ export function feelsLike(t: number, rh: number): number {
3333

3434
// Sky phase from the location's real sunrise/sunset ("HH:MM", local) — falls back to the clock.
3535
export type SkyPhase = "night" | "dawn" | "day" | "golden" | "dusk";
36-
const hm = (s: string) => { const [h, m] = s.split(":").map(Number); return (h || 0) * 60 + (m || 0); };
36+
// Site-local minutes since midnight. The site is in Thailand (UTC+7, no DST) and rise/set
37+
// strings are site-local, so "now" is read on the Bangkok clock too — a viewer abroad
38+
// must still see the site's sky, not their own (same contract as lib/format.ts).
39+
const SITE_OFFSET_MIN = 7 * 60;
40+
export const siteMinutes = (now: Date) => {
41+
const m = Math.floor(now.getTime() / 60000) + SITE_OFFSET_MIN;
42+
return ((m % 1440) + 1440) % 1440;
43+
};
44+
// strict "HH:MM" → minutes; anything else (e.g. the "—" placeholder) → null, never 00:00
45+
const hm = (s?: string | null): number | null => {
46+
const m = /^(\d{1,2}):(\d{2})$/.exec(String(s ?? "").trim());
47+
if (!m) return null;
48+
const h = +m[1], mi = +m[2];
49+
return h < 24 && mi < 60 ? h * 60 + mi : null;
50+
};
3751
export function skyPhase(now: Date, rise?: string, set?: string): SkyPhase {
38-
const t = now.getHours() * 60 + now.getMinutes();
39-
const r = rise ? hm(rise) : 6 * 60, s = set ? hm(set) : 18 * 60;
52+
const t = siteMinutes(now);
53+
let r = hm(rise), s = hm(set);
54+
if (r == null || s == null || s <= r) { r = 6 * 60; s = 18 * 60; } // unknown/invalid → plain clock fallback
4055
if (t < r - 35 || t >= s + 30) return "night";
4156
if (t < r + 40) return "dawn";
4257
if (t >= s) return "dusk";
@@ -45,11 +60,10 @@ export function skyPhase(now: Date, rise?: string, set?: string): SkyPhase {
4560
}
4661
// Sun position on today's arc: x 0..1 across the sky, elevation in degrees (0 when down).
4762
export function sunPos(now: Date, sun?: { rise?: string; set?: string; arc?: number[] }): { x: number; elev: number } | null {
48-
if (!sun?.rise || !sun?.set) return null;
49-
const r = hm(sun.rise), s = hm(sun.set), t = now.getHours() * 60 + now.getMinutes();
50-
if (s <= r || t < r || t > s) return null;
63+
const r = hm(sun?.rise), s = hm(sun?.set), t = siteMinutes(now);
64+
if (r == null || s == null || s <= r || t < r || t > s) return null;
5165
const x = (t - r) / (s - r);
52-
const arc = sun.arc || [];
66+
const arc = sun?.arc || [];
5367
if (arc.length < 2) return { x, elev: Math.sin(Math.PI * x) * 60 };
5468
const i = x * (arc.length - 1), lo = Math.floor(i), hi = Math.min(arc.length - 1, lo + 1);
5569
return { x, elev: arc[lo] + (arc[hi] - arc[lo]) * (i - lo) };

0 commit comments

Comments
 (0)