salat 3.3.1 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.js +8 -48
- package/dist/ui.js +73 -0
- package/dist/utils.js +29 -20
- package/dist/utils.test.js +45 -4
- package/package.json +7 -4
- package/src/app.ts +8 -62
- package/src/types.ts +4 -5
- package/src/ui.tsx +132 -0
- package/src/utils.test.ts +51 -4
- package/src/utils.ts +44 -28
- package/tsconfig.json +2 -1
package/dist/app.js
CHANGED
|
@@ -1,53 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Project's dependencies
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
import citiesData from "./data/cities.json" with { type: "json" };
|
|
8
|
-
// Setting up localStorage
|
|
9
|
-
import { LocalStorage } from "node-localstorage";
|
|
10
|
-
const args = process.argv;
|
|
3
|
+
import { render } from "ink";
|
|
4
|
+
import React from "react";
|
|
5
|
+
import App from "./ui.js"; // Note the .js extension for ESM imports
|
|
6
|
+
// Project's setup
|
|
11
7
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
|
12
|
-
|
|
13
|
-
const green = (msg) => console.log(chalk.green(msg));
|
|
14
|
-
// Cast citiesData to City[] properly
|
|
15
|
-
const cities = citiesData;
|
|
16
|
-
const localStorage = new LocalStorage(LOCAL_STORAGE_PATH);
|
|
8
|
+
const args = process.argv;
|
|
17
9
|
const cityNameArg = args[2];
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
const cityId = getCityId(cityName, cities);
|
|
21
|
-
const main = async () => {
|
|
22
|
-
// Printing a banner ('cause I'm cool and I can do it XD)
|
|
23
|
-
green(BANNER);
|
|
24
|
-
const storageKey = `${cityName.toLowerCase()}_${new Date().toLocaleDateString()}`;
|
|
25
|
-
let item = localStorage.getItem(storageKey);
|
|
26
|
-
// Disable localStorage for local development
|
|
27
|
-
if (process.env.NODE_ENV === "development") {
|
|
28
|
-
console.log("development mode: localStorage is disabled");
|
|
29
|
-
item = null;
|
|
30
|
-
}
|
|
31
|
-
let prayers = null;
|
|
32
|
-
if (item) {
|
|
33
|
-
prayers = JSON.parse(item);
|
|
34
|
-
}
|
|
35
|
-
else {
|
|
36
|
-
try {
|
|
37
|
-
const data = await getData(cityId);
|
|
38
|
-
prayers = parsePrayerTimesFromResponse(data);
|
|
39
|
-
localStorage.setItem(storageKey, JSON.stringify(prayers));
|
|
40
|
-
}
|
|
41
|
-
catch (ex) {
|
|
42
|
-
//TODO: Use a more descriptive error message
|
|
43
|
-
console.error("Something went wrong!");
|
|
44
|
-
console.log(ex);
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
console.clear();
|
|
49
|
-
displayResult(prayers, cityName);
|
|
10
|
+
const main = () => {
|
|
11
|
+
render(React.createElement(App, { cityNameArg }));
|
|
50
12
|
};
|
|
51
|
-
(
|
|
52
|
-
await main();
|
|
53
|
-
})();
|
|
13
|
+
main();
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { LOCAL_STORAGE_PATH } from "#constants";
|
|
3
|
+
import { getCityId, getCityName, getData, getNextPrayer, parsePrayerTimesFromResponse, tConv24, } from "#utils";
|
|
4
|
+
import { Box, Text, useApp } from "ink";
|
|
5
|
+
import { useEffect, useState } from "react";
|
|
6
|
+
// @ts-ignore
|
|
7
|
+
import { format } from "date-fns";
|
|
8
|
+
import { LocalStorage } from "node-localstorage";
|
|
9
|
+
import citiesData from "./data/cities.json" with { type: "json" };
|
|
10
|
+
const cities = citiesData;
|
|
11
|
+
const localStorage = new LocalStorage(LOCAL_STORAGE_PATH);
|
|
12
|
+
const App = ({ cityNameArg }) => {
|
|
13
|
+
const { exit } = useApp();
|
|
14
|
+
const [prayerTimes, setPrayerTimes] = useState(null);
|
|
15
|
+
const [error, setError] = useState(null);
|
|
16
|
+
const [loading, setLoading] = useState(true);
|
|
17
|
+
const [resolvedCityName, setResolvedCityName] = useState("");
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const fetchData = async () => {
|
|
20
|
+
try {
|
|
21
|
+
const name = getCityName(cityNameArg, cities);
|
|
22
|
+
setResolvedCityName(name);
|
|
23
|
+
const storageKey = `${name.toLowerCase()}_${format(new Date(), "yyyy-MM-dd")}`;
|
|
24
|
+
let item = localStorage.getItem(storageKey);
|
|
25
|
+
// Disable localStorage for local development
|
|
26
|
+
if (process.env.NODE_ENV === "development") {
|
|
27
|
+
item = null;
|
|
28
|
+
}
|
|
29
|
+
if (item) {
|
|
30
|
+
setPrayerTimes(JSON.parse(item));
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
const cityId = getCityId(name, cities);
|
|
34
|
+
const data = await getData(cityId);
|
|
35
|
+
const prayers = parsePrayerTimesFromResponse(data);
|
|
36
|
+
setPrayerTimes(prayers);
|
|
37
|
+
localStorage.setItem(storageKey, JSON.stringify(prayers));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
setError(err.message || "An error occurred");
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
setLoading(false);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
fetchData();
|
|
48
|
+
}, [cityNameArg]);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!loading && (prayerTimes || error)) {
|
|
51
|
+
// Small delay to ensure render happens
|
|
52
|
+
// Ink might need a tick to flush the output to stdout
|
|
53
|
+
const timer = setTimeout(() => {
|
|
54
|
+
exit();
|
|
55
|
+
}, 100);
|
|
56
|
+
return () => clearTimeout(timer);
|
|
57
|
+
}
|
|
58
|
+
}, [loading, prayerTimes, error, exit]);
|
|
59
|
+
if (loading) {
|
|
60
|
+
return _jsx(Text, { children: "Loading prayer times..." });
|
|
61
|
+
}
|
|
62
|
+
if (error) {
|
|
63
|
+
return _jsxs(Text, { color: "red", children: ["Error: ", error] });
|
|
64
|
+
}
|
|
65
|
+
if (!prayerTimes) {
|
|
66
|
+
return _jsx(Text, { color: "red", children: "Could not fetch prayer times." });
|
|
67
|
+
}
|
|
68
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["\uD83E\uDDED ", resolvedCityName, ", Morocco"] }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["\uD83D\uDCC5 ", format(new Date(), "PPPP")] }) }), prayerTimes && (_jsxs(Box, { borderStyle: "round", borderColor: "cyan", paddingX: 1, marginBottom: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Next Prayer: " }), _jsx(Text, { bold: true, children: getNextPrayer(prayerTimes, new Date()).prayer })] }), _jsxs(Box, { children: [_jsx(Text, { children: "Time: " }), _jsx(Text, { children: tConv24(getNextPrayer(prayerTimes, new Date()).time) })] }), _jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "Remaining: " }), _jsx(Text, { color: "yellow", children: getNextPrayer(prayerTimes, new Date()).timeLeft })] })] })), _jsx(Box, { flexDirection: "column", children: Object.entries(prayerTimes).map(([prayer, time]) => {
|
|
69
|
+
const isNext = prayer === getNextPrayer(prayerTimes, new Date()).prayer;
|
|
70
|
+
return (_jsxs(Box, { children: [_jsx(Box, { width: 10, children: _jsx(Text, { color: isNext ? "cyan" : "white", bold: isNext, children: prayer }) }), _jsx(Box, { marginRight: 2, children: _jsx(Text, { color: isNext ? "cyan" : "gray", children: "-->" }) }), _jsx(Text, { color: isNext ? "yellow" : "green", bold: isNext, children: tConv24(time) })] }, prayer));
|
|
71
|
+
}) })] }));
|
|
72
|
+
};
|
|
73
|
+
export default App;
|
package/dist/utils.js
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { API_URL, DEFAULT_CITY, NOT_FOUND_ERROR } from "#constants";
|
|
2
|
-
import
|
|
2
|
+
import { addDays, differenceInSeconds, format, parse } from "date-fns";
|
|
3
3
|
import domino from "domino";
|
|
4
4
|
import fetch from "node-fetch";
|
|
5
5
|
import prayersData from "./data/prayers.json" with { type: "json" };
|
|
6
|
-
const error = (msg) => console.error(chalk.red(msg));
|
|
7
6
|
export const getCityName = (arg, cities) => {
|
|
8
7
|
if (arg == null)
|
|
9
8
|
return DEFAULT_CITY;
|
|
10
9
|
const index = getCityIndex(arg, cities);
|
|
11
10
|
if (index === -1) {
|
|
12
|
-
error(NOT_FOUND_ERROR);
|
|
11
|
+
console.error(NOT_FOUND_ERROR);
|
|
13
12
|
return DEFAULT_CITY;
|
|
14
13
|
}
|
|
15
14
|
return arg;
|
|
@@ -44,22 +43,32 @@ export const parsePrayerTimesFromResponse = (response) => {
|
|
|
44
43
|
return acc;
|
|
45
44
|
}, {});
|
|
46
45
|
};
|
|
47
|
-
function tConv24(time24) {
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
const formattedHour = hour % 12 || 12;
|
|
51
|
-
const formattedHourWithZero = (formattedHour + "").padStart(2, "0");
|
|
52
|
-
const formattedMinutes = minutes.padStart(2, "0");
|
|
53
|
-
const formattedTime = `${formattedHourWithZero}:${formattedMinutes}`;
|
|
54
|
-
const ampm = hour < 12 ? "AM" : "PM";
|
|
55
|
-
return `${formattedTime} ${ampm}`;
|
|
46
|
+
export function tConv24(time24) {
|
|
47
|
+
const date = parse(time24, "HH:mm", new Date());
|
|
48
|
+
return format(date, "hh:mm a");
|
|
56
49
|
}
|
|
57
|
-
export
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
50
|
+
export function getNextPrayer(prayerTimes, now) {
|
|
51
|
+
const prayerNames = ["Fajr", "Chorouq", "Dhuhr", "Asr", "Maghrib", "Ishae"];
|
|
52
|
+
const prayersWithDates = prayerNames.map((name) => {
|
|
53
|
+
const time = prayerTimes[name];
|
|
54
|
+
const prayerDate = parse(time, "HH:mm", now);
|
|
55
|
+
return { name, date: prayerDate, time };
|
|
63
56
|
});
|
|
64
|
-
|
|
65
|
-
|
|
57
|
+
let next = prayersWithDates.find((p) => p.date > now);
|
|
58
|
+
if (!next) {
|
|
59
|
+
const firstPrayerName = prayerNames[0];
|
|
60
|
+
const firstTime = prayerTimes[firstPrayerName];
|
|
61
|
+
const tomorrowFajr = addDays(parse(firstTime, "HH:mm", now), 1);
|
|
62
|
+
next = { name: firstPrayerName, date: tomorrowFajr, time: firstTime };
|
|
63
|
+
}
|
|
64
|
+
const diffSec = differenceInSeconds(next.date, now);
|
|
65
|
+
const h = Math.floor(diffSec / 3600);
|
|
66
|
+
const m = Math.floor((diffSec % 3600) / 60);
|
|
67
|
+
const s = diffSec % 60;
|
|
68
|
+
const timeLeft = `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
69
|
+
return {
|
|
70
|
+
prayer: next.name,
|
|
71
|
+
time: next.time,
|
|
72
|
+
timeLeft,
|
|
73
|
+
};
|
|
74
|
+
}
|
package/dist/utils.test.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as constants from '#constants';
|
|
2
|
-
import { getCityId, getCityName, parsePrayerTimesFromResponse } from '#utils';
|
|
2
|
+
import { getCityId, getCityName, getNextPrayer, parsePrayerTimesFromResponse } from '#utils';
|
|
3
|
+
import { parseISO } from 'date-fns';
|
|
3
4
|
import { describe, expect, it, vi } from 'vitest';
|
|
4
5
|
// Mock cities data
|
|
5
6
|
const mockCities = [
|
|
@@ -17,12 +18,14 @@ describe('utils', () => {
|
|
|
17
18
|
});
|
|
18
19
|
it('should return default city and log error if city does not exist', () => {
|
|
19
20
|
// Spy on console.error using vitest
|
|
20
|
-
const
|
|
21
|
+
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
22
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
21
23
|
expect(getCityName('UnknownCity', mockCities)).toBe(constants.DEFAULT_CITY);
|
|
22
24
|
// We expect some error message to be logged.
|
|
23
25
|
// The actual implementation logs with chalk.red, so we just check it was called.
|
|
24
|
-
expect(
|
|
25
|
-
|
|
26
|
+
expect(consoleLogSpy.mock.calls.length + consoleErrorSpy.mock.calls.length).toBeGreaterThan(0);
|
|
27
|
+
consoleLogSpy.mockRestore();
|
|
28
|
+
consoleErrorSpy.mockRestore();
|
|
26
29
|
});
|
|
27
30
|
it('should be case insensitive', () => {
|
|
28
31
|
expect(getCityName('casablanca', mockCities)).toBe('casablanca');
|
|
@@ -72,4 +75,42 @@ describe('utils', () => {
|
|
|
72
75
|
expect(results).toHaveProperty('Dhuhr');
|
|
73
76
|
});
|
|
74
77
|
});
|
|
78
|
+
describe('getNextPrayer', () => {
|
|
79
|
+
const prayerTimes = {
|
|
80
|
+
Fajr: "05:30",
|
|
81
|
+
Chorouq: "07:00",
|
|
82
|
+
Dhuhr: "12:30",
|
|
83
|
+
Asr: "15:45",
|
|
84
|
+
Maghrib: "18:20",
|
|
85
|
+
Ishae: "19:50"
|
|
86
|
+
};
|
|
87
|
+
it('should return Fajr if current time is before Fajr', () => {
|
|
88
|
+
const now = parseISO('2026-02-07T04:00:00');
|
|
89
|
+
const result = getNextPrayer(prayerTimes, now);
|
|
90
|
+
expect(result.prayer).toBe('Fajr');
|
|
91
|
+
expect(result.time).toBe('05:30');
|
|
92
|
+
expect(result.timeLeft).toBe('01:30:00');
|
|
93
|
+
});
|
|
94
|
+
it('should return Dhuhr if current time is between Chorouq and Dhuhr', () => {
|
|
95
|
+
const now = parseISO('2026-02-07T10:00:00');
|
|
96
|
+
const result = getNextPrayer(prayerTimes, now);
|
|
97
|
+
expect(result.prayer).toBe('Dhuhr');
|
|
98
|
+
expect(result.time).toBe('12:30');
|
|
99
|
+
expect(result.timeLeft).toBe('02:30:00');
|
|
100
|
+
});
|
|
101
|
+
it('should return tomorrow Fajr if current time is after Ishae', () => {
|
|
102
|
+
const now = parseISO('2026-02-07T21:00:00');
|
|
103
|
+
const result = getNextPrayer(prayerTimes, now);
|
|
104
|
+
expect(result.prayer).toBe('Fajr');
|
|
105
|
+
expect(result.time).toBe('05:30');
|
|
106
|
+
// From 21:00 to 05:30 next day is 8 hours and 30 minutes
|
|
107
|
+
expect(result.timeLeft).toBe('08:30:00');
|
|
108
|
+
});
|
|
109
|
+
it('should handle seconds correctly in timeLeft', () => {
|
|
110
|
+
const now = parseISO('2026-02-07T12:29:45');
|
|
111
|
+
const result = getNextPrayer(prayerTimes, now);
|
|
112
|
+
expect(result.prayer).toBe('Dhuhr');
|
|
113
|
+
expect(result.timeLeft).toBe('00:00:15');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
75
116
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "salat",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"imports": {
|
|
5
5
|
"#*": "./dist/*.js"
|
|
6
6
|
},
|
|
@@ -12,10 +12,12 @@
|
|
|
12
12
|
},
|
|
13
13
|
"type": "module",
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"
|
|
15
|
+
"date-fns": "^4.1.0",
|
|
16
16
|
"domino": "^2.1.6",
|
|
17
|
+
"ink": "^6.6.0",
|
|
17
18
|
"node-fetch": "^3.3.2",
|
|
18
|
-
"node-localstorage": "^3.0.5"
|
|
19
|
+
"node-localstorage": "^3.0.5",
|
|
20
|
+
"react": "^19.2.4"
|
|
19
21
|
},
|
|
20
22
|
"scripts": {
|
|
21
23
|
"dev": "node --no-warnings --loader ts-node/esm src/app.ts",
|
|
@@ -40,7 +42,8 @@
|
|
|
40
42
|
"@types/node": "^25.2.1",
|
|
41
43
|
"@types/node-fetch": "^2.6.13",
|
|
42
44
|
"@types/node-localstorage": "^1.3.3",
|
|
43
|
-
"
|
|
45
|
+
"@types/react": "^19.2.13",
|
|
46
|
+
"@types/react-dom": "^19.2.3",
|
|
44
47
|
"ts-node": "^10.9.2",
|
|
45
48
|
"typescript": "^5.9.3",
|
|
46
49
|
"vitest": "^4.0.18"
|
package/src/app.ts
CHANGED
|
@@ -1,72 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// Project's dependencies
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
getCityName,
|
|
8
|
-
getData,
|
|
9
|
-
parsePrayerTimesFromResponse,
|
|
10
|
-
} from "#utils";
|
|
11
|
-
import chalk from "chalk";
|
|
4
|
+
import { render } from "ink";
|
|
5
|
+
import React from "react";
|
|
6
|
+
import App from "./ui.js"; // Note the .js extension for ESM imports
|
|
12
7
|
|
|
13
|
-
// Project's
|
|
14
|
-
import { BANNER, LOCAL_STORAGE_PATH } from "#constants";
|
|
15
|
-
import { City, PrayerTimes } from "#types";
|
|
16
|
-
import citiesData from "./data/cities.json" with { type: "json" };
|
|
17
|
-
|
|
18
|
-
// Setting up localStorage
|
|
19
|
-
import { LocalStorage } from "node-localstorage";
|
|
20
|
-
|
|
21
|
-
const args = process.argv;
|
|
8
|
+
// Project's setup
|
|
22
9
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
|
23
10
|
|
|
24
|
-
|
|
25
|
-
const green = (msg: string) => console.log(chalk.green(msg));
|
|
26
|
-
|
|
27
|
-
// Cast citiesData to City[] properly
|
|
28
|
-
const cities: City[] = citiesData as City[];
|
|
29
|
-
|
|
30
|
-
const localStorage = new LocalStorage(LOCAL_STORAGE_PATH);
|
|
31
|
-
|
|
11
|
+
const args = process.argv;
|
|
32
12
|
const cityNameArg = args[2];
|
|
33
|
-
const cityName = getCityName(cityNameArg, cities);
|
|
34
|
-
// Convert string ID to number since getCityId returns number and getData expects number
|
|
35
|
-
const cityId = getCityId(cityName, cities);
|
|
36
|
-
|
|
37
|
-
const main = async () => {
|
|
38
|
-
// Printing a banner ('cause I'm cool and I can do it XD)
|
|
39
|
-
green(BANNER);
|
|
40
|
-
|
|
41
|
-
const storageKey = `${cityName.toLowerCase()}_${new Date().toLocaleDateString()}`;
|
|
42
|
-
let item = localStorage.getItem(storageKey);
|
|
43
|
-
|
|
44
|
-
// Disable localStorage for local development
|
|
45
|
-
if (process.env.NODE_ENV === "development") {
|
|
46
|
-
console.log("development mode: localStorage is disabled");
|
|
47
|
-
item = null;
|
|
48
|
-
}
|
|
49
|
-
let prayers: PrayerTimes | null = null;
|
|
50
|
-
|
|
51
|
-
if (item) {
|
|
52
|
-
prayers = JSON.parse(item);
|
|
53
|
-
} else {
|
|
54
|
-
try {
|
|
55
|
-
const data = await getData(cityId);
|
|
56
|
-
prayers = parsePrayerTimesFromResponse(data);
|
|
57
|
-
localStorage.setItem(storageKey, JSON.stringify(prayers));
|
|
58
|
-
} catch (ex) {
|
|
59
|
-
//TODO: Use a more descriptive error message
|
|
60
|
-
console.error("Something went wrong!");
|
|
61
|
-
console.log(ex);
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
13
|
|
|
66
|
-
|
|
67
|
-
|
|
14
|
+
const main = () => {
|
|
15
|
+
render(React.createElement(App, { cityNameArg }));
|
|
68
16
|
};
|
|
69
17
|
|
|
70
|
-
(
|
|
71
|
-
await main();
|
|
72
|
-
})();
|
|
18
|
+
main();
|
package/src/types.ts
CHANGED
|
@@ -3,13 +3,12 @@ export interface City {
|
|
|
3
3
|
name: string;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}
|
|
6
|
+
|
|
7
|
+
export type PrayerName = "Fajr"| "Chorouq"| "Dhuhr"| "Asr"| "Maghrib"| "Ishae"
|
|
9
8
|
|
|
10
9
|
export interface PrayerTime {
|
|
11
|
-
prayer:
|
|
10
|
+
prayer: PrayerName;
|
|
12
11
|
time: string;
|
|
13
12
|
}
|
|
14
13
|
|
|
15
|
-
export type PrayerTimes = Record<
|
|
14
|
+
export type PrayerTimes = Record<PrayerName, string>;
|
package/src/ui.tsx
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { LOCAL_STORAGE_PATH } from "#constants";
|
|
2
|
+
import { City, PrayerTimes } from "#types";
|
|
3
|
+
import {
|
|
4
|
+
getCityId,
|
|
5
|
+
getCityName,
|
|
6
|
+
getData,
|
|
7
|
+
getNextPrayer,
|
|
8
|
+
parsePrayerTimesFromResponse,
|
|
9
|
+
tConv24,
|
|
10
|
+
} from "#utils";
|
|
11
|
+
import { Box, Text, useApp } from "ink";
|
|
12
|
+
import React, { useEffect, useState } from "react";
|
|
13
|
+
// @ts-ignore
|
|
14
|
+
import { format } from "date-fns";
|
|
15
|
+
import { LocalStorage } from "node-localstorage";
|
|
16
|
+
import citiesData from "./data/cities.json" with { type: "json" };
|
|
17
|
+
|
|
18
|
+
const cities: City[] = citiesData as City[];
|
|
19
|
+
const localStorage = new LocalStorage(LOCAL_STORAGE_PATH);
|
|
20
|
+
|
|
21
|
+
interface AppProps {
|
|
22
|
+
cityNameArg?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const App: React.FC<AppProps> = ({ cityNameArg }) => {
|
|
26
|
+
const { exit } = useApp();
|
|
27
|
+
const [prayerTimes, setPrayerTimes] = useState<PrayerTimes | null>(null);
|
|
28
|
+
const [error, setError] = useState<string | null>(null);
|
|
29
|
+
const [loading, setLoading] = useState<boolean>(true);
|
|
30
|
+
const [resolvedCityName, setResolvedCityName] = useState<string>("");
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const fetchData = async () => {
|
|
34
|
+
try {
|
|
35
|
+
const name = getCityName(cityNameArg, cities);
|
|
36
|
+
setResolvedCityName(name);
|
|
37
|
+
|
|
38
|
+
const storageKey = `${name.toLowerCase()}_${format(new Date(), "yyyy-MM-dd")}`;
|
|
39
|
+
let item = localStorage.getItem(storageKey);
|
|
40
|
+
|
|
41
|
+
// Disable localStorage for local development
|
|
42
|
+
if (process.env.NODE_ENV === "development") {
|
|
43
|
+
item = null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (item) {
|
|
47
|
+
setPrayerTimes(JSON.parse(item));
|
|
48
|
+
} else {
|
|
49
|
+
const cityId = getCityId(name, cities);
|
|
50
|
+
const data = await getData(cityId);
|
|
51
|
+
const prayers = parsePrayerTimesFromResponse(data);
|
|
52
|
+
setPrayerTimes(prayers);
|
|
53
|
+
localStorage.setItem(storageKey, JSON.stringify(prayers));
|
|
54
|
+
}
|
|
55
|
+
} catch (err: any) {
|
|
56
|
+
setError(err.message || "An error occurred");
|
|
57
|
+
} finally {
|
|
58
|
+
setLoading(false);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
fetchData();
|
|
63
|
+
}, [cityNameArg]);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!loading && (prayerTimes || error)) {
|
|
67
|
+
// Small delay to ensure render happens
|
|
68
|
+
// Ink might need a tick to flush the output to stdout
|
|
69
|
+
const timer = setTimeout(() => {
|
|
70
|
+
exit();
|
|
71
|
+
}, 100);
|
|
72
|
+
return () => clearTimeout(timer);
|
|
73
|
+
}
|
|
74
|
+
}, [loading, prayerTimes, error, exit]);
|
|
75
|
+
|
|
76
|
+
if (loading) {
|
|
77
|
+
return <Text>Loading prayer times...</Text>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (error) {
|
|
81
|
+
return <Text color="red">Error: {error}</Text>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!prayerTimes) {
|
|
85
|
+
return <Text color="red">Could not fetch prayer times.</Text>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<Box flexDirection="column" padding={1}>
|
|
90
|
+
<Box marginBottom={1}>
|
|
91
|
+
<Text>🧭 {resolvedCityName}, Morocco</Text>
|
|
92
|
+
</Box>
|
|
93
|
+
<Box marginBottom={1}>
|
|
94
|
+
<Text>📅 {format(new Date(), "PPPP")}</Text>
|
|
95
|
+
</Box>
|
|
96
|
+
{prayerTimes && (
|
|
97
|
+
<Box borderStyle="round" borderColor="cyan" paddingX={1} marginBottom={1} flexDirection="column">
|
|
98
|
+
<Box>
|
|
99
|
+
<Text bold color="cyan">Next Prayer: </Text>
|
|
100
|
+
<Text bold>{getNextPrayer(prayerTimes, new Date()).prayer}</Text>
|
|
101
|
+
</Box>
|
|
102
|
+
<Box>
|
|
103
|
+
<Text>Time: </Text>
|
|
104
|
+
<Text>{tConv24(getNextPrayer(prayerTimes, new Date()).time)}</Text>
|
|
105
|
+
</Box>
|
|
106
|
+
<Box>
|
|
107
|
+
<Text color="yellow">Remaining: </Text>
|
|
108
|
+
<Text color="yellow">{getNextPrayer(prayerTimes, new Date()).timeLeft}</Text>
|
|
109
|
+
</Box>
|
|
110
|
+
</Box>
|
|
111
|
+
)}
|
|
112
|
+
<Box flexDirection="column">
|
|
113
|
+
{Object.entries(prayerTimes).map(([prayer, time]) => {
|
|
114
|
+
const isNext = prayer === getNextPrayer(prayerTimes!, new Date()).prayer;
|
|
115
|
+
return (
|
|
116
|
+
<Box key={prayer}>
|
|
117
|
+
<Box width={10}>
|
|
118
|
+
<Text color={isNext ? "cyan" : "white"} bold={isNext}>{prayer}</Text>
|
|
119
|
+
</Box>
|
|
120
|
+
<Box marginRight={2}>
|
|
121
|
+
<Text color={isNext ? "cyan" : "gray"}>--></Text>
|
|
122
|
+
</Box>
|
|
123
|
+
<Text color={isNext ? "yellow" : "green"} bold={isNext}>{tConv24(time)}</Text>
|
|
124
|
+
</Box>
|
|
125
|
+
);
|
|
126
|
+
})}
|
|
127
|
+
</Box>
|
|
128
|
+
</Box>
|
|
129
|
+
);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export default App;
|
package/src/utils.test.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
|
|
2
2
|
import * as constants from '#constants';
|
|
3
3
|
import { City } from '#types';
|
|
4
|
-
import { getCityId, getCityName, parsePrayerTimesFromResponse } from '#utils';
|
|
4
|
+
import { getCityId, getCityName, getNextPrayer, parsePrayerTimesFromResponse } from '#utils';
|
|
5
|
+
import { parseISO } from 'date-fns';
|
|
5
6
|
import { describe, expect, it, vi } from 'vitest';
|
|
6
7
|
|
|
7
8
|
// Mock cities data
|
|
@@ -23,14 +24,17 @@ describe('utils', () => {
|
|
|
23
24
|
|
|
24
25
|
it('should return default city and log error if city does not exist', () => {
|
|
25
26
|
// Spy on console.error using vitest
|
|
26
|
-
const
|
|
27
|
+
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
28
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
27
29
|
|
|
28
30
|
expect(getCityName('UnknownCity', mockCities)).toBe(constants.DEFAULT_CITY);
|
|
29
31
|
|
|
30
32
|
// We expect some error message to be logged.
|
|
31
33
|
// The actual implementation logs with chalk.red, so we just check it was called.
|
|
32
|
-
expect(
|
|
33
|
-
|
|
34
|
+
expect(consoleLogSpy.mock.calls.length + consoleErrorSpy.mock.calls.length).toBeGreaterThan(0);
|
|
35
|
+
|
|
36
|
+
consoleLogSpy.mockRestore();
|
|
37
|
+
consoleErrorSpy.mockRestore();
|
|
34
38
|
});
|
|
35
39
|
|
|
36
40
|
it('should be case insensitive', () => {
|
|
@@ -89,4 +93,47 @@ describe('utils', () => {
|
|
|
89
93
|
expect(results).toHaveProperty('Dhuhr');
|
|
90
94
|
});
|
|
91
95
|
});
|
|
96
|
+
|
|
97
|
+
describe('getNextPrayer', () => {
|
|
98
|
+
const prayerTimes = {
|
|
99
|
+
Fajr: "05:30",
|
|
100
|
+
Chorouq: "07:00",
|
|
101
|
+
Dhuhr: "12:30",
|
|
102
|
+
Asr: "15:45",
|
|
103
|
+
Maghrib: "18:20",
|
|
104
|
+
Ishae: "19:50"
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
it('should return Fajr if current time is before Fajr', () => {
|
|
108
|
+
const now = parseISO('2026-02-07T04:00:00');
|
|
109
|
+
const result = getNextPrayer(prayerTimes, now);
|
|
110
|
+
expect(result.prayer).toBe('Fajr');
|
|
111
|
+
expect(result.time).toBe('05:30');
|
|
112
|
+
expect(result.timeLeft).toBe('01:30:00');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should return Dhuhr if current time is between Chorouq and Dhuhr', () => {
|
|
116
|
+
const now = parseISO('2026-02-07T10:00:00');
|
|
117
|
+
const result = getNextPrayer(prayerTimes, now);
|
|
118
|
+
expect(result.prayer).toBe('Dhuhr');
|
|
119
|
+
expect(result.time).toBe('12:30');
|
|
120
|
+
expect(result.timeLeft).toBe('02:30:00');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should return tomorrow Fajr if current time is after Ishae', () => {
|
|
124
|
+
const now = parseISO('2026-02-07T21:00:00');
|
|
125
|
+
const result = getNextPrayer(prayerTimes, now);
|
|
126
|
+
expect(result.prayer).toBe('Fajr');
|
|
127
|
+
expect(result.time).toBe('05:30');
|
|
128
|
+
// From 21:00 to 05:30 next day is 8 hours and 30 minutes
|
|
129
|
+
expect(result.timeLeft).toBe('08:30:00');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should handle seconds correctly in timeLeft', () => {
|
|
133
|
+
const now = parseISO('2026-02-07T12:29:45');
|
|
134
|
+
const result = getNextPrayer(prayerTimes, now);
|
|
135
|
+
expect(result.prayer).toBe('Dhuhr');
|
|
136
|
+
expect(result.timeLeft).toBe('00:00:15');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
92
139
|
});
|
package/src/utils.ts
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
import { API_URL, DEFAULT_CITY, NOT_FOUND_ERROR } from "#constants";
|
|
2
|
-
import { City,
|
|
3
|
-
import
|
|
2
|
+
import { City, PrayerName, PrayerTimes } from "#types";
|
|
3
|
+
import { addDays, differenceInSeconds, format, parse } from "date-fns";
|
|
4
4
|
import domino from "domino";
|
|
5
5
|
import fetch from "node-fetch";
|
|
6
6
|
import prayersData from "./data/prayers.json" with { type: "json" };
|
|
7
7
|
|
|
8
|
-
const error = (msg: string) => console.error(chalk.red(msg));
|
|
9
8
|
|
|
10
9
|
export const getCityName = (arg: string | undefined, cities: City[]): string => {
|
|
11
10
|
if (arg == null) return DEFAULT_CITY;
|
|
12
11
|
const index = getCityIndex(arg, cities);
|
|
13
12
|
if (index === -1) {
|
|
14
|
-
error(NOT_FOUND_ERROR);
|
|
13
|
+
console.error(NOT_FOUND_ERROR);
|
|
15
14
|
return DEFAULT_CITY;
|
|
16
15
|
}
|
|
17
16
|
return arg;
|
|
@@ -39,7 +38,7 @@ export const parsePrayerTimesFromResponse = (response: string): PrayerTimes => {
|
|
|
39
38
|
const document = window.document;
|
|
40
39
|
const tds = document.getElementsByTagName("td");
|
|
41
40
|
|
|
42
|
-
const prayers:
|
|
41
|
+
const prayers: { prayer: PrayerName; time?: string }[] = JSON.parse(
|
|
43
42
|
JSON.stringify(prayersData)
|
|
44
43
|
);
|
|
45
44
|
|
|
@@ -58,29 +57,46 @@ export const parsePrayerTimesFromResponse = (response: string): PrayerTimes => {
|
|
|
58
57
|
}, {} as PrayerTimes);
|
|
59
58
|
};
|
|
60
59
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
const formattedHourWithZero = (formattedHour + "").padStart(2, "0");
|
|
66
|
-
const formattedMinutes = minutes.padStart(2, "0");
|
|
67
|
-
const formattedTime = `${formattedHourWithZero}:${formattedMinutes}`;
|
|
68
|
-
const ampm = hour < 12 ? "AM" : "PM";
|
|
69
|
-
return `${formattedTime} ${ampm}`;
|
|
60
|
+
|
|
61
|
+
export function tConv24(time24: string): string {
|
|
62
|
+
const date = parse(time24, "HH:mm", new Date());
|
|
63
|
+
return format(date, "hh:mm a");
|
|
70
64
|
}
|
|
71
65
|
|
|
72
|
-
export
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
)}`
|
|
83
|
-
);
|
|
66
|
+
export function getNextPrayer(
|
|
67
|
+
prayerTimes: PrayerTimes,
|
|
68
|
+
now: Date
|
|
69
|
+
): { prayer: string; time: string; timeLeft: string } {
|
|
70
|
+
const prayerNames: PrayerName[] = ["Fajr", "Chorouq", "Dhuhr", "Asr", "Maghrib", "Ishae"];
|
|
71
|
+
|
|
72
|
+
const prayersWithDates = prayerNames.map((name) => {
|
|
73
|
+
const time = prayerTimes[name];
|
|
74
|
+
const prayerDate = parse(time, "HH:mm", now);
|
|
75
|
+
return { name, date: prayerDate, time };
|
|
84
76
|
});
|
|
85
|
-
|
|
86
|
-
|
|
77
|
+
|
|
78
|
+
let next = prayersWithDates.find((p) => p.date > now);
|
|
79
|
+
|
|
80
|
+
if (!next) {
|
|
81
|
+
const firstPrayerName = prayerNames[0];
|
|
82
|
+
const firstTime = prayerTimes[firstPrayerName];
|
|
83
|
+
const tomorrowFajr = addDays(parse(firstTime, "HH:mm", now), 1);
|
|
84
|
+
next = { name: firstPrayerName, date: tomorrowFajr, time: firstTime };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const diffSec = differenceInSeconds(next.date, now);
|
|
88
|
+
const h = Math.floor(diffSec / 3600);
|
|
89
|
+
const m = Math.floor((diffSec % 3600) / 60);
|
|
90
|
+
const s = diffSec % 60;
|
|
91
|
+
|
|
92
|
+
const timeLeft = `${String(h).padStart(2, "0")}:${String(m).padStart(
|
|
93
|
+
2,
|
|
94
|
+
"0"
|
|
95
|
+
)}:${String(s).padStart(2, "0")}`;
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
prayer: next.name,
|
|
99
|
+
time: next.time,
|
|
100
|
+
timeLeft,
|
|
101
|
+
};
|
|
102
|
+
}
|