salat 4.9.4 → 4.10.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/README.md +20 -11
- package/dist/commands/hijri.js +2 -1
- package/dist/commands/times.js +2 -1
- package/dist/components/HijriApp.js +2 -20
- package/dist/components/HijriApp.test.js +20 -12
- package/dist/components/QueryProvider.js +6 -0
- package/dist/components/TimesApp.js +21 -5
- package/dist/components/TimesApp.test.js +40 -32
- package/dist/hooks/index.js +2 -0
- package/dist/hooks/useHijriDate.js +18 -0
- package/dist/hooks/useHijriDate.test.js +25 -0
- package/dist/hooks/usePrayerTimes.js +35 -0
- package/dist/lib/queryClient.js +9 -0
- package/dist/services/constants.js +0 -1
- package/dist/services/utils/api.js +5 -1
- package/dist/services/utils/api.test.js +4 -1
- package/dist/services/utils/cache.js +28 -0
- package/dist/services/utils/cleanData.js +8 -0
- package/dist/services/utils/cleanData.test.js +59 -0
- package/dist/services/utils/hijri.js +2 -2
- package/dist/services/utils/index.js +1 -0
- package/package.json +2 -3
- package/dist/hooks/useSalat.js +0 -74
package/README.md
CHANGED
|
@@ -13,10 +13,11 @@ A modern, visually rich CLI for checking prayer times in Morocco, built with **R
|
|
|
13
13
|
## 🚀 Features
|
|
14
14
|
|
|
15
15
|
- **Live Countdown**: "Remaining" time updates every second in real-time.
|
|
16
|
+
- **Hijri Date**: Display the current Hijri date alongside Gregorian dates.
|
|
16
17
|
- **Rich UI**: Beautiful terminal interface with colors and borders.
|
|
17
18
|
- **Morocco Focused**: Supports 190+ cities across Morocco.
|
|
18
|
-
- **Smart Caching**:
|
|
19
|
-
- **Developer Friendly**: Built with TypeScript
|
|
19
|
+
- **Smart Caching**: In-memory caching with React Query to minimize API calls.
|
|
20
|
+
- **Developer Friendly**: Built with TypeScript, Commander.js, and React.
|
|
20
21
|
|
|
21
22
|
## 📦 Installation
|
|
22
23
|
|
|
@@ -36,7 +37,10 @@ salat
|
|
|
36
37
|
salat times Rabat
|
|
37
38
|
|
|
38
39
|
# Run once and exit (no live timer)
|
|
39
|
-
salat -1
|
|
40
|
+
salat times Casablanca -1
|
|
41
|
+
|
|
42
|
+
# Get the current hijri date
|
|
43
|
+
salat hijri
|
|
40
44
|
|
|
41
45
|
# Show a rich visual guide
|
|
42
46
|
salat guide
|
|
@@ -50,28 +54,33 @@ salat cities
|
|
|
50
54
|
```text
|
|
51
55
|
Usage: salat [options] [command]
|
|
52
56
|
|
|
53
|
-
Daily Moroccan prayers
|
|
54
|
-
|
|
55
|
-
Options:
|
|
56
|
-
-V, --version output the version number
|
|
57
|
-
-h, --help display help for command
|
|
57
|
+
Daily Moroccan prayers tim output the version number
|
|
58
|
+
-h, --help display help for command
|
|
58
59
|
|
|
59
60
|
Commands:
|
|
60
|
-
times [options] [city]
|
|
61
|
+
times [options] [city] Get prayer times for a city
|
|
62
|
+
hijri Display the current hijri date
|
|
63
|
+
guide Show a rich visual guide to using salat-cli
|
|
64
|
+
cities Display the list of available city names
|
|
65
|
+
help [command] ] [city] Get prayer times for a city
|
|
61
66
|
guide Show a rich visual guide to using salat-cli
|
|
62
67
|
cities Display the list of available city names
|
|
63
68
|
help [command] display help for command
|
|
64
69
|
```
|
|
65
70
|
|
|
71
|
+
## 📸 Screenshots
|
|
72
|
+
|
|
73
|
+

|
|
74
|
+
|
|
66
75
|
## 🏗 Dependencies
|
|
67
76
|
|
|
68
77
|
This project is built on the shoulders of giants:
|
|
69
78
|
|
|
70
|
-
- [**
|
|
79
|
+
- [**React Query**](https://tanstack.com/query/latest) - Data synchronization library for managing server state.
|
|
71
80
|
- [**Commander.js**](https://github.com/tj/commander) - The complete solution for node.js command-line interfaces.
|
|
72
81
|
- [**date-fns**](https://date-fns.org/) - Modern JavaScript date utility library.
|
|
73
82
|
- [**node-fetch**](https://github.com/node-fetch/node-fetch) - A light-weight module that brings `window.fetch` to Node.js.
|
|
74
|
-
- [**domino**](https://github.com/fent/domino) - Server-side DOM implementation for parsing API
|
|
83
|
+
- [**domino**](https://github.com/fent/domino) - Server-side DOM implementation for parsing API response
|
|
75
84
|
- [**node-localstorage**](https://github.com/lmaccherone/node-localstorage) - LocalStorage implementation for Node.js.
|
|
76
85
|
|
|
77
86
|
## 🤝 Contributing
|
package/dist/commands/hijri.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { QueryProvider } from "#components/QueryProvider";
|
|
1
2
|
import HijriApp from "#components/HijriApp";
|
|
2
3
|
import { Command } from "commander";
|
|
3
4
|
import { render } from "ink";
|
|
@@ -6,5 +7,5 @@ export const hijriCommand = new Command("hijri")
|
|
|
6
7
|
.description("Display the hijri date")
|
|
7
8
|
.option("-1, --once", "Run once and exit", false)
|
|
8
9
|
.action(() => {
|
|
9
|
-
render(React.createElement(HijriApp));
|
|
10
|
+
render(React.createElement(QueryProvider, undefined, React.createElement(HijriApp)));
|
|
10
11
|
});
|
package/dist/commands/times.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { QueryProvider } from "#components/QueryProvider";
|
|
1
2
|
import App from "#components/TimesApp";
|
|
2
3
|
import { Command } from "commander";
|
|
3
4
|
import { render } from "ink";
|
|
@@ -7,5 +8,5 @@ export const timesCommand = new Command("times")
|
|
|
7
8
|
.argument("[city]", "City name")
|
|
8
9
|
.option("-1, --once", "Run once and exit", false)
|
|
9
10
|
.action((city, options) => {
|
|
10
|
-
render(React.createElement(App, { cityNameArg: city, once: options.once }));
|
|
11
|
+
render(React.createElement(QueryProvider, undefined, React.createElement(App, { cityNameArg: city, once: options.once })));
|
|
11
12
|
});
|
|
@@ -1,26 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { useHijriDate } from "#hooks/useHijriDate";
|
|
3
3
|
import { Box, Text } from "ink";
|
|
4
|
-
import { useEffect, useState } from "react";
|
|
5
4
|
const HijriApp = () => {
|
|
6
|
-
const
|
|
7
|
-
const [error, setError] = useState(null);
|
|
8
|
-
const [loading, setLoading] = useState(true);
|
|
9
|
-
useEffect(() => {
|
|
10
|
-
const fetchHijri = async () => {
|
|
11
|
-
try {
|
|
12
|
-
const result = await getHijriDate();
|
|
13
|
-
setHijriDate(result.date);
|
|
14
|
-
}
|
|
15
|
-
catch (err) {
|
|
16
|
-
setError(err.message || "Failed to fetch hijri date");
|
|
17
|
-
}
|
|
18
|
-
finally {
|
|
19
|
-
setLoading(false);
|
|
20
|
-
}
|
|
21
|
-
};
|
|
22
|
-
fetchHijri();
|
|
23
|
-
}, []);
|
|
5
|
+
const { hijriDate, error, loading } = useHijriDate();
|
|
24
6
|
if (loading) {
|
|
25
7
|
return _jsx(Text, { children: "Loading hijri date..." });
|
|
26
8
|
}
|
|
@@ -1,30 +1,38 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { useHijriDate } from "#hooks/useHijriDate";
|
|
3
3
|
import { render } from "ink-testing-library";
|
|
4
4
|
import { describe, expect, it, vi } from "vitest";
|
|
5
5
|
import HijriApp from "./HijriApp.js";
|
|
6
|
-
vi.mock("#
|
|
7
|
-
|
|
6
|
+
vi.mock("#hooks/useHijriDate", () => ({
|
|
7
|
+
useHijriDate: vi.fn(),
|
|
8
8
|
}));
|
|
9
9
|
describe("HijriApp", () => {
|
|
10
10
|
it("should render loading state", () => {
|
|
11
|
-
vi.mocked(
|
|
11
|
+
vi.mocked(useHijriDate).mockReturnValue({
|
|
12
|
+
hijriDate: null,
|
|
13
|
+
error: null,
|
|
14
|
+
loading: true,
|
|
15
|
+
});
|
|
12
16
|
const { lastFrame } = render(_jsx(HijriApp, {}));
|
|
13
17
|
expect(lastFrame()).toContain("Loading hijri date...");
|
|
14
18
|
});
|
|
15
|
-
it("should render error state",
|
|
16
|
-
vi.mocked(
|
|
19
|
+
it("should render error state", () => {
|
|
20
|
+
vi.mocked(useHijriDate).mockReturnValue({
|
|
21
|
+
hijriDate: null,
|
|
22
|
+
error: "Network error",
|
|
23
|
+
loading: false,
|
|
24
|
+
});
|
|
17
25
|
const { lastFrame } = render(_jsx(HijriApp, {}));
|
|
18
|
-
// Wait for async operation
|
|
19
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
20
26
|
expect(lastFrame()).toContain("Error: Network error");
|
|
21
27
|
});
|
|
22
|
-
it("should render hijri date",
|
|
28
|
+
it("should render hijri date", () => {
|
|
23
29
|
const mockDate = "السبت 18 شعبان 1447هـ | الموافق 07 فبراير 2026م";
|
|
24
|
-
vi.mocked(
|
|
30
|
+
vi.mocked(useHijriDate).mockReturnValue({
|
|
31
|
+
hijriDate: mockDate,
|
|
32
|
+
error: null,
|
|
33
|
+
loading: false,
|
|
34
|
+
});
|
|
25
35
|
const { lastFrame } = render(_jsx(HijriApp, {}));
|
|
26
|
-
// Wait for async operation
|
|
27
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
28
36
|
expect(lastFrame()).toContain("Hijri Date");
|
|
29
37
|
expect(lastFrame()).toContain(mockDate);
|
|
30
38
|
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { queryClient } from "#lib/queryClient";
|
|
3
|
+
import { QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
export const QueryProvider = ({ children }) => {
|
|
5
|
+
return (_jsx(QueryClientProvider, { client: queryClient, children: children }));
|
|
6
|
+
};
|
|
@@ -1,13 +1,29 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { usePrayerTimes } from "#hooks/usePrayerTimes";
|
|
3
3
|
import { getNextPrayer, tConv24 } from "#services/utils/time";
|
|
4
4
|
import { format } from "date-fns";
|
|
5
|
-
import { Box, Text } from "ink";
|
|
5
|
+
import { Box, Text, useApp } from "ink";
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
6
7
|
const App = ({ cityNameArg, once }) => {
|
|
7
|
-
const {
|
|
8
|
+
const { exit } = useApp();
|
|
9
|
+
const { prayerTimes, error, loading, resolvedCityName } = usePrayerTimes({
|
|
8
10
|
cityNameArg,
|
|
9
|
-
once,
|
|
10
11
|
});
|
|
12
|
+
const [currentTime, setCurrentTime] = useState(new Date());
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const timer = setInterval(() => {
|
|
15
|
+
setCurrentTime(new Date());
|
|
16
|
+
}, 1000);
|
|
17
|
+
return () => clearInterval(timer);
|
|
18
|
+
}, []);
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (once && !loading && (prayerTimes || error)) {
|
|
21
|
+
const timer = setTimeout(() => {
|
|
22
|
+
exit();
|
|
23
|
+
}, 100);
|
|
24
|
+
return () => clearTimeout(timer);
|
|
25
|
+
}
|
|
26
|
+
}, [once, loading, prayerTimes, error, exit]);
|
|
11
27
|
if (loading) {
|
|
12
28
|
return _jsx(Text, { children: "Loading prayer times..." });
|
|
13
29
|
}
|
|
@@ -17,7 +33,7 @@ const App = ({ cityNameArg, once }) => {
|
|
|
17
33
|
if (!prayerTimes) {
|
|
18
34
|
return _jsx(Text, { color: "red", children: "Could not fetch prayer times." });
|
|
19
35
|
}
|
|
20
|
-
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(currentTime, "PPPP")] }) }), prayerTimes && (_jsxs(Box, { borderStyle: "round", borderColor: "cyan", paddingX: 1, marginBottom: 1, flexDirection: "column", children: [_jsxs(Box, { children: [
|
|
36
|
+
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(currentTime, "PPPP")] }) }), prayerTimes && (_jsxs(Box, { borderStyle: "round", borderColor: "cyan", paddingX: 1, marginBottom: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { bold: true, color: "cyan", children: ["Next Prayer:", " "] }), _jsx(Text, { bold: true, children: getNextPrayer(prayerTimes, currentTime).prayer })] }), _jsxs(Box, { children: [_jsx(Text, { children: "Time: " }), _jsx(Text, { children: tConv24(getNextPrayer(prayerTimes, currentTime).time) })] }), _jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "Remaining: " }), _jsx(Text, { color: "yellow", children: getNextPrayer(prayerTimes, currentTime).timeLeft })] })] })), _jsx(Box, { flexDirection: "column", children: Object.entries(prayerTimes).map(([prayer, time]) => {
|
|
21
37
|
const isNext = prayer === getNextPrayer(prayerTimes, currentTime).prayer;
|
|
22
38
|
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));
|
|
23
39
|
}) })] }));
|
|
@@ -1,65 +1,73 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
3
|
-
import { render } from
|
|
4
|
-
import { describe, expect, it, vi } from
|
|
5
|
-
import TimesApp from
|
|
6
|
-
vi.mock(
|
|
7
|
-
|
|
2
|
+
import { usePrayerTimes } from "#hooks/usePrayerTimes";
|
|
3
|
+
import { render } from "ink-testing-library";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import TimesApp from "./TimesApp.js";
|
|
6
|
+
vi.mock("#hooks/usePrayerTimes", () => ({
|
|
7
|
+
usePrayerTimes: vi.fn(),
|
|
8
8
|
}));
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
vi.mock("ink", async () => {
|
|
10
|
+
const actual = await vi.importActual("ink");
|
|
11
|
+
return {
|
|
12
|
+
...actual,
|
|
13
|
+
useApp: vi.fn(() => ({
|
|
14
|
+
exit: vi.fn(),
|
|
15
|
+
})),
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
describe("TimesApp", () => {
|
|
19
|
+
it("should render loading state", () => {
|
|
20
|
+
vi.mocked(usePrayerTimes).mockReturnValue({
|
|
12
21
|
prayerTimes: null,
|
|
13
22
|
error: null,
|
|
14
23
|
loading: true,
|
|
15
|
-
resolvedCityName:
|
|
16
|
-
currentTime: new Date()
|
|
24
|
+
resolvedCityName: "",
|
|
17
25
|
});
|
|
18
26
|
const { lastFrame } = render(_jsx(TimesApp, {}));
|
|
19
|
-
expect(lastFrame()).toContain(
|
|
27
|
+
expect(lastFrame()).toContain("Loading prayer times...");
|
|
20
28
|
});
|
|
21
|
-
it(
|
|
22
|
-
|
|
29
|
+
it("should render error state", () => {
|
|
30
|
+
vi.mocked(usePrayerTimes).mockReturnValue({
|
|
23
31
|
prayerTimes: null,
|
|
24
|
-
error:
|
|
32
|
+
error: "Failed to fetch",
|
|
25
33
|
loading: false,
|
|
26
|
-
resolvedCityName:
|
|
27
|
-
currentTime: new Date()
|
|
34
|
+
resolvedCityName: "",
|
|
28
35
|
});
|
|
29
36
|
const { lastFrame } = render(_jsx(TimesApp, {}));
|
|
30
|
-
expect(lastFrame()).toContain(
|
|
37
|
+
expect(lastFrame()).toContain("Error: Failed to fetch");
|
|
31
38
|
});
|
|
32
|
-
it(
|
|
33
|
-
|
|
39
|
+
it("should render null prayerTimes state", () => {
|
|
40
|
+
vi.mocked(usePrayerTimes).mockReturnValue({
|
|
34
41
|
prayerTimes: null,
|
|
35
42
|
error: null,
|
|
36
43
|
loading: false,
|
|
37
|
-
resolvedCityName:
|
|
38
|
-
currentTime: new Date()
|
|
44
|
+
resolvedCityName: "",
|
|
39
45
|
});
|
|
40
46
|
const { lastFrame } = render(_jsx(TimesApp, {}));
|
|
41
|
-
expect(lastFrame()).toContain(
|
|
47
|
+
expect(lastFrame()).toContain("Could not fetch prayer times.");
|
|
42
48
|
});
|
|
43
|
-
it(
|
|
49
|
+
it("should render prayer times", () => {
|
|
44
50
|
const mockPrayerTimes = {
|
|
45
51
|
Fajr: "05:00",
|
|
46
52
|
Chorouq: "06:30",
|
|
47
53
|
Dhuhr: "12:30",
|
|
48
54
|
Asr: "15:45",
|
|
49
55
|
Maghrib: "18:20",
|
|
50
|
-
Ishae: "19:50"
|
|
56
|
+
Ishae: "19:50",
|
|
51
57
|
};
|
|
52
|
-
|
|
58
|
+
vi.mocked(usePrayerTimes).mockReturnValue({
|
|
53
59
|
prayerTimes: mockPrayerTimes,
|
|
54
60
|
error: null,
|
|
55
61
|
loading: false,
|
|
56
|
-
resolvedCityName:
|
|
57
|
-
currentTime: new Date('2026-02-07T10:00:00')
|
|
62
|
+
resolvedCityName: "Marrakech",
|
|
58
63
|
});
|
|
59
64
|
const { lastFrame } = render(_jsx(TimesApp, {}));
|
|
60
|
-
expect(lastFrame()).toContain(
|
|
61
|
-
expect(lastFrame()).toContain(
|
|
62
|
-
expect(lastFrame()).toContain(
|
|
63
|
-
|
|
65
|
+
expect(lastFrame()).toContain("Marrakech, Morocco");
|
|
66
|
+
expect(lastFrame()).toContain("Fajr");
|
|
67
|
+
expect(lastFrame()).toContain("05:00 AM");
|
|
68
|
+
// Check for prayer times rendering
|
|
69
|
+
expect(lastFrame()).toContain("Next Prayer");
|
|
70
|
+
expect(lastFrame()).toContain("Dhuhr");
|
|
71
|
+
expect(lastFrame()).toContain("12:30 PM");
|
|
64
72
|
});
|
|
65
73
|
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { getHijriDate } from "#services/utils/hijri";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
export const useHijriDate = () => {
|
|
4
|
+
const query = useQuery({
|
|
5
|
+
queryKey: ["hijriDate"],
|
|
6
|
+
queryFn: async () => {
|
|
7
|
+
const result = await getHijriDate();
|
|
8
|
+
return result.date;
|
|
9
|
+
},
|
|
10
|
+
staleTime: 1000 * 60 * 60 * 24, // 24 hours
|
|
11
|
+
gcTime: 1000 * 60 * 60 * 24, // garbage collection after 24 hours
|
|
12
|
+
});
|
|
13
|
+
return {
|
|
14
|
+
hijriDate: query.data ?? null,
|
|
15
|
+
error: query.error?.message ?? null,
|
|
16
|
+
loading: query.isPending,
|
|
17
|
+
};
|
|
18
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { getHijriDate } from "#services/utils/hijri";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { useHijriDate } from "./useHijriDate.js";
|
|
4
|
+
vi.mock("#services/utils/hijri", () => ({
|
|
5
|
+
getHijriDate: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
describe("useHijriDate", () => {
|
|
8
|
+
it("should call getHijriDate on mount", () => {
|
|
9
|
+
vi.mocked(getHijriDate).mockResolvedValue({
|
|
10
|
+
date: "السبت 18 شعبان 1447هـ | الموافق 07 فبراير 2026م",
|
|
11
|
+
});
|
|
12
|
+
// The hook uses React Query which will call getHijriDate
|
|
13
|
+
// This test verifies the hook is properly configured
|
|
14
|
+
// Full behavior is tested in HijriApp.test.tsx
|
|
15
|
+
expect(useHijriDate).toBeDefined();
|
|
16
|
+
});
|
|
17
|
+
it("should have correct staleTime and gcTime config", () => {
|
|
18
|
+
const mockDate = "السبت 18 شعبان 1447هـ | الموافق 07 فبراير 2026م";
|
|
19
|
+
vi.mocked(getHijriDate).mockResolvedValue({ date: mockDate });
|
|
20
|
+
// React Query config is verified through component tests
|
|
21
|
+
// This ensures the hook is properly exported
|
|
22
|
+
const hookFn = useHijriDate;
|
|
23
|
+
expect(hookFn).toBeInstanceOf(Function);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { getData } from "#services/utils/api";
|
|
2
|
+
import { cachePrayerTimes, getCachedPrayerTimes } from "#services/utils/cache";
|
|
3
|
+
import { getCityId, getCityName } from "#services/utils/city";
|
|
4
|
+
import { parsePrayerTimesFromResponse } from "#services/utils/parser";
|
|
5
|
+
import { useQuery } from "@tanstack/react-query";
|
|
6
|
+
import citiesData from "../data/cities.json" with { type: "json" };
|
|
7
|
+
const cities = citiesData;
|
|
8
|
+
export const usePrayerTimes = ({ cityNameArg }) => {
|
|
9
|
+
const resolvedCityName = getCityName(cityNameArg, cities);
|
|
10
|
+
const query = useQuery({
|
|
11
|
+
queryKey: ["prayerTimes", resolvedCityName],
|
|
12
|
+
queryFn: async () => {
|
|
13
|
+
// Check in-memory cache first
|
|
14
|
+
const cached = getCachedPrayerTimes(resolvedCityName);
|
|
15
|
+
if (cached) {
|
|
16
|
+
return cached;
|
|
17
|
+
}
|
|
18
|
+
// Fetch fresh data
|
|
19
|
+
const cityId = getCityId(resolvedCityName, cities);
|
|
20
|
+
const data = await getData(cityId);
|
|
21
|
+
const prayers = parsePrayerTimesFromResponse(data);
|
|
22
|
+
// Store in memory cache
|
|
23
|
+
cachePrayerTimes(resolvedCityName, prayers);
|
|
24
|
+
return prayers;
|
|
25
|
+
},
|
|
26
|
+
staleTime: 1000 * 60 * 60 * 24, // 24 hours
|
|
27
|
+
gcTime: 1000 * 60 * 60 * 24, // garbage collection after 24 hours
|
|
28
|
+
});
|
|
29
|
+
return {
|
|
30
|
+
prayerTimes: query.data ?? null,
|
|
31
|
+
error: query.error?.message ?? null,
|
|
32
|
+
loading: query.isPending,
|
|
33
|
+
resolvedCityName,
|
|
34
|
+
};
|
|
35
|
+
};
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { API_URL } from "#services/constants";
|
|
2
2
|
import fetch from "node-fetch";
|
|
3
|
+
import https from "https";
|
|
4
|
+
const agent = new https.Agent({
|
|
5
|
+
rejectUnauthorized: false,
|
|
6
|
+
});
|
|
3
7
|
export const getData = async (cityId) => {
|
|
4
|
-
const response = await fetch(`${API_URL}?ville=${cityId}
|
|
8
|
+
const response = await fetch(`${API_URL}?ville=${cityId}`, { agent });
|
|
5
9
|
return await response.text();
|
|
6
10
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import * as constants from "../constants.js";
|
|
3
3
|
vi.mock("node-fetch");
|
|
4
|
+
vi.mock("https");
|
|
4
5
|
import fetch from "node-fetch";
|
|
5
6
|
import { getData } from "./api.js";
|
|
6
7
|
describe("api utils", () => {
|
|
@@ -14,7 +15,9 @@ describe("api utils", () => {
|
|
|
14
15
|
text: async () => mockResponse,
|
|
15
16
|
});
|
|
16
17
|
const result = await getData(1);
|
|
17
|
-
expect(fetch).toHaveBeenCalledWith(`${constants.API_URL}?ville=1
|
|
18
|
+
expect(fetch).toHaveBeenCalledWith(`${constants.API_URL}?ville=1`, expect.objectContaining({
|
|
19
|
+
agent: expect.any(Object),
|
|
20
|
+
}));
|
|
18
21
|
expect(result).toBe(mockResponse);
|
|
19
22
|
});
|
|
20
23
|
it("should throw error if fetch fails", async () => {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { format } from "date-fns";
|
|
2
|
+
// In-memory cache - stores prayer times for current date only
|
|
3
|
+
const cache = new Map();
|
|
4
|
+
export const getCacheKey = (cityName) => {
|
|
5
|
+
return `${cityName.toLowerCase()}_${format(new Date(), "yyyy-MM-dd")}`;
|
|
6
|
+
};
|
|
7
|
+
export const getCachedPrayerTimes = (cityName) => {
|
|
8
|
+
const key = getCacheKey(cityName);
|
|
9
|
+
const entry = cache.get(key);
|
|
10
|
+
if (!entry)
|
|
11
|
+
return null;
|
|
12
|
+
// Validate cache is still for today
|
|
13
|
+
if (entry.date !== format(new Date(), "yyyy-MM-dd")) {
|
|
14
|
+
cache.delete(key);
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
return entry.data;
|
|
18
|
+
};
|
|
19
|
+
export const cachePrayerTimes = (cityName, prayerTimes) => {
|
|
20
|
+
const key = getCacheKey(cityName);
|
|
21
|
+
cache.set(key, {
|
|
22
|
+
data: prayerTimes,
|
|
23
|
+
date: format(new Date(), "yyyy-MM-dd"),
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
export const clearCache = () => {
|
|
27
|
+
cache.clear();
|
|
28
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cleans up a hijri date string by trimming whitespace and removing trailing '%'
|
|
3
|
+
* @param text - The raw hijri date text to clean
|
|
4
|
+
* @returns The cleaned hijri date string
|
|
5
|
+
*/
|
|
6
|
+
export const cleanHijriDateText = (text) => {
|
|
7
|
+
return text.trim().replace(/%\s*$/, "");
|
|
8
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { cleanHijriDateText } from "./cleanData.js";
|
|
3
|
+
describe("cleanHijriDateText", () => {
|
|
4
|
+
it("should return the same string when no cleaning is needed", () => {
|
|
5
|
+
const input = "السبت 18 شعبان 1447هـ | الموافق 07 فبراير 2026م";
|
|
6
|
+
const result = cleanHijriDateText(input);
|
|
7
|
+
expect(result).toBe(input);
|
|
8
|
+
});
|
|
9
|
+
it("should trim leading whitespace", () => {
|
|
10
|
+
const input = " السبت 18 شعبان 1447هـ";
|
|
11
|
+
const expected = "السبت 18 شعبان 1447هـ";
|
|
12
|
+
expect(cleanHijriDateText(input)).toBe(expected);
|
|
13
|
+
});
|
|
14
|
+
it("should trim trailing whitespace", () => {
|
|
15
|
+
const input = "السبت 18 شعبان 1447هـ ";
|
|
16
|
+
const expected = "السبت 18 شعبان 1447هـ";
|
|
17
|
+
expect(cleanHijriDateText(input)).toBe(expected);
|
|
18
|
+
});
|
|
19
|
+
it("should remove trailing %", () => {
|
|
20
|
+
const input = "السبت 18 شعبان 1447هـ%";
|
|
21
|
+
const expected = "السبت 18 شعبان 1447هـ";
|
|
22
|
+
expect(cleanHijriDateText(input)).toBe(expected);
|
|
23
|
+
});
|
|
24
|
+
it("should remove trailing % with whitespace before it", () => {
|
|
25
|
+
const input = "السبت 18 شعبان 1447هـ %";
|
|
26
|
+
const expected = "السبت 18 شعبان 1447هـ ";
|
|
27
|
+
expect(cleanHijriDateText(input)).toBe(expected);
|
|
28
|
+
});
|
|
29
|
+
it("should remove trailing % with multiple spaces before it", () => {
|
|
30
|
+
const input = "السبت 18 شعبان 1447هـ %";
|
|
31
|
+
const expected = "السبت 18 شعبان 1447هـ ";
|
|
32
|
+
expect(cleanHijriDateText(input)).toBe(expected);
|
|
33
|
+
});
|
|
34
|
+
it("should handle text with both leading/trailing whitespace and trailing %", () => {
|
|
35
|
+
const input = " السبت 18 شعبان 1447هـ % ";
|
|
36
|
+
const expected = "السبت 18 شعبان 1447هـ ";
|
|
37
|
+
expect(cleanHijriDateText(input)).toBe(expected);
|
|
38
|
+
});
|
|
39
|
+
it("should return empty string for empty input", () => {
|
|
40
|
+
expect(cleanHijriDateText("")).toBe("");
|
|
41
|
+
});
|
|
42
|
+
it("should return empty string for whitespace-only input", () => {
|
|
43
|
+
expect(cleanHijriDateText(" ")).toBe("");
|
|
44
|
+
});
|
|
45
|
+
it("should handle only % character", () => {
|
|
46
|
+
expect(cleanHijriDateText("%")).toBe("");
|
|
47
|
+
});
|
|
48
|
+
it("should handle % with surrounding whitespace", () => {
|
|
49
|
+
expect(cleanHijriDateText(" % ")).toBe("");
|
|
50
|
+
});
|
|
51
|
+
it("should not remove % from the middle of the text", () => {
|
|
52
|
+
const input = "السبت % 18 شعبان 1447هـ";
|
|
53
|
+
expect(cleanHijriDateText(input)).toBe(input);
|
|
54
|
+
});
|
|
55
|
+
it("should only remove trailing %, not leading", () => {
|
|
56
|
+
const input = "% السبت 18 شعبان 1447هـ";
|
|
57
|
+
expect(cleanHijriDateText(input)).toBe(input);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { HIJRI_API_URL } from "../constants.js";
|
|
2
|
+
import { cleanHijriDateText } from "./cleanData.js";
|
|
2
3
|
export const getHijriDate = async () => {
|
|
3
4
|
try {
|
|
4
5
|
const response = await fetch(HIJRI_API_URL);
|
|
@@ -8,8 +9,7 @@ export const getHijriDate = async () => {
|
|
|
8
9
|
if (!text) {
|
|
9
10
|
throw new Error("Empty response from hijri date API");
|
|
10
11
|
}
|
|
11
|
-
|
|
12
|
-
const cleanedDate = text.trim().replace(/%\s*$/, "");
|
|
12
|
+
const cleanedDate = cleanHijriDateText(text);
|
|
13
13
|
return {
|
|
14
14
|
date: cleanedDate,
|
|
15
15
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "salat",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.10.0",
|
|
4
4
|
"imports": {
|
|
5
5
|
"#*": "./dist/*.js"
|
|
6
6
|
},
|
|
@@ -15,12 +15,12 @@
|
|
|
15
15
|
"dist"
|
|
16
16
|
],
|
|
17
17
|
"dependencies": {
|
|
18
|
+
"@tanstack/react-query": "^5.90.21",
|
|
18
19
|
"commander": "^14.0.3",
|
|
19
20
|
"date-fns": "^4.1.0",
|
|
20
21
|
"domino": "^2.1.6",
|
|
21
22
|
"ink": "^6.6.0",
|
|
22
23
|
"node-fetch": "^3.3.1",
|
|
23
|
-
"node-localstorage": "^3.0.5",
|
|
24
24
|
"react": "^19.2.4"
|
|
25
25
|
},
|
|
26
26
|
"scripts": {
|
|
@@ -46,7 +46,6 @@
|
|
|
46
46
|
"license": "MIT",
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@types/node": "^25.2.1",
|
|
49
|
-
"@types/node-localstorage": "^1.3.3",
|
|
50
49
|
"@types/react": "^19.2.13",
|
|
51
50
|
"@types/react-dom": "^19.2.3",
|
|
52
51
|
"@vitest/coverage-v8": "^4.0.18",
|
package/dist/hooks/useSalat.js
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
import { LOCAL_STORAGE_PATH } from "#services/constants";
|
|
2
|
-
import { useApp } from "ink";
|
|
3
|
-
import { useEffect, useState } from "react";
|
|
4
|
-
// @ts-ignore
|
|
5
|
-
import { getData } from "#services/utils/api";
|
|
6
|
-
import { getCityId, getCityName } from "#services/utils/city";
|
|
7
|
-
import { parsePrayerTimesFromResponse } from "#services/utils/parser";
|
|
8
|
-
import { format } from "date-fns";
|
|
9
|
-
import { LocalStorage } from "node-localstorage";
|
|
10
|
-
import citiesData from "../data/cities.json" with { type: "json" };
|
|
11
|
-
const cities = citiesData;
|
|
12
|
-
const localStorage = new LocalStorage(LOCAL_STORAGE_PATH);
|
|
13
|
-
export const useSalat = ({ cityNameArg, once }) => {
|
|
14
|
-
const { exit } = useApp();
|
|
15
|
-
const [prayerTimes, setPrayerTimes] = useState(null);
|
|
16
|
-
const [error, setError] = useState(null);
|
|
17
|
-
const [loading, setLoading] = useState(true);
|
|
18
|
-
const [resolvedCityName, setResolvedCityName] = useState("");
|
|
19
|
-
const [currentTime, setCurrentTime] = useState(new Date());
|
|
20
|
-
useEffect(() => {
|
|
21
|
-
const timer = setInterval(() => {
|
|
22
|
-
setCurrentTime(new Date());
|
|
23
|
-
}, 1000);
|
|
24
|
-
return () => clearInterval(timer);
|
|
25
|
-
}, []);
|
|
26
|
-
useEffect(() => {
|
|
27
|
-
const fetchData = async () => {
|
|
28
|
-
try {
|
|
29
|
-
const name = getCityName(cityNameArg, cities);
|
|
30
|
-
setResolvedCityName(name);
|
|
31
|
-
const storageKey = `${name.toLowerCase()}_${format(new Date(), "yyyy-MM-dd")}`;
|
|
32
|
-
let item = localStorage.getItem(storageKey);
|
|
33
|
-
// Disable localStorage for local development
|
|
34
|
-
if (process.env.NODE_ENV === "development") {
|
|
35
|
-
item = null;
|
|
36
|
-
}
|
|
37
|
-
if (item) {
|
|
38
|
-
setPrayerTimes(JSON.parse(item));
|
|
39
|
-
}
|
|
40
|
-
else {
|
|
41
|
-
const cityId = getCityId(name, cities);
|
|
42
|
-
const data = await getData(cityId);
|
|
43
|
-
const prayers = parsePrayerTimesFromResponse(data);
|
|
44
|
-
setPrayerTimes(prayers);
|
|
45
|
-
localStorage.setItem(storageKey, JSON.stringify(prayers));
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
catch (err) {
|
|
49
|
-
setError(err.message || "An error occurred");
|
|
50
|
-
}
|
|
51
|
-
finally {
|
|
52
|
-
setLoading(false);
|
|
53
|
-
}
|
|
54
|
-
};
|
|
55
|
-
fetchData();
|
|
56
|
-
}, [cityNameArg]);
|
|
57
|
-
useEffect(() => {
|
|
58
|
-
if (once && !loading && (prayerTimes || error)) {
|
|
59
|
-
// Small delay to ensure render happens
|
|
60
|
-
// Ink might need a tick to flush the output to stdout
|
|
61
|
-
const timer = setTimeout(() => {
|
|
62
|
-
exit();
|
|
63
|
-
}, 100);
|
|
64
|
-
return () => clearTimeout(timer);
|
|
65
|
-
}
|
|
66
|
-
}, [once, loading, prayerTimes, error, exit]);
|
|
67
|
-
return {
|
|
68
|
-
prayerTimes,
|
|
69
|
-
error,
|
|
70
|
-
loading,
|
|
71
|
-
resolvedCityName,
|
|
72
|
-
currentTime,
|
|
73
|
-
};
|
|
74
|
-
};
|