From 7e69d9a3b6070d947c5110875a5208a7143a36b1 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Wed, 9 Sep 2026 21:42:40 +0700 Subject: [PATCH 1/3] feat: weekday on info clock and cross-day reset date --- README.md | 2 +- assets/hero.svg | 4 ++-- src/format.ts | 20 +++++++++++++++----- src/index.ts | 8 ++++---- test/lines.test.ts | 17 ++++++++++++----- 5 files changed, 34 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 6127cbf..cf0a80f 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. | | **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/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/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", ); }); From 34f5687ecfea0047148117a417ee24329ed9d990 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Wed, 9 Sep 2026 21:50:22 +0700 Subject: [PATCH 2/3] feat: hijri date rolls over at Maghrib (Aladhan gToH, cached per city-day) --- README.md | 2 +- src/deen/api.ts | 51 ++++++++++--- src/deen/cache.ts | 3 + src/deen/source.ts | 55 ++++++++++++-- src/deen/time.ts | 17 +++++ test/deen.test.ts | 182 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 290 insertions(+), 20 deletions(-) create mode 100644 test/deen.test.ts diff --git a/README.md b/README.md index cf0a80f..8d21cea 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Render order (top → bottom): **info · prayers · money · zai**. | Line | Contents | |---|---| -| **info** | Local weekday + clock — `Wed 21:12` · 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/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/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); + })); From eb8f6885ff5350a0ad67da3efe5cb1329246778a Mon Sep 17 00:00:00 2001 From: RECTOR Date: Wed, 9 Sep 2026 21:50:45 +0700 Subject: [PATCH 3/3] chore: bump version to 0.6.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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",