Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
4 changes: 2 additions & 2 deletions assets/hero.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
51 changes: 40 additions & 11 deletions src/deen/api.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -15,32 +16,42 @@ 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 {
return null;
}
if (parsed?.code !== 200 || parsed?.status !== "OK" || !parsed.data) return null;

const timings = parsed.data.timings as Record<string, unknown> | 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<string, unknown>; // 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 {
Expand All @@ -64,3 +75,21 @@ export async function fetchPrayerTimes(opts: FetchOpts): Promise<DeenData | null
return null;
}
}

// v0.6.0 Maghrib rollover: tomorrow's hijri date for the evening display. gToH
// keeps Aladhan the single calendar authority (same hijri shape as timingsByCity);
// fails soft (null) — callers fall back to today's hijri and retry later.
export async function fetchHijriForDate(dateParam: string, fetchImpl?: typeof fetch): Promise<string | null> {
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;
}
}
3 changes: 3 additions & 0 deletions src/deen/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
55 changes: 47 additions & 8 deletions src/deen/source.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -59,6 +59,7 @@ export interface DeenSourceOpts {
geoFetchFn?: typeof fetch;
fetchPrayer?: typeof fetchPrayerTimes;
fetchGeo?: (fetchImpl?: typeof fetch) => Promise<GeoInfo | null>;
fetchHijri?: typeof fetchHijriForDate;
}

export function createDeenSource(opts: DeenSourceOpts): DeenSource {
Expand All @@ -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,
Expand Down Expand Up @@ -119,15 +139,31 @@ 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;
}

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 {
Expand All @@ -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;
}

Expand All @@ -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;
Expand Down
17 changes: 17 additions & 0 deletions src/deen/time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}
20 changes: 15 additions & 5 deletions src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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`.
Expand All @@ -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()];
}
Loading
Loading