diff --git a/README.md b/README.md
index 6127cbf..8d21cea 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@ Render order (top → bottom): **info · prayers · money · zai**.
| Line | Contents |
|---|---|
-| **info** | Local clock · Hijri date with Gregorian gloss — `27 Rabīʿ al-awwal 1448 (09 Sep 2026)` · city — all dim. |
+| **info** | Local weekday + clock — `Wed 21:12` · Hijri date with Gregorian gloss — `27 Rabīʿ al-awwal 1448 (09 Sep 2026)` · city — all dim. The Hijri date advances at Maghrib, not civil midnight (evening value via Aladhan `gToH`, cached per city-day; on failure it stays on today's date). |
| **prayers** | All five prayers with wall times, from [aladhan](https://api.aladhan.com) (cached per local day, stale-marker on degradation). Past prayers get a dim `✓`; the next prayer is green with a countdown on its segment only — `Dhuhr 11:51 (3h 36m)`. |
| **money** | API spend + token volume per window: `REPO $68.36 (1.3B)` (all-time for the current project) · `DAY` (since 00:00 local) · `7DAY` / `30DAY` (rolling, hour-aligned). Token volumes are dim, from the same sessions scan. |
| **zai** | Quota pace per window: `LABEL usage%/elapsed% (pace · reset · absolute)` — e.g. `5hrs 16%/26% (30m under · 3h 43m · 11:58)`. **Provider-gated**: renders only while the active model's provider is `zai` (or the provider is unreadable); vanishes entirely on other providers. |
diff --git a/assets/hero.svg b/assets/hero.svg
index 71b687a..a136d9b 100644
--- a/assets/hero.svg
+++ b/assets/hero.svg
@@ -27,13 +27,13 @@
// v0.3.0 (RECTOR order): info first, prayers, money, zai
- 08:15 · 25 Rabīʿ al-awwal 1448 (07 Sep 2026) · Jakarta
+ Mon 08:15 · 25 Rabīʿ al-awwal 1448 (07 Sep 2026) · Jakarta
Fajr 04:33 ✓ · Dhuhr 11:51 (3h 36m) · Asr 15:07 · Maghrib 17:52 · Isha 19:01
REPO $68.36 (1.3B) · DAY $26.50 (138.2M) · 7DAY $315.27 (2.7B) · 30DAY $492.88 (5.9B)
- zai 5hrs 16%/26% (30m under · 3h 43m · 11:58) · 7DAY 33%/31% (3h 22m over · 4d 19h · Sep 12 03:30)
+ zai 5hrs 16%/26% (30m under · 3h 43m · 11:58) · 7DAY 33%/31% (3h 22m over · 4d 19h · Sat Sep 12 03:30)
omp native statusline closes the block — placement tracked upstream (oh-my-pi #11100)
diff --git a/package.json b/package.json
index 6509714..ad8c6eb 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@getpipher/omp-statusline",
- "version": "0.5.1",
+ "version": "0.6.0",
"description": "Four-line omp statusline widget: zai coding-plan quota (provider-gated), prayer times, hijri clock, and API-spend ledger (REPO/DAY/7DAY/30DAY, subagent-inclusive).",
"keywords": [
"pi-package",
diff --git a/src/deen/api.ts b/src/deen/api.ts
index 8e87e5f..99773f9 100644
--- a/src/deen/api.ts
+++ b/src/deen/api.ts
@@ -1,5 +1,6 @@
// src/deen/api.ts
const ALADHAN_URL = "https://api.aladhan.com/v1/timingsByCity";
+const G_TO_H_URL = "https://api.aladhan.com/v1/gToH";
export type PrayerName = "Fajr" | "Dhuhr" | "Asr" | "Maghrib" | "Isha";
@@ -15,8 +16,20 @@ const PRAYER_NAMES: PrayerName[] = ["Fajr", "Dhuhr", "Asr", "Maghrib", "Isha"];
const WALL_TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
+// Shared hijri-object extraction: timingsByCity (today's date) and gToH (arbitrary
+// date) answer with the same `data.hijri` shape → `${day} ${month.en} ${year}`.
+function parseHijri(hijri: unknown): string | null {
+ const h = hijri as { day?: unknown; year?: unknown; month?: { en?: unknown } } | null | undefined;
+ if (!h || typeof h.day !== "string" || typeof h.year !== "string" || typeof h.month?.en !== "string") return null;
+ return `${h.day} ${h.month.en} ${h.year}`;
+}
+
export function parseTimingsResponse(body: string): DeenData | null {
- let parsed: { code?: unknown; status?: unknown; data?: any };
+ let parsed: {
+ code?: unknown;
+ status?: unknown;
+ data?: { timings?: unknown; meta?: { timezone?: unknown } | null; date?: { hijri?: unknown } | null };
+ } | null;
try {
parsed = JSON.parse(body);
} catch {
@@ -24,23 +37,21 @@ export function parseTimingsResponse(body: string): DeenData | null {
}
if (parsed?.code !== 200 || parsed?.status !== "OK" || !parsed.data) return null;
- const timings = parsed.data.timings as Record | undefined;
- const timezone = (parsed.data.meta as { timezone?: unknown } | undefined)?.timezone;
- const hijri = (parsed.data.date as { hijri?: any } | undefined)?.hijri;
- if (!timings || typeof timezone !== "string" || !hijri) return null;
+ const timings = parsed.data.timings;
+ if (!timings || typeof timings !== "object") return null;
+ const byName = timings as Record; // guarded above; per-name checks below
+ const timezone = parsed.data.meta?.timezone;
+ const hijri = parseHijri(parsed.data.date?.hijri);
+ if (typeof timezone !== "string" || !hijri) return null;
const prayers = {} as PrayerTimes;
for (const name of PRAYER_NAMES) {
- const value = timings[name];
+ const value = byName[name];
if (typeof value !== "string" || !WALL_TIME.test(value)) return null;
prayers[name] = value;
}
- const { day, year } = hijri;
- const monthEn = hijri.month?.en;
- if (typeof day !== "string" || typeof year !== "string" || typeof monthEn !== "string") return null;
-
- return { prayers, timezone, hijri: `${day} ${monthEn} ${year}` };
+ return { prayers, timezone, hijri };
}
export interface FetchOpts {
@@ -64,3 +75,21 @@ export async function fetchPrayerTimes(opts: FetchOpts): Promise {
+ try {
+ const res = await (fetchImpl ?? fetch)(`${G_TO_H_URL}?date=${encodeURIComponent(dateParam)}`, {
+ headers: { Accept: "application/json" },
+ signal: AbortSignal.timeout(5_000),
+ });
+ if (!res.ok) return null;
+ const parsed = JSON.parse(await res.text()) as { code?: unknown; data?: { hijri?: unknown } | null };
+ if (parsed?.code !== 200 || !parsed?.data) return null;
+ return parseHijri(parsed.data.hijri);
+ } catch {
+ return null;
+ }
+}
diff --git a/src/deen/cache.ts b/src/deen/cache.ts
index 3fdcb6e..a93ddf4 100644
--- a/src/deen/cache.ts
+++ b/src/deen/cache.ts
@@ -15,6 +15,9 @@ export interface DeenCacheFile {
fetchedAt: number;
data: DeenData;
geo?: GeoInfo;
+ // v0.6.0 Maghrib rollover: gToH(tomorrow) answer for this city-day evening —
+ // present once the rollover fetched it; keyed implicitly by `key`.
+ tomorrowHijri?: string;
}
const DAY_MS = 86_400_000;
diff --git a/src/deen/source.ts b/src/deen/source.ts
index 3a03d7e..8270a10 100644
--- a/src/deen/source.ts
+++ b/src/deen/source.ts
@@ -1,6 +1,6 @@
// src/deen/source.ts
-import { fetchPrayerTimes, type DeenData, type FetchOpts } from "./api.ts";
-import { computeSchedule, escalationState, type EscalationState, type PrayerScheduleEntry } from "./time.ts";
+import { fetchPrayerTimes, fetchHijriForDate, type DeenData, type FetchOpts } from "./api.ts";
+import { computeSchedule, escalationState, gToHDateParam, maghribRolloverActive, type EscalationState, type PrayerScheduleEntry } from "./time.ts";
import { isDataFresh, isGeoFresh, loadDeenCache, saveDeenCache, type DeenCacheFile, type GeoInfo } from "./cache.ts";
export interface DeenSourceConfig {
@@ -59,6 +59,7 @@ export interface DeenSourceOpts {
geoFetchFn?: typeof fetch;
fetchPrayer?: typeof fetchPrayerTimes;
fetchGeo?: (fetchImpl?: typeof fetch) => Promise;
+ fetchHijri?: typeof fetchHijriForDate;
}
export function createDeenSource(opts: DeenSourceOpts): DeenSource {
@@ -69,17 +70,36 @@ export function createDeenSource(opts: DeenSourceOpts): DeenSource {
let lastKey = "";
let lastFetchedAt = 0;
let geo: GeoInfo | null = null;
+ let lastHijriAttempt = 0;
+
+ // v0.6.0 Maghrib rollover: after the city's Maghrib minute the Islamic day has
+ // already turned, so the snapshot renders tomorrow's hijri — gToH(now+24h) in
+ // the city tz, cached per city-day via DeenCacheFile.tomorrowHijri, retried at
+ // most every HIJRI_RETRY_MS. A cached/degraded value short-circuits the network;
+ // a failed fetch keeps today's hijri (exactly the pre-rollover display).
+ const HIJRI_RETRY_MS = 5 * 60_000;
+ async function resolveHijri(data: DeenData, cachedTomorrow: string | undefined, allowFetch: boolean): Promise<{ hijri: string; tomorrow?: string }> {
+ if (!maghribRolloverActive(data.prayers, now(), data.timezone)) return { hijri: data.hijri };
+ if (cachedTomorrow) return { hijri: cachedTomorrow, tomorrow: cachedTomorrow };
+ if (!allowFetch) return { hijri: data.hijri };
+ const nowMs = now();
+ if (nowMs - lastHijriAttempt < HIJRI_RETRY_MS) return { hijri: data.hijri };
+ lastHijriAttempt = nowMs;
+ const tomorrow = await (opts.fetchHijri ?? fetchHijriForDate)(gToHDateParam(nowMs, data.timezone), opts.fetchFn);
+ if (!tomorrow) return { hijri: data.hijri };
+ return { hijri: tomorrow, tomorrow };
+ }
// P2-8: a defensive failure (e.g. an invalid IANA timezone reaching Intl inside
// computeSchedule) yields a null snapshot rather than crashing the render path.
- function toSnapshot(data: DeenData, city: string, staleMinutes: number | null, cfg: DeenSourceConfig): DeenSnapshot | null {
+ function toSnapshot(data: DeenData, city: string, staleMinutes: number | null, cfg: DeenSourceConfig, hijri: string): DeenSnapshot | null {
try {
const schedule = computeSchedule(data.prayers, now(), data.timezone);
const minutesUntilNext = schedule.find((e) => e.state === "next" || e.state === "adhan")?.minutesUntil ?? 0;
return {
schedule,
escalation: escalationState(minutesUntilNext, cfg.escalateMinutes),
- hijri: data.hijri,
+ hijri,
city,
timezone: data.timezone,
staleMinutes,
@@ -119,7 +139,16 @@ export function createDeenSource(opts: DeenSourceOpts): DeenSource {
const key = `${city}|${country}|${cfg.method}|${localDateKey(nowMs, keyTz)}`;
if (!force && cached && isDataFresh(cached, key, nowMs)) {
- snapshot = toSnapshot(cached.data, city, null, cfg);
+ const rolled = await resolveHijri(cached.data, cached.tomorrowHijri, true);
+ if (rolled.tomorrow && rolled.tomorrow !== cached.tomorrowHijri) {
+ // Persist the evening's gToH answer without resetting data freshness.
+ try {
+ saveDeenCache(opts.cachePath, { ...cached, tomorrowHijri: rolled.tomorrow });
+ } catch {
+ /* non-fatal; snapshot still serves */
+ }
+ }
+ snapshot = toSnapshot(cached.data, city, null, cfg, rolled.hijri);
lastKey = key;
lastFetchedAt = cached.fetchedAt;
return;
@@ -127,7 +156,14 @@ export function createDeenSource(opts: DeenSourceOpts): DeenSource {
const data = await fetchPrayer({ city, country, method: cfg.method, fetchImpl: opts.fetchFn });
if (data) {
- const file: DeenCacheFile = { key, fetchedAt: nowMs, data, ...(geo ? { geo } : cached?.geo ? { geo: cached.geo } : {}) };
+ const rolled = await resolveHijri(data, cached?.key === key ? cached.tomorrowHijri : undefined, true);
+ const file: DeenCacheFile = {
+ key,
+ fetchedAt: nowMs,
+ data,
+ ...(geo ? { geo } : cached?.geo ? { geo: cached.geo } : {}),
+ ...(rolled.tomorrow ? { tomorrowHijri: rolled.tomorrow } : {}),
+ };
// Non-fatal write: a locked-down cache dir (EACCES) or full disk degrades to
// serving the fresh snapshot from memory — refresh() must never reject here.
try {
@@ -137,7 +173,7 @@ export function createDeenSource(opts: DeenSourceOpts): DeenSource {
}
lastKey = key;
lastFetchedAt = nowMs;
- snapshot = toSnapshot(data, city, null, cfg);
+ snapshot = toSnapshot(data, city, null, cfg, rolled.hijri);
return;
}
@@ -147,7 +183,10 @@ export function createDeenSource(opts: DeenSourceOpts): DeenSource {
return;
}
if (cached && cached.data.timezone === keyTz) {
- snapshot = toSnapshot(cached.data, city, Math.floor((nowMs - cached.fetchedAt) / 60_000), cfg);
+ // Degraded: serve stale cache; the rollover only reuses a persisted value —
+ // no network attempts while the API is already failing.
+ const rolled = await resolveHijri(cached.data, cached.tomorrowHijri, false);
+ snapshot = toSnapshot(cached.data, city, Math.floor((nowMs - cached.fetchedAt) / 60_000), cfg, rolled.hijri);
return;
}
snapshot = null;
diff --git a/src/deen/time.ts b/src/deen/time.ts
index 5ccb0cf..b299ffe 100644
--- a/src/deen/time.ts
+++ b/src/deen/time.ts
@@ -69,3 +69,20 @@ export function escalationState(minutesUntilNext: number, escalateMinutes: numbe
if (minutesUntilNext <= escalateMinutes) return "soon";
return "calm";
}
+
+// v0.6.0 Maghrib rollover: the Islamic day traditionally begins at Maghrib, so the
+// displayed hijri date advances once the city's wall clock reaches the Maghrib
+// minute (not civil midnight). Pure decision — source.ts picks the rendered value.
+export function maghribRolloverActive(prayers: PrayerTimes, now: number, timezone: string): boolean {
+ return wallMinutes(now, timezone) >= parseWallMin(prayers.Maghrib);
+}
+
+// Gregorian DD-MM-YYYY of now+24h in the city tz — the gToH query for the hijri
+// day that begins at this evening's Maghrib. Only called while the rollover is
+// active: past Maghrib means past midday, so +24h lands on the next civil date
+// in every timezone (DST shifts of ±1h cannot cross back).
+export function gToHDateParam(now: number, timezone: string): string {
+ const fmt = new Intl.DateTimeFormat("en-GB", { timeZone: timezone, day: "2-digit", month: "2-digit", year: "numeric" });
+ const [d, m, y] = fmt.format(new Date(now + 86_400_000)).split("/");
+ return `${d}-${m}-${y}`;
+}
diff --git a/src/format.ts b/src/format.ts
index a83e681..3063eee 100644
--- a/src/format.ts
+++ b/src/format.ts
@@ -44,10 +44,10 @@ export function formatReset(targetMs: number, now: number): string {
}
// v0.5.0 absolute reset wall-clock (reset-time feature): same-day resets read as
-// clock time (`23:30`); anything further out carries an EN month-day (`Sep 12
-// 04:09`) — no weekday abbreviation (ambiguous when the reset lands on the
-// viewer's own weekday). Machine-local like infoLine's clock; hardcoded EN months
-// keep rendering deterministic (no Intl comma/platform variance).
+// clock time (`23:30`); anything further out carries an EN weekday + month-day
+// (`Sat Sep 12 04:09`, v0.6.0 per RECTOR — the original "no weekday" stance is
+// superseded). Machine-local like infoLine's clock; hardcoded EN tables keep
+// rendering deterministic (no Intl comma/platform variance).
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
export function formatResetAbs(targetMs: number, now: number): string {
@@ -58,7 +58,9 @@ export function formatResetAbs(targetMs: number, now: number): string {
t.getFullYear() === n.getFullYear() &&
t.getMonth() === n.getMonth() &&
t.getDate() === n.getDate();
- return sameDay ? formatClock(targetMs) : `${MONTHS[t.getMonth()]} ${String(t.getDate()).padStart(2, "0")} ${formatClock(targetMs)}`;
+ return sameDay
+ ? formatClock(targetMs)
+ : `${WEEKDAYS[t.getDay()]} ${MONTHS[t.getMonth()]} ${String(t.getDate()).padStart(2, "0")} ${formatClock(targetMs)}`;
}
// v0.5.1: Gregorian gloss beside the Hijri date in infoLine — `09 Sep 2026`.
@@ -68,3 +70,11 @@ export function formatGregorian(ts: number): string {
const d = new Date(ts);
return `${String(d.getDate()).padStart(2, "0")} ${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
}
+
+// v0.6.0: 3-letter EN weekday abbreviations, machine-local like MONTHS above
+// (deterministic, no Intl/platform variance).
+const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
+
+export function formatWeekday(ts: number): string {
+ return WEEKDAYS[new Date(ts).getDay()];
+}
diff --git a/src/index.ts b/src/index.ts
index 1e4c879..58cb708 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,7 +1,7 @@
// omp-statusline — 4-line belowEditor widget, RECTOR-approved 2026-09-07:
-// zai 5hrs 16%/26% (30m under · 3h 43m · 11:58) · 7DAY 33%/31% (3h 22m over · 4d 19h · Sep 12 03:30) ← provider-gated (zai only)
+// zai 5hrs 16%/26% (30m under · 3h 43m · 11:58) · 7DAY 33%/31% (3h 22m over · 4d 19h · Sat Sep 12 03:30) ← provider-gated (zai only)
// Fajr 04:33 ✓ · Dhuhr 11:51 (3h 36m) · Asr 15:07 · Maghrib 17:52 · Isha 19:01
-// 08:15 · 25 Rabīʿ al-awwal 1448 (07 Sep 2026) · Jakarta
+// Mon 08:15 · 25 Rabīʿ al-awwal 1448 (07 Sep 2026) · Jakarta
// REPO $68.36 · DAY $26.50 · 7DAY $315.27 · 30DAY $492.88
// Data layer vendored from @getpipher/pi-statusline (quota/zai, format, deen, adapters);
// money comes from the omp sessions disk-scan (money.ts — subagent-inclusive). State
@@ -14,7 +14,7 @@ import { readFileSync, mkdirSync } from "node:fs";
import { fetchQuota, readZaiKey, type QuotaLimit, type QuotaResult } from "./quota/zai.ts";
import { FIVE_HOUR_MS, WEEK_MS, windowElapsedPercent } from "./quota/project.ts";
-import { formatReset, formatResetAbs, formatGregorian } from "./format.ts";
+import { formatReset, formatResetAbs, formatGregorian, formatWeekday } from "./format.ts";
import { createDeenSource, type DeenSnapshot, type DeenSourceConfig } from "./deen/source.ts";
import { zaiStatusDetail } from "./adapters/zai.ts";
import { scanMoney, type MoneySnapshot } from "./money.ts";
@@ -214,7 +214,7 @@ export function prayerLine(theme: SlTheme, s: DeenSnapshot, sep: string): string
export function infoLine(theme: SlTheme, s: DeenSnapshot, now: number, sep: string): string {
const d = new Date(now);
- const clock = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
+ const clock = `${formatWeekday(now)} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
return ` ${theme.fg("dim", "")} ${[clock, `${s.hijri} (${formatGregorian(now)})`, s.city].map((part) => theme.fg("dim", part)).join(sep)}`;
}
diff --git a/test/deen.test.ts b/test/deen.test.ts
new file mode 100644
index 0000000..034ec78
--- /dev/null
+++ b/test/deen.test.ts
@@ -0,0 +1,182 @@
+// test/deen.test.ts — Maghrib-rollover data layer (v0.6.0)
+// All instants built via Date.UTC against Asia/Jakarta (fixed UTC+7, no DST) so
+// assertions hold in any CI timezone.
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { maghribRolloverActive, gToHDateParam } from "../src/deen/time.ts";
+import { fetchHijriForDate, parseTimingsResponse } from "../src/deen/api.ts";
+
+const JKT = "Asia/Jakarta"; // fixed UTC+7, no DST — machine-tz-independent instants
+// Date.UTC(2026, 8, 9, 11, 0) = Wed 18:00 Jakarta. +7h to the UTC hour = JKT wall time.
+const jkt = (utcHour: number, minute: number) => Date.UTC(2026, 8, 9, utcHour, minute);
+
+const PRAYERS = { Fajr: "04:33", Dhuhr: "11:51", Asr: "15:07", Maghrib: "18:00", Isha: "19:01" };
+
+test("maghribRolloverActive: flips at the Maghrib minute, city-tz based", () => {
+ assert.equal(maghribRolloverActive(PRAYERS, jkt(10, 59), JKT), false); // 17:59 — before Maghrib
+ assert.equal(maghribRolloverActive(PRAYERS, jkt(11, 0), JKT), true); // 18:00 — exact minute
+ assert.equal(maghribRolloverActive(PRAYERS, jkt(16, 30), JKT), true); // 23:30 JKT — deep night, same Islamic day
+});
+
+test("gToHDateParam: DD-MM-YYYY of now+24h in the city tz (gToH query format)", () => {
+ assert.equal(gToHDateParam(jkt(11, 1), JKT), "10-09-2026"); // 18:01 JKT Sep 9 → next civil day
+ assert.equal(gToHDateParam(jkt(16, 30), JKT), "10-09-2026"); // 23:30 JKT Sep 9 → still Sep 10
+ assert.equal(gToHDateParam(Date.UTC(2026, 8, 9, 12, 0), "UTC"), "10-09-2026"); // tz-generic
+});
+
+function stubFetch(body: string, ok = true): { impl: typeof fetch; url: () => string } {
+ const state: { lastUrl: string } = { lastUrl: "" };
+ const impl = (async (input: RequestInfo | URL) => {
+ state.lastUrl = String(input);
+ return { ok, status: ok ? 200 : 500, text: async () => body } as unknown as Response;
+ }) as unknown as typeof fetch;
+ return { impl, url: () => state.lastUrl };
+}
+
+const G_TO_H_BODY = JSON.stringify({
+ code: 200,
+ status: "OK",
+ data: { hijri: { date: "10-09-1448", day: "10", month: { number: 3, en: "Rabīʿ al-awwal" }, year: "1448" } },
+});
+
+test("fetchHijriForDate: gToH envelope → hijri string; hits the documented URL", async () => {
+ const { impl, url } = stubFetch(G_TO_H_BODY);
+ assert.equal(await fetchHijriForDate("10-09-2026", impl), "10 Rabīʿ al-awwal 1448");
+ assert.equal(url(), "https://api.aladhan.com/v1/gToH?date=10-09-2026");
+});
+
+test("fetchHijriForDate: non-200, bad envelope, malformed json, HTTP failure → null", async () => {
+ const notOk = stubFetch(G_TO_H_BODY, false);
+ assert.equal(await fetchHijriForDate("10-09-2026", notOk.impl), null);
+ const badCode = stubFetch(JSON.stringify({ code: 400, status: "BAD", data: {} }));
+ assert.equal(await fetchHijriForDate("10-09-2026", badCode.impl), null);
+ const noHijri = stubFetch(JSON.stringify({ code: 200, status: "OK", data: {} }));
+ assert.equal(await fetchHijriForDate("10-09-2026", noHijri.impl), null);
+ const garbage = stubFetch("");
+ assert.equal(await fetchHijriForDate("10-09-2026", garbage.impl), null);
+});
+
+// Refactor guard: parseTimingsResponse keeps its contract once hijri extraction
+// moves into the shared parseHijri helper (reused by fetchHijriForDate).
+test("parseTimingsResponse: hijri extraction intact — day, month.en, year", () => {
+ const body = JSON.stringify({
+ code: 200,
+ status: "OK",
+ data: {
+ timings: PRAYERS,
+ meta: { timezone: JKT },
+ date: { hijri: { day: "25", month: { en: "Rabīʿ al-awwal" }, year: "1448" } },
+ },
+ });
+ const data = parseTimingsResponse(body);
+ assert.ok(data);
+ assert.equal(data.hijri, "25 Rabīʿ al-awwal 1448");
+ assert.equal(data.timezone, JKT);
+ assert.equal(data.prayers.Maghrib, "18:00");
+});
+
+// --- source integration: rollover wiring (cache + fetch seams injected) -------
+import { createDeenSource } from "../src/deen/source.ts";
+import { saveDeenCache, type DeenCacheFile } from "../src/deen/cache.ts";
+import { mkdtempSync, readFileSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const DATA = { prayers: PRAYERS, timezone: JKT, hijri: "25 Rabīʿ al-awwal 1448" };
+const TOMORROW = "26 Rabīʿ al-awwal 1448";
+const CFG = { city: "Jakarta", country: "Indonesia", method: "auto", escalateMinutes: 15 };
+const KEY = "Jakarta|Indonesia|auto|2026-09-09"; // city|country|method|localDateKey (18:01 JKT)
+
+function withCacheDir(run: (dir: string) => Promise): Promise {
+ const dir = mkdtempSync(join(tmpdir(), "deen-test-"));
+ return run(dir).finally(() => rmSync(dir, { recursive: true, force: true }));
+}
+
+test("source: past Maghrib the snapshot hijri advances to tomorrow; value persists", () =>
+ withCacheDir(async (dir) => {
+ let hijriCalls = 0;
+ const src = createDeenSource({
+ cachePath: join(dir, "cache.json"),
+ config: () => CFG,
+ now: () => jkt(11, 1), // 18:01 JKT — 1 minute past Maghrib
+ fetchPrayer: async () => DATA,
+ fetchHijri: async () => { hijriCalls++; return TOMORROW; },
+ });
+ await src.refresh();
+ assert.equal(src.current()?.hijri, TOMORROW);
+ assert.equal(hijriCalls, 1);
+ const cached = JSON.parse(readFileSync(join(dir, "cache.json"), "utf8")) as DeenCacheFile;
+ assert.equal(cached.tomorrowHijri, TOMORROW);
+ }));
+
+test("source: failed gToH keeps today's hijri; retries throttle to 5 min", () =>
+ withCacheDir(async (dir) => {
+ let nowMs = jkt(11, 1);
+ let hijriCalls = 0;
+ const src = createDeenSource({
+ cachePath: join(dir, "cache.json"),
+ config: () => CFG,
+ now: () => nowMs,
+ fetchPrayer: async () => DATA,
+ fetchHijri: async () => { hijriCalls++; return null; },
+ });
+ await src.refresh();
+ assert.equal(src.current()?.hijri, "25 Rabīʿ al-awwal 1448");
+ await src.refresh(); // seconds later — throttled, no retry
+ assert.equal(hijriCalls, 1);
+ nowMs = jkt(11, 1) + 6 * 60_000; // 6 min later — retry allowed
+ await src.refresh();
+ assert.equal(hijriCalls, 2);
+ }));
+
+test("source: before Maghrib today's hijri stands and gToH is never called", () =>
+ withCacheDir(async (dir) => {
+ let hijriCalls = 0;
+ const src = createDeenSource({
+ cachePath: join(dir, "cache.json"),
+ config: () => CFG,
+ now: () => jkt(10, 59), // 17:59 JKT — one minute before Maghrib
+ fetchPrayer: async () => DATA,
+ fetchHijri: async () => { hijriCalls++; return TOMORROW; },
+ });
+ await src.refresh();
+ assert.equal(src.current()?.hijri, "25 Rabīʿ al-awwal 1448");
+ assert.equal(hijriCalls, 0);
+ }));
+
+test("source: cached tomorrowHijri on the fresh path skips the gToH network", () =>
+ withCacheDir(async (dir) => {
+ const file: DeenCacheFile = { key: KEY, fetchedAt: jkt(11, 0), data: DATA, tomorrowHijri: TOMORROW };
+ saveDeenCache(join(dir, "cache.json"), file);
+ let hijriCalls = 0;
+ const src = createDeenSource({
+ cachePath: join(dir, "cache.json"),
+ config: () => CFG,
+ now: () => jkt(11, 1),
+ fetchPrayer: async () => DATA,
+ fetchHijri: async () => { hijriCalls++; return TOMORROW; },
+ });
+ await src.refresh();
+ assert.equal(src.current()?.hijri, TOMORROW);
+ assert.equal(hijriCalls, 0);
+ }));
+
+test("source: degraded refresh keeps the rolled-over hijri without new gToH calls", () =>
+ withCacheDir(async (dir) => {
+ let hijriCalls = 0;
+ let prayersFail = false;
+ const src = createDeenSource({
+ cachePath: join(dir, "cache.json"),
+ config: () => CFG,
+ now: () => jkt(11, 1),
+ fetchPrayer: async () => (prayersFail ? null : DATA),
+ fetchHijri: async () => { hijriCalls++; return TOMORROW; },
+ });
+ await src.refresh(); // healthy — rolls over, seeds cache
+ assert.equal(src.current()?.hijri, TOMORROW);
+ prayersFail = true;
+ await src.refresh(true); // forced refetch fails → last-good with stale marker
+ assert.equal(src.current()?.hijri, TOMORROW);
+ assert.ok(src.current()?.staleMinutes !== null);
+ assert.equal(hijriCalls, 1);
+ }));
diff --git a/test/lines.test.ts b/test/lines.test.ts
index 9d2aedd..60f8eee 100644
--- a/test/lines.test.ts
+++ b/test/lines.test.ts
@@ -7,7 +7,7 @@ import type { SlTheme } from "../src/index.ts";
import type { DeenSnapshot } from "../src/deen/source.ts";
import type { PrayerScheduleEntry } from "../src/deen/time.ts";
import type { QuotaResult } from "../src/quota/zai.ts";
-import { formatResetAbs, formatGregorian } from "../src/format.ts";
+import { formatResetAbs, formatGregorian, formatWeekday } from "../src/format.ts";
const theme: SlTheme = { fg: (token, text) => `<${token}>${text}>` };
const sep = theme.fg("dim", " · ");
@@ -62,7 +62,7 @@ test("zaiLine: percents keep heat; paren = pace · countdown · absolute; lowerc
assert.ok(zaiLine(theme, data(105), now, sep).includes("5hrs 100%+/80%"));
const both: QuotaResult = { tier: "pro", fiveHour: fiveHour(16), weekly: { ...fiveHour(24), nextResetTime: now + 3 * DAY + 18 * HOUR }, fetchedAt: now };
assert.ok(zaiLine(theme, both, now, sep).includes("> · >"));
- assert.ok(zaiLine(theme, both, now, sep).includes(" · Sep 13 04:00>")); // cross-day → month-day form
+ assert.ok(zaiLine(theme, both, now, sep).includes(" · Sun Sep 13 04:00>")); // cross-day → weekday month-day form
const none: QuotaResult = { tier: "pro", fiveHour: null, weekly: null, fetchedAt: now };
assert.equal(zaiLine(theme, none, now, sep), " zai — no quota windows>");
});
@@ -70,11 +70,11 @@ test("zaiLine: percents keep heat; paren = pace · countdown · absolute; lowerc
test("formatResetAbs: same-day clock, cross-day month-day, past → now", () => {
const now = new Date(2026, 8, 9, 10, 0).getTime(); // Wed 09 Sep 2026 10:00 local — DST-edge-free
assert.equal(formatResetAbs(now + HOUR, now), "11:00");
- assert.equal(formatResetAbs(now + 20 * HOUR, now), "Sep 10 06:00");
+ assert.equal(formatResetAbs(now + 20 * HOUR, now), "Thu Sep 10 06:00");
assert.equal(formatResetAbs(now - 60_000, now), "now");
const lateNight = new Date(2026, 8, 4, 23, 0).getTime(); // Fri 04 Sep 23:00 local
assert.equal(formatResetAbs(lateNight + 59 * 60_000, lateNight), "23:59"); // same-day branch near midnight
- assert.equal(formatResetAbs(lateNight + 2 * HOUR, lateNight), "Sep 05 01:00"); // crosses midnight → padded single-digit day
+ assert.equal(formatResetAbs(lateNight + 2 * HOUR, lateNight), "Sat Sep 05 01:00"); // crosses midnight → weekday + padded single-digit day
});
test("formatGregorian: zero-padded day, EN month from local date, year", () => {
@@ -84,6 +84,13 @@ test("formatGregorian: zero-padded day, EN month from local date, year", () => {
assert.equal(formatGregorian(new Date(2027, 0, 1).getTime()), "01 Jan 2027"); // year boundary
});
+test("formatWeekday: 3-letter EN abbrev from the local date", () => {
+ assert.equal(formatWeekday(new Date(2026, 8, 7).getTime()), "Mon"); // 07 Sep 2026
+ assert.equal(formatWeekday(new Date(2026, 8, 12).getTime()), "Sat"); // 12 Sep 2026
+ assert.equal(formatWeekday(new Date(2026, 8, 13).getTime()), "Sun"); // 13 Sep 2026
+ assert.equal(formatWeekday(new Date(2026, 8, 6).getTime()), "Sun"); // week wraps on Sunday
+});
+
test("paceText: gap = window × Δ%/100; formats and over/under tokens", () => {
const H = 3_600_000;
assert.deepEqual(paceText(5 * H, 34, 74), { text: "2h 0m under", token: "success" }); // RECTOR's worked example
@@ -104,7 +111,7 @@ test("infoLine: clock · hijri (gregorian in parens) · city, all dim", () => {
const now = new Date(2026, 8, 7, 8, 15, 3).getTime(); // local-time construction → 08:15 in any tz
assert.equal(
infoLine(theme, deen(SCHEDULE), now, sep),
- " > 08:15> · >25 Rabīʿ al-awwal 1448 (07 Sep 2026)> · >Jakarta>",
+ " > Mon 08:15> · >25 Rabīʿ al-awwal 1448 (07 Sep 2026)> · >Jakarta>",
);
});