salat 4.9.5 → 5.0.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.
Files changed (38) hide show
  1. package/README.md +20 -16
  2. package/dist/commands/hijri.js +2 -1
  3. package/dist/commands/times.js +5 -2
  4. package/dist/components/CitiesApp.js +2 -2
  5. package/dist/components/CitySelect.js +22 -0
  6. package/dist/components/CitySelect.test.js +40 -0
  7. package/dist/components/HijriApp.js +3 -21
  8. package/dist/components/HijriApp.test.js +20 -12
  9. package/dist/components/QueryProvider.js +6 -0
  10. package/dist/components/TimesApp.js +64 -14
  11. package/dist/components/TimesApp.test.js +47 -32
  12. package/dist/components/TimesCommandWrapper.js +10 -0
  13. package/dist/components/TimesCommandWrapper.test.js +35 -0
  14. package/dist/data/cities.json +1323 -197
  15. package/dist/hooks/index.js +2 -0
  16. package/dist/hooks/useHijriDate.js +18 -0
  17. package/dist/hooks/useHijriDate.test.js +53 -0
  18. package/dist/hooks/usePrayerTimes.js +25 -0
  19. package/dist/hooks/usePrayerTimes.test.js +62 -0
  20. package/dist/lib/queryClient.js +9 -0
  21. package/dist/services/constants.js +3 -4
  22. package/dist/services/constants.test.js +20 -0
  23. package/dist/services/utils/api.js +11 -8
  24. package/dist/services/utils/api.test.js +24 -13
  25. package/dist/services/utils/city.js +1 -1
  26. package/dist/services/utils/city.test.js +39 -37
  27. package/dist/services/utils/hijri.js +3 -5
  28. package/dist/services/utils/hijri.test.js +2 -2
  29. package/dist/services/utils/index.js +1 -1
  30. package/dist/services/utils/parseHijri.js +34 -0
  31. package/dist/services/utils/parseHijri.test.js +19 -0
  32. package/dist/services/utils/time.js +6 -1
  33. package/dist/services/utils/time.test.js +8 -1
  34. package/package.json +5 -6
  35. package/dist/data/prayers.json +0 -20
  36. package/dist/hooks/useSalat.js +0 -74
  37. package/dist/services/utils/parser.js +0 -20
  38. package/dist/services/utils/parser.test.js +0 -33
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**: Local storage caching to minimize API calls.
19
- - **Developer Friendly**: Built with TypeScript and Commander.js.
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,29 +54,29 @@ salat cities
50
54
  ```text
51
55
  Usage: salat [options] [command]
52
56
 
53
- Daily Moroccan prayers time, right in your console
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] Get prayer times for a city
61
- guide Show a rich visual guide to using salat-cli
62
- cities Display the list of available city names
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
63
65
  help [command] display help for command
64
66
  ```
65
67
 
68
+ ## 📸 Screenshots
69
+
70
+ ![Screenshot 1](images/image.png)
71
+
66
72
  ## 🏗 Dependencies
67
73
 
68
74
  This project is built on the shoulders of giants:
69
-
70
- - [**Ink**](https://github.com/vadimdemedes/ink) - React for interactive command-line apps.
71
75
  - [**Commander.js**](https://github.com/tj/commander) - The complete solution for node.js command-line interfaces.
76
+ - [**Ink**](https://github.com/vadimdemedes/ink) - React for interactive command-line apps.
77
+ - [**React Query**](https://tanstack.com/query/latest) - Data synchronization library for managing server state.
72
78
  - [**date-fns**](https://date-fns.org/) - Modern JavaScript date utility library.
73
- - [**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 responses.
75
- - [**node-localstorage**](https://github.com/lmaccherone/node-localstorage) - LocalStorage implementation for Node.js.
79
+ - [**domino**](https://github.com/fent/domino) - Server-side DOM implementation for parsing API response
76
80
 
77
81
  ## 🤝 Contributing
78
82
 
@@ -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
  });
@@ -1,4 +1,4 @@
1
- import App from "#components/TimesApp";
1
+ import TimesCommandWrapper from "#components/TimesCommandWrapper";
2
2
  import { Command } from "commander";
3
3
  import { render } from "ink";
4
4
  import React from "react";
@@ -7,5 +7,8 @@ export const timesCommand = new Command("times")
7
7
  .argument("[city]", "City name")
8
8
  .option("-1, --once", "Run once and exit", false)
9
9
  .action((city, options) => {
10
- render(React.createElement(App, { cityNameArg: city, once: options.once }));
10
+ render(React.createElement(TimesCommandWrapper, {
11
+ initialCity: city,
12
+ once: options.once,
13
+ }));
11
14
  });
@@ -4,7 +4,7 @@ import citiesData from "../data/cities.json" with { type: "json" };
4
4
  const CitiesApp = () => {
5
5
  const cities = citiesData;
6
6
  // Sort cities alphabetically
7
- const sortedCities = [...cities].sort((a, b) => a.name.localeCompare(b.name));
8
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "cyan", children: ["\uD83C\uDF0D Available Cities in Morocco (", cities.length, ")"] }) }), _jsx(Box, { flexDirection: "row", flexWrap: "wrap", children: sortedCities.map((city) => (_jsxs(Box, { width: 25, marginBottom: 0, children: [_jsx(Text, { color: "gray", children: "- " }), _jsx(Text, { children: city.name })] }, city.id))) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "gray", children: ["Tip: Use these names with the 'times' command, e.g., 'salat times ", sortedCities[0]?.name, "'"] }) })] }));
7
+ const sortedCities = [...cities].sort((a, b) => a.frenchName.localeCompare(b.frenchName));
8
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "cyan", children: ["\uD83C\uDF0D Available Cities in Morocco (", cities.length, ")"] }) }), _jsx(Box, { flexDirection: "row", flexWrap: "wrap", children: sortedCities.map((city) => (_jsxs(Box, { width: 25, marginBottom: 0, children: [_jsx(Text, { color: "gray", children: "- " }), _jsx(Text, { children: city.frenchName })] }, city.id))) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "gray", children: ["Tip: Use these names with the 'times' command, e.g., 'salat times", " ", sortedCities[0]?.frenchName, "'"] }) })] }));
9
9
  };
10
10
  export default CitiesApp;
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import SelectInput from "ink-select-input";
4
+ import TextInput from "ink-text-input";
5
+ import { useMemo, useState } from "react";
6
+ import citiesData from "../data/cities.json" with { type: "json" };
7
+ const cities = citiesData;
8
+ const CitySelect = ({ onSelect }) => {
9
+ const [query, setQuery] = useState("");
10
+ const filteredCities = useMemo(() => {
11
+ if (!query)
12
+ return cities;
13
+ const lowerQuery = query.toLowerCase();
14
+ return cities.filter((c) => c.frenchName.toLowerCase().includes(lowerQuery));
15
+ }, [query]);
16
+ const items = filteredCities.map((c) => ({
17
+ label: `${c.frenchName}`,
18
+ value: c.frenchName,
19
+ }));
20
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, width: 60, borderStyle: "round", borderColor: "green", children: [_jsx(Box, { flexDirection: "column", alignItems: "center", marginBottom: 1, children: _jsx(Box, { borderStyle: "single", borderColor: "green", paddingX: 2, children: _jsx(Text, { bold: true, color: "green", children: "SELECT YOUR CITY" }) }) }), _jsxs(Box, { marginBottom: 1, paddingX: 2, children: [_jsx(Text, { color: "white", bold: true, children: "Filter: " }), _jsx(TextInput, { value: query, onChange: setQuery, placeholder: "Type city name..." })] }), _jsx(Box, { borderStyle: "single", borderColor: "gray", padding: 1, children: items.length > 0 ? (_jsx(SelectInput, { items: items.sort((a, b) => a.label.localeCompare(b.label)), onSelect: (item) => onSelect(item.value), limit: 8, indicatorComponent: ({ isSelected }) => (_jsx(Text, { color: "yellow", children: isSelected ? "> " : " " })), itemComponent: ({ label, isSelected }) => (_jsx(Text, { color: isSelected ? "yellow" : "white", children: label })) })) : (_jsx(Box, { justifyContent: "center", width: "100%", children: _jsx(Text, { color: "red", children: "No cities found." }) })) }), _jsx(Box, { marginTop: 1, justifyContent: "center", children: _jsx(Text, { dimColor: true, color: "gray", children: "Use arrow keys to select \u2022 Enter to confirm" }) })] }));
21
+ };
22
+ export default CitySelect;
@@ -0,0 +1,40 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from "ink-testing-library";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import CitySelect from "./CitySelect.js";
5
+ // Mock data
6
+ vi.mock("../data/cities.json", () => ({
7
+ default: [
8
+ { frenchName: "Casablanca", arabicName: "الدار البيضاء" },
9
+ { frenchName: "Rabat", arabicName: "الرباط" },
10
+ { frenchName: "Tangier", arabicName: "طنجة" },
11
+ ],
12
+ }));
13
+ describe("CitySelect", () => {
14
+ it("should render input and list", () => {
15
+ const { lastFrame } = render(_jsx(CitySelect, { onSelect: () => { } }));
16
+ expect(lastFrame()).toContain("SELECT YOUR CITY");
17
+ expect(lastFrame()).toContain("Casablanca");
18
+ expect(lastFrame()).toContain("Rabat");
19
+ });
20
+ it("should show no results message when no match", async () => {
21
+ const { lastFrame, stdin } = render(_jsx(CitySelect, { onSelect: () => { } }));
22
+ stdin.write("NonExistentCity");
23
+ await new Promise((resolve) => setTimeout(resolve, 100));
24
+ expect(lastFrame()).toContain("No cities found");
25
+ });
26
+ it("should filter cities by french name", async () => {
27
+ const { lastFrame, stdin } = render(_jsx(CitySelect, { onSelect: () => { } }));
28
+ stdin.write("Rab");
29
+ await new Promise((resolve) => setTimeout(resolve, 100));
30
+ expect(lastFrame()).toContain("Rabat");
31
+ expect(lastFrame()).not.toContain("Casablanca");
32
+ });
33
+ it("should select a city", () => {
34
+ const onSelect = vi.fn();
35
+ const { stdin, lastFrame } = render(_jsx(CitySelect, { onSelect: onSelect }));
36
+ // Select first item (Casablanca)
37
+ stdin.write("\r");
38
+ expect(onSelect).toHaveBeenCalledWith("Casablanca");
39
+ });
40
+ });
@@ -1,32 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { getHijriDate } from "#services/utils/hijri";
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 [hijriDate, setHijriDate] = useState(null);
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
  }
27
9
  if (error) {
28
10
  return _jsxs(Text, { color: "red", children: ["Error: ", error] });
29
11
  }
30
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "blue", children: "\uD83D\uDD4C Hijri Date" }) }), _jsx(Box, { padding: 1, children: _jsx(Text, { children: `\u061C${hijriDate}` }) })] }));
12
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "blue", children: "\uD83D\uDD4C Hijri Date" }) }), _jsx(Box, { padding: 1, children: _jsx(Text, { children: `${hijriDate}` }) })] }));
31
13
  };
32
14
  export default HijriApp;
@@ -1,30 +1,38 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { getHijriDate } from "#services/utils/hijri";
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("#services/utils/hijri", () => ({
7
- getHijriDate: vi.fn(),
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(getHijriDate).mockImplementation(() => new Promise(() => { }));
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", async () => {
16
- vi.mocked(getHijriDate).mockRejectedValue(new Error("Network error"));
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", async () => {
28
+ it("should render hijri date", () => {
23
29
  const mockDate = "السبت 18 شعبان 1447هـ | الموافق 07 فبراير 2026م";
24
- vi.mocked(getHijriDate).mockResolvedValue({ date: mockDate });
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,25 +1,75 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useSalat } from "#hooks/useSalat";
3
- import { getNextPrayer, tConv24 } from "#services/utils/time";
4
- import { format } from "date-fns";
5
- import { Box, Text } from "ink";
6
- const App = ({ cityNameArg, once }) => {
7
- const { prayerTimes, error, loading, resolvedCityName, currentTime } = useSalat({
2
+ import { useHijriDate } from "#hooks/useHijriDate";
3
+ import { usePrayerTimes } from "#hooks/usePrayerTimes";
4
+ import { getImsakTime, getNextPrayer, tConv24 } from "#services/utils/time";
5
+ import { differenceInSeconds, format, parse, subDays } from "date-fns";
6
+ import { Box, Text, useApp, useInput } from "ink";
7
+ import { useEffect, useState } from "react";
8
+ const ProgressBar = ({ progress, width, }) => {
9
+ const filledWidth = Math.max(0, Math.min(width, Math.round(progress * width)));
10
+ const emptyWidth = width - filledWidth;
11
+ return (_jsxs(Box, { children: [_jsx(Text, { color: "green", children: "█".repeat(filledWidth) }), _jsx(Text, { color: "gray", children: "░".repeat(emptyWidth) })] }));
12
+ };
13
+ const App = ({ cityNameArg, once, onReset }) => {
14
+ const { exit } = useApp();
15
+ const { prayerTimes, error, loading, resolvedCityName } = usePrayerTimes({
8
16
  cityNameArg,
9
- once,
10
17
  });
18
+ const { hijriDate } = useHijriDate();
19
+ const [currentTime, setCurrentTime] = useState(new Date());
20
+ useInput((input) => {
21
+ const key = input.toLowerCase();
22
+ if (key === "c" && onReset) {
23
+ onReset();
24
+ }
25
+ });
26
+ useEffect(() => {
27
+ const timer = setInterval(() => {
28
+ setCurrentTime(new Date());
29
+ }, 1000);
30
+ return () => clearInterval(timer);
31
+ }, []);
32
+ useEffect(() => {
33
+ if (once && !loading && (prayerTimes || error)) {
34
+ const timer = setTimeout(() => {
35
+ exit();
36
+ }, 100);
37
+ return () => clearTimeout(timer);
38
+ }
39
+ }, [once, loading, prayerTimes, error, exit]);
11
40
  if (loading) {
12
- return _jsx(Text, { children: "Loading prayer times..." });
41
+ return (_jsx(Box, { padding: 1, children: _jsxs(Text, { color: "yellow", children: ["Loading prayer times for ", resolvedCityName, "..."] }) }));
13
42
  }
14
43
  if (error) {
15
- return _jsxs(Text, { color: "red", children: ["Error: ", error] });
44
+ return (_jsx(Box, { padding: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["Error: ", error] }) }));
16
45
  }
17
46
  if (!prayerTimes) {
18
- return _jsx(Text, { color: "red", children: "Could not fetch prayer times." });
47
+ return (_jsx(Box, { padding: 1, children: _jsx(Text, { color: "red", bold: true, children: "Could not fetch prayer times." }) }));
48
+ }
49
+ const nextPrayerData = getNextPrayer(prayerTimes, currentTime);
50
+ // Calculate progress
51
+ const prayerOrder = ["Fajr", "Chorouq", "Dhuhr", "Asr", "Maghrib", "Ishae"];
52
+ const nextIndex = prayerOrder.indexOf(nextPrayerData.prayer);
53
+ const prevIndex = (nextIndex - 1 + prayerOrder.length) % prayerOrder.length;
54
+ const prevPrayerName = prayerOrder[prevIndex];
55
+ let prevDate = parse(prayerTimes[prevPrayerName], "HH:mm", currentTime);
56
+ let nextDate = parse(nextPrayerData.time, "HH:mm", currentTime);
57
+ if (nextPrayerData.prayer === "Fajr" && currentTime.getHours() >= 12) {
58
+ nextDate = parse(nextPrayerData.time, "HH:mm", new Date(currentTime.getTime() + 24 * 60 * 60 * 1000));
59
+ }
60
+ else if (nextPrayerData.prayer === "Fajr" && currentTime.getHours() < 12) {
61
+ prevDate = subDays(parse(prayerTimes.Ishae, "HH:mm", currentTime), 1);
19
62
  }
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: [_jsx(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
- const isNext = prayer === getNextPrayer(prayerTimes, currentTime).prayer;
22
- 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
- }) })] }));
63
+ const totalSeconds = differenceInSeconds(nextDate, prevDate);
64
+ const elapsedSeconds = differenceInSeconds(currentTime, prevDate);
65
+ const progress = Math.max(0, Math.min(1, elapsedSeconds / totalSeconds));
66
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, borderStyle: "round", borderColor: "green", width: 60, children: [_jsxs(Box, { flexDirection: "column", alignItems: "center", gap: 1, children: [_jsx(Box, { borderStyle: "single", borderColor: "green", children: _jsxs(Text, { bold: true, color: "green", children: ["\uD83C\uDDF2\uD83C\uDDE6 ", resolvedCityName, ", Morocco \uD83C\uDDF2\uD83C\uDDE6"] }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "white", bold: true, children: ["\uD83D\uDCC5 ", format(currentTime, "EEEE, MMMM do yyyy")] }) }), hijriDate && (_jsxs(Text, { color: "gray", dimColor: true, children: ["\uD83D\uDD4C ", hijriDate] }))] }), _jsx(Box, { flexDirection: "column", marginY: 1, children: Object.entries({
67
+ "Imsak *": getImsakTime(prayerTimes.Fajr),
68
+ ...prayerTimes,
69
+ }).map(([prayer, time]) => {
70
+ const isNext = prayer === nextPrayerData.prayer;
71
+ const isImsak = prayer === "Imsak *";
72
+ return (_jsxs(Box, { justifyContent: "space-between", paddingX: 2, backgroundColor: isNext ? "green" : undefined, children: [_jsx(Box, { children: _jsxs(Text, { color: isNext ? "white" : (isImsak ? "gray" : "white"), bold: isNext, children: [isNext ? "> " : " ", prayer.padEnd(12)] }) }), _jsx(Box, { children: _jsx(Text, { color: isNext ? "white" : (isImsak ? "gray" : "white"), bold: isNext, children: tConv24(time) }) })] }, prayer));
73
+ }) }), _jsx(Box, { paddingX: 2, marginTop: 1, children: _jsx(Text, { dimColor: true, color: "gray", italic: true, children: "* Imsak is 10 min before Fajr for safety" }) }), _jsxs(Box, { marginTop: 1, paddingX: 2, paddingY: 1, flexDirection: "column", alignItems: "center", borderStyle: "single", borderColor: "yellow", children: [_jsxs(Text, { children: ["Next: ", _jsx(Text, { color: "green", bold: true, children: nextPrayerData.prayer }), " in ", _jsx(Text, { color: "yellow", bold: true, children: nextPrayerData.timeLeft })] }), _jsxs(Box, { flexDirection: "column", alignItems: "center", marginY: 1, children: [_jsx(ProgressBar, { progress: progress, width: 50 }), _jsxs(Box, { justifyContent: "space-between", width: 50, marginTop: 0, children: [_jsx(Text, { color: "gray", dimColor: true, children: "0%" }), _jsxs(Text, { color: "green", bold: true, children: [Math.round(progress * 100), "%"] }), _jsx(Text, { color: "gray", dimColor: true, children: "100%" })] })] })] }), !once && (_jsx(Box, { marginTop: 1, justifyContent: "center", children: _jsx(Text, { dimColor: true, color: "gray", children: "[C] Change City \u2022 [Ctrl+C] Exit" }) }))] }));
24
74
  };
25
75
  export default App;
@@ -1,65 +1,80 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useSalat } from '#hooks/useSalat';
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/useSalat', () => ({
7
- useSalat: vi.fn()
2
+ import { useHijriDate } from "#hooks/useHijriDate";
3
+ import { usePrayerTimes } from "#hooks/usePrayerTimes";
4
+ import { render } from "ink-testing-library";
5
+ import { beforeEach, describe, expect, it, vi } from "vitest";
6
+ import TimesApp from "./TimesApp.js";
7
+ vi.mock("#hooks/usePrayerTimes", () => ({
8
+ usePrayerTimes: vi.fn(),
8
9
  }));
9
- describe('TimesApp', () => {
10
- it('should render loading state', () => {
11
- (vi.mocked(useSalat)).mockReturnValue({
10
+ vi.mock("#hooks/useHijriDate", () => ({
11
+ useHijriDate: vi.fn(),
12
+ }));
13
+ vi.mock("ink", async () => {
14
+ const actual = await vi.importActual("ink");
15
+ return {
16
+ ...actual,
17
+ useApp: vi.fn(() => ({
18
+ exit: vi.fn(),
19
+ })),
20
+ };
21
+ });
22
+ describe("TimesApp", () => {
23
+ beforeEach(() => {
24
+ vi.mocked(useHijriDate).mockReturnValue({
25
+ hijriDate: "18 Sha'ban 1447",
26
+ error: null,
27
+ loading: false,
28
+ });
29
+ });
30
+ it("should render loading state", () => {
31
+ vi.mocked(usePrayerTimes).mockReturnValue({
12
32
  prayerTimes: null,
13
33
  error: null,
14
34
  loading: true,
15
- resolvedCityName: '',
16
- currentTime: new Date()
35
+ resolvedCityName: "Marrakech",
17
36
  });
18
37
  const { lastFrame } = render(_jsx(TimesApp, {}));
19
- expect(lastFrame()).toContain('Loading prayer times...');
38
+ expect(lastFrame()).toContain("Loading prayer times for Marrakech");
20
39
  });
21
- it('should render error state', () => {
22
- (vi.mocked(useSalat)).mockReturnValue({
40
+ it("should render error state", () => {
41
+ vi.mocked(usePrayerTimes).mockReturnValue({
23
42
  prayerTimes: null,
24
- error: 'Failed to fetch',
43
+ error: "Failed to fetch",
25
44
  loading: false,
26
- resolvedCityName: '',
27
- currentTime: new Date()
45
+ resolvedCityName: "",
28
46
  });
29
47
  const { lastFrame } = render(_jsx(TimesApp, {}));
30
- expect(lastFrame()).toContain('Error: Failed to fetch');
48
+ expect(lastFrame()).toContain("Error: Failed to fetch");
31
49
  });
32
- it('should render null prayerTimes state', () => {
33
- (vi.mocked(useSalat)).mockReturnValue({
50
+ it("should render null prayerTimes state", () => {
51
+ vi.mocked(usePrayerTimes).mockReturnValue({
34
52
  prayerTimes: null,
35
53
  error: null,
36
54
  loading: false,
37
- resolvedCityName: '',
38
- currentTime: new Date()
55
+ resolvedCityName: "",
39
56
  });
40
57
  const { lastFrame } = render(_jsx(TimesApp, {}));
41
- expect(lastFrame()).toContain('Could not fetch prayer times.');
58
+ expect(lastFrame()).toContain("Could not fetch prayer times.");
42
59
  });
43
- it('should render prayer times', () => {
60
+ it("should render prayer times", () => {
44
61
  const mockPrayerTimes = {
45
62
  Fajr: "05:00",
46
63
  Chorouq: "06:30",
47
64
  Dhuhr: "12:30",
48
65
  Asr: "15:45",
49
66
  Maghrib: "18:20",
50
- Ishae: "19:50"
67
+ Ishae: "19:50",
51
68
  };
52
- (vi.mocked(useSalat)).mockReturnValue({
69
+ vi.mocked(usePrayerTimes).mockReturnValue({
53
70
  prayerTimes: mockPrayerTimes,
54
71
  error: null,
55
72
  loading: false,
56
- resolvedCityName: 'Marrakech',
57
- currentTime: new Date('2026-02-07T10:00:00')
73
+ resolvedCityName: "Marrakech",
58
74
  });
59
75
  const { lastFrame } = render(_jsx(TimesApp, {}));
60
- expect(lastFrame()).toContain('Marrakech, Morocco');
61
- expect(lastFrame()).toContain('Fajr');
62
- expect(lastFrame()).toContain('05:00 AM');
63
- expect(lastFrame()).toContain('Next Prayer: Dhuhr');
76
+ expect(lastFrame()).toContain("Marrakech");
77
+ expect(lastFrame()).toContain("Fajr");
78
+ expect(lastFrame()).toContain("05:00 AM");
64
79
  });
65
80
  });
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import CitySelect from "#components/CitySelect";
3
+ import { QueryProvider } from "#components/QueryProvider";
4
+ import App from "#components/TimesApp";
5
+ import { useState } from "react";
6
+ const TimesCommandWrapper = ({ initialCity, once, }) => {
7
+ const [city, setCity] = useState(initialCity);
8
+ return (_jsx(QueryProvider, { children: city ? (_jsx(App, { cityNameArg: city, once: once, onReset: () => setCity(undefined) })) : (_jsx(CitySelect, { onSelect: setCity })) }));
9
+ };
10
+ export default TimesCommandWrapper;
@@ -0,0 +1,35 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { render } from "ink-testing-library";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import TimesCommandWrapper from "./TimesCommandWrapper.js";
5
+ // Mock dependencies
6
+ vi.mock("#components/CitySelect", async () => {
7
+ const { Text } = await import("ink");
8
+ return {
9
+ default: ({ onSelect }) => {
10
+ // Trigger onSelect immediately? No, we need to test rendering.
11
+ return _jsx(Text, { children: "CitySelect Mock" });
12
+ },
13
+ };
14
+ });
15
+ vi.mock("#components/TimesApp", async () => {
16
+ const { Text } = await import("ink");
17
+ return {
18
+ default: ({ cityNameArg }) => (_jsxs(Text, { children: ["TimesApp Mock: ", cityNameArg] })),
19
+ };
20
+ });
21
+ vi.mock("#components/QueryProvider", () => ({
22
+ QueryProvider: ({ children }) => (_jsx(_Fragment, { children: children })),
23
+ }));
24
+ describe("TimesCommandWrapper", () => {
25
+ it("should render TimesApp if initialCity is provided", () => {
26
+ const { lastFrame } = render(_jsx(TimesCommandWrapper, { initialCity: "Casablanca" }));
27
+ expect(lastFrame()).toContain("TimesApp Mock: Casablanca");
28
+ expect(lastFrame()).not.toContain("CitySelect Mock");
29
+ });
30
+ it("should render CitySelect if no initialCity provided", () => {
31
+ const { lastFrame } = render(_jsx(TimesCommandWrapper, {}));
32
+ expect(lastFrame()).toContain("CitySelect Mock");
33
+ expect(lastFrame()).not.toContain("TimesApp Mock");
34
+ });
35
+ });