SALAH 0.2: prayer times for Casablanca (Morocco method), chord-tapered round layout
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
build/
|
||||
dist/
|
||||
sign/*.pem
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
Generated
+10223
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "xiaomi-vela-s4-hello-world",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Minimal Xiaomi Vela JS Hello World app for Xiaomi Watch S4",
|
||||
"scripts": {
|
||||
"build": "aiot build",
|
||||
"release": "aiot release",
|
||||
"build:source-map": "aiot build --devtool=source-map",
|
||||
"help": "aiot build -h"
|
||||
},
|
||||
"devDependencies": {
|
||||
"aiot-toolkit": "2.0.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { margin: 0; padding: 20px; background: #16161e; font-family: sans-serif; display: flex; gap: 30px; align-items: center; }
|
||||
.watch {
|
||||
width: 466px; height: 466px; border-radius: 233px;
|
||||
background: #0a0d18; position: relative; overflow: hidden; flex-shrink: 0;
|
||||
box-shadow: 0 0 0 8px #222, 0 10px 40px rgba(0,0,0,.6);
|
||||
}
|
||||
.city { position: absolute; top: 18px; width: 100%; text-align: center; color: #8f95c7; font-size: 18px; letter-spacing: 2px; }
|
||||
.clock { position: absolute; top: 44px; width: 100%; text-align: center; color: #fff; font-size: 44px; }
|
||||
.banner {
|
||||
position: absolute; top: 116px; left: 68px; width: 330px; height: 64px; border-radius: 32px;
|
||||
background: #6d4fd4; display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
}
|
||||
.bannerLabel { color: #d9d2ff; font-size: 22px; }
|
||||
.bannerTime { color: #fff; font-size: 26px; font-weight: bold; }
|
||||
.row {
|
||||
position: absolute; left: 53px; width: 360px; height: 44px; border-radius: 22px;
|
||||
background: #161a2e; display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 0 22px; box-sizing: border-box;
|
||||
}
|
||||
.rowName { color: #a7acce; font-size: 21px; }
|
||||
.rowTime { color: #fff; font-size: 21px; }
|
||||
.rowNext { background: #2b2460; border: 1px solid #6d4fd4; }
|
||||
.nameNext { color: #b9a8ff; font-weight: bold; }
|
||||
.caption { color: #8888aa; font-size: 14px; max-width: 220px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="watch">
|
||||
<div class="city">CASABLANCA</div>
|
||||
<div class="clock">17:05</div>
|
||||
<div class="banner"><span class="bannerLabel">ASR in</span><span class="bannerTime">2h 5m</span></div>
|
||||
<div class="row" style="top:196px"><span class="rowName">FAJR</span><span class="rowTime">05:40</span></div>
|
||||
<div class="row" style="top:245px"><span class="rowName">SHURUK</span><span class="rowTime">07:10</span></div>
|
||||
<div class="row" style="top:294px"><span class="rowName">DHUHR</span><span class="rowTime">13:33</span></div>
|
||||
<div class="row rowNext" style="top:343px"><span class="rowName nameNext">ASR</span><span class="rowTime">17:01</span></div>
|
||||
<div class="row" style="top:392px"><span class="rowName">MAGHRIB</span><span class="rowTime">19:51</span></div>
|
||||
<div class="row" style="top:441px"><span class="rowName">ISHA</span><span class="rowTime">21:05</span></div>
|
||||
</div>
|
||||
<div class="caption">SALAH 0.1 — round-screen layout preview (466px). Rows at top 196→441px; chord width at row 441 (y 441-485, dy=208 from center) is ~2·√(233²−208²)≈209px — the 360px row exceeds it. <b>This preview intentionally shows the clipping risk at the bottom row.</b></div>
|
||||
</body>
|
||||
</html>
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<script>
|
||||
export default {
|
||||
onCreate() {
|
||||
console.log('Salah app created')
|
||||
},
|
||||
onDestroy() {
|
||||
console.log('Salah app destroyed')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,100 @@
|
||||
// Prayer time computation — pure functions, no Vela APIs.
|
||||
// Method: Morocco (Ministry of Habous) — Fajr 19°, Isha 17°, Standard Asr (shadow 1x),
|
||||
// Maghrib = sunset +5 min, Dhuhr = solar noon +5 min (matches AlAdhan method 21 offsets).
|
||||
// All times returned as minutes since local midnight.
|
||||
const RAD = Math.PI / 180
|
||||
const DEG = 180 / Math.PI
|
||||
|
||||
// Low-precision solar vector (Meeus) — declination (deg), right ascension (deg), GMST (hours)
|
||||
function sunVector(date) {
|
||||
const jd = date.getTime() / 86400000 + 2440587.5
|
||||
const n = jd - 2451545.0
|
||||
const L = (280.460 + 0.9856474 * n) % 360
|
||||
const g = ((357.528 + 0.9856003 * n) % 360) * RAD
|
||||
const lambda = (L + 1.915 * Math.sin(g) + 0.020 * Math.sin(2 * g)) * RAD
|
||||
const eps = (23.439 - 0.0000004 * n) * RAD
|
||||
const declination = Math.asin(Math.sin(eps) * Math.sin(lambda)) * DEG
|
||||
const rightAscension = Math.atan2(Math.cos(eps) * Math.sin(lambda), Math.cos(lambda)) * DEG
|
||||
const gmst = (18.697374558 + 24.06570982441908 * n) % 24
|
||||
return { declination, rightAscension: (rightAscension + 360) % 360, gmst: (gmst + 24) % 24 }
|
||||
}
|
||||
|
||||
const PRAYERS = ['fajr', 'sunrise', 'dhuhr', 'asr', 'maghrib', 'isha']
|
||||
|
||||
// `date` — any Date on the local civil day; uses its UTC date parts as the local date.
|
||||
// `tzHours` — local UTC offset (Casablanca: 1). Returns minutes-since-local-midnight map.
|
||||
function times(date, latDeg, lonDeg, tzHours) {
|
||||
const midnightUTC = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
|
||||
const anchorH = 12 // anchor instant: 12:00 UTC
|
||||
const sv = sunVector(new Date(midnightUTC + anchorH * 3600e3))
|
||||
// transit: hour angle = 0 ⇔ gmst*15 + lon == RA; gmst advances 1.002737909 h per clock hour.
|
||||
// Delta measured from the anchor instant; works for any evaluation time `evalH` because
|
||||
// gmst_at_anchor is reconstructed from the vector's own gmst. Wraps to [-12h, +12h).
|
||||
const transitFrom = (svec, evalH) => {
|
||||
const neededGmst = ((svec.rightAscension - lonDeg) / 15 + 24) % 24
|
||||
const gmstAtAnchor = svec.gmst - 1.002737909 * (evalH - anchorH)
|
||||
const deltaH = (((neededGmst - gmstAtAnchor + 36) % 24) - 12) / 1.002737909
|
||||
return anchorH + deltaH // UTC hours after midnight
|
||||
}
|
||||
let transitUTC = transitFrom(sv, anchorH)
|
||||
// one refinement pass at the transit instant (RA moves ~1°/day)
|
||||
const sv2 = sunVector(new Date(midnightUTC + transitUTC * 3600e3))
|
||||
transitUTC = transitFrom(sv2, transitUTC)
|
||||
const transitLocal = transitUTC + tzHours // local hours
|
||||
|
||||
const lat = latDeg * RAD
|
||||
const dec = sv2.declination * RAD
|
||||
const cosH = (altDeg) => {
|
||||
const c = (Math.sin(altDeg * RAD) - Math.sin(lat) * Math.sin(dec)) / (Math.cos(lat) * Math.cos(dec))
|
||||
return Math.max(-1, Math.min(1, c))
|
||||
}
|
||||
// local hours for a given altitude; NaN when the sun never reaches it (polar day/night).
|
||||
// rise=true → transit − H (morning), rise=false → transit + H (afternoon)
|
||||
const timeAt = (altDeg, rise) => {
|
||||
const c = cosH(altDeg)
|
||||
if (Math.abs(Math.sin(altDeg * RAD) - Math.sin(lat) * Math.sin(dec)) > Math.cos(lat) * Math.cos(dec)) return NaN
|
||||
const h = (Math.acos(c) * DEG) / 15
|
||||
return rise ? transitLocal - h : transitLocal + h
|
||||
}
|
||||
// Asr: altitude where shadow length = shadow factor x object height
|
||||
const asrAlt = (shadow) => Math.atan(1 / (shadow + Math.tan(Math.abs(latDeg - sv2.declination) * RAD))) * DEG
|
||||
|
||||
const sunset = timeAt(-0.833, false)
|
||||
return {
|
||||
fajr: timeAt(-19, true) * 60,
|
||||
sunrise: timeAt(-0.833, true) * 60,
|
||||
dhuhr: transitLocal * 60 + 5,
|
||||
asr: timeAt(asrAlt(1), false) * 60,
|
||||
maghrib: sunset * 60 + 5,
|
||||
isha: timeAt(-17, false) * 60
|
||||
}
|
||||
}
|
||||
|
||||
// minutes -> 'HH:MM' (wraps values past 24h back into the next morning)
|
||||
function format(minutes) {
|
||||
const total = ((Math.round(minutes) % 1440) + 1440) % 1440
|
||||
const h = Math.floor(total / 60)
|
||||
const m = total % 60
|
||||
return (h < 10 ? '0' : '') + h + ':' + (m < 10 ? '0' : '') + m
|
||||
}
|
||||
|
||||
// Next prayer after `nowMin` (minutes since local midnight). Returns
|
||||
// { key, minutesLeft, nextDay, current } — sunrise is included as SHURUK.
|
||||
function nextPrayer(t, nowMin) {
|
||||
let current = null
|
||||
for (const key of PRAYERS) {
|
||||
if (t[key] > nowMin) {
|
||||
return { key, minutesLeft: t[key] - nowMin, nextDay: false, current }
|
||||
}
|
||||
current = key
|
||||
}
|
||||
return { key: 'fajr', minutesLeft: 1440 - nowMin + t.fajr, nextDay: true, current: 'isha' }
|
||||
}
|
||||
|
||||
function formatCountdown(minutes) {
|
||||
const m = Math.max(0, Math.round(minutes))
|
||||
if (m < 60) return m + 'm'
|
||||
return Math.floor(m / 60) + 'h ' + (m % 60) + 'm'
|
||||
}
|
||||
|
||||
module.exports = { times, format, nextPrayer, formatCountdown, PRAYERS }
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"package": "com.ilias.salah",
|
||||
"name": "SALAH",
|
||||
"icon": "/common/icon.png",
|
||||
"versionName": "0.2",
|
||||
"versionCode": 2,
|
||||
"minAPILevel": 1,
|
||||
"deviceTypeList": [
|
||||
"watch"
|
||||
],
|
||||
"config": {
|
||||
"logLevel": "debug",
|
||||
"designWidth": 466
|
||||
},
|
||||
"router": {
|
||||
"entry": "pages/index",
|
||||
"pages": {
|
||||
"pages/index": {
|
||||
"component": "index",
|
||||
"path": "/"
|
||||
}
|
||||
}
|
||||
},
|
||||
"display": {
|
||||
"backgroundColor": "#0a0d18"
|
||||
},
|
||||
"features": [
|
||||
{
|
||||
"name": "system.router"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<!-- header: city + clock -->
|
||||
<text class="city">CASABLANCA</text>
|
||||
<text class="clock">{{ clock }}</text>
|
||||
<!-- next-prayer banner -->
|
||||
<div class="banner">
|
||||
<text class="bannerLabel">{{ nextName }} in</text>
|
||||
<text class="bannerTime">{{ countdown }}</text>
|
||||
</div>
|
||||
<!-- prayer rows: width tapers with the chord via per-row bound style (sundial markerStyle pattern) -->
|
||||
<div class="row {{ row.key === nextKey ? 'rowNext' : '' }} {{ row.passed ? 'rowPassed' : '' }}"
|
||||
style="{{ row.rowStyle }}" for="{{ row in rows }}" tid="key">
|
||||
<text class="rowName {{ row.key === nextKey ? 'nameNext' : '' }}">{{ row.label }}</text>
|
||||
<text class="rowTime {{ row.key === nextKey ? 'timeNext' : '' }}">{{ row.time }}</text>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import prayer from '../../common/prayer.js'
|
||||
|
||||
// Casablanca
|
||||
const LAT = 33.5731
|
||||
const LON = -7.5898
|
||||
const TZ = 1 // Africa/Casablanca is UTC+1 year-round (no DST since 2018)
|
||||
|
||||
const LABELS = {
|
||||
fajr: 'FAJR',
|
||||
sunrise: 'SHURUK',
|
||||
dhuhr: 'DHUHR',
|
||||
asr: 'ASR',
|
||||
maghrib: 'MAGHRIB',
|
||||
isha: 'ISHA'
|
||||
}
|
||||
|
||||
// chord-tapered row widths (v0.2 round-screen layout): safe = chord - 2*10px inset
|
||||
const ROW_WIDTHS = [380, 412, 424, 406, 368, 304]
|
||||
|
||||
export default {
|
||||
private: {
|
||||
clock: '--:--',
|
||||
countdown: '',
|
||||
nextKey: 'fajr',
|
||||
nextName: 'FAJR',
|
||||
rows: []
|
||||
},
|
||||
onInit() {},
|
||||
onShow() {
|
||||
this.update()
|
||||
this.timer = setInterval(() => this.update(), 15000) // 15s cadence
|
||||
},
|
||||
onHide() {
|
||||
clearInterval(this.timer)
|
||||
},
|
||||
onDestroy() {
|
||||
clearInterval(this.timer)
|
||||
},
|
||||
update() {
|
||||
const now = new Date()
|
||||
const hh = String(now.getHours()).padStart(2, '0')
|
||||
const mm = String(now.getMinutes()).padStart(2, '0')
|
||||
this.clock = hh + ':' + mm
|
||||
|
||||
const t = prayer.times(now, LAT, LON, TZ)
|
||||
const nowMin = now.getHours() * 60 + now.getMinutes() + now.getSeconds() / 60
|
||||
const next = prayer.nextPrayer(t, nowMin)
|
||||
|
||||
this.nextKey = next.key
|
||||
this.nextName = LABELS[next.key]
|
||||
this.countdown = next.nextDay ? '+1d ' + prayer.formatCountdown(next.minutesLeft) : prayer.formatCountdown(next.minutesLeft)
|
||||
|
||||
this.rows = prayer.PRAYERS.map((key, i) => ({
|
||||
key,
|
||||
label: LABELS[key],
|
||||
time: prayer.format(t[key]),
|
||||
passed: t[key] < nowMin,
|
||||
rowStyle: { width: ROW_WIDTHS[i] + 'px' }
|
||||
}))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.page {
|
||||
width: 466px;
|
||||
height: 466px;
|
||||
background-color: #0a0d18;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.city {
|
||||
height: 20px;
|
||||
color: #8f95c7;
|
||||
font-size: 15px;
|
||||
letter-spacing: 2px;
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.clock {
|
||||
height: 40px;
|
||||
color: #ffffff;
|
||||
font-size: 34px;
|
||||
text-align: center;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.banner {
|
||||
width: 300px;
|
||||
height: 56px;
|
||||
border-radius: 28px;
|
||||
background-color: #6d4fd4;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.bannerLabel {
|
||||
color: #d9d2ff;
|
||||
font-size: 20px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.bannerTime {
|
||||
color: #ffffff;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.row {
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
background-color: #161a2e;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
.rowNext {
|
||||
background-color: #2b2460;
|
||||
border-width: 1px;
|
||||
border-color: #6d4fd4;
|
||||
}
|
||||
.rowPassed {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.rowName {
|
||||
color: #a7acce;
|
||||
font-size: 20px;
|
||||
}
|
||||
.nameNext {
|
||||
color: #b9a8ff;
|
||||
font-weight: bold;
|
||||
}
|
||||
.rowTime {
|
||||
color: #ffffff;
|
||||
font-size: 20px;
|
||||
}
|
||||
.timeNext {
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
import zipfile, io, json
|
||||
from PIL import Image
|
||||
|
||||
z = zipfile.ZipFile('/home/iliasmohuchine/vela-salah/dist/com.ilias.salah.release.0.1.rpk')
|
||||
names = z.namelist()
|
||||
print('entries:', names)
|
||||
if 'common/icon.png' in names:
|
||||
im = Image.open(io.BytesIO(z.read('common/icon.png')))
|
||||
print('icon in rpk:', im.size, im.mode)
|
||||
mf = json.loads(z.read('manifest.json'))
|
||||
print('manifest:', mf['package'], mf['versionName'], mf['versionCode'])
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert the user-supplied mosque icon art to a 192x192 RGB Vela app icon.
|
||||
|
||||
Proven pipeline (from the sundial v0.5 icon): detect the round disc bounds on the
|
||||
mid row/column of the dark-square source, crop square around disc center, apply an
|
||||
anti-aliased circular alpha mask (drawn at 4x, LANCZOS down), resize to 192x192,
|
||||
flatten to RGB over the dark background color.
|
||||
"""
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
SRC = "/home/iliasmohuchine/Downloads/ChatGPT Image Sep 9, 2026, 04_42_51 PM.png"
|
||||
OUT = "/home/iliasmohuchine/vela-salah/src/common/icon.png"
|
||||
|
||||
im = Image.open(SRC).convert("RGB")
|
||||
w, h = im.size
|
||||
print("source:", im.size)
|
||||
|
||||
# detect disc bounds on mid row / mid column (background ~ rgb(17,17,24) dark)
|
||||
px = im.load()
|
||||
midy, midx = h // 2, w // 2
|
||||
BG = px[2, 2]
|
||||
print("bg color:", BG)
|
||||
|
||||
def is_fg(p):
|
||||
return abs(p[0] - BG[0]) + abs(p[1] - BG[1]) + abs(p[2] - BG[2]) > 40
|
||||
|
||||
row = [x for x in range(w) if is_fg(px[x, midy])]
|
||||
col = [y for y in range(h) if is_fg(px[midx, y])]
|
||||
left, right = min(row), max(row)
|
||||
top, bottom = min(col), max(col)
|
||||
print("disc bounds: x", left, right, " y", top, bottom)
|
||||
|
||||
# square crop around disc center, sized to the larger span
|
||||
cx, cy = (left + right) / 2, (top + bottom) / 2
|
||||
span = max(right - left, bottom - top)
|
||||
half = span / 2 + 2
|
||||
box = (int(cx - half), int(cy - half), int(cx + half), int(cy + half))
|
||||
disc = im.crop(box)
|
||||
print("crop box:", box, "disc size:", disc.size)
|
||||
|
||||
# circular alpha mask at 4x
|
||||
size = disc.size[0]
|
||||
mask = Image.new("L", (size * 4, size * 4), 0)
|
||||
d = ImageDraw.Draw(mask)
|
||||
d.ellipse((0, 0, size * 4 - 1, size * 4 - 1), fill=255)
|
||||
mask = mask.resize((size, size), Image.LANCZOS)
|
||||
|
||||
out = disc.convert("RGBA")
|
||||
out.putalpha(mask)
|
||||
out = out.resize((192, 192), Image.LANCZOS)
|
||||
|
||||
# flatten to RGB over dark bg
|
||||
bg = Image.new("RGB", (192, 192), BG)
|
||||
bg.paste(out, (0, 0), out)
|
||||
bg.save(OUT, "PNG")
|
||||
print("saved", OUT, bg.size, bg.mode)
|
||||
|
||||
# side-by-side montage for preview: source | converted
|
||||
src_small = im.copy()
|
||||
src_small.thumbnail((192, 192))
|
||||
mont = Image.new("RGB", (192 * 2 + 24, 192), (17, 17, 24))
|
||||
mont.paste(src_small, (0, 0))
|
||||
mont.paste(bg, (192 + 24, 0))
|
||||
mont.save("/home/iliasmohuchine/vela-salah/icon-preview.png")
|
||||
print("montage saved")
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
// Page wiring tests — extract the <script> from index.ux, run it in a VM, drive update().
|
||||
'use strict'
|
||||
const assert = require('assert/strict')
|
||||
const fs = require('fs')
|
||||
const vm = require('vm')
|
||||
|
||||
const src = fs.readFileSync(__dirname + '/../src/pages/index/index.ux', 'utf8')
|
||||
const m = src.match(/<script>([\s\S]*?)<\/script>/)
|
||||
assert.ok(m, 'index.ux has a <script> block')
|
||||
let body = m[1]
|
||||
|
||||
// strip the import (prayer module provided via sandbox), replace `export default` with a spec capture
|
||||
body = body.replace(/import\s+\w+\s+from\s+'[^']*';?/, '')
|
||||
body = body.replace(/export\s+default/, 'globalThis.spec =')
|
||||
// strip comments FIRST so trailing // comments don't pollute const spans
|
||||
body = body.replace(/\/\/[^\n]*/g, '')
|
||||
|
||||
// strip module-level const declarations and re-inject them as sandbox globals
|
||||
// (skill pitfall: a top-level const breaks the `(body)` wrap).
|
||||
// replace() can only remove the MATCH span, so rebuild the string manually:
|
||||
// skip [matchStart, exprEnd) for each `const NAME = <literal>` at line start.
|
||||
const constDecls = {}
|
||||
{
|
||||
const re = /^const\s+(\w+)\s*=\s*/gm
|
||||
const matches = [...body.matchAll(re)]
|
||||
let out = ''
|
||||
let pos = 0
|
||||
for (const mt of matches) {
|
||||
const start = mt.index
|
||||
const name = mt[1]
|
||||
// scan forward from the match end, balancing (), [], {} and strings
|
||||
let i = start + mt[0].length
|
||||
const opens = { '(': ')', '[': ']', '{': '}' }
|
||||
const stack = []
|
||||
let inStr = null
|
||||
while (i < body.length) {
|
||||
const c = body[i]
|
||||
if (inStr) {
|
||||
if (c === '\\') { i += 2; continue }
|
||||
if (c === inStr) inStr = null
|
||||
} else if (c === "'" || c === '"' || c === '`') {
|
||||
inStr = c
|
||||
} else if (opens[c]) {
|
||||
stack.push(opens[c])
|
||||
} else if (stack.length && c === stack[stack.length - 1]) {
|
||||
stack.pop()
|
||||
if (!stack.length) { i++; break }
|
||||
} else if (!stack.length && (c === ';' || c === '\n')) {
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
const expr = body.slice(start + mt[0].length, i).replace(/[;\n]+$/, '').trim()
|
||||
try {
|
||||
constDecls[name] = vm.runInNewContext('(' + expr + ')', {})
|
||||
} catch (e) {
|
||||
constDecls[name] = undefined
|
||||
}
|
||||
// also skip a trailing comment if the literal ended mid-line before one
|
||||
let end = i
|
||||
const semi = body.indexOf('//', i)
|
||||
const nl = body.indexOf('\n', i)
|
||||
if (semi !== -1 && nl !== -1 && semi < nl) end = nl
|
||||
else if (end < body.length && body[end] !== '\n') end = nl === -1 ? body.length : nl
|
||||
out += body.slice(pos, start)
|
||||
pos = end
|
||||
}
|
||||
body = out + body.slice(pos)
|
||||
}
|
||||
|
||||
const sandbox = {
|
||||
require,
|
||||
console,
|
||||
prayer: require('../src/common/prayer.js'),
|
||||
globalThis: {},
|
||||
setInterval: () => 0,
|
||||
clearInterval: () => {},
|
||||
setTimeout: () => 0,
|
||||
clearTimeout: () => {},
|
||||
...constDecls
|
||||
}
|
||||
sandbox.globalThis = sandbox
|
||||
vm.createContext(sandbox)
|
||||
vm.runInContext('(' + body + ')', sandbox)
|
||||
const spec = sandbox.spec
|
||||
assert.ok(spec && spec.private, 'spec captured with private state')
|
||||
|
||||
// page() helper: clone spec + private state into a fresh object, wire method contexts
|
||||
function page() {
|
||||
const p = {}
|
||||
Object.assign(p, JSON.parse(JSON.stringify(spec.private)))
|
||||
p.onInit = spec.onInit
|
||||
p.onShow = spec.onShow
|
||||
p.onHide = spec.onHide
|
||||
p.onDestroy = spec.onDestroy
|
||||
p.update = spec.update
|
||||
p.rows = []
|
||||
return p
|
||||
}
|
||||
|
||||
let passed = 0
|
||||
function test(name, fn) {
|
||||
try { fn(); passed++; console.log(' ok -', name) }
|
||||
catch (e) { console.error(' FAIL -', name, '\n ', e.message); process.exitCode = 1 }
|
||||
}
|
||||
|
||||
// Label map: labels must exist for every prayer key (sandbox global, captured at load)
|
||||
const LABELS = sandbox.LABELS || spec.LABELS
|
||||
|
||||
console.log('page.cjs tests')
|
||||
|
||||
test('private state has all bound fields (clock, countdown, nextKey, nextName, rows)', () => {
|
||||
for (const f of ['clock', 'countdown', 'nextKey', 'nextName', 'rows']) {
|
||||
assert.ok(f in spec.private, f + ' in private')
|
||||
}
|
||||
})
|
||||
|
||||
test('LABELS covers every prayer key + no extras', () => {
|
||||
assert.deepEqual(Object.keys(LABELS), ['fajr', 'sunrise', 'dhuhr', 'asr', 'maghrib', 'isha'])
|
||||
})
|
||||
|
||||
test('template binds: every {{ }} expression field is in private or LABELS-adjacent fields', () => {
|
||||
const tpl = fs.readFileSync(__dirname + '/../src/pages/index/index.ux', 'utf8').match(/<template>([\s\S]*?)<\/template>/)[1]
|
||||
const binds = [...tpl.matchAll(/\{\{\s*([\w.]+)\s*\}\}/g)].map((x) => x[1])
|
||||
for (const b of binds) {
|
||||
const root = b.split('.')[0]
|
||||
assert.ok(root in spec.private || ['isDay', 'row'].includes(root), 'template bind {{ ' + b + ' }} has a private root (' + root + ')')
|
||||
}
|
||||
})
|
||||
|
||||
test('update() populates clock as HH:MM', () => {
|
||||
const p = page()
|
||||
p.update()
|
||||
assert.match(p.clock, /^\d{2}:\d{2}$/)
|
||||
})
|
||||
|
||||
test('update() rows: six entries, key+label+time wired', () => {
|
||||
const p = page()
|
||||
p.update()
|
||||
assert.equal(p.rows.length, 6)
|
||||
const t = require('../src/common/prayer.js').times(new Date(), 33.5731, -7.5898, 1)
|
||||
for (const row of p.rows) {
|
||||
assert.ok(row.key in LABELS, 'row key ' + row.key)
|
||||
assert.equal(row.label, LABELS[row.key])
|
||||
assert.match(row.time, /^\d{2}:\d{2}$/)
|
||||
assert.equal(typeof row.passed, 'boolean')
|
||||
assert.ok(row.rowStyle && /^\d+px$/.test(row.rowStyle.width), 'rowStyle.width set (' + JSON.stringify(row.rowStyle) + ')')
|
||||
}
|
||||
})
|
||||
|
||||
test('row widths taper with the chord and fit the 466 circle at every row position', () => {
|
||||
// rows: 6 x 40px + 5 x 4px margins, block starts after header+banner (top=130)
|
||||
const ROW_WIDTHS = spec.ROW_WIDTHS || sandbox.ROW_WIDTHS
|
||||
assert.equal(ROW_WIDTHS.length, 6)
|
||||
const H = 40, MARGIN = 4, BLOCK_TOP = 130, R = 233, INSET = 10
|
||||
for (let n = 0; n < 6; n++) {
|
||||
const top = BLOCK_TOP + n * (H + MARGIN)
|
||||
const dy = Math.max(Math.abs(233 - top), Math.abs(233 - (top + H)))
|
||||
const chord = 2 * Math.sqrt(Math.max(0, R * R - dy * dy))
|
||||
const safe = chord - 2 * INSET
|
||||
assert.ok(ROW_WIDTHS[n] <= safe, `row ${n} width ${ROW_WIDTHS[n]} exceeds safe chord ${safe.toFixed(0)} at top ${top}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('bound rowStyle width equals ROW_WIDTHS[i]', () => {
|
||||
const ROW_WIDTHS = spec.ROW_WIDTHS || sandbox.ROW_WIDTHS
|
||||
const p = page()
|
||||
p.update()
|
||||
p.rows.forEach((row, i) => {
|
||||
assert.equal(row.rowStyle.width, ROW_WIDTHS[i] + 'px')
|
||||
})
|
||||
})
|
||||
|
||||
test('update() nextKey/nextName agree with nextPrayer', () => {
|
||||
const p = page()
|
||||
p.update()
|
||||
const now = new Date()
|
||||
const t = require('../src/common/prayer.js').times(now, 33.5731, -7.5898, 1)
|
||||
const next = require('../src/common/prayer.js').nextPrayer(t, now.getHours() * 60 + now.getMinutes())
|
||||
assert.equal(p.nextKey, next.key)
|
||||
assert.equal(p.nextName, LABELS[next.key])
|
||||
})
|
||||
|
||||
test('countdown is minutes/hours format or +1d wrap', () => {
|
||||
const p = page()
|
||||
p.update()
|
||||
assert.match(p.countdown, /^(\+1d )?(\d+m|\d+h \d+m)$/)
|
||||
})
|
||||
|
||||
test('rowNext highlight is set on exactly one row (the next prayer)', () => {
|
||||
const p = page()
|
||||
p.update()
|
||||
const nextRows = p.rows.filter((r) => r.key === p.nextKey)
|
||||
assert.equal(nextRows.length, 1, 'exactly one row matches nextKey')
|
||||
})
|
||||
|
||||
test('update is idempotent-ish: two calls keep rows length 6 and consistent next', () => {
|
||||
const p = page()
|
||||
p.update()
|
||||
p.update()
|
||||
assert.equal(p.rows.length, 6)
|
||||
assert.ok(p.nextKey in LABELS)
|
||||
})
|
||||
|
||||
console.log(process.exitCode ? 'FAILED' : 'ALL PASSED (' + passed + ')')
|
||||
@@ -0,0 +1,111 @@
|
||||
// Prayer time math tests — reference: AlAdhan API method 21 (Morocco, Fajr 19, Isha 17, school STANDARD)
|
||||
// Casablanca (33.5731, -7.5898) 2026-09-09 reference times:
|
||||
// Fajr 05:40, Sunrise 07:10, Dhuhr 13:33, Asr 17:02, Maghrib 19:50, Isha 21:05
|
||||
'use strict'
|
||||
const assert = require('assert/strict')
|
||||
const prayer = require('../src/common/prayer.js')
|
||||
|
||||
// Casablanca is UTC+1 in September 2026; emulate the watch by passing tz offset explicitly.
|
||||
const TZ = 1
|
||||
const CASA = { lat: 33.5731, lon: -7.5898 }
|
||||
|
||||
function timesFor(y, m, d) {
|
||||
return prayer.times(new Date(Date.UTC(y, m - 1, d, 12, 0, 0)), CASA.lat, CASA.lon, TZ)
|
||||
}
|
||||
|
||||
function assertClose(actualMin, refHM, label, tol = 2) {
|
||||
const [rh, rm] = refHM.split(':').map(Number)
|
||||
const ref = rh * 60 + rm
|
||||
const diff = Math.abs(actualMin - ref)
|
||||
assert.ok(diff <= tol, `${label}: got ${prayer.format(actualMin)} (${actualMin}), expected ${refHM} — off by ${diff.toFixed(1)} min`)
|
||||
}
|
||||
|
||||
let passed = 0
|
||||
function test(name, fn) {
|
||||
try { fn(); passed++; console.log(' ok -', name) }
|
||||
catch (e) { console.error(' FAIL -', name, '\n ', e.message); process.exitCode = 1 }
|
||||
}
|
||||
|
||||
console.log('prayer.js tests')
|
||||
|
||||
test('times match AlAdhan Morocco method for Casablanca 2026-09-09', () => {
|
||||
const t = timesFor(2026, 9, 9)
|
||||
assertClose(t.fajr, '05:40', 'Fajr')
|
||||
assertClose(t.sunrise, '07:10', 'Sunrise')
|
||||
assertClose(t.dhuhr, '13:33', 'Dhuhr')
|
||||
assertClose(t.asr, '17:02', 'Asr')
|
||||
assertClose(t.maghrib, '19:50', 'Maghrib')
|
||||
assertClose(t.isha, '21:05', 'Isha')
|
||||
})
|
||||
|
||||
test('times match for a midwinter date 2026-01-15 (AlAdhan cross-check)', () => {
|
||||
// AlAdhan method 21, Casablanca, 15-01-2026: Fajr 07:02, Sunrise 08:35, Dhuhr 13:45, Asr 16:26, Maghrib 18:50, Isha 20:08
|
||||
const t = timesFor(2026, 1, 15)
|
||||
assertClose(t.fajr, '07:02', 'Fajr')
|
||||
assertClose(t.sunrise, '08:35', 'Sunrise')
|
||||
assertClose(t.dhuhr, '13:45', 'Dhuhr')
|
||||
assertClose(t.asr, '16:26', 'Asr')
|
||||
assertClose(t.maghrib, '18:50', 'Maghrib')
|
||||
assertClose(t.isha, '20:08', 'Isha')
|
||||
})
|
||||
|
||||
test('times match for a summer date 2026-07-01 (AlAdhan cross-check)', () => {
|
||||
// AlAdhan method 21, Casablanca, 01-07-2026: Fajr 04:34, Sunrise 06:24, Dhuhr 13:39, Asr 17:19, Maghrib 20:50, Isha 22:21
|
||||
const t = timesFor(2026, 7, 1)
|
||||
assertClose(t.fajr, '04:34', 'Fajr')
|
||||
assertClose(t.sunrise, '06:24', 'Sunrise')
|
||||
assertClose(t.dhuhr, '13:39', 'Dhuhr')
|
||||
assertClose(t.asr, '17:19', 'Asr')
|
||||
assertClose(t.maghrib, '20:50', 'Maghrib')
|
||||
assertClose(t.isha, '22:21', 'Isha')
|
||||
})
|
||||
|
||||
test('format pads minutes', () => {
|
||||
assert.equal(prayer.format(9 * 60 + 5), '09:05')
|
||||
assert.equal(prayer.format(13 * 60 + 33), '13:33')
|
||||
})
|
||||
|
||||
test('format wraps past midnight (next-day fajr)', () => {
|
||||
// 25h15 -> 01:15 next day
|
||||
assert.equal(prayer.format(25 * 60 + 15), '01:15')
|
||||
})
|
||||
|
||||
test('nextPrayer: before fajr -> next is fajr', () => {
|
||||
const t = timesFor(2026, 9, 9)
|
||||
const n = prayer.nextPrayer(t, 4 * 60)
|
||||
assert.equal(n.key, 'fajr')
|
||||
assert.ok(n.minutesLeft > 0 && n.minutesLeft < 1440, 'minutesLeft ' + n.minutesLeft)
|
||||
// 04:00 -> fajr 05:40 ≈ 100 min
|
||||
assert.ok(Math.abs(n.minutesLeft - 100) <= 2, 'minutesLeft ' + n.minutesLeft)
|
||||
})
|
||||
|
||||
test('nextPrayer: between dhuhr and asr -> next is asr', () => {
|
||||
const t = timesFor(2026, 9, 9)
|
||||
const n = prayer.nextPrayer(t, 14 * 60)
|
||||
assert.equal(n.key, 'asr')
|
||||
assert.ok(n.current === 'dhuhr')
|
||||
})
|
||||
|
||||
test('nextPrayer: after isha -> wraps to fajr (+1d) and minutesLeft crosses midnight', () => {
|
||||
const t = timesFor(2026, 9, 9)
|
||||
const n = prayer.nextPrayer(t, 23 * 60)
|
||||
assert.equal(n.key, 'fajr')
|
||||
assert.ok(n.nextDay === true, 'nextDay flag')
|
||||
// 23:00 -> fajr 05:40 next day = 6h40 = 400 min
|
||||
assert.ok(Math.abs(n.minutesLeft - 400) <= 2, 'minutesLeft ' + n.minutesLeft)
|
||||
})
|
||||
|
||||
test('nextPrayer: exactly at dhuhr time -> next is asr (current dhuhr)', () => {
|
||||
const t = timesFor(2026, 9, 9)
|
||||
const n = prayer.nextPrayer(t, Math.round(t.dhuhr))
|
||||
assert.equal(n.key, 'asr')
|
||||
})
|
||||
|
||||
test('minutesLeft is exact', () => {
|
||||
const t = timesFor(2026, 9, 9)
|
||||
const n = prayer.nextPrayer(t, 17 * 60)
|
||||
// 17:00 -> asr 17:02 = 2 min
|
||||
assert.ok(Math.abs(n.minutesLeft - 2) <= 1, 'minutesLeft ' + n.minutesLeft)
|
||||
})
|
||||
|
||||
console.log(process.exitCode ? 'FAILED' : 'ALL PASSED (' + passed + ')')
|
||||
Reference in New Issue
Block a user